From 8fb53f023560f3f5685a520b8e09b1e0767a6ccc Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:37:43 +0530 Subject: [PATCH] feat(providers): add createos sandbox provider Add a SandboxProvider for the createos (@nodeops-createos/sandbox) VM sandbox control plane, mirroring the existing e2b provider pattern. - src/providers/createos.ts: factory returning a SandboxProvider. create() provisions a sandbox with ingress enabled, curl-installs sandbox-agent, installs the default agents, and starts the server; getUrl() resolves the ingress preview URL for the agent port; pause/kill/destroy/reconnect/ ensureServer map onto the SDK lifecycle (reconnect resumes paused boxes and maps NotFound -> SandboxDestroyedError). - package.json: ./createos subpath export + optional peerDep/devDep on @nodeops-createos/sandbox. - tsup.config.ts: new entry + external. - tests/createos.test.ts: drives the real SDK through a mocked fetch to assert wire behavior (install/server command strings, ingress URL, lifecycle endpoints). - tests/providers.test.ts: registry entry, gated on CREATEOS_API_KEY. - examples/createos: runnable example mirroring examples/e2b. Verified live against the production control plane: provision, /v1/health, a Claude Code session on the haiku model, and teardown. Note: pnpm-lock.yaml is intentionally not included; adding the dep regenerates 1400+ lines of unrelated transitive bumps from preexisting drift in the committed lock. Run `pnpm install` to refresh it locally. --- examples/createos/package.json | 20 +++ examples/createos/src/createos.ts | 35 +++++ examples/createos/src/index.ts | 31 ++++ examples/createos/tests/createos.test.ts | 28 ++++ examples/createos/tsconfig.json | 17 +++ sdks/typescript/package.json | 9 ++ sdks/typescript/src/providers/createos.ts | 125 ++++++++++++++++ sdks/typescript/tests/createos.test.ts | 171 ++++++++++++++++++++++ sdks/typescript/tests/providers.test.ts | 21 +++ sdks/typescript/tsup.config.ts | 2 + 10 files changed, 459 insertions(+) create mode 100644 examples/createos/package.json create mode 100644 examples/createos/src/createos.ts create mode 100644 examples/createos/src/index.ts create mode 100644 examples/createos/tests/createos.test.ts create mode 100644 examples/createos/tsconfig.json create mode 100644 sdks/typescript/src/providers/createos.ts create mode 100644 sdks/typescript/tests/createos.test.ts diff --git a/examples/createos/package.json b/examples/createos/package.json new file mode 100644 index 00000000..2109bb8f --- /dev/null +++ b/examples/createos/package.json @@ -0,0 +1,20 @@ +{ + "name": "@sandbox-agent/example-createos", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@nodeops-createos/sandbox": "latest", + "@sandbox-agent/example-shared": "workspace:*", + "sandbox-agent": "workspace:*" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest", + "vitest": "^3.0.0" + } +} diff --git a/examples/createos/src/createos.ts b/examples/createos/src/createos.ts new file mode 100644 index 00000000..8d0410c1 --- /dev/null +++ b/examples/createos/src/createos.ts @@ -0,0 +1,35 @@ +import { SandboxAgent } from "sandbox-agent"; +import { createos } from "sandbox-agent/createos"; + +function collectEnvVars(): Record { + const envs: Record = {}; + if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + return envs; +} + +function inspectorUrlToBaseUrl(inspectorUrl: string): string { + return inspectorUrl.replace(/\/ui\/$/, ""); +} + +export async function setupCreateosSandboxAgent(): Promise<{ + baseUrl: string; + token?: string; + cleanup: () => Promise; +}> { + const client = await SandboxAgent.start({ + sandbox: createos({ + client: { apiKey: process.env.CREATEOS_API_KEY }, + ...(process.env.CREATEOS_SHAPE ? { shape: process.env.CREATEOS_SHAPE } : {}), + ...(process.env.CREATEOS_ROOTFS ? { rootfs: process.env.CREATEOS_ROOTFS } : {}), + create: { envs: collectEnvVars() }, + }), + }); + + return { + baseUrl: inspectorUrlToBaseUrl(client.inspectorUrl), + cleanup: async () => { + await client.killSandbox(); + }, + }; +} diff --git a/examples/createos/src/index.ts b/examples/createos/src/index.ts new file mode 100644 index 00000000..b190e0ec --- /dev/null +++ b/examples/createos/src/index.ts @@ -0,0 +1,31 @@ +import { detectAgent } from "@sandbox-agent/example-shared"; +import { SandboxAgent } from "sandbox-agent"; +import { createos } from "sandbox-agent/createos"; + +const envs: Record = {}; +if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + +const client = await SandboxAgent.start({ + // ✨ NEW ✨ + sandbox: createos({ + client: { apiKey: process.env.CREATEOS_API_KEY }, + ...(process.env.CREATEOS_SHAPE ? { shape: process.env.CREATEOS_SHAPE } : {}), + create: { envs }, + }), +}); + +const session = await client.createSession({ + agent: detectAgent(), +}); + +session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); +}); + +session.prompt([{ type: "text", text: "Say hello from createos in one sentence." }]); + +process.once("SIGINT", async () => { + await client.destroySandbox(); + process.exit(0); +}); diff --git a/examples/createos/tests/createos.test.ts b/examples/createos/tests/createos.test.ts new file mode 100644 index 00000000..d4ab1e66 --- /dev/null +++ b/examples/createos/tests/createos.test.ts @@ -0,0 +1,28 @@ +import { buildHeaders } from "@sandbox-agent/example-shared"; +import { describe, expect, it } from "vitest"; +import { setupCreateosSandboxAgent } from "../src/createos.ts"; + +const shouldRun = Boolean(process.env.CREATEOS_API_KEY); +const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 300_000; + +const testFn = shouldRun ? it : it.skip; + +describe("createos example", () => { + testFn( + "starts sandbox-agent and responds to /v1/health", + async () => { + const { baseUrl, token, cleanup } = await setupCreateosSandboxAgent(); + try { + const response = await fetch(`${baseUrl}/v1/health`, { + headers: buildHeaders({ token }), + }); + expect(response.ok).toBe(true); + const data = await response.json(); + expect(data.status).toBe("ok"); + } finally { + await cleanup(); + } + }, + timeoutMs, + ); +}); diff --git a/examples/createos/tsconfig.json b/examples/createos/tsconfig.json new file mode 100644 index 00000000..ad591c3b --- /dev/null +++ b/examples/createos/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index dc22ca73..18ad6b9e 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -54,9 +54,14 @@ "./sprites": { "types": "./dist/providers/sprites.d.ts", "import": "./dist/providers/sprites.js" + }, + "./createos": { + "types": "./dist/providers/createos.d.ts", + "import": "./dist/providers/createos.js" } }, "peerDependencies": { + "@nodeops-createos/sandbox": ">=0.6.0", "@cloudflare/sandbox": ">=0.1.0", "@daytonaio/sdk": ">=0.12.0", "@e2b/code-interpreter": ">=1.0.0", @@ -68,6 +73,9 @@ "computesdk": ">=0.1.0" }, "peerDependenciesMeta": { + "@nodeops-createos/sandbox": { + "optional": true + }, "@cloudflare/sandbox": { "optional": true }, @@ -113,6 +121,7 @@ "test:watch": "vitest" }, "devDependencies": { + "@nodeops-createos/sandbox": ">=0.6.0", "@cloudflare/sandbox": ">=0.1.0", "@daytonaio/sdk": ">=0.12.0", "@e2b/code-interpreter": ">=1.0.0", diff --git a/sdks/typescript/src/providers/createos.ts b/sdks/typescript/src/providers/createos.ts new file mode 100644 index 00000000..9d8fe362 --- /dev/null +++ b/sdks/typescript/src/providers/createos.ts @@ -0,0 +1,125 @@ +import { type CreateosSandboxClientOptions, CreateosSandboxNotFoundError, type CreateSandboxRequest, Sandbox } from "@nodeops-createos/sandbox"; +import { SandboxDestroyedError } from "../client.ts"; +import { buildServerStartCommand, DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts"; +import type { SandboxProvider } from "./types.ts"; + +const DEFAULT_AGENT_PORT = 3000; +// createos requires a shape at create time; this is a sane default for running +// sandbox-agent plus a coding agent. Override via `shape` / `create`. +const DEFAULT_SHAPE = "s-1vcpu-1gb"; +const DEFAULT_CWD = "/root"; +// sandbox-agent installs into /usr/local/bin or ~/.local/bin; a login shell +// won't always have the latter on PATH, so export it before every command. +const SANDBOX_AGENT_PATH_EXPORT = 'export PATH="/usr/local/bin:$HOME/.local/bin:$PATH"'; + +type CreateOverrides = Omit, "shape"> & { shape?: string }; +type CreateOverridesInput = CreateOverrides | (() => CreateOverrides | Promise); + +export interface CreateosProviderOptions { + /** Client options forwarded to the createos SDK (apiKey, baseUrl, …). */ + client?: CreateosSandboxClientOptions; + /** Shape id from `listShapes()`. Defaults to {@link DEFAULT_SHAPE}. */ + shape?: string; + /** Rootfs catalog name or template id. Empty = host default. */ + rootfs?: string; + /** Extra create-request overrides, or a factory returning them. */ + create?: CreateOverridesInput; + /** In-guest port the sandbox-agent server listens on. */ + agentPort?: number; + /** Default working directory for sessions. */ + defaultCwd?: string; + /** URL scheme for the ingress preview URL. Defaults to "https"; pass + * "http" when the ingress TLS cert has not been provisioned yet. */ + scheme?: "http" | "https"; +} + +async function resolveCreate(value: CreateOverridesInput | undefined): Promise { + if (!value) return {}; + return typeof value === "function" ? await value() : value; +} + +function shScript(command: string): string { + return `${SANDBOX_AGENT_PATH_EXPORT}; ${command}`; +} + +export function createos(options: CreateosProviderOptions = {}): SandboxProvider { + const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; + const scheme = options.scheme ?? "https"; + const clientOptions = options.client ?? {}; + + const connect = (sandboxId: string) => Sandbox.connect(sandboxId, { ...clientOptions }); + + return { + name: "createos", + defaultCwd: options.defaultCwd ?? DEFAULT_CWD, + async create(): Promise { + const overrides = await resolveCreate(options.create); + const request: CreateSandboxRequest = { + shape: options.shape ?? overrides.shape ?? DEFAULT_SHAPE, + ...(options.rootfs !== undefined ? { rootfs: options.rootfs } : {}), + ...overrides, + // Ingress is how we reach the agent server from outside the sandbox. + ingress_enabled: true, + }; + + const sandbox = await Sandbox.create(request, { ...clientOptions }); + + await sandbox.sh(shScript(`curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`), { + label: "createos install", + }); + for (const agent of DEFAULT_AGENTS) { + await sandbox.sh(shScript(`sandbox-agent install-agent ${agent}`), { + label: `createos install-agent ${agent}`, + }); + } + await sandbox.sh(shScript(buildServerStartCommand(agentPort)), { + label: "createos server start", + }); + + return sandbox.id; + }, + async destroy(sandboxId: string): Promise { + const sandbox = await connect(sandboxId); + await sandbox.destroy(); + }, + async reconnect(sandboxId: string): Promise { + let sandbox: Sandbox; + try { + sandbox = await connect(sandboxId); + } catch (error) { + if (error instanceof CreateosSandboxNotFoundError) { + throw new SandboxDestroyedError(sandboxId, "createos", { cause: error }); + } + throw error; + } + if (sandbox.status === "paused") { + await sandbox.resume(); + } + }, + async pause(sandboxId: string): Promise { + const sandbox = await connect(sandboxId); + await sandbox.pause(); + }, + async kill(sandboxId: string): Promise { + const sandbox = await connect(sandboxId); + await sandbox.destroy(); + }, + async getUrl(sandboxId: string): Promise { + const sandbox = await connect(sandboxId); + if (!sandbox.data.ingress_enabled) { + await sandbox.setIngress(true); + } + return sandbox.previewUrl(agentPort, { scheme }); + }, + async ensureServer(sandboxId: string): Promise { + const sandbox = await connect(sandboxId); + if (sandbox.status === "paused") { + await sandbox.resume(); + } + // Idempotent: a duplicate server exits on port conflict. + await sandbox.sh(shScript(buildServerStartCommand(agentPort)), { + label: "createos ensure server", + }); + }, + }; +} diff --git a/sdks/typescript/tests/createos.test.ts b/sdks/typescript/tests/createos.test.ts new file mode 100644 index 00000000..78758cec --- /dev/null +++ b/sdks/typescript/tests/createos.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { createos } from "../src/providers/createos.ts"; + +// Drives the REAL @nodeops-createos/sandbox SDK through a mocked fetch (the +// SDK's documented test seam). Verifies the provider's wire behavior — +// command strings, ingress URL, lifecycle endpoint mapping — without secrets +// or a live control plane. + +const SANDBOX_ID = "sb_test_01"; +const INGRESS_TEMPLATE = "https://-sb.example.test"; + +interface ExecCall { + cmd: string; + args: string[]; +} + +function jsend(data: unknown, status = 200): Response { + return new Response(JSON.stringify({ status: "success", data }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function runningView(overrides: Record = {}): Record { + return { + id: SANDBOX_ID, + status: "running", + ip: "10.0.0.2", + vcpu: 1, + mem_mib: 1024, + disk_mib: 4096, + created_at: "2026-06-22T00:00:00Z", + ingress_enabled: true, + ingress_url_template: INGRESS_TEMPLATE, + shape: "s-1vcpu-1gb", + rootfs: "devbox:1", + ...overrides, + }; +} + +interface Harness { + fetch: typeof globalThis.fetch; + execCalls: ExecCall[]; + hits: string[]; + bodies: Record; +} + +function makeHarness(opts: { ingressOnCreate?: boolean } = {}): Harness { + const execCalls: ExecCall[] = []; + const hits: string[] = []; + const bodies: Record = {}; + const ingress = opts.ingressOnCreate ?? true; + + const fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = new URL(typeof input === "string" ? input : input.toString()); + const method = (init?.method ?? "GET").toUpperCase(); + const path = url.pathname; + const key = `${method} ${path}`; + hits.push(key); + const body = init?.body ? JSON.parse(init.body as string) : undefined; + if (body) { + bodies[key] ??= []; + bodies[key].push(body); + } + + // POST /v1/sandboxes — create + if (method === "POST" && path === "/v1/sandboxes") { + return jsend({ + id: SANDBOX_ID, + name: "test", + ip: "10.0.0.2", + shape: "s-1vcpu-1gb", + rootfs: "devbox:1", + vcpu: 1, + mem_mib: 1024, + disk_mib: 4096, + spawn_ms: 10, + egress: ["*"], + bandwidth_quota_bytes: -1, + ...(ingress ? { ingress_url_template: INGRESS_TEMPLATE } : {}), + }); + } + // POST /v1/sandboxes/:id/exec + if (method === "POST" && path.endsWith("/exec")) { + execCalls.push({ cmd: body.cmd, args: body.args }); + return jsend({ result: { stdout: "", stderr: "", exit_code: 0 }, exec_ms: 1 }); + } + // POST .../pause | .../resume — return the patched view + if (method === "POST" && path.endsWith("/pause")) return jsend(runningView({ status: "paused" })); + if (method === "POST" && path.endsWith("/resume")) return jsend(runningView()); + // PATCH /v1/sandboxes/:id — setIngress + if (method === "PATCH" && path === `/v1/sandboxes/${SANDBOX_ID}`) { + return jsend(runningView({ ingress_enabled: body.ingress_enabled, ingress_url_template: INGRESS_TEMPLATE })); + } + // DELETE /v1/sandboxes/:id — destroy + if (method === "DELETE" && path === `/v1/sandboxes/${SANDBOX_ID}`) { + return jsend({ id: SANDBOX_ID, status: "destroying" }); + } + // GET /v1/sandboxes/:id — view + if (method === "GET" && path === `/v1/sandboxes/${SANDBOX_ID}`) { + return jsend(runningView({ ingress_enabled: ingress })); + } + + return jsend({ message: `unhandled ${key}` }, 500); + }) as typeof globalThis.fetch; + + return { fetch, execCalls, hits, bodies }; +} + +function provider(h: Harness) { + return createos({ client: { apiKey: "test", baseUrl: "https://api.test", fetch: h.fetch } }); +} + +describe("createos provider (mocked fetch)", () => { + it("create(): provisions with ingress, installs agent + agents, starts server", async () => { + const h = makeHarness(); + const id = await provider(h).create(); + + expect(id).toBe(SANDBOX_ID); + + // ingress requested at create time + const createBody = h.bodies["POST /v1/sandboxes"]?.[0] as Record; + expect(createBody.ingress_enabled).toBe(true); + expect(createBody.shape).toBe("s-1vcpu-1gb"); + + // every exec goes through `bash -lc` + for (const call of h.execCalls) { + expect(call.cmd).toBe("bash"); + expect(call.args[0]).toBe("-lc"); + } + const scripts = h.execCalls.map((c) => c.args[1]); + expect(scripts.some((s) => s.includes("curl -fsSL") && s.includes("install.sh"))).toBe(true); + expect(scripts.some((s) => s.includes("install-agent claude"))).toBe(true); + expect(scripts.some((s) => s.includes("install-agent codex"))).toBe(true); + expect(scripts.some((s) => s.includes("sandbox-agent server") && s.includes("--port 3000"))).toBe(true); + // PATH exported before each command + for (const s of scripts) expect(s).toContain("/usr/local/bin"); + }); + + it("getUrl(): returns ingress preview URL for the agent port", async () => { + const h = makeHarness(); + const url = await provider(h).getUrl(SANDBOX_ID); + expect(url).toBe("https://3000-sb.example.test"); + }); + + it("getUrl(): enables ingress when it was off", async () => { + const h = makeHarness({ ingressOnCreate: false }); + const url = await provider(h).getUrl(SANDBOX_ID); + expect(h.hits).toContain(`PATCH /v1/sandboxes/${SANDBOX_ID}`); + expect(url).toBe("https://3000-sb.example.test"); + }); + + it("destroy(): DELETEs the sandbox", async () => { + const h = makeHarness(); + await provider(h).destroy(SANDBOX_ID); + expect(h.hits).toContain(`DELETE /v1/sandboxes/${SANDBOX_ID}`); + }); + + it("pause(): POSTs /pause", async () => { + const h = makeHarness(); + await provider(h).pause?.(SANDBOX_ID); + expect(h.hits).toContain(`POST /v1/sandboxes/${SANDBOX_ID}/pause`); + }); + + it("ensureServer(): restarts the agent server", async () => { + const h = makeHarness(); + await provider(h).ensureServer?.(SANDBOX_ID); + const scripts = h.execCalls.map((c) => c.args[1]); + expect(scripts.some((s) => s.includes("sandbox-agent server") && s.includes("--port 3000"))).toBe(true); + }); +}); diff --git a/sdks/typescript/tests/providers.test.ts b/sdks/typescript/tests/providers.test.ts index ff6f04d6..de8ee427 100644 --- a/sdks/typescript/tests/providers.test.ts +++ b/sdks/typescript/tests/providers.test.ts @@ -17,6 +17,7 @@ import { vercel } from "../src/providers/vercel.ts"; import { modal } from "../src/providers/modal.ts"; import { computesdk } from "../src/providers/computesdk.ts"; import { sprites } from "../src/providers/sprites.ts"; +import { createos } from "../src/providers/createos.ts"; import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -304,6 +305,26 @@ function buildProviders(): ProviderEntry[] { }); } + // --- createos --- + { + entries.push({ + name: "createos", + skipReasons: [...missingEnvVars("CREATEOS_API_KEY"), ...missingModules("@nodeops-createos/sandbox")], + agent: "claude", + startTimeoutMs: 300_000, + canVerifyDestroyedSandbox: false, + sessionSkipReasons: missingEnvVars("ANTHROPIC_API_KEY"), + sessionCwd: "/root", + sessionTestsEnabled: true, + createProvider() { + return createos({ + client: { apiKey: process.env.CREATEOS_API_KEY }, + create: { envs: collectApiKeys() }, + }); + }, + }); + } + return entries; } diff --git a/sdks/typescript/tsup.config.ts b/sdks/typescript/tsup.config.ts index 0080eef0..327a883c 100644 --- a/sdks/typescript/tsup.config.ts +++ b/sdks/typescript/tsup.config.ts @@ -13,12 +13,14 @@ export default defineConfig({ "src/providers/modal.ts", "src/providers/computesdk.ts", "src/providers/sprites.ts", + "src/providers/createos.ts", ], format: ["esm"], dts: true, clean: true, sourcemap: true, external: [ + "@nodeops-createos/sandbox", "@cloudflare/sandbox", "@daytonaio/sdk", "@e2b/code-interpreter",