diff --git a/README.md b/README.md index 27f5800..afbdcca 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Sandbox SDK with supported provider logos

-Run the same TypeScript sandbox code on Local, E2B, Daytona, Vercel Sandbox, or Upstash Box. +Run the same TypeScript sandbox code on Local, E2B, Daytona, Vercel Sandbox, Upstash Box, or Ascii Box. ## Install @@ -14,13 +14,14 @@ Node.js 22 or 24 is supported. Bun 1.3 or newer is also supported. ## Providers -| Provider | Runtime | Best for | -| --------------------------------------------------------------- | ----------------------- | -------------------------------------- | -| [Local](https://sandbox-sdk.app/docs/providers/local) | AgentOS VM | Development, CI, and self-hosting | -| [E2B](https://sandbox-sdk.app/docs/providers/e2b) | Hosted Linux sandbox | Coding agents and isolated jobs | -| [Daytona](https://sandbox-sdk.app/docs/providers/daytona) | Cloud workspace | Persistent projects and GPUs | -| [Vercel Sandbox](https://sandbox-sdk.app/docs/providers/vercel) | Hosted Linux sandbox | Vercel workloads and previews | -| [Upstash Box](https://sandbox-sdk.app/docs/providers/upstash) | Durable cloud container | Serverless agents and long-lived state | +| Provider | Runtime | Best for | +| --------------------------------------------------------------- | ------------------------- | ---------------------------------------- | +| [Local](https://sandbox-sdk.app/docs/providers/local) | AgentOS VM | Development, CI, and self-hosting | +| [E2B](https://sandbox-sdk.app/docs/providers/e2b) | Hosted Linux sandbox | Coding agents and isolated jobs | +| [Daytona](https://sandbox-sdk.app/docs/providers/daytona) | Cloud workspace | Persistent projects and GPUs | +| [Vercel Sandbox](https://sandbox-sdk.app/docs/providers/vercel) | Hosted Linux sandbox | Vercel workloads and previews | +| [Upstash Box](https://sandbox-sdk.app/docs/providers/upstash) | Durable cloud container | Serverless agents and long-lived state | +| [Ascii Box](https://sandbox-sdk.app/docs/providers/ascii) | Persistent cloud computer | Agents, previews, and durable workspaces | Local is included. Cloud providers use their official SDKs and credentials. diff --git a/apps/fumadocs/content/docs/api/ports.mdx b/apps/fumadocs/content/docs/api/ports.mdx index 01fca55..4f71ae8 100644 --- a/apps/fumadocs/content/docs/api/ports.mdx +++ b/apps/fumadocs/content/docs/api/ports.mdx @@ -20,12 +20,13 @@ const response = await preview.request?.("/health"); Authenticated adapters keep preview credentials inside `request()`. `toJSON()` never returns credential-bearing headers and redacts URL query values. -| Provider | Port behavior | -| -------------- | ------------------------------------------------------------------ | -| Local | Returns a private URL and bridges calls through `request()`. | -| E2B | Returns an E2B host and uses a native access token when required. | -| Daytona | Returns a public or token-authenticated preview URL. | -| Vercel Sandbox | Registers the port and returns a public `vercel.run` route. | -| Upstash Box | Returns a public URL or keeps its bearer token inside `request()`. | +| Provider | Port behavior | +| -------------- | ---------------------------------------------------------------------- | +| Local | Returns a private URL and bridges calls through `request()`. | +| E2B | Returns an E2B host and uses a native access token when required. | +| Daytona | Returns a public or token-authenticated preview URL. | +| Vercel Sandbox | Registers the port and returns a public `vercel.run` route. | +| Upstash Box | Returns a public URL or keeps its bearer token inside `request()`. | +| Ascii Box | Keeps `_token` and its same-origin cookie redirect inside `request()`. | Compare provider preview behavior in [Providers](/docs/providers). diff --git a/apps/fumadocs/content/docs/api/processes.mdx b/apps/fumadocs/content/docs/api/processes.mdx index 17e61ce..d1801dd 100644 --- a/apps/fumadocs/content/docs/api/processes.mdx +++ b/apps/fumadocs/content/docs/api/processes.mdx @@ -25,7 +25,7 @@ Each event reports `stream`, `data`, and an optional `timestamp`. Providers that ## Writing stdin -Check the capability before writing because Vercel does not expose normalized background stdin. +Check the capability before writing because Vercel and Ascii do not expose normalized background stdin. ```ts title="stdin.ts" import { supports } from "@opencoredev/sandbox-sdk"; diff --git a/apps/fumadocs/content/docs/api/sandboxes.mdx b/apps/fumadocs/content/docs/api/sandboxes.mdx index af9acfe..98d2a02 100644 --- a/apps/fumadocs/content/docs/api/sandboxes.mdx +++ b/apps/fumadocs/content/docs/api/sandboxes.mdx @@ -27,13 +27,13 @@ const sandbox = await createSandbox({ ## Sandbox properties -| Property | Type | Description | -| -------------- | -------------------- | --------------------------------------------------- | -| `id` | `string` | Provider or adapter identifier for this sandbox. | -| `provider` | `ProviderName` | `local`, `e2b`, `daytona`, `vercel`, or `upstash`. | -| `cwd` | `string` | Normalized working directory. | -| `capabilities` | `CapabilityMap` | Supported operations and their modes. | -| `raw` | Provider-native type | Typed access to the underlying provider SDK object. | +| Property | Type | Description | +| -------------- | -------------------- | ----------------------------------------------------------- | +| `id` | `string` | Provider or adapter identifier for this sandbox. | +| `provider` | `ProviderName` | `local`, `e2b`, `daytona`, `vercel`, `upstash`, or `ascii`. | +| `cwd` | `string` | Normalized working directory. | +| `capabilities` | `CapabilityMap` | Supported operations and their modes. | +| `raw` | Provider-native type | Typed access to the underlying provider SDK object. | ## Stopping a sandbox diff --git a/apps/fumadocs/content/docs/api/snapshots.mdx b/apps/fumadocs/content/docs/api/snapshots.mdx index 3b3e29b..63f7151 100644 --- a/apps/fumadocs/content/docs/api/snapshots.mdx +++ b/apps/fumadocs/content/docs/api/snapshots.mdx @@ -35,7 +35,7 @@ if (supports(sandbox, "snapshot.restore")) { } ``` -Only Local exposes normalized in-place restore. E2B and Vercel restore flows create new native sandboxes, so those operations remain on `sandbox.raw`. Daytona snapshot and fork behavior also remains provider-specific. +Only Local exposes normalized in-place restore. E2B and Vercel restore flows create new native sandboxes, so those operations remain on `sandbox.raw`. Daytona snapshot and fork behavior also remains provider-specific. Ascii captures a snapshot by archiving and resuming the Box; snapshot deletion and point-in-time restore remain on `sandbox.raw.api`. ## Read next diff --git a/apps/fumadocs/content/docs/index.mdx b/apps/fumadocs/content/docs/index.mdx index 474d7d7..087d80f 100644 --- a/apps/fumadocs/content/docs/index.mdx +++ b/apps/fumadocs/content/docs/index.mdx @@ -3,7 +3,7 @@ title: Quickstart description: Install Sandbox SDK, run a Local sandbox, and switch to a cloud provider. --- -Sandbox SDK uses one TypeScript API for Local, E2B, Daytona, Vercel Sandbox, and Upstash Box. +Sandbox SDK uses one TypeScript API for Local, E2B, Daytona, Vercel Sandbox, Upstash Box, and Ascii Box. ## Install diff --git a/apps/fumadocs/content/docs/integrations/eve.mdx b/apps/fumadocs/content/docs/integrations/eve.mdx index 61c8de0..6fa11ac 100644 --- a/apps/fumadocs/content/docs/integrations/eve.mdx +++ b/apps/fumadocs/content/docs/integrations/eve.mdx @@ -40,7 +40,7 @@ Eve runs `bootstrap` once for a template identity. Each durable session starts f ## Choose a provider - + ```ts import { local } from "@opencoredev/sandbox-sdk/local"; @@ -72,6 +72,12 @@ Eve runs `bootstrap` once for a template identity. Each durable session starts f const backend = createEveSandboxBackend({ provider: upstash({ runtime: "node" }) }); ``` + + ```ts + import { ascii } from "@opencoredev/sandbox-sdk/ascii"; + const backend = createEveSandboxBackend({ provider: ascii({ ttlSeconds: 3600 }) }); + ``` + The backend maps Eve seed files, filesystem methods, process streams, reconnect metadata, and shutdown. Network-policy changes throw an `unsupported` `SandboxError` when the selected provider cannot enforce them. diff --git a/apps/fumadocs/content/docs/integrations/mastra.mdx b/apps/fumadocs/content/docs/integrations/mastra.mdx index 0c50bc1..31555dd 100644 --- a/apps/fumadocs/content/docs/integrations/mastra.mdx +++ b/apps/fumadocs/content/docs/integrations/mastra.mdx @@ -18,13 +18,14 @@ bun add @opencoredev/sandbox-sdk @mastra/core The Local provider is included. Install the native SDK only for the cloud provider you use. -| Provider | Additional package | -| -------------- | ------------------ | -| Local | None | -| E2B | `e2b` | -| Daytona | `@daytona/sdk` | -| Vercel Sandbox | `@vercel/sandbox` | -| Upstash Box | `@upstash/box` | +| Provider | Additional package | +| -------------- | ------------------- | +| Local | None | +| E2B | `e2b` | +| Daytona | `@daytona/sdk` | +| Vercel Sandbox | `@vercel/sandbox` | +| Upstash Box | `@upstash/box` | +| Ascii Box | `@asciidev/box-sdk` | Configure the provider's credentials as described in its [provider guide](/docs/providers). The agent example below also expects `OPENAI_API_KEY` for its selected model. @@ -76,7 +77,7 @@ The adapter normalizes Mastra's workspace contract once. Switching providers cha Every supported provider uses the same `createMastraWorkspace()` factory. - + ```ts import { createMastraWorkspace } from "@opencoredev/sandbox-sdk/mastra"; @@ -140,6 +141,18 @@ Every supported provider uses the same `createMastraWorkspace()` factory. Set `UPSTASH_BOX_API_KEY` before starting the agent. + + ```ts + import { createMastraWorkspace } from "@opencoredev/sandbox-sdk/mastra"; + import { ascii } from "@opencoredev/sandbox-sdk/ascii"; + + export const workspace = createMastraWorkspace({ + provider: ascii({ ttlSeconds: 3600 }), + }); + ``` + Set `BOX_API_KEY` before starting the agent. + + Provider capability differences still apply. Check the exact process, stdin, cancellation, port, and persistence modes in [Compatibility](/docs/reference/compatibility). diff --git a/apps/fumadocs/content/docs/providers/ascii.mdx b/apps/fumadocs/content/docs/providers/ascii.mdx new file mode 100644 index 0000000..de2e477 --- /dev/null +++ b/apps/fumadocs/content/docs/providers/ascii.mdx @@ -0,0 +1,104 @@ +--- +title: Ascii Box +description: Run Ascii cloud computers through the normalized SDK API. +icon: ascii +--- + +Ascii Box provides persistent cloud computers with files, commands, detached processes, protected previews, snapshots, desktop streaming, SSH, and native agent workflows. + +## Installation + +```bash title="Terminal" +bun add @opencoredev/sandbox-sdk @asciidev/box-sdk +``` + +## Authentication + +Create an API key in the Box dashboard and set `BOX_API_KEY`, or pass `apiKey` to `ascii()`. Set `BOX_BASE_URL` only when targeting a non-production Box API. + +## Run a command + +```ts title="ascii.ts" +import { withSandbox } from "@opencoredev/sandbox-sdk"; +import { ascii } from "@opencoredev/sandbox-sdk/ascii"; + +await withSandbox({ provider: ascii({ ttlSeconds: 600 }) }, async (sandbox) => { + const result = await sandbox.run("node --version"); + console.log(result.stdout); +}); +``` + +The Box command API preserves separate stdout and stderr streams. Foreground commands are limited to 60 seconds; use `sandbox.processes.start()` for longer work. + +## Run an agent + +Export one provider instance and pass it to [AI SDK](/docs/integrations/ai-sdk), [HarnessAgent](/docs/integrations/ai-sdk-harness), [Eve](/docs/integrations/eve), or [Mastra](/docs/integrations/mastra). + +```ts title="agent-provider.ts" +import { ascii } from "@opencoredev/sandbox-sdk/ascii"; + +export const agentSandboxProvider = ascii({ ttlSeconds: 3600 }); +``` + +Managed sessions archive the native Box when stopped and resume its persistent filesystem later. Destroying the session deletes the Box. + +## Files and processes + +Ascii's file and command endpoints use paths relative to the Box work directory. Sandbox SDK maps that directory to the sandbox's virtual working directory, `/workspace` by default. + +```ts title="workspace.ts" +await sandbox.files.mkdir("output"); +await sandbox.files.write("output/result.txt", "ready\n"); + +const process = await sandbox.processes.start("bun run dev"); +for await (const event of process.output()) { + console.log(event.stream, event.data); +} +``` + +Background processes are detached inside the Box. Their stdout, stderr, and exit status are persisted under `.sandbox-sdk/processes` so callers can stream output, wait, inspect status, and send signals. Normalized stdin is not available. + +## Ports + +Box previews are protected by default. The adapter removes `_token` from the returned URL and retains it inside `preview.request()` so serialization does not leak the credential. On server runtimes, `request()` also completes Box's same-origin cookie redirect because native `fetch` does not provide a cookie jar. + +```ts title="preview.ts" +const preview = await sandbox.ports.expose(3000); +const response = await preview.request?.("/health"); +``` + +Pass `public: true` to `ascii()` only when the service is safe to expose without Box authentication. + +## Snapshots and lifecycle + +Ascii creates filesystem snapshots while archiving. `sandbox.snapshots.create()` archives the Box, waits for a new completed snapshot, then resumes the same Box. Snapshot deletion and point-in-time restoration are not exposed by Box v1 and remain unsupported on the normalized surface. Snapshot download and tree APIs remain typed on `sandbox.raw.api`. + +`sandbox.stop()` deletes the Box because it is the normalized cleanup operation. If Box requires a recent safety snapshot before deletion, the adapter archives the Box, waits for the new snapshot, and retries deletion. Managed integration `stop()` calls archive instead, allowing the session to resume. + +## Options + +| Option | Type | Default | Behavior | +| ---------------- | ---------------- | ---------------------------- | ---------------------------------------------- | +| `apiKey` | `string` | `BOX_API_KEY` | Authenticates Box v1 requests. | +| `baseUrl` | `string` | `BOX_BASE_URL` or production | Selects the Box v1 API endpoint. | +| `ttlSeconds` | `number \| null` | Provider default | Configures automatic archival. | +| `noEnv` | `boolean` | `false` | Withholds account secrets and credentials. | +| `public` | `boolean` | `false` | Creates public rather than protected previews. | +| `readyTimeoutMs` | `number` | `300000` | Bounds provisioning and lifecycle waits. | +| `pollIntervalMs` | `number` | `1000` | Controls lifecycle and process polling. | +| `configuration` | `object` | `{}` | Adds official SDK fetch configuration. | + +## Native API + +```ts title="native.ts" +const box = await sandbox.raw.get(); +const snapshots = await sandbox.raw.api.listBoxSnapshots({ + boxId: sandbox.raw.id, +}); +``` + +Prompting Codex or Claude Code, event streaming, desktop access, SSH keys, repository selection, forks, and snapshot downloads are provider-specific and remain available through the official `BoxApi` on `sandbox.raw.api`. + +## Read next + +See [Box's TypeScript SDK guide](https://docs.ascii.dev/box/sdks/typescript), compare exact modes in [Compatibility](/docs/reference/compatibility), or connect Ascii to [Mastra](/docs/integrations/mastra). diff --git a/apps/fumadocs/content/docs/providers/index.mdx b/apps/fumadocs/content/docs/providers/index.mdx index 70f303f..d6f00c2 100644 --- a/apps/fumadocs/content/docs/providers/index.mdx +++ b/apps/fumadocs/content/docs/providers/index.mdx @@ -13,6 +13,7 @@ Every provider supports files, foreground commands, background processes, stream | [Daytona](/docs/providers/daytona) | Persistent workspaces, GPUs | Linux workspace | Persistent | Public or authenticated | Native API | | [Vercel Sandbox](/docs/providers/vercel) | Vercel workloads and previews | Linux sandbox | Optional | Public | Filesystem | | [Upstash Box](/docs/providers/upstash) | Durable serverless boxes | Linux container | Persistent | Public or bearer token | Filesystem | +| [Ascii Box](/docs/providers/ascii) | Agents and durable workspaces | Cloud computer | Persistent | Protected or public | Archive filesystem | Optional semantics differ. Check the generated [compatibility matrix](/docs/reference/compatibility) before depending on stdin, restore, resume, networking, GPUs, or a custom image. diff --git a/apps/fumadocs/content/docs/providers/meta.json b/apps/fumadocs/content/docs/providers/meta.json index e52a993..99d4e26 100644 --- a/apps/fumadocs/content/docs/providers/meta.json +++ b/apps/fumadocs/content/docs/providers/meta.json @@ -1,5 +1,5 @@ { "title": "Providers", "icon": "providers", - "pages": ["index", "local", "e2b", "daytona", "vercel", "upstash"] + "pages": ["index", "local", "e2b", "daytona", "vercel", "upstash", "ascii"] } diff --git a/apps/fumadocs/content/docs/reference/compatibility.mdx b/apps/fumadocs/content/docs/reference/compatibility.mdx index 3e134f3..123549a 100644 --- a/apps/fumadocs/content/docs/reference/compatibility.mdx +++ b/apps/fumadocs/content/docs/reference/compatibility.mdx @@ -16,6 +16,7 @@ The matrix is generated from the same capability declarations exported by the ad - Vercel and E2B snapshots create provider-native artifacts, but restore creates a new native sandbox and stays on `raw`. - Daytona persistence, PTYs, networking, images, and GPUs are provider-native capabilities beyond the normalized core. - Upstash Box provides durable filesystems, pause and resume, network policies, and public or bearer-token URLs; snapshot restore creates a new native Box. +- Ascii Box provides a persistent cloud computer, protected or public previews, detached cancellable processes, and archive/resume. Snapshot capture archives and resumes the Box; deletion and point-in-time restore remain provider-native. ## Read next diff --git a/apps/fumadocs/content/docs/reference/package-exports.mdx b/apps/fumadocs/content/docs/reference/package-exports.mdx index d6580e6..07ca343 100644 --- a/apps/fumadocs/content/docs/reference/package-exports.mdx +++ b/apps/fumadocs/content/docs/reference/package-exports.mdx @@ -17,6 +17,7 @@ import { e2b } from "@opencoredev/sandbox-sdk/e2b"; import { daytona } from "@opencoredev/sandbox-sdk/daytona"; import { vercel } from "@opencoredev/sandbox-sdk/vercel"; import { upstash } from "@opencoredev/sandbox-sdk/upstash"; +import { ascii } from "@opencoredev/sandbox-sdk/ascii"; ``` Each adapter export includes its options type, native sandbox type, and capability declaration. diff --git a/apps/fumadocs/src/app/(home)/partners/page.tsx b/apps/fumadocs/src/app/(home)/partners/page.tsx index d46fcba..341c221 100644 --- a/apps/fumadocs/src/app/(home)/partners/page.tsx +++ b/apps/fumadocs/src/app/(home)/partners/page.tsx @@ -28,9 +28,9 @@ export default function PartnersPage() {

