From 332efb05b8e28636f68e9608c4dc566c1c4c977f Mon Sep 17 00:00:00 2001 From: chenanran555 Date: Mon, 7 Sep 2026 15:11:51 +0800 Subject: [PATCH 1/7] feat(sandbox): add Bailian Sandbox lifecycle commands --- .vite-hooks/pre-commit | 2 + AGENTS.md | 2 +- CHANGELOG.md | 10 + CHANGELOG.zh.md | 10 + docs/agents/skill-change.md | 1 + packages/cli/package.json | 2 +- packages/cli/src/commands.ts | 26 + .../cli/tests/skill-sandbox-reference.test.ts | 53 ++ .../commands/src/commands/sandbox/instance.ts | 423 +++++++++++++ .../commands/src/commands/sandbox/shared.ts | 231 +++++++ .../commands/src/commands/sandbox/template.ts | 586 ++++++++++++++++++ packages/commands/src/index.ts | 17 + .../commands/tests/e2e/sandbox.e2e.test.ts | 195 ++++++ packages/commands/tests/e2e/topic-routes.ts | 16 + packages/commands/tests/sandbox.test.ts | 500 +++++++++++++++ packages/core/src/client/endpoints.ts | 39 ++ packages/core/src/client/index.ts | 6 + packages/core/src/errors/api.ts | 5 +- packages/core/tests/index.test.ts | 69 +++ skills/bailian-cli/SKILL.md | 9 +- skills/bailian-protocol/SKILL.md | 3 +- skills/bailian-sandbox/SKILL.md | 58 ++ skills/bailian-sandbox/reference/index.md | 84 +++ skills/bailian-sandbox/reference/sandbox.md | 567 +++++++++++++++++ tools/generate-reference.ts | 2 + tools/release/check.mjs | 2 + 26 files changed, 2910 insertions(+), 8 deletions(-) create mode 100644 packages/cli/tests/skill-sandbox-reference.test.ts create mode 100644 packages/commands/src/commands/sandbox/instance.ts create mode 100644 packages/commands/src/commands/sandbox/shared.ts create mode 100644 packages/commands/src/commands/sandbox/template.ts create mode 100644 packages/commands/tests/e2e/sandbox.e2e.test.ts create mode 100644 packages/commands/tests/sandbox.test.ts create mode 100644 skills/bailian-sandbox/SKILL.md create mode 100644 skills/bailian-sandbox/reference/index.md create mode 100644 skills/bailian-sandbox/reference/sandbox.md diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit index 8998194bc..b1c222fb5 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 f24cb0240..46e7c3a52 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 881232187..58f533c00 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 dcfbba248..8e3dd68a5 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 7a6acdf2e..ca4a31f42 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 5c8ee4d5a..71cca929f 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 309b5d68d..8a774a01e 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 000000000..41d181ae9 --- /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 000000000..98e61423c --- /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 000000000..106c27038 --- /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 000000000..c23b146b4 --- /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 0eb21c415..b4b20322e 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 000000000..5929c7843 --- /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 18e6279c4..a12bea90d 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 000000000..728819499 --- /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 8717c942b..a9ed891fb 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 0a387f1ac..50ee01c08 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 2a682bbff..91fff6527 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 44eb765fe..914fb681c 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 7efd2e9ef..013e286f9 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 8e1bb5a70..9bb953259 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 000000000..15ec6b439 --- /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 000000000..41bfaeb00 --- /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 000000000..ebada9145 --- /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 2565392ae..3ed2d3829 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 8e67fa6fd..5d22d09b5 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"); From c0fca869c2a067640751e453e58a14be901a2709 Mon Sep 17 00:00:00 2001 From: chenanran555 Date: Mon, 7 Sep 2026 16:37:56 +0800 Subject: [PATCH 2/7] fix(sandbox): support shared --base-url configuration with workspace fallback --- .../commands/src/commands/sandbox/instance.ts | 26 +- .../commands/src/commands/sandbox/shared.ts | 41 ++- .../commands/src/commands/sandbox/template.ts | 41 ++- .../tests/e2e/sandbox-base-url.e2e.test.ts | 233 ++++++++++++++++++ .../commands/tests/e2e/sandbox.e2e.test.ts | 26 +- packages/commands/tests/sandbox.test.ts | 136 ++++++---- packages/core/src/auth/index.ts | 1 + packages/core/src/auth/resolver.ts | 17 +- packages/core/src/client/client.ts | 12 +- packages/core/src/client/endpoints.ts | 19 +- packages/core/src/client/index.ts | 2 + packages/core/tests/config-priority.test.ts | 19 ++ packages/core/tests/index.test.ts | 23 ++ packages/runtime/src/create-cli.ts | 4 +- packages/runtime/src/middleware.ts | 6 +- .../tests/auth-profile-fallback.test.ts | 22 ++ skills/bailian-sandbox/SKILL.md | 6 +- skills/bailian-sandbox/reference/sandbox.md | 165 +++++++------ 18 files changed, 604 insertions(+), 195 deletions(-) create mode 100644 packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts diff --git a/packages/commands/src/commands/sandbox/instance.ts b/packages/commands/src/commands/sandbox/instance.ts index 98e61423c..1893083b7 100644 --- a/packages/commands/src/commands/sandbox/instance.ts +++ b/packages/commands/src/commands/sandbox/instance.ts @@ -3,7 +3,6 @@ import { detectOutputFormat, defineCommand, ExitCode, - sandboxEndpoint, SANDBOX_PATHS, sandboxInstanceActionPath, sandboxInstancePath, @@ -20,7 +19,7 @@ import { readRequestBody, redactConnectionCredentials, redactRequestSecrets, - resolveWorkspaceId, + resolveSandboxEndpoint, SANDBOX_NOTES, setDefined, SHOW_CREDENTIALS_FLAG, @@ -222,14 +221,14 @@ export const sandboxCreate = defineCommand({ flags: CREATE_FLAGS, exampleArgs: [ "--template-id tpl-xxx --instance-timeout 3600", + "--template-id tpl-xxx --base-url https://workspace.cn-beijing.maas.aliyuncs.com", "--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 endpoint = resolveSandboxEndpoint(ctx, SANDBOX_PATHS.sandboxes); const body = await buildSandboxCreateBody(ctx.flags); if (ctx.settings.dryRun) { emitResult({ method: "POST", endpoint, request: redactRequestSecrets(body) }, format); @@ -265,8 +264,7 @@ export const sandboxList = defineCommand({ return undefined; }, async run(ctx) { - const workspaceId = resolveWorkspaceId(ctx); - const url = new URL(sandboxEndpoint(workspaceId, SANDBOX_PATHS.sandboxList)); + const url = new URL(resolveSandboxEndpoint(ctx, 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); @@ -314,8 +312,7 @@ export const sandboxGet = defineCommand({ 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 endpoint = resolveSandboxEndpoint(ctx, sandboxInstancePath(ctx.flags.sandboxId)); const response = await ctx.client.requestJson({ path: endpoint, method: "GET" }); emitSandboxObject(response, { format: detectOutputFormat(ctx.settings.output), @@ -343,9 +340,8 @@ function connectionCommand(action: "connect" | "resume") { notes: SANDBOX_NOTES, async run(ctx) { const format = detectOutputFormat(ctx.settings.output); - const workspaceId = resolveWorkspaceId(ctx); - const endpoint = sandboxEndpoint( - workspaceId, + const endpoint = resolveSandboxEndpoint( + ctx, sandboxInstanceActionPath(ctx.flags.sandboxId, action), ); const body = await buildConnectionBody(ctx.flags); @@ -379,9 +375,8 @@ export const sandboxPause = defineCommand({ notes: SANDBOX_NOTES, async run(ctx) { const format = detectOutputFormat(ctx.settings.output); - const workspaceId = resolveWorkspaceId(ctx); - const endpoint = sandboxEndpoint( - workspaceId, + const endpoint = resolveSandboxEndpoint( + ctx, sandboxInstanceActionPath(ctx.flags.sandboxId, "pause"), ); if (ctx.settings.dryRun) { @@ -410,8 +405,7 @@ export const sandboxDelete = defineCommand({ notes: SANDBOX_NOTES, async run(ctx) { const format = detectOutputFormat(ctx.settings.output); - const workspaceId = resolveWorkspaceId(ctx); - const endpoint = sandboxEndpoint(workspaceId, sandboxInstancePath(ctx.flags.sandboxId)); + const endpoint = resolveSandboxEndpoint(ctx, sandboxInstancePath(ctx.flags.sandboxId)); if (ctx.settings.dryRun) { emitResult({ method: "DELETE", endpoint, request: null }, format); return; diff --git a/packages/commands/src/commands/sandbox/shared.ts b/packages/commands/src/commands/sandbox/shared.ts index 106c27038..f03a65f7b 100644 --- a/packages/commands/src/commands/sandbox/shared.ts +++ b/packages/commands/src/commands/sandbox/shared.ts @@ -1,13 +1,21 @@ import { readFile } from "node:fs/promises"; -import { BailianError, ExitCode, type FlagsDef, type LocalizedText } from "bailian-cli-core"; +import { + BailianError, + ExitCode, + sandboxApiPath, + sandboxBaseUrl, + type Client, + 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)", + "en-US": "Workspace ID for the default Sandbox endpoint; optional with a configured base URL", + "zh-CN": "默认 Sandbox Endpoint 的 Workspace ID;已配置 Base URL 时可省略", }, }, } satisfies FlagsDef; @@ -62,8 +70,15 @@ export const SANDBOX_NOTES: LocalizedText[] = [ }, { "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 解析。", + "Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint.", + "zh-CN": + "Base URL 优先级:--base-url > DASHSCOPE_BASE_URL > 登录/Profile 的 base_url。CLI 取其 origin 并追加 /api/v1/agentstudio/sandbox;未配置时使用工作空间的 cn-beijing Endpoint。", + }, + { + "en-US": + "Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > config workspace_id.", + "zh-CN": + "未配置 Base URL 时必须提供 Workspace:--workspace-id > BAILIAN_WORKSPACE_ID > 配置项 workspace_id。", }, { "en-US": @@ -80,17 +95,23 @@ export const SANDBOX_NOTES: LocalizedText[] = [ export type JsonObject = Record; -export function resolveWorkspaceId(ctx: { +interface SandboxEndpointContext { flags: { workspaceId?: string }; settings: { workspaceId?: string }; - identity: { binName: string }; -}): string { + client: Pick; +} + +export function resolveSandboxEndpoint(ctx: SandboxEndpointContext, path: string): string { + return ctx.client.url(sandboxApiPath(path), () => sandboxBaseUrl(resolveWorkspaceId(ctx))); +} + +export function resolveWorkspaceId(ctx: Omit): string { const workspaceId = ctx.flags.workspaceId || ctx.settings.workspaceId; if (!workspaceId) { throw new BailianError( - "Workspace ID is required.", + "Workspace ID is required when no base URL is configured. / 未配置 Base URL 时必须提供 Workspace ID。", ExitCode.USAGE, - `Pass --workspace-id, set BAILIAN_WORKSPACE_ID, or configure: ${ctx.identity.binName} config set workspace_id `, + "Pass --workspace-id, set BAILIAN_WORKSPACE_ID, configure workspace_id, or set --base-url. / 请传入 --workspace-id、设置 BAILIAN_WORKSPACE_ID 或 workspace_id 配置,或通过 --base-url 指定地址。", ); } return workspaceId; diff --git a/packages/commands/src/commands/sandbox/template.ts b/packages/commands/src/commands/sandbox/template.ts index c23b146b4..b368f42ab 100644 --- a/packages/commands/src/commands/sandbox/template.ts +++ b/packages/commands/src/commands/sandbox/template.ts @@ -3,7 +3,7 @@ import { detectOutputFormat, defineCommand, ExitCode, - sandboxEndpoint, + sandboxApiPath, SANDBOX_PATHS, sandboxTemplateBuildStatusPath, sandboxTemplatePath, @@ -21,7 +21,7 @@ import { POLL_INTERVAL_FLAG, readRequestBody, redactRequestSecrets, - resolveWorkspaceId, + resolveSandboxEndpoint, SANDBOX_NOTES, setDefined, validateIntegerRange, @@ -312,7 +312,7 @@ function validatePollInterval(flags: { pollInterval?: number }): string | undefi async function emitTemplateMutationResult(options: { response: TemplateInfo; - workspaceId: string; + endpoint: string; client: Client; settings: Settings; async: boolean; @@ -333,10 +333,13 @@ async function emitTemplateMutationResult(options: { } return; } - const buildEndpoint = sandboxEndpoint( - options.workspaceId, - sandboxTemplateBuildStatusPath(options.response.templateID, options.response.buildID), - ); + // Poll the same origin that accepted the build, including custom gateways. + const buildEndpoint = new URL( + sandboxApiPath( + sandboxTemplateBuildStatusPath(options.response.templateID, options.response.buildID), + ), + options.endpoint, + ).toString(); const build = await waitForTemplateBuild( options.client, options.settings, @@ -372,8 +375,7 @@ export const sandboxTemplateCreate = defineCommand({ validate: validatePollInterval, async run(ctx) { const format = detectOutputFormat(ctx.settings.output); - const workspaceId = resolveWorkspaceId(ctx); - const endpoint = sandboxEndpoint(workspaceId, SANDBOX_PATHS.templateCreate); + const endpoint = resolveSandboxEndpoint(ctx, SANDBOX_PATHS.templateCreate); const body = await buildTemplateCreateBody(ctx.flags); if (ctx.settings.dryRun) { emitResult({ method: "POST", endpoint, request: redactRequestSecrets(body) }, format); @@ -386,7 +388,7 @@ export const sandboxTemplateCreate = defineCommand({ }); await emitTemplateMutationResult({ response, - workspaceId, + endpoint, client: ctx.client, settings: ctx.settings, async: ctx.flags.async, @@ -419,8 +421,7 @@ export const sandboxTemplateList = defineCommand({ return undefined; }, async run(ctx) { - const workspaceId = resolveWorkspaceId(ctx); - const url = new URL(sandboxEndpoint(workspaceId, SANDBOX_PATHS.templateList)); + const url = new URL(resolveSandboxEndpoint(ctx, 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({ @@ -466,8 +467,7 @@ export const sandboxTemplateGet = defineCommand({ 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 endpoint = resolveSandboxEndpoint(ctx, 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)); @@ -499,8 +499,7 @@ export const sandboxTemplateUpdate = defineCommand({ validate: validatePollInterval, async run(ctx) { const format = detectOutputFormat(ctx.settings.output); - const workspaceId = resolveWorkspaceId(ctx); - const endpoint = sandboxEndpoint(workspaceId, sandboxTemplatePath(ctx.flags.templateId)); + const endpoint = resolveSandboxEndpoint(ctx, sandboxTemplatePath(ctx.flags.templateId)); const body = await buildTemplateUpdateBody(ctx.flags); if (ctx.settings.dryRun) { emitResult({ method: "PUT", endpoint, request: redactRequestSecrets(body) }, format); @@ -513,7 +512,7 @@ export const sandboxTemplateUpdate = defineCommand({ }); await emitTemplateMutationResult({ response, - workspaceId, + endpoint, client: ctx.client, settings: ctx.settings, async: ctx.flags.async, @@ -536,9 +535,8 @@ export const sandboxTemplateBuildStatus = defineCommand({ ], notes: SANDBOX_NOTES, async run(ctx) { - const workspaceId = resolveWorkspaceId(ctx); - const endpoint = sandboxEndpoint( - workspaceId, + const endpoint = resolveSandboxEndpoint( + ctx, sandboxTemplateBuildStatusPath(ctx.flags.templateId, ctx.flags.buildId), ); const response = await ctx.client.requestJson({ @@ -573,8 +571,7 @@ export const sandboxTemplateDelete = defineCommand({ ], async run(ctx) { const format = detectOutputFormat(ctx.settings.output); - const workspaceId = resolveWorkspaceId(ctx); - const endpoint = sandboxEndpoint(workspaceId, sandboxTemplatePath(ctx.flags.templateId)); + const endpoint = resolveSandboxEndpoint(ctx, sandboxTemplatePath(ctx.flags.templateId)); if (ctx.settings.dryRun) { emitResult({ method: "DELETE", endpoint, request: null }, format); return; diff --git a/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts b/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts new file mode 100644 index 000000000..ab0f5924e --- /dev/null +++ b/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts @@ -0,0 +1,233 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { SANDBOX_ROUTES } from "./topic-routes.ts"; + +const ROUTES = { ...SANDBOX_ROUTES, "auth login": "authLogin" } as const; +const tempDirectories: string[] = []; +const servers: Server[] = []; +const API_PATH = "/api/v1/agentstudio/sandbox"; +const PROFILE_CONFIG = { + api_key: "sk-default-test", + base_url: "https://default.example.test", + active_config: "sandbox-test", + "sandbox-test": { + api_key: "sk-profile-test", + base_url: "https://profile.example.test", + }, +}; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + ), + ); + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeConfigEnv(config: Record = {}): NodeJS.ProcessEnv { + const directory = mkdtempSync(join(tmpdir(), "bl-sandbox-base-url-")); + tempDirectories.push(directory); + writeFileSync(join(directory, "config.json"), JSON.stringify(config)); + return { + BAILIAN_CONFIG_DIR: directory, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + BAILIAN_WORKSPACE_ID: "", + }; +} + +describe("e2e: Sandbox shared base URL resolution", () => { + test.each([ + { + name: "flag overrides env and profile, normalizing paths and preserving the port", + args: ["--base-url", "https://flag.example.test:8443/api/v1/agentstudio/?ignored=1#fragment"], + envBaseUrl: "https://env.example.test", + config: PROFILE_CONFIG, + expectedOrigin: "https://flag.example.test:8443", + }, + { + name: "env overrides the active profile", + args: [], + envBaseUrl: "https://env.example.test/compatible-mode/v1/", + config: PROFILE_CONFIG, + expectedOrigin: "https://env.example.test", + }, + { + name: "the active profile supplies its configured origin", + args: [], + envBaseUrl: "", + config: PROFILE_CONFIG, + expectedOrigin: "https://profile.example.test", + }, + { + name: "an explicit profile selection uses that profile", + args: ["--config", "default"], + envBaseUrl: "", + config: PROFILE_CONFIG, + expectedOrigin: "https://default.example.test", + }, + { + name: "an explicit default model origin still overrides workspace inference", + args: ["--base-url", "https://dashscope.aliyuncs.com", "--workspace-id", "unused"], + envBaseUrl: "", + config: {}, + expectedOrigin: "https://dashscope.aliyuncs.com", + }, + { + name: "without any base URL the workspace endpoint is preserved", + args: ["--workspace-id", "ws-test"], + envBaseUrl: "", + config: {}, + expectedOrigin: "https://ws-test.cn-beijing.maas.aliyuncs.com", + }, + ])("$name", async ({ args, envBaseUrl, config, expectedOrigin }) => { + const result = await runCommandE2e( + ROUTES, + ["sandbox", "create", "--template-id", "tpl-test", ...args, "--dry-run", "--output", "json"], + { ...makeConfigEnv(config), DASHSCOPE_BASE_URL: envBaseUrl }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseStdoutJson(result.stdout)).toMatchObject({ + endpoint: `${expectedOrigin}${API_PATH}/sandboxes`, + }); + }); + + test("an unconfigured URL still requires a workspace, while malformed URLs fail validation", async () => { + const env = makeConfigEnv(); + for (const args of [[], ["--base-url", "invalid"], ["--base-url", "file:///tmp/gateway"]]) { + const result = await runCommandE2e( + ROUTES, + [ + "sandbox", + "create", + "--template-id", + "tpl-test", + ...args, + "--dry-run", + "--output", + "json", + ], + env, + ); + expect(result.exitCode).toBe(2); + expect(JSON.parse(result.stderr).error.message).toMatch( + /Workspace ID|Invalid model base URL/, + ); + } + }); + + test("auth login persists a base URL that Sandbox uses without a workspace flag", async () => { + const env = makeConfigEnv(); + const login = await runCommandE2e( + ROUTES, + [ + "auth", + "login", + "--config", + "sandbox-test", + "--api-key", + "sk-login-test", + "--base-url", + "https://login.example.test/api/v1/agentstudio/sandbox/", + ], + env, + ); + expect(login.exitCode, login.stderr).toBe(0); + const stored = JSON.parse(readFileSync(join(env.BAILIAN_CONFIG_DIR!, "config.json"), "utf8")); + expect(stored.active_config).toBe("sandbox-test"); + expect(stored["sandbox-test"].base_url).toBe("https://login.example.test"); + + const result = await runCommandE2e( + ROUTES, + ["sandbox", "pause", "--sandbox-id", "sbx-test", "--dry-run", "--output", "json"], + env, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseStdoutJson(result.stdout)).toMatchObject({ + endpoint: `https://login.example.test${API_PATH}/sandboxes/sbx-test/pause`, + }); + }); +}); + +describe("e2e: Sandbox custom gateway transport", () => { + test.each([ + { + action: "create", + args: ["--name", "python", "--cpu-count", "1", "--memory-mb", "2048"], + method: "POST", + suffix: "/v3/templates", + }, + { + action: "update", + args: ["--template-id", "template/a", "--description", "updated"], + method: "PUT", + suffix: "/templates/template%2Fa", + }, + ])( + "template $action and build polling use the same gateway and Bearer key", + async ({ action, args, method, suffix }) => { + const received: { + method?: string; + path?: string; + authorization?: string; + e2bKey?: string | string[]; + }[] = []; + const server = createServer((request, response) => { + request.resume(); + received.push({ + method: request.method, + path: request.url, + authorization: request.headers.authorization, + e2bKey: request.headers["x-api-key"], + }); + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + templateID: "template/a", + buildID: "build a", + status: request.method === "GET" ? "ready" : "building", + }), + ); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a local TCP server."); + const origin = `http://127.0.0.1:${address.port}`; + const result = await runCommandE2e( + ROUTES, + ["sandbox", "template", action, ...args, "--poll-interval", "1", "--output", "json"], + makeConfigEnv({ api_key: "sk-gateway-test", base_url: origin }), + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(received).toEqual([ + { + method, + path: `${API_PATH}${suffix}`, + authorization: "Bearer sk-gateway-test", + e2bKey: undefined, + }, + { + method: "GET", + path: `${API_PATH}/templates/template%2Fa/builds/build%20a/status`, + authorization: "Bearer sk-gateway-test", + e2bKey: undefined, + }, + ]); + expect(parseStdoutJson(result.stdout)).toMatchObject({ build: { status: "ready" } }); + }, + ); +}); diff --git a/packages/commands/tests/e2e/sandbox.e2e.test.ts b/packages/commands/tests/e2e/sandbox.e2e.test.ts index 5929c7843..0e3b7e342 100644 --- a/packages/commands/tests/e2e/sandbox.e2e.test.ts +++ b/packages/commands/tests/e2e/sandbox.e2e.test.ts @@ -1,8 +1,30 @@ -import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCommandE2e, runCommandHelp } from "./helpers.ts"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test"; +import { parseStdoutJson, runCommandE2e as runBaseCommandE2e, runCommandHelp } from "./helpers.ts"; import { SANDBOX_ROUTES } from "./topic-routes.ts"; const AUTH_ARGS = ["--api-key", "sk-sandbox-e2e", "--workspace-id", "ws-e2e"]; +let configDirectory: string; + +beforeEach(() => { + configDirectory = mkdtempSync(join(tmpdir(), "bl-sandbox-e2e-")); + writeFileSync(join(configDirectory, "config.json"), "{}"); +}); + +afterEach(() => { + rmSync(configDirectory, { recursive: true, force: true }); +}); + +function runCommandE2e(routes: typeof SANDBOX_ROUTES, args: string[]) { + return runBaseCommandE2e(routes, args, { + BAILIAN_CONFIG_DIR: configDirectory, + DASHSCOPE_BASE_URL: "", + DASHSCOPE_API_KEY: "", + BAILIAN_WORKSPACE_ID: "", + }); +} describe("e2e: Sandbox command discovery", () => { test.each([ diff --git a/packages/commands/tests/sandbox.test.ts b/packages/commands/tests/sandbox.test.ts index 728819499..2c7f81386 100644 --- a/packages/commands/tests/sandbox.test.ts +++ b/packages/commands/tests/sandbox.test.ts @@ -1,7 +1,7 @@ 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 { BailianError, Client, ExitCode, type AnyCommand, type Settings } from "bailian-cli-core"; import { afterEach, describe, expect, test, vi } from "vite-plus/test"; import { buildSandboxCreateBody, @@ -51,10 +51,26 @@ interface RecordedRequest { timeout?: number; } +function createUrlResolver(baseUrl?: string): Client["url"] { + const client = new Client({ + identity: { + binName: "bl", + version: "test", + npmPackage: "bailian-cli", + clientName: "bailian-cli", + }, + settings: SETTINGS, + baseUrl: baseUrl ?? "https://dashscope.aliyuncs.com", + baseUrlIsDefault: baseUrl === undefined, + }); + return client.url.bind(client); +} + async function runCommand( command: AnyCommand, flags: Record, response: unknown, + baseUrl?: string, ): Promise { vi.spyOn(process.stdout, "write").mockImplementation(() => true); const requestJson = vi.fn(async (_request: RecordedRequest) => response); @@ -71,7 +87,7 @@ async function runCommand( }, settings: SETTINGS, flags, - client: { requestJson, request }, + client: { requestJson, request, url: createUrlResolver(baseUrl) }, } as never); const recorded = requestJson.mock.calls[0]?.[0] ?? request.mock.calls[0]?.[0]; @@ -336,6 +352,20 @@ describe("Sandbox command transport", () => { "maps $name to its documented method and path", async ({ command, flags, response, request }) => { expect(await runCommand(command, flags, response)).toEqual(request); + expect( + await runCommand( + command, + { ...flags, workspaceId: undefined }, + response, + "https://gateway.example.test", + ), + ).toEqual({ + ...request, + path: request.path.replace( + "https://ws-test.cn-beijing.maas.aliyuncs.com", + "https://gateway.example.test", + ), + }); }, ); @@ -357,6 +387,7 @@ describe("Sandbox command transport", () => { showCredentials: testCase.showCredentials, }, client: { + url: createUrlResolver(), requestJson: async () => ({ sandboxID: "sandbox-test", envdAccessToken: "envd-secret", @@ -435,62 +466,65 @@ describe("Sandbox template build polling", () => { memoryMb: 2048, async: true, }, - client: { requestJson }, + client: { requestJson, url: createUrlResolver() }, } 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", + test.each([undefined, "https://gateway.example.test"])( + "template creation polls the selected origin %s and emits the final envelope", + async (baseUrl) => { + let stdout = ""; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; }); - 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); + 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, url: createUrlResolver(baseUrl) }, + } 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", - }, - }); - }); + expect(requestJson).toHaveBeenCalledTimes(2); + expect(requestJson.mock.calls[1]?.[0]).toMatchObject({ + method: "GET", + path: `${baseUrl ?? "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 () => { diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index 00acf13e0..3d46316ee 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -4,6 +4,7 @@ export { resolveOpenApi, describeAuthState, resolveModelBaseUrl, + resolveModelBaseUrlState, } from "./resolver.ts"; export { makeAuthStore, type AuthStore, type AuthPersistPatch } from "./store.ts"; export type { diff --git a/packages/core/src/auth/resolver.ts b/packages/core/src/auth/resolver.ts index 08c9602a1..faf2241f2 100644 --- a/packages/core/src/auth/resolver.ts +++ b/packages/core/src/auth/resolver.ts @@ -8,11 +8,22 @@ import { ExitCode } from "../errors/codes.ts"; // Resolve the credential for a command's declared domain (model = api-key, // console = access-token), by priority, or throw. Read only from sources. +/** Preserve whether the shared base URL chain selected a configured origin or its default. */ +export function resolveModelBaseUrlState( + sources: ResolutionSources, + fallback: string = REGIONS.cn, +): { baseUrl: string; baseUrlIsDefault: boolean } { + const configuredBaseUrl = + sources.flags.baseUrl || sources.env.DASHSCOPE_BASE_URL || sources.file.base_url; + return { + baseUrl: normalizeModelBaseUrl(configuredBaseUrl || fallback), + baseUrlIsDefault: !configuredBaseUrl, + }; +} + /** Model-domain baseUrl(flag > env > config file > fallback);无需 key 也可解析。 */ export function resolveModelBaseUrl(s: ResolutionSources, fallback: string = REGIONS.cn): string { - return normalizeModelBaseUrl( - s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || fallback, - ); + return resolveModelBaseUrlState(s, fallback).baseUrl; } /** diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 785e2c1a4..067c52319 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -23,6 +23,8 @@ export interface ClientDeps { settings: Settings; /** Model 域 base URL(凭证无关链解析,resolveModelBaseUrl;有 apiCred 时两者一致)。 */ baseUrl: string; + /** True only when the shared URL chain used its default; explicit origins win over service defaults. */ + baseUrlIsDefault?: boolean; apiCred?: ApiKeyCredential; consoleCred?: ConsoleCredential; openApiCred?: OpenApiCredential; @@ -108,9 +110,13 @@ export class Client { return this.deps.apiCred; } - /** Full URL for a model-domain {@link path}; build request/display URLs only through this. */ - url(path: string): string { - return this.baseUrl + path; + /** + * Full URL for a model-domain path. Services may supply a lazy default origin, + * evaluated only when no flag/env/profile base URL was configured. + */ + url(path: string, defaultBaseUrl?: () => string): string { + const baseUrl = this.deps.baseUrlIsDefault && defaultBaseUrl ? defaultBaseUrl() : this.baseUrl; + return baseUrl + path; } private toOpts({ path, ...rest }: ClientRequestOpts): RequestOpts { diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index a9ed891fb..0a57d94f3 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -228,14 +228,19 @@ export function ragEndpoint(workspaceId: string, path: string): string { // ---- 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. - */ +/** Default Sandbox origin when no shared base URL was configured (cn-beijing only). */ +export function sandboxBaseUrl(workspaceId: string): string { + return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com`; +} + +/** Sandbox service prefix, appended to the selected origin just like AgentStudio SDK paths. */ +export function sandboxApiPath(path: string): string { + return `/api/v1/agentstudio/sandbox${path}`; +} + +/** Build the default workspace-scoped absolute Sandbox control-plane URL. */ export function sandboxEndpoint(workspaceId: string, path: string): string { - return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox${path}`; + return sandboxBaseUrl(workspaceId) + sandboxApiPath(path); } export const SANDBOX_PATHS = { diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 50ee01c08..45b97816a 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -19,6 +19,8 @@ export { ragEndpoint, RAG_PATHS, sandboxEndpoint, + sandboxBaseUrl, + sandboxApiPath, SANDBOX_PATHS, sandboxInstancePath, sandboxInstanceActionPath, diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index 9d5ce1b58..96375fb69 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -5,6 +5,7 @@ import { resolveApiKey, resolveConsole, resolveModelBaseUrl, + resolveModelBaseUrlState, resolveOpenApi, } from "../src/auth/resolver.ts"; import { getModelProfilePreset } from "../src/config/profile-presets.ts"; @@ -98,6 +99,24 @@ test("baseUrl:非法 flag/env 在 resolver 边界报 usage error", () => { ).toThrow(/Invalid model base URL/); }); +test("baseUrl state distinguishes an implicit default from an explicitly configured default origin", () => { + const defaultOrigin = "https://dashscope.aliyuncs.com"; + expect(resolveModelBaseUrlState(src({}))).toEqual({ + baseUrl: defaultOrigin, + baseUrlIsDefault: true, + }); + for (const sources of [ + src({ flags: { baseUrl: defaultOrigin } }), + src({ env: { DASHSCOPE_BASE_URL: defaultOrigin } }), + src({ file: { base_url: defaultOrigin } }), + ]) { + expect(resolveModelBaseUrlState(sources)).toEqual({ + baseUrl: defaultOrigin, + baseUrlIsDefault: false, + }); + } +}); + test("命名 config 仍保持 flag > env > selected file", () => { const env = { DASHSCOPE_BASE_URL: "https://env.example.com", diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index 914fb681c..ba3a73a8a 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -142,6 +142,29 @@ test("Client sends Sandbox REST requests with Bearer auth and no E2B API key", a } }); +test("Client.url evaluates a service default only when the shared origin was not configured", () => { + const deps = testDeps(); + const defaultClient = new Client({ + ...deps, + baseUrl: "https://dashscope.aliyuncs.com", + baseUrlIsDefault: true, + }); + const configuredClient = new Client({ + ...deps, + baseUrl: "https://dashscope.aliyuncs.com", + baseUrlIsDefault: false, + }); + expect(defaultClient.url("/service")).toBe("https://dashscope.aliyuncs.com/service"); + expect(defaultClient.url("/service", () => "https://workspace.example.test")).toBe( + "https://workspace.example.test/service", + ); + expect( + configuredClient.url("/service", () => { + throw new Error("A configured origin must not require a workspace default."); + }), + ).toBe("https://dashscope.aliyuncs.com/service"); +}); + 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/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 1cf98e3c1..2ed40b47d 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -29,7 +29,7 @@ import { buildSources, buildSettings, describeAuthState, - resolveModelBaseUrl, + resolveModelBaseUrlState, makeConfigStore, makeAuthStore, flushTelemetry, @@ -238,7 +238,7 @@ export function createCli(commands: Record, opts: CliOptions client: new Client({ identity, settings, - baseUrl: resolveModelBaseUrl(sources), + ...resolveModelBaseUrlState(sources), }), }; await runMiddleware(ctx); diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index eed4da4b0..660916912 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -19,7 +19,7 @@ import { resolveApiKey, resolveConsole, resolveOpenApi, - resolveModelBaseUrl, + resolveModelBaseUrlState, selectApiKeyResolutionSources, trackCommandExecution, } from "bailian-cli-core"; @@ -128,7 +128,7 @@ export const authStage: Middleware = async (ctx, next) => { const base = { identity: ctx.identity, settings, - baseUrl: resolveModelBaseUrl(sources), + ...resolveModelBaseUrlState(sources), }; if (command.auth === "apiKey") { const capability = ctx.path.join("."); @@ -145,7 +145,7 @@ export const authStage: Middleware = async (ctx, next) => { } ctx.client = new Client({ ...base, - baseUrl: resolveModelBaseUrl(apiSources), + ...resolveModelBaseUrlState(apiSources), apiCred: cred, }); if (cred) maybeShowStatusBar(settings, cred.token, cred); diff --git a/packages/runtime/tests/auth-profile-fallback.test.ts b/packages/runtime/tests/auth-profile-fallback.test.ts index 11221f29e..d102b3a15 100644 --- a/packages/runtime/tests/auth-profile-fallback.test.ts +++ b/packages/runtime/tests/auth-profile-fallback.test.ts @@ -81,6 +81,28 @@ async function runAuth(context: RunContext): Promise { await authStage(context, async () => {}); } +test.each([undefined, "https://default.example.com", "https://dashscope.aliyuncs.com"])( + "service defaults follow the effective profile base URL after capability fallback: %s", + async (defaultBaseUrl) => { + useTempConfigDir(); + await writeConfigFile({ api_key: "sk-default", base_url: defaultBaseUrl }); + await writeConfigFile( + { + api_key: "sk-plan", + base_url: "https://plan.example.com", + api_key_capabilities: ["text.chat"], + }, + "company-plan", + ); + const context = makeContext(["sandbox", "list"]); + await runAuth(context); + expect(context.client.url("/service", () => "https://workspace.example.test")).toBe( + `${defaultBaseUrl ?? "https://workspace.example.test"}/service`, + ); + expect(context.client.exportApiCredential()?.token).toBe("sk-default"); + }, +); + async function captureStderr(operation: () => Promise): Promise<{ output: string; error?: unknown; diff --git a/skills/bailian-sandbox/SKILL.md b/skills/bailian-sandbox/SKILL.md index 15ec6b439..6360c80f0 100644 --- a/skills/bailian-sandbox/SKILL.md +++ b/skills/bailian-sandbox/SKILL.md @@ -19,8 +19,8 @@ Before running `bl`, read the shared [bailian-protocol](../bailian-protocol/SKIL ## 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. +- Resolve Base URL through the same CLI chain as Managed Agent: `--base-url` > `DASHSCOPE_BASE_URL` > login/profile `base_url`. Use an origin such as `https://workspace.cn-beijing.maas.aliyuncs.com`; the CLI strips URL paths/query/fragment and appends `/api/v1/agentstudio/sandbox`. The saved API Key is reused. Profile capability fallback follows the shared protocol for both the key and Base URL. +- If no Base URL is configured, resolve the workspace from `--workspace-id`, then `BAILIAN_WORKSPACE_ID`, then configured `workspace_id`, and use `https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox`. With a configured Base URL, the workspace flag is optional. The service currently supports `cn-beijing` and requires prior Sandbox SLR authorization. - 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 @@ -36,6 +36,8 @@ Before running `bl`, read the shared [bailian-protocol](../bailian-protocol/SKIL 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. +To save the origin in an isolated Profile, use `bl auth login --config sandbox --api-key --base-url `. For a one-command override, use `bl sandbox list --base-url `. Custom gateways also apply to template build-status polling. + ## 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. diff --git a/skills/bailian-sandbox/reference/sandbox.md b/skills/bailian-sandbox/reference/sandbox.md index ebada9145..953e500e8 100644 --- a/skills/bailian-sandbox/reference/sandbox.md +++ b/skills/bailian-sandbox/reference/sandbox.md @@ -38,7 +38,7 @@ Index: [index.md](index.md) | Flag | Type | Required | Description | | ------------------------------ | ------ | -------- | ---------------------------------------------------------------------------------- | -| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | | `--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) | @@ -49,7 +49,8 @@ Index: [index.md](index.md) #### 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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. @@ -76,7 +77,7 @@ bl sandbox connect --sandbox-id sbx-xxx --show-credentials --output json | Flag | Type | Required | Description | | -------------------------------- | ------- | -------- | ---------------------------------------------------------------------------------- | -| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | | `--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) | @@ -95,7 +96,8 @@ bl sandbox connect --sandbox-id sbx-xxx --show-credentials --output json #### 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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. @@ -105,6 +107,10 @@ bl sandbox connect --sandbox-id sbx-xxx --show-credentials --output json bl sandbox create --template-id tpl-xxx --instance-timeout 3600 ``` +```bash +bl sandbox create --template-id tpl-xxx --base-url https://workspace.cn-beijing.maas.aliyuncs.com +``` + ```bash bl sandbox create --body @sandbox.json --dry-run --output json ``` @@ -128,18 +134,19 @@ bl sandbox create --template-id tpl-xxx --show-credentials --output json #### 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 | +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | +| `--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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. @@ -165,18 +172,19 @@ bl sandbox delete --sandbox-id sbx-xxx --yes #### 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 | +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | +| `--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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. @@ -201,20 +209,21 @@ bl sandbox get --sandbox-id sbx-xxx --show-credentials --output json #### 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 | +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | +| `--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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. @@ -243,17 +252,18 @@ bl sandbox list --template-id tpl-xxx --output json #### 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 | +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | +| `--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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. @@ -280,7 +290,7 @@ bl sandbox pause --sandbox-id sbx-xxx --dry-run --output json | Flag | Type | Required | Description | | ------------------------------ | ------ | -------- | ---------------------------------------------------------------------------------- | -| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | | `--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) | @@ -291,7 +301,8 @@ bl sandbox pause --sandbox-id sbx-xxx --dry-run --output json #### 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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. @@ -316,18 +327,19 @@ bl sandbox resume --sandbox-id sbx-xxx --show-credentials --output json #### 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 | +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | +| `--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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. @@ -354,7 +366,7 @@ bl sandbox template build-status --template-id tpl-xxx --build-id build-xxx --ou | Flag | Type | Required | Description | | ------------------------------ | ------ | -------- | ---------------------------------------------------------------------------------- | -| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | | `--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 | @@ -377,7 +389,8 @@ bl sandbox template build-status --template-id tpl-xxx --build-id build-xxx --ou #### 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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. @@ -412,18 +425,19 @@ bl sandbox template create --name browser --cpu-count 4 --memory-mb 8192 --dry-r #### 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 | +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | +| `--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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. @@ -450,17 +464,18 @@ bl sandbox template delete --template-id tpl-xxx --yes #### 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 | +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | +| `--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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. @@ -485,18 +500,19 @@ bl sandbox template get --template-id tpl-xxx --output json #### 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 | +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | +| `--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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. @@ -524,7 +540,7 @@ bl sandbox template list --limit 100 --output json | Flag | Type | Required | Description | | ------------------------------ | ------ | -------- | ---------------------------------------------------------------------------------- | -| `--workspace-id ` | string | no | Workspace ID for the Sandbox endpoint (or set BAILIAN_WORKSPACE_ID) | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | | `--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 | @@ -546,7 +562,8 @@ bl sandbox template list --limit 100 --output json #### 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. +- Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint. +- Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. From f4e479ce802cad48956d46e719aa0c407ef2eb0a Mon Sep 17 00:00:00 2001 From: chenanran555 Date: Wed, 9 Sep 2026 16:45:37 +0800 Subject: [PATCH 3/7] feat(sandbox): add official image presets and template image selection --- packages/cli/src/commands.ts | 2 + .../commands/src/commands/sandbox/images.ts | 88 ++++++++++++ .../commands/src/commands/sandbox/template.ts | 31 ++++ packages/commands/src/index.ts | 1 + .../commands/tests/e2e/sandbox.e2e.test.ts | 117 +++++++++++++++ packages/commands/tests/e2e/topic-routes.ts | 1 + .../commands/tests/sandbox-images.test.ts | 134 +++++++++++++++++ skills/bailian-sandbox/SKILL.md | 21 +-- skills/bailian-sandbox/reference/index.md | 7 +- skills/bailian-sandbox/reference/sandbox.md | 135 ++++++++++++------ 10 files changed, 484 insertions(+), 53 deletions(-) create mode 100644 packages/commands/src/commands/sandbox/images.ts create mode 100644 packages/commands/tests/sandbox-images.test.ts diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 87355ca80..5551caf45 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -207,6 +207,7 @@ import { managedAgentFileDownload, managedAgentFileDelete, sandboxCreate, + sandboxOfficialImages, sandboxList, sandboxGet, sandboxConnect, @@ -437,6 +438,7 @@ export const commands: Record = { "managed-agent file download": managedAgentFileDownload, "managed-agent file delete": managedAgentFileDelete, "sandbox create": sandboxCreate, + "sandbox official-images": sandboxOfficialImages, "sandbox list": sandboxList, "sandbox get": sandboxGet, "sandbox connect": sandboxConnect, diff --git a/packages/commands/src/commands/sandbox/images.ts b/packages/commands/src/commands/sandbox/images.ts new file mode 100644 index 000000000..c40239c2e --- /dev/null +++ b/packages/commands/src/commands/sandbox/images.ts @@ -0,0 +1,88 @@ +import { defineCommand, detectOutputFormat, UsageError } from "bailian-cli-core"; +import { emitBare, emitResult } from "bailian-cli-runtime"; + +/** Pinned CLI presets, not a live catalog or a guarantee of regional availability. */ +export const SANDBOX_IMAGES = [ + { + id: "code-interpreter", + imageName: "代码解释器", + imageUrl: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/code-interpreter-v1:v0.0.44", + icon: "https://img.alicdn.com/imgextra/i2/O1CN01iX8RH9ckFvC093x2_!!6000000000905-2-tps-72-72.png", + description: { + "en-US": "Python / Node.js runtimes with common data-processing libraries", + "zh-CN": "Python / Node.js 运行时+常用数据处理库", + }, + }, + { + id: "browser", + imageName: "浏览器", + imageUrl: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44", + icon: "https://img.alicdn.com/imgextra/i4/O1CN01ShCbPEunrRI093x2_!!6000000000155-2-tps-72-72.png", + description: { + "en-US": "Chromium and a visual desktop for clicking, filling forms, and screenshots", + "zh-CN": "Chromium +可视化桌面,支持点击/ 填表/截图", + }, + }, + { + id: "all-in-one", + imageName: "全能型", + imageUrl: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/all-in-one:v0.0.44", + icon: "https://img.alicdn.com/imgextra/i1/O1CN01smhklUahsyE093x2_!!6000000006657-2-tps-72-72.png", + description: { + "en-US": "Code execution and browser capabilities together", + "zh-CN": "代码执行+浏览器双能力", + }, + }, +] as const; + +export const SANDBOX_IMAGE_CHOICES = SANDBOX_IMAGES.flatMap((image) => [image.id, image.imageName]); + +export const SANDBOX_IMAGE_NOTES = SANDBOX_IMAGES.map((image) => ({ + "en-US": `${image.id} (${image.imageName}): ${image.description["en-US"]}. ${image.imageUrl}`, + "zh-CN": `${image.id}(${image.imageName}):${image.description["zh-CN"]}。${image.imageUrl}`, +})); + +export function resolveSandboxImage(selector: string) { + const image = SANDBOX_IMAGES.find( + (candidate) => candidate.id === selector || candidate.imageName === selector, + ); + if (!image) { + throw new UsageError( + `Unknown built-in image / 未知内置镜像: ${selector}. ${SANDBOX_IMAGE_CHOICES.join(", ")}`, + ); + } + return image; +} + +export const sandboxOfficialImages = defineCommand({ + description: { + "en-US": "List the built-in Sandbox base images (offline)", + "zh-CN": "列出 CLI 内置的 Sandbox 基础镜像(离线)", + }, + auth: "none", + exampleArgs: ["", "--output json", "--quiet"], + notes: [ + { + "en-US": + "Use a preset ID or Chinese name with template create/update --image. These are pinned cn-beijing images; other environments may differ. Use --from-image for a custom image.", + "zh-CN": + "在 template create/update 中通过 --image 传入预设 ID 或中文名。这些是固定版本的 cn-beijing 镜像,其他环境可能不同;自定义镜像使用 --from-image。", + }, + ...SANDBOX_IMAGE_NOTES, + ], + async run(ctx) { + if (ctx.settings.quiet) { + for (const image of SANDBOX_IMAGES) emitBare(image.id); + return; + } + if (detectOutputFormat(ctx.settings.output) === "json") { + emitResult(SANDBOX_IMAGES, "json"); + return; + } + for (const image of SANDBOX_IMAGES) { + emitBare(`${image.id} (${image.imageName})`); + emitBare(` ${image.imageUrl}`); + emitBare(` ${image.description["en-US"]} / ${image.description["zh-CN"]}`); + } + }, +}); diff --git a/packages/commands/src/commands/sandbox/template.ts b/packages/commands/src/commands/sandbox/template.ts index b368f42ab..300cdfb07 100644 --- a/packages/commands/src/commands/sandbox/template.ts +++ b/packages/commands/src/commands/sandbox/template.ts @@ -13,6 +13,7 @@ import { type Settings, } from "bailian-cli-core"; import { createSpinner, emitBare, emitResult, formatTable } from "bailian-cli-runtime"; +import { resolveSandboxImage, SANDBOX_IMAGE_CHOICES, SANDBOX_IMAGE_NOTES } from "./images.ts"; import { BODY_FLAG, displayValue, @@ -69,6 +70,15 @@ const TEMPLATE_ID_FLAG = { } satisfies FlagsDef; const TEMPLATE_MUTATION_FIELDS = { + image: { + type: "string", + valueHint: "", + choices: SANDBOX_IMAGE_CHOICES, + description: { + "en-US": "Built-in image ID or Chinese name; fills fromImage and imageName", + "zh-CN": "内置镜像 ID 或中文名;自动填写 fromImage 和 imageName", + }, + }, name: { type: "string", valueHint: "", @@ -198,6 +208,11 @@ const BUILD_STATUS_FLAGS = { } satisfies FlagsDef; function applyTemplateMutationFlags(body: JsonObject, flags: CreateFlags | UpdateFlags): void { + if (flags.image !== undefined) { + const image = resolveSandboxImage(flags.image); + body.fromImage = image.imageUrl; + body.imageName = image.imageName; + } setDefined(body, "name", flags.name); setDefined(body, "cpuCount", flags.cpuCount); setDefined(body, "memoryMB", flags.memoryMb); @@ -356,12 +371,20 @@ export const sandboxTemplateCreate = defineCommand({ usageArgs: "(--name --cpu-count --memory-mb | --body ) [flags]", flags: CREATE_FLAGS, exampleArgs: [ + "--name browser --image browser --cpu-count 1 --memory-mb 2048", "--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, + ...SANDBOX_IMAGE_NOTES, + { + "en-US": + "--image overrides body fromImage/imageName; explicit --from-image/--image-name override the corresponding preset fields. Without --image, image behavior is unchanged.", + "zh-CN": + "--image 覆盖 body 中的 fromImage/imageName;显式 --from-image/--image-name 再覆盖对应预设字段。不传 --image 时保持原有镜像行为。", + }, { "en-US": "File mounts and other complete nested structures can be supplied through --body.", "zh-CN": "文件挂载等完整嵌套结构可通过 --body 提供。", @@ -480,12 +503,20 @@ export const sandboxTemplateUpdate = defineCommand({ usageArgs: "--template-id (--body | [fields])", flags: UPDATE_FLAGS, exampleArgs: [ + "--template-id tpl-xxx --image all-in-one", "--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, + ...SANDBOX_IMAGE_NOTES, + { + "en-US": + "--image overrides body fromImage/imageName; explicit --from-image/--image-name override the corresponding preset fields. Without --image, image behavior is unchanged.", + "zh-CN": + "--image 覆盖 body 中的 fromImage/imageName;显式 --from-image/--image-name 再覆盖对应预设字段。不传 --image 时保持原有镜像行为。", + }, { "en-US": "Supplying envConfig or --env replaces the template's complete environment map.", "zh-CN": "传入 envConfig 或 --env 会整体替换模版的环境变量 Map。", diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index 821dcef8d..bf9c1a2d9 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -223,6 +223,7 @@ 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 { sandboxOfficialImages } from "./commands/sandbox/images.ts"; export { sandboxConnect, sandboxCreate, diff --git a/packages/commands/tests/e2e/sandbox.e2e.test.ts b/packages/commands/tests/e2e/sandbox.e2e.test.ts index 0e3b7e342..cb6c83d19 100644 --- a/packages/commands/tests/e2e/sandbox.e2e.test.ts +++ b/packages/commands/tests/e2e/sandbox.e2e.test.ts @@ -27,6 +27,18 @@ function runCommandE2e(routes: typeof SANDBOX_ROUTES, args: string[]) { } describe("e2e: Sandbox command discovery", () => { + test("built-in images are discoverable without API Key authentication", async () => { + const { stderr, exitCode } = await runCommandHelp(SANDBOX_ROUTES, [ + "sandbox", + "official-images", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("Usage: bl sandbox official-images"); + expect(stderr).not.toContain("Authentication: API Key"); + expect(stderr).toContain("code-interpreter"); + expect(stderr).toContain("all-in-one"); + }); test.each([ ["sandbox", "create"], ["sandbox", "list"], @@ -69,6 +81,8 @@ describe("e2e: Sandbox command discovery", () => { ]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--max-running-time /); + expect(stderr).toMatch(/--image <[^>]*code-interpreter[^>]*browser[^>]*all-in-one[^>]*>/); + expect(stderr).toContain("browser:v0.0.44"); expect(stderr).toMatch(/--async/); expect(stderr).toMatch(/--poll-interval /); expect(stderr).toMatch(/waits for build status ready/i); @@ -76,6 +90,109 @@ describe("e2e: Sandbox command discovery", () => { }); describe("e2e: Sandbox offline validation and dry-run", () => { + test("image catalog is available offline in JSON, text, and quiet output", async () => { + const jsonResult = await runCommandE2e(SANDBOX_ROUTES, [ + "sandbox", + "official-images", + "--output", + "json", + ]); + expect(jsonResult.exitCode, jsonResult.stderr).toBe(0); + const images = JSON.parse(jsonResult.stdout) as { + id: string; + imageName: string; + imageUrl: string; + }[]; + expect(images.map((image) => image.id)).toEqual(["code-interpreter", "browser", "all-in-one"]); + expect(images[1]).toMatchObject({ + imageName: "浏览器", + imageUrl: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44", + }); + const textResult = await runCommandE2e(SANDBOX_ROUTES, [ + "sandbox", + "official-images", + "--output", + "text", + ]); + expect(textResult.exitCode, textResult.stderr).toBe(0); + expect(textResult.stdout).toContain("代码解释器"); + expect(textResult.stdout).toContain("browser:v0.0.44"); + const quietResult = await runCommandE2e(SANDBOX_ROUTES, [ + "sandbox", + "official-images", + "--quiet", + ]); + expect(quietResult.exitCode, quietResult.stderr).toBe(0); + expect(quietResult.stdout.trim().split("\n")).toEqual([ + "code-interpreter", + "browser", + "all-in-one", + ]); + }); + + test.each([ + { + operation: "create", + args: ["--name", "test", "--cpu-count", "1", "--memory-mb", "2048"], + selector: "browser", + method: "POST", + }, + { + operation: "update", + args: ["--template-id", "template-test"], + selector: "浏览器", + method: "PUT", + }, + ])( + "template $operation resolves --image before dry-run output", + async ({ operation, args, selector, method }) => { + const { stdout, stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [ + "sandbox", + "template", + operation, + ...args, + "--image", + selector, + ...AUTH_ARGS, + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + expect(parseStdoutJson(stdout)).toMatchObject({ + method, + request: { + fromImage: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44", + imageName: "浏览器", + }, + }); + }, + ); + + test("unknown image selector fails before a request", async () => { + const { stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [ + "sandbox", + "template", + "create", + "--name", + "test", + "--cpu-count", + "1", + "--memory-mb", + "2048", + "--image", + "unknown", + ...AUTH_ARGS, + "--dry-run", + "--output", + "json", + ]); + expect(exitCode).toBe(2); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 2, message: expect.stringContaining("--image") }, + }); + }); + test("create requires a template in flags or body", async () => { const { stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [ "sandbox", diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 525dd7c8d..680993c8d 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -184,6 +184,7 @@ export const SKILL_ROUTES: E2eRouteExports = { export const SANDBOX_ROUTES: E2eRouteExports = { "sandbox create": "sandboxCreate", + "sandbox official-images": "sandboxOfficialImages", "sandbox list": "sandboxList", "sandbox get": "sandboxGet", "sandbox connect": "sandboxConnect", diff --git a/packages/commands/tests/sandbox-images.test.ts b/packages/commands/tests/sandbox-images.test.ts new file mode 100644 index 000000000..6968aaf80 --- /dev/null +++ b/packages/commands/tests/sandbox-images.test.ts @@ -0,0 +1,134 @@ +import { ExitCode } from "bailian-cli-core"; +import { describe, expect, test } from "vite-plus/test"; +import { resolveSandboxImage, SANDBOX_IMAGES } from "../src/commands/sandbox/images.ts"; +import { + buildTemplateCreateBody, + buildTemplateUpdateBody, +} from "../src/commands/sandbox/template.ts"; + +const EXPECTED_IMAGES = [ + ["code-interpreter", "代码解释器", "code-interpreter-v1"], + ["browser", "浏览器", "browser"], + ["all-in-one", "全能型", "all-in-one"], +] as const; + +describe("Sandbox built-in image presets", () => { + test("contains exactly the three pinned images with icons and bilingual descriptions", () => { + expect(SANDBOX_IMAGES.map((image) => image.id)).toEqual(EXPECTED_IMAGES.map(([id]) => id)); + for (const image of SANDBOX_IMAGES) { + expect(image.icon).toMatch(/^https:\/\/img\.alicdn\.com\/.*\.png$/); + expect(image.description["en-US"]).not.toBe(""); + expect(image.description["zh-CN"]).not.toBe(""); + } + }); + + test.each(EXPECTED_IMAGES)( + "%s maps both selectors to the exact create/update payload", + async (id, imageName, repositoryName) => { + const fromImage = `fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/${repositoryName}:v0.0.44`; + for (const selector of [id, imageName]) { + expect(resolveSandboxImage(selector)).toMatchObject({ id, imageName, imageUrl: fromImage }); + expect( + await buildTemplateCreateBody({ + image: selector, + name: "test", + cpuCount: 1, + memoryMb: 2048, + async: false, + }), + ).toEqual({ name: "test", cpuCount: 1, memoryMB: 2048, fromImage, imageName }); + expect( + await buildTemplateUpdateBody({ + image: selector, + templateId: "template-test", + async: false, + }), + ).toEqual({ fromImage, imageName }); + } + }, + ); + + test("preset fields override body fields without sending catalog metadata", async () => { + expect( + await buildTemplateUpdateBody({ + image: "browser", + templateId: "template-test", + async: false, + body: JSON.stringify({ fromImage: "body:latest", imageName: "body", description: "kept" }), + }), + ).toEqual({ + fromImage: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44", + imageName: "浏览器", + description: "kept", + }); + }); + + test("explicit custom image fields override the corresponding preset fields", async () => { + expect( + await buildTemplateUpdateBody({ + image: "browser", + fromImage: "custom:v2", + imageName: "custom name", + templateId: "template-test", + async: false, + }), + ).toEqual({ fromImage: "custom:v2", imageName: "custom name" }); + expect( + await buildTemplateUpdateBody({ + image: "browser", + imageName: "renamed", + templateId: "template-test", + async: false, + }), + ).toEqual({ + fromImage: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44", + imageName: "renamed", + }); + }); + + test("without --image, display names and custom URLs retain their previous behavior", async () => { + expect( + await buildTemplateUpdateBody({ + imageName: "浏览器", + templateId: "template-test", + async: false, + }), + ).toEqual({ imageName: "浏览器" }); + expect( + await buildTemplateCreateBody({ + name: "test", + cpuCount: 4, + memoryMb: 8192, + async: false, + body: '{"fromImage":"custom:latest","imageName":"custom"}', + }), + ).toEqual({ + name: "test", + cpuCount: 4, + memoryMB: 8192, + fromImage: "custom:latest", + imageName: "custom", + }); + expect( + await buildTemplateCreateBody({ + name: "test", + cpuCount: 1, + memoryMb: 2048, + async: false, + }), + ).not.toHaveProperty("fromImage"); + expect( + await buildTemplateUpdateBody({ + description: "only description", + templateId: "template-test", + async: false, + }), + ).toEqual({ description: "only description" }); + }); + + test("rejects unknown presets locally", () => { + expect(() => resolveSandboxImage("unknown")).toThrowError( + expect.objectContaining({ exitCode: ExitCode.USAGE }), + ); + }); +}); diff --git a/skills/bailian-sandbox/SKILL.md b/skills/bailian-sandbox/SKILL.md index a2489e239..0db895039 100644 --- a/skills/bailian-sandbox/SKILL.md +++ b/skills/bailian-sandbox/SKILL.md @@ -6,7 +6,7 @@ metadata: bins: ["bl"] description: >- 阿里云百炼 Sandbox 沙箱实例与模版生命周期管理入口:用户要创建、查询、连接、暂停、恢复或释放百炼沙箱, - 或创建、更新、查询、删除沙箱模版、查看模版构建状态时,使用 `bl sandbox`。 + 或查看内置基础镜像、创建、更新、查询、删除沙箱模版、查看模版构建状态时,使用 `bl sandbox`。 仅覆盖百炼 Sandbox 管控面;不用于宿主执行沙箱设置、E2B 官方云资源或沙箱内命令执行与文件传输。 agents.yaml 托管 Agent / Session / Environment 管理交给 bailian-managed-agent。 官方安装:`bl skill init`(与共享协议 bailian-protocol 同装)。 @@ -25,19 +25,22 @@ Before running `bl`, read the shared [bailian-protocol](../bailian-protocol/SKIL ## 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` | +| User intent | Command family | +| ------------------------------------- | --------------------------------------------------------- | +| Discover built-in base image presets | `bl sandbox official-images` (offline, no authentication) | +| 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. To save the origin in an isolated Profile, use `bl auth login --config sandbox --api-key --base-url `. For a one-command override, use `bl sandbox list --base-url `. Custom gateways also apply to template build-status polling. +For built-in template images, discover the preset ID or Chinese name through `sandbox official-images`, then pass it to template create/update with `--image`. This fills both `fromImage` and `imageName`; `--image-name` alone remains a display name, not an image selector. Presets override the body's image fields; explicit `--from-image` / `--image-name` override the corresponding preset fields. Presets are pinned cn-beijing image URLs, not a live catalog or an availability guarantee for other environments. Omitting `--image` does not change existing defaults or update a template's image implicitly. See the generated reference for the catalog and exact 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. diff --git a/skills/bailian-sandbox/reference/index.md b/skills/bailian-sandbox/reference/index.md index 41bfaeb00..7b7cd66a3 100644 --- a/skills/bailian-sandbox/reference/index.md +++ b/skills/bailian-sandbox/reference/index.md @@ -16,6 +16,7 @@ Use this index for the skill-scoped quick index and global flags. | `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 official-images` | No Auth | List the built-in Sandbox base images (offline) | [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) | @@ -27,9 +28,9 @@ Use this index for the skill-scoped quick index and global flags. ## 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) | +| Group | Commands | Reference | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| `sandbox` | `connect`, `create`, `delete`, `get`, `list`, `official-images`, `pause`, `resume`, `template build-status`, `template create`, `template delete`, `template get`, `template list`, `template update` | [sandbox.md](sandbox.md) | ## Global flags diff --git a/skills/bailian-sandbox/reference/sandbox.md b/skills/bailian-sandbox/reference/sandbox.md index 953e500e8..6f5503f32 100644 --- a/skills/bailian-sandbox/reference/sandbox.md +++ b/skills/bailian-sandbox/reference/sandbox.md @@ -14,6 +14,7 @@ Index: [index.md](index.md) | `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 official-images` | No Auth | List the built-in Sandbox base images (offline) | | `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 | @@ -241,6 +242,40 @@ bl sandbox list --state running --limit 20 bl sandbox list --template-id tpl-xxx --output json ``` +### `bl sandbox official-images` + +| Field | Value | +| ------------------ | ----------------------------------------------- | +| **Name** | `sandbox official-images` | +| **Description** | List the built-in Sandbox base images (offline) | +| **Authentication** | No Auth | +| **Usage** | `bl sandbox official-images` | + +#### Flags + +_No command-specific flags._ + +#### Notes + +- Use a preset ID or Chinese name with template create/update --image. These are pinned cn-beijing images; other environments may differ. Use --from-image for a custom image. +- code-interpreter (代码解释器): Python / Node.js runtimes with common data-processing libraries. fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/code-interpreter-v1:v0.0.44 +- browser (浏览器): Chromium and a visual desktop for clicking, filling forms, and screenshots. fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44 +- all-in-one (全能型): Code execution and browser capabilities together. fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/all-in-one:v0.0.44 + +#### Examples + +```bash +bl sandbox official-images +``` + +```bash +bl sandbox official-images --output json +``` + +```bash +bl sandbox official-images --quiet +``` + ### `bl sandbox pause` | Field | Value | @@ -364,27 +399,28 @@ bl sandbox template build-status --template-id tpl-xxx --build-id build-xxx --ou #### Flags -| Flag | Type | Required | Description | -| ------------------------------ | ------ | -------- | ---------------------------------------------------------------------------------- | -| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | -| `--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 | +| Flag | Type | Required | Description | +| ----------------------------------------------------------------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | +| `--body ` | string | no | JSON request body, inline or loaded from an @file path; explicit flags override it | +| `--image ` | string | no | Built-in image ID or Chinese name; fills fromImage and imageName | +| `--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 @@ -393,11 +429,19 @@ bl sandbox template build-status --template-id tpl-xxx --build-id build-xxx --ou - Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. +- code-interpreter (代码解释器): Python / Node.js runtimes with common data-processing libraries. fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/code-interpreter-v1:v0.0.44 +- browser (浏览器): Chromium and a visual desktop for clicking, filling forms, and screenshots. fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44 +- all-in-one (全能型): Code execution and browser capabilities together. fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/all-in-one:v0.0.44 +- --image overrides body fromImage/imageName; explicit --from-image/--image-name override the corresponding preset fields. Without --image, image behavior is unchanged. - 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 browser --image browser --cpu-count 1 --memory-mb 2048 +``` + ```bash bl sandbox template create --name python --cpu-count 1 --memory-mb 2048 ``` @@ -538,26 +582,27 @@ bl sandbox template list --limit 100 --output json #### Flags -| Flag | Type | Required | Description | -| ------------------------------ | ------ | -------- | ---------------------------------------------------------------------------------- | -| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | -| `--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 | +| Flag | Type | Required | Description | +| ----------------------------------------------------------------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | +| `--template-id ` | string | yes | Sandbox template ID | +| `--body ` | string | no | JSON request body, inline or loaded from an @file path; explicit flags override it | +| `--image ` | string | no | Built-in image ID or Chinese name; fills fromImage and imageName | +| `--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 @@ -566,11 +611,19 @@ bl sandbox template list --limit 100 --output json - Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > 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. +- code-interpreter (代码解释器): Python / Node.js runtimes with common data-processing libraries. fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/code-interpreter-v1:v0.0.44 +- browser (浏览器): Chromium and a visual desktop for clicking, filling forms, and screenshots. fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44 +- all-in-one (全能型): Code execution and browser capabilities together. fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/all-in-one:v0.0.44 +- --image overrides body fromImage/imageName; explicit --from-image/--image-name override the corresponding preset fields. Without --image, image behavior is unchanged. - 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 --image all-in-one +``` + ```bash bl sandbox template update --template-id tpl-xxx --cpu-count 4 --memory-mb 8192 ``` From ceef514a50c7229436eb6f898d3202f08498fb8a Mon Sep 17 00:00:00 2001 From: chenanran555 Date: Wed, 9 Sep 2026 17:35:23 +0800 Subject: [PATCH 4/7] feat(sandbox): add template file upload with sandbox_template source --- packages/cli/src/commands.ts | 2 + .../commands/src/commands/sandbox/file.ts | 113 +++++++ .../commands/src/commands/sandbox/template.ts | 12 +- packages/commands/src/index.ts | 1 + .../tests/e2e/sandbox-file.e2e.test.ts | 312 ++++++++++++++++++ packages/commands/tests/e2e/topic-routes.ts | 1 + packages/core/src/client/endpoints.ts | 5 + packages/core/src/client/index.ts | 1 + skills/bailian-sandbox/SKILL.md | 26 +- skills/bailian-sandbox/reference/index.md | 7 +- skills/bailian-sandbox/reference/sandbox.md | 45 ++- 11 files changed, 507 insertions(+), 18 deletions(-) create mode 100644 packages/commands/src/commands/sandbox/file.ts create mode 100644 packages/commands/tests/e2e/sandbox-file.e2e.test.ts diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 5551caf45..90099762f 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -208,6 +208,7 @@ import { managedAgentFileDelete, sandboxCreate, sandboxOfficialImages, + sandboxFileUpload, sandboxList, sandboxGet, sandboxConnect, @@ -439,6 +440,7 @@ export const commands: Record = { "managed-agent file delete": managedAgentFileDelete, "sandbox create": sandboxCreate, "sandbox official-images": sandboxOfficialImages, + "sandbox file upload": sandboxFileUpload, "sandbox list": sandboxList, "sandbox get": sandboxGet, "sandbox connect": sandboxConnect, diff --git a/packages/commands/src/commands/sandbox/file.ts b/packages/commands/src/commands/sandbox/file.ts new file mode 100644 index 000000000..34f47e210 --- /dev/null +++ b/packages/commands/src/commands/sandbox/file.ts @@ -0,0 +1,113 @@ +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; +import { + agentStudioFilesPath, + BailianError, + defineCommand, + detectOutputFormat, + ExitCode, + sandboxBaseUrl, +} from "bailian-cli-core"; +import { emitBare, emitResult } from "bailian-cli-runtime"; +import { redactConnectionCredentials, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts"; + +const UPLOAD_SOURCE = "sandbox_template"; + +export const sandboxFileUpload = defineCommand({ + description: { + "en-US": "Upload a workspace file for Sandbox template mounts", + "zh-CN": "上传工作空间文件,供 Sandbox 模版挂载使用", + }, + auth: "apiKey", + usageArgs: "--path [--filename ] [--mime-type ]", + flags: { + ...WORKSPACE_FLAG, + path: { + type: "string", + valueHint: "", + required: true, + description: { "en-US": "Local file path", "zh-CN": "本地文件路径" }, + }, + filename: { + type: "string", + valueHint: "", + description: { "en-US": "Remote filename override", "zh-CN": "覆盖远端文件名" }, + }, + mimeType: { + type: "string", + valueHint: "", + description: { + "en-US": "Multipart file MIME type (default: application/octet-stream)", + "zh-CN": "Multipart 文件部分的 MIME 类型(默认:application/octet-stream)", + }, + }, + }, + exampleArgs: [ + "--path ./config.json --output json", + "--path ./config.json --quiet", + "--path ./notes.txt --filename notes.txt --mime-type text/plain --dry-run --output json", + ], + notes: [ + { + "en-US": + "POST /api/v1/agentstudio/files with multipart fields file and source=sandbox_template. Uses a Bailian Bearer API Key, not Console authentication or an E2B key; no agents.yaml is needed.", + "zh-CN": + "向 /api/v1/agentstudio/files 发送 multipart 字段 file 和 source=sandbox_template。使用百炼 Bearer API Key,不使用 Console 鉴权或 E2B Key;无需 agents.yaml。", + }, + { + "en-US": + "Base URL follows Sandbox: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. Without one, --workspace-id > BAILIAN_WORKSPACE_ID > config workspace_id selects the cn-beijing origin. The upload path has no /sandbox prefix.", + "zh-CN": + "Base URL 沿用 Sandbox:--base-url > DASHSCOPE_BASE_URL > 登录/Profile 的 base_url。未配置时,按 --workspace-id > BAILIAN_WORKSPACE_ID > 配置项 workspace_id 选择 cn-beijing 地址。上传路径不带 /sandbox 前缀。", + }, + { + "en-US": + "Returns the upload response immediately; --quiet prints only its id. Upload does not wait for security review: status=checking is not ready to mount. Use an available file's id as mntConfig[].originFileId in template create/update --body, together with mountPath and optional originFileName, in the same workspace. This does not transfer files into a running instance.", + "zh-CN": + "上传响应返回后立即输出,--quiet 仅输出 id。不会等待安全审核:status=checking 不代表已可挂载。在同一工作空间的 template create/update --body 中,将可用文件的 id 填入 mntConfig[].originFileId,同时传入 mountPath 和可选的 originFileName。此命令不向运行中的实例传文件。", + }, + { + "en-US": + "--dry-run previews the endpoint, source, and local path without reading or uploading the file. The service detects the MIME type and enforces upload limits.", + "zh-CN": + "--dry-run 仅预览 Endpoint、source 和本地路径,不读取或上传文件。MIME 类型检测和上传限制由服务端执行。", + }, + ], + async run(ctx) { + const endpoint = ctx.client.url(agentStudioFilesPath(), () => + sandboxBaseUrl(resolveWorkspaceId(ctx)), + ); + const filename = ctx.flags.filename ?? basename(ctx.flags.path); + const mimeType = ctx.flags.mimeType ?? "application/octet-stream"; + const format = detectOutputFormat(ctx.settings.output); + if (ctx.settings.dryRun) { + emitResult( + { + method: "POST", + endpoint, + request: { source: UPLOAD_SOURCE, file: { path: ctx.flags.path, filename, mimeType } }, + }, + format, + ); + return; + } + + const content = new Uint8Array(await readFile(ctx.flags.path)); + const form = new FormData(); + form.append("source", UPLOAD_SOURCE); + form.append("file", new Blob([content], { type: mimeType }), filename); + const file = await ctx.client.requestJson>({ + method: "POST", + path: endpoint, + body: form, + }); + if (!file || typeof file.id !== "string" || !file.id.trim()) { + throw new BailianError( + "Upload response did not contain a File ID. / 上传响应未包含 File ID。", + ExitCode.GENERAL, + ); + } + if (ctx.settings.quiet) emitBare(file.id); + else emitResult(redactConnectionCredentials(file), format); + }, +}); diff --git a/packages/commands/src/commands/sandbox/template.ts b/packages/commands/src/commands/sandbox/template.ts index 300cdfb07..7bfcaf692 100644 --- a/packages/commands/src/commands/sandbox/template.ts +++ b/packages/commands/src/commands/sandbox/template.ts @@ -386,8 +386,10 @@ export const sandboxTemplateCreate = defineCommand({ "--image 覆盖 body 中的 fromImage/imageName;显式 --from-image/--image-name 再覆盖对应预设字段。不传 --image 时保持原有镜像行为。", }, { - "en-US": "File mounts and other complete nested structures can be supplied through --body.", - "zh-CN": "文件挂载等完整嵌套结构可通过 --body 提供。", + "en-US": + "For local file mounts, use sandbox file upload first; put the available file's id into --body mntConfig[].originFileId with mountPath and optional originFileName. Upload and template must use the same workspace. Other complete nested structures can also be supplied through --body.", + "zh-CN": + "挂载本地文件前先调用 sandbox file upload;将可用文件的 id 填入 --body 的 mntConfig[].originFileId,并传入 mountPath 和可选的 originFileName。上传和模版须位于同一工作空间。其他完整嵌套结构也可通过 --body 提供。", }, { "en-US": @@ -521,6 +523,12 @@ export const sandboxTemplateUpdate = defineCommand({ "en-US": "Supplying envConfig or --env replaces the template's complete environment map.", "zh-CN": "传入 envConfig 或 --env 会整体替换模版的环境变量 Map。", }, + { + "en-US": + "Use sandbox file upload for local mount files. In --body mntConfig[], set originFileId to the available file's id and supply mountPath; upload and template must use the same workspace.", + "zh-CN": + "本地挂载文件先通过 sandbox file upload 上传。在 --body 的 mntConfig[] 中,将 originFileId 设为可用文件的 id 并传入 mountPath;上传和模版须位于同一工作空间。", + }, { "en-US": "By default the command waits for build status ready; --async returns the submitted build immediately.", diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index bf9c1a2d9..4ecbb4969 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -224,6 +224,7 @@ 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 { sandboxOfficialImages } from "./commands/sandbox/images.ts"; +export { sandboxFileUpload } from "./commands/sandbox/file.ts"; export { sandboxConnect, sandboxCreate, diff --git a/packages/commands/tests/e2e/sandbox-file.e2e.test.ts b/packages/commands/tests/e2e/sandbox-file.e2e.test.ts new file mode 100644 index 000000000..c42a9158a --- /dev/null +++ b/packages/commands/tests/e2e/sandbox-file.e2e.test.ts @@ -0,0 +1,312 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type IncomingHttpHeaders, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { parseStdoutJson, runCommandE2e, runCommandHelp } from "./helpers.ts"; +import { SANDBOX_ROUTES } from "./topic-routes.ts"; + +const directories: string[] = []; +const servers: Server[] = []; +const FILE_PATH = "/api/v1/agentstudio/files"; +const UPLOAD_COMMAND = ["sandbox", "file", "upload"]; +const RESPONSE = { + id: "file_sandbox_test", + filename: "config.json", + type: "file", + status: "available", + mime_type: "application/json", + size_bytes: 12, + requestId: "request-test", +}; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + ), + ); + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function setup(config: Record = {}) { + const directory = mkdtempSync(join(tmpdir(), "bl-sandbox-file-")); + directories.push(directory); + writeFileSync(join(directory, "config.json"), JSON.stringify(config)); + return { + directory, + env: { + BAILIAN_CONFIG_DIR: directory, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + BAILIAN_WORKSPACE_ID: "", + }, + }; +} + +async function gateway(responseBody: unknown = RESPONSE, status = 200) { + const received: { + method?: string; + path?: string; + headers: IncomingHttpHeaders; + body: Buffer; + }[] = []; + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + received.push({ + method: request.method, + path: request.url, + headers: request.headers, + body: Buffer.concat(chunks), + }); + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(responseBody)); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a local TCP server."); + return { origin: `http://127.0.0.1:${address.port}`, received }; +} + +describe("e2e: Sandbox template file upload", () => { + test("help documents upload auth, source, mount mapping, and the runtime boundary", async () => { + const result = await runCommandHelp(SANDBOX_ROUTES, [...UPLOAD_COMMAND, "--help"]); + expect(result.exitCode).toBe(0); + for (const text of [ + "bl sandbox file upload", + "--path", + "--filename", + "--mime-type", + "--base-url", + "--workspace-id", + "source=sandbox_template", + FILE_PATH, + "mntConfig[].originFileId", + "status=checking", + "running instance", + ]) { + expect(result.stderr).toContain(text); + } + expect(result.stderr).not.toContain("--console-site"); + expect(result.stderr).not.toContain("--yes"); + }); + + test("requires --path and does not expose a source override", async () => { + const { env } = setup(); + for (const args of [[], ["--path", "unused", "--source", "other"]]) { + const result = await runCommandE2e( + SANDBOX_ROUTES, + [...UPLOAD_COMMAND, ...args, "--output", "json"], + env, + ); + expect(result.exitCode).toBe(2); + } + }); + + test.each(["flag", "env", "profile", "workspace"])( + "%s determines the upload origin; dry-run does not read the local file or need credentials", + async (source) => { + const selectedOrigin = `https://${source}.example.test:8443`; + const { directory, env } = setup( + source === "profile" + ? { active_config: "sandbox", sandbox: { base_url: selectedOrigin } } + : {}, + ); + const path = join(directory, "does-not-exist.json"); + const args = + source === "flag" + ? ["--base-url", `${selectedOrigin}/api/v1/agentstudio/sandbox?ignored=1#fragment`] + : source === "workspace" + ? ["--workspace-id", "ws-files"] + : []; + const result = await runCommandE2e( + SANDBOX_ROUTES, + [...UPLOAD_COMMAND, "--path", path, ...args, "--dry-run", "--output", "json"], + { ...env, DASHSCOPE_BASE_URL: source === "env" ? selectedOrigin : "" }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseStdoutJson(result.stdout)).toEqual({ + method: "POST", + endpoint: + (source === "workspace" + ? "https://ws-files.cn-beijing.maas.aliyuncs.com" + : selectedOrigin) + FILE_PATH, + request: { + source: "sandbox_template", + file: { path, filename: "does-not-exist.json", mimeType: "application/octet-stream" }, + }, + }); + }, + ); + + test("uploads exact multipart bytes and source with saved Bearer credentials, then reuses the ID in a template mount", async () => { + const { origin, received } = await gateway(); + const { directory, env } = setup({ + active_config: "sandbox", + sandbox: { api_key: "sk-upload-test-only", base_url: origin }, + }); + const path = join(directory, "local data.bin"); + const content = Buffer.from([0, 1, 255, 13, 10, 65]); + writeFileSync(path, content); + const result = await runCommandE2e( + SANDBOX_ROUTES, + [ + ...UPLOAD_COMMAND, + "--path", + path, + "--filename", + "config.json", + "--mime-type", + "application/json", + "--output", + "json", + "--verbose", + ], + env, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseStdoutJson(result.stdout)).toEqual(RESPONSE); + expect(result.stdout + result.stderr).not.toContain("sk-upload-test-only"); + expect(received).toHaveLength(1); + const request = received[0]; + expect(request.method).toBe("POST"); + expect(request.path).toBe(FILE_PATH); + expect(request.headers.authorization).toBe("Bearer sk-upload-test-only"); + expect(request.headers["x-api-key"]).toBeUndefined(); + expect(request.headers["content-type"]).toMatch(/^multipart\/form-data; boundary=/); + const form = await new Request(`${origin}${FILE_PATH}`, { + method: "POST", + headers: { "content-type": request.headers["content-type"]! }, + body: new Uint8Array(request.body), + }).formData(); + expect([...form.keys()].sort()).toEqual(["file", "source"]); + expect(form.get("source")).toBe("sandbox_template"); + const file = form.get("file"); + if (!file || typeof file === "string") throw new Error("Expected a multipart file."); + expect(file.name).toBe("config.json"); + expect(file.type).toBe("application/json"); + expect(Buffer.from(await file.arrayBuffer())).toEqual(content); + + const mount = { + originFileId: RESPONSE.id, + originFileName: RESPONSE.filename, + mountPath: "/home/user/config.json", + }; + const template = await runCommandE2e( + SANDBOX_ROUTES, + [ + "sandbox", + "template", + "create", + "--name", + "files", + "--cpu-count", + "1", + "--memory-mb", + "2048", + "--body", + JSON.stringify({ mntConfig: [mount] }), + "--dry-run", + "--output", + "json", + ], + env, + ); + expect(template.exitCode, template.stderr).toBe(0); + expect(parseStdoutJson(template.stdout)).toMatchObject({ request: { mntConfig: [mount] } }); + expect(received).toHaveLength(1); + }); + + test.each(["json", "text", "quiet"])( + "%s output returns checking status or the bare ID without polling or creating a template", + async (output) => { + const pending = { ...RESPONSE, status: "checking" }; + const { origin, received } = await gateway(pending); + const { directory, env } = setup({ api_key: "sk-upload-test-only", base_url: origin }); + const path = join(directory, "notes.txt"); + writeFileSync(path, "Only synthetic test data."); + const result = await runCommandE2e( + SANDBOX_ROUTES, + [ + ...UPLOAD_COMMAND, + "--path", + path, + ...(output === "quiet" ? ["--quiet"] : ["--output", output]), + ], + env, + ); + expect(result.exitCode, result.stderr).toBe(0); + if (output === "quiet") expect(result.stdout.trim()).toBe(RESPONSE.id); + else { + expect(result.stdout).toContain("checking"); + expect(result.stdout).toContain(RESPONSE.id); + } + expect(received).toHaveLength(1); + }, + ); + + test("missing files fail locally without an upload request", async () => { + const { origin, received } = await gateway(); + const { directory, env } = setup({ api_key: "sk-upload-test-only", base_url: origin }); + const result = await runCommandE2e( + SANDBOX_ROUTES, + [...UPLOAD_COMMAND, "--path", join(directory, "missing.txt"), "--output", "json"], + env, + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("ENOENT"); + expect(received).toHaveLength(0); + }); + + test("server errors are passed through without translating them", async () => { + const { origin, received } = await gateway( + { code: "InvalidSource", message: "source rejected by server", request_id: "req-error" }, + 400, + ); + const { directory, env } = setup({ api_key: "sk-upload-test-only", base_url: origin }); + const path = join(directory, "notes.txt"); + writeFileSync(path, "test"); + const result = await runCommandE2e( + SANDBOX_ROUTES, + [...UPLOAD_COMMAND, "--path", path, "--output", "json"], + env, + ); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { + message: "source rejected by server", + http_status: 400, + api_code: "InvalidSource", + request_id: "req-error", + }, + }); + expect(received).toHaveLength(1); + }); + + test("a response without an ID is not reported as a successful upload", async () => { + const { origin } = await gateway({ status: "checking" }); + const { directory, env } = setup({ api_key: "sk-upload-test-only", base_url: origin }); + const path = join(directory, "notes.txt"); + writeFileSync(path, "test"); + const result = await runCommandE2e( + SANDBOX_ROUTES, + [...UPLOAD_COMMAND, "--path", path, "--quiet", "--output", "json"], + env, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("File ID"); + }); +}); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 680993c8d..0b7d4ec64 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -185,6 +185,7 @@ export const SKILL_ROUTES: E2eRouteExports = { export const SANDBOX_ROUTES: E2eRouteExports = { "sandbox create": "sandboxCreate", "sandbox official-images": "sandboxOfficialImages", + "sandbox file upload": "sandboxFileUpload", "sandbox list": "sandboxList", "sandbox get": "sandboxGet", "sandbox connect": "sandboxConnect", diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 0a57d94f3..99518b93d 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -228,6 +228,11 @@ export function ragEndpoint(workspaceId: string, path: string): string { // ---- Sandbox control plane (workspace-based host, cn-beijing only) ---- +/** AgentStudio workspace files; template uploads send source=sandbox_template. */ +export function agentStudioFilesPath(): string { + return "/api/v1/agentstudio/files"; +} + /** Default Sandbox origin when no shared base URL was configured (cn-beijing only). */ export function sandboxBaseUrl(workspaceId: string): string { return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com`; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 45b97816a..64c127fa9 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -18,6 +18,7 @@ export { profileSchemaPath, ragEndpoint, RAG_PATHS, + agentStudioFilesPath, sandboxEndpoint, sandboxBaseUrl, sandboxApiPath, diff --git a/skills/bailian-sandbox/SKILL.md b/skills/bailian-sandbox/SKILL.md index ca289223c..0c2bd93be 100644 --- a/skills/bailian-sandbox/SKILL.md +++ b/skills/bailian-sandbox/SKILL.md @@ -6,7 +6,7 @@ metadata: bins: ["bl"] description: >- 阿里云百炼 Sandbox 沙箱实例与模版生命周期管理入口:用户要创建、查询、连接、暂停、恢复或释放百炼沙箱, - 或查看内置基础镜像、创建、更新、查询、删除沙箱模版、查看模版构建状态时,使用 `bl sandbox`。 + 或查看内置基础镜像、上传模版挂载文件、创建、更新、查询、删除沙箱模版、查看模版构建状态时,使用 `bl sandbox`。 仅覆盖百炼 Sandbox 管控面;不用于宿主执行沙箱设置、E2B 官方云资源或沙箱内命令执行与文件传输。 agents.yaml 托管 Agent / Session / Environment 管理交给 bailian-managed-agent。 官方安装:`bl skill init`(与共享协议 bailian-protocol 同装)。 @@ -19,21 +19,22 @@ Before running `bl`, read the shared [bailian-protocol](../bailian-protocol/SKIL ## 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 Base URL through the same CLI chain as Managed Agent: `--base-url` > `DASHSCOPE_BASE_URL` > login/profile `base_url`. Use an origin such as `https://workspace.cn-beijing.maas.aliyuncs.com`; the CLI strips URL paths/query/fragment and appends `/api/v1/agentstudio/sandbox`. The saved API Key is reused. Profile capability fallback follows the shared protocol for both the key and Base URL. +- Resolve Base URL through the same CLI chain as Managed Agent: `--base-url` > `DASHSCOPE_BASE_URL` > login/profile `base_url`. Use an origin such as `https://workspace.cn-beijing.maas.aliyuncs.com`; the CLI strips URL paths/query/fragment and appends `/api/v1/agentstudio/sandbox` for lifecycle operations, or `/api/v1/agentstudio/files` for template file uploads. The saved API Key is reused. Profile capability fallback follows the shared protocol for both the key and Base URL. - If no Base URL is configured, resolve the workspace from `--workspace-id`, then `BAILIAN_WORKSPACE_ID`, then configured `workspace_id`, and use `https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox`. With a configured Base URL, the workspace flag is optional. The service currently supports `cn-beijing` and requires prior Sandbox SLR authorization. - 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 | -| ------------------------------------- | --------------------------------------------------------- | -| Discover built-in base image presets | `bl sandbox official-images` (offline, no authentication) | -| 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` | +| User intent | Command family | +| --------------------------------------- | --------------------------------------------------------- | +| Discover built-in base image presets | `bl sandbox official-images` (offline, no authentication) | +| Upload a local file for template mounts | `bl sandbox file upload` | +| 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. @@ -48,7 +49,8 @@ For built-in template images, discover the preset ID or Chinese name through `sa - 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`. +- `--body` accepts a JSON object inline or through `@path`; explicit flags override body fields. For local template mounts, first use `bl sandbox file upload`: it sends multipart `file` and fixed `source=sandbox_template` directly to `/api/v1/agentstudio/files` with the Bailian API Key. Use the returned `id` as `mntConfig[].originFileId`, with `mountPath` and optional `originFileName`, in template create/update `--body`. The upload and template must use the same workspace. This is not the temporary OSS upload from `bl file upload` or a transfer into a running instance. +- File upload returns immediately after the upload response and does not poll security review. `status=checking` is not ready to mount; only use files whose status is `available`. `--quiet` returns the File ID only; inspect the normal/JSON response for status. Upload alone does not authorize creating a template or instance. ## Common hand-offs diff --git a/skills/bailian-sandbox/reference/index.md b/skills/bailian-sandbox/reference/index.md index 7b7cd66a3..d3ef52161 100644 --- a/skills/bailian-sandbox/reference/index.md +++ b/skills/bailian-sandbox/reference/index.md @@ -14,6 +14,7 @@ Use this index for the skill-scoped quick index and global flags. | `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 file upload` | API Key | Upload a workspace file for Sandbox template mounts | [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 official-images` | No Auth | List the built-in Sandbox base images (offline) | [sandbox.md](sandbox.md) | @@ -28,9 +29,9 @@ Use this index for the skill-scoped quick index and global flags. ## By group -| Group | Commands | Reference | -| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | -| `sandbox` | `connect`, `create`, `delete`, `get`, `list`, `official-images`, `pause`, `resume`, `template build-status`, `template create`, `template delete`, `template get`, `template list`, `template update` | [sandbox.md](sandbox.md) | +| Group | Commands | Reference | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| `sandbox` | `connect`, `create`, `delete`, `file upload`, `get`, `list`, `official-images`, `pause`, `resume`, `template build-status`, `template create`, `template delete`, `template get`, `template list`, `template update` | [sandbox.md](sandbox.md) | ## Global flags diff --git a/skills/bailian-sandbox/reference/sandbox.md b/skills/bailian-sandbox/reference/sandbox.md index 6f5503f32..e5616a6e5 100644 --- a/skills/bailian-sandbox/reference/sandbox.md +++ b/skills/bailian-sandbox/reference/sandbox.md @@ -12,6 +12,7 @@ Index: [index.md](index.md) | `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 file upload` | API Key | Upload a workspace file for Sandbox template mounts | | `bl sandbox get` | API Key | Get Sandbox instance details | | `bl sandbox list` | API Key | List Sandbox instances | | `bl sandbox official-images` | No Auth | List the built-in Sandbox base images (offline) | @@ -162,6 +163,47 @@ bl sandbox delete --sandbox-id sbx-xxx --dry-run bl sandbox delete --sandbox-id sbx-xxx --yes ``` +### `bl sandbox file upload` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------- | +| **Name** | `sandbox file upload` | +| **Description** | Upload a workspace file for Sandbox template mounts | +| **Authentication** | API Key | +| **Usage** | `bl sandbox file upload --path [--filename ] [--mime-type ]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for the default Sandbox endpoint; optional with a configured base URL | +| `--path ` | string | yes | Local file path | +| `--filename ` | string | no | Remote filename override | +| `--mime-type ` | string | no | Multipart file MIME type (default: application/octet-stream) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- POST /api/v1/agentstudio/files with multipart fields file and source=sandbox_template. Uses a Bailian Bearer API Key, not Console authentication or an E2B key; no agents.yaml is needed. +- Base URL follows Sandbox: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. Without one, --workspace-id > BAILIAN_WORKSPACE_ID > config workspace_id selects the cn-beijing origin. The upload path has no /sandbox prefix. +- Returns the upload response immediately; --quiet prints only its id. Upload does not wait for security review: status=checking is not ready to mount. Use an available file's id as mntConfig[].originFileId in template create/update --body, together with mountPath and optional originFileName, in the same workspace. This does not transfer files into a running instance. +- --dry-run previews the endpoint, source, and local path without reading or uploading the file. The service detects the MIME type and enforces upload limits. + +#### Examples + +```bash +bl sandbox file upload --path ./config.json --output json +``` + +```bash +bl sandbox file upload --path ./config.json --quiet +``` + +```bash +bl sandbox file upload --path ./notes.txt --filename notes.txt --mime-type text/plain --dry-run --output json +``` + ### `bl sandbox get` | Field | Value | @@ -433,7 +475,7 @@ bl sandbox template build-status --template-id tpl-xxx --build-id build-xxx --ou - browser (浏览器): Chromium and a visual desktop for clicking, filling forms, and screenshots. fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44 - all-in-one (全能型): Code execution and browser capabilities together. fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/all-in-one:v0.0.44 - --image overrides body fromImage/imageName; explicit --from-image/--image-name override the corresponding preset fields. Without --image, image behavior is unchanged. -- File mounts and other complete nested structures can be supplied through --body. +- For local file mounts, use sandbox file upload first; put the available file's id into --body mntConfig[].originFileId with mountPath and optional originFileName. Upload and template must use the same workspace. Other complete nested structures can also be supplied through --body. - By default the command waits for build status ready; --async returns the submitted build immediately. #### Examples @@ -616,6 +658,7 @@ bl sandbox template list --limit 100 --output json - all-in-one (全能型): Code execution and browser capabilities together. fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/all-in-one:v0.0.44 - --image overrides body fromImage/imageName; explicit --from-image/--image-name override the corresponding preset fields. Without --image, image behavior is unchanged. - Supplying envConfig or --env replaces the template's complete environment map. +- Use sandbox file upload for local mount files. In --body mntConfig[], set originFileId to the available file's id and supply mountPath; upload and template must use the same workspace. - By default the command waits for build status ready; --async returns the submitted build immediately. #### Examples From 3e8df956fe8cabc251334bea2eb70af0c28ec963 Mon Sep 17 00:00:00 2001 From: chenanran555 Date: Thu, 10 Sep 2026 11:21:05 +0800 Subject: [PATCH 5/7] fix(sandbox): retain submitted build IDs on polling failure --- .../commands/src/commands/sandbox/template.ts | 41 ++++++- .../tests/e2e/sandbox-base-url.e2e.test.ts | 85 ++++++++++++++ packages/commands/tests/sandbox.test.ts | 107 ++++++++++++++++++ 3 files changed, 227 insertions(+), 6 deletions(-) diff --git a/packages/commands/src/commands/sandbox/template.ts b/packages/commands/src/commands/sandbox/template.ts index 7bfcaf692..cee17fb62 100644 --- a/packages/commands/src/commands/sandbox/template.ts +++ b/packages/commands/src/commands/sandbox/template.ts @@ -355,12 +355,41 @@ async function emitTemplateMutationResult(options: { ), options.endpoint, ).toString(); - const build = await waitForTemplateBuild( - options.client, - options.settings, - buildEndpoint, - options.pollInterval ?? 5, - ); + let build: TemplateBuildStatus; + try { + build = await waitForTemplateBuild( + options.client, + options.settings, + buildEndpoint, + options.pollInterval ?? 5, + ); + } catch (error) { + const hint = + `Submitted build / 已提交的构建: templateID=${options.response.templateID}, buildID=${options.response.buildID}.\n` + + "Check this build with sandbox template build-status before submitting another build. / 请先通过 sandbox template build-status 查询本次构建,再决定是否重新提交。"; + if (error instanceof BailianError) { + throw new BailianError( + error.message, + error.exitCode, + [error.hint, hint].filter(Boolean).join("\n"), + { + api: error.api, + rawResponse: error.rawResponse, + cause: error.cause, + }, + ); + } + // Leave transport errors intact so runtime retains timeout/network classification. + const recovery = { + templateID: options.response.templateID, + buildID: options.response.buildID, + hint, + }; + process.stderr.write( + format === "json" ? `${JSON.stringify(recovery, null, 2)}\n\n` : `${hint}\n`, + ); + throw error; + } if (options.settings.quiet) emitBare(displayValue(options.response.templateID)); else emitResult({ template: options.response, build }, format); } diff --git a/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts b/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts index ab0f5924e..5e1b58d53 100644 --- a/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts +++ b/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts @@ -231,3 +231,88 @@ describe("e2e: Sandbox custom gateway transport", () => { }, ); }); + +describe("e2e: Sandbox submitted build recovery", () => { + test.each([ + { action: "create", output: "json", failure: "timeout", exitCode: 5 }, + { action: "update", output: "text", failure: "service", exitCode: 1 }, + { action: "create", output: "json", failure: "network", exitCode: 6 }, + ])( + "$action retains IDs after a $failure in $output output", + async ({ action, output, failure, exitCode }) => { + let submissionCount = 0; + const server = createServer((request, response) => { + request.resume(); + response.setHeader("content-type", "application/json"); + if (request.method !== "GET") { + submissionCount += 1; + response.end( + JSON.stringify({ + templateID: "template-recovery", + buildID: "build-recovery", + buildStatus: "building", + }), + ); + } else if (failure === "network") { + request.socket.destroy(); + } else if (failure === "service") { + response.writeHead(503); + response.end( + JSON.stringify({ + code: 100005, + message: "original service failure", + requestID: "request-recovery", + }), + ); + } else { + response.end(JSON.stringify({ status: "building" })); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a local TCP server."); + const args = + action === "create" + ? ["--name", "recovery", "--cpu-count", "1", "--memory-mb", "2048"] + : ["--template-id", "template-recovery", "--description", "updated"]; + const result = await runCommandE2e( + ROUTES, + ["sandbox", "template", action, ...args, "--timeout", "1", "--quiet", "--output", output], + makeConfigEnv({ + api_key: "sk-recovery-test", + base_url: `http://127.0.0.1:${address.port}`, + }), + ); + expect(result.exitCode, result.stderr).toBe(exitCode); + expect(result.stdout).toBe(""); + expect(submissionCount).toBe(1); + expect(result.stderr).toContain("templateID=template-recovery, buildID=build-recovery"); + expect(result.stderr).toContain("sandbox template build-status"); + if (output === "json") { + const diagnostics = result.stderr + .trim() + .split(/\n\s*\n/) + .map((diagnostic) => JSON.parse(diagnostic)); + expect(diagnostics.at(-1)).toMatchObject({ error: { code: exitCode } }); + if (failure === "timeout") { + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].error.message).toBe("Template build polling timed out."); + } else { + expect(diagnostics[0]).toMatchObject({ + templateID: "template-recovery", + buildID: "build-recovery", + }); + expect(diagnostics.at(-1).error.message).toContain("Network request failed"); + } + } else { + expect(result.stderr).toContain("original service failure"); + expect(result.stderr).toContain("HTTP 503 (100005)"); + expect(result.stderr).toContain("request-recovery"); + } + }, + ); +}); diff --git a/packages/commands/tests/sandbox.test.ts b/packages/commands/tests/sandbox.test.ts index 2c7f81386..9356301e7 100644 --- a/packages/commands/tests/sandbox.test.ts +++ b/packages/commands/tests/sandbox.test.ts @@ -445,6 +445,113 @@ describe("Sandbox template build polling", () => { expect(requestJson).not.toHaveBeenCalled(); }); + test.each([ + { name: "create", command: sandboxTemplateCreate }, + { name: "update", command: sandboxTemplateUpdate }, + ])("$name preserves submitted IDs and the original polling failure", async ({ command }) => { + const submission = { templateID: "template-test", buildID: "build-test" }; + const serviceCause = new Error("original cause"); + const serviceError = new BailianError("service message", ExitCode.GENERAL, "original hint", { + api: { httpStatus: 503, apiCode: "Unavailable", requestId: "request-test" }, + rawResponse: "original response", + cause: serviceCause, + }); + const scenarios = [ + { + timeout: 0, + response: { status: "building" }, + exitCode: ExitCode.TIMEOUT, + message: "Template build polling timed out.", + }, + { + timeout: 30, + response: { status: "error", reason: { message: "image download failed" } }, + exitCode: ExitCode.GENERAL, + message: "image download failed", + }, + { + timeout: 30, + error: serviceError, + exitCode: ExitCode.GENERAL, + message: serviceError.message, + }, + ]; + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + for (const scenario of scenarios) { + const requestJson = vi.fn().mockResolvedValueOnce(submission); + if (scenario.error) requestJson.mockRejectedValue(scenario.error); + else requestJson.mockResolvedValue(scenario.response); + const operation = command.run({ + identity: { binName: "bl" }, + settings: { ...SETTINGS, timeout: scenario.timeout }, + flags: { + workspaceId: "ws-test", + templateId: "template-test", + name: "python", + cpuCount: 1, + memoryMb: 2048, + async: false, + }, + client: { requestJson, url: createUrlResolver() }, + } as never); + await expect(operation).rejects.toMatchObject({ + message: scenario.message, + exitCode: scenario.exitCode, + hint: expect.stringContaining("templateID=template-test, buildID=build-test"), + }); + if (scenario.error) { + await expect(operation).rejects.toMatchObject({ + api: serviceError.api, + rawResponse: serviceError.rawResponse, + cause: serviceCause, + hint: expect.stringContaining("original hint"), + }); + } + expect(requestJson.mock.calls.filter(([request]) => request.method !== "GET")).toHaveLength( + 1, + ); + } + expect(stdout).not.toHaveBeenCalled(); + }); + + test.each(["json", "text"] as const)( + "transport failures retain their identity and emit build recovery in %s diagnostics", + async (output) => { + const failure = new TypeError("fetch failed", { cause: { code: "ECONNRESET" } }); + const requestJson = vi + .fn() + .mockResolvedValueOnce({ templateID: "template-test", buildID: "build-test" }) + .mockRejectedValue(failure); + let stderr = ""; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + await expect( + sandboxTemplateCreate.run({ + identity: { binName: "bl" }, + settings: { ...SETTINGS, output }, + flags: { + workspaceId: "ws-test", + name: "python", + cpuCount: 1, + memoryMb: 2048, + async: false, + }, + client: { requestJson, url: createUrlResolver() }, + } as never), + ).rejects.toBe(failure); + if (output === "json") { + expect(JSON.parse(stderr)).toMatchObject({ + templateID: "template-test", + buildID: "build-test", + }); + } else { + expect(stderr).toContain("templateID=template-test, buildID=build-test"); + } + }, + ); + test("async template creation returns after the submit request", async () => { let stdout = ""; vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { From fa1a8c7b8476d8255aa3b16498b2fd3291fa7938 Mon Sep 17 00:00:00 2001 From: chenanran555 Date: Thu, 10 Sep 2026 16:07:41 +0800 Subject: [PATCH 6/7] docs(sandbox): add connection workflows and official references to skill --- skills/bailian-sandbox/SKILL.md | 16 +- .../assets/instance-connections.md | 152 ++++++++++++++++++ 2 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 skills/bailian-sandbox/assets/instance-connections.md diff --git a/skills/bailian-sandbox/SKILL.md b/skills/bailian-sandbox/SKILL.md index 0c2bd93be..7ecf3fe6b 100644 --- a/skills/bailian-sandbox/SKILL.md +++ b/skills/bailian-sandbox/SKILL.md @@ -7,7 +7,8 @@ metadata: description: >- 阿里云百炼 Sandbox 沙箱实例与模版生命周期管理入口:用户要创建、查询、连接、暂停、恢复或释放百炼沙箱, 或查看内置基础镜像、上传模版挂载文件、创建、更新、查询、删除沙箱模版、查看模版构建状态时,使用 `bl sandbox`。 - 仅覆盖百炼 Sandbox 管控面;不用于宿主执行沙箱设置、E2B 官方云资源或沙箱内命令执行与文件传输。 + 用户要访问已创建实例、打开 WebShell、连接 browser-use 浏览器自动化或 VNC 实时画面时,先获取连接信息,再连接数据面。 + 不用于宿主执行沙箱设置、E2B 官方云资源或通用文件传输。 agents.yaml 托管 Agent / Session / Environment 管理交给 bailian-managed-agent。 官方安装:`bl skill init`(与共享协议 bailian-protocol 同装)。 --- @@ -21,7 +22,7 @@ Before running `bl`, read the shared [bailian-protocol](../bailian-protocol/SKIL - 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 Base URL through the same CLI chain as Managed Agent: `--base-url` > `DASHSCOPE_BASE_URL` > login/profile `base_url`. Use an origin such as `https://workspace.cn-beijing.maas.aliyuncs.com`; the CLI strips URL paths/query/fragment and appends `/api/v1/agentstudio/sandbox` for lifecycle operations, or `/api/v1/agentstudio/files` for template file uploads. The saved API Key is reused. Profile capability fallback follows the shared protocol for both the key and Base URL. - If no Base URL is configured, resolve the workspace from `--workspace-id`, then `BAILIAN_WORKSPACE_ID`, then configured `workspace_id`, and use `https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox`. With a configured Base URL, the workspace flag is optional. The service currently supports `cn-beijing` and requires prior Sandbox SLR authorization. -- 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. +- No `agents.yaml` or local IaC state is required. `get` / `connect` return instance connection information; neither opens an interactive shell or browser. For WebShell, CDP, or VNC, use that information with the runtime APIs described below. Do not invent `bl sandbox exec`, `webshell`, or browser subcommands. ## Choose the operation @@ -42,11 +43,19 @@ To save the origin in an isolated Profile, use `bl auth login --config sandbox - For built-in template images, discover the preset ID or Chinese name through `sandbox official-images`, then pass it to template create/update with `--image`. This fills both `fromImage` and `imageName`; `--image-name` alone remains a display name, not an image selector. Presets override the body's image fields; explicit `--from-image` / `--image-name` override the corresponding preset fields. Presets are pinned cn-beijing image URLs, not a live catalog or an availability guarantee for other environments. Omitting `--image` does not change existing defaults or update a template's image implicitly. See the generated reference for the catalog and exact flags. +## Connect to an instance + +For requests to access a running instance, open WebShell, control a browser, or show its live desktop, read [assets/instance-connections.md](assets/instance-connections.md). It contains the connection workflow, API payloads, verification steps, and official documentation links. + +- Obtain fresh JSON with `bl sandbox get --sandbox-id --show-credentials --output json`. Use `envdAccessToken` as `X-Access-Token`; the control-plane Bailian API Key is a different credential. +- WebShell uses the returned `envdUrl` and envd PTY RPCs. The official browser template uses port 3000 on `{port}-{sandboxID}.{domain}` for CDP and VNC. These ports and paths come from the template contract, not fields returned by `get`; do not apply them to other images without checking their documentation. +- When the user asks to open or access the instance, complete the connection and verify a terminal command or rendered browser page. Returning connection JSON or a successful health check alone does not complete that request. A local proxy URL is a host-side access page, not a URL returned by the Sandbox API. + ## 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. +- Connection credentials are redacted by default. A request to connect to or access the instance needs the tokens: use `--show-credentials` for that operation, capture the result privately, and keep tokens out of chat summaries, logs, URLs, 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. For local template mounts, first use `bl sandbox file upload`: it sends multipart `file` and fixed `source=sandbox_template` directly to `/api/v1/agentstudio/files` with the Bailian API Key. Use the returned `id` as `mntConfig[].originFileId`, with `mountPath` and optional `originFileName`, in template create/update `--body`. The upload and template must use the same workspace. This is not the temporary OSS upload from `bl file upload` or a transfer into a running instance. @@ -63,3 +72,4 @@ Refer to sibling skills by name: read them if installed; otherwise use the comma - [bailian-protocol](../bailian-protocol/SKILL.md) — shared execution protocol, installed with `bl skill init` - [reference/](reference/index.md) — generated command reference +- [Instance connections](assets/instance-connections.md) — WebShell, browser CDP, VNC, and official runtime documentation diff --git a/skills/bailian-sandbox/assets/instance-connections.md b/skills/bailian-sandbox/assets/instance-connections.md new file mode 100644 index 000000000..2497cf516 --- /dev/null +++ b/skills/bailian-sandbox/assets/instance-connections.md @@ -0,0 +1,152 @@ +# Connect to a Sandbox instance + +Use this guide when the user asks to access an instance, open WebShell, automate its browser, or view the live desktop. Lifecycle operations stay in `bl sandbox`; runtime access uses the returned connection information with HTTP, CDP, or VNC clients. + +## Official documentation and endpoint ownership + +- [Bailian Sandbox SDK](https://docs.agent.bailian.aliyun.com/zh/sandbox/sdk) ([Markdown](https://docs.agent.bailian.aliyun.com/zh/sandbox/sdk.md)) — separates control-plane operations from runtime access; documents command execution through `POST /process.Process/Start`. +- [Browser template](https://help.aliyun.com/en/functioncompute/browser-template) — the **WebSocket endpoints** section defines port 3000, `/health`, `/ws/automation`, `/ws/livestream`, and `X-Access-Token` authentication. +- [Use Browser Use Sandbox](https://help.aliyun.com/en/functioncompute/use-browser-use-sandbox) — browser readiness, Puppeteer / BrowserUse integration, page navigation, and screenshots. +- [Connect protocol](https://connectrpc.com/docs/protocol/) — streaming envelopes, unary JSON requests, and end-of-stream errors used by envd. + +The browser paths below belong to the official browser image, not to every Sandbox template. Custom images or gateways may have different ports and paths; check their contract before constructing a URL. The envd PTY payloads below were verified with Bailian `envdVersion=0.2.4`; use the returned version when diagnosing compatibility changes. + +## 1. Obtain connection information + +Reuse the requested instance. Create one only when creation is part of the user's request. Follow the shared protocol before running `bl`; use the generated command reference for lifecycle flags. + +Capture the credential-bearing result in a private temporary directory: + +```bash +umask 077 +sandbox_connection_dir="$(mktemp -d)" +bl sandbox get --sandbox-id --show-credentials --output json > "$sandbox_connection_dir/connection.json" +``` + +Check the command exit code before reading the file. `create` and `connect` can also return connection fields with `--show-credentials`, but neither command opens a terminal or browser. Without that flag, tokens are `[REDACTED]` and cannot authenticate a runtime request. + +| Returned field | Use | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `envdUrl` | Runtime base URL for WebShell / process RPCs; use it directly. | +| `envdAccessToken` | Value for the runtime `X-Access-Token` header; not the Bailian API Key. | +| `sandboxID`, `domain` | Construct a documented service host as `{port}-{sandboxID}.{domain}`. Use `sandboxID`, not `bailianSandboxId` or `templateID`. | +| `state`, `endAt`, `envdVersion` | Check readiness, expiry, and the runtime version. | +| `trafficAccessToken` | Separate optional credential; do not substitute it for `envdAccessToken` in the flows below. | + +Require a running instance and a usable token. If it is paused, resume only within the requested scope, then fetch fresh connection information. If it has expired or no longer exists, report that state rather than retrying an old token or silently creating a replacement. Do not hardcode a previously observed domain, instance ID, or token. + +## 2. Check the intended service + +For the official browser template, set `browserHost = "3000-" + sandboxID + "." + domain`. + +| Capability | Address | Transport | +| ------------------------- | ----------------------------------- | -------------------------- | +| envd readiness | `{envdUrl}/health` | HTTPS GET | +| WebShell / shell commands | `{envdUrl}/process.Process/Start` | HTTPS POST, Connect stream | +| Browser readiness | `https://{browserHost}/health` | HTTPS GET | +| Browser automation | `wss://{browserHost}/ws/automation` | CDP over WebSocket | +| Live browser desktop | `wss://{browserHost}/ws/livestream` | VNC / RFB over WebSocket | + +Send `X-Access-Token: ` on each request. The control-plane `Authorization: Bearer ` is not this data-plane header. Do not send the Bailian API Key to runtime service hosts. + +This Python example uses only the standard library and keeps the token out of command arguments and output. It checks envd and, for a browser instance, the browser service: + +```bash +python3 - "$sandbox_connection_dir/connection.json" <<'PYTHON' +import json +import sys +import urllib.request +from pathlib import Path + +connection = json.loads(Path(sys.argv[1]).read_text()) +token = connection.get("envdAccessToken") +if not token or token == "[REDACTED]": + raise SystemExit("Fetch connection information with --show-credentials first") +browser_host = f"3000-{connection['sandboxID']}.{connection['domain']}" +for base_url in [connection["envdUrl"], f"https://{browser_host}"]: + request = urllib.request.Request( + base_url.rstrip("/") + "/health", + headers={"X-Access-Token": token}, + ) + with urllib.request.urlopen(request, timeout=15) as response: + print(base_url, response.status, response.read(1024).decode()) +PYTHON +``` + +An authenticated `/health` response of 200 establishes service readiness only. A root-path `/` response of 404 does not prove that the runtime is unavailable; these endpoints need not serve an HTML homepage. For a newly starting browser service, retry health checks with a bounded deadline before opening CDP or VNC. + +## 3. Open WebShell through envd PTY + +Use a terminal client or a host-side page with a terminal emulator such as xterm.js. Reuse an available envd client instead of recreating the protocol when possible. + +1. POST to `{envdUrl}/process.Process/Start` with `X-Access-Token`, `Connect-Protocol-Version: 1`, `Content-Type: application/connect+json`, and `connect-content-encoding: identity`. +2. Encode the JSON payload below in a Connect envelope: one zero flags byte, a four-byte big-endian payload length, then the UTF-8 JSON bytes. This is not a plain JSON POST. + +```json +{ + "process": { + "cmd": "/bin/bash", + "args": ["--noprofile", "--norc", "-i"], + "envs": { "TERM": "xterm-256color" } + }, + "pty": { "size": { "cols": 100, "rows": 30 } } +} +``` + +Choose a shell available in the image; the browser image used here has `/bin/bash`. Consume the response incrementally instead of waiting for the interactive process to finish: + +- Save `event.start.pid` as the process handle. +- Decode Base64 `event.data.pty` into bytes and write them to the terminal; preserve multibyte characters across chunks. For non-PTY processes, output may use `stdout` / `stderr` instead. +- Handle `event.end.exitCode`. Frames whose flags contain `0x02` are end-of-stream frames; report their `error` even when HTTP status is 200. Buffer incomplete frames across network chunks. + +Subsequent requests use `Content-Type: application/json` and the same token and protocol-version headers, without a streaming envelope: + +| Action | POST path under `envdUrl` | JSON body | +| ------------------------------------------- | ----------------------------- | --------------------------------------------------------------- | +| Send keyboard input | `/process.Process/SendInput` | `{"process":{"pid":123},"input":{"pty":"cHdkDQ=="}}` | +| Resize the terminal | `/process.Process/Update` | `{"process":{"pid":123},"pty":{"size":{"cols":100,"rows":30}}}` | +| Terminate this shell on explicit disconnect | `/process.Process/SendSignal` | `{"process":{"pid":123},"signal":"SIGNAL_SIGKILL"}` | + +Replace `123` with the returned PID. `cHdkDQ==` is Base64 for `pwd` followed by a carriage return. Serialize keyboard input requests to preserve their order. A WebShell frontend maps terminal input to `SendInput`, terminal resize to `Update`, and streamed output back to the terminal. Abort streaming requests and clean up only the shell process created by that client when disconnecting; do not kill the sandbox or the browser stack. + +Verify the interactive path by typing `pwd` in the terminal and observing the result and a new prompt. A successful `Start` response alone does not verify keyboard input. In a page that wraps global `fetch`, use an unwrapped streaming client or a local proxy if the wrapper buffers the stream. + +## 4. Connect browser automation through CDP + +Use a WebSocket client that supports custom headers. With `puppeteer-core` available in a scratch project, save the following as `connect-browser.mjs` there and run `node connect-browser.mjs `. The browser runs in the sandbox; no local Chromium installation or E2B SDK is needed for this direct connection. + +```javascript +import { readFile } from "node:fs/promises"; +import puppeteer from "puppeteer-core"; + +const [connectionPath, targetUrl, screenshotPath] = process.argv.slice(2); +if (!connectionPath || !targetUrl || !screenshotPath) { + throw new Error("Expected connection.json, target URL, and screenshot path"); +} +const connection = JSON.parse(await readFile(connectionPath, "utf8")); +const browserHost = `3000-${connection.sandboxID}.${connection.domain}`; +const browser = await puppeteer.connect({ + browserWSEndpoint: `wss://${browserHost}/ws/automation`, + headers: { "X-Access-Token": connection.envdAccessToken }, +}); +try { + const page = await browser.newPage(); + await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.screenshot({ path: screenshotPath }); + console.log(JSON.stringify({ url: page.url(), title: await page.title(), screenshotPath })); +} finally { + browser.disconnect(); +} +``` + +Run the health/token checks first. Use the user's requested target; if they only ask for an access check, `https://example.com` is a suitable sample. Verify the page title and screenshot. Disconnecting the client leaves the remote browser available; do not copy tutorial cleanup that closes the browser or kills a sandbox the user wants to keep using. + +## 5. Show the live browser desktop through VNC + +Connect a noVNC client to `wss://{browserHost}/ws/livestream`. This endpoint carries VNC frames, not CDP messages or an HTML page. A WebSocket handshake alone does not establish a working desktop; verify that the client finishes the RFB handshake and renders the remote browser. + +Browser-native WebSocket cannot attach `X-Access-Token` headers. For a user-facing page, use a header-capable bridge: browser noVNC → local WebSocket proxy → sandbox VNC endpoint. Keep the token on the proxy side and attach it only to the sandbox request. Do not put it in page source or query parameters to work around header support. + +For a temporary local viewer, bind to `127.0.0.1`, check the WebSocket Origin, and forward only the selected instance's service. Serve the needed noVNC assets from an available dependency or a scratch install. The same local approach can bridge an xterm.js WebShell page to envd. Keep the proxy running while the user is using the page; stop its connections when the instance expires or the user finishes. + +Report the actual local page URL separately from the remote service URL. Open the page when requested, verify the connected state and visible desktop or terminal output, and retain the usable tab for the user. State that a loopback URL works only on this machine and report the instance's actual expiry from `endAt`. Do not treat `127.0.0.1` pages as addresses returned by `sandbox get`. From fa3f5de7289c186e9f9da2947c11a3c494e006c1 Mon Sep 17 00:00:00 2001 From: chenanran555 Date: Fri, 11 Sep 2026 14:55:08 +0800 Subject: [PATCH 7/7] chore(release): prepare 1.24.0 --- CHANGELOG.md | 14 +++++++++++--- CHANGELOG.zh.md | 14 +++++++++++--- packages/cli/package.json | 2 +- packages/commands/package.json | 2 +- packages/core/package.json | 2 +- packages/kscli/package.json | 2 +- packages/runtime/package.json | 2 +- skills/bailian-cli/SKILL.md | 2 +- skills/bailian-finetune/SKILL.md | 2 +- skills/bailian-gen/SKILL.md | 2 +- skills/bailian-managed-agent/SKILL.md | 2 +- skills/bailian-protocol/SKILL.md | 2 +- skills/bailian-sandbox/SKILL.md | 2 +- skills/bailian-web-search/SKILL.md | 2 +- 14 files changed, 34 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58b8b5fc7..199d5cf11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and [中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md) -## [Unreleased] +## [1.24.0] - 2026-09-11 ### 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. +- Sandbox instance and template lifecycle commands, with automatic build polling and `--async` submission. +- `bl sandbox official-images` and template `--image` presets for Code Interpreter, Browser, and All-in-One. +- `bl sandbox file upload` uploads template mount files with `source=sandbox_template`; use the returned File ID in `mntConfig`. +- Sandbox supports shared `--base-url`, environment and Profile configuration, with workspace-based endpoint fallback. +- Dedicated `bailian-sandbox` Skill with command references and instance connection guidance. + +### Fixed + +- Preserve submitted `templateID` and `buildID` when template-build polling fails, so users can check the existing build before resubmitting. ### 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. +- Sandbox REST calls use Bailian Bearer authentication without an E2B SDK or E2B API key. Connection credentials and dry-run environment values are redacted by default. ## [1.23.0] - 2026-09-10 diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 7f357cdf6..6ceb7ea8b 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -6,15 +6,23 @@ [English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md) -## [未发布] +## [1.24.0] - 2026-09-11 ### 新增 -- **Sandbox 管控面命令** —— 新增 `bl sandbox` 实例与模版生命周期命令,覆盖百炼 Sandbox 的 13 个 REST API;模版构建默认等待完成,也可通过 `--async` 仅提交并立即返回。 +- Sandbox 实例与模版生命周期命令,支持自动轮询构建状态和 `--async` 异步提交。 +- `bl sandbox official-images` 与模版 `--image` 参数,内置代码解释器、浏览器和全能型镜像预设。 +- `bl sandbox file upload` 使用 `source=sandbox_template` 上传模版挂载文件,返回的 File ID 可用于 `mntConfig`。 +- Sandbox 复用 `--base-url`、环境变量和 Profile 配置,未配置时通过工作空间拼接接入地址。 +- 独立的 `bailian-sandbox` Skill,提供命令参考和实例连接指南。 + +### 修复 + +- 模版构建轮询失败时保留已提交的 `templateID` 和 `buildID`,便于先查询已有构建,避免重复提交。 ### 安全 -- Sandbox 调用使用百炼 API Key 作为 Bearer Token,不依赖 E2B SDK,也不发送 E2B API Key。连接凭据和 dry-run 中的环境变量默认脱敏;仅在确实需要原始连接 Token 时使用 `--show-credentials`。 +- Sandbox REST 调用使用百炼 Bearer 鉴权,不依赖 E2B SDK 或 E2B API Key;连接凭据和 dry-run 环境变量默认脱敏。 ## [1.23.0] - 2026-09-10 diff --git a/packages/cli/package.json b/packages/cli/package.json index 09b8835a1..26ff6e3ea 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli", - "version": "1.23.0", + "version": "1.24.0", "description": "CLI for Aliyun Model Studio (DashScope) AI Platform.", "keywords": [ "agent", diff --git a/packages/commands/package.json b/packages/commands/package.json index c15c2b7d9..6a517e7a0 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-commands", - "version": "1.23.0", + "version": "1.24.0", "description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/core/package.json b/packages/core/package.json index 488ab335a..d21507f15 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-core", - "version": "1.23.0", + "version": "1.24.0", "description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/kscli/package.json b/packages/kscli/package.json index ab728b96e..b0dd6b715 100644 --- a/packages/kscli/package.json +++ b/packages/kscli/package.json @@ -1,6 +1,6 @@ { "name": "knowledge-studio-cli", - "version": "1.23.0", + "version": "1.24.0", "description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.", "keywords": [ "alibaba-cloud", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index c566bc460..5c7d9b39e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-runtime", - "version": "1.23.0", + "version": "1.24.0", "description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 03591dd2e..fb5196596 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-cli metadata: - version: "1.23.0" + version: "1.24.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-finetune/SKILL.md b/skills/bailian-finetune/SKILL.md index 58d98ae93..e56c0daee 100644 --- a/skills/bailian-finetune/SKILL.md +++ b/skills/bailian-finetune/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-finetune metadata: - version: "1.23.0" + version: "1.24.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index b0e88ff9a..822e8a7bc 100644 --- a/skills/bailian-gen/SKILL.md +++ b/skills/bailian-gen/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-gen metadata: - version: "1.23.0" + version: "1.24.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-managed-agent/SKILL.md b/skills/bailian-managed-agent/SKILL.md index 907a2614b..58980d627 100644 --- a/skills/bailian-managed-agent/SKILL.md +++ b/skills/bailian-managed-agent/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-managed-agent metadata: - version: "1.23.0" + version: "1.24.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index ca96b1295..b77723a5a 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-protocol metadata: - version: "1.23.0" + version: "1.24.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-sandbox/SKILL.md b/skills/bailian-sandbox/SKILL.md index f187bb06f..ebdb68417 100644 --- a/skills/bailian-sandbox/SKILL.md +++ b/skills/bailian-sandbox/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-sandbox metadata: - version: "1.23.0" + version: "1.24.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-web-search/SKILL.md b/skills/bailian-web-search/SKILL.md index aafb68acb..07fe7c61f 100644 --- a/skills/bailian-web-search/SKILL.md +++ b/skills/bailian-web-search/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-web-search metadata: - version: "1.23.0" + version: "1.24.0" requires: bins: ["bl"] description: >-