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 @@
-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",