diff --git a/bun.lock b/bun.lock index 4ede584..c50a7d7 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "sandbox-sdk", @@ -62,6 +61,7 @@ "@asciidev/box-sdk": "0.0.24", "@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", @@ -77,6 +77,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", @@ -87,6 +88,7 @@ "@ai-sdk/harness", "@daytona/sdk", "@mastra/core", + "@nodeops-createos/sandbox", "@upstash/box", "@vercel/sandbox", "ai", @@ -468,6 +470,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=="], @@ -2112,6 +2116,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=="], @@ -2334,6 +2340,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 c27f569..a0829be 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -54,6 +54,10 @@ "types": "./dist/providers/railway/index.d.mts", "import": "./dist/providers/railway/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" @@ -100,7 +104,8 @@ "test:live:vercel": "bun test tests/live/vercel.test.ts", "test:live:upstash": "bun test tests/live/upstash.test.ts", "test:live:box": "bun test tests/live/box.test.ts", - "test:live:railway": "bun test tests/live/railway.test.ts" + "test:live:railway": "bun test tests/live/railway.test.ts", + "test:live:createos": "bun test tests/live/createos.test.ts" }, "dependencies": { "@rivet-dev/agentos-core": "^0.2.7" @@ -110,6 +115,7 @@ "@asciidev/box-sdk": "0.0.24", "@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", @@ -129,6 +135,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", "railway": "^3.5.7" @@ -161,6 +168,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 22b9c45..88c2dfb 100644 --- a/packages/sdk/src/core/types.ts +++ b/packages/sdk/src/core/types.ts @@ -6,6 +6,7 @@ export const providerNames = [ "upstash", "box", "railway", + "createos", ] as const; export type ProviderName = (typeof providerNames)[number]; diff --git a/packages/sdk/src/metadata.ts b/packages/sdk/src/metadata.ts index ae86480..4e7b63f 100644 --- a/packages/sdk/src/metadata.ts +++ b/packages/sdk/src/metadata.ts @@ -1,5 +1,6 @@ import { boxCapabilities, + createosCapabilities, daytonaCapabilities, e2bCapabilities, localCapabilities, @@ -174,6 +175,25 @@ export const providers: readonly ProviderMetadata[] = [ runtimeLimitations: "Railway Sandboxes are in Priority Boarding and the provider SDK may introduce breaking changes between releases.", }, + { + 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 888ebd5..8d82678 100644 --- a/packages/sdk/src/providers/capabilities.ts +++ b/packages/sdk/src/providers/capabilities.ts @@ -125,3 +125,19 @@ export const railwayCapabilities = defineCapabilities({ "image.custom": "template", "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..6db5fed --- /dev/null +++ b/packages/sdk/src/providers/createos/index.ts @@ -0,0 +1,358 @@ +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("find", [ + path, "-maxdepth", "1", "-mindepth", "1", "-printf", "%y\\t%f\\0", + ]); + if (result.result.exit_code !== 0) { + throw new CreateosSandboxError( + `find failed: ${result.result.stderr || result.result.error}`, + ); + } + if (!result.result.stdout) return []; + const normalizedPath = path.endsWith("/") ? path : `${path}/`; + return result.result.stdout + .split("\0") + .filter(Boolean) + .map((entry) => { + const sep = entry.indexOf("\t"); + const typeChar = entry.slice(0, sep); + const name = entry.slice(sep + 1); + const type = typeChar === "d" + ? ("directory" as const) + : typeChar === "l" + ? ("symlink" as const) + : ("file" as const); + return { name, path: `${normalizedPath}${name}`, type }; + }); + } 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 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", `${cdPrefix}${envPrefix}${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 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 = `${cdPrefix}${envPrefix}${cmd}`; + + const push = (stream: "stdout" | "stderr", data: string) => { + events.push({ stream, data, timestamp: new Date() }); + for (const wake of waiters) wake(); + waiters.clear(); + }; + + const killController = new AbortController(); + const combinedSignal = runOptions.signal + ? AbortSignal.any([runOptions.signal, killController.signal]) + : killController.signal; + + const streamIter = raw.streamCommand("bash", ["-c", fullCmd], { + timeoutMs: runOptions.timeout, + signal: combinedSignal, + }); + + 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; + } + } + } catch (error) { + if (combinedSignal.aborted) { + exitCode = exitCode === -1 ? 137 : exitCode; + } else { + throw error; + } + } 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() { + killController.abort(); + await completed.catch(() => {}); + }, + }; + 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..bd7ce9b --- /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 === "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 }; + }); + 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 82e68e2..57ead73 100644 --- a/packages/sdk/tsdown.config.ts +++ b/packages/sdk/tsdown.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ "src/providers/upstash/index.ts", "src/providers/box/index.ts", "src/providers/railway/index.ts", + "src/providers/createos/index.ts", "src/ai/index.ts", "src/ai/harness.ts", "src/eve/index.ts", @@ -30,6 +31,7 @@ export default defineConfig({ "@upstash/box", "@asciidev/box-sdk", "railway", + "@nodeops-createos/sandbox", "ai", "@ai-sdk/harness", "@mastra/core/workspace",