From 0d175b289fa3b60fc6e46c96ee2c83c57dd076f9 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Thu, 18 Jun 2026 15:25:40 +0200 Subject: [PATCH 01/16] add sandbox PoC --- .github/workflows/sandbox-agent.yml | 37 ++++ packages/cli/README.md | 128 ++++++++++++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/sandbox/create.ts | 190 ++++++++++++++++++ packages/cli/src/commands/sandbox/delete.ts | 70 +++++++ packages/cli/src/commands/sandbox/exec.ts | 59 ++++++ packages/cli/src/commands/sandbox/index.ts | 16 ++ packages/cli/src/commands/sandbox/list.ts | 33 +++ packages/cli/src/commands/sandbox/ssh-exec.ts | 29 +++ packages/cli/src/commands/sandbox/ssh.ts | 48 +++++ packages/cli/src/commands/sandbox/url/add.ts | 103 ++++++++++ .../cli/src/commands/sandbox/url/delete.ts | 66 ++++++ .../cli/src/commands/sandbox/url/index.ts | 10 + packages/cli/src/commands/sandbox/url/list.ts | 66 ++++++ packages/cli/src/config/index.ts | 21 +- packages/cli/src/config/schema.ts | 8 + sandbox/Dockerfile | 51 +++++ sandbox/entrypoint.sh | 5 + 18 files changed, 940 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/sandbox-agent.yml create mode 100644 packages/cli/src/commands/sandbox/create.ts create mode 100644 packages/cli/src/commands/sandbox/delete.ts create mode 100644 packages/cli/src/commands/sandbox/exec.ts create mode 100644 packages/cli/src/commands/sandbox/index.ts create mode 100644 packages/cli/src/commands/sandbox/list.ts create mode 100644 packages/cli/src/commands/sandbox/ssh-exec.ts create mode 100644 packages/cli/src/commands/sandbox/ssh.ts create mode 100644 packages/cli/src/commands/sandbox/url/add.ts create mode 100644 packages/cli/src/commands/sandbox/url/delete.ts create mode 100644 packages/cli/src/commands/sandbox/url/index.ts create mode 100644 packages/cli/src/commands/sandbox/url/list.ts create mode 100644 sandbox/Dockerfile create mode 100644 sandbox/entrypoint.sh diff --git a/.github/workflows/sandbox-agent.yml b/.github/workflows/sandbox-agent.yml new file mode 100644 index 00000000..a570f563 --- /dev/null +++ b/.github/workflows/sandbox-agent.yml @@ -0,0 +1,37 @@ +name: Sandbox Agent + +on: + push: + branches: [main] + paths: + - sandbox/** + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./sandbox + push: true + tags: ghcr.io/bunnyway/sandbox-agent:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/packages/cli/README.md b/packages/cli/README.md index bb074326..0fbd93cd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -787,6 +787,134 @@ Open the Edge Scripts documentation in your browser. bunny scripts docs ``` +### `bunny sandbox` + +Manage on-demand cloud sandbox environments backed by Bunny Magic Containers. Each sandbox is a fully isolated Ubuntu container with Node.js, Bun, Python, and Claude Code pre-installed. A 10 GB persistent volume is mounted at `/workplace`, your default working directory. + +Sandbox credentials (app ID, hostname, SSH endpoint, agent token) are stored in `~/.config/bunnynet.json` so you can reconnect without re-creating. + +#### `bunny sandbox create` + +Create and start a new sandbox. Waits for the container's SSH port to become reachable before returning. + +```bash +# Create a sandbox with the default name "sandbox" +bunny sandbox create + +# Create a named sandbox +bunny sandbox create my-sandbox + +# Create in a specific region +bunny sandbox create my-sandbox --region NY +``` + +| Flag | Description | Default | +| ---------- | ---------------------------------------------------- | ------- | +| `--region` | Region ID to deploy in (e.g. `AMS`, `NY`, `LA`, …) | `AMS` | + +Once ready, the output shows the app ID, public HTTPS hostname, and SSH address. + +#### `bunny sandbox list` + +List all sandboxes saved in your local config. + +```bash +bunny sandbox list +bunny sandbox ls # alias +``` + +Columns: Name, App ID, Hostname, SSH. + +#### `bunny sandbox delete` + +Delete a sandbox and permanently destroy the underlying Magic Containers app. + +```bash +bunny sandbox delete my-sandbox + +# Skip the confirmation prompt +bunny sandbox delete my-sandbox --force +bunny sandbox rm my-sandbox -f # alias +``` + +| Flag | Alias | Description | Default | +| --------- | ----- | ------------------------- | ------- | +| `--force` | `-f` | Skip confirmation prompt | `false` | + +#### `bunny sandbox exec` + +Run a shell command inside a sandbox over SSH. Defaults to `/workplace` as the working directory. + +```bash +# Run a command +bunny sandbox exec my-sandbox ls -la + +# Run in a different directory +bunny sandbox exec my-sandbox --cwd /tmp env + +# Pipe-friendly: exit code is propagated +bunny sandbox exec my-sandbox -- cat /etc/os-release +``` + +| Flag | Description | Default | +| ------- | ---------------------------------------- | ------------ | +| `--cwd` | Working directory inside the sandbox | `/workplace` | + +#### `bunny sandbox ssh` + +Open a full interactive SSH session. Drops you into a bash shell at `/workplace`. Type `exit` or press Ctrl-D to close. + +```bash +bunny sandbox ssh my-sandbox +``` + +#### `bunny sandbox url` + +Manage public CDN endpoints for ports running inside a sandbox. Useful for exposing a dev server or API to the internet. + +##### `bunny sandbox url add` + +Expose a container port as a public HTTPS endpoint. Waits until the URL is provisioned and prints it. + +```bash +# Expose port 3000 (endpoint named "port-3000") +bunny sandbox url add my-sandbox 3000 + +# Custom endpoint name +bunny sandbox url add my-sandbox 8080 --label my-api +``` + +| Flag | Description | Default | +| --------- | ---------------------------------------------- | ---------------- | +| `--label` | Display name for the endpoint | `port-` | + +##### `bunny sandbox url list` + +List all user-created endpoints for a sandbox (built-in `api` and `ssh` endpoints are hidden). + +```bash +bunny sandbox url list my-sandbox +bunny sandbox url ls my-sandbox # alias +``` + +Columns: ID, Name, Type, Port, URL. + +##### `bunny sandbox url delete` + +Delete a public endpoint by name. + +```bash +bunny sandbox url delete my-sandbox port-3000 + +# Skip confirmation +bunny sandbox url delete my-sandbox my-api --force +bunny sandbox url rm my-sandbox my-api -f # alias +``` + +| Flag | Alias | Description | Default | +| --------- | ----- | ------------------------ | ------- | +| `--force` | `-f` | Skip confirmation prompt | `false` | + ### `bunny api` Make a raw authenticated HTTP request to any bunny.net API endpoint. Auth is handled automatically via your configured API key. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 784bbdfe..a22b9a8f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -12,6 +12,7 @@ import { dnsNamespace } from "./commands/dns/index.ts"; import { docsCommand } from "./commands/docs.ts"; import { openCommand } from "./commands/open.ts"; import { registriesNamespace } from "./commands/registries/index.ts"; +import { sandboxNamespace } from "./commands/sandbox/index.ts"; import { scriptsNamespace } from "./commands/scripts/index.ts"; import { whoamiCommand } from "./commands/whoami.ts"; import { bunny } from "./core/colors.ts"; @@ -35,6 +36,7 @@ const experimentalCommands: CommandModule[] = [ appsNamespace, registriesNamespace, dnsNamespace, + sandboxNamespace, ]; let instance = yargs(hideBin(process.argv)) diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts new file mode 100644 index 00000000..9bece7b3 --- /dev/null +++ b/packages/cli/src/commands/sandbox/create.ts @@ -0,0 +1,190 @@ +import { createMcClient } from "@bunny.net/openapi-client"; +import { randomBytes } from "node:crypto"; +import { resolveConfig, setSandbox } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { WORKPLACE } from "./ssh-exec.ts"; + +const IMAGE_REGISTRY_ID = "1156"; +const IMAGE_NAMESPACE = "bunnyway"; +const IMAGE_NAME = "sandbox-agent"; +const IMAGE_TAG = "latest"; +const DEFAULT_REGION = "AMS"; +const POLL_INTERVAL_MS = 3000; +const STARTUP_TIMEOUT_MS = 120_000; + +type App = Record & { + id?: string; + status?: string; + containerTemplates?: Array<{ endpoints?: Array & { type?: string; publicHost?: string }> }>; +}; + +function generateToken(): string { + return randomBytes(32).toString("base64url"); +} + +function extractAnycastHost(app: App): string | null { + for (const ct of app.containerTemplates ?? []) { + for (const ep of ct.endpoints ?? []) { + if (ep.type === "anycast") { + return (ep.publicHost as string | undefined) ?? null; + } + } + } + return null; +} + +async function probeSsh(host: string, port: number): Promise { + try { + const socket = await Bun.connect({ hostname: host, port, socket: { data() {}, open() {}, close() {}, error() {} } }); + socket.end(); + return true; + } catch { + return false; + } +} + +async function waitUntilActive( + client: ReturnType, + appId: string, +): Promise<{ sshHost: string }> { + const deadline = Date.now() + STARTUP_TIMEOUT_MS; + + // Phase 1: poll API until the anycast SSH endpoint is assigned + let sshHost: string | null = null; + await Bun.sleep(3000); + while (Date.now() < deadline) { + const { data, error } = await client.GET("/apps/{appId}", { + params: { path: { appId } }, + }); + if (error) throw new UserError(`Failed to poll app: ${JSON.stringify(error)}`); + const app = data as App; + const status = (app as Record).status as string | undefined; + if (status === "failing" || status === "suspended") { + throw new UserError(`Sandbox entered terminal state: ${status}`); + } + sshHost = extractAnycastHost(app); + if (sshHost) break; + await Bun.sleep(POLL_INTERVAL_MS); + } + + if (!sshHost) { + throw new UserError(`Sandbox SSH endpoint was not assigned within ${STARTUP_TIMEOUT_MS / 1000}s`); + } + + // Phase 2: probe SSH port until the container accepts connections + const [sshIp, sshPortStr] = (sshHost.includes(":") ? sshHost.split(":") : [sshHost, "8023"]) as [string, string]; + const sshPort = Number(sshPortStr); + while (Date.now() < deadline) { + if (await probeSsh(sshIp, sshPort)) return { sshHost }; + await Bun.sleep(POLL_INTERVAL_MS); + } + + throw new UserError(`Sandbox SSH did not become reachable within ${STARTUP_TIMEOUT_MS / 1000}s`); +} + +interface CreateArgs { + name: string; + region: string; +} + +export const sandboxCreateCommand = defineCommand({ + command: "create [name]", + describe: "Create and start a new sandbox.", + examples: [ + ["$0 sandbox create", "Create a sandbox with a generated name"], + ["$0 sandbox create my-sandbox", "Create a sandbox named my-sandbox"], + ["$0 sandbox create my-sandbox --region NY", "Create a sandbox in New York"], + ], + + builder: (yargs) => + yargs + .positional("name", { + type: "string", + default: "sandbox", + describe: "Name for the sandbox", + }) + .option("region", { + type: "string", + default: DEFAULT_REGION, + describe: "Region ID to deploy the sandbox in (e.g. AMS, NY, LA)", + }), + + handler: async ({ profile, verbose, apiKey, name, region }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createMcClient(clientOptions(config, verbose)); + const agentToken = generateToken(); + + const spin = spinner("Creating sandbox..."); + spin.start(); + + const { data: app, error: createError } = await (client as any).POST("/apps", { + body: { + name, + runtimeType: "shared", + autoScaling: { min: 1, max: 1 }, + regionSettings: { + allowedRegionIds: [region], + requiredRegionIds: [region], + }, + volumes: [ + { name: "workplace", size: 10 }, + ], + containerTemplates: [ + { + name: "agent", + imageRegistryId: IMAGE_REGISTRY_ID, + imageNamespace: IMAGE_NAMESPACE, + imageName: IMAGE_NAME, + imageTag: IMAGE_TAG, + imagePullPolicy: "ifNotPresent", + environmentVariables: [{ name: "AGENT_TOKEN", value: agentToken }], + volumeMounts: [ + { name: "workplace", mountPath: WORKPLACE }, + ], + endpoints: [ + { + displayName: "ssh", + anycast: { + type: "ipv4", + portMappings: [{ containerPort: 8023, exposedPort: 8023, protocols: ["Tcp"] }], + }, + }, + ], + }, + ], + }, + }); + + if (createError || !app) { + spin.stop(); + throw new UserError(`Failed to create sandbox: ${JSON.stringify(createError)}`); + } + + const appId = (app as Record).id as string; + spin.text = "Waiting for sandbox to become active..."; + + let sshHost: string; + try { + ({ sshHost } = await waitUntilActive(client, appId)); + } catch (err) { + spin.stop(); + // best-effort cleanup + await client.DELETE("/apps/{appId}", { params: { path: { appId } } }).catch(() => {}); + throw err; + } + + spin.stop(); + + const record = { app_id: appId, agent_token: agentToken, ssh_host: sshHost }; + setSandbox(name, record); + + logger.log(`Sandbox "${name}" is ready.`); + logger.log(` App ID: ${appId}`); + logger.log(` SSH: ${sshHost}`); + logger.log(`\nRun commands with: bunny sandbox exec ${name} `); + }, +}); diff --git a/packages/cli/src/commands/sandbox/delete.ts b/packages/cli/src/commands/sandbox/delete.ts new file mode 100644 index 00000000..adc69344 --- /dev/null +++ b/packages/cli/src/commands/sandbox/delete.ts @@ -0,0 +1,70 @@ +import { createMcClient } from "@bunny.net/openapi-client"; +import { deleteSandbox, getSandbox, resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { confirm, spinner } from "../../core/ui.ts"; + +interface DeleteArgs { + name: string; + force: boolean; +} + +export const sandboxDeleteCommand = defineCommand({ + command: "delete ", + aliases: ["rm"], + describe: "Delete a sandbox and its MC app.", + examples: [ + ["$0 sandbox delete my-sandbox", "Delete a sandbox"], + ["$0 sandbox delete my-sandbox --force", "Delete without confirmation"], + ], + + builder: (yargs) => + yargs + .positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ name, force, profile, verbose, apiKey }) => { + const record = getSandbox(name); + if (!record) { + throw new UserError(`No sandbox named "${name}" found.`); + } + + if (!force) { + const ok = await confirm(`Delete sandbox "${name}" (app ${record.app_id})?`); + if (!ok) { + logger.info("Aborted."); + return; + } + } + + const config = resolveConfig(profile, apiKey, verbose); + const client = createMcClient(clientOptions(config, verbose)); + + const spin = spinner("Deleting sandbox..."); + spin.start(); + + const { error } = await client.DELETE("/apps/{appId}", { + params: { path: { appId: record.app_id } }, + }); + + spin.stop(); + + if (error) { + throw new UserError(`Failed to delete app: ${JSON.stringify(error)}`); + } + + deleteSandbox(name); + logger.log(`Sandbox "${name}" deleted.`); + }, +}); diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts new file mode 100644 index 00000000..3b7a8103 --- /dev/null +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -0,0 +1,59 @@ +import { getSandbox } from "../../config/index.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { WORKPLACE, sshArgs } from "./ssh-exec.ts"; + +interface ExecArgs { + name: string; + command: string[]; + cwd: string; +} + +export const sandboxExecCommand = defineCommand({ + command: "exec ", + describe: "Run a shell command inside a sandbox via SSH.", + examples: [ + ["$0 sandbox exec my-sandbox uname -a", "Run a command"], + ["$0 sandbox exec my-sandbox --cwd /tmp ls -la", "Run with a working directory"], + ], + + builder: (yargs) => + yargs + .parserConfiguration({ "unknown-options-as-args": true }) + .positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }) + .positional("command", { + type: "string", + array: true, + demandOption: true, + describe: "Command to execute", + }) + .option("cwd", { + type: "string", + default: WORKPLACE, + describe: "Working directory inside the sandbox", + }), + + handler: async ({ name, command, cwd }) => { + const record = getSandbox(name); + if (!record) { + throw new UserError(`No sandbox named "${name}" found. Run: bunny sandbox create ${name}`); + } + if (!record.ssh_host) { + throw new UserError(`Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`); + } + + const remoteCmd = `cd ${cwd} && ${command.join(" ")}`; + + const proc = Bun.spawn(sshArgs(record, remoteCmd), { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + + process.exitCode = await proc.exited; + }, +}); diff --git a/packages/cli/src/commands/sandbox/index.ts b/packages/cli/src/commands/sandbox/index.ts new file mode 100644 index 00000000..913fa100 --- /dev/null +++ b/packages/cli/src/commands/sandbox/index.ts @@ -0,0 +1,16 @@ +import { defineNamespace } from "../../core/define-namespace.ts"; +import { sandboxCreateCommand } from "./create.ts"; +import { sandboxDeleteCommand } from "./delete.ts"; +import { sandboxExecCommand } from "./exec.ts"; +import { sandboxListCommand } from "./list.ts"; +import { sandboxSshCommand } from "./ssh.ts"; +import { sandboxUrlNamespace } from "./url/index.ts"; + +export const sandboxNamespace = defineNamespace("sandbox", "Manage sandboxes.", [ + sandboxCreateCommand, + sandboxListCommand, + sandboxDeleteCommand, + sandboxExecCommand, + sandboxSshCommand, + sandboxUrlNamespace, +]); diff --git a/packages/cli/src/commands/sandbox/list.ts b/packages/cli/src/commands/sandbox/list.ts new file mode 100644 index 00000000..d41e5731 --- /dev/null +++ b/packages/cli/src/commands/sandbox/list.ts @@ -0,0 +1,33 @@ +import { loadConfigFile } from "../../config/index.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { formatTable } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; + +export const sandboxListCommand = defineCommand({ + command: "list", + aliases: ["ls"], + describe: "List all sandboxes.", + examples: [["$0 sandbox list", "List all sandboxes"]], + + handler: async ({ output }) => { + const sandboxes = Object.entries(loadConfigFile()?.sandboxes ?? {}); + + if (output === "json") { + logger.log(JSON.stringify(Object.fromEntries(sandboxes), null, 2)); + return; + } + + if (sandboxes.length === 0) { + logger.info("No sandboxes found. Run: bunny sandbox create"); + return; + } + + logger.log( + formatTable( + ["Name", "App ID", "SSH"], + sandboxes.map(([name, s]) => [name, s.app_id, s.ssh_host ?? ""]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/sandbox/ssh-exec.ts b/packages/cli/src/commands/sandbox/ssh-exec.ts new file mode 100644 index 00000000..efdf05cc --- /dev/null +++ b/packages/cli/src/commands/sandbox/ssh-exec.ts @@ -0,0 +1,29 @@ +import type { SandboxRecord } from "../../config/schema.ts"; + +export const WORKPLACE = "/workplace"; + +export function sshArgs(record: SandboxRecord, remoteCmd: string): string[] { + const [host, portStr] = (record.ssh_host!.includes(":") + ? record.ssh_host!.split(":") + : [record.ssh_host!, "8023"]) as [string, string]; + + return [ + "sshpass", "-p", record.agent_token, + "ssh", + "-p", portStr, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + `root@${host}`, + remoteCmd, + ]; +} + +export async function runSshCommand(record: SandboxRecord, remoteCmd: string): Promise { + const proc = Bun.spawn(sshArgs(record, remoteCmd), { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + return proc.exited; +} diff --git a/packages/cli/src/commands/sandbox/ssh.ts b/packages/cli/src/commands/sandbox/ssh.ts new file mode 100644 index 00000000..9d85ac3f --- /dev/null +++ b/packages/cli/src/commands/sandbox/ssh.ts @@ -0,0 +1,48 @@ +import { getSandbox } from "../../config/index.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { WORKPLACE } from "./ssh-exec.ts"; + +export const sandboxSshCommand = defineCommand({ + command: "ssh ", + describe: "Open an interactive SSH session inside a sandbox.", + examples: [["$0 sandbox ssh my-sandbox", "Open a shell in my-sandbox"]], + + builder: (yargs) => + yargs.positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }), + + handler: async ({ name }) => { + const record = getSandbox(name); + if (!record) { + throw new UserError(`No sandbox named "${name}" found. Run: bunny sandbox create ${name}`); + } + if (!record.ssh_host) { + throw new UserError(`Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`); + } + + const [host, portStr] = (record.ssh_host.includes(":") + ? record.ssh_host.split(":") + : [record.ssh_host, "8023"]) as [string, string]; + + const proc = Bun.spawn( + [ + "sshpass", "-p", record.agent_token, + "ssh", + "-t", + "-p", portStr, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + `root@${host}`, + `cd ${WORKPLACE} && exec bash -l`, + ], + { stdin: "inherit", stdout: "inherit", stderr: "inherit" }, + ); + + process.exitCode = await proc.exited; + }, +}); diff --git a/packages/cli/src/commands/sandbox/url/add.ts b/packages/cli/src/commands/sandbox/url/add.ts new file mode 100644 index 00000000..34d52999 --- /dev/null +++ b/packages/cli/src/commands/sandbox/url/add.ts @@ -0,0 +1,103 @@ +import { createMcClient } from "@bunny.net/openapi-client"; +import { getSandbox, resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; + +interface AddArgs { + name: string; + port: number; + label?: string; +} + +export const sandboxUrlAddCommand = defineCommand({ + command: "add ", + describe: "Expose a port as a public CDN endpoint.", + examples: [ + ["$0 sandbox url add my-sandbox 3000", "Expose port 3000"], + ["$0 sandbox url add my-sandbox 8080 --label api", "Expose port 8080 as 'api'"], + ], + + builder: (yargs) => + yargs + .positional("name", { type: "string", demandOption: true, describe: "Sandbox name" }) + .positional("port", { type: "number", demandOption: true, describe: "Container port to expose" }) + .option("label", { type: "string", describe: "Endpoint display name (defaults to 'port-')" }), + + handler: async ({ name, port, label, profile, apiKey, verbose, output }) => { + const record = getSandbox(name); + if (!record) throw new UserError(`No sandbox named "${name}" found.`); + + const config = resolveConfig(profile, apiKey, verbose); + const client = createMcClient(clientOptions(config, verbose)); + + const spin = spinner("Fetching sandbox info..."); + spin.start(); + + // Get the container template ID from the app + const { data: app, error: appError } = await client.GET("/apps/{appId}", { + params: { path: { appId: record.app_id } }, + }); + if (appError || !app) { + spin.stop(); + throw new UserError(`Failed to fetch app: ${JSON.stringify(appError)}`); + } + + const containerId = (app as any).containerTemplates?.[0]?.id as string | undefined; + if (!containerId) { + spin.stop(); + throw new UserError("Could not find container template ID."); + } + + const displayName = label ?? `port-${port}`; + spin.text = `Creating endpoint "${displayName}"...`; + + const { data: ep, error: epError } = await (client as any).POST( + "/apps/{appId}/containers/{containerId}/endpoints", + { + params: { path: { appId: record.app_id, containerId } }, + body: { + displayName, + cdn: { + isSslEnabled: false, + portMappings: [{ containerPort: port, protocols: ["Tcp"] }], + }, + }, + }, + ); + + if (epError) { + spin.stop(); + throw new UserError(`Failed to create endpoint: ${JSON.stringify(epError)}`); + } + + const endpointId = (ep as any)?.id as string; + + // Poll until publicHost is assigned + spin.text = "Waiting for public URL..."; + const deadline = Date.now() + 60_000; + let publicHost: string | null = null; + while (Date.now() < deadline) { + await Bun.sleep(2000); + const { data: list } = await client.GET("/apps/{appId}/endpoints", { + params: { path: { appId: record.app_id } }, + }); + const found = (list?.items ?? []).find((e: any) => e.id === endpointId); + if (found?.publicHost) { publicHost = found.publicHost; break; } + } + + spin.stop(); + + if (output === "json") { + logger.log(JSON.stringify({ id: endpointId, displayName, port, publicHost }, null, 2)); + return; + } + + logger.log(`Endpoint "${displayName}" created.`); + logger.log(` Port: ${port}`); + logger.log(` ID: ${endpointId}`); + logger.log(` URL: ${publicHost ? `https://${publicHost}` : "— (still provisioning)"}`); + }, +}); diff --git a/packages/cli/src/commands/sandbox/url/delete.ts b/packages/cli/src/commands/sandbox/url/delete.ts new file mode 100644 index 00000000..a73ed98d --- /dev/null +++ b/packages/cli/src/commands/sandbox/url/delete.ts @@ -0,0 +1,66 @@ +import { createMcClient } from "@bunny.net/openapi-client"; +import { getSandbox, resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; + +interface DeleteArgs { + name: string; + "endpoint-name": string; + force: boolean; +} + +export const sandboxUrlDeleteCommand = defineCommand({ + command: "delete ", + aliases: ["rm"], + describe: "Delete a public endpoint from a sandbox.", + examples: [["$0 sandbox url delete my-sandbox port-3000", "Delete endpoint by name"]], + + builder: (yargs) => + yargs + .positional("name", { type: "string", demandOption: true, describe: "Sandbox name" }) + .positional("endpoint-name", { type: "string", demandOption: true, describe: "Endpoint name (from url list)" }) + .option("force", { alias: "f", type: "boolean", default: false, describe: "Skip confirmation" }), + + handler: async ({ name, "endpoint-name": endpointName, force, profile, apiKey, verbose }) => { + const record = getSandbox(name); + if (!record) throw new UserError(`No sandbox named "${name}" found.`); + + const config = resolveConfig(profile, apiKey, verbose); + const client = createMcClient(clientOptions(config, verbose)); + + const spin = spinner("Looking up endpoint..."); + spin.start(); + + const { data, error: listError } = await client.GET("/apps/{appId}/endpoints", { + params: { path: { appId: record.app_id } }, + }); + + spin.stop(); + + if (listError) throw new UserError(`Failed to fetch endpoints: ${JSON.stringify(listError)}`); + + const ep = (data?.items ?? [] as any[]).find((e: any) => e.displayName === endpointName); + if (!ep) throw new UserError(`No endpoint named "${endpointName}" found. Run: bunny sandbox url list ${name}`); + + if (!force) { + const ok = await confirm(`Delete endpoint "${endpointName}" from sandbox "${name}"?`); + if (!ok) { logger.info("Aborted."); return; } + } + + const spin2 = spinner("Deleting endpoint..."); + spin2.start(); + + const { error } = await client.DELETE("/apps/{appId}/endpoints/{endpointId}", { + params: { path: { appId: record.app_id, endpointId: ep.id } }, + }); + + spin2.stop(); + + if (error) throw new UserError(`Failed to delete endpoint: ${JSON.stringify(error)}`); + + logger.log(`Endpoint "${endpointName}" deleted.`); + }, +}); diff --git a/packages/cli/src/commands/sandbox/url/index.ts b/packages/cli/src/commands/sandbox/url/index.ts new file mode 100644 index 00000000..c5b88b0c --- /dev/null +++ b/packages/cli/src/commands/sandbox/url/index.ts @@ -0,0 +1,10 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { sandboxUrlAddCommand } from "./add.ts"; +import { sandboxUrlDeleteCommand } from "./delete.ts"; +import { sandboxUrlListCommand } from "./list.ts"; + +export const sandboxUrlNamespace = defineNamespace("url", "Manage public URL endpoints for a sandbox.", [ + sandboxUrlAddCommand, + sandboxUrlListCommand, + sandboxUrlDeleteCommand, +]); diff --git a/packages/cli/src/commands/sandbox/url/list.ts b/packages/cli/src/commands/sandbox/url/list.ts new file mode 100644 index 00000000..2b4d22ea --- /dev/null +++ b/packages/cli/src/commands/sandbox/url/list.ts @@ -0,0 +1,66 @@ +import { createMcClient } from "@bunny.net/openapi-client"; +import { getSandbox, resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; + +export const sandboxUrlListCommand = defineCommand({ + command: "list ", + aliases: ["ls"], + describe: "List public endpoints for a sandbox.", + examples: [["$0 sandbox url list my-sandbox", "List all endpoints"]], + + builder: (yargs) => + yargs.positional("name", { type: "string", demandOption: true, describe: "Sandbox name" }), + + handler: async ({ name, profile, apiKey, verbose, output }: any) => { + const record = getSandbox(name); + if (!record) throw new UserError(`No sandbox named "${name}" found.`); + + const config = resolveConfig(profile, apiKey, verbose); + const client = createMcClient(clientOptions(config, verbose)); + + const spin = spinner("Fetching endpoints..."); + spin.start(); + + const { data, error } = await client.GET("/apps/{appId}/endpoints", { + params: { path: { appId: record.app_id } }, + }); + + spin.stop(); + + if (error) throw new UserError(`Failed to fetch endpoints: ${JSON.stringify(error)}`); + + const DEFAULT_ENDPOINTS = new Set(["api", "ssh"]); + const items = ((data?.items ?? []) as any[]).filter( + (ep) => !DEFAULT_ENDPOINTS.has(ep.displayName), + ); + + if (output === "json") { + logger.log(JSON.stringify(items, null, 2)); + return; + } + + if (items.length === 0) { + logger.info("No endpoints found."); + return; + } + + logger.log( + formatTable( + ["ID", "Name", "Type", "Port", "URL"], + items.map((ep) => [ + ep.id ?? "", + ep.displayName ?? "", + ep.type ?? "", + String(ep.portMappings?.[0]?.containerPort ?? ""), + ep.publicHost ? `https://${ep.publicHost}` : "—", + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index a9e5de33..ccc7ebe1 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { logger } from "../core/logger.ts"; import { findConfigFile, getConfigWritePath } from "./paths.ts"; -import { type ConfigFile, ConfigFileSchema } from "./schema.ts"; +import { type ConfigFile, ConfigFileSchema, type SandboxRecord } from "./schema.ts"; export interface ResolvedConfig { apiKey: string; @@ -76,7 +76,7 @@ function saveConfigFile(data: ConfigFile, filePath?: string): void { } export function setProfile(profile: string, apiKey: string): void { - const existing = loadConfigFile() ?? { profiles: {} }; + const existing = loadConfigFile() ?? { profiles: {}, sandboxes: {} }; existing.profiles[profile] = { api_key: apiKey, @@ -97,3 +97,20 @@ export function profileExists(profile: string): boolean { const file = loadConfigFile(); return !!file?.profiles[profile]; } + +export function getSandbox(name: string): SandboxRecord | null { + return loadConfigFile()?.sandboxes?.[name] ?? null; +} + +export function setSandbox(name: string, record: SandboxRecord): void { + const existing = loadConfigFile() ?? { profiles: {}, sandboxes: {} }; + existing.sandboxes[name] = record; + saveConfigFile(existing); +} + +export function deleteSandbox(name: string): void { + const existing = loadConfigFile(); + if (!existing) return; + delete existing.sandboxes[name]; + saveConfigFile(existing); +} diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 055d11b4..1f2f9185 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -5,10 +5,18 @@ export const ProfileSchema = z.object({ api_url: z.string().optional(), }); +export const SandboxRecordSchema = z.object({ + app_id: z.string(), + agent_token: z.string(), + ssh_host: z.string().nullable().optional(), +}); + export const ConfigFileSchema = z.object({ log_level: z.string().optional(), profiles: z.record(z.string(), ProfileSchema).default({}), + sandboxes: z.record(z.string(), SandboxRecordSchema).default({}), }); export type Profile = z.infer; export type ConfigFile = z.infer; +export type SandboxRecord = z.infer; diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile new file mode 100644 index 00000000..94254e4f --- /dev/null +++ b/sandbox/Dockerfile @@ -0,0 +1,51 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# System tools + SSH + Python +RUN apt-get update && apt-get install -y \ + openssh-server \ + curl wget \ + nano vim \ + git \ + procps htop \ + net-tools iputils-ping \ + unzip zip \ + jq \ + python3 python3-pip python3-venv python3-dev \ + build-essential \ + && ln -sf /usr/bin/python3 /usr/bin/python \ + && ln -sf /usr/bin/pip3 /usr/bin/pip \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /var/run/sshd \ + && sed -i 's|#\?Port 22$|Port 8023|' /etc/ssh/sshd_config \ + && sed -i 's|#\?PermitRootLogin.*|PermitRootLogin yes|' /etc/ssh/sshd_config \ + && sed -i 's|#\?PasswordAuthentication.*|PasswordAuthentication yes|' /etc/ssh/sshd_config + +# Node.js (LTS) +RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Bun +RUN curl -fsSL https://bun.sh/install | bash +ENV PATH="/root/.bun/bin:$PATH" + +# Claude Code +RUN install -d -m 0755 /etc/apt/keyrings \ + && curl -fsSL https://downloads.claude.ai/keys/claude-code.asc \ + -o /etc/apt/keyrings/claude-code.asc \ + && echo "deb [signed-by=/etc/apt/keyrings/claude-code.asc] https://downloads.claude.ai/claude-code/apt/stable stable main" \ + > /etc/apt/sources.list.d/claude-code.list \ + && apt-get update && apt-get install -y claude-code \ + && rm -rf /var/lib/apt/lists/* + +# Workplace — SSH sessions land here, bun on PATH, Claude config lives here +ENV CLAUDE_CONFIG_DIR=/workplace/.claude +RUN mkdir -p /workplace \ + && printf 'cd /workplace\nexport PATH="/root/.bun/bin:$PATH"\n' >> /root/.bashrc + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/sandbox/entrypoint.sh b/sandbox/entrypoint.sh new file mode 100644 index 00000000..6144832c --- /dev/null +++ b/sandbox/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -e + +echo "root:${AGENT_TOKEN}" | chpasswd +exec /usr/sbin/sshd -D From c5b70b8b9a9d1de7de91c8c1d54e948dea0d2023 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Fri, 19 Jun 2026 12:00:25 +0200 Subject: [PATCH 02/16] fix the core style --- packages/cli/README.md | 24 ++-- packages/cli/src/commands/sandbox/create.ts | 129 +++++++++++------- packages/cli/src/commands/sandbox/delete.ts | 10 +- packages/cli/src/commands/sandbox/exec.ts | 15 +- packages/cli/src/commands/sandbox/index.ts | 20 +-- packages/cli/src/commands/sandbox/ssh-exec.ts | 28 ++-- packages/cli/src/commands/sandbox/ssh.ts | 32 +++-- packages/cli/src/commands/sandbox/url/add.ts | 47 +++++-- .../cli/src/commands/sandbox/url/delete.ts | 80 ++++++++--- .../cli/src/commands/sandbox/url/index.ts | 10 +- packages/cli/src/commands/sandbox/url/list.ts | 11 +- packages/cli/src/config/index.ts | 6 +- 12 files changed, 285 insertions(+), 127 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 0fbd93cd..9bec8530 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -808,9 +808,9 @@ bunny sandbox create my-sandbox bunny sandbox create my-sandbox --region NY ``` -| Flag | Description | Default | -| ---------- | ---------------------------------------------------- | ------- | -| `--region` | Region ID to deploy in (e.g. `AMS`, `NY`, `LA`, …) | `AMS` | +| Flag | Description | Default | +| ---------- | -------------------------------------------------- | ------- | +| `--region` | Region ID to deploy in (e.g. `AMS`, `NY`, `LA`, …) | `AMS` | Once ready, the output shows the app ID, public HTTPS hostname, and SSH address. @@ -837,9 +837,9 @@ bunny sandbox delete my-sandbox --force bunny sandbox rm my-sandbox -f # alias ``` -| Flag | Alias | Description | Default | -| --------- | ----- | ------------------------- | ------- | -| `--force` | `-f` | Skip confirmation prompt | `false` | +| Flag | Alias | Description | Default | +| --------- | ----- | ------------------------ | ------- | +| `--force` | `-f` | Skip confirmation prompt | `false` | #### `bunny sandbox exec` @@ -856,9 +856,9 @@ bunny sandbox exec my-sandbox --cwd /tmp env bunny sandbox exec my-sandbox -- cat /etc/os-release ``` -| Flag | Description | Default | -| ------- | ---------------------------------------- | ------------ | -| `--cwd` | Working directory inside the sandbox | `/workplace` | +| Flag | Description | Default | +| ------- | ------------------------------------ | ------------ | +| `--cwd` | Working directory inside the sandbox | `/workplace` | #### `bunny sandbox ssh` @@ -884,9 +884,9 @@ bunny sandbox url add my-sandbox 3000 bunny sandbox url add my-sandbox 8080 --label my-api ``` -| Flag | Description | Default | -| --------- | ---------------------------------------------- | ---------------- | -| `--label` | Display name for the endpoint | `port-` | +| Flag | Description | Default | +| --------- | ----------------------------- | ------------- | +| `--label` | Display name for the endpoint | `port-` | ##### `bunny sandbox url list` diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts index 9bece7b3..20119e6a 100644 --- a/packages/cli/src/commands/sandbox/create.ts +++ b/packages/cli/src/commands/sandbox/create.ts @@ -1,5 +1,5 @@ -import { createMcClient } from "@bunny.net/openapi-client"; import { randomBytes } from "node:crypto"; +import { createMcClient } from "@bunny.net/openapi-client"; import { resolveConfig, setSandbox } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -19,7 +19,11 @@ const STARTUP_TIMEOUT_MS = 120_000; type App = Record & { id?: string; status?: string; - containerTemplates?: Array<{ endpoints?: Array & { type?: string; publicHost?: string }> }>; + containerTemplates?: Array<{ + endpoints?: Array< + Record & { type?: string; publicHost?: string } + >; + }>; }; function generateToken(): string { @@ -39,7 +43,11 @@ function extractAnycastHost(app: App): string | null { async function probeSsh(host: string, port: number): Promise { try { - const socket = await Bun.connect({ hostname: host, port, socket: { data() {}, open() {}, close() {}, error() {} } }); + const socket = await Bun.connect({ + hostname: host, + port, + socket: { data() {}, open() {}, close() {}, error() {} }, + }); socket.end(); return true; } catch { @@ -60,9 +68,12 @@ async function waitUntilActive( const { data, error } = await client.GET("/apps/{appId}", { params: { path: { appId } }, }); - if (error) throw new UserError(`Failed to poll app: ${JSON.stringify(error)}`); + if (error) + throw new UserError(`Failed to poll app: ${JSON.stringify(error)}`); const app = data as App; - const status = (app as Record).status as string | undefined; + const status = (app as Record).status as + | string + | undefined; if (status === "failing" || status === "suspended") { throw new UserError(`Sandbox entered terminal state: ${status}`); } @@ -72,18 +83,24 @@ async function waitUntilActive( } if (!sshHost) { - throw new UserError(`Sandbox SSH endpoint was not assigned within ${STARTUP_TIMEOUT_MS / 1000}s`); + throw new UserError( + `Sandbox SSH endpoint was not assigned within ${STARTUP_TIMEOUT_MS / 1000}s`, + ); } // Phase 2: probe SSH port until the container accepts connections - const [sshIp, sshPortStr] = (sshHost.includes(":") ? sshHost.split(":") : [sshHost, "8023"]) as [string, string]; + const [sshIp, sshPortStr] = ( + sshHost.includes(":") ? sshHost.split(":") : [sshHost, "8023"] + ) as [string, string]; const sshPort = Number(sshPortStr); while (Date.now() < deadline) { if (await probeSsh(sshIp, sshPort)) return { sshHost }; await Bun.sleep(POLL_INTERVAL_MS); } - throw new UserError(`Sandbox SSH did not become reachable within ${STARTUP_TIMEOUT_MS / 1000}s`); + throw new UserError( + `Sandbox SSH did not become reachable within ${STARTUP_TIMEOUT_MS / 1000}s`, + ); } interface CreateArgs { @@ -97,7 +114,10 @@ export const sandboxCreateCommand = defineCommand({ examples: [ ["$0 sandbox create", "Create a sandbox with a generated name"], ["$0 sandbox create my-sandbox", "Create a sandbox named my-sandbox"], - ["$0 sandbox create my-sandbox --region NY", "Create a sandbox in New York"], + [ + "$0 sandbox create my-sandbox --region NY", + "Create a sandbox in New York", + ], ], builder: (yargs) => @@ -121,47 +141,56 @@ export const sandboxCreateCommand = defineCommand({ const spin = spinner("Creating sandbox..."); spin.start(); - const { data: app, error: createError } = await (client as any).POST("/apps", { - body: { - name, - runtimeType: "shared", - autoScaling: { min: 1, max: 1 }, - regionSettings: { - allowedRegionIds: [region], - requiredRegionIds: [region], - }, - volumes: [ - { name: "workplace", size: 10 }, - ], - containerTemplates: [ - { - name: "agent", - imageRegistryId: IMAGE_REGISTRY_ID, - imageNamespace: IMAGE_NAMESPACE, - imageName: IMAGE_NAME, - imageTag: IMAGE_TAG, - imagePullPolicy: "ifNotPresent", - environmentVariables: [{ name: "AGENT_TOKEN", value: agentToken }], - volumeMounts: [ - { name: "workplace", mountPath: WORKPLACE }, - ], - endpoints: [ - { - displayName: "ssh", - anycast: { - type: "ipv4", - portMappings: [{ containerPort: 8023, exposedPort: 8023, protocols: ["Tcp"] }], - }, - }, - ], + const { data: app, error: createError } = await (client as any).POST( + "/apps", + { + body: { + name, + runtimeType: "shared", + autoScaling: { min: 1, max: 1 }, + regionSettings: { + allowedRegionIds: [region], + requiredRegionIds: [region], }, - ], + volumes: [{ name: "workplace", size: 10 }], + containerTemplates: [ + { + name: "agent", + imageRegistryId: IMAGE_REGISTRY_ID, + imageNamespace: IMAGE_NAMESPACE, + imageName: IMAGE_NAME, + imageTag: IMAGE_TAG, + imagePullPolicy: "ifNotPresent", + environmentVariables: [ + { name: "AGENT_TOKEN", value: agentToken }, + ], + volumeMounts: [{ name: "workplace", mountPath: WORKPLACE }], + endpoints: [ + { + displayName: "ssh", + anycast: { + type: "ipv4", + portMappings: [ + { + containerPort: 8023, + exposedPort: 8023, + protocols: ["Tcp"], + }, + ], + }, + }, + ], + }, + ], + }, }, - }); + ); if (createError || !app) { spin.stop(); - throw new UserError(`Failed to create sandbox: ${JSON.stringify(createError)}`); + throw new UserError( + `Failed to create sandbox: ${JSON.stringify(createError)}`, + ); } const appId = (app as Record).id as string; @@ -173,13 +202,19 @@ export const sandboxCreateCommand = defineCommand({ } catch (err) { spin.stop(); // best-effort cleanup - await client.DELETE("/apps/{appId}", { params: { path: { appId } } }).catch(() => {}); + await client + .DELETE("/apps/{appId}", { params: { path: { appId } } }) + .catch(() => {}); throw err; } spin.stop(); - const record = { app_id: appId, agent_token: agentToken, ssh_host: sshHost }; + const record = { + app_id: appId, + agent_token: agentToken, + ssh_host: sshHost, + }; setSandbox(name, record); logger.log(`Sandbox "${name}" is ready.`); diff --git a/packages/cli/src/commands/sandbox/delete.ts b/packages/cli/src/commands/sandbox/delete.ts index adc69344..eed40570 100644 --- a/packages/cli/src/commands/sandbox/delete.ts +++ b/packages/cli/src/commands/sandbox/delete.ts @@ -1,5 +1,9 @@ import { createMcClient } from "@bunny.net/openapi-client"; -import { deleteSandbox, getSandbox, resolveConfig } from "../../config/index.ts"; +import { + deleteSandbox, + getSandbox, + resolveConfig, +} from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; @@ -41,7 +45,9 @@ export const sandboxDeleteCommand = defineCommand({ } if (!force) { - const ok = await confirm(`Delete sandbox "${name}" (app ${record.app_id})?`); + const ok = await confirm( + `Delete sandbox "${name}" (app ${record.app_id})?`, + ); if (!ok) { logger.info("Aborted."); return; diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts index 3b7a8103..c7d89be9 100644 --- a/packages/cli/src/commands/sandbox/exec.ts +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -1,7 +1,7 @@ import { getSandbox } from "../../config/index.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; -import { WORKPLACE, sshArgs } from "./ssh-exec.ts"; +import { sshArgs, WORKPLACE } from "./ssh-exec.ts"; interface ExecArgs { name: string; @@ -14,7 +14,10 @@ export const sandboxExecCommand = defineCommand({ describe: "Run a shell command inside a sandbox via SSH.", examples: [ ["$0 sandbox exec my-sandbox uname -a", "Run a command"], - ["$0 sandbox exec my-sandbox --cwd /tmp ls -la", "Run with a working directory"], + [ + "$0 sandbox exec my-sandbox --cwd /tmp ls -la", + "Run with a working directory", + ], ], builder: (yargs) => @@ -40,10 +43,14 @@ export const sandboxExecCommand = defineCommand({ handler: async ({ name, command, cwd }) => { const record = getSandbox(name); if (!record) { - throw new UserError(`No sandbox named "${name}" found. Run: bunny sandbox create ${name}`); + throw new UserError( + `No sandbox named "${name}" found. Run: bunny sandbox create ${name}`, + ); } if (!record.ssh_host) { - throw new UserError(`Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`); + throw new UserError( + `Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`, + ); } const remoteCmd = `cd ${cwd} && ${command.join(" ")}`; diff --git a/packages/cli/src/commands/sandbox/index.ts b/packages/cli/src/commands/sandbox/index.ts index 913fa100..e2f79478 100644 --- a/packages/cli/src/commands/sandbox/index.ts +++ b/packages/cli/src/commands/sandbox/index.ts @@ -6,11 +6,15 @@ import { sandboxListCommand } from "./list.ts"; import { sandboxSshCommand } from "./ssh.ts"; import { sandboxUrlNamespace } from "./url/index.ts"; -export const sandboxNamespace = defineNamespace("sandbox", "Manage sandboxes.", [ - sandboxCreateCommand, - sandboxListCommand, - sandboxDeleteCommand, - sandboxExecCommand, - sandboxSshCommand, - sandboxUrlNamespace, -]); +export const sandboxNamespace = defineNamespace( + "sandbox", + "Manage sandboxes.", + [ + sandboxCreateCommand, + sandboxListCommand, + sandboxDeleteCommand, + sandboxExecCommand, + sandboxSshCommand, + sandboxUrlNamespace, + ], +); diff --git a/packages/cli/src/commands/sandbox/ssh-exec.ts b/packages/cli/src/commands/sandbox/ssh-exec.ts index efdf05cc..5fd5958b 100644 --- a/packages/cli/src/commands/sandbox/ssh-exec.ts +++ b/packages/cli/src/commands/sandbox/ssh-exec.ts @@ -3,23 +3,33 @@ import type { SandboxRecord } from "../../config/schema.ts"; export const WORKPLACE = "/workplace"; export function sshArgs(record: SandboxRecord, remoteCmd: string): string[] { - const [host, portStr] = (record.ssh_host!.includes(":") - ? record.ssh_host!.split(":") - : [record.ssh_host!, "8023"]) as [string, string]; + const sshHost = record.ssh_host ?? "localhost"; + const [host, portStr] = ( + sshHost.includes(":") ? sshHost.split(":") : [sshHost, "8023"] + ) as [string, string]; return [ - "sshpass", "-p", record.agent_token, + "sshpass", + "-p", + record.agent_token, "ssh", - "-p", portStr, - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", + "-p", + portStr, + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", `root@${host}`, remoteCmd, ]; } -export async function runSshCommand(record: SandboxRecord, remoteCmd: string): Promise { +export async function runSshCommand( + record: SandboxRecord, + remoteCmd: string, +): Promise { const proc = Bun.spawn(sshArgs(record, remoteCmd), { stdin: "inherit", stdout: "inherit", diff --git a/packages/cli/src/commands/sandbox/ssh.ts b/packages/cli/src/commands/sandbox/ssh.ts index 9d85ac3f..024b8cff 100644 --- a/packages/cli/src/commands/sandbox/ssh.ts +++ b/packages/cli/src/commands/sandbox/ssh.ts @@ -18,25 +18,37 @@ export const sandboxSshCommand = defineCommand({ handler: async ({ name }) => { const record = getSandbox(name); if (!record) { - throw new UserError(`No sandbox named "${name}" found. Run: bunny sandbox create ${name}`); + throw new UserError( + `No sandbox named "${name}" found. Run: bunny sandbox create ${name}`, + ); } if (!record.ssh_host) { - throw new UserError(`Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`); + throw new UserError( + `Sandbox "${name}" has no SSH endpoint recorded. Re-create it.`, + ); } - const [host, portStr] = (record.ssh_host.includes(":") - ? record.ssh_host.split(":") - : [record.ssh_host, "8023"]) as [string, string]; + const [host, portStr] = ( + record.ssh_host.includes(":") + ? record.ssh_host.split(":") + : [record.ssh_host, "8023"] + ) as [string, string]; const proc = Bun.spawn( [ - "sshpass", "-p", record.agent_token, + "sshpass", + "-p", + record.agent_token, "ssh", "-t", - "-p", portStr, - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", + "-p", + portStr, + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", `root@${host}`, `cd ${WORKPLACE} && exec bash -l`, ], diff --git a/packages/cli/src/commands/sandbox/url/add.ts b/packages/cli/src/commands/sandbox/url/add.ts index 34d52999..f50219d0 100644 --- a/packages/cli/src/commands/sandbox/url/add.ts +++ b/packages/cli/src/commands/sandbox/url/add.ts @@ -17,14 +17,28 @@ export const sandboxUrlAddCommand = defineCommand({ describe: "Expose a port as a public CDN endpoint.", examples: [ ["$0 sandbox url add my-sandbox 3000", "Expose port 3000"], - ["$0 sandbox url add my-sandbox 8080 --label api", "Expose port 8080 as 'api'"], + [ + "$0 sandbox url add my-sandbox 8080 --label api", + "Expose port 8080 as 'api'", + ], ], builder: (yargs) => yargs - .positional("name", { type: "string", demandOption: true, describe: "Sandbox name" }) - .positional("port", { type: "number", demandOption: true, describe: "Container port to expose" }) - .option("label", { type: "string", describe: "Endpoint display name (defaults to 'port-')" }), + .positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }) + .positional("port", { + type: "number", + demandOption: true, + describe: "Container port to expose", + }) + .option("label", { + type: "string", + describe: "Endpoint display name (defaults to 'port-')", + }), handler: async ({ name, port, label, profile, apiKey, verbose, output }) => { const record = getSandbox(name); @@ -45,7 +59,9 @@ export const sandboxUrlAddCommand = defineCommand({ throw new UserError(`Failed to fetch app: ${JSON.stringify(appError)}`); } - const containerId = (app as any).containerTemplates?.[0]?.id as string | undefined; + const containerId = (app as any).containerTemplates?.[0]?.id as + | string + | undefined; if (!containerId) { spin.stop(); throw new UserError("Could not find container template ID."); @@ -70,7 +86,9 @@ export const sandboxUrlAddCommand = defineCommand({ if (epError) { spin.stop(); - throw new UserError(`Failed to create endpoint: ${JSON.stringify(epError)}`); + throw new UserError( + `Failed to create endpoint: ${JSON.stringify(epError)}`, + ); } const endpointId = (ep as any)?.id as string; @@ -85,19 +103,30 @@ export const sandboxUrlAddCommand = defineCommand({ params: { path: { appId: record.app_id } }, }); const found = (list?.items ?? []).find((e: any) => e.id === endpointId); - if (found?.publicHost) { publicHost = found.publicHost; break; } + if (found?.publicHost) { + publicHost = found.publicHost; + break; + } } spin.stop(); if (output === "json") { - logger.log(JSON.stringify({ id: endpointId, displayName, port, publicHost }, null, 2)); + logger.log( + JSON.stringify( + { id: endpointId, displayName, port, publicHost }, + null, + 2, + ), + ); return; } logger.log(`Endpoint "${displayName}" created.`); logger.log(` Port: ${port}`); logger.log(` ID: ${endpointId}`); - logger.log(` URL: ${publicHost ? `https://${publicHost}` : "— (still provisioning)"}`); + logger.log( + ` URL: ${publicHost ? `https://${publicHost}` : "— (still provisioning)"}`, + ); }, }); diff --git a/packages/cli/src/commands/sandbox/url/delete.ts b/packages/cli/src/commands/sandbox/url/delete.ts index a73ed98d..7643ed93 100644 --- a/packages/cli/src/commands/sandbox/url/delete.ts +++ b/packages/cli/src/commands/sandbox/url/delete.ts @@ -16,15 +16,37 @@ export const sandboxUrlDeleteCommand = defineCommand({ command: "delete ", aliases: ["rm"], describe: "Delete a public endpoint from a sandbox.", - examples: [["$0 sandbox url delete my-sandbox port-3000", "Delete endpoint by name"]], + examples: [ + ["$0 sandbox url delete my-sandbox port-3000", "Delete endpoint by name"], + ], builder: (yargs) => yargs - .positional("name", { type: "string", demandOption: true, describe: "Sandbox name" }) - .positional("endpoint-name", { type: "string", demandOption: true, describe: "Endpoint name (from url list)" }) - .option("force", { alias: "f", type: "boolean", default: false, describe: "Skip confirmation" }), - - handler: async ({ name, "endpoint-name": endpointName, force, profile, apiKey, verbose }) => { + .positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }) + .positional("endpoint-name", { + type: "string", + demandOption: true, + describe: "Endpoint name (from url list)", + }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation", + }), + + handler: async ({ + name, + "endpoint-name": endpointName, + force, + profile, + apiKey, + verbose, + }) => { const record = getSandbox(name); if (!record) throw new UserError(`No sandbox named "${name}" found.`); @@ -34,32 +56,54 @@ export const sandboxUrlDeleteCommand = defineCommand({ const spin = spinner("Looking up endpoint..."); spin.start(); - const { data, error: listError } = await client.GET("/apps/{appId}/endpoints", { - params: { path: { appId: record.app_id } }, - }); + const { data, error: listError } = await client.GET( + "/apps/{appId}/endpoints", + { + params: { path: { appId: record.app_id } }, + }, + ); spin.stop(); - if (listError) throw new UserError(`Failed to fetch endpoints: ${JSON.stringify(listError)}`); + if (listError) + throw new UserError( + `Failed to fetch endpoints: ${JSON.stringify(listError)}`, + ); - const ep = (data?.items ?? [] as any[]).find((e: any) => e.displayName === endpointName); - if (!ep) throw new UserError(`No endpoint named "${endpointName}" found. Run: bunny sandbox url list ${name}`); + const ep = (data?.items ?? ([] as any[])).find( + (e: any) => e.displayName === endpointName, + ); + if (!ep) + throw new UserError( + `No endpoint named "${endpointName}" found. Run: bunny sandbox url list ${name}`, + ); if (!force) { - const ok = await confirm(`Delete endpoint "${endpointName}" from sandbox "${name}"?`); - if (!ok) { logger.info("Aborted."); return; } + const ok = await confirm( + `Delete endpoint "${endpointName}" from sandbox "${name}"?`, + ); + if (!ok) { + logger.info("Aborted."); + return; + } } const spin2 = spinner("Deleting endpoint..."); spin2.start(); - const { error } = await client.DELETE("/apps/{appId}/endpoints/{endpointId}", { - params: { path: { appId: record.app_id, endpointId: ep.id } }, - }); + const { error } = await client.DELETE( + "/apps/{appId}/endpoints/{endpointId}", + { + params: { path: { appId: record.app_id, endpointId: ep.id } }, + }, + ); spin2.stop(); - if (error) throw new UserError(`Failed to delete endpoint: ${JSON.stringify(error)}`); + if (error) + throw new UserError( + `Failed to delete endpoint: ${JSON.stringify(error)}`, + ); logger.log(`Endpoint "${endpointName}" deleted.`); }, diff --git a/packages/cli/src/commands/sandbox/url/index.ts b/packages/cli/src/commands/sandbox/url/index.ts index c5b88b0c..1cc964f8 100644 --- a/packages/cli/src/commands/sandbox/url/index.ts +++ b/packages/cli/src/commands/sandbox/url/index.ts @@ -3,8 +3,8 @@ import { sandboxUrlAddCommand } from "./add.ts"; import { sandboxUrlDeleteCommand } from "./delete.ts"; import { sandboxUrlListCommand } from "./list.ts"; -export const sandboxUrlNamespace = defineNamespace("url", "Manage public URL endpoints for a sandbox.", [ - sandboxUrlAddCommand, - sandboxUrlListCommand, - sandboxUrlDeleteCommand, -]); +export const sandboxUrlNamespace = defineNamespace( + "url", + "Manage public URL endpoints for a sandbox.", + [sandboxUrlAddCommand, sandboxUrlListCommand, sandboxUrlDeleteCommand], +); diff --git a/packages/cli/src/commands/sandbox/url/list.ts b/packages/cli/src/commands/sandbox/url/list.ts index 2b4d22ea..4e639c45 100644 --- a/packages/cli/src/commands/sandbox/url/list.ts +++ b/packages/cli/src/commands/sandbox/url/list.ts @@ -14,7 +14,11 @@ export const sandboxUrlListCommand = defineCommand({ examples: [["$0 sandbox url list my-sandbox", "List all endpoints"]], builder: (yargs) => - yargs.positional("name", { type: "string", demandOption: true, describe: "Sandbox name" }), + yargs.positional("name", { + type: "string", + demandOption: true, + describe: "Sandbox name", + }), handler: async ({ name, profile, apiKey, verbose, output }: any) => { const record = getSandbox(name); @@ -32,7 +36,10 @@ export const sandboxUrlListCommand = defineCommand({ spin.stop(); - if (error) throw new UserError(`Failed to fetch endpoints: ${JSON.stringify(error)}`); + if (error) + throw new UserError( + `Failed to fetch endpoints: ${JSON.stringify(error)}`, + ); const DEFAULT_ENDPOINTS = new Set(["api", "ssh"]); const items = ((data?.items ?? []) as any[]).filter( diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index ccc7ebe1..dca92a5d 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -2,7 +2,11 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { logger } from "../core/logger.ts"; import { findConfigFile, getConfigWritePath } from "./paths.ts"; -import { type ConfigFile, ConfigFileSchema, type SandboxRecord } from "./schema.ts"; +import { + type ConfigFile, + ConfigFileSchema, + type SandboxRecord, +} from "./schema.ts"; export interface ResolvedConfig { apiKey: string; From 5ceffadd65081f895301629588b33e5ebd375408 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Mon, 22 Jun 2026 14:28:27 +0200 Subject: [PATCH 03/16] add prompt to sandbox names --- packages/cli/src/commands/sandbox/create.ts | 33 ++++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts index 20119e6a..fb0a7102 100644 --- a/packages/cli/src/commands/sandbox/create.ts +++ b/packages/cli/src/commands/sandbox/create.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto"; import { createMcClient } from "@bunny.net/openapi-client"; +import prompts from "prompts"; import { resolveConfig, setSandbox } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -104,7 +105,7 @@ async function waitUntilActive( } interface CreateArgs { - name: string; + name?: string; region: string; } @@ -112,7 +113,7 @@ export const sandboxCreateCommand = defineCommand({ command: "create [name]", describe: "Create and start a new sandbox.", examples: [ - ["$0 sandbox create", "Create a sandbox with a generated name"], + ["$0 sandbox create", "Interactive: prompts for a sandbox name"], ["$0 sandbox create my-sandbox", "Create a sandbox named my-sandbox"], [ "$0 sandbox create my-sandbox --region NY", @@ -124,7 +125,6 @@ export const sandboxCreateCommand = defineCommand({ yargs .positional("name", { type: "string", - default: "sandbox", describe: "Name for the sandbox", }) .option("region", { @@ -133,9 +133,24 @@ export const sandboxCreateCommand = defineCommand({ describe: "Region ID to deploy the sandbox in (e.g. AMS, NY, LA)", }), - handler: async ({ profile, verbose, apiKey, name, region }) => { + handler: async ({ profile, verbose, apiKey, name, region, output }) => { const config = resolveConfig(profile, apiKey, verbose); const client = createMcClient(clientOptions(config, verbose)); + + // JSON output stays non-interactive; the name must come from the positional. + const interactive = output !== "json"; + + let sandboxName = name; + if (!sandboxName && interactive) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Sandbox name:", + }); + sandboxName = value; + } + if (!sandboxName) throw new UserError("A sandbox name is required."); + const agentToken = generateToken(); const spin = spinner("Creating sandbox..."); @@ -145,7 +160,7 @@ export const sandboxCreateCommand = defineCommand({ "/apps", { body: { - name, + name: sandboxName, runtimeType: "shared", autoScaling: { min: 1, max: 1 }, regionSettings: { @@ -215,11 +230,13 @@ export const sandboxCreateCommand = defineCommand({ agent_token: agentToken, ssh_host: sshHost, }; - setSandbox(name, record); + setSandbox(sandboxName, record); - logger.log(`Sandbox "${name}" is ready.`); + logger.log(`Sandbox "${sandboxName}" is ready.`); logger.log(` App ID: ${appId}`); logger.log(` SSH: ${sshHost}`); - logger.log(`\nRun commands with: bunny sandbox exec ${name} `); + logger.log( + `\nRun commands with: bunny sandbox exec ${sandboxName} `, + ); }, }); From 5406ea140535c864164d68bcef35a1dc0346ef03 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 29 Jun 2026 11:35:14 +0100 Subject: [PATCH 04/16] feat(packages/sandbox): sandbox sdk poc (#101) * feat(packages/sandbox): sandbox sdk poc * fix(packages/sandbox): address Greptile review - validate env var names before shell interpolation (block injection) - reuse a single SFTP channel instead of opening one per file op - delete the app if exposePort fails during create() to avoid orphans - fast-fail waitForPublicHost on terminal app states - drop the unreachable throw lastErr in withRegistryRetry * fix(packages/sandbox): address Codex review - readFile returns null on SFTP NO_SUCH_FILE (numeric 2), not just ENOENT - resolve the MC client lazily so fromHandle reconnects need no API key - make port exposure idempotent and treat a slow host assignment as pending, not a failed create - thread the abort signal through the SSH reachability wait - ship @types/ssh2 as a runtime dependency for downstream consumers - settle detached commands on stream error instead of leaving wait() hanging * fmt --- .changeset/sandbox-sdk.md | 6 + AGENTS.md | 20 +- CLAUDE.md | 5 +- bun.lock | 42 ++- packages/cli/package.json | 1 + packages/cli/src/commands/sandbox/create.ts | 200 ++----------- packages/cli/src/commands/sandbox/delete.ts | 33 ++- packages/cli/src/commands/sandbox/url/add.ts | 94 ++---- packages/sandbox/README.md | 98 ++++++ packages/sandbox/package.json | 21 ++ packages/sandbox/src/command.ts | 104 +++++++ packages/sandbox/src/errors.ts | 6 + packages/sandbox/src/index.ts | 12 + packages/sandbox/src/integration.test.ts | 58 ++++ packages/sandbox/src/provision.ts | 294 ++++++++++++++++++ packages/sandbox/src/sandbox.test.ts | 85 ++++++ packages/sandbox/src/sandbox.ts | 296 +++++++++++++++++++ packages/sandbox/src/transport.ts | 173 +++++++++++ packages/sandbox/src/types.ts | 70 +++++ packages/sandbox/tsconfig.json | 4 + 20 files changed, 1362 insertions(+), 260 deletions(-) create mode 100644 .changeset/sandbox-sdk.md create mode 100644 packages/sandbox/README.md create mode 100644 packages/sandbox/package.json create mode 100644 packages/sandbox/src/command.ts create mode 100644 packages/sandbox/src/errors.ts create mode 100644 packages/sandbox/src/index.ts create mode 100644 packages/sandbox/src/integration.test.ts create mode 100644 packages/sandbox/src/provision.ts create mode 100644 packages/sandbox/src/sandbox.test.ts create mode 100644 packages/sandbox/src/sandbox.ts create mode 100644 packages/sandbox/src/transport.ts create mode 100644 packages/sandbox/src/types.ts create mode 100644 packages/sandbox/tsconfig.json diff --git a/.changeset/sandbox-sdk.md b/.changeset/sandbox-sdk.md new file mode 100644 index 00000000..2b8e8835 --- /dev/null +++ b/.changeset/sandbox-sdk.md @@ -0,0 +1,6 @@ +--- +"@bunny.net/sandbox": minor +"@bunny.net/cli": minor +--- + +Add @bunny.net/sandbox SDK for programmatic sandbox create, file buffering, command execution, and port exposure; wire sandbox CLI commands onto it diff --git a/AGENTS.md b/AGENTS.md index e2a4d052..884008b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,13 +69,14 @@ Bun replaces the entire Node.js toolchain. There are no separate tools for trans ## Project Structure -This is a Bun workspace monorepo with four packages: +This is a Bun workspace monorepo with six packages: - **`@bunny.net/openapi-client`** (`packages/openapi-client/`) — Standalone, type-safe OpenAPI client for bunny.net, generated from OpenAPI specs. Zero CLI dependencies. Publishable to npm. - **`@bunny.net/app-config`** (`packages/app-config/`) — Shared app configuration schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. Used by the CLI and potentially other tools. - **`@bunny.net/database-shell`** (`packages/database-shell/`) — Standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`). - **`@bunny.net/scriptable-dns-types`** (`packages/scriptable-dns-types/`): Ambient TypeScript declarations for the Scriptable DNS runtime globals (`ARecord`, `Monitoring`, `RoutingEngine`, etc.). Types-only, no runtime code: the DNS runtime can't `import`, so these power editor autocomplete and an optional typecheck step. Scaffolded into projects by `bunny dns scripts init`; intended to also feed the dashboard editor. Publishable to npm. -- **`@bunny.net/cli`** (`packages/cli/`) — The CLI. Depends on `@bunny.net/openapi-client`, `@bunny.net/app-config`, and `@bunny.net/database-shell`. +- **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Zero CLI dependencies. +- **`@bunny.net/cli`** (`packages/cli/`) — The CLI. Depends on `@bunny.net/openapi-client`, `@bunny.net/app-config`, `@bunny.net/database-shell`, `@bunny.net/scriptable-dns-types`, and `@bunny.net/sandbox`. ``` bunny-cli/ @@ -150,6 +151,19 @@ bunny-cli/ │ │ ├── types.ts # ShellLogger, ShellOptions, PrintMode │ │ └── shell.test.ts # Tests for shell utilities │ │ +│ ├── sandbox/ # @bunny.net/sandbox package +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ └── src/ +│ │ ├── index.ts # Barrel export: Sandbox, Command, types +│ │ ├── sandbox.ts # Sandbox class: create/get/fromHandle, runCommand, writeFiles, readFile, mkDir, exposePort, domain, delete +│ │ ├── provision.ts # Magic Containers app create/poll/endpoints + auth helpers +│ │ ├── transport.ts # ssh2 SSH/SFTP transport (exec, file IO, reachability) +│ │ ├── command.ts # Command (detached, logs()) and CommandFinished +│ │ ├── types.ts # Option and handle types +│ │ ├── errors.ts # SandboxError +│ │ └── sandbox.test.ts # Tests for pure logic (command building, app extraction) +│ │ │ └── cli/ # @bunny.net/cli package │ ├── package.json │ ├── tsconfig.json @@ -366,7 +380,7 @@ bunny-cli/ ### Conventions -- **Monorepo with Bun workspaces.** `packages/openapi-client/` is the standalone API client SDK; `packages/app-config/` provides shared Zod schemas, types, and API conversion functions for `bunny.jsonc`; `packages/database-shell/` is the standalone SQL shell engine; `packages/cli/` is the CLI. +- **Monorepo with Bun workspaces.** `packages/openapi-client/` is the standalone API client SDK; `packages/app-config/` provides shared Zod schemas, types, and API conversion functions for `bunny.jsonc`; `packages/database-shell/` is the standalone SQL shell engine; `packages/sandbox/` is the standalone sandbox SDK (provisioning + SSH transport); `packages/cli/` is the CLI. - **API clients use `ClientOptions`** — an options object with `apiKey`, `baseUrl`, `verbose`, `userAgent`, and `onDebug`. The CLI provides a `clientOptions(config, verbose)` helper to build this from `ResolvedConfig`. - **One command per file.** Each file in `commands/` exports a single command or namespace. - **Commands are grouped by domain** in subdirectories (`config/`, `db/`, `scripts/`). diff --git a/CLAUDE.md b/CLAUDE.md index 127c9c32..581a29c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,12 +26,13 @@ test("example", () => { ## Monorepo structure -This is a Bun workspace monorepo with four packages: +This is a Bun workspace monorepo with five packages: - `packages/openapi-client/` (`@bunny.net/openapi-client`) — standalone, type-safe OpenAPI client, zero CLI deps - `packages/app-config/` (`@bunny.net/app-config`) — shared Zod schemas, types, and JSON Schema for `bunny.jsonc` - `packages/database-shell/` (`@bunny.net/database-shell`) — standalone SQL shell engine (REPL, formatting, masking) -- `packages/cli/` (`@bunny.net/cli`) — the CLI, depends on all three +- `packages/sandbox/` (`@bunny.net/sandbox`) — standalone sandbox SDK (create, file buffering, command exec, port exposure) over Magic Containers + SSH +- `packages/cli/` (`@bunny.net/cli`) — the CLI, depends on the other four ## Project conventions diff --git a/bun.lock b/bun.lock index 22350339..a08528a4 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@bunny.net/database-shell": "workspace:*", "@bunny.net/database-studio": "workspace:*", "@bunny.net/openapi-client": "workspace:*", + "@bunny.net/sandbox": "workspace:*", "@libsql/client": "^0.17.0", "@types/prompts": "^2.4.9", "@types/yargs": "^17.0.35", @@ -46,11 +47,11 @@ "zod": "^4.3.6", }, "optionalDependencies": { - "@bunny.net/cli-darwin-arm64": "0.4.2", - "@bunny.net/cli-darwin-x64": "0.4.2", - "@bunny.net/cli-linux-arm64": "0.4.2", - "@bunny.net/cli-linux-x64": "0.4.2", - "@bunny.net/cli-windows-x64": "0.4.2", + "@bunny.net/cli-darwin-arm64": "0.7.0", + "@bunny.net/cli-darwin-x64": "0.7.0", + "@bunny.net/cli-linux-arm64": "0.7.0", + "@bunny.net/cli-linux-x64": "0.7.0", + "@bunny.net/cli-windows-x64": "0.7.0", }, }, "packages/cli-darwin-arm64": { @@ -181,6 +182,15 @@ "typescript": "^5", }, }, + "packages/sandbox": { + "name": "@bunny.net/sandbox", + "version": "0.1.0", + "dependencies": { + "@bunny.net/openapi-client": "workspace:*", + "@types/ssh2": "^1.15.0", + "ssh2": "^1.16.0", + }, + }, "packages/scriptable-dns-types": { "name": "@bunny.net/scriptable-dns-types", "version": "0.1.0", @@ -281,6 +291,8 @@ "@bunny.net/openapi-client": ["@bunny.net/openapi-client@workspace:packages/openapi-client"], + "@bunny.net/sandbox": ["@bunny.net/sandbox@workspace:packages/sandbox"], + "@bunny.net/scriptable-dns-types": ["@bunny.net/scriptable-dns-types@workspace:packages/scriptable-dns-types"], "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.0", "", { "dependencies": { "@changesets/config": "^3.1.3", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ=="], @@ -607,6 +619,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], @@ -635,10 +649,14 @@ "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw=="], + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -647,6 +665,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="], @@ -673,6 +693,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], + "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -847,6 +869,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], @@ -955,6 +979,8 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], + "stdin-discarder": ["stdin-discarder@0.3.1", "", {}, "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -983,6 +1009,8 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -1071,6 +1099,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -1091,6 +1121,8 @@ "yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index 0b3c363d..39ffdc51 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,6 +13,7 @@ "@bunny.net/openapi-client": "workspace:*", "@bunny.net/app-config": "workspace:*", "@bunny.net/database-shell": "workspace:*", + "@bunny.net/sandbox": "workspace:*", "@bunny.net/database-studio": "workspace:*", "@libsql/client": "^0.17.0", "@types/prompts": "^2.4.9", diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts index fb0a7102..5d450ddf 100644 --- a/packages/cli/src/commands/sandbox/create.ts +++ b/packages/cli/src/commands/sandbox/create.ts @@ -1,108 +1,12 @@ -import { randomBytes } from "node:crypto"; -import { createMcClient } from "@bunny.net/openapi-client"; +import { Sandbox, SandboxError } from "@bunny.net/sandbox"; import prompts from "prompts"; import { resolveConfig, setSandbox } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { spinner } from "../../core/ui.ts"; -import { WORKPLACE } from "./ssh-exec.ts"; -const IMAGE_REGISTRY_ID = "1156"; -const IMAGE_NAMESPACE = "bunnyway"; -const IMAGE_NAME = "sandbox-agent"; -const IMAGE_TAG = "latest"; const DEFAULT_REGION = "AMS"; -const POLL_INTERVAL_MS = 3000; -const STARTUP_TIMEOUT_MS = 120_000; - -type App = Record & { - id?: string; - status?: string; - containerTemplates?: Array<{ - endpoints?: Array< - Record & { type?: string; publicHost?: string } - >; - }>; -}; - -function generateToken(): string { - return randomBytes(32).toString("base64url"); -} - -function extractAnycastHost(app: App): string | null { - for (const ct of app.containerTemplates ?? []) { - for (const ep of ct.endpoints ?? []) { - if (ep.type === "anycast") { - return (ep.publicHost as string | undefined) ?? null; - } - } - } - return null; -} - -async function probeSsh(host: string, port: number): Promise { - try { - const socket = await Bun.connect({ - hostname: host, - port, - socket: { data() {}, open() {}, close() {}, error() {} }, - }); - socket.end(); - return true; - } catch { - return false; - } -} - -async function waitUntilActive( - client: ReturnType, - appId: string, -): Promise<{ sshHost: string }> { - const deadline = Date.now() + STARTUP_TIMEOUT_MS; - - // Phase 1: poll API until the anycast SSH endpoint is assigned - let sshHost: string | null = null; - await Bun.sleep(3000); - while (Date.now() < deadline) { - const { data, error } = await client.GET("/apps/{appId}", { - params: { path: { appId } }, - }); - if (error) - throw new UserError(`Failed to poll app: ${JSON.stringify(error)}`); - const app = data as App; - const status = (app as Record).status as - | string - | undefined; - if (status === "failing" || status === "suspended") { - throw new UserError(`Sandbox entered terminal state: ${status}`); - } - sshHost = extractAnycastHost(app); - if (sshHost) break; - await Bun.sleep(POLL_INTERVAL_MS); - } - - if (!sshHost) { - throw new UserError( - `Sandbox SSH endpoint was not assigned within ${STARTUP_TIMEOUT_MS / 1000}s`, - ); - } - - // Phase 2: probe SSH port until the container accepts connections - const [sshIp, sshPortStr] = ( - sshHost.includes(":") ? sshHost.split(":") : [sshHost, "8023"] - ) as [string, string]; - const sshPort = Number(sshPortStr); - while (Date.now() < deadline) { - if (await probeSsh(sshIp, sshPort)) return { sshHost }; - await Bun.sleep(POLL_INTERVAL_MS); - } - - throw new UserError( - `Sandbox SSH did not become reachable within ${STARTUP_TIMEOUT_MS / 1000}s`, - ); -} interface CreateArgs { name?: string; @@ -135,7 +39,6 @@ export const sandboxCreateCommand = defineCommand({ handler: async ({ profile, verbose, apiKey, name, region, output }) => { const config = resolveConfig(profile, apiKey, verbose); - const client = createMcClient(clientOptions(config, verbose)); // JSON output stays non-interactive; the name must come from the positional. const interactive = output !== "json"; @@ -151,90 +54,49 @@ export const sandboxCreateCommand = defineCommand({ } if (!sandboxName) throw new UserError("A sandbox name is required."); - const agentToken = generateToken(); - const spin = spinner("Creating sandbox..."); spin.start(); - const { data: app, error: createError } = await (client as any).POST( - "/apps", - { - body: { - name: sandboxName, - runtimeType: "shared", - autoScaling: { min: 1, max: 1 }, - regionSettings: { - allowedRegionIds: [region], - requiredRegionIds: [region], - }, - volumes: [{ name: "workplace", size: 10 }], - containerTemplates: [ - { - name: "agent", - imageRegistryId: IMAGE_REGISTRY_ID, - imageNamespace: IMAGE_NAMESPACE, - imageName: IMAGE_NAME, - imageTag: IMAGE_TAG, - imagePullPolicy: "ifNotPresent", - environmentVariables: [ - { name: "AGENT_TOKEN", value: agentToken }, - ], - volumeMounts: [{ name: "workplace", mountPath: WORKPLACE }], - endpoints: [ - { - displayName: "ssh", - anycast: { - type: "ipv4", - portMappings: [ - { - containerPort: 8023, - exposedPort: 8023, - protocols: ["Tcp"], - }, - ], - }, - }, - ], - }, - ], - }, - }, - ); - - if (createError || !app) { - spin.stop(); - throw new UserError( - `Failed to create sandbox: ${JSON.stringify(createError)}`, - ); - } - - const appId = (app as Record).id as string; - spin.text = "Waiting for sandbox to become active..."; - - let sshHost: string; + let sandbox: Sandbox; try { - ({ sshHost } = await waitUntilActive(client, appId)); + sandbox = await Sandbox.create({ + apiKey: config.apiKey, + apiUrl: config.apiUrl, + verbose, + onDebug: (msg) => logger.debug(msg, true), + name: sandboxName, + region, + }); } catch (err) { spin.stop(); - // best-effort cleanup - await client - .DELETE("/apps/{appId}", { params: { path: { appId } } }) - .catch(() => {}); + if (err instanceof SandboxError) throw new UserError(err.message); throw err; } spin.stop(); - const record = { - app_id: appId, - agent_token: agentToken, - ssh_host: sshHost, - }; - setSandbox(sandboxName, record); + const handle = sandbox.toHandle(); + sandbox.disconnect(); + setSandbox(sandboxName, { + app_id: handle.appId, + agent_token: handle.agentToken, + ssh_host: handle.sshHost, + }); + + if (output === "json") { + logger.log( + JSON.stringify( + { name: sandboxName, appId: handle.appId, sshHost: handle.sshHost }, + null, + 2, + ), + ); + return; + } logger.log(`Sandbox "${sandboxName}" is ready.`); - logger.log(` App ID: ${appId}`); - logger.log(` SSH: ${sshHost}`); + logger.log(` App ID: ${handle.appId}`); + logger.log(` SSH: ${handle.sshHost}`); logger.log( `\nRun commands with: bunny sandbox exec ${sandboxName} `, ); diff --git a/packages/cli/src/commands/sandbox/delete.ts b/packages/cli/src/commands/sandbox/delete.ts index eed40570..e6d7c793 100644 --- a/packages/cli/src/commands/sandbox/delete.ts +++ b/packages/cli/src/commands/sandbox/delete.ts @@ -1,10 +1,9 @@ -import { createMcClient } from "@bunny.net/openapi-client"; +import { Sandbox, SandboxError } from "@bunny.net/sandbox"; import { deleteSandbox, getSandbox, resolveConfig, } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; @@ -55,21 +54,33 @@ export const sandboxDeleteCommand = defineCommand({ } const config = resolveConfig(profile, apiKey, verbose); - const client = createMcClient(clientOptions(config, verbose)); const spin = spinner("Deleting sandbox..."); spin.start(); - const { error } = await client.DELETE("/apps/{appId}", { - params: { path: { appId: record.app_id } }, - }); - - spin.stop(); - - if (error) { - throw new UserError(`Failed to delete app: ${JSON.stringify(error)}`); + try { + const sandbox = Sandbox.fromHandle( + { + appId: record.app_id, + name, + agentToken: record.agent_token, + sshHost: record.ssh_host ?? "", + }, + { + apiKey: config.apiKey, + apiUrl: config.apiUrl, + verbose, + onDebug: (msg) => logger.debug(msg, true), + }, + ); + await sandbox.delete(); + } catch (err) { + spin.stop(); + if (err instanceof SandboxError) throw new UserError(err.message); + throw err; } + spin.stop(); deleteSandbox(name); logger.log(`Sandbox "${name}" deleted.`); }, diff --git a/packages/cli/src/commands/sandbox/url/add.ts b/packages/cli/src/commands/sandbox/url/add.ts index f50219d0..1fb93466 100644 --- a/packages/cli/src/commands/sandbox/url/add.ts +++ b/packages/cli/src/commands/sandbox/url/add.ts @@ -1,6 +1,5 @@ -import { createMcClient } from "@bunny.net/openapi-client"; +import { Sandbox, SandboxError } from "@bunny.net/sandbox"; import { getSandbox, resolveConfig } from "../../../config/index.ts"; -import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; @@ -45,88 +44,43 @@ export const sandboxUrlAddCommand = defineCommand({ if (!record) throw new UserError(`No sandbox named "${name}" found.`); const config = resolveConfig(profile, apiKey, verbose); - const client = createMcClient(clientOptions(config, verbose)); - const spin = spinner("Fetching sandbox info..."); + const spin = spinner(`Exposing port ${port}...`); spin.start(); - // Get the container template ID from the app - const { data: app, error: appError } = await client.GET("/apps/{appId}", { - params: { path: { appId: record.app_id } }, - }); - if (appError || !app) { - spin.stop(); - throw new UserError(`Failed to fetch app: ${JSON.stringify(appError)}`); - } - - const containerId = (app as any).containerTemplates?.[0]?.id as - | string - | undefined; - if (!containerId) { - spin.stop(); - throw new UserError("Could not find container template ID."); - } - - const displayName = label ?? `port-${port}`; - spin.text = `Creating endpoint "${displayName}"...`; - - const { data: ep, error: epError } = await (client as any).POST( - "/apps/{appId}/containers/{containerId}/endpoints", - { - params: { path: { appId: record.app_id, containerId } }, - body: { - displayName, - cdn: { - isSslEnabled: false, - portMappings: [{ containerPort: port, protocols: ["Tcp"] }], - }, + let url: string; + try { + const sandbox = Sandbox.fromHandle( + { + appId: record.app_id, + name, + agentToken: record.agent_token, + sshHost: record.ssh_host ?? "", + }, + { + apiKey: config.apiKey, + apiUrl: config.apiUrl, + verbose, + onDebug: (msg) => logger.debug(msg, true), }, - }, - ); - - if (epError) { - spin.stop(); - throw new UserError( - `Failed to create endpoint: ${JSON.stringify(epError)}`, ); - } - - const endpointId = (ep as any)?.id as string; - - // Poll until publicHost is assigned - spin.text = "Waiting for public URL..."; - const deadline = Date.now() + 60_000; - let publicHost: string | null = null; - while (Date.now() < deadline) { - await Bun.sleep(2000); - const { data: list } = await client.GET("/apps/{appId}/endpoints", { - params: { path: { appId: record.app_id } }, - }); - const found = (list?.items ?? []).find((e: any) => e.id === endpointId); - if (found?.publicHost) { - publicHost = found.publicHost; - break; - } + url = await sandbox.exposePort(port, label); + } catch (err) { + spin.stop(); + if (err instanceof SandboxError) throw new UserError(err.message); + throw err; } spin.stop(); + const displayName = label ?? `port-${port}`; if (output === "json") { - logger.log( - JSON.stringify( - { id: endpointId, displayName, port, publicHost }, - null, - 2, - ), - ); + logger.log(JSON.stringify({ displayName, port, url }, null, 2)); return; } logger.log(`Endpoint "${displayName}" created.`); logger.log(` Port: ${port}`); - logger.log(` ID: ${endpointId}`); - logger.log( - ` URL: ${publicHost ? `https://${publicHost}` : "— (still provisioning)"}`, - ); + logger.log(` URL: ${url}`); }, }); diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md new file mode 100644 index 00000000..fb69b07e --- /dev/null +++ b/packages/sandbox/README.md @@ -0,0 +1,98 @@ +# @bunny.net/sandbox + +Programmatically create bunny.net sandboxes, buffer files into them, run commands, and expose ports. Backed by Magic Containers, with a code-first developer experience. + +## Install + +```bash +bun add @bunny.net/sandbox +``` + +## Quick start + +```ts +import { Sandbox } from "@bunny.net/sandbox"; + +// Provision a sandbox and wait until it accepts connections. +const sandbox = await Sandbox.create({ + apiKey: process.env.BUNNYNET_API_KEY, + name: "my-sandbox", + region: "AMS", +}); + +// Buffer a file into the sandbox. +await sandbox.writeFiles([{ path: "server.js", content: Buffer.from("console.log('hi')") }]); + +// Run a command and read the result. +const result = await sandbox.runCommand("node", ["--version"]); +console.log(result.exitCode, await result.stdout()); + +// Expose a port as a public CDN URL. +const url = await sandbox.exposePort(3000); +console.log(url); + +await sandbox.delete(); +``` + +Authentication comes from the `apiKey` option or the `BUNNYNET_API_KEY` environment variable. + +## Long-running commands + +Pass `detached: true` to start a process and stream its output: + +```ts +const server = await sandbox.runCommand({ + cmd: "node", + args: ["server.js"], + detached: true, +}); + +for await (const { stream, data } of server.logs()) { + process[stream].write(data); +} + +const finished = await server.wait(); +console.log(finished.exitCode); +``` + +## Reconnecting + +`Sandbox.create` returns a handle you can persist and rebuild later, with no API round trip: + +```ts +const handle = sandbox.toHandle(); +// ...store handle somewhere... + +const same = Sandbox.fromHandle(handle, { apiKey }); +``` + +Or look a sandbox up by its app ID, recovering its connection details from the API: + +```ts +const sandbox = await Sandbox.get({ apiKey, appId }); +``` + +## API + +| Method | Description | +| --------------------------------------------- | ---------------------------------------------------- | +| `Sandbox.create(options)` | Provision a sandbox and wait until SSH is reachable. | +| `Sandbox.get({ appId })` | Retrieve an existing sandbox by app ID. | +| `Sandbox.fromHandle(handle)` | Rebuild a sandbox from a serialized handle. | +| `sandbox.runCommand(cmd, args)` | Run a command, blocking for the result. | +| `sandbox.runCommand({ ..., detached: true })` | Start a command and stream `logs()`. | +| `sandbox.writeFiles(files)` | Upload files, creating parent directories. | +| `sandbox.readFile(path)` | Read a file into a Buffer, or `null` if missing. | +| `sandbox.mkDir(path)` | Create a directory. | +| `sandbox.exposePort(port, label?)` | Expose a port as a public CDN URL. | +| `sandbox.domain(port)` | Return the URL of an already exposed port. | +| `sandbox.delete()` | Delete the sandbox and its backing app. | +| `sandbox.toHandle()` | Serialize the sandbox for reconnection. | + +## Not yet supported + +Magic Containers has no equivalent for these features, so they are intentionally omitted: `stop()`/resume, automatic timeouts, and snapshot/fork. Volumes persist across the sandbox lifetime, but there is no snapshot or branch API. + +## Transport + +Commands and file transfers run over SSH/SFTP (pure-JS [`ssh2`](https://github.com/mscdex/ssh2), no `sshpass` binary). The agent token generated at creation is used as the root password, and the connection targets the anycast SSH endpoint provisioned on the app. diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json new file mode 100644 index 00000000..08b6bcfc --- /dev/null +++ b/packages/sandbox/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bunny.net/sandbox", + "version": "0.1.0", + "type": "module", + "module": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src", + "README.md" + ], + "dependencies": { + "@bunny.net/openapi-client": "workspace:*", + "@types/ssh2": "^1.15.0", + "ssh2": "^1.16.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/sandbox/src/command.ts b/packages/sandbox/src/command.ts new file mode 100644 index 00000000..40ec485e --- /dev/null +++ b/packages/sandbox/src/command.ts @@ -0,0 +1,104 @@ +import type { ClientChannel } from "ssh2"; +import { SandboxError } from "./errors.ts"; + +export interface LogChunk { + stream: "stdout" | "stderr"; + data: string; +} + +export class CommandFinished { + constructor( + readonly exitCode: number | null, + private readonly _stdout: string, + private readonly _stderr: string, + ) {} + + async stdout(): Promise { + return this._stdout; + } + + async stderr(): Promise { + return this._stderr; + } +} + +/** A live command. Stream output via logs() and await wait() for the result. */ +export class Command { + exitCode: number | null = null; + private _stdout = ""; + private _stderr = ""; + private closed = false; + private readonly done: Promise; + private readonly queue: LogChunk[] = []; + private readonly waiters: Array<(r: IteratorResult) => void> = []; + + constructor(private readonly stream: ClientChannel) { + stream.on("data", (d: Buffer) => this.push("stdout", d.toString())); + stream.stderr.on("data", (d: Buffer) => this.push("stderr", d.toString())); + this.done = new Promise((resolve, reject) => { + stream.on("close", (code: number | null) => { + this.exitCode = code ?? null; + this.closed = true; + for (const w of this.waiters.splice(0)) { + w({ done: true, value: undefined }); + } + resolve(new CommandFinished(this.exitCode, this._stdout, this._stderr)); + }); + stream.on("error", (err: Error) => { + this.closed = true; + for (const w of this.waiters.splice(0)) { + w({ done: true, value: undefined }); + } + reject(new SandboxError("Command stream error.", err)); + }); + }); + // Keep an unawaited stream error from surfacing as an unhandled rejection. + this.done.catch(() => {}); + } + + /** Async-iterate stdout and stderr chunks as they arrive. */ + logs(): AsyncIterableIterator { + const self = this; + return { + [Symbol.asyncIterator]() { + return this; + }, + next(): Promise> { + const buffered = self.queue.shift(); + if (buffered) return Promise.resolve({ done: false, value: buffered }); + if (self.closed) + return Promise.resolve({ done: true, value: undefined }); + return new Promise((resolve) => self.waiters.push(resolve)); + }, + }; + } + + async wait(): Promise { + return this.done; + } + + /** Collect the full stdout after the command exits. */ + async stdout(): Promise { + await this.done; + return this._stdout; + } + + /** Collect the full stderr after the command exits. */ + async stderr(): Promise { + await this.done; + return this._stderr; + } + + /** Send a signal to the running process. Defaults to TERM. */ + kill(signal = "TERM"): void { + this.stream.signal(signal); + } + + private push(stream: "stdout" | "stderr", data: string): void { + if (stream === "stdout") this._stdout += data; + else this._stderr += data; + const waiter = this.waiters.shift(); + if (waiter) waiter({ done: false, value: { stream, data } }); + else this.queue.push({ stream, data }); + } +} diff --git a/packages/sandbox/src/errors.ts b/packages/sandbox/src/errors.ts new file mode 100644 index 00000000..f4dad110 --- /dev/null +++ b/packages/sandbox/src/errors.ts @@ -0,0 +1,6 @@ +export class SandboxError extends Error { + constructor(message: string, cause?: unknown) { + super(message, cause !== undefined ? { cause } : undefined); + this.name = "SandboxError"; + } +} diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts new file mode 100644 index 00000000..c9886d3f --- /dev/null +++ b/packages/sandbox/src/index.ts @@ -0,0 +1,12 @@ +export { Command, CommandFinished, type LogChunk } from "./command.ts"; +export { SandboxError } from "./errors.ts"; +export { Sandbox } from "./sandbox.ts"; +export type { + CreateOptions, + FileToWrite, + GetOptions, + RunCommandOptions, + SandboxAuth, + SandboxHandle, + SandboxImage, +} from "./types.ts"; diff --git a/packages/sandbox/src/integration.test.ts b/packages/sandbox/src/integration.test.ts new file mode 100644 index 00000000..23937762 --- /dev/null +++ b/packages/sandbox/src/integration.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { Sandbox } from "./index.ts"; + +// Live test against a real bunny.net account. Skipped unless both +// BUNNYNET_API_KEY and SANDBOX_INTEGRATION=1 are set, so it never runs +// during normal `bun test` or in CI. It provisions a real app, connects +// over SSH, exercises the SDK, then deletes the app. +const RUN = + !!process.env.BUNNYNET_API_KEY && process.env.SANDBOX_INTEGRATION === "1"; + +const suite = RUN ? describe : describe.skip; +const TIMEOUT_MS = 240_000; +const region = process.env.SANDBOX_REGION ?? "AMS"; + +suite("sandbox integration", () => { + test( + "create, exec, file IO, detached logs, delete", + async () => { + const sandbox = await Sandbox.create({ + name: `it-${Date.now().toString(36)}`, + region, + }); + + try { + // Capture stdout from a blocking command. + const echo = await sandbox.runCommand("echo", ["hello-sandbox"]); + expect(echo.exitCode).toBe(0); + expect((await echo.stdout()).trim()).toBe("hello-sandbox"); + + // Buffer a file in and read it back. + await sandbox.writeFiles([ + { path: "note.txt", content: "from the integration test" }, + ]); + const buf = await sandbox.readFile("note.txt"); + expect(buf?.toString()).toBe("from the integration test"); + + // Missing files resolve to null. + expect(await sandbox.readFile("does-not-exist.txt")).toBeNull(); + + // Stream output from a detached command. + const cmd = await sandbox.runCommand({ + cmd: "sh", + args: ["-c", "echo one; echo two"], + detached: true, + }); + let output = ""; + for await (const chunk of cmd.logs()) output += chunk.data; + const finished = await cmd.wait(); + expect(finished.exitCode).toBe(0); + expect(output).toContain("one"); + expect(output).toContain("two"); + } finally { + await sandbox.delete().catch(() => {}); + } + }, + TIMEOUT_MS, + ); +}); diff --git a/packages/sandbox/src/provision.ts b/packages/sandbox/src/provision.ts new file mode 100644 index 00000000..f0c95824 --- /dev/null +++ b/packages/sandbox/src/provision.ts @@ -0,0 +1,294 @@ +import { + ApiError, + type ClientOptions, + createMcClient, +} from "@bunny.net/openapi-client"; +import { SandboxError } from "./errors.ts"; +import type { SandboxAuth, SandboxImage } from "./types.ts"; + +export const WORKPLACE = "/workplace"; +export const SSH_PORT = 8023; + +const POLL_INTERVAL_MS = 3000; +const PROVISION_TIMEOUT_MS = 120_000; +const PUBLIC_HOST_TIMEOUT_MS = 60_000; +const REGISTRY_RETRIES = 3; +const REGISTRY_RETRY_DELAY_MS = 4000; + +type McClient = ReturnType; + +interface AppContainer { + id?: string; + environmentVariables?: Array<{ name?: string; value?: string }>; + endpoints?: Array<{ type?: string; publicHost?: string }>; +} + +interface App { + id?: string; + status?: string; + containerTemplates?: AppContainer[]; +} + +export function resolveApiKey(auth: SandboxAuth): string { + const apiKey = auth.apiKey ?? process.env.BUNNYNET_API_KEY; + if (!apiKey) { + throw new SandboxError( + "No API key. Pass { apiKey } or set BUNNYNET_API_KEY.", + ); + } + return apiKey; +} + +export function mcClient(auth: SandboxAuth): McClient { + const options: ClientOptions = { + apiKey: resolveApiKey(auth), + baseUrl: auth.apiUrl, + verbose: auth.verbose, + userAgent: "bunny-sandbox-sdk", + onDebug: auth.onDebug, + }; + return createMcClient(options); +} + +export function extractAnycastHost(app: App): string | null { + for (const ct of app.containerTemplates ?? []) { + for (const ep of ct.endpoints ?? []) { + if (ep.type === "anycast" && ep.publicHost) return ep.publicHost; + } + } + return null; +} + +export function extractAgentToken(app: App): string | null { + for (const ct of app.containerTemplates ?? []) { + for (const env of ct.environmentVariables ?? []) { + if (env.name === "AGENT_TOKEN" && env.value) return env.value; + } + } + return null; +} + +export function firstContainerId(app: App): string | null { + return app.containerTemplates?.[0]?.id ?? null; +} + +/** Split a host:port string into a host and a port, defaulting to SSH_PORT. */ +export function splitHost(host: string): { host: string; port: number } { + if (!host.includes(":")) return { host, port: SSH_PORT }; + const [h, p] = host.split(":") as [string, string]; + return { host: h, port: Number(p) }; +} + +export async function getApp(client: McClient, appId: string): Promise { + const { data, error } = await client.GET("/apps/{appId}", { + params: { path: { appId } }, + }); + if (error) throw new SandboxError(`Failed to fetch app: ${str(error)}`); + return data as App; +} + +export async function deleteApp( + client: McClient, + appId: string, +): Promise { + const { error } = await client.DELETE("/apps/{appId}", { + params: { path: { appId } }, + }); + if (error) throw new SandboxError(`Failed to delete app: ${str(error)}`); +} + +export async function createApp( + client: McClient, + params: { + name: string; + region: string; + agentToken: string; + volumeSize: number; + env: Record; + image: SandboxImage; + }, +): Promise { + const environmentVariables = [ + { name: "AGENT_TOKEN", value: params.agentToken }, + ...Object.entries(params.env).map(([name, value]) => ({ name, value })), + ]; + + const body = { + name: params.name, + runtimeType: "shared", + autoScaling: { min: 1, max: 1 }, + regionSettings: { + allowedRegionIds: [params.region], + requiredRegionIds: [params.region], + }, + volumes: [{ name: "workplace", size: params.volumeSize }], + containerTemplates: [ + { + name: "agent", + imageRegistryId: params.image.registryId, + imageNamespace: params.image.namespace, + imageName: params.image.name, + imageTag: params.image.tag, + // A pinned digest lets MC skip its flaky create-time registry lookup. + ...(params.image.digest ? { imageDigest: params.image.digest } : {}), + imagePullPolicy: "ifNotPresent", + environmentVariables, + volumeMounts: [{ name: "workplace", mountPath: WORKPLACE }], + endpoints: [ + { + displayName: "ssh", + anycast: { + type: "ipv4", + portMappings: [ + { + containerPort: SSH_PORT, + exposedPort: SSH_PORT, + protocols: ["Tcp"], + }, + ], + }, + }, + ], + }, + ], + }; + + const data = await withRegistryRetry(async () => { + const res = await (client as any).POST("/apps", { body }); + if (res.error || !res.data) { + throw new SandboxError(`Failed to create sandbox: ${str(res.error)}`); + } + return res.data as App; + }); + + const appId = data.id; + if (!appId) throw new SandboxError("Create returned no app ID."); + return appId; +} + +/** Retry the transient MC registry error, which asks the caller to try again. */ +async function withRegistryRetry(fn: () => Promise): Promise { + for (let attempt = 1; attempt <= REGISTRY_RETRIES; attempt++) { + try { + return await fn(); + } catch (err) { + if (!isRegistryError(err) || attempt === REGISTRY_RETRIES) throw err; + await sleep(REGISTRY_RETRY_DELAY_MS * attempt); + } + } + // Unreachable: the final attempt always rethrows above. + throw new SandboxError("Registry retry exhausted."); +} + +/** Detect the catch-all MC registry error so it can be retried. */ +function isRegistryError(err: unknown): boolean { + const message = + err instanceof ApiError || err instanceof SandboxError + ? err.message + : String(err); + return message.toLowerCase().includes("container registry"); +} + +export async function waitForSshHost( + client: McClient, + appId: string, + signal?: AbortSignal, +): Promise { + const deadline = Date.now() + PROVISION_TIMEOUT_MS; + await sleep(POLL_INTERVAL_MS, signal); + while (Date.now() < deadline) { + signal?.throwIfAborted(); + const app = await getApp(client, appId); + if (app.status === "failing" || app.status === "suspended") { + throw new SandboxError(`Sandbox entered terminal state: ${app.status}`); + } + const host = extractAnycastHost(app); + if (host) return host; + await sleep(POLL_INTERVAL_MS, signal); + } + throw new SandboxError( + `SSH endpoint was not assigned within ${PROVISION_TIMEOUT_MS / 1000}s`, + ); +} + +export async function addCdnEndpoint( + client: McClient, + appId: string, + containerId: string, + port: number, + label?: string, +): Promise { + const displayName = label ?? `port-${port}`; + // Reuse an endpoint of the same name so retries after a slow host assignment do not conflict. + const existing = await findEndpointId(client, appId, displayName); + if (existing) return existing; + + const { data, error } = await (client as any).POST( + "/apps/{appId}/containers/{containerId}/endpoints", + { + params: { path: { appId, containerId } }, + body: { + displayName, + cdn: { + isSslEnabled: false, + portMappings: [{ containerPort: port, protocols: ["Tcp"] }], + }, + }, + }, + ); + if (error) throw new SandboxError(`Failed to expose port: ${str(error)}`); + const id = (data as { id?: string })?.id; + if (!id) throw new SandboxError("Endpoint creation returned no ID."); + return id; +} + +/** Poll for the endpoint's public host. Returns null if it is not assigned within the window. */ +export async function waitForPublicHost( + client: McClient, + appId: string, + endpointId: string, +): Promise { + const deadline = Date.now() + PUBLIC_HOST_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(2000); + const app = await getApp(client, appId); + if (app.status === "failing" || app.status === "suspended") { + throw new SandboxError(`Sandbox entered terminal state: ${app.status}`); + } + const { data } = await client.GET("/apps/{appId}/endpoints", { + params: { path: { appId } }, + }); + const items = (data?.items ?? []) as Array<{ + id?: string; + publicHost?: string; + }>; + const found = items.find((e) => e.id === endpointId); + if (found?.publicHost) return found.publicHost; + } + return null; +} + +/** Find an existing endpoint by display name, returning its ID or null. */ +async function findEndpointId( + client: McClient, + appId: string, + displayName: string, +): Promise { + const { data } = await client.GET("/apps/{appId}/endpoints", { + params: { path: { appId } }, + }); + const items = (data?.items ?? []) as Array<{ + id?: string; + displayName?: string; + }>; + return items.find((e) => e.displayName === displayName)?.id ?? null; +} + +function str(error: unknown): string { + return typeof error === "string" ? error : JSON.stringify(error); +} + +async function sleep(ms: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/sandbox/src/sandbox.test.ts b/packages/sandbox/src/sandbox.test.ts new file mode 100644 index 00000000..c3b62201 --- /dev/null +++ b/packages/sandbox/src/sandbox.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { + extractAgentToken, + extractAnycastHost, + firstContainerId, + splitHost, +} from "./provision.ts"; +import { buildRemoteCommand, resolvePath, shellQuote } from "./sandbox.ts"; + +describe("buildRemoteCommand", () => { + test("defaults to the workplace and quotes the command", () => { + expect(buildRemoteCommand({ cmd: "ls", args: ["-la"] })).toBe( + "cd '/workplace' && 'ls' '-la'", + ); + }); + + test("honors cwd, sudo, and env", () => { + expect( + buildRemoteCommand({ + cmd: "node", + args: ["app.js"], + cwd: "/srv", + sudo: true, + env: { NODE_ENV: "production" }, + }), + ).toBe("cd '/srv' && sudo NODE_ENV='production' 'node' 'app.js'"); + }); + + test("rejects env names with shell metacharacters", () => { + expect(() => + buildRemoteCommand({ cmd: "ls", env: { "x; rm -rf /": "1" } }), + ).toThrow("Invalid environment variable name"); + }); +}); + +describe("resolvePath", () => { + test("keeps absolute paths", () => { + expect(resolvePath("/etc/hosts")).toBe("/etc/hosts"); + }); + test("resolves relative paths against the workplace", () => { + expect(resolvePath("src/index.ts")).toBe("/workplace/src/index.ts"); + }); +}); + +describe("shellQuote", () => { + test("escapes embedded single quotes", () => { + expect(shellQuote("it's")).toBe("'it'\\''s'"); + }); +}); + +describe("splitHost", () => { + test("defaults to the SSH port", () => { + expect(splitHost("1.2.3.4")).toEqual({ host: "1.2.3.4", port: 8023 }); + }); + test("parses an explicit port", () => { + expect(splitHost("1.2.3.4:2222")).toEqual({ host: "1.2.3.4", port: 2222 }); + }); +}); + +describe("app extraction", () => { + const app = { + containerTemplates: [ + { + id: "ct-1", + environmentVariables: [{ name: "AGENT_TOKEN", value: "secret" }], + endpoints: [{ type: "anycast", publicHost: "1.2.3.4:8023" }], + }, + ], + }; + + test("reads the anycast host", () => { + expect(extractAnycastHost(app)).toBe("1.2.3.4:8023"); + }); + test("recovers the agent token", () => { + expect(extractAgentToken(app)).toBe("secret"); + }); + test("returns the first container id", () => { + expect(firstContainerId(app)).toBe("ct-1"); + }); + test("returns null when fields are absent", () => { + expect(extractAnycastHost({})).toBeNull(); + expect(extractAgentToken({})).toBeNull(); + expect(firstContainerId({})).toBeNull(); + }); +}); diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts new file mode 100644 index 00000000..a44edaef --- /dev/null +++ b/packages/sandbox/src/sandbox.ts @@ -0,0 +1,296 @@ +import { randomBytes } from "node:crypto"; +import type { createMcClient } from "@bunny.net/openapi-client"; +import { Command, CommandFinished } from "./command.ts"; +import { SandboxError } from "./errors.ts"; +import { + addCdnEndpoint, + createApp, + deleteApp, + extractAgentToken, + extractAnycastHost, + firstContainerId, + getApp, + mcClient, + splitHost, + WORKPLACE, + waitForPublicHost, + waitForSshHost, +} from "./provision.ts"; +import { SshTransport } from "./transport.ts"; +import type { + CreateOptions, + FileToWrite, + GetOptions, + RunCommandOptions, + SandboxAuth, + SandboxHandle, +} from "./types.ts"; + +type McClient = ReturnType; + +const DEFAULT_REGION = "AMS"; +const DEFAULT_VOLUME_GB = 10; +const SSH_REACHABLE_TIMEOUT_MS = 120_000; +const DEFAULT_IMAGE = { + registryId: "1156", + namespace: "bunnyway", + name: "sandbox-agent", + tag: "latest", +} as const; + +/** Programmatic handle to a bunny.net sandbox backed by a Magic Containers app. */ +export class Sandbox { + readonly appId: string; + readonly name: string; + private readonly agentToken: string; + private readonly sshHost: string; + private readonly ports: Map; + private readonly clientFactory: () => McClient; + private clientInstance: McClient | null = null; + private readonly transport: SshTransport; + + private constructor( + handle: SandboxHandle, + clientFactory: () => McClient, + transport: SshTransport, + ) { + this.appId = handle.appId; + this.name = handle.name; + this.agentToken = handle.agentToken; + this.sshHost = handle.sshHost; + this.ports = new Map( + Object.entries(handle.ports ?? {}).map(([p, h]) => [Number(p), h]), + ); + this.clientFactory = clientFactory; + this.transport = transport; + } + + // Resolve the MC client lazily so SSH-only reconnects via fromHandle need no API key. + private get client(): McClient { + this.clientInstance ??= this.clientFactory(); + return this.clientInstance; + } + + /** Provision a new sandbox and wait until it accepts connections. */ + static async create(options: CreateOptions = {}): Promise { + const client = mcClient(options); + const name = options.name ?? generateName(); + const agentToken = generateToken(); + + const appId = await createApp(client, { + name, + region: options.region ?? DEFAULT_REGION, + agentToken, + volumeSize: options.volumeSize ?? DEFAULT_VOLUME_GB, + env: options.env ?? {}, + image: options.image ?? DEFAULT_IMAGE, + }); + + let sshHost: string; + try { + sshHost = await waitForSshHost(client, appId, options.signal); + } catch (err) { + await deleteApp(client, appId).catch(() => {}); + throw err; + } + + const transport = transportFor(sshHost, agentToken); + try { + await transport.waitUntilReachable( + SSH_REACHABLE_TIMEOUT_MS, + options.signal, + ); + } catch (err) { + await deleteApp(client, appId).catch(() => {}); + throw err; + } + + const sandbox = new Sandbox( + { appId, name, agentToken, sshHost }, + () => client, + transport, + ); + + try { + for (const port of options.ports ?? []) { + await sandbox.exposePort(port); + } + } catch (err) { + await deleteApp(client, appId).catch(() => {}); + throw err; + } + return sandbox; + } + + /** Retrieve an existing sandbox by app ID, recovering its connection details. */ + static async get(options: GetOptions): Promise { + const client = mcClient(options); + const app = await getApp(client, options.appId); + + const agentToken = extractAgentToken(app); + const sshHost = extractAnycastHost(app); + if (!agentToken || !sshHost) { + throw new SandboxError( + "Could not recover sandbox credentials from the app.", + ); + } + const name = (app as { name?: string }).name ?? options.appId; + + return new Sandbox( + { appId: options.appId, name, agentToken, sshHost }, + () => client, + transportFor(sshHost, agentToken), + ); + } + + /** Rebuild a sandbox from a serialized handle without an API round trip. */ + static fromHandle(handle: SandboxHandle, auth: SandboxAuth = {}): Sandbox { + return new Sandbox( + handle, + () => mcClient(auth), + transportFor(handle.sshHost, handle.agentToken), + ); + } + + /** Run a command, blocking for the result unless detached is set. */ + async runCommand(command: string, args?: string[]): Promise; + async runCommand(command: RunCommandOptions): Promise; + async runCommand( + command: string | RunCommandOptions, + args: string[] = [], + ): Promise { + const opts: RunCommandOptions = + typeof command === "string" ? { cmd: command, args } : command; + const remote = buildRemoteCommand(opts); + + if (opts.detached) { + return new Command(await this.transport.execStream(remote)); + } + const { stdout, stderr, exitCode } = await this.transport.exec(remote); + return new CommandFinished(exitCode, stdout, stderr); + } + + /** Upload one or more files, creating parent directories as needed. */ + async writeFiles(files: FileToWrite[]): Promise { + for (const file of files) { + const path = resolvePath(file.path); + const dir = path.slice(0, path.lastIndexOf("/")); + if (dir) await this.mkDir(dir); + const content = + typeof file.content === "string" + ? Buffer.from(file.content) + : file.content; + await this.transport.writeFile(path, content, file.mode); + } + } + + /** Read a file into a Buffer, or null when it does not exist. */ + async readFile(path: string): Promise { + return this.transport.readFile(resolvePath(path)); + } + + async mkDir(path: string): Promise { + const { exitCode, stderr } = await this.transport.exec( + `mkdir -p ${shellQuote(resolvePath(path))}`, + ); + if (exitCode !== 0) { + throw new SandboxError(`Failed to create directory: ${stderr.trim()}`); + } + } + + /** Expose a container port as a public CDN endpoint and return its URL. */ + async exposePort(port: number, label?: string): Promise { + const app = await getApp(this.client, this.appId); + const containerId = firstContainerId(app); + if (!containerId) { + throw new SandboxError("Could not find a container to expose the port."); + } + const endpointId = await addCdnEndpoint( + this.client, + this.appId, + containerId, + port, + label, + ); + const host = await waitForPublicHost(this.client, this.appId, endpointId); + if (!host) { + throw new SandboxError( + `Endpoint "${label ?? `port-${port}`}" was created but its public host is still provisioning. Re-run to fetch the URL once it is ready.`, + ); + } + this.ports.set(port, host); + return `https://${host}`; + } + + /** Return the public URL for a previously exposed port. */ + domain(port: number): string { + const host = this.ports.get(port); + if (!host) { + throw new SandboxError( + `Port ${port} is not exposed. Call exposePort(${port}) first.`, + ); + } + return `https://${host}`; + } + + /** Permanently delete the sandbox and its backing app. */ + async delete(): Promise { + this.transport.close(); + await deleteApp(this.client, this.appId); + } + + /** Close the SSH connection without deleting the sandbox. */ + disconnect(): void { + this.transport.close(); + } + + /** Serialize the sandbox so another process can reconnect via fromHandle. */ + toHandle(): SandboxHandle { + return { + appId: this.appId, + name: this.name, + agentToken: this.agentToken, + sshHost: this.sshHost, + ports: Object.fromEntries(this.ports), + }; + } +} + +function transportFor(sshHost: string, password: string): SshTransport { + const { host, port } = splitHost(sshHost); + return new SshTransport({ host, port, password }); +} + +const ENV_KEY_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +export function buildRemoteCommand(opts: RunCommandOptions): string { + const parts: string[] = [`cd ${shellQuote(opts.cwd ?? WORKPLACE)} &&`]; + if (opts.sudo) parts.push("sudo"); + for (const [key, value] of Object.entries(opts.env ?? {})) { + if (!ENV_KEY_PATTERN.test(key)) { + throw new SandboxError(`Invalid environment variable name: ${key}`); + } + parts.push(`${key}=${shellQuote(value)}`); + } + parts.push(shellQuote(opts.cmd)); + for (const arg of opts.args ?? []) parts.push(shellQuote(arg)); + return parts.join(" "); +} + +/** Resolve a sandbox path, defaulting relative paths to the workplace. */ +export function resolvePath(path: string): string { + return path.startsWith("/") ? path : `${WORKPLACE}/${path}`; +} + +/** Single-quote a token for safe use in a remote shell command. */ +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function generateToken(): string { + return randomBytes(32).toString("base64url"); +} + +function generateName(): string { + return `sandbox-${randomBytes(4).toString("hex")}`; +} diff --git a/packages/sandbox/src/transport.ts b/packages/sandbox/src/transport.ts new file mode 100644 index 00000000..933fb887 --- /dev/null +++ b/packages/sandbox/src/transport.ts @@ -0,0 +1,173 @@ +import { + Client, + type ClientChannel, + type ConnectConfig, + type SFTPWrapper, +} from "ssh2"; +import { SandboxError } from "./errors.ts"; + +export interface TransportConfig { + host: string; + port: number; + /** Agent token used as the root password. */ + password: string; + username?: string; +} + +export interface ExecResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +// ssh2 reports a missing SFTP file as the numeric status NO_SUCH_FILE (2), not a Node ENOENT string. +const SFTP_NO_SUCH_FILE = 2; +function isMissingFileError(err: unknown): boolean { + const code = (err as { code?: unknown }).code; + return ( + code === "ENOENT" || code === "NO_SUCH_FILE" || code === SFTP_NO_SUCH_FILE + ); +} + +/** Pure-JS SSH/SFTP transport over a single reused connection. */ +export class SshTransport { + private conn: Client | null = null; + private connecting: Promise | null = null; + private sftpChannel: Promise | null = null; + + constructor(private readonly config: TransportConfig) {} + + /** Connect, retrying until the SSH server accepts connections or timeout. */ + async waitUntilReachable( + timeoutMs: number, + signal?: AbortSignal, + ): Promise { + const deadline = Date.now() + timeoutMs; + let lastErr: unknown; + while (Date.now() < deadline) { + signal?.throwIfAborted(); + try { + await this.ready(); + return; + } catch (err) { + lastErr = err; + await new Promise((r) => setTimeout(r, 3000)); + } + } + throw new SandboxError("Sandbox SSH did not become reachable.", lastErr); + } + + /** Run a command to completion and collect its output. */ + async exec(command: string): Promise { + const stream = await this.execStream(command); + let stdout = ""; + let stderr = ""; + stream.on("data", (d: Buffer) => { + stdout += d.toString(); + }); + stream.stderr.on("data", (d: Buffer) => { + stderr += d.toString(); + }); + return new Promise((resolve, reject) => { + stream.on("error", reject); + stream.on("close", (code: number | null) => { + resolve({ stdout, stderr, exitCode: code }); + }); + }); + } + + /** Start a command and return its live channel for streaming. */ + async execStream(command: string): Promise { + const conn = await this.ready(); + return new Promise((resolve, reject) => { + conn.exec(command, (err, stream) => { + if (err) reject(new SandboxError("Command failed to start.", err)); + else resolve(stream); + }); + }); + } + + async writeFile(path: string, content: Buffer, mode?: number): Promise { + const sftp = await this.sftp(); + return new Promise((resolve, reject) => { + sftp.writeFile(path, content, mode ? { mode } : {}, (err) => { + if (err) reject(new SandboxError(`Failed to write ${path}.`, err)); + else resolve(); + }); + }); + } + + /** Read a file into a Buffer, or null when it does not exist. */ + async readFile(path: string): Promise { + const sftp = await this.sftp(); + return new Promise((resolve, reject) => { + sftp.readFile(path, (err, data) => { + if (!err) return resolve(data); + if (isMissingFileError(err)) return resolve(null); + reject(new SandboxError(`Failed to read ${path}.`, err)); + }); + }); + } + + close(): void { + this.conn?.end(); + this.conn = null; + this.connecting = null; + this.sftpChannel = null; + } + + /** Open the SFTP subsystem once and reuse the channel across operations. */ + private sftp(): Promise { + if (this.sftpChannel) return this.sftpChannel; + this.sftpChannel = this.ready().then( + (conn) => + new Promise((resolve, reject) => { + conn.sftp((err, sftp) => { + if (err) { + this.sftpChannel = null; + reject(new SandboxError("Failed to open SFTP.", err)); + return; + } + sftp.on("close", () => { + this.sftpChannel = null; + }); + resolve(sftp); + }); + }), + ); + return this.sftpChannel; + } + + private ready(): Promise { + if (this.conn) return Promise.resolve(this.conn); + if (this.connecting) return this.connecting; + + const config: ConnectConfig = { + host: this.config.host, + port: this.config.port, + username: this.config.username ?? "root", + password: this.config.password, + readyTimeout: 15_000, + }; + + this.connecting = new Promise((resolve, reject) => { + const conn = new Client(); + conn + .on("ready", () => { + this.conn = conn; + this.connecting = null; + resolve(conn); + }) + .on("error", (err) => { + this.connecting = null; + reject(new SandboxError("SSH connection failed.", err)); + }) + .on("close", () => { + this.conn = null; + this.sftpChannel = null; + }) + .connect(config); + }); + return this.connecting; + } +} diff --git a/packages/sandbox/src/types.ts b/packages/sandbox/src/types.ts new file mode 100644 index 00000000..02bf4a0c --- /dev/null +++ b/packages/sandbox/src/types.ts @@ -0,0 +1,70 @@ +export interface SandboxAuth { + /** bunny.net API key. Falls back to BUNNYNET_API_KEY when omitted. */ + apiKey?: string; + /** Override the Magic Containers API base URL. */ + apiUrl?: string; + /** Emit request/response debug lines to onDebug. */ + verbose?: boolean; + /** Debug logger callback, used when verbose is true. */ + onDebug?: (msg: string) => void; +} + +export interface CreateOptions extends SandboxAuth { + /** Unique app name. A random name is generated when omitted. */ + name?: string; + /** Region ID to deploy in (e.g. AMS, NY, LA). Defaults to AMS. */ + region?: string; + /** Container ports to expose as public CDN endpoints at creation time. */ + ports?: number[]; + /** Default environment variables for the container. */ + env?: Record; + /** Persistent volume size in GB. Defaults to 10. */ + volumeSize?: number; + /** Override the container image. Defaults to the bunny sandbox-agent image. */ + image?: SandboxImage; + /** Cancel provisioning. */ + signal?: AbortSignal; +} + +export interface GetOptions extends SandboxAuth { + appId: string; +} + +export interface SandboxImage { + registryId: string; + namespace: string; + name: string; + tag: string; + /** Pinned sha256 digest. Lets MC skip its flaky create-time registry lookup. */ + digest?: string; +} + +/** Serializable handle that round-trips a sandbox across processes. */ +export interface SandboxHandle { + appId: string; + name: string; + agentToken: string; + sshHost: string; + /** Exposed port to public host mappings discovered so far. */ + ports?: Record; +} + +export interface RunCommandOptions { + cmd: string; + args?: string[]; + /** Working directory. Defaults to the sandbox workplace. */ + cwd?: string; + /** Extra environment variables for this command only. */ + env?: Record; + sudo?: boolean; + /** Return immediately with a live Command instead of blocking. */ + detached?: boolean; +} + +export interface FileToWrite { + /** Path inside the sandbox. Relative paths resolve against the workplace. */ + path: string; + content: Buffer | string; + /** Unix file mode in octal, e.g. 0o755 for an executable. */ + mode?: number; +} diff --git a/packages/sandbox/tsconfig.json b/packages/sandbox/tsconfig.json new file mode 100644 index 00000000..596e2cf7 --- /dev/null +++ b/packages/sandbox/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} From 7ef5298a50b21a27c2edc3adc5328bb495d9bcf8 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Mon, 29 Jun 2026 14:46:40 +0200 Subject: [PATCH 05/16] stringify cwd value on sandbox exec --- packages/cli/src/commands/sandbox/exec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts index c7d89be9..c6a0ce94 100644 --- a/packages/cli/src/commands/sandbox/exec.ts +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -53,7 +53,7 @@ export const sandboxExecCommand = defineCommand({ ); } - const remoteCmd = `cd ${cwd} && ${command.join(" ")}`; + const remoteCmd = `cd ${JSON.stringify(cwd)} && ${command.join(" ")}`; const proc = Bun.spawn(sshArgs(record, remoteCmd), { stdin: "inherit", From 68266d5dc7b2ee891d156728e9d0aa5d4f9f13f4 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Mon, 29 Jun 2026 14:49:50 +0200 Subject: [PATCH 06/16] always pull image when creating sandboxes --- packages/app-config/src/convert.ts | 2 +- packages/sandbox/src/provision.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-config/src/convert.ts b/packages/app-config/src/convert.ts index 4ecd8cb3..da41411b 100644 --- a/packages/app-config/src/convert.ts +++ b/packages/app-config/src/convert.ts @@ -120,7 +120,7 @@ function containerConfigToRequest( imageNamespace, imageTag, imageRegistryId: config.registry ?? "", - imagePullPolicy: "ifNotPresent", + imagePullPolicy: "always", }; if (config.command) { diff --git a/packages/sandbox/src/provision.ts b/packages/sandbox/src/provision.ts index f0c95824..5621499f 100644 --- a/packages/sandbox/src/provision.ts +++ b/packages/sandbox/src/provision.ts @@ -131,7 +131,7 @@ export async function createApp( imageTag: params.image.tag, // A pinned digest lets MC skip its flaky create-time registry lookup. ...(params.image.digest ? { imageDigest: params.image.digest } : {}), - imagePullPolicy: "ifNotPresent", + imagePullPolicy: "always", environmentVariables, volumeMounts: [{ name: "workplace", mountPath: WORKPLACE }], endpoints: [ From 64b3b73c4a5f00cd4fa8db539d01debd51462a07 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Mon, 29 Jun 2026 15:53:08 +0200 Subject: [PATCH 07/16] prevent passwordless sandboxes on container level --- sandbox/entrypoint.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sandbox/entrypoint.sh b/sandbox/entrypoint.sh index 6144832c..c911aff8 100644 --- a/sandbox/entrypoint.sh +++ b/sandbox/entrypoint.sh @@ -1,5 +1,10 @@ #!/bin/bash set -e +if [ -z "${AGENT_TOKEN}" ]; then + echo "AGENT_TOKEN is not set; refusing to start with an empty root password." >&2 + exit 1 +fi + echo "root:${AGENT_TOKEN}" | chpasswd exec /usr/sbin/sshd -D From 0e4ceffb45283e905e02b897f1b6d1b9adc8fbcf Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Mon, 29 Jun 2026 15:54:21 +0200 Subject: [PATCH 08/16] stringify the command args individually --- packages/cli/src/commands/sandbox/exec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts index c6a0ce94..8e9045fb 100644 --- a/packages/cli/src/commands/sandbox/exec.ts +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -53,7 +53,9 @@ export const sandboxExecCommand = defineCommand({ ); } - const remoteCmd = `cd ${JSON.stringify(cwd)} && ${command.join(" ")}`; + const remoteCmd = `cd ${JSON.stringify(cwd)} && ${command + .map((arg) => JSON.stringify(arg)) + .join(" ")}`; const proc = Bun.spawn(sshArgs(record, remoteCmd), { stdin: "inherit", From 2adbe42d426c7785a5ecf2d0591e0cb34c3074b9 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Tue, 30 Jun 2026 11:18:31 +0200 Subject: [PATCH 09/16] fix(sandbox): pass agent token via SSHPASS env, not argv sshpass -p exposed the root SSH password in the process argument list to any other user on the machine. Switch to sshpass -e, reading the token from the SSHPASS env var, across exec/ssh/runSshCommand. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/sandbox/exec.ts | 3 +- packages/cli/src/commands/sandbox/ssh-exec.ts | 22 +++++++++++-- packages/cli/src/commands/sandbox/ssh.ts | 33 +++++-------------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts index 8e9045fb..f1676c18 100644 --- a/packages/cli/src/commands/sandbox/exec.ts +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -1,7 +1,7 @@ import { getSandbox } from "../../config/index.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; -import { sshArgs, WORKPLACE } from "./ssh-exec.ts"; +import { sshArgs, sshEnv, WORKPLACE } from "./ssh-exec.ts"; interface ExecArgs { name: string; @@ -61,6 +61,7 @@ export const sandboxExecCommand = defineCommand({ stdin: "inherit", stdout: "inherit", stderr: "inherit", + env: sshEnv(record), }); process.exitCode = await proc.exited; diff --git a/packages/cli/src/commands/sandbox/ssh-exec.ts b/packages/cli/src/commands/sandbox/ssh-exec.ts index 5fd5958b..ec365f6f 100644 --- a/packages/cli/src/commands/sandbox/ssh-exec.ts +++ b/packages/cli/src/commands/sandbox/ssh-exec.ts @@ -2,17 +2,24 @@ import type { SandboxRecord } from "../../config/schema.ts"; export const WORKPLACE = "/workplace"; -export function sshArgs(record: SandboxRecord, remoteCmd: string): string[] { +export function sshArgs( + record: SandboxRecord, + remoteCmd: string, + options: { tty?: boolean } = {}, +): string[] { const sshHost = record.ssh_host ?? "localhost"; const [host, portStr] = ( sshHost.includes(":") ? sshHost.split(":") : [sshHost, "8023"] ) as [string, string]; + // Use `sshpass -e` so the token is read from the SSHPASS env var rather than + // passed as `-p `, which would expose it in the process argument list + // to any other user on the machine. Callers must spawn with `sshEnv(record)`. return [ "sshpass", - "-p", - record.agent_token, + "-e", "ssh", + ...(options.tty ? ["-t"] : []), "-p", portStr, "-o", @@ -26,6 +33,14 @@ export function sshArgs(record: SandboxRecord, remoteCmd: string): string[] { ]; } +/** + * Environment for spawning the `sshpass -e` command produced by `sshArgs`. + * Keeps the agent token out of the process argument list. + */ +export function sshEnv(record: SandboxRecord): Record { + return { ...process.env, SSHPASS: record.agent_token }; +} + export async function runSshCommand( record: SandboxRecord, remoteCmd: string, @@ -34,6 +49,7 @@ export async function runSshCommand( stdin: "inherit", stdout: "inherit", stderr: "inherit", + env: sshEnv(record), }); return proc.exited; } diff --git a/packages/cli/src/commands/sandbox/ssh.ts b/packages/cli/src/commands/sandbox/ssh.ts index 024b8cff..e96bc251 100644 --- a/packages/cli/src/commands/sandbox/ssh.ts +++ b/packages/cli/src/commands/sandbox/ssh.ts @@ -1,7 +1,7 @@ import { getSandbox } from "../../config/index.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; -import { WORKPLACE } from "./ssh-exec.ts"; +import { sshArgs, sshEnv, WORKPLACE } from "./ssh-exec.ts"; export const sandboxSshCommand = defineCommand({ command: "ssh ", @@ -28,31 +28,14 @@ export const sandboxSshCommand = defineCommand({ ); } - const [host, portStr] = ( - record.ssh_host.includes(":") - ? record.ssh_host.split(":") - : [record.ssh_host, "8023"] - ) as [string, string]; - const proc = Bun.spawn( - [ - "sshpass", - "-p", - record.agent_token, - "ssh", - "-t", - "-p", - portStr, - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "LogLevel=ERROR", - `root@${host}`, - `cd ${WORKPLACE} && exec bash -l`, - ], - { stdin: "inherit", stdout: "inherit", stderr: "inherit" }, + sshArgs(record, `cd ${WORKPLACE} && exec bash -l`, { tty: true }), + { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + env: sshEnv(record), + }, ); process.exitCode = await proc.exited; From dcf11d9d6c4f3f033dfd561748b497ecfc26f63b Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Tue, 30 Jun 2026 11:18:31 +0200 Subject: [PATCH 10/16] fix(sandbox): pin and verify Node/Bun installs in agent image Replace unverified curl | bash installers. Node now comes from a pinned NodeSource apt repo whose packages apt verifies against a signed-by key; Bun is downloaded as a version-pinned release artifact checked against its SHA256 (x64 + arm64). Co-Authored-By: Claude Opus 4.8 --- sandbox/Dockerfile | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile index 94254e4f..bf2683e1 100644 --- a/sandbox/Dockerfile +++ b/sandbox/Dockerfile @@ -22,13 +22,31 @@ RUN apt-get update && apt-get install -y \ && sed -i 's|#\?PermitRootLogin.*|PermitRootLogin yes|' /etc/ssh/sshd_config \ && sed -i 's|#\?PasswordAuthentication.*|PasswordAuthentication yes|' /etc/ssh/sshd_config -# Node.js (LTS) -RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ - && apt-get install -y nodejs \ +# Node.js (pinned NodeSource apt repo with a verified signing key — no curl | bash). +# apt then cryptographically verifies the nodejs package against the pinned key. +ARG NODE_MAJOR=22 +RUN install -d -m 0755 /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + -o /etc/apt/keyrings/nodesource.asc \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.asc] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* -# Bun -RUN curl -fsSL https://bun.sh/install | bash +# Bun (pinned version, checksum-verified release artifact — no curl | bash). +ARG BUN_VERSION=1.3.14 +ARG TARGETARCH +RUN set -eux; \ + case "${TARGETARCH:-amd64}" in \ + amd64) BUN_ARCH=x64; BUN_SHA256=951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f ;; \ + arm64) BUN_ARCH=aarch64; BUN_SHA256=a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b ;; \ + *) echo "unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-${BUN_ARCH}.zip" -o /tmp/bun.zip; \ + echo "${BUN_SHA256} /tmp/bun.zip" | sha256sum -c -; \ + unzip -q /tmp/bun.zip -d /tmp/bun; \ + install -Dm755 "/tmp/bun/bun-linux-${BUN_ARCH}/bun" /root/.bun/bin/bun; \ + rm -rf /tmp/bun.zip /tmp/bun ENV PATH="/root/.bun/bin:$PATH" # Claude Code From 5ded7958d8f00eaa17cbfc5cb77da68b7c956da9 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Tue, 30 Jun 2026 11:45:36 +0200 Subject: [PATCH 11/16] add cdn type to sandbox endpoint check --- packages/sandbox/src/provision.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/sandbox/src/provision.ts b/packages/sandbox/src/provision.ts index 5621499f..4cadd102 100644 --- a/packages/sandbox/src/provision.ts +++ b/packages/sandbox/src/provision.ts @@ -268,7 +268,7 @@ export async function waitForPublicHost( return null; } -/** Find an existing endpoint by display name, returning its ID or null. */ +/** Find an existing CDN endpoint by display name, returning its ID or null. */ async function findEndpointId( client: McClient, appId: string, @@ -279,9 +279,13 @@ async function findEndpointId( }); const items = (data?.items ?? []) as Array<{ id?: string; + type?: string; displayName?: string; }>; - return items.find((e) => e.displayName === displayName)?.id ?? null; + return ( + items.find((e) => e.type === "cdn" && e.displayName === displayName)?.id ?? + null + ); } function str(error: unknown): string { From 9e8f15e841479653984de9f5af9c4d7961e3f6e4 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Tue, 30 Jun 2026 12:00:32 +0200 Subject: [PATCH 12/16] fix typo iPv4 to ipv4 --- packages/app-config/src/convert.test.ts | 4 ++-- packages/app-config/src/convert.ts | 2 +- packages/cli/src/commands/apps/endpoints/add.ts | 2 +- packages/cli/src/commands/apps/suggestions.test.ts | 2 +- packages/openapi-client/specs/magic-containers.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app-config/src/convert.test.ts b/packages/app-config/src/convert.test.ts index baf5a403..3c98340b 100644 --- a/packages/app-config/src/convert.test.ts +++ b/packages/app-config/src/convert.test.ts @@ -339,7 +339,7 @@ describe("configToAddRequest", () => { ]); }); - test("anycast endpoint stamps type 'iPv4'", () => { + test("anycast endpoint stamps type 'ipv4'", () => { const req = configToAddRequest({ version: CURRENT_VERSION, app: { @@ -355,7 +355,7 @@ describe("configToAddRequest", () => { }, }); const ep = req.containerTemplates?.[0]?.endpoints?.[0]; - expect(ep?.anycast?.type).toBe("iPv4"); + expect(ep?.anycast?.type).toBe("ipv4"); expect(ep?.anycast?.portMappings).toEqual([ { containerPort: 80, exposedPort: 80 }, ]); diff --git a/packages/app-config/src/convert.ts b/packages/app-config/src/convert.ts index da41411b..26ee2015 100644 --- a/packages/app-config/src/convert.ts +++ b/packages/app-config/src/convert.ts @@ -162,7 +162,7 @@ function endpointConfigToRequest(ep: EndpointConfig): EndpointRequest { }; } else if (ep.type === "anycast") { req.anycast = { - type: "iPv4", + type: "ipv4", portMappings: (ep.ports ?? []).map((p) => ({ containerPort: p.container, exposedPort: p.public, diff --git a/packages/cli/src/commands/apps/endpoints/add.ts b/packages/cli/src/commands/apps/endpoints/add.ts index 6d17ec4e..0a684250 100644 --- a/packages/cli/src/commands/apps/endpoints/add.ts +++ b/packages/cli/src/commands/apps/endpoints/add.ts @@ -133,7 +133,7 @@ export const appsEndpointsAddCommand = defineCommand({ }; } else { body.anycast = { - type: "iPv4", + type: "ipv4", portMappings: [{ containerPort: cPort, exposedPort: pPort }], }; } diff --git a/packages/cli/src/commands/apps/suggestions.test.ts b/packages/cli/src/commands/apps/suggestions.test.ts index 56875eb5..03a5868d 100644 --- a/packages/cli/src/commands/apps/suggestions.test.ts +++ b/packages/cli/src/commands/apps/suggestions.test.ts @@ -114,7 +114,7 @@ describe("endpointRequestToConfig", () => { const ep: EndpointRequest = { displayName: "anycast", anycast: { - type: "iPv4", + type: "ipv4", portMappings: [{ exposedPort: 9090, containerPort: 9090 }], }, }; diff --git a/packages/openapi-client/specs/magic-containers.json b/packages/openapi-client/specs/magic-containers.json index d0904a32..1aa7d30e 100644 --- a/packages/openapi-client/specs/magic-containers.json +++ b/packages/openapi-client/specs/magic-containers.json @@ -3808,7 +3808,7 @@ "additionalProperties": false }, "AnycastIpProtocolVersion": { - "enum": ["iPv4"], + "enum": ["ipv4"], "type": "string" }, "AppListItem": { From 97a1c9a1df6d3338e618c2f089eb30e3e074fc5a Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Tue, 30 Jun 2026 12:02:27 +0200 Subject: [PATCH 13/16] fix typo Tcp to tcp --- packages/sandbox/src/provision.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sandbox/src/provision.ts b/packages/sandbox/src/provision.ts index 4cadd102..70bbef96 100644 --- a/packages/sandbox/src/provision.ts +++ b/packages/sandbox/src/provision.ts @@ -143,7 +143,7 @@ export async function createApp( { containerPort: SSH_PORT, exposedPort: SSH_PORT, - protocols: ["Tcp"], + protocols: ["tcp"], }, ], }, From df120ba90d83e7780e6424f9fb363951bc4b6011 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Tue, 30 Jun 2026 12:07:34 +0200 Subject: [PATCH 14/16] add non-interactive mode to sandbox delete --- packages/cli/src/commands/sandbox/delete.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/sandbox/delete.ts b/packages/cli/src/commands/sandbox/delete.ts index e6d7c793..5a8dbf77 100644 --- a/packages/cli/src/commands/sandbox/delete.ts +++ b/packages/cli/src/commands/sandbox/delete.ts @@ -12,6 +12,7 @@ import { confirm, spinner } from "../../core/ui.ts"; interface DeleteArgs { name: string; force: boolean; + output?: string; } export const sandboxDeleteCommand = defineCommand({ @@ -37,13 +38,13 @@ export const sandboxDeleteCommand = defineCommand({ describe: "Skip confirmation prompt", }), - handler: async ({ name, force, profile, verbose, apiKey }) => { + handler: async ({ name, force, output, profile, verbose, apiKey }) => { const record = getSandbox(name); if (!record) { throw new UserError(`No sandbox named "${name}" found.`); } - if (!force) { + if (!force && output !== "json") { const ok = await confirm( `Delete sandbox "${name}" (app ${record.app_id})?`, ); @@ -82,6 +83,12 @@ export const sandboxDeleteCommand = defineCommand({ spin.stop(); deleteSandbox(name); + + if (output === "json") { + logger.log(JSON.stringify({ deleted: true, name }, null, 2)); + return; + } + logger.log(`Sandbox "${name}" deleted.`); }, }); From 313cbca32ebcd4d41906fd9b59249517640645b3 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Tue, 30 Jun 2026 12:15:08 +0200 Subject: [PATCH 15/16] use ssh instead of sshpass --- packages/cli/src/commands/sandbox/exec.ts | 17 ++++--- packages/cli/src/commands/sandbox/ssh-exec.ts | 50 +++++++++++++------ packages/cli/src/commands/sandbox/ssh.ts | 25 +++++----- 3 files changed, 57 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts index f1676c18..428bf120 100644 --- a/packages/cli/src/commands/sandbox/exec.ts +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -1,7 +1,7 @@ import { getSandbox } from "../../config/index.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; -import { sshArgs, sshEnv, WORKPLACE } from "./ssh-exec.ts"; +import { sshArgs, withSshEnv, WORKPLACE } from "./ssh-exec.ts"; interface ExecArgs { name: string; @@ -57,13 +57,14 @@ export const sandboxExecCommand = defineCommand({ .map((arg) => JSON.stringify(arg)) .join(" ")}`; - const proc = Bun.spawn(sshArgs(record, remoteCmd), { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - env: sshEnv(record), + process.exitCode = await withSshEnv(record, async (env) => { + const proc = Bun.spawn(sshArgs(record, remoteCmd), { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + env, + }); + return proc.exited; }); - - process.exitCode = await proc.exited; }, }); diff --git a/packages/cli/src/commands/sandbox/ssh-exec.ts b/packages/cli/src/commands/sandbox/ssh-exec.ts index ec365f6f..2ce6ce75 100644 --- a/packages/cli/src/commands/sandbox/ssh-exec.ts +++ b/packages/cli/src/commands/sandbox/ssh-exec.ts @@ -1,3 +1,6 @@ +import { chmod, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { SandboxRecord } from "../../config/schema.ts"; export const WORKPLACE = "/workplace"; @@ -12,12 +15,7 @@ export function sshArgs( sshHost.includes(":") ? sshHost.split(":") : [sshHost, "8023"] ) as [string, string]; - // Use `sshpass -e` so the token is read from the SSHPASS env var rather than - // passed as `-p `, which would expose it in the process argument list - // to any other user on the machine. Callers must spawn with `sshEnv(record)`. return [ - "sshpass", - "-e", "ssh", ...(options.tty ? ["-t"] : []), "-p", @@ -34,22 +32,44 @@ export function sshArgs( } /** - * Environment for spawning the `sshpass -e` command produced by `sshArgs`. - * Keeps the agent token out of the process argument list. + * Runs `fn` with an environment that makes OpenSSH use a temp askpass helper + * to supply the sandbox token. The helper reads BUNNY_SSH_TOKEN so the token + * is never written to disk — only held in the process environment. + * The temp directory is removed when `fn` resolves or rejects. + * + * Requires OpenSSH ≥ 8.4 (SSH_ASKPASS_REQUIRE=force). macOS ships ≥ 8.6. */ -export function sshEnv(record: SandboxRecord): Record { - return { ...process.env, SSHPASS: record.agent_token }; +export async function withSshEnv( + record: SandboxRecord, + fn: (env: Record) => Promise, +): Promise { + const dir = await mkdtemp(join(tmpdir(), "bunny-ssh-")); + const scriptPath = join(dir, "askpass"); + await Bun.write(scriptPath, `#!/bin/sh\nprintf '%s' "$BUNNY_SSH_TOKEN"\n`); + await chmod(scriptPath, 0o700); + try { + return await fn({ + ...process.env, + BUNNY_SSH_TOKEN: record.agent_token, + SSH_ASKPASS: scriptPath, + SSH_ASKPASS_REQUIRE: "force", + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } } export async function runSshCommand( record: SandboxRecord, remoteCmd: string, ): Promise { - const proc = Bun.spawn(sshArgs(record, remoteCmd), { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - env: sshEnv(record), + return withSshEnv(record, async (env) => { + const proc = Bun.spawn(sshArgs(record, remoteCmd), { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + env, + }); + return proc.exited; }); - return proc.exited; } diff --git a/packages/cli/src/commands/sandbox/ssh.ts b/packages/cli/src/commands/sandbox/ssh.ts index e96bc251..9767d216 100644 --- a/packages/cli/src/commands/sandbox/ssh.ts +++ b/packages/cli/src/commands/sandbox/ssh.ts @@ -1,7 +1,7 @@ import { getSandbox } from "../../config/index.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; -import { sshArgs, sshEnv, WORKPLACE } from "./ssh-exec.ts"; +import { sshArgs, withSshEnv, WORKPLACE } from "./ssh-exec.ts"; export const sandboxSshCommand = defineCommand({ command: "ssh ", @@ -28,16 +28,17 @@ export const sandboxSshCommand = defineCommand({ ); } - const proc = Bun.spawn( - sshArgs(record, `cd ${WORKPLACE} && exec bash -l`, { tty: true }), - { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - env: sshEnv(record), - }, - ); - - process.exitCode = await proc.exited; + process.exitCode = await withSshEnv(record, async (env) => { + const proc = Bun.spawn( + sshArgs(record, `cd ${WORKPLACE} && exec bash -l`, { tty: true }), + { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + env, + }, + ); + return proc.exited; + }); }, }); From 986ee670cac41c19f163798f434ddc01ade7a446 Mon Sep 17 00:00:00 2001 From: Amir Keshavarz Date: Tue, 30 Jun 2026 12:19:45 +0200 Subject: [PATCH 16/16] fix linter --- packages/cli/src/commands/sandbox/exec.ts | 2 +- packages/cli/src/commands/sandbox/ssh.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts index 428bf120..f812390e 100644 --- a/packages/cli/src/commands/sandbox/exec.ts +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -1,7 +1,7 @@ import { getSandbox } from "../../config/index.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; -import { sshArgs, withSshEnv, WORKPLACE } from "./ssh-exec.ts"; +import { sshArgs, WORKPLACE, withSshEnv } from "./ssh-exec.ts"; interface ExecArgs { name: string; diff --git a/packages/cli/src/commands/sandbox/ssh.ts b/packages/cli/src/commands/sandbox/ssh.ts index 9767d216..c0546c92 100644 --- a/packages/cli/src/commands/sandbox/ssh.ts +++ b/packages/cli/src/commands/sandbox/ssh.ts @@ -1,7 +1,7 @@ import { getSandbox } from "../../config/index.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; -import { sshArgs, withSshEnv, WORKPLACE } from "./ssh-exec.ts"; +import { sshArgs, WORKPLACE, withSshEnv } from "./ssh-exec.ts"; export const sandboxSshCommand = defineCommand({ command: "ssh ",