What we maintain

- OpenCore maintains five integrations: Local, E2B, Daytona, Vercel Sandbox and Upstash Box. - Local is powered by AgentOS under the hood. The unified API covers files, commands, - processes, ports, capabilities, errors, cleanup and typed native access. + OpenCore maintains six integrations: Local, E2B, Daytona, Vercel Sandbox, Upstash Box and + Ascii Box. Local is powered by AgentOS under the hood. The unified API covers files, + commands, processes, ports, capabilities, errors, cleanup and typed native access.

Continuous compatibility testing

diff --git a/apps/fumadocs/src/app/layout.tsx b/apps/fumadocs/src/app/layout.tsx index 821f440..e67ba4b 100644 --- a/apps/fumadocs/src/app/layout.tsx +++ b/apps/fumadocs/src/app/layout.tsx @@ -46,6 +46,7 @@ export const metadata: Metadata = { "Daytona", "Vercel Sandbox", "Upstash Box", + "Ascii Box", ], alternates: { canonical: "/" }, openGraph: { diff --git a/apps/fumadocs/src/components/docs-icon.tsx b/apps/fumadocs/src/components/docs-icon.tsx index 6abd156..4c8ffe8 100644 --- a/apps/fumadocs/src/components/docs-icon.tsx +++ b/apps/fumadocs/src/components/docs-icon.tsx @@ -27,6 +27,8 @@ export function ProviderLogo({ id, ...props }: IconProps & { id: string }) { return ; case "upstash": return ; + case "ascii": + return ; default: return ; } @@ -42,6 +44,7 @@ export function resolveDocsIcon(icon: string | undefined): ReactNode { case "daytona": case "vercel": case "upstash": + case "ascii": return ; case "integrations": return ; diff --git a/bun.lock b/bun.lock index ce5a3ef..382bf8b 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ }, "devDependencies": { "@ai-sdk/harness": "^1", + "@asciidev/box-sdk": "0.0.24", "@daytona/sdk": "^0.196.0", "@mastra/core": "1.51.0", "@types/bun": "^1.3.14", @@ -155,6 +156,8 @@ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.74.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw=="], + "@asciidev/box-sdk": ["@asciidev/box-sdk@0.0.24", "", {}, "sha512-w77vTWA+yrJ5O+FmchCkurjux1UZkQ5yeurnzX/FJTlQulEtj1xp0g/2cSh/GZWLXrgCV0exU99E+NyiilBeHA=="], + "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.16", "", { "dependencies": { "@aws-sdk/core": "^3.975.1", "@aws-sdk/types": "^3.974.0", "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-EKnvkXSmz3IpA99tCNuI+dLFXyZyClSm8zns9sB/elvkU+MTuomAs6toJMPMBf98/fICG/urXDkzGz0/c3yyAQ=="], "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1087.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/credential-provider-node": "^3.972.68", "@aws-sdk/eventstream-handler-node": "^3.972.27", "@aws-sdk/middleware-eventstream": "^3.972.23", "@aws-sdk/middleware-websocket": "^3.972.40", "@aws-sdk/token-providers": "3.1087.0", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Nyv5gbmraL+Q8yrT7bFgcH7lxBcUnSQdOc1jbiisi7kk/dg26PAnqbwEDpFtbh4HGnLaTWudhCwVUAyVKhtiWQ=="], diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 9258bbe..b90315a 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- Add the Ascii Box provider with normalized files, commands, detached processes, protected ports, safe snapshot-backed cleanup, persistent managed sessions, snapshot capture, metadata, documentation, and live coverage. + ## @opencoredev/sandbox-sdk@0.1.0 ### Initial release diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 3c2e17d..25f9054 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -2,7 +2,7 @@ Sandbox SDK with supported provider logos

-Run the same TypeScript sandbox code on Local, E2B, Daytona, Vercel Sandbox, or Upstash Box. +Run the same TypeScript sandbox code on Local, E2B, Daytona, Vercel Sandbox, Upstash Box, or Ascii Box. ## Install @@ -14,13 +14,14 @@ Node.js 22 or 24 is supported. Bun 1.3 or newer is also supported. ## Providers -| Provider | Runtime | Best for | -| --------------------------------------------------------------- | ----------------------- | -------------------------------------- | -| [Local](https://sandbox-sdk.app/docs/providers/local) | AgentOS VM | Development, CI, and self-hosting | -| [E2B](https://sandbox-sdk.app/docs/providers/e2b) | Hosted Linux sandbox | Coding agents and isolated jobs | -| [Daytona](https://sandbox-sdk.app/docs/providers/daytona) | Cloud workspace | Persistent projects and GPUs | -| [Vercel Sandbox](https://sandbox-sdk.app/docs/providers/vercel) | Hosted Linux sandbox | Vercel workloads and previews | -| [Upstash Box](https://sandbox-sdk.app/docs/providers/upstash) | Durable cloud container | Serverless agents and long-lived state | +| Provider | Runtime | Best for | +| --------------------------------------------------------------- | ------------------------- | ---------------------------------------- | +| [Local](https://sandbox-sdk.app/docs/providers/local) | AgentOS VM | Development, CI, and self-hosting | +| [E2B](https://sandbox-sdk.app/docs/providers/e2b) | Hosted Linux sandbox | Coding agents and isolated jobs | +| [Daytona](https://sandbox-sdk.app/docs/providers/daytona) | Cloud workspace | Persistent projects and GPUs | +| [Vercel Sandbox](https://sandbox-sdk.app/docs/providers/vercel) | Hosted Linux sandbox | Vercel workloads and previews | +| [Upstash Box](https://sandbox-sdk.app/docs/providers/upstash) | Durable cloud container | Serverless agents and long-lived state | +| [Ascii Box](https://sandbox-sdk.app/docs/providers/ascii) | Persistent cloud computer | Agents, previews, and durable workspaces | Local is included. Cloud providers use their official SDKs and credentials. diff --git a/packages/sdk/examples/ascii.ts b/packages/sdk/examples/ascii.ts new file mode 100644 index 0000000..e6e04de --- /dev/null +++ b/packages/sdk/examples/ascii.ts @@ -0,0 +1,8 @@ +import { withSandbox } from "../src"; +import { ascii } from "../src/providers/ascii"; + +await withSandbox({ provider: ascii({ ttlSeconds: 600 }) }, async (sandbox) => { + await sandbox.files.write("hello.txt", "hello from Ascii Box\n"); + const result = await sandbox.run("cat hello.txt"); + console.log(result.stdout); +}); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index ac4367d..c4851c4 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -46,6 +46,10 @@ "types": "./dist/providers/upstash/index.d.mts", "import": "./dist/providers/upstash/index.mjs" }, + "./ascii": { + "types": "./dist/providers/ascii/index.d.mts", + "import": "./dist/providers/ascii/index.mjs" + }, "./metadata": { "types": "./dist/metadata.d.mts", "import": "./dist/metadata.mjs" @@ -90,13 +94,15 @@ "test:live:integrations": "bun test tests/live/integrations.test.ts", "test:live:mastra": "bun test tests/live/mastra.test.ts", "test:live:vercel": "bun test tests/live/vercel.test.ts", - "test:live:upstash": "bun test tests/live/upstash.test.ts" + "test:live:upstash": "bun test tests/live/upstash.test.ts", + "test:live:ascii": "bun test tests/live/ascii.test.ts" }, "dependencies": { "@rivet-dev/agentos-core": "^0.2.7" }, "devDependencies": { "@ai-sdk/harness": "^1", + "@asciidev/box-sdk": "0.0.24", "@daytona/sdk": "^0.196.0", "@mastra/core": "1.51.0", "@types/bun": "^1.3.14", @@ -111,6 +117,7 @@ }, "peerDependencies": { "@ai-sdk/harness": "^1.0.0", + "@asciidev/box-sdk": "^0.0.24", "@daytona/sdk": "^0.196.0", "@mastra/core": ">=1.12.0-0 <2.0.0-0", "@upstash/box": "^0.5.3", @@ -132,6 +139,9 @@ "@upstash/box": { "optional": true }, + "@asciidev/box-sdk": { + "optional": true + }, "ai": { "optional": true }, diff --git a/packages/sdk/scripts/run-live.ts b/packages/sdk/scripts/run-live.ts index d44350a..44de085 100644 --- a/packages/sdk/scripts/run-live.ts +++ b/packages/sdk/scripts/run-live.ts @@ -12,14 +12,16 @@ const missingCredentials = ? !process.env.E2B_API_KEY : id === "daytona" ? !process.env.DAYTONA_API_KEY - : id === "upstash" - ? !process.env.UPSTASH_BOX_API_KEY - : !process.env.VERCEL_OIDC_TOKEN && - !( - process.env.VERCEL_TOKEN && - process.env.VERCEL_TEAM_ID && - process.env.VERCEL_PROJECT_ID - ); + : id === "ascii" + ? !process.env.BOX_API_KEY + : id === "upstash" + ? !process.env.UPSTASH_BOX_API_KEY + : !process.env.VERCEL_OIDC_TOKEN && + !( + process.env.VERCEL_TOKEN && + process.env.VERCEL_TEAM_ID && + process.env.VERCEL_PROJECT_ID + ); const processResult = missingCredentials ? null : Bun.spawn(["bun", "test", `tests/live/${id}.test.ts`], { diff --git a/packages/sdk/src/core/types.ts b/packages/sdk/src/core/types.ts index 3b31b93..a1bd67d 100644 --- a/packages/sdk/src/core/types.ts +++ b/packages/sdk/src/core/types.ts @@ -1,4 +1,4 @@ -export const providerNames = ["local", "e2b", "daytona", "vercel", "upstash"] as const; +export const providerNames = ["local", "e2b", "daytona", "vercel", "upstash", "ascii"] as const; export type ProviderName = (typeof providerNames)[number]; export const capabilityNames = [ diff --git a/packages/sdk/src/metadata.ts b/packages/sdk/src/metadata.ts index e3f4c3b..8a14138 100644 --- a/packages/sdk/src/metadata.ts +++ b/packages/sdk/src/metadata.ts @@ -1,4 +1,5 @@ import { + asciiCapabilities, daytonaCapabilities, e2bCapabilities, localCapabilities, @@ -134,6 +135,25 @@ export const providers: readonly ProviderMetadata[] = [ runtimeLimitations: "Durable Debian or Alpine boxes with Node.js, Python, Go, Ruby, or Rust runtimes.", }, + { + id: "ascii", + displayName: "Ascii Box", + officialUrl: "https://docs.ascii.dev/box", + packageName: "@asciidev/box-sdk", + packageVersion: "0.0.24", + capabilities: asciiCapabilities, + environmentVariables: ["BOX_API_KEY", "BOX_BASE_URL"], + technicalStatus: "experimental", + providerReviewed: false, + sponsor: false, + liveTest: null, + portBehavior: + "Creates a protected URL by default and keeps its query token inside request(), or creates a public URL when configured.", + snapshotBehavior: + "Archives the Box to capture its latest filesystem snapshot, then resumes it. Cleanup also satisfies Box's recent-snapshot safeguard before deletion. Point-in-time restore remains on raw.", + runtimeLimitations: + "Commands are limited to 60 seconds by the Box v1 command API. Normalized paths are virtual paths rooted at the Box work directory.", + }, ]; export function getProviderMetadata(id: ProviderName): ProviderMetadata { diff --git a/packages/sdk/src/providers/ascii/index.ts b/packages/sdk/src/providers/ascii/index.ts new file mode 100644 index 0000000..d20a458 --- /dev/null +++ b/packages/sdk/src/providers/ascii/index.ts @@ -0,0 +1,789 @@ +import { + BoxApi, + Configuration, + ResponseError, + type Box, + type ConfigurationParameters, + type SnapshotSummary, +} from "@asciidev/box-sdk"; +import { SandboxError, type SandboxErrorCode } from "../../core/errors"; +import type { SandboxProvider } from "../../core/provider"; +import type { + CommandInput, + CommandResult, + ProcessOutputEvent, + RunOptions, + SandboxDirectoryEntry, + SandboxProcess, + SandboxSnapshot, +} from "../../core/types"; +import { withManagedSessions } from "../../internal/managed-provider"; +import { + commandString, + portResult, + toUint8Array, + unsupported, +} from "../../internal/provider-utils"; +import { asciiCapabilities } from "../capabilities"; + +const defaultBaseUrl = "https://ascii.dev/api/box/v1"; +const defaultReadyTimeoutMs = 300_000; +const defaultPollIntervalMs = 1_000; +const ansiEscape = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g"); + +export interface AsciiOptions { + /** Box API key. Defaults to BOX_API_KEY. */ + apiKey?: string; + /** Box v1 API base URL. Defaults to BOX_BASE_URL or Ascii's production API. */ + baseUrl?: string; + /** Automatic archival TTL in seconds. Pass null to disable automatic archival. */ + ttlSeconds?: number | null; + /** Withhold account-level secrets and credentials from the Box. */ + noEnv?: boolean; + /** Expose ports publicly instead of using Box's protected URLs. */ + public?: boolean; + /** Maximum time to wait for provisioning, archival, resume, and snapshots. */ + readyTimeoutMs?: number; + /** Polling interval for lifecycle and background-process operations. */ + pollIntervalMs?: number; + /** Additional official SDK configuration, useful for custom fetch implementations. */ + configuration?: Omit; +} + +/** Native Ascii handle retained on `sandbox.raw`. */ +export interface AsciiBox { + readonly id: string; + readonly api: BoxApi; + get(options?: { signal?: AbortSignal }): Promise; + archive(options?: { signal?: AbortSignal }): Promise; + resume(options?: { signal?: AbortSignal }): Promise; + remove(options?: { signal?: AbortSignal }): Promise; +} + +export { asciiCapabilities } from "../capabilities"; + +export function ascii(options: AsciiOptions = {}): SandboxProvider { + const readyTimeoutMs = options.readyTimeoutMs ?? defaultReadyTimeoutMs; + const pollIntervalMs = options.pollIntervalMs ?? defaultPollIntervalMs; + const api = new BoxApi( + new Configuration({ + ...options.configuration, + basePath: options.baseUrl ?? process.env.BOX_BASE_URL ?? defaultBaseUrl, + accessToken: options.apiKey ?? process.env.BOX_API_KEY, + }), + ); + + const provider: SandboxProvider = { + id: "ascii", + capabilities: asciiCapabilities, + async create(createOptions) { + assertNotAborted(createOptions.signal); + const created = await asciiCall("sandbox.create", () => + api.create( + { + createBoxRequest: { + ttlSeconds: options.ttlSeconds, + noEnv: options.noEnv, + env: { ...createOptions.env }, + }, + }, + requestInit(createOptions.signal), + ), + ); + const id = created.box.id; + const raw = createRawBox(api, id, readyTimeoutMs, pollIntervalMs); + + try { + await waitForReady(api, id, { + timeoutMs: createOptions.timeout ?? readyTimeoutMs, + intervalMs: pollIntervalMs, + signal: createOptions.signal, + }); + } catch (error) { + try { + await raw.remove(); + } catch (cleanupError) { + if (error instanceof Error) + Object.defineProperty(error, "cleanupError", { value: cleanupError }); + } + throw error; + } + + const execute = (command: CommandInput, runOptions: RunOptions) => + runCommand(api, id, createOptions.cwd, command, runOptions); + + return { + id, + raw, + capabilities: asciiCapabilities, + files: { + async write(path, value) { + const content = Buffer.from(await toUint8Array(value)).toString("base64"); + await asciiCall("files.write", () => + api.writeFile( + { + boxId: id, + fileWriteRequest: { + path: toRemotePath(path, createOptions.cwd), + content, + encoding: "base64", + }, + }, + requestInit(), + ), + ); + }, + async read(path) { + const result = await asciiCall("files.read", () => + api.readFile({ + boxId: id, + path: toRemotePath(path, createOptions.cwd), + encoding: "base64", + }), + ); + return new Uint8Array(Buffer.from(result.content, "base64")); + }, + async list(path) { + const remotePath = toRemotePath(path, createOptions.cwd); + const result = await execute( + `find ${shellQuote(remotePath)} -mindepth 1 -maxdepth 1 -printf '%f\\0%y\\0%s\\0'`, + {}, + ); + assertCommandSucceeded(result, "files.list"); + return parseDirectoryEntries(result.stdout, path); + }, + async mkdir(path) { + const result = await execute( + `mkdir -p -- ${shellQuote(toRemotePath(path, createOptions.cwd))}`, + {}, + ); + assertCommandSucceeded(result, "files.mkdir"); + }, + async remove(path) { + const result = await execute( + `rm -rf -- ${shellQuote(toRemotePath(path, createOptions.cwd))}`, + {}, + ); + assertCommandSucceeded(result, "files.remove"); + }, + async exists(path) { + const result = await execute( + `test -e ${shellQuote(toRemotePath(path, createOptions.cwd))}`, + {}, + ); + return result.exitCode === 0; + }, + }, + run: execute, + start: (command, runOptions) => + startProcess(api, id, createOptions.cwd, command, runOptions, pollIntervalMs), + async expose(port) { + const registration = await execute( + `host ${port} ${options.public ? "--public" : "--private"}`, + { timeout: 60_000 }, + ); + assertCommandSucceeded(registration, "ports.expose"); + const resolved = await execute(`host url ${port}${options.public ? " --public" : ""}`, { + timeout: 60_000, + }); + assertCommandSucceeded(resolved, "ports.expose"); + const nativeUrl = extractUrl(resolved.stdout); + const parsed = new URL(nativeUrl); + const token = rawQueryValue(nativeUrl, "_token"); + parsed.searchParams.delete("_token"); + const cleanUrl = parsed.toString(); + return portResult( + port, + cleanUrl, + !token, + Boolean(token), + token + ? (path = "/", init = {}) => { + const target = new URL(path, cleanUrl); + const hash = target.hash; + target.hash = ""; + const separator = target.search ? "&" : "?"; + return fetchProtectedPreview( + `${target.href}${separator}_token=${token}${hash}`, + init, + ); + } + : undefined, + ); + }, + snapshots: { + async create(snapshotOptions) { + const previous = await latestSnapshot(api, id); + await raw.archive(); + let snapshot: SnapshotSummary; + try { + await waitForArchived(api, id, { + timeoutMs: readyTimeoutMs, + intervalMs: pollIntervalMs, + }); + snapshot = await waitForSnapshot(api, id, previous?.id, { + timeoutMs: readyTimeoutMs, + intervalMs: pollIntervalMs, + }); + } finally { + await raw.resume(); + } + return { + id: snapshot.id, + name: snapshotOptions?.name, + mode: "filesystem", + createdAt: snapshot.createdAt, + } satisfies SandboxSnapshot; + }, + async delete() { + unsupported("ascii", "snapshot.delete"); + }, + async restore() { + unsupported("ascii", "snapshot.restore"); + }, + }, + stop: () => raw.remove(), + }; + }, + }; + + return withManagedSessions(provider, [], { + stop: (sandbox) => sandbox.raw.archive(), + resume: (sandbox) => sandbox.raw.resume().then(() => undefined), + destroy: (sandbox) => sandbox.raw.remove(), + }); +} + +function createRawBox(api: BoxApi, id: string, timeoutMs: number, intervalMs: number): AsciiBox { + return { + id, + api, + async get(options) { + return ( + await asciiCall("raw.get", () => api.get({ boxId: id }, requestInit(options?.signal))) + ).box; + }, + async archive(options) { + await asciiCall("raw.archive", () => api.stop({ boxId: id }, requestInit(options?.signal))); + await waitForArchived(api, id, { + timeoutMs, + intervalMs, + signal: options?.signal, + }); + }, + async resume(options) { + await asciiCall("raw.resume", () => api.resume({ boxId: id }, requestInit(options?.signal))); + return waitForReady(api, id, { + timeoutMs, + intervalMs, + signal: options?.signal, + }); + }, + async remove(options) { + await removeBox(api, id, { + timeoutMs, + intervalMs, + signal: options?.signal, + }); + }, + }; +} + +async function removeBox(api: BoxApi, boxId: string, options: PollOptions): Promise { + try { + await asciiCall("raw.remove", () => api.remove({ boxId }, requestInit(options.signal))); + return; + } catch (error) { + if ( + !(error instanceof SandboxError) || + error.code !== "conflict" || + !error.message.toLowerCase().includes("snapshot") + ) + throw error; + } + + const [box, previous] = await Promise.all([ + asciiCall("raw.get", () => api.get({ boxId }, requestInit(options.signal))).then( + (result) => result.box, + ), + latestSnapshot(api, boxId), + ]); + if (box.state === "archived") { + await asciiCall("raw.resume", () => api.resume({ boxId }, requestInit(options.signal))); + await waitForReady(api, boxId, options); + await asciiCall("raw.archive", () => api.stop({ boxId }, requestInit(options.signal))); + } else if (box.state !== "archiving") { + await asciiCall("raw.archive", () => api.stop({ boxId }, requestInit(options.signal))); + } + await waitForArchived(api, boxId, options); + await waitForSnapshot(api, boxId, previous?.id, options); + await asciiCall("raw.remove", () => api.remove({ boxId }, requestInit(options.signal))); +} + +async function runCommand( + api: BoxApi, + boxId: string, + virtualCwd: string, + input: CommandInput, + options: RunOptions, +): Promise { + assertNotAborted(options.signal); + const started = performance.now(); + const command = commandWithEnvironment(input, options.env); + const timeoutSeconds = + options.timeout === undefined + ? undefined + : Math.min(60, Math.max(1, Math.ceil(options.timeout / 1_000))); + const result = await asciiCall("process.run", () => + api.command( + { + boxId, + commandRequest: { + command, + cwd: remoteCwd(toRemotePath(options.cwd ?? virtualCwd, virtualCwd)), + timeoutSeconds, + }, + }, + requestInit(options.signal), + ), + ); + if (result.timedOut) { + throw new SandboxError({ + code: "timeout", + provider: "ascii", + operation: "process.run", + message: `Command timed out after ${timeoutSeconds ?? 30}s`, + }); + } + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode ?? 1, + success: result.success, + signal: result.signal ?? undefined, + durationMs: + result.startedAt && result.finishedAt + ? result.finishedAt.getTime() - result.startedAt.getTime() + : Math.round(performance.now() - started), + }; +} + +async function startProcess( + api: BoxApi, + boxId: string, + virtualCwd: string, + input: CommandInput, + options: RunOptions, + pollIntervalMs: number, +): Promise { + assertNotAborted(options.signal); + const processId = crypto.randomUUID(); + const work = `.sandbox-sdk/processes/${processId}`; + const remoteCwd = toRemotePath(options.cwd ?? virtualCwd, virtualCwd); + const command = commandWithEnvironment(input, options.env); + const timedCommand = options.timeout + ? `timeout ${Math.max(1, Math.ceil(options.timeout / 1_000))}s sh -lc ${shellQuote(command)}` + : `sh -lc ${shellQuote(command)}`; + const inner = [ + 'work="$1"', + 'cwd="$2"', + `cd "$cwd" && ${timedCommand}`, + "code=$?", + 'printf "%s" "$code" > "$work/exit"', + 'exit "$code"', + ].join("; "); + const bootstrap = [ + "root=$(pwd -P)", + `work="$root/${work}"`, + `cwd="$root/${remoteCwd === "." ? "" : remoteCwd}"`, + 'mkdir -p "$work"', + ': > "$work/stdout"', + ': > "$work/stderr"', + `nohup setsid sh -c ${shellQuote(inner)} sandbox-sdk "$work" "$cwd" > "$work/stdout" 2> "$work/stderr" < /dev/null & printf "%s" "$!"`, + ].join("; "); + const started = await runCommand(api, boxId, virtualCwd, bootstrap, {}); + assertCommandSucceeded(started, "process.start"); + const pid = started.stdout.trim(); + if (!/^\d+$/.test(pid)) throw new Error(`process.start returned an invalid pid: ${pid}`); + let killed = false; + + const readExitCode = async (): Promise => { + const value = await readOptionalFile(api, boxId, `${work}/exit`); + if (!value) return undefined; + const parsed = Number.parseInt(new TextDecoder().decode(value).trim(), 10); + return Number.isFinite(parsed) ? parsed : 1; + }; + + return { + id: processId, + async status() { + if ((await readExitCode()) !== undefined) return killed ? "killed" : "exited"; + const status = await runCommand(api, boxId, virtualCwd, `kill -0 ${pid}`, {}); + return status.exitCode === 0 ? "running" : "unknown"; + }, + async *output() { + const offsets = { stdout: 0, stderr: 0 }; + let exitObserved = false; + for (;;) { + assertNotAborted(options.signal); + const [stdout, stderr, exitCode] = await Promise.all([ + readOptionalFile(api, boxId, `${work}/stdout`), + readOptionalFile(api, boxId, `${work}/stderr`), + readExitCode(), + ]); + for (const [stream, bytes] of [ + ["stdout", stdout], + ["stderr", stderr], + ] as const) { + if (bytes && bytes.byteLength > offsets[stream]) { + const data = bytes.slice(offsets[stream]); + offsets[stream] = bytes.byteLength; + yield { + stream, + data, + timestamp: new Date(), + } satisfies ProcessOutputEvent; + } + } + if (exitCode !== undefined) { + if (exitObserved) return; + exitObserved = true; + } else { + exitObserved = false; + } + await delay(pollIntervalMs, options.signal); + } + }, + async write() { + unsupported("ascii", "process.stdin"); + }, + async wait() { + for (;;) { + assertNotAborted(options.signal); + const exitCode = await readExitCode(); + if (exitCode !== undefined) return { exitCode }; + await delay(pollIntervalMs, options.signal); + } + }, + async kill(signal = "SIGTERM") { + if (!/^SIG[A-Z0-9]+$/.test(signal)) { + throw new SandboxError({ + code: "invalid_input", + provider: "ascii", + operation: "process.cancel", + message: `Invalid process signal: ${signal}`, + }); + } + const signalName = signal.slice(3); + const result = await runCommand( + api, + boxId, + virtualCwd, + `kill -s ${shellQuote(signalName)} -- -${pid} 2>/dev/null || kill -s ${shellQuote(signalName)} ${pid} 2>/dev/null || true; printf ${shellQuote(String(signalExitCode(signalName)))} > ${shellQuote(`${work}/exit`)}`, + {}, + ); + assertCommandSucceeded(result, "process.cancel"); + killed = true; + }, + }; +} + +function commandWithEnvironment( + input: CommandInput, + environment?: Readonly>, +): string { + const command = commandString(input); + const entries = Object.entries(environment ?? {}); + if (!entries.length) return command; + const assignments = entries.map(([key, value]) => shellQuote(`${key}=${value}`)).join(" "); + return `env ${assignments} sh -lc ${shellQuote(command)}`; +} + +function parseDirectoryEntries(stdout: string, path: string): SandboxDirectoryEntry[] { + const fields = stdout.split("\0"); + if (fields.at(-1) === "") fields.pop(); + if (fields.length % 3 !== 0) throw new Error("files.list returned malformed output"); + const base = path.replace(/\/$/, ""); + const entries: SandboxDirectoryEntry[] = []; + for (let index = 0; index < fields.length; index += 3) { + const name = fields[index]!; + const nativeType = fields[index + 1]!; + const size = Number.parseInt(fields[index + 2]!, 10); + entries.push({ + name, + path: `${base}/${name}`.replace(/\/{2,}/g, "/"), + type: + nativeType === "f" + ? "file" + : nativeType === "d" + ? "directory" + : nativeType === "l" + ? "symlink" + : "unknown", + ...(nativeType === "f" && Number.isFinite(size) ? { size } : {}), + }); + } + return entries; +} + +function toRemotePath(path: string, virtualRoot: string): string { + if (path === virtualRoot) return "."; + if (path.startsWith(`${virtualRoot}/`)) return path.slice(virtualRoot.length + 1); + return path.replace(/^\/+/, "") || "."; +} + +function remoteCwd(path: string): string | undefined { + return path === "." ? undefined : path; +} + +function signalExitCode(signal: string): number { + const numbers: Record = { + HUP: 1, + INT: 2, + QUIT: 3, + KILL: 9, + TERM: 15, + }; + return 128 + (numbers[signal] ?? 15); +} + +function extractUrl(output: string): string { + const plain = output.replaceAll(ansiEscape, ""); + const match = plain.match(/https:\/\/[^\s]+/); + if (!match) throw new Error("ports.expose did not return an HTTPS URL"); + return match[0]; +} + +function rawQueryValue(url: string, name: string): string | undefined { + const query = url.slice(url.indexOf("?") + 1).split("#", 1)[0] ?? ""; + const prefix = `${encodeURIComponent(name)}=`; + return query + .split("&") + .find((field) => field.startsWith(prefix)) + ?.slice(prefix.length); +} + +async function fetchProtectedPreview(url: string, init: RequestInit): Promise { + if (init.redirect === "manual" || init.redirect === "error") return fetch(url, init); + let target = new URL(url); + let request = { ...init, redirect: "manual" as const }; + const cookies = new Map(); + for (let redirects = 0; redirects < 5; redirects += 1) { + const response = await fetch(target, request); + const location = response.headers.get("location"); + if (response.status < 300 || response.status >= 400 || !location) return response; + for (const value of responseCookies(response.headers)) { + const pair = value.split(";", 1)[0]; + const separator = pair?.indexOf("=") ?? -1; + if (pair && separator > 0) cookies.set(pair.slice(0, separator), pair.slice(separator + 1)); + } + const next = new URL(location, target); + const headers = new Headers(request.headers); + if (next.origin === target.origin && cookies.size) + headers.set("cookie", [...cookies].map(([name, value]) => `${name}=${value}`).join("; ")); + else headers.delete("cookie"); + const method = request.method?.toUpperCase(); + const switchToGet = + response.status === 303 || + ((response.status === 301 || response.status === 302) && method === "POST"); + request = switchToGet + ? { ...request, method: "GET", body: undefined, headers } + : { ...request, headers }; + target = next; + } + throw new SandboxError({ + code: "unavailable", + provider: "ascii", + operation: "ports.request", + message: "Ascii preview exceeded the redirect limit", + }); +} + +function responseCookies(headers: Headers): string[] { + const extended = headers as Headers & { getSetCookie?: () => string[] }; + return ( + extended.getSetCookie?.() ?? (headers.get("set-cookie") ? [headers.get("set-cookie")!] : []) + ); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function assertCommandSucceeded(result: CommandResult, operation: string): void { + if (result.success) return; + throw new SandboxError({ + code: "process_failed", + provider: "ascii", + operation, + message: `${operation} failed with exit code ${result.exitCode}${result.stderr ? `: ${result.stderr}` : ""}`, + }); +} + +async function readOptionalFile( + api: BoxApi, + boxId: string, + path: string, +): Promise { + try { + const result = await api.readFile({ boxId, path, encoding: "base64" }); + return new Uint8Array(Buffer.from(result.content, "base64")); + } catch (error) { + if (responseStatus(error) === 404) return undefined; + const normalized = await normalizeAsciiError("files.read", error); + if ( + normalized.code === "invalid_input" && + /enoent|no such file or directory/i.test(normalized.message) + ) + return undefined; + throw normalized; + } +} + +async function latestSnapshot(api: BoxApi, boxId: string): Promise { + return (await asciiCall("snapshot.latest", () => api.getLatestBoxSnapshot({ boxId }))).snapshot; +} + +async function waitForReady(api: BoxApi, boxId: string, options: PollOptions): Promise { + return poll(options, async () => { + const box = ( + await asciiCall("sandbox.wait", () => api.get({ boxId }, requestInit(options.signal))) + ).box; + if (["ready", "idle", "running"].includes(box.state)) return box; + if (["archived", "archiving", "error"].includes(box.state)) + throw new Error(`Box entered terminal state ${box.state}`); + }); +} + +async function waitForArchived(api: BoxApi, boxId: string, options: PollOptions): Promise { + return poll(options, async () => { + const box = ( + await asciiCall("sandbox.archive", () => api.get({ boxId }, requestInit(options.signal))) + ).box; + if (box.state === "archived") return box; + if (box.state === "error") throw new Error("Box entered terminal state error"); + }); +} + +async function waitForSnapshot( + api: BoxApi, + boxId: string, + previousId: string | undefined, + options: PollOptions, +): Promise { + return poll(options, async () => { + const snapshot = await latestSnapshot(api, boxId); + return snapshot && snapshot.id !== previousId ? snapshot : undefined; + }); +} + +interface PollOptions { + timeoutMs: number; + intervalMs: number; + signal?: AbortSignal; +} + +async function poll(options: PollOptions, read: () => Promise): Promise { + const deadline = + options.timeoutMs === 0 ? Number.POSITIVE_INFINITY : Date.now() + options.timeoutMs; + for (;;) { + assertNotAborted(options.signal); + const value = await read(); + if (value !== undefined) return value; + if (Date.now() >= deadline) { + throw new SandboxError({ + code: "timeout", + provider: "ascii", + operation: "sandbox.wait", + message: `Timed out waiting for Box after ${options.timeoutMs}ms`, + }); + } + await delay(options.intervalMs, options.signal); + } +} + +function delay(ms: number, signal?: AbortSignal): Promise { + assertNotAborted(signal); + return new Promise((resolve, reject) => { + const aborted = () => { + clearTimeout(timeout); + reject(signal?.reason ?? new DOMException("Aborted", "AbortError")); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", aborted); + resolve(); + }, ms); + signal?.addEventListener("abort", aborted, { once: true }); + }); +} + +function assertNotAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError"); +} + +function requestInit(signal?: AbortSignal): RequestInit | undefined { + return signal ? { signal } : undefined; +} + +async function asciiCall(operation: string, call: () => Promise): Promise { + try { + return await call(); + } catch (error) { + throw await normalizeAsciiError(operation, error); + } +} + +async function normalizeAsciiError(operation: string, error: unknown): Promise { + if (error instanceof SandboxError) return error; + const status = responseStatus(error); + const code = statusCode(status); + let detail: string | undefined; + const response = responseFromError(error); + if (response) { + try { + const body = (await response.clone().json()) as { + error?: { message?: string; code?: string }; + message?: string; + }; + detail = body.error?.message ?? body.message ?? body.error?.code; + } catch { + // Preserve the SDK's error when the response is not JSON. + } + } + const fallback = error instanceof Error ? error.message : "Unknown Ascii Box error"; + return new SandboxError({ + code, + provider: "ascii", + operation, + message: detail ?? fallback, + cause: error, + }); +} + +function responseFromError(error: unknown): Response | undefined { + if (error instanceof ResponseError) return error.response; + if ( + typeof error === "object" && + error !== null && + "response" in error && + error.response instanceof Response + ) + return error.response; + return undefined; +} + +function responseStatus(error: unknown): number | undefined { + return responseFromError(error)?.status; +} + +function statusCode(status: number | undefined): SandboxErrorCode { + if (status === 400 || status === 422) return "invalid_input"; + if (status === 401) return "authentication"; + if (status === 403) return "permission"; + if (status === 404) return "not_found"; + if (status === 408 || status === 504) return "timeout"; + if (status === 409) return "conflict"; + if (status === 429) return "rate_limited"; + if (status !== undefined && status >= 500) return "unavailable"; + return "internal"; +} diff --git a/packages/sdk/src/providers/capabilities.ts b/packages/sdk/src/providers/capabilities.ts index f1404d4..6d45ae7 100644 --- a/packages/sdk/src/providers/capabilities.ts +++ b/packages/sdk/src/providers/capabilities.ts @@ -97,3 +97,19 @@ export const upstashCapabilities = defineCapabilities({ "image.custom": "native", "network.policy": "native", }); + +export const asciiCapabilities = defineCapabilities({ + "files.read": "full", + "files.write": "full", + "files.list": "full", + "files.remove": "full", + "process.run": "separate-streams", + "process.stream": "separate-streams", + "process.background": "full", + "process.cancel": "full", + "ports.expose": "authenticated", + "ports.authenticatedRequest": "authenticated", + "snapshot.create": "filesystem", + "sandbox.resume": "persistent", + "filesystem.persistent": "persistent", +}); diff --git a/packages/sdk/tests/live/ascii.test.ts b/packages/sdk/tests/live/ascii.test.ts new file mode 100644 index 0000000..295122d --- /dev/null +++ b/packages/sdk/tests/live/ascii.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test"; +import { createSandbox } from "../../src"; +import { ascii } from "../../src/providers/ascii"; + +test.skipIf(!process.env.BOX_API_KEY)( + "Ascii live conformance smoke test", + async () => { + const sandbox = await createSandbox({ + provider: ascii({ ttlSeconds: 600 }), + timeout: 300_000, + }); + try { + expect((await sandbox.run("printf live-ascii")).stdout).toBe("live-ascii"); + expect(await sandbox.run("printf out; printf err >&2; exit 7")).toMatchObject({ + stdout: "out", + stderr: "err", + exitCode: 7, + success: false, + }); + await expect(sandbox.run("sleep 2", { timeout: 20 })).rejects.toMatchObject({ + code: "timeout", + provider: "ascii", + }); + + await sandbox.files.mkdir("nested"); + await sandbox.files.write("nested/file.txt", "ascii"); + expect(await sandbox.files.text("nested/file.txt")).toBe("ascii"); + expect(await sandbox.files.exists("nested/file.txt")).toBe(true); + + const process = await sandbox.processes.start("printf started; printf warning >&2"); + const events = []; + for await (const event of process.output()) events.push(event); + const output = { stdout: "", stderr: "" }; + for (const event of events) + output[event.stream] += + typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); + expect(output.stdout).toContain("started"); + expect(output.stderr).toContain("warning"); + expect(await process.wait()).toEqual({ exitCode: 0 }); + + const sleeping = await sandbox.processes.start("sleep 30"); + await sleeping.kill(); + expect(await sleeping.wait()).toEqual({ exitCode: 143 }); + + const port = 43_123; + const server = await sandbox.processes.start( + `node -e "require('http').createServer((_,res)=>res.end('ascii-port')).listen(${port},'0.0.0.0')"`, + ); + try { + const preview = await sandbox.ports.expose(port); + expect(preview.authenticated).toBe(true); + expect(preview.url).not.toContain("_token"); + expect(await (await preview.request!("/")).text()).toBe("ascii-port"); + } finally { + await server.kill(); + } + + const snapshot = await sandbox.snapshots.create({ name: "live-ascii" }); + expect(snapshot.mode).toBe("filesystem"); + expect(await sandbox.files.text("nested/file.txt")).toBe("ascii"); + } finally { + await sandbox.stop(); + } + }, + 360_000, +); diff --git a/packages/sdk/tests/live/integrations.test.ts b/packages/sdk/tests/live/integrations.test.ts index c8ab7a3..de3a54d 100644 --- a/packages/sdk/tests/live/integrations.test.ts +++ b/packages/sdk/tests/live/integrations.test.ts @@ -4,6 +4,7 @@ import { toAISandboxSession } from "../../src/ai"; import { isSandboxError } from "../../src/core/errors"; import type { SandboxProvider } from "../../src/core/provider"; import { createEveSandboxBackend } from "../../src/eve"; +import { ascii } from "../../src/providers/ascii"; import { daytona } from "../../src/providers/daytona"; import { e2b } from "../../src/providers/e2b"; import { local } from "../../src/providers/local"; @@ -41,6 +42,11 @@ const providers: Array<{ enabled: Boolean(process.env.UPSTASH_BOX_API_KEY), create: () => upstash({ runtime: "node", timeout: 180_000 }), }, + { + name: "Ascii", + enabled: Boolean(process.env.BOX_API_KEY), + create: () => ascii({ ttlSeconds: 1800 }), + }, ]; for (const live of providers) { diff --git a/packages/sdk/tests/live/mastra.test.ts b/packages/sdk/tests/live/mastra.test.ts index 70dde4b..c8e62bb 100644 --- a/packages/sdk/tests/live/mastra.test.ts +++ b/packages/sdk/tests/live/mastra.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import type { SandboxProvider } from "../../src/core/provider"; import { createMastraWorkspace } from "../../src/mastra"; +import { ascii } from "../../src/providers/ascii"; import { daytona } from "../../src/providers/daytona"; import { e2b } from "../../src/providers/e2b"; import { local } from "../../src/providers/local"; @@ -45,6 +46,11 @@ const cases: LiveMastraCase[] = [ enabled: Boolean(process.env.UPSTASH_BOX_API_KEY), provider: () => upstash({ runtime: "node", timeout: 180_000 }), }, + { + name: "Ascii", + enabled: Boolean(process.env.BOX_API_KEY), + provider: () => ascii({ ttlSeconds: 1800 }), + }, ]; for (const live of cases) { diff --git a/packages/sdk/tests/package/exports.test.ts b/packages/sdk/tests/package/exports.test.ts index 7c0db6a..38d1bad 100644 --- a/packages/sdk/tests/package/exports.test.ts +++ b/packages/sdk/tests/package/exports.test.ts @@ -13,9 +13,11 @@ test("core and self-hosted built entries import independently", async () => { const coreEntry = "../../dist/index.mjs"; const localEntry = "../../dist/providers/local/index.mjs"; const agentosEntry = "../../dist/providers/agentos/index.mjs"; + const asciiEntry = "../../dist/providers/ascii/index.mjs"; expect(await import(coreEntry)).toHaveProperty("createSandbox"); expect(await import(localEntry)).toHaveProperty("local"); expect(await import(agentosEntry)).toHaveProperty("agentos"); + expect(await import(asciiEntry)).toHaveProperty("ascii"); }); test("experimental integration entries import independently", async () => { diff --git a/packages/sdk/tests/providers/ascii.test.ts b/packages/sdk/tests/providers/ascii.test.ts new file mode 100644 index 0000000..103c71b --- /dev/null +++ b/packages/sdk/tests/providers/ascii.test.ts @@ -0,0 +1,291 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { createSandbox } from "../../src"; + +const files = new Map(); +const commands: Array<{ command: string; cwd?: string; timeoutSeconds?: number }> = []; +let state = "ready"; +let failCreate = false; +let latestSnapshotId = "snap-0"; +const removed = mock(async () => { + if (state !== "archived" || latestSnapshotId === "snap-0") + throw new NativeResponseError( + Response.json( + { error: { message: "Refusing box delete: no successful snapshot" } }, + { status: 409 }, + ), + ); + return { ok: true, id: "bx_testbox", status: "deleted" }; +}); + +const nativeBox = () => ({ + id: "bx_testbox", + name: "test", + state, + desktopAvailable: true, + snapshotAvailable: true, +}); + +class NativeConfiguration { + constructor(readonly options: unknown) {} +} + +class NativeResponseError extends Error { + constructor(readonly response: Response) { + super("Response returned an error code"); + } +} + +class NativeBoxApi { + async create() { + if (failCreate) + throw new NativeResponseError( + Response.json({ error: { message: "Invalid Box API key" } }, { status: 401 }), + ); + state = "ready"; + latestSnapshotId = "snap-0"; + return { ok: true, box: nativeBox(), status: "provisioning", ttlSeconds: 1800 }; + } + async get() { + return { ok: true, box: nativeBox() }; + } + async writeFile({ fileWriteRequest }: { fileWriteRequest: { path: string; content: string } }) { + files.set(fileWriteRequest.path, fileWriteRequest.content); + return { ok: true }; + } + async readFile({ path }: { path: string }) { + const content = files.get(path); + if (content === undefined) + throw new NativeResponseError( + Response.json( + { error: { message: `ENOENT: no such file or directory, statx '${path}'` } }, + { status: 400 }, + ), + ); + return { ok: true, content, encoding: "base64", path, size: content.length }; + } + async command({ commandRequest }: { commandRequest: (typeof commands)[number] }) { + commands.push(commandRequest); + let stdout = ""; + let stderr = ""; + let exitCode = 0; + if (commandRequest.command.startsWith("find ")) + stdout = ["file.txt", "f", "4", "folder", "d", "4096", ""].join("\0"); + else if ( + commandRequest.command.includes("node") && + commandRequest.command.includes("--version") + ) + stdout = "v24.0.0\n"; + else if (commandRequest.command.includes("printf out")) { + stdout = "out"; + stderr = "err"; + exitCode = 7; + } else if (commandRequest.command.startsWith("test -e")) exitCode = 0; + else if (commandRequest.command.startsWith("host ")) + stdout = "\x1b[32mhttps://box-test-3000.on.ascii.dev?_token=secret+token\x1b[0m\n"; + else if (commandRequest.command.includes("nohup setsid")) { + const processId = commandRequest.command.match(/\.sandbox-sdk\/processes\/([\w-]+)/)?.[1]; + if (!processId) throw new Error("Missing process id"); + files.set( + `.sandbox-sdk/processes/${processId}/stdout`, + Buffer.from(commandRequest.command.includes("sleep 30") ? "" : "started\n").toString( + "base64", + ), + ); + files.set( + `.sandbox-sdk/processes/${processId}/stderr`, + Buffer.from(commandRequest.command.includes("sleep 30") ? "" : "warning\n").toString( + "base64", + ), + ); + if (!commandRequest.command.includes("sleep 30")) + files.set(`.sandbox-sdk/processes/${processId}/exit`, Buffer.from("0").toString("base64")); + stdout = "4242"; + } else if (commandRequest.command.startsWith("kill -s")) { + const processId = commandRequest.command.match(/\.sandbox-sdk\/processes\/([\w-]+)/)?.[1]; + if (!processId) throw new Error("Missing process id"); + files.set(`.sandbox-sdk/processes/${processId}/exit`, Buffer.from("143").toString("base64")); + } + return { + ok: true, + success: exitCode === 0, + exitCode, + stdout, + stderr, + timedOut: false, + }; + } + async stop() { + state = "archived"; + latestSnapshotId = `snap-${Number.parseInt(latestSnapshotId.slice(5), 10) + 1}`; + return { ok: true }; + } + async resume() { + state = "ready"; + return { ok: true }; + } + remove = removed; + async getLatestBoxSnapshot() { + return { + ok: true, + snapshot: { + id: latestSnapshotId, + boxId: "bx_testbox", + status: "completed", + generation: latestSnapshotId === "snap-0" ? 0 : 1, + createdAt: new Date("2026-07-16T00:00:00Z"), + sizeBytes: 1, + fileCount: 1, + }, + }; + } +} + +mock.module("@asciidev/box-sdk", () => ({ + BoxApi: NativeBoxApi, + Configuration: NativeConfiguration, + ResponseError: NativeResponseError, +})); + +afterEach(() => { + files.clear(); + commands.length = 0; + removed.mockClear(); + state = "ready"; + failCreate = false; + latestSnapshotId = "snap-0"; +}); + +describe("Ascii adapter", () => { + test("maps files, commands, paths, ports, and cleanup", async () => { + const { ascii } = await import("../../src/providers/ascii"); + const sandbox = await createSandbox({ provider: ascii({ pollIntervalMs: 1 }) }); + await sandbox.files.write("file.txt", "value"); + expect(await sandbox.files.text("file.txt")).toBe("value"); + expect(files.has("file.txt")).toBe(true); + expect(await sandbox.files.list()).toEqual([ + { name: "file.txt", path: "/workspace/file.txt", type: "file", size: 4 }, + { name: "folder", path: "/workspace/folder", type: "directory" }, + ]); + expect(await sandbox.run({ command: "node", args: ["--version"] })).toMatchObject({ + stdout: "v24.0.0\n", + stderr: "", + success: true, + }); + expect(await sandbox.run("printf out; printf err >&2; exit 7")).toMatchObject({ + stdout: "out", + stderr: "err", + exitCode: 7, + success: false, + }); + const exposed = await sandbox.ports.expose(3000); + expect(exposed).toMatchObject({ + url: "https://box-test-3000.on.ascii.dev/", + public: false, + authenticated: true, + }); + expect(exposed.toJSON().url).not.toContain("secret-token"); + const originalFetch = globalThis.fetch; + const requested: string[] = []; + const requestCookies: Array = []; + globalThis.fetch = mock(async (input, init) => { + requested.push(String(input)); + requestCookies.push(new Headers(init?.headers).get("cookie")); + if (requested.length === 1) + return new Response(null, { + status: 302, + headers: { + location: "/health?check=1", + "set-cookie": "ascii_preview=session-value; Path=/; HttpOnly", + }, + }); + return new Response("ok"); + }) as unknown as typeof fetch; + try { + expect(await (await exposed.request!("/health?check=1")).text()).toBe("ok"); + } finally { + globalThis.fetch = originalFetch; + } + const requestedUrl = new URL(requested[0]!); + expect(requested[0]).toContain("_token=secret+token"); + expect(requestedUrl.searchParams.get("check")).toBe("1"); + expect(requestCookies).toEqual([null, "ascii_preview=session-value"]); + await sandbox.stop(); + expect(sandbox.raw.id).toBe("bx_testbox"); + expect(removed).toHaveBeenCalled(); + }); + + test("streams detached process output and returns its exit code", async () => { + const { ascii } = await import("../../src/providers/ascii"); + const sandbox = await createSandbox({ provider: ascii({ pollIntervalMs: 1 }) }); + const process = await sandbox.processes.start("printf started; printf warning >&2"); + expect(commands.find((entry) => entry.command.includes("nohup setsid"))?.command).not.toContain( + "&;", + ); + const events = []; + for await (const event of process.output()) events.push(event); + expect( + events.map((event) => [ + event.stream, + typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data), + ]), + ).toEqual([ + ["stdout", "started\n"], + ["stderr", "warning\n"], + ]); + expect(await process.wait()).toEqual({ exitCode: 0 }); + expect(await process.status()).toBe("exited"); + await expect(process.write("input")).rejects.toMatchObject({ + code: "unsupported", + provider: "ascii", + }); + await sandbox.stop(); + }); + + test("cancels a detached process group", async () => { + const { ascii } = await import("../../src/providers/ascii"); + const sandbox = await createSandbox({ provider: ascii({ pollIntervalMs: 1 }) }); + const process = await sandbox.processes.start("sleep 30"); + expect(await process.status()).toBe("running"); + await process.kill(); + expect(await process.status()).toBe("killed"); + expect(await process.wait()).toEqual({ exitCode: 143 }); + await sandbox.stop(); + }); + + test("archives, snapshots, resumes, and destroys managed sessions", async () => { + const { ascii } = await import("../../src/providers/ascii"); + const provider = ascii({ pollIntervalMs: 1 }); + const sandbox = await createSandbox({ provider }); + expect(await sandbox.snapshots.create({ name: "prepared" })).toMatchObject({ + id: "snap-1", + name: "prepared", + mode: "filesystem", + }); + expect(state).toBe("ready"); + await expect(sandbox.snapshots.delete("snap-1")).rejects.toMatchObject({ + code: "unsupported", + provider: "ascii", + }); + expect(sandbox.capabilities["snapshot.delete"]).toBe(false); + await sandbox.stop(); + + const session = await provider.managed!.create({ sessionId: "session-1" }); + await session.stop(); + expect(state).toBe("archived"); + await session.resume(); + expect(state).toBe("ready"); + await session.destroy(); + expect(removed).toHaveBeenCalled(); + }); + + test("normalizes official SDK HTTP errors", async () => { + failCreate = true; + const { ascii } = await import("../../src/providers/ascii"); + await expect(createSandbox({ provider: ascii() })).rejects.toMatchObject({ + code: "authentication", + provider: "ascii", + operation: "sandbox.create", + message: "Invalid Box API key", + }); + }); +}); diff --git a/packages/sdk/tests/types/provider-contracts.typecheck.ts b/packages/sdk/tests/types/provider-contracts.typecheck.ts index d90f49f..4611b36 100644 --- a/packages/sdk/tests/types/provider-contracts.typecheck.ts +++ b/packages/sdk/tests/types/provider-contracts.typecheck.ts @@ -2,6 +2,7 @@ import type { Sandbox as DaytonaNative } from "@daytona/sdk"; import type { Sandbox as E2BNative } from "e2b"; import type { Sandbox as VercelNative } from "@vercel/sandbox"; import type { Box as UpstashNative } from "@upstash/box"; +import type { AsciiBox } from "../../src/providers/ascii"; import type { SandboxProvider } from "../../src/core/provider"; import { agentos, type AgentOsSandbox } from "../../src/providers/agentos"; import { daytona } from "../../src/providers/daytona"; @@ -9,6 +10,7 @@ import { e2b } from "../../src/providers/e2b"; import { local, type LocalSandbox } from "../../src/providers/local"; import { vercel } from "../../src/providers/vercel"; import { upstash } from "../../src/providers/upstash"; +import { ascii } from "../../src/providers/ascii"; const localContract: SandboxProvider = local(); const agentosContract: SandboxProvider = agentos(); @@ -16,6 +18,7 @@ const e2bContract: SandboxProvider = e2b(); const daytonaContract: SandboxProvider = daytona(); const vercelContract: SandboxProvider = vercel(); const upstashContract: SandboxProvider = upstash(); +const asciiContract: SandboxProvider = ascii(); void [ localContract, agentosContract, @@ -23,6 +26,7 @@ void [ daytonaContract, vercelContract, upstashContract, + asciiContract, ]; // @ts-expect-error Explicit access-token authentication requires the complete credential triple. diff --git a/packages/sdk/tests/types/public-imports.typecheck.ts b/packages/sdk/tests/types/public-imports.typecheck.ts index 3852372..7110539 100644 --- a/packages/sdk/tests/types/public-imports.typecheck.ts +++ b/packages/sdk/tests/types/public-imports.typecheck.ts @@ -1,4 +1,4 @@ -// @ts-expect-error Providers outside the six launch integrations are not exported. +// @ts-expect-error Providers outside the supported integrations are not exported. import "@opencoredev/sandbox-sdk/modal"; // @ts-expect-error Internal runtime contracts are not public subpaths. import "@opencoredev/sandbox-sdk/core/provider"; diff --git a/packages/sdk/tests/types/raw.typecheck.ts b/packages/sdk/tests/types/raw.typecheck.ts index 46582a5..a42e106 100644 --- a/packages/sdk/tests/types/raw.typecheck.ts +++ b/packages/sdk/tests/types/raw.typecheck.ts @@ -3,6 +3,7 @@ import type { Sandbox as E2BNative } from "e2b"; import type { Sandbox as VercelNative } from "@vercel/sandbox"; import type { Box as UpstashNative } from "@upstash/box"; import { createSandbox } from "../../src"; +import { ascii, type AsciiBox } from "../../src/providers/ascii"; import { agentos, type AgentOsSandbox } from "../../src/providers/agentos"; import { daytona } from "../../src/providers/daytona"; import { e2b } from "../../src/providers/e2b"; @@ -17,7 +18,16 @@ async function rawTypes() { const daytonaSandbox: DaytonaNative = (await createSandbox({ provider: daytona() })).raw; const vercelSandbox: VercelNative = (await createSandbox({ provider: vercel() })).raw; const upstashSandbox: UpstashNative = (await createSandbox({ provider: upstash() })).raw; - void [localSandbox, agentosSandbox, e2bSandbox, daytonaSandbox, vercelSandbox, upstashSandbox]; + const asciiSandbox: AsciiBox = (await createSandbox({ provider: ascii() })).raw; + void [ + localSandbox, + agentosSandbox, + e2bSandbox, + daytonaSandbox, + vercelSandbox, + upstashSandbox, + asciiSandbox, + ]; } void rawTypes; diff --git a/packages/sdk/tsdown.config.ts b/packages/sdk/tsdown.config.ts index a73dbbe..6fdd131 100644 --- a/packages/sdk/tsdown.config.ts +++ b/packages/sdk/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "src/providers/daytona/index.ts", "src/providers/vercel/index.ts", "src/providers/upstash/index.ts", + "src/providers/ascii/index.ts", "src/ai/index.ts", "src/ai/harness.ts", "src/eve/index.ts", @@ -26,6 +27,7 @@ export default defineConfig({ "@daytona/sdk", "@vercel/sandbox", "@upstash/box", + "@asciidev/box-sdk", "ai", "@ai-sdk/harness", "@mastra/core/workspace",