From 96d49d1c9d49f2c4955cd8d9f9b577110d2facfb Mon Sep 17 00:00:00 2001 From: Bhautik Date: Fri, 24 Jul 2026 11:56:02 +0530 Subject: [PATCH 1/5] feat: createos --- bun.lock | 10 +- packages/sdk/examples/createos.ts | 13 + packages/sdk/package.json | 12 +- packages/sdk/src/core/types.ts | 2 +- packages/sdk/src/metadata.ts | 20 + packages/sdk/src/providers/capabilities.ts | 16 + packages/sdk/src/providers/createos/index.ts | 343 ++++++++++++++++++ packages/sdk/tests/live/createos.test.ts | 238 ++++++++++++ packages/sdk/tests/providers/createos.test.ts | 155 ++++++++ packages/sdk/tsdown.config.ts | 2 + 10 files changed, 808 insertions(+), 3 deletions(-) create mode 100644 packages/sdk/examples/createos.ts create mode 100644 packages/sdk/src/providers/createos/index.ts create mode 100644 packages/sdk/tests/live/createos.test.ts create mode 100644 packages/sdk/tests/providers/createos.test.ts diff --git a/bun.lock b/bun.lock index 76556cb..8d94363 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "sandbox-sdk", @@ -61,6 +60,7 @@ "@ai-sdk/harness": "^1", "@daytona/sdk": "^0.196.0", "@mastra/core": "1.51.0", + "@nodeops-createos/sandbox": "^0.7.1", "@types/bun": "^1.3.14", "@types/node": "^22.13.14", "@upstash/box": "0.5.3", @@ -75,6 +75,7 @@ "@ai-sdk/harness": "^1.0.0", "@daytona/sdk": "^0.196.0", "@mastra/core": ">=1.12.0-0 <2.0.0-0", + "@nodeops-createos/sandbox": "^0.7.1", "@upstash/box": "^0.5.3", "@vercel/sandbox": "^2.5.0", "ai": "^7.0.0", @@ -85,6 +86,7 @@ "@ai-sdk/harness", "@daytona/sdk", "@mastra/core", + "@nodeops-createos/sandbox", "@upstash/box", "@vercel/sandbox", "ai", @@ -462,6 +464,8 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@nodeops-createos/sandbox": ["@nodeops-createos/sandbox@0.7.1", "", { "optionalDependencies": { "@types/node": "26.1.1", "undici": "^7.28.0" } }, "sha512-6bGA3wNe8OZ6pmcH2AzVA7grAalqzHGBrxY0DQkHTJ8JR/oSWRf/Xhdd0hNZYMDbkQQxwumRfbtfSZVot10/Lg=="], + "@opencoredev/sandbox-sdk": ["@opencoredev/sandbox-sdk@workspace:packages/sdk"], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], @@ -2098,6 +2102,8 @@ "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "@nodeops-createos/sandbox/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="], "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="], @@ -2320,6 +2326,8 @@ "@mariozechner/pi-tui/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@nodeops-createos/sandbox/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "@types/yauzl/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], diff --git a/packages/sdk/examples/createos.ts b/packages/sdk/examples/createos.ts new file mode 100644 index 0000000..91236ab --- /dev/null +++ b/packages/sdk/examples/createos.ts @@ -0,0 +1,13 @@ +import { createSandbox } from "../src"; +import { createos } from "../src/providers/createos"; + +await using sandbox = await createSandbox({ + provider: createos({ shape: "s-1vcpu-256mb", rootfs: "devbox:1" }), +}); + +console.log((await sandbox.run("node --version")).stdout); + +// Access createos-specific features via raw handle +// await sandbox.raw.pause(); +// await sandbox.raw.resume(); +// await sandbox.raw.fork(); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index af61cfb..7c5f1f4 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" }, + "./createos": { + "types": "./dist/providers/createos/index.d.mts", + "import": "./dist/providers/createos/index.mjs" + }, "./metadata": { "types": "./dist/metadata.d.mts", "import": "./dist/metadata.mjs" @@ -90,7 +94,8 @@ "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:createos": "bun test tests/live/createos.test.ts" }, "dependencies": { "@rivet-dev/agentos-core": "^0.2.7" @@ -99,6 +104,7 @@ "@ai-sdk/harness": "^1", "@daytona/sdk": "^0.196.0", "@mastra/core": "1.51.0", + "@nodeops-createos/sandbox": "^0.7.1", "@types/bun": "^1.3.14", "@types/node": "^22.13.14", "@upstash/box": "0.5.3", @@ -116,6 +122,7 @@ "@upstash/box": "^0.5.3", "@vercel/sandbox": "^2.5.0", "ai": "^7.0.0", + "@nodeops-createos/sandbox": "^0.7.1", "e2b": "^2.32.0", "eve": "^0.22.0" }, @@ -141,6 +148,9 @@ "@mastra/core": { "optional": true }, + "@nodeops-createos/sandbox": { + "optional": true + }, "eve": { "optional": true } diff --git a/packages/sdk/src/core/types.ts b/packages/sdk/src/core/types.ts index 4cd55a1..338ea05 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", "createos"] 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 c7ace3b..de2198b 100644 --- a/packages/sdk/src/metadata.ts +++ b/packages/sdk/src/metadata.ts @@ -1,4 +1,5 @@ import { + createosCapabilities, 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: "createos", + displayName: "CreateOS Sandbox", + officialUrl: "https://github.com/NodeOps-app/createos-sandbox-sdk", + packageName: "@nodeops-createos/sandbox", + packageVersion: "0.7.1", + capabilities: createosCapabilities, + environmentVariables: ["CREATEOS_SANDBOX_API_KEY", "CREATEOS_SANDBOX_BASE_URL"], + technicalStatus: "supported", + providerReviewed: false, + sponsor: false, + liveTest: null, + portBehavior: + "Returns an ingress URL with a port placeholder. Ingress is enabled on first expose() call if not already set.", + snapshotBehavior: + "Snapshots are not supported. Use sandbox.raw for pause/resume/fork lifecycle operations.", + runtimeLimitations: + "VM-based sandbox with configurable shapes. stdin is not available on exec. Files list/mkdir/remove/exists use shell commands.", + }, ]; export function getProviderMetadata(id: ProviderName): ProviderMetadata { diff --git a/packages/sdk/src/providers/capabilities.ts b/packages/sdk/src/providers/capabilities.ts index f1404d4..22c8ee9 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 createosCapabilities = 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", + "sandbox.resume": "persistent", + "filesystem.persistent": "ephemeral", + "image.custom": "template", + "network.policy": "native", +}); diff --git a/packages/sdk/src/providers/createos/index.ts b/packages/sdk/src/providers/createos/index.ts new file mode 100644 index 0000000..596c277 --- /dev/null +++ b/packages/sdk/src/providers/createos/index.ts @@ -0,0 +1,343 @@ +import { + CreateosSandboxClient, + Sandbox as CreateosSandbox, + CreateosSandboxError, + CreateosSandboxApiError, + CreateosSandboxAuthError, + CreateosSandboxPermissionError, + CreateosSandboxNotFoundError, + CreateosSandboxTimeoutError, + CreateosSandboxRateLimitError, + CreateosSandboxValidationError, + CreateosSandboxServerError, + CreateosSandboxConnectionError, +} from "@nodeops-createos/sandbox"; +import { SandboxError } from "../../core/errors"; +import type { SandboxProvider } from "../../core/provider"; +import type { ProcessOutputEvent, SandboxProcess } from "../../core/types"; +import { + commandString, + portResult, + toUint8Array, + unsupportedSnapshots, +} from "../../internal/provider-utils"; +import { createosCapabilities } from "../capabilities"; + +export interface CreateosOptions { + /** createos-sandbox API key. Falls back to CREATEOS_SANDBOX_API_KEY env var. */ + apiKey?: string; + /** Control-plane base URL. Falls back to CREATEOS_SANDBOX_BASE_URL env var. */ + baseUrl?: string; + /** VM sizing preset, e.g. "s-1vcpu-256mb" or "s-4vcpu-4gb". */ + shape?: string; + /** Rootfs catalog name or template id, e.g. "devbox:1". */ + rootfs?: string; + /** Enable HTTP ingress at create time. Defaults to true. */ + ingressEnabled?: boolean; + /** Egress allowlist rules (host:port). Empty or omitted = allow all. */ + egress?: string[]; + /** Env vars injected into every exec inside the VM. */ + envs?: Record; + /** Idle auto-pause timeout in seconds (60–86400). Omit to disable. */ + autoPauseAfterSeconds?: number; + /** Timeout in ms for sandbox creation. */ + timeout?: number; +} + +export { createosCapabilities } from "../capabilities"; + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function mapError(operation: string, error: unknown): SandboxError { + if (error instanceof SandboxError) return error; + if (!(error instanceof CreateosSandboxError)) { + return new SandboxError({ + code: "internal", + provider: "createos", + operation, + message: error instanceof Error ? error.message : "Unknown error", + cause: error, + }); + } + + const message = error.message; + const base = { provider: "createos" as const, operation, message, cause: error }; + + if (error instanceof CreateosSandboxAuthError) + return new SandboxError({ ...base, code: "authentication" }); + if (error instanceof CreateosSandboxPermissionError) + return new SandboxError({ ...base, code: "permission" }); + if (error instanceof CreateosSandboxNotFoundError) + return new SandboxError({ ...base, code: "not_found" }); + if (error instanceof CreateosSandboxTimeoutError) + return new SandboxError({ ...base, code: "timeout", retryable: true }); + if (error instanceof CreateosSandboxRateLimitError) + return new SandboxError({ ...base, code: "rate_limited", retryable: true }); + if (error instanceof CreateosSandboxValidationError) + return new SandboxError({ ...base, code: "invalid_input" }); + if (error instanceof CreateosSandboxServerError) + return new SandboxError({ ...base, code: "unavailable", retryable: true }); + if (error instanceof CreateosSandboxConnectionError) + return new SandboxError({ ...base, code: "unavailable", retryable: true }); + if (error instanceof CreateosSandboxApiError) + return new SandboxError({ ...base, code: "internal" }); + + return new SandboxError({ ...base, code: "internal" }); +} + +export function createos(options: CreateosOptions = {}): SandboxProvider { + return { + id: "createos", + capabilities: createosCapabilities, + async create(createOptions) { + const client = new CreateosSandboxClient({ + apiKey: options.apiKey, + baseUrl: options.baseUrl, + }); + + let raw: CreateosSandbox; + try { + raw = await client.createSandbox( + { + shape: options.shape ?? "s-1vcpu-256mb", + rootfs: options.rootfs, + ingress_enabled: options.ingressEnabled ?? true, + egress: options.egress, + envs: { ...createOptions.env, ...options.envs }, + auto_pause_after_seconds: options.autoPauseAfterSeconds, + }, + { + timeoutMs: options.timeout ?? createOptions.timeout, + signal: createOptions.signal, + }, + ); + } catch (error) { + throw mapError("create", error); + } + + try { + await raw.runCommand("mkdir", ["-p", createOptions.cwd]); + } catch (error) { + await raw.destroy().catch(() => undefined); + throw mapError("create", error); + } + + return { + id: raw.id, + raw, + capabilities: createosCapabilities, + files: { + async write(path, value) { + try { + const bytes = await toUint8Array(value); + await raw.files.upload(path, new Blob([new Uint8Array(bytes) as unknown as ArrayBuffer])); + } catch (error) { + throw mapError("files.write", error); + } + }, + async read(path) { + try { + return new Uint8Array(await raw.files.download(path)); + } catch (error) { + throw mapError("files.read", error); + } + }, + async list(path) { + try { + const result = await raw.runCommand("ls", ["-1apL", path]); + if (result.result.exit_code !== 0) { + throw new CreateosSandboxError( + `ls failed: ${result.result.stderr || result.result.error}`, + ); + } + return result.result.stdout + .split("\n") + .filter((line) => line && line !== "." && line !== ".." && line !== "./" && line !== "../") + .map((line) => { + const isDir = line.endsWith("/"); + const name = isDir ? line.slice(0, -1) : line; + const normalizedPath = path.endsWith("/") ? path : `${path}/`; + return { + name, + path: `${normalizedPath}${name}`, + type: isDir ? ("directory" as const) : ("file" as const), + }; + }); + } catch (error) { + throw mapError("files.list", error); + } + }, + async mkdir(path) { + try { + const result = await raw.runCommand("mkdir", ["-p", path]); + if (result.result.exit_code !== 0) { + throw new CreateosSandboxError( + `mkdir failed: ${result.result.stderr || result.result.error}`, + ); + } + } catch (error) { + throw mapError("files.mkdir", error); + } + }, + async remove(path) { + try { + const result = await raw.runCommand("rm", ["-rf", path]); + if (result.result.exit_code !== 0) { + throw new CreateosSandboxError( + `rm failed: ${result.result.stderr || result.result.error}`, + ); + } + } catch (error) { + throw mapError("files.remove", error); + } + }, + async exists(path) { + try { + const result = await raw.runCommand("test", ["-e", path]); + return result.result.exit_code === 0; + } catch (error) { + throw mapError("files.exists", error); + } + }, + }, + async run(command, runOptions) { + try { + const cmd = commandString(command); + const envExports = runOptions.env + ? Object.entries(runOptions.env) + .map(([k, v]) => `export ${k}=${shellQuote(v)}`) + .join("; ") + "; " + : ""; + const cdPrefix = runOptions.cwd + ? `cd ${shellQuote(runOptions.cwd)} && ` + : ""; + const fullArgs = runOptions.cwd || runOptions.env + ? ["-c", `${envExports}${cdPrefix}${cmd}`] + : ["-c", cmd]; + + const started = performance.now(); + const response = await raw.runCommand("bash", fullArgs, { + timeoutMs: runOptions.timeout, + signal: runOptions.signal, + }); + return { + stdout: response.result.stdout, + stderr: response.result.stderr, + exitCode: response.result.exit_code, + success: response.result.exit_code === 0, + durationMs: Math.round(performance.now() - started), + }; + } catch (error) { + throw mapError("process.run", error); + } + }, + async start(command, runOptions) { + const events: ProcessOutputEvent[] = []; + const waiters = new Set<() => void>(); + let running = true; + let exitCode = -1; + + const cmd = commandString(command); + const envExports = runOptions.env + ? Object.entries(runOptions.env) + .map(([k, v]) => `export ${k}=${shellQuote(v)}`) + .join("; ") + "; " + : ""; + const cdPrefix = runOptions.cwd + ? `cd ${shellQuote(runOptions.cwd)} && ` + : ""; + const fullCmd = `${envExports}${cdPrefix}${cmd}`; + + const push = (stream: "stdout" | "stderr", data: string) => { + events.push({ stream, data, timestamp: new Date() }); + for (const wake of waiters) wake(); + waiters.clear(); + }; + + const streamIter = raw.streamCommand("bash", ["-c", fullCmd], { + timeoutMs: runOptions.timeout, + signal: runOptions.signal, + }); + + const completed = (async () => { + try { + for await (const event of streamIter) { + switch (event.type) { + case "stdout": + push("stdout", event.data); + break; + case "stderr": + push("stderr", event.data); + break; + case "exit": + exitCode = event.exitCode; + break; + case "error": + push("stderr", event.message); + break; + } + } + } finally { + running = false; + for (const wake of waiters) wake(); + waiters.clear(); + } + return { exitCode }; + })(); + + const process: SandboxProcess = { + id: `createos-stream-${Date.now()}`, + async status() { + return running ? "running" : "exited"; + }, + async *output() { + let index = 0; + while (running || index < events.length) { + while (index < events.length) yield events[index++]!; + if (!running) break; + await new Promise((resolve) => waiters.add(resolve)); + } + }, + async write() { + throw new SandboxError({ + code: "unsupported", + provider: "createos", + operation: "process.stdin", + message: "createos does not support stdin on exec", + }); + }, + wait: () => completed, + async kill() { + running = false; + for (const wake of waiters) wake(); + waiters.clear(); + }, + }; + return process; + }, + async expose(port) { + try { + if (!raw.data.ingress_enabled) { + await raw.setIngress(true); + } + return portResult(port, raw.previewUrl(port), false, true); + } catch (error) { + throw mapError("ports.expose", error); + } + }, + snapshots: unsupportedSnapshots("createos"), + async stop() { + try { + await raw.destroy(); + } catch (error) { + throw mapError("stop", error); + } + }, + }; + }, + }; +} + +export type { CreateosSandbox }; diff --git a/packages/sdk/tests/live/createos.test.ts b/packages/sdk/tests/live/createos.test.ts new file mode 100644 index 0000000..978a3f7 --- /dev/null +++ b/packages/sdk/tests/live/createos.test.ts @@ -0,0 +1,238 @@ +import { expect, test } from "bun:test"; +import { createSandbox } from "../../src"; +import { createos } from "../../src/providers/createos"; + +const hasKeys = Boolean( + process.env.CREATEOS_SANDBOX_API_KEY && process.env.CREATEOS_SANDBOX_BASE_URL, +); + +test.skipIf(!hasKeys)( + "CreateOS live: run command", + async () => { + const sandbox = await createSandbox({ + provider: createos({ timeout: 120_000 }), + timeout: 120_000, + }); + try { + expect((await sandbox.run("printf live-createos")).stdout).toContain("live-createos"); + } finally { + await sandbox.stop(); + } + }, + 150_000, +); + +test.skipIf(!hasKeys)( + "CreateOS live: file operations", + async () => { + const sandbox = await createSandbox({ + provider: createos({ timeout: 120_000 }), + timeout: 120_000, + }); + try { + await sandbox.files.write("/tmp/test.txt", "hello createos"); + expect(await sandbox.files.text("/tmp/test.txt")).toBe("hello createos"); + expect(await sandbox.files.exists("/tmp/test.txt")).toBe(true); + expect(await sandbox.files.exists("/tmp/nonexistent-file")).toBe(false); + + const entries = await sandbox.files.list("/tmp"); + expect(entries.some((e) => e.name === "test.txt")).toBe(true); + + await sandbox.files.mkdir("/tmp/testdir"); + expect(await sandbox.files.exists("/tmp/testdir")).toBe(true); + + await sandbox.files.remove("/tmp/test.txt"); + expect(await sandbox.files.exists("/tmp/test.txt")).toBe(false); + } finally { + await sandbox.stop(); + } + }, + 150_000, +); + +test.skipIf(!hasKeys)( + "CreateOS live: process execution with exit code", + async () => { + const sandbox = await createSandbox({ + provider: createos({ timeout: 120_000 }), + timeout: 120_000, + }); + try { + const result = await sandbox.run("printf out; printf err >&2; exit 7"); + expect(result).toMatchObject({ stdout: "out", stderr: "err", exitCode: 7, success: false }); + } finally { + await sandbox.stop(); + } + }, + 150_000, +); + +test.skipIf(!hasKeys)( + "CreateOS live: background process streaming", + async () => { + const sandbox = await createSandbox({ + provider: createos({ timeout: 120_000 }), + timeout: 120_000, + }); + try { + const proc = await sandbox.processes.start("printf streamed"); + const events: { stream: string; data: unknown }[] = []; + for await (const event of proc.output()) events.push(event); + expect(events.some((e) => e.stream === "stdout" && String(e.data).includes("streamed"))).toBe(true); + expect(await proc.wait()).toEqual({ exitCode: 0 }); + } finally { + await sandbox.stop(); + } + }, + 150_000, +); + +test.skipIf(!hasKeys)( + "CreateOS live: port exposure", + async () => { + const sandbox = await createSandbox({ + provider: createos({ timeout: 120_000, ingressEnabled: true }), + timeout: 120_000, + }); + try { + const port = await sandbox.ports.expose(8080); + expect(port).toMatchObject({ port: 8080, authenticated: true }); + expect(port.url).toContain("8080"); + } finally { + await sandbox.stop(); + } + }, + 150_000, +); + +test.skipIf(!hasKeys)( + "CreateOS live: snapshots unsupported", + async () => { + const sandbox = await createSandbox({ + provider: createos({ timeout: 120_000 }), + timeout: 120_000, + }); + try { + await expect(sandbox.snapshots.create()).rejects.toMatchObject({ code: "unsupported" }); + } finally { + await sandbox.stop(); + } + }, + 150_000, +); + +test.skipIf(!hasKeys)( + "CreateOS live: pause and resume via raw handle", + async () => { + const sandbox = await createSandbox({ + provider: createos({ timeout: 120_000 }), + timeout: 120_000, + }); + try { + expect((await sandbox.run("printf before-pause")).stdout).toContain("before-pause"); + + await sandbox.raw.pause(); + await sandbox.raw.waitUntilPaused({ timeoutMs: 60_000 }); + expect(sandbox.raw.status).toBe("paused"); + + await sandbox.raw.resume(); + await sandbox.raw.waitUntilRunning({ timeoutMs: 60_000 }); + expect(sandbox.raw.status).toBe("running"); + + expect((await sandbox.run("printf after-resume")).stdout).toContain("after-resume"); + } finally { + await sandbox.stop(); + } + }, + 300_000, +); + +test.skipIf(!hasKeys)( + "CreateOS live: create with custom options", + async () => { + const sandbox = await createSandbox({ + provider: createos({ + timeout: 120_000, + shape: "s-1vcpu-256mb", + ingressEnabled: true, + envs: { MY_VAR: "hello-from-createos" }, + }), + timeout: 120_000, + }); + try { + expect(sandbox.raw.data.shape).toBe("s-1vcpu-256mb"); + expect(sandbox.raw.data.vcpu).toBe(1); + expect(sandbox.raw.data.mem_mib).toBe(256); + expect((await sandbox.run("printenv MY_VAR")).stdout.trim()).toBe("hello-from-createos"); + expect(sandbox.raw.data.ingress_enabled).toBe(true); + } finally { + await sandbox.stop(); + } + }, + 150_000, +); + +test.skipIf(!hasKeys)( + "CreateOS live: fork a paused sandbox", + async () => { + const sandbox = await createSandbox({ + provider: createos({ timeout: 120_000 }), + timeout: 120_000, + }); + let forked; + try { + await sandbox.files.write("/tmp/fork-test.txt", "fork-data"); + expect(await sandbox.files.text("/tmp/fork-test.txt")).toBe("fork-data"); + + await sandbox.raw.pause(); + await sandbox.raw.waitUntilPaused({ timeoutMs: 60_000 }); + + forked = await sandbox.raw.fork(); + await forked.waitUntilRunning({ timeoutMs: 60_000 }); + expect(forked.id).not.toBe(sandbox.raw.id); + + const forkResult = await forked.runCommand("cat", ["/tmp/fork-test.txt"]); + expect(forkResult.result.stdout).toBe("fork-data"); + } finally { + if (forked) await forked.destroy().catch(() => {}); + await sandbox.raw.resume().catch(() => {}); + await sandbox.stop(); + } + }, + 300_000, +); + +test.skipIf(!hasKeys)( + "CreateOS live: per-command cwd and env", + async () => { + const sandbox = await createSandbox({ + provider: createos({ timeout: 120_000 }), + timeout: 120_000, + }); + try { + await sandbox.files.mkdir("/tmp/mydir"); + expect((await sandbox.run("pwd", { cwd: "/tmp/mydir" })).stdout.trim()).toBe("/tmp/mydir"); + expect((await sandbox.run("printenv CUSTOM_VAR", { env: { CUSTOM_VAR: "per-command" } })).stdout.trim()).toBe("per-command"); + } finally { + await sandbox.stop(); + } + }, + 150_000, +); + +test.skipIf(!hasKeys)( + "CreateOS live: stdin throws unsupported", + async () => { + const sandbox = await createSandbox({ + provider: createos({ timeout: 120_000 }), + timeout: 120_000, + }); + try { + const proc = await sandbox.processes.start("cat"); + await expect(proc.write("hello")).rejects.toMatchObject({ code: "unsupported", provider: "createos" }); + } finally { + await sandbox.stop(); + } + }, + 150_000, +); diff --git a/packages/sdk/tests/providers/createos.test.ts b/packages/sdk/tests/providers/createos.test.ts new file mode 100644 index 0000000..362b958 --- /dev/null +++ b/packages/sdk/tests/providers/createos.test.ts @@ -0,0 +1,155 @@ +import { expect, mock, test } from "bun:test"; +import { createSandbox } from "../../src"; + +const files = new Map(); +const destroyMock = mock(async () => ({ id: "sb_test", status: "destroyed" as const })); + +class MockSandboxFiles { + upload = mock(async (_path: string, data: Blob) => { + files.set(_path, await data.arrayBuffer()); + }); + download = mock(async (path: string) => { + const data = files.get(path); + if (!data) throw new Error("Not found"); + return data; + }); +} + +class MockSandbox { + id = "sb_test"; + status = "running" as const; + data = { + id: "sb_test", + status: "running" as const, + ingress_enabled: true, + ingress_url_template: "https://-sb_test.sb.example.com", + vcpu: 1, + mem_mib: 256, + disk_mib: 512, + created_at: "2025-01-01T00:00:00Z", + }; + files = new MockSandboxFiles(); + runCommand = mock(async (cmd: string, args: string[] = []) => { + if (cmd === "mkdir") return { result: { stdout: "", stderr: "", exit_code: 0 }, exec_ms: 1 }; + if (cmd === "test" && args[0] === "-e") return { result: { stdout: "", stderr: "", exit_code: 0 }, exec_ms: 1 }; + if (cmd === "ls") return { result: { stdout: "file1.txt\ndir1/\n", stderr: "", exit_code: 0 }, exec_ms: 1 }; + if (cmd === "rm") return { result: { stdout: "", stderr: "", exit_code: 0 }, exec_ms: 1 }; + if (cmd === "bash") return { result: { stdout: "createos-output", stderr: "createos-err", exit_code: 0 }, exec_ms: 5 }; + return { result: { stdout: "", stderr: "", exit_code: 0 }, exec_ms: 1 }; + }); + async *streamCommand() { + yield { type: "stdout" as const, data: "stream-out" }; + yield { type: "stderr" as const, data: "stream-err" }; + yield { type: "exit" as const, exitCode: 0 }; + } + setIngress = mock(async () => {}); + previewUrl = mock((port: number) => `https://${port}-sb_test.sb.example.com`); + destroy = destroyMock; + pause = mock(async () => {}); + resume = mock(async () => {}); + fork = mock(async () => new MockSandbox()); + refresh = mock(async () => {}); +} + +class MockClient { + createSandbox = mock(async () => new MockSandbox()); +} + +mock.module("@nodeops-createos/sandbox", () => ({ + CreateosSandboxClient: MockClient, + Sandbox: MockSandbox, + CreateosSandboxError: class extends Error {}, + CreateosSandboxApiError: class extends Error {}, + CreateosSandboxAuthError: class extends Error {}, + CreateosSandboxPermissionError: class extends Error {}, + CreateosSandboxNotFoundError: class extends Error {}, + CreateosSandboxTimeoutError: class extends Error {}, + CreateosSandboxRateLimitError: class extends Error {}, + CreateosSandboxValidationError: class extends Error {}, + CreateosSandboxServerError: class extends Error {}, + CreateosSandboxConnectionError: class extends Error {}, +})); + +test("createos adapter maps SDK operations", async () => { + const { createos } = await import("../../src/providers/createos"); + const sandbox = await createSandbox({ provider: createos() }); + + const result = await sandbox.run("echo hello"); + expect(result).toMatchObject({ stdout: "createos-output", exitCode: 0, success: true }); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + + await sandbox.files.write("/test.txt", "hello createos"); + expect(await sandbox.files.text("/test.txt")).toBe("hello createos"); + + const entries = await sandbox.files.list("/workspace"); + expect(entries).toEqual([ + { name: "file1.txt", path: "/workspace/file1.txt", type: "file" }, + { name: "dir1", path: "/workspace/dir1", type: "directory" }, + ]); + + await sandbox.files.mkdir("/workspace/newdir"); + await sandbox.files.remove("/workspace/oldfile"); + expect(await sandbox.files.exists("/workspace/test")).toBe(true); + + const port = await sandbox.ports.expose(8080); + expect(port).toMatchObject({ port: 8080, authenticated: true, public: false }); + expect(port.url).toContain("8080"); + + await sandbox.stop(); + expect(destroyMock).toHaveBeenCalled(); +}); + +test("createos adapter: background process streaming", async () => { + const { createos } = await import("../../src/providers/createos"); + const sandbox = await createSandbox({ provider: createos() }); + + const proc = await sandbox.processes.start("echo streaming"); + const events: { stream: string; data: unknown }[] = []; + for await (const event of proc.output()) events.push(event); + + expect(events[0]).toMatchObject({ stream: "stdout", data: "stream-out" }); + expect(events[1]).toMatchObject({ stream: "stderr", data: "stream-err" }); + expect(await proc.wait()).toEqual({ exitCode: 0 }); + + await sandbox.stop(); +}); + +test("createos adapter: stdin throws unsupported", async () => { + const { createos } = await import("../../src/providers/createos"); + const sandbox = await createSandbox({ provider: createos() }); + + const proc = await sandbox.processes.start("cat"); + await expect(proc.write("hello")).rejects.toMatchObject({ code: "unsupported", provider: "createos" }); + + await sandbox.stop(); +}); + +test("createos adapter: snapshots throw unsupported", async () => { + const { createos } = await import("../../src/providers/createos"); + const sandbox = await createSandbox({ provider: createos() }); + + await expect(sandbox.snapshots.create()).rejects.toMatchObject({ code: "unsupported", provider: "createos" }); + await expect(sandbox.snapshots.delete("snap-1")).rejects.toMatchObject({ code: "unsupported" }); + await expect(sandbox.snapshots.restore("snap-1")).rejects.toMatchObject({ code: "unsupported" }); + + await sandbox.stop(); +}); + +test("createos adapter: raw handle and capabilities", async () => { + const { createos, createosCapabilities } = await import("../../src/providers/createos"); + const sandbox = await createSandbox({ provider: createos() }); + + expect(sandbox.raw).toBeInstanceOf(MockSandbox); + expect(sandbox.provider).toBe("createos"); + expect(sandbox.id).toBe("sb_test"); + expect(sandbox.capabilities).toBe(createosCapabilities); + expect(sandbox.capabilities["files.read"]).toBe("full"); + expect(sandbox.capabilities["process.run"]).toBe("separate-streams"); + expect(sandbox.capabilities["process.stdin"]).toBe(false); + expect(sandbox.capabilities["snapshot.create"]).toBe(false); + expect(sandbox.capabilities["sandbox.resume"]).toBe("persistent"); + expect(sandbox.capabilities["image.custom"]).toBe("template"); + expect(sandbox.capabilities["compute.gpu"]).toBe(false); + + await sandbox.stop(); +}); diff --git a/packages/sdk/tsdown.config.ts b/packages/sdk/tsdown.config.ts index a73dbbe..526cfd1 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/createos/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", + "@nodeops-createos/sandbox", "ai", "@ai-sdk/harness", "@mastra/core/workspace", From bae6fe4a880799cf0c08f7d004a1c183d1d9b6db Mon Sep 17 00:00:00 2001 From: Bhautik Date: Mon, 27 Jul 2026 19:40:23 +0530 Subject: [PATCH 2/5] Fix review issues: kill cancellation, safe file listing, env key escaping - Use AbortController to cancel remote streamCommand on kill() - Replace ls -1apL with find -printf using null-byte delimiters to handle filenames with newlines and avoid symlink dereferencing - Quote env keys with shellQuote() in export statements to prevent shell injection via metacharacters in both run() and start() --- packages/sdk/src/providers/createos/index.ts | 34 ++++++++++++------- packages/sdk/tests/providers/createos.test.ts | 2 +- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/sdk/src/providers/createos/index.ts b/packages/sdk/src/providers/createos/index.ts index 596c277..7d2924d 100644 --- a/packages/sdk/src/providers/createos/index.ts +++ b/packages/sdk/src/providers/createos/index.ts @@ -146,23 +146,27 @@ export function createos(options: CreateosOptions = {}): SandboxProvider line && line !== "." && line !== ".." && line !== "./" && line !== "../") - .map((line) => { - const isDir = line.endsWith("/"); - const name = isDir ? line.slice(0, -1) : line; - const normalizedPath = path.endsWith("/") ? path : `${path}/`; + .split("\0") + .filter(Boolean) + .map((entry) => { + const sep = entry.indexOf("\t"); + const typeChar = entry.slice(0, sep); + const name = entry.slice(sep + 1); return { name, path: `${normalizedPath}${name}`, - type: isDir ? ("directory" as const) : ("file" as const), + type: typeChar === "d" ? ("directory" as const) : ("file" as const), }; }); } catch (error) { @@ -207,7 +211,7 @@ export function createos(options: CreateosOptions = {}): SandboxProvider `export ${k}=${shellQuote(v)}`) + .map(([k, v]) => `export ${shellQuote(k)}=${shellQuote(v)}`) .join("; ") + "; " : ""; const cdPrefix = runOptions.cwd @@ -242,7 +246,7 @@ export function createos(options: CreateosOptions = {}): SandboxProvider `export ${k}=${shellQuote(v)}`) + .map(([k, v]) => `export ${shellQuote(k)}=${shellQuote(v)}`) .join("; ") + "; " : ""; const cdPrefix = runOptions.cwd @@ -256,9 +260,14 @@ export function createos(options: CreateosOptions = {}): SandboxProvider { @@ -310,6 +319,7 @@ export function createos(options: CreateosOptions = {}): SandboxProvider completed, async kill() { + killController.abort(); running = false; for (const wake of waiters) wake(); waiters.clear(); diff --git a/packages/sdk/tests/providers/createos.test.ts b/packages/sdk/tests/providers/createos.test.ts index 362b958..bd7ce9b 100644 --- a/packages/sdk/tests/providers/createos.test.ts +++ b/packages/sdk/tests/providers/createos.test.ts @@ -32,7 +32,7 @@ class MockSandbox { runCommand = mock(async (cmd: string, args: string[] = []) => { if (cmd === "mkdir") return { result: { stdout: "", stderr: "", exit_code: 0 }, exec_ms: 1 }; if (cmd === "test" && args[0] === "-e") return { result: { stdout: "", stderr: "", exit_code: 0 }, exec_ms: 1 }; - if (cmd === "ls") return { result: { stdout: "file1.txt\ndir1/\n", stderr: "", exit_code: 0 }, exec_ms: 1 }; + if (cmd === "find") return { result: { stdout: "f\tfile1.txt\0d\tdir1\0", stderr: "", exit_code: 0 }, exec_ms: 1 }; if (cmd === "rm") return { result: { stdout: "", stderr: "", exit_code: 0 }, exec_ms: 1 }; if (cmd === "bash") return { result: { stdout: "createos-output", stderr: "createos-err", exit_code: 0 }, exec_ms: 5 }; return { result: { stdout: "", stderr: "", exit_code: 0 }, exec_ms: 1 }; From aa0376f631fb4ee89472ac21d23465b378ebb129 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Mon, 27 Jul 2026 20:23:36 +0530 Subject: [PATCH 3/5] Fix kill/wait rejection and env key handling - Catch abort errors in stream iterator so wait() resolves with exit code 137 instead of rejecting when kill() is called - Replace export with env command prefix to support non-identifier env keys like MY-KEY in both run() and start() --- packages/sdk/src/providers/createos/index.ts | 26 ++++++++++++-------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/sdk/src/providers/createos/index.ts b/packages/sdk/src/providers/createos/index.ts index 7d2924d..504541e 100644 --- a/packages/sdk/src/providers/createos/index.ts +++ b/packages/sdk/src/providers/createos/index.ts @@ -209,16 +209,16 @@ export function createos(options: CreateosOptions = {}): SandboxProvider `export ${shellQuote(k)}=${shellQuote(v)}`) - .join("; ") + "; " + const envPrefix = runOptions.env + ? "env " + Object.entries(runOptions.env) + .map(([k, v]) => `${shellQuote(k + "=" + v)}`) + .join(" ") + " " : ""; const cdPrefix = runOptions.cwd ? `cd ${shellQuote(runOptions.cwd)} && ` : ""; const fullArgs = runOptions.cwd || runOptions.env - ? ["-c", `${envExports}${cdPrefix}${cmd}`] + ? ["-c", `${cdPrefix}${envPrefix}${cmd}`] : ["-c", cmd]; const started = performance.now(); @@ -244,15 +244,15 @@ export function createos(options: CreateosOptions = {}): SandboxProvider `export ${shellQuote(k)}=${shellQuote(v)}`) - .join("; ") + "; " + const envPrefix = runOptions.env + ? "env " + Object.entries(runOptions.env) + .map(([k, v]) => `${shellQuote(k + "=" + v)}`) + .join(" ") + " " : ""; const cdPrefix = runOptions.cwd ? `cd ${shellQuote(runOptions.cwd)} && ` : ""; - const fullCmd = `${envExports}${cdPrefix}${cmd}`; + const fullCmd = `${cdPrefix}${envPrefix}${cmd}`; const push = (stream: "stdout" | "stderr", data: string) => { events.push({ stream, data, timestamp: new Date() }); @@ -288,6 +288,12 @@ export function createos(options: CreateosOptions = {}): SandboxProvider Date: Mon, 27 Jul 2026 20:58:55 +0530 Subject: [PATCH 4/5] Fix caller signal cancellation and kill awaiting stream termination - Check combinedSignal.aborted instead of only killController so caller-provided signal aborts also resolve wait() gracefully - Make kill() await the completed promise so it only returns after the remote stream has actually terminated --- packages/sdk/src/providers/createos/index.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/providers/createos/index.ts b/packages/sdk/src/providers/createos/index.ts index 504541e..681d267 100644 --- a/packages/sdk/src/providers/createos/index.ts +++ b/packages/sdk/src/providers/createos/index.ts @@ -289,7 +289,7 @@ export function createos(options: CreateosOptions = {}): SandboxProvider completed, async kill() { killController.abort(); - running = false; - for (const wake of waiters) wake(); - waiters.clear(); + await completed.catch(() => {}); }, }; return process; From b2603240b2fa3276a6f634a2641793ba0c0f7dd9 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Tue, 28 Jul 2026 09:58:31 +0530 Subject: [PATCH 5/5] Classify symlinks correctly in files.list() Map find type char 'l' to 'symlink' instead of falling through to 'file'. --- packages/sdk/src/providers/createos/index.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/providers/createos/index.ts b/packages/sdk/src/providers/createos/index.ts index 681d267..6db5fed 100644 --- a/packages/sdk/src/providers/createos/index.ts +++ b/packages/sdk/src/providers/createos/index.ts @@ -163,11 +163,12 @@ export function createos(options: CreateosOptions = {}): SandboxProvider