diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit index 8998194..b1c222f 100755 --- a/.vite-hooks/pre-commit +++ b/.vite-hooks/pre-commit @@ -15,6 +15,8 @@ git add \ skills/bailian-finetune/reference \ skills/bailian-managed-agent/SKILL.md \ skills/bailian-managed-agent/reference \ + skills/bailian-sandbox/SKILL.md \ + skills/bailian-sandbox/reference \ skills/bailian-web-search/SKILL.md vp staged diff --git a/AGENTS.md b/AGENTS.md index f24cb02..46e7c3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ packages/core/src/auth/ # apiKey / console credential 解析与落盘 packages/core/src/client/ # HTTP client / endpoints / console gateway ``` -Skill / 命令手册随 `skills/bailian-*/` 经 `bl skill init` 安装(装齐 registry 中全部 `bailian-*`,含共享协议 `bailian-protocol`)。业务 skill(`bailian-cli` / `bailian-gen` / `bailian-finetune` / `bailian-managed-agent` / `bailian-web-search`)执行前读 `skills/bailian-protocol/`;不要依赖 frontmatter `companions`(安装器不强制)。`tools/generate-reference.ts` 从 **`packages/cli/src/commands.ts`** 按一级命令归属表分流写入各 `skills//reference/`(纳入 git);`tools/sync-skill-metadata.ts` 从 `packages/cli/package.json` 同步各 `skills/*/SKILL.md` 的 `metadata.version`。两者由根脚本 `pnpm run sync:skill-assets` 和 `.vite-hooks/pre-commit` 执行。hub `bailian-cli` 的路由表不复述领域命令明细;SKILL 文案 / 安装约定 / hand-off 见 [docs/agents/skill-change.md](docs/agents/skill-change.md)。 +Skill / 命令手册随 `skills/bailian-*/` 经 `bl skill init` 安装(装齐 registry 中全部 `bailian-*`,含共享协议 `bailian-protocol`)。业务 skill(`bailian-cli` / `bailian-gen` / `bailian-finetune` / `bailian-managed-agent` / `bailian-sandbox` / `bailian-web-search`)执行前读 `skills/bailian-protocol/`;不要依赖 frontmatter `companions`(安装器不强制)。`tools/generate-reference.ts` 从 **`packages/cli/src/commands.ts`** 按一级命令归属表分流写入各 `skills//reference/`(纳入 git);`tools/sync-skill-metadata.ts` 从 `packages/cli/package.json` 同步各 `skills/*/SKILL.md` 的 `metadata.version`。两者由根脚本 `pnpm run sync:skill-assets` 和 `.vite-hooks/pre-commit` 执行。hub `bailian-cli` 的路由表不复述领域命令明细;SKILL 文案 / 安装约定 / hand-off 见 [docs/agents/skill-change.md](docs/agents/skill-change.md)。 约定: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8812321..58f533c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and [中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md) +## [Unreleased] + +### Added + +- **Sandbox control-plane commands** — added `bl sandbox` instance and template lifecycle commands for all 13 Bailian Sandbox REST APIs, including default template-build polling and explicit `--async` submission mode. + +### Security + +- Sandbox calls use the Bailian API Key as a Bearer token without an E2B SDK or E2B API key. Returned connection credentials and dry-run environment values are redacted by default; use `--show-credentials` only when the raw connection tokens are required. + ## [1.20.0] - 2026-09-03 > Managed Agents now combines YAML-first infrastructure management with direct Bailian AgentStudio resource and runtime operations. diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index dcfbba2..8e3dd68 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -6,6 +6,16 @@ [English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md) +## [未发布] + +### 新增 + +- **Sandbox 管控面命令** —— 新增 `bl sandbox` 实例与模版生命周期命令,覆盖百炼 Sandbox 的 13 个 REST API;模版构建默认等待完成,也可通过 `--async` 仅提交并立即返回。 + +### 安全 + +- Sandbox 调用使用百炼 API Key 作为 Bearer Token,不依赖 E2B SDK,也不发送 E2B API Key。连接凭据和 dry-run 中的环境变量默认脱敏;仅在确实需要原始连接 Token 时使用 `--show-credentials`。 + ## [1.20.0] - 2026-09-03 > Managed Agent 现在同时提供 YAML-first 基础设施管理与百炼 AgentStudio 资源、运行时 API 操作。 diff --git a/docs/agents/skill-change.md b/docs/agents/skill-change.md index 7a6acdf..ca4a31f 100644 --- a/docs/agents/skill-change.md +++ b/docs/agents/skill-change.md @@ -56,6 +56,7 @@ bailian-gen bailian-finetune bailian-managed-agent bailian-web-search ### C. 归属与生成 - [ ] 新一级命令组归属领域时:改 `tools/generate-reference.ts` 的 `GROUP_OWNER_SKILL`,并更新**拥有方** skill 的路由表;hub 最多加一行 hand-off +- [ ] 新增领域目录时:补齐 `SKILL.md`(含 `metadata.version`),同步 `packages/cli/package.json` 的 reference 格式化路径、`.vite-hooks/pre-commit` 的生成物暂存清单、`tools/release/check.mjs` 的生成物校验清单;同步 hub 与共享协议的领域路由 - [ ] 跑 `pnpm run sync:skill-assets`(或 commit 走 pre-commit),提交生成的 `reference/` 与 version 同步结果 - [ ] 高风险命令生成的 reference 必须包含 `Risk` / `Risk message` 和简短 Agent safety 提示;带 `--yes` 的示例必须标注只能在确认后执行,不要手改生成物 - [ ] 默认模型若写在领域路由表(如 `bailian-gen`):与命令 default / [model-add-remove.md](model-add-remove.md) 一并核对 diff --git a/packages/cli/package.json b/packages/cli/package.json index 5c8ee4d..71cca92 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,7 +41,7 @@ "registry": "https://registry.npmjs.org/" }, "scripts": { - "generate:reference": "tsx ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference skills/bailian-gen/reference skills/bailian-finetune/reference skills/bailian-managed-agent/reference'", + "generate:reference": "tsx ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference skills/bailian-gen/reference skills/bailian-finetune/reference skills/bailian-managed-agent/reference skills/bailian-sandbox/reference'", "sync:skill-version": "tsx ../../tools/sync-skill-metadata.ts", "build": "vp pack", "dev": "tsx src/main.ts", diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 309b5d6..8a774a0 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -194,6 +194,19 @@ import { managedAgentFileSearch, managedAgentFileDownload, managedAgentFileDelete, + sandboxCreate, + sandboxList, + sandboxGet, + sandboxConnect, + sandboxPause, + sandboxResume, + sandboxDelete, + sandboxTemplateCreate, + sandboxTemplateList, + sandboxTemplateGet, + sandboxTemplateUpdate, + sandboxTemplateBuildStatus, + sandboxTemplateDelete, } from "bailian-cli-commands"; // Full bailian-cli product: every command, exposed under the `bl` binary. @@ -399,6 +412,19 @@ export const commands: Record = { "managed-agent file search": managedAgentFileSearch, "managed-agent file download": managedAgentFileDownload, "managed-agent file delete": managedAgentFileDelete, + "sandbox create": sandboxCreate, + "sandbox list": sandboxList, + "sandbox get": sandboxGet, + "sandbox connect": sandboxConnect, + "sandbox pause": sandboxPause, + "sandbox resume": sandboxResume, + "sandbox delete": sandboxDelete, + "sandbox template create": sandboxTemplateCreate, + "sandbox template list": sandboxTemplateList, + "sandbox template get": sandboxTemplateGet, + "sandbox template update": sandboxTemplateUpdate, + "sandbox template build-status": sandboxTemplateBuildStatus, + "sandbox template delete": sandboxTemplateDelete, }; /** diff --git a/packages/cli/tests/skill-sandbox-reference.test.ts b/packages/cli/tests/skill-sandbox-reference.test.ts new file mode 100644 index 0000000..41d181a --- /dev/null +++ b/packages/cli/tests/skill-sandbox-reference.test.ts @@ -0,0 +1,53 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { validateSkillDir } from "bailian-cli-core"; +import { expect, test } from "vite-plus/test"; +import { parse } from "yaml"; +import { commands } from "../src/commands.ts"; + +const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), "../../.."); +const sandboxSkillDirectory = join(repositoryRoot, "skills/bailian-sandbox"); + +test("Sandbox is a valid standalone skill aligned with the CLI version", () => { + const metadata = validateSkillDir(sandboxSkillDirectory, "bailian-sandbox"); + expect(metadata.name).toBe("bailian-sandbox"); + + const skillMarkdown = readFileSync(join(sandboxSkillDirectory, "SKILL.md"), "utf8"); + const frontmatter = parse(skillMarkdown.split(/^---\s*$/m)[1]) as { + metadata: { version: string }; + }; + const cliPackage = JSON.parse( + readFileSync(join(repositoryRoot, "packages/cli/package.json"), "utf8"), + ) as { version: string }; + expect(frontmatter.metadata.version).toBe(cliPackage.version); + + for (const link of skillMarkdown.matchAll(/\]\(([^)]+)\)/g)) { + expect(existsSync(join(sandboxSkillDirectory, link[1]))).toBe(true); + } +}); + +test("Sandbox reference contains every registered Sandbox command only in its owning skill", () => { + const expectedCommands = Object.keys(commands) + .filter((commandPath) => commandPath.startsWith("sandbox ")) + .map((commandPath) => `bl ${commandPath}`) + .sort(); + expect(expectedCommands.length).toBeGreaterThan(0); + + const reference = readFileSync(join(sandboxSkillDirectory, "reference/sandbox.md"), "utf8"); + const documentedCommands = [...reference.matchAll(/^### `([^`]+)`/gm)] + .map((match) => match[1]) + .sort(); + expect(documentedCommands).toEqual(expectedCommands); + + const index = readFileSync(join(sandboxSkillDirectory, "reference/index.md"), "utf8"); + for (const commandPath of expectedCommands) { + expect(index).toContain(`\`${commandPath}\``); + } + expect(existsSync(join(repositoryRoot, "skills/bailian-cli/reference/sandbox.md"))).toBe(false); + const hubIndex = readFileSync( + join(repositoryRoot, "skills/bailian-cli/reference/index.md"), + "utf8", + ); + expect(hubIndex).not.toContain("`bl sandbox "); +}); diff --git a/packages/commands/src/commands/sandbox/instance.ts b/packages/commands/src/commands/sandbox/instance.ts new file mode 100644 index 0000000..98e6142 --- /dev/null +++ b/packages/commands/src/commands/sandbox/instance.ts @@ -0,0 +1,423 @@ +import { + BailianError, + detectOutputFormat, + defineCommand, + ExitCode, + sandboxEndpoint, + SANDBOX_PATHS, + sandboxInstanceActionPath, + sandboxInstancePath, + type FlagsDef, + type ParsedFlags, +} from "bailian-cli-core"; +import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; +import { + BODY_FLAG, + displayValue, + INSTANCE_TIMEOUT_FLAG, + mergeObjectField, + parseKeyValueEntries, + readRequestBody, + redactConnectionCredentials, + redactRequestSecrets, + resolveWorkspaceId, + SANDBOX_NOTES, + setDefined, + SHOW_CREDENTIALS_FLAG, + validateIntegerRange, + WORKSPACE_FLAG, + type JsonObject, +} from "./shared.ts"; + +interface SandboxInfo extends JsonObject { + sandboxID?: string; + templateID?: string; + state?: string; + cpuCount?: number; + memoryMB?: number; + startedAt?: string; + endAt?: string; +} + +const SANDBOX_ID_FLAG = { + sandboxId: { + type: "string", + valueHint: "", + required: true, + description: { "en-US": "Sandbox instance ID", "zh-CN": "Sandbox 实例 ID" }, + }, +} satisfies FlagsDef; + +const CREATE_FLAGS = { + ...WORKSPACE_FLAG, + ...BODY_FLAG, + templateId: { + type: "string", + valueHint: "", + description: { + "en-US": "Ready template ID; may alternatively be supplied as templateID in --body", + "zh-CN": "已就绪的模版 ID;也可通过 --body 中的 templateID 提供", + }, + }, + ...INSTANCE_TIMEOUT_FLAG, + allowInternetAccess: { + type: "boolean", + valueHint: "", + description: { "en-US": "Allow public internet access", "zh-CN": "允许访问公网" }, + }, + metadata: { + type: "array", + valueHint: "", + description: { + "en-US": "Instance metadata entry; repeat for multiple values", + "zh-CN": "实例 Metadata 键值;可重复传入", + }, + }, + env: { + type: "array", + valueHint: "", + description: { + "en-US": "Instance environment variable; repeat for multiple values", + "zh-CN": "实例环境变量;可重复传入", + }, + }, + autoPause: { + type: "boolean", + valueHint: "", + description: { + "en-US": "Pause the instance when its timeout expires", + "zh-CN": "实例超时后自动暂停", + }, + }, + autoResume: { + type: "boolean", + valueHint: "", + description: { + "en-US": "Automatically resume a paused instance when connecting", + "zh-CN": "连接已暂停实例时自动恢复", + }, + }, + allowOut: { + type: "array", + valueHint: "
", + description: { + "en-US": "Outbound allow-list entry; repeat for multiple values", + "zh-CN": "出站白名单条目;可重复传入", + }, + }, + denyOut: { + type: "array", + valueHint: "
", + description: { + "en-US": "Outbound deny-list entry; repeat for multiple values", + "zh-CN": "出站黑名单条目;可重复传入", + }, + }, + maskRequestHost: { + type: "string", + valueHint: "", + description: { + "en-US": "Override the outbound request Host header", + "zh-CN": "覆盖出站请求的 Host Header", + }, + }, + ...SHOW_CREDENTIALS_FLAG, +} satisfies FlagsDef; + +type CreateFlags = ParsedFlags; + +const LIST_FLAGS = { + ...WORKSPACE_FLAG, + templateId: { + type: "string", + valueHint: "", + description: { "en-US": "Filter by template ID", "zh-CN": "按模版 ID 筛选" }, + }, + sandboxId: { + type: "string", + valueHint: "", + description: { "en-US": "Filter by sandbox ID", "zh-CN": "按 Sandbox ID 筛选" }, + }, + state: { + type: "string", + valueHint: "", + description: { + "en-US": "Filter by state, for example running or paused", + "zh-CN": "按状态筛选,例如 running 或 paused", + }, + }, + limit: { + type: "number", + valueHint: "", + description: { "en-US": "Maximum results (1-50)", "zh-CN": "最大返回数量(1–50)" }, + }, +} satisfies FlagsDef; + +const GET_FLAGS = { + ...WORKSPACE_FLAG, + ...SANDBOX_ID_FLAG, + ...SHOW_CREDENTIALS_FLAG, +} satisfies FlagsDef; +const ACTION_FLAGS = { ...WORKSPACE_FLAG, ...SANDBOX_ID_FLAG } satisfies FlagsDef; +const CONNECTION_ACTION_FLAGS = { + ...WORKSPACE_FLAG, + ...SANDBOX_ID_FLAG, + ...BODY_FLAG, + ...INSTANCE_TIMEOUT_FLAG, + ...SHOW_CREDENTIALS_FLAG, +} satisfies FlagsDef; + +export async function buildSandboxCreateBody(flags: CreateFlags): Promise { + const body = await readRequestBody(flags.body); + setDefined(body, "templateID", flags.templateId); + setDefined(body, "timeout", flags.instanceTimeout); + setDefined(body, "allow_internet_access", flags.allowInternetAccess); + setDefined(body, "autoPause", flags.autoPause); + setDefined(body, "autoResume", flags.autoResume); + mergeObjectField(body, "metadata", parseKeyValueEntries(flags.metadata, "--metadata")); + mergeObjectField(body, "envVars", parseKeyValueEntries(flags.env, "--env")); + + const networkOverrides: JsonObject = {}; + setDefined(networkOverrides, "allowOut", flags.allowOut); + setDefined(networkOverrides, "denyOut", flags.denyOut); + setDefined(networkOverrides, "maskRequestHost", flags.maskRequestHost); + mergeObjectField(body, "network", networkOverrides); + + if (typeof body.templateID !== "string" || body.templateID.trim().length === 0) { + throw new BailianError( + "Sandbox create requires --template-id or templateID in --body.", + ExitCode.USAGE, + ); + } + validateIntegerRange(body.timeout, "Sandbox timeout", 300, 604800); + return body; +} + +async function buildConnectionBody(flags: { + body?: string; + instanceTimeout?: number; +}): Promise { + const body = await readRequestBody(flags.body); + setDefined(body, "timeout", flags.instanceTimeout); + validateIntegerRange(body.timeout, "Sandbox timeout", 300, 604800); + return body; +} + +function emitSandboxObject( + sandbox: SandboxInfo, + options: { format: "text" | "json"; quiet: boolean; showCredentials: boolean }, +): void { + const output = options.showCredentials ? sandbox : redactConnectionCredentials(sandbox); + if (options.quiet) { + emitBare(displayValue(sandbox.sandboxID)); + return; + } + emitResult(output, options.format); +} + +export const sandboxCreate = defineCommand({ + description: { "en-US": "Create a Sandbox instance", "zh-CN": "创建 Sandbox 实例" }, + auth: "apiKey", + usageArgs: "(--template-id | --body ) [flags]", + flags: CREATE_FLAGS, + exampleArgs: [ + "--template-id tpl-xxx --instance-timeout 3600", + "--body @sandbox.json --dry-run --output json", + "--template-id tpl-xxx --show-credentials --output json", + ], + notes: SANDBOX_NOTES, + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + const workspaceId = resolveWorkspaceId(ctx); + const endpoint = sandboxEndpoint(workspaceId, SANDBOX_PATHS.sandboxes); + const body = await buildSandboxCreateBody(ctx.flags); + if (ctx.settings.dryRun) { + emitResult({ method: "POST", endpoint, request: redactRequestSecrets(body) }, format); + return; + } + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + emitSandboxObject(response, { + format, + quiet: ctx.settings.quiet, + showCredentials: ctx.flags.showCredentials, + }); + }, +}); + +export const sandboxList = defineCommand({ + description: { "en-US": "List Sandbox instances", "zh-CN": "列出 Sandbox 实例" }, + auth: "apiKey", + usageArgs: "[--template-id ] [--sandbox-id ] [--state ] [--limit ]", + flags: LIST_FLAGS, + exampleArgs: ["", "--state running --limit 20", "--template-id tpl-xxx --output json"], + notes: SANDBOX_NOTES, + validate(flags) { + if ( + flags.limit !== undefined && + (!Number.isInteger(flags.limit) || flags.limit < 1 || flags.limit > 50) + ) { + return "--limit must be an integer between 1 and 50."; + } + return undefined; + }, + async run(ctx) { + const workspaceId = resolveWorkspaceId(ctx); + const url = new URL(sandboxEndpoint(workspaceId, SANDBOX_PATHS.sandboxList)); + if (ctx.flags.templateId) url.searchParams.set("templateID", ctx.flags.templateId); + if (ctx.flags.sandboxId) url.searchParams.set("sandboxID", ctx.flags.sandboxId); + if (ctx.flags.state) url.searchParams.set("state", ctx.flags.state); + if (ctx.flags.limit !== undefined) url.searchParams.set("limit", String(ctx.flags.limit)); + const response = await ctx.client.requestJson({ + path: url.toString(), + method: "GET", + }); + if (ctx.settings.quiet) { + for (const sandbox of response) emitBare(displayValue(sandbox.sandboxID)); + return; + } + const format = detectOutputFormat(ctx.settings.output); + if (format === "json") { + emitResult(response, format); + return; + } + if (response.length === 0) { + emitBare("No Sandbox instances found."); + return; + } + const rows = response.map((sandbox) => [ + displayValue(sandbox.sandboxID), + displayValue(sandbox.templateID), + displayValue(sandbox.state), + displayValue(sandbox.cpuCount), + displayValue(sandbox.memoryMB), + displayValue(sandbox.startedAt), + displayValue(sandbox.endAt), + ]); + for (const line of formatTable( + ["ID", "TEMPLATE", "STATE", "CPU", "MEMORY_MB", "STARTED_AT", "END_AT"], + rows, + )) { + emitBare(line); + } + }, +}); + +export const sandboxGet = defineCommand({ + description: { "en-US": "Get Sandbox instance details", "zh-CN": "获取 Sandbox 实例详情" }, + auth: "apiKey", + usageArgs: "--sandbox-id [--show-credentials]", + flags: GET_FLAGS, + exampleArgs: ["--sandbox-id sbx-xxx", "--sandbox-id sbx-xxx --show-credentials --output json"], + notes: SANDBOX_NOTES, + async run(ctx) { + const workspaceId = resolveWorkspaceId(ctx); + const endpoint = sandboxEndpoint(workspaceId, sandboxInstancePath(ctx.flags.sandboxId)); + const response = await ctx.client.requestJson({ path: endpoint, method: "GET" }); + emitSandboxObject(response, { + format: detectOutputFormat(ctx.settings.output), + quiet: ctx.settings.quiet, + showCredentials: ctx.flags.showCredentials, + }); + }, +}); + +function connectionCommand(action: "connect" | "resume") { + const actionText = action === "connect" ? "Connect to" : "Resume"; + const actionTextZh = action === "connect" ? "连接" : "恢复"; + return defineCommand({ + description: { + "en-US": `${actionText} a Sandbox instance and return connection information`, + "zh-CN": `${actionTextZh} Sandbox 实例并返回连接信息`, + }, + auth: "apiKey", + usageArgs: "--sandbox-id [--instance-timeout ] [--show-credentials]", + flags: CONNECTION_ACTION_FLAGS, + exampleArgs: [ + `--sandbox-id sbx-xxx --instance-timeout 3600`, + `--sandbox-id sbx-xxx --show-credentials --output json`, + ], + notes: SANDBOX_NOTES, + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + const workspaceId = resolveWorkspaceId(ctx); + const endpoint = sandboxEndpoint( + workspaceId, + sandboxInstanceActionPath(ctx.flags.sandboxId, action), + ); + const body = await buildConnectionBody(ctx.flags); + if (ctx.settings.dryRun) { + emitResult({ method: "POST", endpoint, request: redactRequestSecrets(body) }, format); + return; + } + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body: Object.keys(body).length > 0 ? body : undefined, + }); + emitSandboxObject(response, { + format, + quiet: ctx.settings.quiet, + showCredentials: ctx.flags.showCredentials, + }); + }, + }); +} + +export const sandboxConnect = connectionCommand("connect"); +export const sandboxResume = connectionCommand("resume"); + +export const sandboxPause = defineCommand({ + description: { "en-US": "Pause a Sandbox instance", "zh-CN": "暂停 Sandbox 实例" }, + auth: "apiKey", + usageArgs: "--sandbox-id ", + flags: ACTION_FLAGS, + exampleArgs: ["--sandbox-id sbx-xxx", "--sandbox-id sbx-xxx --dry-run --output json"], + notes: SANDBOX_NOTES, + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + const workspaceId = resolveWorkspaceId(ctx); + const endpoint = sandboxEndpoint( + workspaceId, + sandboxInstanceActionPath(ctx.flags.sandboxId, "pause"), + ); + if (ctx.settings.dryRun) { + emitResult({ method: "POST", endpoint, request: null }, format); + return; + } + await ctx.client.request({ path: endpoint, method: "POST" }); + if (ctx.settings.quiet) emitBare(ctx.flags.sandboxId); + else emitResult({ sandboxID: ctx.flags.sandboxId, paused: true }, format); + }, +}); + +export const sandboxDelete = defineCommand({ + description: { "en-US": "Release a Sandbox instance", "zh-CN": "释放 Sandbox 实例" }, + auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": "This permanently releases the Sandbox instance and cannot be undone.", + "zh-CN": "该操作会永久释放 Sandbox 实例,且无法撤销。", + }, + }, + usageArgs: "--sandbox-id ", + flags: ACTION_FLAGS, + exampleArgs: ["--sandbox-id sbx-xxx --dry-run", "--sandbox-id sbx-xxx --yes"], + notes: SANDBOX_NOTES, + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + const workspaceId = resolveWorkspaceId(ctx); + const endpoint = sandboxEndpoint(workspaceId, sandboxInstancePath(ctx.flags.sandboxId)); + if (ctx.settings.dryRun) { + emitResult({ method: "DELETE", endpoint, request: null }, format); + return; + } + await ctx.client.request({ path: endpoint, method: "DELETE" }); + if (ctx.settings.quiet) emitBare(ctx.flags.sandboxId); + else emitResult({ sandboxID: ctx.flags.sandboxId, deleted: true }, format); + }, +}); diff --git a/packages/commands/src/commands/sandbox/shared.ts b/packages/commands/src/commands/sandbox/shared.ts new file mode 100644 index 0000000..106c270 --- /dev/null +++ b/packages/commands/src/commands/sandbox/shared.ts @@ -0,0 +1,231 @@ +import { readFile } from "node:fs/promises"; +import { BailianError, ExitCode, type FlagsDef, type LocalizedText } from "bailian-cli-core"; + +export const WORKSPACE_FLAG = { + workspaceId: { + type: "string", + valueHint: "", + description: { + "en-US": "Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID)", + "zh-CN": "Sandbox Endpoint 使用的 Workspace ID(也可设置 BAILIAN_WORKSPACE_ID)", + }, + }, +} satisfies FlagsDef; + +export const BODY_FLAG = { + body: { + type: "string", + valueHint: "", + description: { + "en-US": "JSON request body, inline or loaded from an @file path; explicit flags override it", + "zh-CN": "JSON 请求体,可内联或从 @文件路径读取;显式 Flag 优先", + }, + }, +} satisfies FlagsDef; + +export const INSTANCE_TIMEOUT_FLAG = { + instanceTimeout: { + type: "number", + valueHint: "", + description: { + "en-US": "Sandbox lifetime after this operation (300-604800 seconds)", + "zh-CN": "本次操作后的 Sandbox 存活时间(300–604800 秒)", + }, + }, +} satisfies FlagsDef; + +export const SHOW_CREDENTIALS_FLAG = { + showCredentials: { + type: "switch", + description: { + "en-US": "Print envd and traffic access tokens instead of redacting them", + "zh-CN": "输出 envd 与流量访问 Token 原值,不进行脱敏", + }, + }, +} satisfies FlagsDef; + +export const POLL_INTERVAL_FLAG = { + pollInterval: { + type: "number", + valueHint: "", + description: { + "en-US": "Template build polling interval (default: 5 seconds)", + "zh-CN": "模版构建轮询间隔(默认:5 秒)", + }, + }, +} satisfies FlagsDef; + +export const SANDBOX_NOTES: LocalizedText[] = [ + { + "en-US": "Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent.", + "zh-CN": "鉴权:使用百炼 API Key 作为 Authorization Bearer Token;不会发送 E2B Key。", + }, + { + "en-US": + "The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id.", + "zh-CN": "Workspace 依次从 --workspace-id、BAILIAN_WORKSPACE_ID、配置项 workspace_id 解析。", + }, + { + "en-US": + "Sandbox is currently available in cn-beijing only and requires prior SLR authorization.", + "zh-CN": "Sandbox 当前仅支持 cn-beijing,首次使用前需完成 SLR 授权。", + }, + { + "en-US": + "Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field.", + "zh-CN": + "全局 --timeout 限制 HTTP 请求和模版构建总轮询时间;--instance-timeout 映射到 Sandbox API 的实例存活时间字段。", + }, +]; + +export type JsonObject = Record; + +export function resolveWorkspaceId(ctx: { + flags: { workspaceId?: string }; + settings: { workspaceId?: string }; + identity: { binName: string }; +}): string { + const workspaceId = ctx.flags.workspaceId || ctx.settings.workspaceId; + if (!workspaceId) { + throw new BailianError( + "Workspace ID is required.", + ExitCode.USAGE, + `Pass --workspace-id, set BAILIAN_WORKSPACE_ID, or configure: ${ctx.identity.binName} config set workspace_id `, + ); + } + return workspaceId; +} + +function asJsonObject(value: unknown, source: string): JsonObject { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new BailianError(`${source} must contain a JSON object.`, ExitCode.USAGE); + } + return value as JsonObject; +} + +export async function readRequestBody(argument?: string): Promise { + if (!argument) return {}; + const source = argument.startsWith("@") ? argument.slice(1) : undefined; + if (source !== undefined && source.length === 0) { + throw new BailianError("--body @path must include a file path.", ExitCode.USAGE); + } + const raw = source === undefined ? argument : await readFile(source, "utf8"); + try { + return asJsonObject(JSON.parse(raw) as unknown, source ? `Body file ${source}` : "--body"); + } catch (error) { + if (error instanceof BailianError) throw error; + if (error instanceof SyntaxError) { + throw new BailianError( + source ? `Body file ${source} contains invalid JSON.` : "--body contains invalid JSON.", + ExitCode.USAGE, + ); + } + throw error; + } +} + +export function setDefined(body: JsonObject, key: string, value: unknown): void { + if (value !== undefined) body[key] = value; +} + +export function parseKeyValueEntries(entries: string[] | undefined, flag: string): JsonObject { + const values: JsonObject = {}; + for (const entry of entries ?? []) { + const separator = entry.indexOf("="); + if (separator <= 0) { + throw new BailianError(`${flag} values must use KEY=VALUE format.`, ExitCode.USAGE); + } + const key = entry.slice(0, separator).trim(); + if (!key) { + throw new BailianError(`${flag} values must include a non-empty key.`, ExitCode.USAGE); + } + values[key] = entry.slice(separator + 1); + } + return values; +} + +export function mergeObjectField(body: JsonObject, key: string, overrides: JsonObject): void { + if (Object.keys(overrides).length === 0) return; + const current = body[key]; + const base = + current !== null && typeof current === "object" && !Array.isArray(current) + ? (current as JsonObject) + : {}; + body[key] = { ...base, ...overrides }; +} + +export function validateIntegerRange( + value: unknown, + label: string, + minimum: number, + maximum: number, +): void { + if ( + value !== undefined && + (typeof value !== "number" || !Number.isInteger(value) || value < minimum || value > maximum) + ) { + throw new BailianError( + `${label} must be an integer between ${minimum} and ${maximum}.`, + ExitCode.USAGE, + ); + } +} + +export function validatePositiveInteger(value: unknown, label: string): void { + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { + throw new BailianError(`${label} must be a positive integer.`, ExitCode.USAGE); + } +} + +const CONNECTION_CREDENTIAL_KEYS = new Set(["envdAccessToken", "trafficAccessToken"]); +const SENSITIVE_KEY = /(?:token|secret|password|credential|api[_-]?key)/i; + +function redactValue(value: unknown, redactEnvironmentMaps: boolean): unknown { + if (Array.isArray(value)) return value.map((entry) => redactValue(entry, redactEnvironmentMaps)); + if (value === null || typeof value !== "object") return value; + const output: JsonObject = {}; + for (const [key, entry] of Object.entries(value as JsonObject)) { + if ( + CONNECTION_CREDENTIAL_KEYS.has(key) || + SENSITIVE_KEY.test(key) || + (redactEnvironmentMaps && (key === "envVars" || key === "envConfig")) + ) { + if (entry !== undefined && entry !== null) { + output[key] = + (key === "envVars" || key === "envConfig") && + typeof entry === "object" && + !Array.isArray(entry) + ? Object.fromEntries( + Object.keys(entry as JsonObject).map((environmentKey) => [ + environmentKey, + "[REDACTED]", + ]), + ) + : "[REDACTED]"; + } else { + output[key] = entry; + } + continue; + } + output[key] = redactValue(entry, redactEnvironmentMaps); + } + return output; +} + +export function redactConnectionCredentials(value: T): T { + return redactValue(value, false) as T; +} + +export function redactRequestSecrets(value: T): T { + return redactValue(value, true) as T; +} + +export function displayValue(value: unknown): string { + if (value === undefined || value === null || value === "") return "-"; + if (Array.isArray(value)) return value.join(","); + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return `${value}`; + } + return JSON.stringify(value) ?? "-"; +} diff --git a/packages/commands/src/commands/sandbox/template.ts b/packages/commands/src/commands/sandbox/template.ts new file mode 100644 index 0000000..c23b146 --- /dev/null +++ b/packages/commands/src/commands/sandbox/template.ts @@ -0,0 +1,586 @@ +import { + BailianError, + detectOutputFormat, + defineCommand, + ExitCode, + sandboxEndpoint, + SANDBOX_PATHS, + sandboxTemplateBuildStatusPath, + sandboxTemplatePath, + type Client, + type FlagsDef, + type ParsedFlags, + type Settings, +} from "bailian-cli-core"; +import { createSpinner, emitBare, emitResult, formatTable } from "bailian-cli-runtime"; +import { + BODY_FLAG, + displayValue, + mergeObjectField, + parseKeyValueEntries, + POLL_INTERVAL_FLAG, + readRequestBody, + redactRequestSecrets, + resolveWorkspaceId, + SANDBOX_NOTES, + setDefined, + validateIntegerRange, + validatePositiveInteger, + WORKSPACE_FLAG, + type JsonObject, +} from "./shared.ts"; + +const TEMPLATE_ASYNC_FLAG = { + async: { + type: "switch", + description: { + "en-US": "Return the submitted templateID/buildID immediately without polling", + "zh-CN": "提交后立即返回 templateID/buildID,不轮询构建状态", + }, + }, +} satisfies FlagsDef; + +interface TemplateInfo extends JsonObject { + templateID?: string; + templateName?: string; + cpuCount?: number; + memoryMB?: number; + spawnCount?: number; + buildCount?: number; + updatedAt?: string; + buildID?: string; + buildStatus?: string; +} + +interface TemplateBuildStatus extends JsonObject { + templateID?: string; + buildID?: string; + status?: "building" | "ready" | "error"; + reason?: { code?: number | string; message?: string }; +} + +const TEMPLATE_ID_FLAG = { + templateId: { + type: "string", + valueHint: "", + required: true, + description: { "en-US": "Sandbox template ID", "zh-CN": "Sandbox 模版 ID" }, + }, +} satisfies FlagsDef; + +const TEMPLATE_MUTATION_FIELDS = { + name: { + type: "string", + valueHint: "", + description: { "en-US": "Template name", "zh-CN": "模版名称" }, + }, + cpuCount: { + type: "number", + valueHint: "", + description: { "en-US": "vCPU count", "zh-CN": "vCPU 核数" }, + }, + memoryMb: { + type: "number", + valueHint: "", + description: { "en-US": "Memory in MB", "zh-CN": "内存大小(MB)" }, + }, + fromImage: { + type: "string", + valueHint: "", + description: { "en-US": "Base image identifier", "zh-CN": "基础镜像标识" }, + }, + imageName: { + type: "string", + valueHint: "", + description: { "en-US": "Base image display name", "zh-CN": "基础镜像展示名称" }, + }, + env: { + type: "array", + valueHint: "", + description: { + "en-US": "Template environment variable; repeat for multiple values", + "zh-CN": "模版环境变量;可重复传入", + }, + }, + allowOut: { + type: "array", + valueHint: "
", + description: { + "en-US": "Outbound allow-list entry; repeat for multiple values", + "zh-CN": "出站白名单条目;可重复传入", + }, + }, + denyOut: { + type: "array", + valueHint: "
", + description: { + "en-US": "Outbound deny-list entry; repeat for multiple values", + "zh-CN": "出站黑名单条目;可重复传入", + }, + }, + autoPauseTime: { + type: "number", + valueHint: "", + description: { + "en-US": "Automatically pause after 300-604800 seconds", + "zh-CN": "在 300–604800 秒后自动暂停", + }, + }, + maxRunningTime: { + type: "number", + valueHint: "", + description: { + "en-US": "Maximum running lifetime in seconds (300-604800)", + "zh-CN": "最大运行时间(秒,300–604800)", + }, + }, + description: { + type: "string", + valueHint: "", + description: { "en-US": "Template description", "zh-CN": "模版描述" }, + }, +} satisfies FlagsDef; + +const CREATE_FLAGS = { + ...WORKSPACE_FLAG, + ...BODY_FLAG, + ...TEMPLATE_MUTATION_FIELDS, + tag: { + type: "array", + valueHint: "", + description: { "en-US": "E2B tag; repeat for multiple values", "zh-CN": "E2B Tag;可重复传入" }, + }, + alias: { + type: "string", + valueHint: "", + description: { "en-US": "E2B template alias", "zh-CN": "E2B 模版别名" }, + }, + ...TEMPLATE_ASYNC_FLAG, + ...POLL_INTERVAL_FLAG, +} satisfies FlagsDef; + +const UPDATE_FLAGS = { + ...WORKSPACE_FLAG, + ...TEMPLATE_ID_FLAG, + ...BODY_FLAG, + ...TEMPLATE_MUTATION_FIELDS, + ...TEMPLATE_ASYNC_FLAG, + ...POLL_INTERVAL_FLAG, +} satisfies FlagsDef; + +type CreateFlags = ParsedFlags; +type UpdateFlags = ParsedFlags; + +const LIST_FLAGS = { + ...WORKSPACE_FLAG, + limit: { + type: "number", + valueHint: "", + description: { "en-US": "Maximum results (1-100)", "zh-CN": "最大返回数量(1–100)" }, + }, + cursor: { + type: "string", + valueHint: "", + description: { "en-US": "Server-side pagination cursor", "zh-CN": "服务端分页 Cursor" }, + }, +} satisfies FlagsDef; + +const GET_FLAGS = { ...WORKSPACE_FLAG, ...TEMPLATE_ID_FLAG } satisfies FlagsDef; +const BUILD_STATUS_FLAGS = { + ...WORKSPACE_FLAG, + ...TEMPLATE_ID_FLAG, + buildId: { + type: "string", + valueHint: "", + required: true, + description: { "en-US": "Template build ID", "zh-CN": "模版构建 ID" }, + }, +} satisfies FlagsDef; + +function applyTemplateMutationFlags(body: JsonObject, flags: CreateFlags | UpdateFlags): void { + setDefined(body, "name", flags.name); + setDefined(body, "cpuCount", flags.cpuCount); + setDefined(body, "memoryMB", flags.memoryMb); + setDefined(body, "fromImage", flags.fromImage); + setDefined(body, "imageName", flags.imageName); + setDefined(body, "autoPauseTime", flags.autoPauseTime); + setDefined(body, "maxRunningTimeout", flags.maxRunningTime); + setDefined(body, "description", flags.description); + mergeObjectField(body, "envConfig", parseKeyValueEntries(flags.env, "--env")); + + const networkOverrides: JsonObject = {}; + setDefined(networkOverrides, "allowOut", flags.allowOut); + setDefined(networkOverrides, "denyOut", flags.denyOut); + mergeObjectField(body, "networkConfig", networkOverrides); +} + +function validateTemplateMutationBody(body: JsonObject, create: boolean): void { + if (create && (typeof body.name !== "string" || body.name.trim().length === 0)) { + throw new BailianError("Template create requires --name or name in --body.", ExitCode.USAGE); + } + + const hasCpu = body.cpuCount !== undefined; + const hasMemory = body.memoryMB !== undefined; + if (create && (!hasCpu || !hasMemory)) { + throw new BailianError( + "Template create requires both --cpu-count and --memory-mb, or their --body fields.", + ExitCode.USAGE, + ); + } + if (hasCpu !== hasMemory) { + throw new BailianError("cpuCount and memoryMB must be provided together.", ExitCode.USAGE); + } + if (hasCpu) validatePositiveInteger(body.cpuCount, "cpuCount"); + if (hasMemory) validatePositiveInteger(body.memoryMB, "memoryMB"); + validateIntegerRange(body.autoPauseTime, "autoPauseTime", 300, 604800); + validateIntegerRange(body.maxRunningTimeout, "maxRunningTimeout", 300, 604800); + if (!create && Object.keys(body).length === 0) { + throw new BailianError("Template update requires at least one field.", ExitCode.USAGE); + } +} + +export async function buildTemplateCreateBody(flags: CreateFlags): Promise { + const body = await readRequestBody(flags.body); + applyTemplateMutationFlags(body, flags); + setDefined(body, "tags", flags.tag); + setDefined(body, "alias", flags.alias); + validateTemplateMutationBody(body, true); + return body; +} + +export async function buildTemplateUpdateBody(flags: UpdateFlags): Promise { + const body = await readRequestBody(flags.body); + applyTemplateMutationFlags(body, flags); + validateTemplateMutationBody(body, false); + return body; +} + +export async function waitForTemplateBuild( + client: Client, + settings: Settings, + endpoint: string, + intervalSec: number, +): Promise { + const deadline = Date.now() + settings.timeout * 1000; + const spinner = createSpinner("Waiting for template build..."); + if (!settings.quiet) spinner.start(); + try { + while (true) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + const status = await client.requestJson({ + path: endpoint, + method: "GET", + timeout: remainingMs / 1000, + }); + if (!settings.quiet) spinner.update(`Build status: ${status.status ?? "unknown"}`); + if (status.status === "ready") { + spinner.stop("Template build ready."); + return status; + } + if (status.status === "error") { + spinner.stop("Template build failed."); + const reason = + status.reason?.message ?? + (status.reason?.code !== undefined ? `${status.reason.code}` : "Template build failed."); + throw new BailianError(reason, ExitCode.GENERAL); + } + const sleepMs = Math.min(intervalSec * 1000, deadline - Date.now()); + if (sleepMs > 0) { + await new Promise((resolve) => setTimeout(resolve, sleepMs)); + } + } + } finally { + spinner.stop(); + } + throw new BailianError( + "Template build polling timed out.", + ExitCode.TIMEOUT, + "Increase --timeout or query the build status command with the returned IDs.", + ); +} + +function validatePollInterval(flags: { pollInterval?: number }): string | undefined { + if ( + flags.pollInterval !== undefined && + (!Number.isInteger(flags.pollInterval) || flags.pollInterval <= 0) + ) { + return "--poll-interval must be a positive integer."; + } + return undefined; +} + +async function emitTemplateMutationResult(options: { + response: TemplateInfo; + workspaceId: string; + client: Client; + settings: Settings; + async: boolean; + pollInterval?: number; +}): Promise { + const format = detectOutputFormat(options.settings.output); + if (!options.response.templateID || !options.response.buildID) { + throw new BailianError( + "Template mutation response is missing templateID or buildID.", + ExitCode.GENERAL, + ); + } + if (options.async) { + if (options.settings.quiet) { + emitBare(`${options.response.templateID}\t${options.response.buildID}`); + } else { + emitResult(options.response, format); + } + return; + } + const buildEndpoint = sandboxEndpoint( + options.workspaceId, + sandboxTemplateBuildStatusPath(options.response.templateID, options.response.buildID), + ); + const build = await waitForTemplateBuild( + options.client, + options.settings, + buildEndpoint, + options.pollInterval ?? 5, + ); + if (options.settings.quiet) emitBare(displayValue(options.response.templateID)); + else emitResult({ template: options.response, build }, format); +} + +export const sandboxTemplateCreate = defineCommand({ + description: { "en-US": "Create a Sandbox template", "zh-CN": "创建 Sandbox 模版" }, + auth: "apiKey", + usageArgs: "(--name --cpu-count --memory-mb | --body ) [flags]", + flags: CREATE_FLAGS, + exampleArgs: [ + "--name python --cpu-count 1 --memory-mb 2048", + "--body @template.json --async --output json", + "--name browser --cpu-count 4 --memory-mb 8192 --dry-run --output json", + ], + notes: [ + ...SANDBOX_NOTES, + { + "en-US": "File mounts and other complete nested structures can be supplied through --body.", + "zh-CN": "文件挂载等完整嵌套结构可通过 --body 提供。", + }, + { + "en-US": + "By default the command waits for build status ready; --async returns the submitted build immediately.", + "zh-CN": "默认等待构建状态变为 ready;--async 会立即返回已提交的构建信息。", + }, + ], + validate: validatePollInterval, + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + const workspaceId = resolveWorkspaceId(ctx); + const endpoint = sandboxEndpoint(workspaceId, SANDBOX_PATHS.templateCreate); + const body = await buildTemplateCreateBody(ctx.flags); + if (ctx.settings.dryRun) { + emitResult({ method: "POST", endpoint, request: redactRequestSecrets(body) }, format); + return; + } + const response = await ctx.client.requestJson({ + path: endpoint, + method: "POST", + body, + }); + await emitTemplateMutationResult({ + response, + workspaceId, + client: ctx.client, + settings: ctx.settings, + async: ctx.flags.async, + pollInterval: ctx.flags.pollInterval, + }); + }, +}); + +export const sandboxTemplateList = defineCommand({ + description: { "en-US": "List Sandbox templates", "zh-CN": "列出 Sandbox 模版" }, + auth: "apiKey", + usageArgs: "[--limit ] [--cursor ]", + flags: LIST_FLAGS, + exampleArgs: ["", "--limit 100 --output json"], + notes: [ + ...SANDBOX_NOTES, + { + "en-US": + "The API response does not expose a next cursor, so automatic --all pagination is unavailable.", + "zh-CN": "API 响应未提供下一页 Cursor,因此不支持自动 --all 翻页。", + }, + ], + validate(flags) { + if ( + flags.limit !== undefined && + (!Number.isInteger(flags.limit) || flags.limit < 1 || flags.limit > 100) + ) { + return "--limit must be an integer between 1 and 100."; + } + return undefined; + }, + async run(ctx) { + const workspaceId = resolveWorkspaceId(ctx); + const url = new URL(sandboxEndpoint(workspaceId, SANDBOX_PATHS.templateList)); + if (ctx.flags.limit !== undefined) url.searchParams.set("limit", String(ctx.flags.limit)); + if (ctx.flags.cursor) url.searchParams.set("cursor", ctx.flags.cursor); + const response = await ctx.client.requestJson({ + path: url.toString(), + method: "GET", + }); + if (ctx.settings.quiet) { + for (const template of response) emitBare(displayValue(template.templateID)); + return; + } + const format = detectOutputFormat(ctx.settings.output); + if (format === "json") { + emitResult(response, format); + return; + } + if (response.length === 0) { + emitBare("No Sandbox templates found."); + return; + } + const rows = response.map((template) => [ + displayValue(template.templateID), + displayValue(template.templateName), + displayValue(template.cpuCount), + displayValue(template.memoryMB), + displayValue(template.spawnCount), + displayValue(template.buildCount), + displayValue(template.updatedAt), + ]); + for (const line of formatTable( + ["ID", "NAME", "CPU", "MEMORY_MB", "INSTANCES", "BUILDS", "UPDATED_AT"], + rows, + )) { + emitBare(line); + } + }, +}); + +export const sandboxTemplateGet = defineCommand({ + description: { "en-US": "Get Sandbox template details", "zh-CN": "获取 Sandbox 模版详情" }, + auth: "apiKey", + usageArgs: "--template-id ", + flags: GET_FLAGS, + exampleArgs: ["--template-id tpl-xxx", "--template-id tpl-xxx --output json"], + notes: SANDBOX_NOTES, + async run(ctx) { + const workspaceId = resolveWorkspaceId(ctx); + const endpoint = sandboxEndpoint(workspaceId, sandboxTemplatePath(ctx.flags.templateId)); + const response = await ctx.client.requestJson({ path: endpoint, method: "GET" }); + if (ctx.settings.quiet) emitBare(displayValue(response.templateID)); + else emitResult(response, detectOutputFormat(ctx.settings.output)); + }, +}); + +export const sandboxTemplateUpdate = defineCommand({ + description: { "en-US": "Update a Sandbox template", "zh-CN": "更新 Sandbox 模版" }, + auth: "apiKey", + usageArgs: "--template-id (--body | [fields])", + flags: UPDATE_FLAGS, + exampleArgs: [ + "--template-id tpl-xxx --cpu-count 4 --memory-mb 8192", + "--template-id tpl-xxx --body @template-update.json --async --output json", + "--template-id tpl-xxx --description updated --dry-run --output json", + ], + notes: [ + ...SANDBOX_NOTES, + { + "en-US": "Supplying envConfig or --env replaces the template's complete environment map.", + "zh-CN": "传入 envConfig 或 --env 会整体替换模版的环境变量 Map。", + }, + { + "en-US": + "By default the command waits for build status ready; --async returns the submitted build immediately.", + "zh-CN": "默认等待构建状态变为 ready;--async 会立即返回已提交的构建信息。", + }, + ], + validate: validatePollInterval, + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + const workspaceId = resolveWorkspaceId(ctx); + const endpoint = sandboxEndpoint(workspaceId, sandboxTemplatePath(ctx.flags.templateId)); + const body = await buildTemplateUpdateBody(ctx.flags); + if (ctx.settings.dryRun) { + emitResult({ method: "PUT", endpoint, request: redactRequestSecrets(body) }, format); + return; + } + const response = await ctx.client.requestJson({ + path: endpoint, + method: "PUT", + body, + }); + await emitTemplateMutationResult({ + response, + workspaceId, + client: ctx.client, + settings: ctx.settings, + async: ctx.flags.async, + pollInterval: ctx.flags.pollInterval, + }); + }, +}); + +export const sandboxTemplateBuildStatus = defineCommand({ + description: { + "en-US": "Get Sandbox template build status", + "zh-CN": "获取 Sandbox 模版构建状态", + }, + auth: "apiKey", + usageArgs: "--template-id --build-id ", + flags: BUILD_STATUS_FLAGS, + exampleArgs: [ + "--template-id tpl-xxx --build-id build-xxx", + "--template-id tpl-xxx --build-id build-xxx --output json", + ], + notes: SANDBOX_NOTES, + async run(ctx) { + const workspaceId = resolveWorkspaceId(ctx); + const endpoint = sandboxEndpoint( + workspaceId, + sandboxTemplateBuildStatusPath(ctx.flags.templateId, ctx.flags.buildId), + ); + const response = await ctx.client.requestJson({ + path: endpoint, + method: "GET", + }); + if (ctx.settings.quiet) emitBare(displayValue(response.status)); + else emitResult(response, detectOutputFormat(ctx.settings.output)); + }, +}); + +export const sandboxTemplateDelete = defineCommand({ + description: { "en-US": "Delete a Sandbox template", "zh-CN": "删除 Sandbox 模版" }, + auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": "This permanently deletes the Sandbox template and cannot be undone.", + "zh-CN": "该操作会永久删除 Sandbox 模版,且无法撤销。", + }, + }, + usageArgs: "--template-id ", + flags: GET_FLAGS, + exampleArgs: ["--template-id tpl-xxx --dry-run", "--template-id tpl-xxx --yes"], + notes: [ + ...SANDBOX_NOTES, + { + "en-US": + "The server rejects deletion while running or paused instances still use the template.", + "zh-CN": "仍有运行中或已暂停实例使用该模版时,服务端会拒绝删除。", + }, + ], + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + const workspaceId = resolveWorkspaceId(ctx); + const endpoint = sandboxEndpoint(workspaceId, sandboxTemplatePath(ctx.flags.templateId)); + if (ctx.settings.dryRun) { + emitResult({ method: "DELETE", endpoint, request: null }, format); + return; + } + await ctx.client.request({ path: endpoint, method: "DELETE" }); + if (ctx.settings.quiet) emitBare(ctx.flags.templateId); + else emitResult({ templateID: ctx.flags.templateId, deleted: true }, format); + }, +}); diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index 0eb21c4..b4b2032 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -209,3 +209,20 @@ export { default as skillUpdate } from "./commands/skill/update.ts"; export { default as skillRemove } from "./commands/skill/remove.ts"; export { default as skillList } from "./commands/skill/list.ts"; export { default as skillInit } from "./commands/skill/init.ts"; +export { + sandboxConnect, + sandboxCreate, + sandboxDelete, + sandboxGet, + sandboxList, + sandboxPause, + sandboxResume, +} from "./commands/sandbox/instance.ts"; +export { + sandboxTemplateBuildStatus, + sandboxTemplateCreate, + sandboxTemplateDelete, + sandboxTemplateGet, + sandboxTemplateList, + sandboxTemplateUpdate, +} from "./commands/sandbox/template.ts"; diff --git a/packages/commands/tests/e2e/sandbox.e2e.test.ts b/packages/commands/tests/e2e/sandbox.e2e.test.ts new file mode 100644 index 0000000..5929c78 --- /dev/null +++ b/packages/commands/tests/e2e/sandbox.e2e.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from "vite-plus/test"; +import { parseStdoutJson, runCommandE2e, runCommandHelp } from "./helpers.ts"; +import { SANDBOX_ROUTES } from "./topic-routes.ts"; + +const AUTH_ARGS = ["--api-key", "sk-sandbox-e2e", "--workspace-id", "ws-e2e"]; + +describe("e2e: Sandbox command discovery", () => { + test.each([ + ["sandbox", "create"], + ["sandbox", "list"], + ["sandbox", "get"], + ["sandbox", "connect"], + ["sandbox", "pause"], + ["sandbox", "resume"], + ["sandbox", "delete"], + ["sandbox", "template", "create"], + ["sandbox", "template", "list"], + ["sandbox", "template", "get"], + ["sandbox", "template", "update"], + ["sandbox", "template", "build-status"], + ["sandbox", "template", "delete"], + ])("%s help resolves", async (...commandPath) => { + const { stderr, exitCode } = await runCommandHelp(SANDBOX_ROUTES, [...commandPath, "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain(`Usage: bl ${commandPath.join(" ")}`); + expect(stderr).toContain("Authentication: API Key"); + }); + + test("instance timeout is separate from the global request/poll timeout", async () => { + const { stderr, exitCode } = await runCommandHelp(SANDBOX_ROUTES, [ + "sandbox", + "create", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--instance-timeout /); + expect(stderr).toMatch(/--timeout /); + expect(stderr).toMatch(/no E2B key is sent/i); + }); + + test("template create exposes max-running-time and default polling controls", async () => { + const { stderr, exitCode } = await runCommandHelp(SANDBOX_ROUTES, [ + "sandbox", + "template", + "create", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--max-running-time /); + expect(stderr).toMatch(/--async/); + expect(stderr).toMatch(/--poll-interval /); + expect(stderr).toMatch(/waits for build status ready/i); + }); +}); + +describe("e2e: Sandbox offline validation and dry-run", () => { + test("create requires a template in flags or body", async () => { + const { stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [ + "sandbox", + "create", + ...AUTH_ARGS, + "--dry-run", + "--output", + "json", + ]); + expect(exitCode).toBe(2); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 2, message: expect.stringMatching(/template-id|templateID/) }, + }); + }); + + test("create body flags win and request environment values are redacted", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [ + "sandbox", + "create", + "--body", + '{"templateID":"template-body","timeout":600,"envVars":{"BODY":"secret"}}', + "--template-id", + "template-flag", + "--instance-timeout", + "900", + "--env", + "FLAG=secret", + ...AUTH_ARGS, + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + expect(parseStdoutJson(stdout)).toMatchObject({ + method: "POST", + endpoint: "https://ws-e2e.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes", + request: { + templateID: "template-flag", + timeout: 900, + envVars: { BODY: "[REDACTED]", FLAG: "[REDACTED]" }, + }, + }); + }); + + test.each([ + { + name: "connect instance", + args: ["sandbox", "connect", "--sandbox-id", "sandbox-test", "--instance-timeout", "900"], + method: "POST", + suffix: "/sandboxes/sandbox-test/connect", + }, + { + name: "pause instance", + args: ["sandbox", "pause", "--sandbox-id", "sandbox-test"], + method: "POST", + suffix: "/sandboxes/sandbox-test/pause", + }, + { + name: "resume instance", + args: ["sandbox", "resume", "--sandbox-id", "sandbox-test"], + method: "POST", + suffix: "/sandboxes/sandbox-test/resume", + }, + { + name: "delete instance", + args: ["sandbox", "delete", "--sandbox-id", "sandbox-test"], + method: "DELETE", + suffix: "/sandboxes/sandbox-test", + }, + { + name: "create template", + args: [ + "sandbox", + "template", + "create", + "--name", + "python", + "--cpu-count", + "1", + "--memory-mb", + "2048", + ], + method: "POST", + suffix: "/v3/templates", + }, + { + name: "update template", + args: [ + "sandbox", + "template", + "update", + "--template-id", + "template-test", + "--description", + "updated", + ], + method: "PUT", + suffix: "/templates/template-test", + }, + { + name: "delete template", + args: ["sandbox", "template", "delete", "--template-id", "template-test"], + method: "DELETE", + suffix: "/templates/template-test", + }, + ])("$name dry-run stays offline", async ({ args, method, suffix }) => { + const { stdout, stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [ + ...args, + ...AUTH_ARGS, + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const result = parseStdoutJson<{ method: string; endpoint: string }>(stdout); + expect(result.method).toBe(method); + expect(result.endpoint).toBe( + `https://ws-e2e.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox${suffix}`, + ); + }); +}); + +describe("e2e: Sandbox high-risk confirmation", () => { + test.each([ + ["sandbox", "delete", "--sandbox-id", "sandbox-test"], + ["sandbox", "template", "delete", "--template-id", "template-test"], + ])("%s requires --yes before a remote delete", async (...commandArgs) => { + const { stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [ + ...commandArgs, + ...AUTH_ARGS, + "--output", + "json", + ]); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); + }); +}); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 18e6279..a12bea9 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -182,6 +182,22 @@ export const SKILL_ROUTES: E2eRouteExports = { "skill init": "skillInit", }; +export const SANDBOX_ROUTES: E2eRouteExports = { + "sandbox create": "sandboxCreate", + "sandbox list": "sandboxList", + "sandbox get": "sandboxGet", + "sandbox connect": "sandboxConnect", + "sandbox pause": "sandboxPause", + "sandbox resume": "sandboxResume", + "sandbox delete": "sandboxDelete", + "sandbox template create": "sandboxTemplateCreate", + "sandbox template list": "sandboxTemplateList", + "sandbox template get": "sandboxTemplateGet", + "sandbox template update": "sandboxTemplateUpdate", + "sandbox template build-status": "sandboxTemplateBuildStatus", + "sandbox template delete": "sandboxTemplateDelete", +}; + export const MANAGED_AGENT_ROUTES: E2eRouteExports = { "managed-agent init": "managedAgentInit", "managed-agent validate": "managedAgentValidate", diff --git a/packages/commands/tests/sandbox.test.ts b/packages/commands/tests/sandbox.test.ts new file mode 100644 index 0000000..7288194 --- /dev/null +++ b/packages/commands/tests/sandbox.test.ts @@ -0,0 +1,500 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BailianError, ExitCode, type AnyCommand, type Settings } from "bailian-cli-core"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { + buildSandboxCreateBody, + sandboxConnect, + sandboxCreate, + sandboxDelete, + sandboxGet, + sandboxList, + sandboxPause, + sandboxResume, +} from "../src/commands/sandbox/instance.ts"; +import { + buildTemplateCreateBody, + buildTemplateUpdateBody, + sandboxTemplateBuildStatus, + sandboxTemplateCreate, + sandboxTemplateDelete, + sandboxTemplateGet, + sandboxTemplateList, + sandboxTemplateUpdate, + waitForTemplateBuild, +} from "../src/commands/sandbox/template.ts"; +import { + readRequestBody, + redactConnectionCredentials, + redactRequestSecrets, +} from "../src/commands/sandbox/shared.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const SETTINGS: Settings = { + output: "json", + outputExplicit: true, + timeout: 30, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, +}; + +interface RecordedRequest { + path: string; + method?: string; + body?: unknown; + timeout?: number; +} + +async function runCommand( + command: AnyCommand, + flags: Record, + response: unknown, +): Promise { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const requestJson = vi.fn(async (_request: RecordedRequest) => response); + const request = vi.fn(async (requestOptions: RecordedRequest) => { + void requestOptions; + return new Response(null, { status: 204 }); + }); + await command.run({ + identity: { + binName: "bl", + version: "test", + npmPackage: "bailian-cli", + clientName: "bailian-cli", + }, + settings: SETTINGS, + flags, + client: { requestJson, request }, + } as never); + + const recorded = requestJson.mock.calls[0]?.[0] ?? request.mock.calls[0]?.[0]; + if (!recorded) throw new Error("Expected the command to issue one request."); + expect(requestJson.mock.calls.length + request.mock.calls.length).toBe(1); + return recorded; +} + +describe("Sandbox request input", () => { + test("--body accepts inline and @file JSON objects", async () => { + expect(await readRequestBody('{"name":"inline"}')).toEqual({ name: "inline" }); + + const directory = mkdtempSync(join(tmpdir(), "bl-sandbox-body-")); + const bodyPath = join(directory, "body.json"); + writeFileSync(bodyPath, '{"name":"file"}', "utf8"); + try { + expect(await readRequestBody(`@${bodyPath}`)).toEqual({ name: "file" }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test("instance flags override body scalars and merge key-value maps", async () => { + const body = await buildSandboxCreateBody({ + body: JSON.stringify({ + templateID: "template-body", + timeout: 600, + metadata: { body: "kept", override: "body" }, + envVars: { BODY: "kept", OVERRIDE: "body" }, + network: { allowOut: ["body.example"], maskRequestHost: "body.example" }, + }), + templateId: "template-flag", + instanceTimeout: 900, + metadata: ["override=flag"], + env: ["OVERRIDE=flag"], + allowOut: ["flag.example"], + maskRequestHost: "flag.example", + showCredentials: false, + }); + expect(body).toEqual({ + templateID: "template-flag", + timeout: 900, + metadata: { body: "kept", override: "flag" }, + envVars: { BODY: "kept", OVERRIDE: "flag" }, + network: { allowOut: ["flag.example"], maskRequestHost: "flag.example" }, + }); + }); + + test("validates instance timeout and paired template CPU/memory fields", async () => { + await expect( + buildSandboxCreateBody({ + templateId: "template-test", + instanceTimeout: 299, + showCredentials: false, + }), + ).rejects.toMatchObject({ exitCode: ExitCode.USAGE }); + + await expect( + buildTemplateUpdateBody({ + templateId: "template-test", + cpuCount: 2, + async: false, + }), + ).rejects.toMatchObject({ exitCode: ExitCode.USAGE }); + }); + + test("template create flags map max-running-time and nested overrides", async () => { + const body = await buildTemplateCreateBody({ + body: JSON.stringify({ networkConfig: { allowOut: ["body.example"] } }), + name: "python", + cpuCount: 2, + memoryMb: 4096, + maxRunningTime: 3600, + allowOut: ["flag.example"], + tag: ["latest"], + async: false, + }); + expect(body).toEqual({ + name: "python", + cpuCount: 2, + memoryMB: 4096, + maxRunningTimeout: 3600, + tags: ["latest"], + networkConfig: { allowOut: ["flag.example"] }, + }); + }); + + test("redacts returned connection credentials and request environment secrets recursively", () => { + expect( + redactConnectionCredentials({ + sandboxID: "sandbox-test", + envdAccessToken: "envd-secret", + connection: { trafficAccessToken: "traffic-secret", hostname: "example.test" }, + }), + ).toEqual({ + sandboxID: "sandbox-test", + envdAccessToken: "[REDACTED]", + connection: { trafficAccessToken: "[REDACTED]", hostname: "example.test" }, + }); + expect( + redactRequestSecrets({ envVars: { PUBLIC_NAME: "secret" }, nested: { apiKey: "secret" } }), + ).toEqual({ + envVars: { PUBLIC_NAME: "[REDACTED]" }, + nested: { apiKey: "[REDACTED]" }, + }); + }); +}); + +describe("Sandbox command transport", () => { + test.each([ + { + name: "create instance", + command: sandboxCreate, + flags: { workspaceId: "ws-test", templateId: "template-test", showCredentials: false }, + response: { sandboxID: "sandbox-test" }, + request: { + method: "POST", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes", + body: { templateID: "template-test" }, + }, + }, + { + name: "list instances", + command: sandboxList, + flags: { workspaceId: "ws-test", templateId: "template-test", state: "running", limit: 10 }, + response: [], + request: { + method: "GET", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/v2/sandboxes?templateID=template-test&state=running&limit=10", + }, + }, + { + name: "get instance", + command: sandboxGet, + flags: { workspaceId: "ws-test", sandboxId: "sandbox/a", showCredentials: false }, + response: { sandboxID: "sandbox/a" }, + request: { + method: "GET", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox%2Fa", + }, + }, + { + name: "connect instance", + command: sandboxConnect, + flags: { + workspaceId: "ws-test", + sandboxId: "sandbox-test", + instanceTimeout: 900, + showCredentials: false, + }, + response: { sandboxID: "sandbox-test" }, + request: { + method: "POST", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox-test/connect", + body: { timeout: 900 }, + }, + }, + { + name: "pause instance", + command: sandboxPause, + flags: { workspaceId: "ws-test", sandboxId: "sandbox-test" }, + response: undefined, + request: { + method: "POST", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox-test/pause", + }, + }, + { + name: "resume instance", + command: sandboxResume, + flags: { workspaceId: "ws-test", sandboxId: "sandbox-test", showCredentials: false }, + response: { sandboxID: "sandbox-test" }, + request: { + method: "POST", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox-test/resume", + }, + }, + { + name: "delete instance", + command: sandboxDelete, + flags: { workspaceId: "ws-test", sandboxId: "sandbox-test" }, + response: undefined, + request: { + method: "DELETE", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox-test", + }, + }, + { + name: "create template", + command: sandboxTemplateCreate, + flags: { + workspaceId: "ws-test", + name: "python", + cpuCount: 1, + memoryMb: 2048, + async: true, + }, + response: { templateID: "template-test", buildID: "build-test" }, + request: { + method: "POST", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/v3/templates", + body: { name: "python", cpuCount: 1, memoryMB: 2048 }, + }, + }, + { + name: "list templates", + command: sandboxTemplateList, + flags: { workspaceId: "ws-test", limit: 20, cursor: "cursor/a" }, + response: [], + request: { + method: "GET", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/v2/templates?limit=20&cursor=cursor%2Fa", + }, + }, + { + name: "get template", + command: sandboxTemplateGet, + flags: { workspaceId: "ws-test", templateId: "template/a" }, + response: { templateID: "template/a" }, + request: { + method: "GET", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template%2Fa", + }, + }, + { + name: "update template", + command: sandboxTemplateUpdate, + flags: { + workspaceId: "ws-test", + templateId: "template-test", + description: "updated", + async: true, + }, + response: { templateID: "template-test", buildID: "build-test" }, + request: { + method: "PUT", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template-test", + body: { description: "updated" }, + }, + }, + { + name: "get template build status", + command: sandboxTemplateBuildStatus, + flags: { workspaceId: "ws-test", templateId: "template-test", buildId: "build/a" }, + response: { status: "ready" }, + request: { + method: "GET", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template-test/builds/build%2Fa/status", + }, + }, + { + name: "delete template", + command: sandboxTemplateDelete, + flags: { workspaceId: "ws-test", templateId: "template-test" }, + response: undefined, + request: { + method: "DELETE", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template-test", + }, + }, + ])( + "maps $name to its documented method and path", + async ({ command, flags, response, request }) => { + expect(await runCommand(command, flags, response)).toEqual(request); + }, + ); + + test.each([ + { showCredentials: false, expectedToken: "[REDACTED]" }, + { showCredentials: true, expectedToken: "envd-secret" }, + ])("show-credentials=$showCredentials controls connection token output", async (testCase) => { + let stdout = ""; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + await sandboxGet.run({ + identity: { binName: "bl" }, + settings: { ...SETTINGS, quiet: false }, + flags: { + workspaceId: "ws-test", + sandboxId: "sandbox-test", + showCredentials: testCase.showCredentials, + }, + client: { + requestJson: async () => ({ + sandboxID: "sandbox-test", + envdAccessToken: "envd-secret", + }), + }, + } as never); + expect(JSON.parse(stdout)).toMatchObject({ envdAccessToken: testCase.expectedToken }); + }); +}); + +describe("Sandbox template build polling", () => { + test("polls building until ready", async () => { + const requestJson = vi + .fn() + .mockResolvedValueOnce({ status: "building" }) + .mockResolvedValueOnce({ status: "ready", buildID: "build-test" }); + const result = await waitForTemplateBuild( + { requestJson } as never, + SETTINGS, + "https://example.test/build/status", + 0, + ); + expect(result).toMatchObject({ status: "ready", buildID: "build-test" }); + expect(requestJson).toHaveBeenCalledTimes(2); + }); + + test("preserves the service build error message", async () => { + const requestJson = vi.fn().mockResolvedValue({ + status: "error", + reason: { code: "BuildFailed", message: "image download failed" }, + }); + await expect( + waitForTemplateBuild( + { requestJson } as never, + SETTINGS, + "https://example.test/build/status", + 0, + ), + ).rejects.toMatchObject({ + message: "image download failed", + exitCode: ExitCode.GENERAL, + }); + }); + + test("returns a timeout error when the global timeout expires", async () => { + const requestJson = vi.fn(); + await expect( + waitForTemplateBuild( + { requestJson } as never, + { ...SETTINGS, timeout: 0 }, + "https://example.test/build/status", + 1, + ), + ).rejects.toEqual(expect.objectContaining({ exitCode: ExitCode.TIMEOUT })); + expect(requestJson).not.toHaveBeenCalled(); + }); + + test("async template creation returns after the submit request", async () => { + let stdout = ""; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const requestJson = vi.fn().mockResolvedValue({ + templateID: "template-test", + buildID: "build-test", + buildStatus: "building", + }); + await sandboxTemplateCreate.run({ + identity: { binName: "bl" }, + settings: SETTINGS, + flags: { + workspaceId: "ws-test", + name: "python", + cpuCount: 1, + memoryMb: 2048, + async: true, + }, + client: { requestJson }, + } as never); + expect(requestJson).toHaveBeenCalledTimes(1); + expect(stdout).toBe("template-test\tbuild-test\n"); + }); + + test("default template creation polls build-status and emits the final envelope", async () => { + let stdout = ""; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const requestJson = vi + .fn() + .mockResolvedValueOnce({ + templateID: "template-test", + buildID: "build-test", + buildStatus: "building", + }) + .mockResolvedValueOnce({ + templateID: "template-test", + buildID: "build-test", + status: "ready", + }); + await sandboxTemplateCreate.run({ + identity: { binName: "bl" }, + settings: { ...SETTINGS, quiet: false }, + flags: { + workspaceId: "ws-test", + name: "python", + cpuCount: 1, + memoryMb: 2048, + async: false, + pollInterval: 1, + }, + client: { requestJson }, + } as never); + + expect(requestJson).toHaveBeenCalledTimes(2); + expect(requestJson.mock.calls[1]?.[0]).toMatchObject({ + method: "GET", + path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template-test/builds/build-test/status", + }); + expect(JSON.parse(stdout)).toEqual({ + template: { + templateID: "template-test", + buildID: "build-test", + buildStatus: "building", + }, + build: { + templateID: "template-test", + buildID: "build-test", + status: "ready", + }, + }); + }); +}); + +test("Sandbox validation errors use CLI usage exit codes", async () => { + await expect(buildSandboxCreateBody({ showCredentials: false })).rejects.toBeInstanceOf( + BailianError, + ); +}); diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 8717c94..a9ed891 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -226,6 +226,45 @@ export function ragEndpoint(workspaceId: string, path: string): string { return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com${path}`; } +// ---- Sandbox control plane (workspace-based host, cn-beijing only) ---- + +/** + * Build an absolute Bailian Sandbox control-plane URL. + * + * Sandbox currently supports cn-beijing only. Keep the workspace-specific host + * centralized here so commands never hard-code API endpoints. + */ +export function sandboxEndpoint(workspaceId: string, path: string): string { + return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox${path}`; +} + +export const SANDBOX_PATHS = { + sandboxes: "/sandboxes", + sandboxList: "/v2/sandboxes", + templates: "/templates", + templateCreate: "/v3/templates", + templateList: "/v2/templates", +} as const; + +export function sandboxInstancePath(sandboxId: string): string { + return `${SANDBOX_PATHS.sandboxes}/${encodeURIComponent(sandboxId)}`; +} + +export function sandboxInstanceActionPath( + sandboxId: string, + action: "connect" | "pause" | "resume", +): string { + return `${sandboxInstancePath(sandboxId)}/${action}`; +} + +export function sandboxTemplatePath(templateId: string): string { + return `${SANDBOX_PATHS.templates}/${encodeURIComponent(templateId)}`; +} + +export function sandboxTemplateBuildStatusPath(templateId: string, buildId: string): string { + return `${sandboxTemplatePath(templateId)}/builds/${encodeURIComponent(buildId)}/status`; +} + export const RAG_PATHS = { // indices domain — knowledge bases / documents / chunks / import jobs. // Note: parameter naming is inconsistent across endpoints; see per-path comments. diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 0a387f1..50ee01c 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -18,6 +18,12 @@ export { profileSchemaPath, ragEndpoint, RAG_PATHS, + sandboxEndpoint, + SANDBOX_PATHS, + sandboxInstancePath, + sandboxInstanceActionPath, + sandboxTemplatePath, + sandboxTemplateBuildStatusPath, responsesPath, speechRecognizePath, speechSynthesizePath, diff --git a/packages/core/src/errors/api.ts b/packages/core/src/errors/api.ts index 2a682bb..91fff65 100644 --- a/packages/core/src/errors/api.ts +++ b/packages/core/src/errors/api.ts @@ -7,9 +7,10 @@ export interface ApiErrorBody { type?: string; code?: number | string; }; - code?: string; + code?: number | string; message?: string; request_id?: string; + requestID?: string; } export function mapApiError(status: number, body: ApiErrorBody, _url?: string): BailianError { @@ -26,7 +27,7 @@ export function mapApiError(status: number, body: ApiErrorBody, _url?: string): api: { httpStatus: status, apiCode, - requestId: body.request_id, + requestId: body.request_id ?? body.requestID, }, }); } diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index 44eb765..914fb68 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -2,11 +2,17 @@ import { expect, test } from "vite-plus/test"; import type { Identity, Settings } from "../src/index.ts"; import { BailianError, + Client, ExitCode, McpClient, callConsoleGateway, mapApiError, request, + sandboxEndpoint, + sandboxInstanceActionPath, + sandboxInstancePath, + sandboxTemplateBuildStatusPath, + sandboxTemplatePath, } from "../src/index.ts"; import { parseConfigFile } from "../src/config/schema.ts"; import { @@ -73,6 +79,69 @@ test("mapApiError captures apiCode and request_id when present", () => { }); }); +test("mapApiError captures Sandbox numeric code and requestID", () => { + const err = mapApiError(400, { + code: 100004, + message: "参数缺失", + requestID: "sandbox-request-id", + }); + expect(err.message).toBe("参数缺失"); + expect(err.api).toEqual({ + httpStatus: 400, + apiCode: "100004", + requestId: "sandbox-request-id", + }); +}); + +test("Sandbox endpoint builders fix cn-beijing and encode path identifiers", () => { + expect(sandboxEndpoint("ws-test", "/sandboxes")).toBe( + "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes", + ); + expect(sandboxInstancePath("sandbox/a b")).toBe("/sandboxes/sandbox%2Fa%20b"); + expect(sandboxInstanceActionPath("sandbox/a b", "connect")).toBe( + "/sandboxes/sandbox%2Fa%20b/connect", + ); + expect(sandboxTemplatePath("template/a b")).toBe("/templates/template%2Fa%20b"); + expect(sandboxTemplateBuildStatusPath("template/a", "build b")).toBe( + "/templates/template%2Fa/builds/build%20b/status", + ); +}); + +test("Client sends Sandbox REST requests with Bearer auth and no E2B API key", async () => { + const originalFetch = globalThis.fetch; + let requestHeaders: RequestInit["headers"]; + globalThis.fetch = async (_input, init) => { + requestHeaders = init?.headers; + return new Response(JSON.stringify({ sandboxID: "sandbox-test" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const deps = testDeps(); + const client = new Client({ + ...deps, + baseUrl: "https://dashscope.aliyuncs.com", + apiCred: { + token: "bailian-api-key", + baseUrl: "https://dashscope.aliyuncs.com", + source: "flag", + }, + }); + await client.requestJson({ + path: sandboxEndpoint("ws-test", "/sandboxes/sandbox-test"), + method: "GET", + }); + + const headers = new Headers(requestHeaders); + expect(headers.get("Authorization")).toBe("Bearer bailian-api-key"); + expect(headers.has("X-API-Key")).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("BailianError propagates cause via options-bag and exposes it in toJSON", () => { const root = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), { code: "ENOTFOUND", diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 7efd2e9..013e286 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -12,7 +12,8 @@ description: >- 用户点名百炼 / DashScope / `bl`,或继续既有 `bl` 工作流时直接使用。 共享协议(consent / 版本预检 / 鉴权 / 错误上报)在 bailian-protocol;官方安装 `bl skill init`。 家族路由:生图/生视频/配音/语音合成/转写 → bailian-gen;精调/微调/训练/数据集 → bailian-finetune; - agents.yaml 托管 Agent → bailian-managed-agent;联网搜索的模型路由(Token Plan 自带搜索 vs MCP 搜索 + 兜底)→ bailian-web-search。 + agents.yaml 托管 Agent → bailian-managed-agent;Sandbox 实例与模版 → bailian-sandbox; + 联网搜索的模型路由(Token Plan 自带搜索 vs MCP 搜索 + 兜底)→ bailian-web-search。 不要用于普通问答、编程、写作、翻译、摘要、泛搜索,或图片理解等宿主自己能做的任务(普通问答、编程、写作、翻译、摘要、泛搜索不触发)。 未命名用量/额度问题:先问用户使用哪个产品,再运行 `bl usage` / `bl quota` 查询。 --- @@ -23,7 +24,7 @@ description: >- > **Family hub** — This skill owns Bailian resource commands and the hub `reference/` (apps, knowledge, usage, auth, config, …). > Shared protocol → [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md) (install the full family with `bl skill init`). -> Soft hand-offs by skill name (Read if installed; else `bl … --help` / prompt `bl skill init`): `bailian-gen` (media) · `bailian-finetune` (training) · `bailian-managed-agent` (agents.yaml IaC) · `bailian-web-search` (web search routing). +> Soft hand-offs by skill name (Read if installed; else `bl … --help` / prompt `bl skill init`): `bailian-gen` (media) · `bailian-finetune` (training) · `bailian-managed-agent` (agents.yaml IaC) · `bailian-sandbox` (Sandbox lifecycle) · `bailian-web-search` (web search routing). > Do not invoke it for ordinary reasoning, coding, writing, translation, summarization, generic research, or image understanding the host agent can complete directly. > > **Install (supported):** `bl skill init` @@ -40,6 +41,7 @@ Domain skills own their own generated reference trees (soft hand-off — do not - `bailian-gen` → `image` / `video` / `speech` / `omni` / `vision` (fallback: `bl image\|video\|speech\|omni\|vision --help`) - `bailian-finetune` → `dataset` / `finetune` / `deploy` (fallback: `bl dataset\|finetune\|deploy --help`) - `bailian-managed-agent` → `managed-agent` (fallback: `bl managed-agent --help`) +- `bailian-sandbox` → `sandbox` (fallback: `bl sandbox --help`) - `bailian-web-search` → web search **routing** (hub still owns `reference/search.md` flags; **must** route via this skill before `bl search web`) Auto-generated from the CLI source at build time (`pnpm --filter bailian-cli run generate:reference`). Before running an unfamiliar command: @@ -54,7 +56,7 @@ Do not guess flags — use the reference files or `--help`. ## When to use which command -Use this table only after the decision table in [`bailian-protocol`](../bailian-protocol/SKILL.md#provider-selection-and-consent) has routed the request to `bl` (class 4, or class 2 after the user picks Bailian). Hub-owned intents only — for media / fine-tune / agents.yaml, soft hand-off to the domain skill. +Use this table only after the decision table in [`bailian-protocol`](../bailian-protocol/SKILL.md#provider-selection-and-consent) has routed the request to `bl` (class 4, or class 2 after the user picks Bailian). Hub-owned intents only — for media / fine-tune / agents.yaml / Sandbox, soft hand-off to the domain skill. | User intent | Command | Notes | | ------------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------- | @@ -79,6 +81,7 @@ Use this table only after the decision table in [`bailian-protocol`](../bailian- | Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | | Dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | | agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` also require `plan` | +| Bailian Sandbox instance / template lifecycle | → skill `bailian-sandbox` | Fallback: `bl sandbox --help` | | Web search (model-aware routing) | → skill `bailian-web-search` | Token Plan vs MCP path + fallback; fallback: `bl search web --help` | Flags, usage, and examples: see hub [`reference/`](reference/index.md) or `bl --help` — do not guess flags. Domain command details live in the owning skill's `reference/`. diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index 8e1bb5a..9bb9532 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -7,7 +7,7 @@ metadata: description: >- 阿里云百炼 `bl` 家族共享执行协议(consent 确认、版本预检、鉴权/安装、错误上报、本地文件与输出约定)。 不是面向用户意图的业务入口;当任一 bailian-* 业务 skill(bailian-cli / bailian-gen / - bailian-finetune / bailian-managed-agent / bailian-web-search)执行前需要公共上下文,或用户首次安装/鉴权/`bl` 报错需上报时读取本 skill。 + bailian-finetune / bailian-managed-agent / bailian-sandbox / bailian-web-search)执行前需要公共上下文,或用户首次安装/鉴权/`bl` 报错需上报时读取本 skill。 官方安装为整包:`bl skill init`(与业务 skill 同装)。 --- @@ -60,6 +60,7 @@ After approval, treat Bailian as selected for the current task. Do not ask again | 生图 / 生视频 / 语音 / 图片理解 / 视频理解 / omni / vision | `bailian-gen` | `bl image\|video\|speech\|omni\|vision --help` | | 精调 / 数据集 / 部署 | `bailian-finetune` | `bl dataset\|finetune\|deploy --help` | | agents.yaml IaC | `bailian-managed-agent` | `bl managed-agent --help` | +| 百炼 Sandbox 实例 / 模版生命周期 | `bailian-sandbox` | `bl sandbox --help` | | 联网搜索 / web search(模型路由 + 兜底) | `bailian-web-search` | `bl search web --help` | | 应用 / 知识库 / 用量 / 鉴权配置等资源 | `bailian-cli` | `bl app\|knowledge\|usage\|auth\|config --help` | diff --git a/skills/bailian-sandbox/SKILL.md b/skills/bailian-sandbox/SKILL.md new file mode 100644 index 0000000..15ec6b4 --- /dev/null +++ b/skills/bailian-sandbox/SKILL.md @@ -0,0 +1,58 @@ +--- +name: bailian-sandbox +metadata: + version: "1.20.0" + requires: + bins: ["bl"] +description: >- + 阿里云百炼 Sandbox 沙箱实例与模版生命周期管理入口:用户要创建、查询、连接、暂停、恢复或释放百炼沙箱, + 或创建、更新、查询、删除沙箱模版、查看模版构建状态时,使用 `bl sandbox`。 + 仅覆盖百炼 Sandbox 管控面;不用于宿主执行沙箱设置、E2B 官方云资源或沙箱内命令执行与文件传输。 + agents.yaml 托管 Agent / Session / Environment 管理交给 bailian-managed-agent。 + 官方安装:`bl skill init`(与共享协议 bailian-protocol 同装)。 +--- + +# Bailian Sandbox (`bl sandbox`) + +Before running `bl`, read the shared [bailian-protocol](../bailian-protocol/SKILL.md) for consent, high-risk confirmation, version checks, authentication, and error handling. If it is missing, stop execution and prompt the user to install the full family with `bl skill init`. + +## Scope and setup + +- Manage Sandbox instances and templates through Bailian's E2B-compatible REST control plane. No E2B SDK or E2B API key is required; authentication uses the Bailian API Key as an Authorization Bearer token. +- Resolve the workspace from `--workspace-id`, then `BAILIAN_WORKSPACE_ID`, then configured `workspace_id`. The current CLI targets `cn-beijing` and requires prior Sandbox SLR authorization. +- The current endpoint is `https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox`. Login and command-level `--base-url` do not override the Sandbox endpoint; the saved API Key is reused. +- No `agents.yaml` or local IaC state is required. `connect` returns instance connection information; it does not open an interactive shell. Do not invent commands for executing code or transferring files inside the sandbox. + +## Choose the operation + +| User intent | Command family | +| ------------------------------------- | -------------------------------------------------------- | +| Inspect or create instances | `bl sandbox list` / `get` / `create` | +| Connect, pause, or resume an instance | `bl sandbox connect` / `pause` / `resume` | +| Release an instance | `bl sandbox delete` | +| Inspect or build templates | `bl sandbox template list` / `get` / `create` / `update` | +| Check a submitted template build | `bl sandbox template build-status` | +| Delete a template | `bl sandbox template delete` | + +Read [reference/index.md](reference/index.md) and the relevant section of [reference/sandbox.md](reference/sandbox.md) for exact flags, usage, and examples, or run the matching command with `--help`. Do not guess flags. + +## Operational boundaries + +- Mutating commands act on remote resources. Only perform the requested operation and scope; read-only discovery does not authorize creating, pausing, resuming, or deleting resources. +- Instance and template deletion are high-risk. Follow the shared protocol: show the exact target and risk, then wait for explicit confirmation before adding `--yes`. Treat `requires_confirmation` as a stop signal, not a reason to retry automatically. +- Connection credentials are redacted by default. Use `--show-credentials` only when the user explicitly needs the connection tokens, and keep them out of chat summaries, logs, and committed files. +- Template create/update wait by polling the build-status endpoint, not template details. `--async` returns after the submission response with `templateID` / `buildID`; it does not mean the build is ready. Use those IDs with `template build-status` to check completion. +- Global `--timeout` limits HTTP requests and total template-build polling. `--instance-timeout` sets instance lifetime; these are different limits. A polling timeout does not prove the remote build failed or stopped; check its status before submitting another build. +- `--body` accepts a JSON object inline or through `@path`; explicit flags override body fields. Template file mounts require workspace File IDs, not temporary `oss://` URLs from `bl file upload`. + +## Common hand-offs + +Refer to sibling skills by name: read them if installed; otherwise use the command's `--help` or prompt `bl skill init`. + +- Managed Agent / Session / Environment resources or `agents.yaml` IaC → `bailian-managed-agent` (`bl managed-agent --help`). +- Workspace discovery or CLI login/configuration → `bailian-cli` (`bl workspace --help` / `bl auth --help` / `bl config --help`). + +## references + +- [bailian-protocol](../bailian-protocol/SKILL.md) — shared execution protocol, installed with `bl skill init` +- [reference/](reference/index.md) — generated command reference diff --git a/skills/bailian-sandbox/reference/index.md b/skills/bailian-sandbox/reference/index.md new file mode 100644 index 0000000..41bfaeb --- /dev/null +++ b/skills/bailian-sandbox/reference/index.md @@ -0,0 +1,84 @@ +# `bailian-sandbox` command reference + +> Auto-generated from `packages/cli/src/commands.ts`. Do not edit by hand. +> Regenerate: `pnpm --filter bailian-cli run generate:reference`. + +Command **details** are in sibling `.md` files in this directory. +This index only covers groups owned by this skill. Other `bl` groups live in sibling bailian-\* skills. +Use this index for the skill-scoped quick index and global flags. + +## Quick index + +| Command | Authentication | Description | Detail | +| ---------------------------------- | -------------- | --------------------------------------------------------------- | ------------------------ | +| `bl sandbox connect` | API Key | Connect to a Sandbox instance and return connection information | [sandbox.md](sandbox.md) | +| `bl sandbox create` | API Key | Create a Sandbox instance | [sandbox.md](sandbox.md) | +| `bl sandbox delete` | API Key | Release a Sandbox instance | [sandbox.md](sandbox.md) | +| `bl sandbox get` | API Key | Get Sandbox instance details | [sandbox.md](sandbox.md) | +| `bl sandbox list` | API Key | List Sandbox instances | [sandbox.md](sandbox.md) | +| `bl sandbox pause` | API Key | Pause a Sandbox instance | [sandbox.md](sandbox.md) | +| `bl sandbox resume` | API Key | Resume a Sandbox instance and return connection information | [sandbox.md](sandbox.md) | +| `bl sandbox template build-status` | API Key | Get Sandbox template build status | [sandbox.md](sandbox.md) | +| `bl sandbox template create` | API Key | Create a Sandbox template | [sandbox.md](sandbox.md) | +| `bl sandbox template delete` | API Key | Delete a Sandbox template | [sandbox.md](sandbox.md) | +| `bl sandbox template get` | API Key | Get Sandbox template details | [sandbox.md](sandbox.md) | +| `bl sandbox template list` | API Key | List Sandbox templates | [sandbox.md](sandbox.md) | +| `bl sandbox template update` | API Key | Update a Sandbox template | [sandbox.md](sandbox.md) | + +## By group + +| Group | Commands | Reference | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| `sandbox` | `connect`, `create`, `delete`, `get`, `list`, `pause`, `resume`, `template build-status`, `template create`, `template delete`, `template get`, `template list`, `template update` | [sandbox.md](sandbox.md) | + +## Global flags + +Available on every command (in addition to command-specific flags): + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------- | +| `--output ` | string | no | Output format: text, json | +| `--timeout ` | number | no | Request timeout | +| `--quiet` | switch | no | Suppress non-essential output | +| `--verbose` | switch | no | Print HTTP request/response details | +| `--dry-run` | switch | no | Dry run mode | +| `--config ` | string | no | Use a config profile for this command | +| `--help` | switch | no | Show help | +| `--version` | switch | no | Print version | + +## Model auth flags + +Available on model-domain commands (API-key auth); also listed per command below: + +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | ------------ | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +## Console auth flags + +Available on console-domain commands (console login auth); also listed per command below: + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | -------------------------------------------------------- | +| `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | +| `--console-site ` | string | no | Console site: domestic, international | +| `--console-switch-agent ` | number | no | Switch agent UID for delegated access | +| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) | + +## OpenAPI auth flags + +Available on OpenAPI-domain commands (AK/SK auth); also listed per command below: + +| Flag | Type | Required | Description | +| --------------------------- | ------ | -------- | ---------------------------------------------------------------------- | +| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (env: ALIBABA_CLOUD_ACCESS_KEY_ID) | +| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (env: ALIBABA_CLOUD_ACCESS_KEY_SECRET) | +| `--security-token ` | string | no | Alibaba Cloud STS Security Token (env: ALIBABA_CLOUD_SECURITY_TOKEN) | + +## Notes + +- Console commands (`app list`, `usage free`, `console call`) require `bl auth login --console`. +- Most API commands use `DASHSCOPE_API_KEY` or `bl auth login --api-key`. +- Token Plan commands use OpenAPI AK/SK via `bl auth login --open-api` or `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`. +- Default output: **text** unless explicitly set to `json` with `--output`, `DASHSCOPE_OUTPUT`, or config. diff --git a/skills/bailian-sandbox/reference/sandbox.md b/skills/bailian-sandbox/reference/sandbox.md new file mode 100644 index 0000000..ebada91 --- /dev/null +++ b/skills/bailian-sandbox/reference/sandbox.md @@ -0,0 +1,567 @@ +# `bl sandbox` commands + +> Auto-generated from `packages/cli/src/commands.ts`. Do not edit by hand. +> Regenerate: `pnpm --filter bailian-cli run generate:reference`. + +Index: [index.md](index.md) + +## Commands in this group + +| Command | Authentication | Description | +| ---------------------------------- | -------------- | --------------------------------------------------------------- | +| `bl sandbox connect` | API Key | Connect to a Sandbox instance and return connection information | +| `bl sandbox create` | API Key | Create a Sandbox instance | +| `bl sandbox delete` | API Key | Release a Sandbox instance | +| `bl sandbox get` | API Key | Get Sandbox instance details | +| `bl sandbox list` | API Key | List Sandbox instances | +| `bl sandbox pause` | API Key | Pause a Sandbox instance | +| `bl sandbox resume` | API Key | Resume a Sandbox instance and return connection information | +| `bl sandbox template build-status` | API Key | Get Sandbox template build status | +| `bl sandbox template create` | API Key | Create a Sandbox template | +| `bl sandbox template delete` | API Key | Delete a Sandbox template | +| `bl sandbox template get` | API Key | Get Sandbox template details | +| `bl sandbox template list` | API Key | List Sandbox templates | +| `bl sandbox template update` | API Key | Update a Sandbox template | + +## Command details + +### `bl sandbox connect` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------ | +| **Name** | `sandbox connect` | +| **Description** | Connect to a Sandbox instance and return connection information | +| **Authentication** | API Key | +| **Usage** | `bl sandbox connect --sandbox-id [--instance-timeout ] [--show-credentials]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--sandbox-id ` | string | yes | Sandbox instance ID | +| `--body ` | string | no | JSON request body, inline or loaded from an @file path; explicit flags override it | +| `--instance-timeout ` | number | no | Sandbox lifetime after this operation (300-604800 seconds) | +| `--show-credentials` | switch | no | Print envd and traffic access tokens instead of redacting them | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. + +#### Examples + +```bash +bl sandbox connect --sandbox-id sbx-xxx --instance-timeout 3600 +``` + +```bash +bl sandbox connect --sandbox-id sbx-xxx --show-credentials --output json +``` + +### `bl sandbox create` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------------ | +| **Name** | `sandbox create` | +| **Description** | Create a Sandbox instance | +| **Authentication** | API Key | +| **Usage** | `bl sandbox create (--template-id \| --body ) [flags]` | + +#### Flags + +| Flag | Type | Required | Description | +| -------------------------------- | ------- | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--body ` | string | no | JSON request body, inline or loaded from an @file path; explicit flags override it | +| `--template-id ` | string | no | Ready template ID; may alternatively be supplied as templateID in --body | +| `--instance-timeout ` | number | no | Sandbox lifetime after this operation (300-604800 seconds) | +| `--allow-internet-access ` | boolean | no | Allow public internet access | +| `--metadata ` | array | no | Instance metadata entry; repeat for multiple values | +| `--env ` | array | no | Instance environment variable; repeat for multiple values | +| `--auto-pause ` | boolean | no | Pause the instance when its timeout expires | +| `--auto-resume ` | boolean | no | Automatically resume a paused instance when connecting | +| `--allow-out
` | array | no | Outbound allow-list entry; repeat for multiple values | +| `--deny-out
` | array | no | Outbound deny-list entry; repeat for multiple values | +| `--mask-request-host ` | string | no | Override the outbound request Host header | +| `--show-credentials` | switch | no | Print envd and traffic access tokens instead of redacting them | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. + +#### Examples + +```bash +bl sandbox create --template-id tpl-xxx --instance-timeout 3600 +``` + +```bash +bl sandbox create --body @sandbox.json --dry-run --output json +``` + +```bash +bl sandbox create --template-id tpl-xxx --show-credentials --output json +``` + +### `bl sandbox delete` + +| Field | Value | +| ------------------ | -------------------------------------------------------------------- | +| **Name** | `sandbox delete` | +| **Description** | Release a Sandbox instance | +| **Authentication** | API Key | +| **Usage** | `bl sandbox delete --sandbox-id ` | +| **Risk** | `high` | +| **Risk message** | This permanently releases the Sandbox instance and cannot be undone. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--sandbox-id ` | string | yes | Sandbox instance ID | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. + +#### Examples + +```bash +bl sandbox delete --sandbox-id sbx-xxx --dry-run +``` + +```bash +# Only after explicit user confirmation: +bl sandbox delete --sandbox-id sbx-xxx --yes +``` + +### `bl sandbox get` + +| Field | Value | +| ------------------ | ------------------------------------------------------- | +| **Name** | `sandbox get` | +| **Description** | Get Sandbox instance details | +| **Authentication** | API Key | +| **Usage** | `bl sandbox get --sandbox-id [--show-credentials]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--sandbox-id ` | string | yes | Sandbox instance ID | +| `--show-credentials` | switch | no | Print envd and traffic access tokens instead of redacting them | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. + +#### Examples + +```bash +bl sandbox get --sandbox-id sbx-xxx +``` + +```bash +bl sandbox get --sandbox-id sbx-xxx --show-credentials --output json +``` + +### `bl sandbox list` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------ | +| **Name** | `sandbox list` | +| **Description** | List Sandbox instances | +| **Authentication** | API Key | +| **Usage** | `bl sandbox list [--template-id ] [--sandbox-id ] [--state ] [--limit ]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--template-id ` | string | no | Filter by template ID | +| `--sandbox-id ` | string | no | Filter by sandbox ID | +| `--state ` | string | no | Filter by state, for example running or paused | +| `--limit ` | number | no | Maximum results (1-50) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. + +#### Examples + +```bash +bl sandbox list +``` + +```bash +bl sandbox list --state running --limit 20 +``` + +```bash +bl sandbox list --template-id tpl-xxx --output json +``` + +### `bl sandbox pause` + +| Field | Value | +| ------------------ | ------------------------------------ | +| **Name** | `sandbox pause` | +| **Description** | Pause a Sandbox instance | +| **Authentication** | API Key | +| **Usage** | `bl sandbox pause --sandbox-id ` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--sandbox-id ` | string | yes | Sandbox instance ID | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. + +#### Examples + +```bash +bl sandbox pause --sandbox-id sbx-xxx +``` + +```bash +bl sandbox pause --sandbox-id sbx-xxx --dry-run --output json +``` + +### `bl sandbox resume` + +| Field | Value | +| ------------------ | ----------------------------------------------------------------------------------------- | +| **Name** | `sandbox resume` | +| **Description** | Resume a Sandbox instance and return connection information | +| **Authentication** | API Key | +| **Usage** | `bl sandbox resume --sandbox-id [--instance-timeout ] [--show-credentials]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--sandbox-id ` | string | yes | Sandbox instance ID | +| `--body ` | string | no | JSON request body, inline or loaded from an @file path; explicit flags override it | +| `--instance-timeout ` | number | no | Sandbox lifetime after this operation (300-604800 seconds) | +| `--show-credentials` | switch | no | Print envd and traffic access tokens instead of redacting them | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. + +#### Examples + +```bash +bl sandbox resume --sandbox-id sbx-xxx --instance-timeout 3600 +``` + +```bash +bl sandbox resume --sandbox-id sbx-xxx --show-credentials --output json +``` + +### `bl sandbox template build-status` + +| Field | Value | +| ------------------ | --------------------------------------------------------------------- | +| **Name** | `sandbox template build-status` | +| **Description** | Get Sandbox template build status | +| **Authentication** | API Key | +| **Usage** | `bl sandbox template build-status --template-id --build-id ` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--template-id ` | string | yes | Sandbox template ID | +| `--build-id ` | string | yes | Template build ID | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. + +#### Examples + +```bash +bl sandbox template build-status --template-id tpl-xxx --build-id build-xxx +``` + +```bash +bl sandbox template build-status --template-id tpl-xxx --build-id build-xxx --output json +``` + +### `bl sandbox template create` + +| Field | Value | +| ------------------ | ----------------------------------------------------------------------------------------------------------------- | +| **Name** | `sandbox template create` | +| **Description** | Create a Sandbox template | +| **Authentication** | API Key | +| **Usage** | `bl sandbox template create (--name --cpu-count --memory-mb \| --body ) [flags]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--body ` | string | no | JSON request body, inline or loaded from an @file path; explicit flags override it | +| `--name ` | string | no | Template name | +| `--cpu-count ` | number | no | vCPU count | +| `--memory-mb ` | number | no | Memory in MB | +| `--from-image ` | string | no | Base image identifier | +| `--image-name ` | string | no | Base image display name | +| `--env ` | array | no | Template environment variable; repeat for multiple values | +| `--allow-out
` | array | no | Outbound allow-list entry; repeat for multiple values | +| `--deny-out
` | array | no | Outbound deny-list entry; repeat for multiple values | +| `--auto-pause-time ` | number | no | Automatically pause after 300-604800 seconds | +| `--max-running-time ` | number | no | Maximum running lifetime in seconds (300-604800) | +| `--description ` | string | no | Template description | +| `--tag ` | array | no | E2B tag; repeat for multiple values | +| `--alias ` | string | no | E2B template alias | +| `--async` | switch | no | Return the submitted templateID/buildID immediately without polling | +| `--poll-interval ` | number | no | Template build polling interval (default: 5 seconds) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. +- File mounts and other complete nested structures can be supplied through --body. +- By default the command waits for build status ready; --async returns the submitted build immediately. + +#### Examples + +```bash +bl sandbox template create --name python --cpu-count 1 --memory-mb 2048 +``` + +```bash +bl sandbox template create --body @template.json --async --output json +``` + +```bash +bl sandbox template create --name browser --cpu-count 4 --memory-mb 8192 --dry-run --output json +``` + +### `bl sandbox template delete` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------- | +| **Name** | `sandbox template delete` | +| **Description** | Delete a Sandbox template | +| **Authentication** | API Key | +| **Usage** | `bl sandbox template delete --template-id ` | +| **Risk** | `high` | +| **Risk message** | This permanently deletes the Sandbox template and cannot be undone. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--template-id ` | string | yes | Sandbox template ID | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. +- The server rejects deletion while running or paused instances still use the template. + +#### Examples + +```bash +bl sandbox template delete --template-id tpl-xxx --dry-run +``` + +```bash +# Only after explicit user confirmation: +bl sandbox template delete --template-id tpl-xxx --yes +``` + +### `bl sandbox template get` + +| Field | Value | +| ------------------ | -------------------------------------------- | +| **Name** | `sandbox template get` | +| **Description** | Get Sandbox template details | +| **Authentication** | API Key | +| **Usage** | `bl sandbox template get --template-id ` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--template-id ` | string | yes | Sandbox template ID | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. + +#### Examples + +```bash +bl sandbox template get --template-id tpl-xxx +``` + +```bash +bl sandbox template get --template-id tpl-xxx --output json +``` + +### `bl sandbox template list` + +| Field | Value | +| ------------------ | ------------------------------------------------------------ | +| **Name** | `sandbox template list` | +| **Description** | List Sandbox templates | +| **Authentication** | API Key | +| **Usage** | `bl sandbox template list [--limit ] [--cursor ]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--limit ` | number | no | Maximum results (1-100) | +| `--cursor ` | string | no | Server-side pagination cursor | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. +- The API response does not expose a next cursor, so automatic --all pagination is unavailable. + +#### Examples + +```bash +bl sandbox template list +``` + +```bash +bl sandbox template list --limit 100 --output json +``` + +### `bl sandbox template update` + +| Field | Value | +| ------------------ | ---------------------------------------------------------------------------------- | +| **Name** | `sandbox template update` | +| **Description** | Update a Sandbox template | +| **Authentication** | API Key | +| **Usage** | `bl sandbox template update --template-id (--body \| [fields])` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--template-id ` | string | yes | Sandbox template ID | +| `--body ` | string | no | JSON request body, inline or loaded from an @file path; explicit flags override it | +| `--name ` | string | no | Template name | +| `--cpu-count ` | number | no | vCPU count | +| `--memory-mb ` | number | no | Memory in MB | +| `--from-image ` | string | no | Base image identifier | +| `--image-name ` | string | no | Base image display name | +| `--env ` | array | no | Template environment variable; repeat for multiple values | +| `--allow-out
` | array | no | Outbound allow-list entry; repeat for multiple values | +| `--deny-out
` | array | no | Outbound deny-list entry; repeat for multiple values | +| `--auto-pause-time ` | number | no | Automatically pause after 300-604800 seconds | +| `--max-running-time ` | number | no | Maximum running lifetime in seconds (300-604800) | +| `--description ` | string | no | Template description | +| `--async` | switch | no | Return the submitted templateID/buildID immediately without polling | +| `--poll-interval ` | number | no | Template build polling interval (default: 5 seconds) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent. +- The workspace is resolved from --workspace-id, BAILIAN_WORKSPACE_ID, then config workspace_id. +- Sandbox is currently available in cn-beijing only and requires prior SLR authorization. +- Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field. +- Supplying envConfig or --env replaces the template's complete environment map. +- By default the command waits for build status ready; --async returns the submitted build immediately. + +#### Examples + +```bash +bl sandbox template update --template-id tpl-xxx --cpu-count 4 --memory-mb 8192 +``` + +```bash +bl sandbox template update --template-id tpl-xxx --body @template-update.json --async --output json +``` + +```bash +bl sandbox template update --template-id tpl-xxx --description updated --dry-run --output json +``` diff --git a/tools/generate-reference.ts b/tools/generate-reference.ts index 2565392..3ed2d38 100644 --- a/tools/generate-reference.ts +++ b/tools/generate-reference.ts @@ -54,6 +54,8 @@ const GROUP_OWNER_SKILL: Readonly> = { deploy: "bailian-finetune", // bailian-managed-agent — agents.yaml IaC "managed-agent": "bailian-managed-agent", + // bailian-sandbox — Sandbox control-plane lifecycle + sandbox: "bailian-sandbox", // everything else → bailian-cli (hub) }; diff --git a/tools/release/check.mjs b/tools/release/check.mjs index 8e67fa6..5d22d09 100644 --- a/tools/release/check.mjs +++ b/tools/release/check.mjs @@ -69,12 +69,14 @@ export async function runCheck(options = {}) { "skills/bailian-gen/SKILL.md", "skills/bailian-finetune/SKILL.md", "skills/bailian-managed-agent/SKILL.md", + "skills/bailian-sandbox/SKILL.md", "skills/bailian-web-search/SKILL.md", ]), "skills/bailian-cli/reference/", "skills/bailian-gen/reference/", "skills/bailian-finetune/reference/", "skills/bailian-managed-agent/reference/", + "skills/bailian-sandbox/reference/", ]); step("build bailian-cli");