From 4beb1352b49e24b23fc31ebead12763377291473 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Wed, 26 Aug 2026 17:07:07 +0530 Subject: [PATCH] feat(createos): add sandbox provider --- README.md | 20 +- apps/cli/package.json | 1 + apps/cli/scripts/stage-runtime.mjs | 26 ++ apps/cli/src/commands/claude.ts | 3 +- apps/cli/src/commands/codex.ts | 3 +- apps/cli/src/commands/create.ts | 6 +- apps/cli/src/commands/inbound.ts | 30 +- apps/cli/src/commands/opencode.ts | 3 +- apps/cli/src/commands/prepare.ts | 4 +- apps/cli/src/commands/prune.ts | 5 +- apps/cli/src/index.ts | 2 + apps/cli/src/lib/cloud-sizing.ts | 7 +- apps/cli/src/provider/loaders.ts | 1 + apps/cli/test/argv-prefix.test.ts | 3 +- apps/cli/test/cloud-sizing.test.ts | 15 +- apps/cli/test/commands.test.ts | 9 + apps/web/content/docs/cli.mdx | 11 +- apps/web/content/docs/configuration.mdx | 17 +- apps/web/content/docs/createos.mdx | 80 ++++ apps/web/content/docs/meta.json | 1 + .../config/schema/user-config.schema.json | 6 +- packages/config/src/image.ts | 3 +- packages/config/src/providers.ts | 12 + packages/config/src/size.ts | 1 + packages/config/src/types.ts | 18 + packages/config/test/providers.test.ts | 2 +- packages/core/test/topology.test.ts | 4 +- packages/relay/src/host-actions.ts | 4 + packages/sandbox-createos/package.json | 44 +++ .../scripts/custom-system-CLAUDE.md | 30 ++ .../sandbox-createos/scripts/install-box.sh | 344 ++++++++++++++++++ packages/sandbox-createos/src/backend.ts | 342 +++++++++++++++++ packages/sandbox-createos/src/build-attach.ts | 85 +++++ packages/sandbox-createos/src/cli.ts | 56 +++ packages/sandbox-createos/src/client.ts | 224 ++++++++++++ packages/sandbox-createos/src/createos-cli.ts | 29 ++ packages/sandbox-createos/src/credentials.ts | 113 ++++++ packages/sandbox-createos/src/env-loader.ts | 41 +++ packages/sandbox-createos/src/index.ts | 89 +++++ packages/sandbox-createos/src/prepare.ts | 49 +++ .../sandbox-createos/src/provider-module.ts | 43 +++ packages/sandbox-createos/src/retry.ts | 118 ++++++ .../sandbox-createos/src/runtime-assets.ts | 119 ++++++ .../test/build-attach.test.ts | 45 +++ packages/sandbox-createos/test/size.test.ts | 30 ++ packages/sandbox-createos/tsconfig.json | 7 + packages/sandbox-createos/tsup.config.ts | 11 + pnpm-lock.yaml | 34 ++ 48 files changed, 2104 insertions(+), 46 deletions(-) create mode 100644 apps/web/content/docs/createos.mdx create mode 100644 packages/sandbox-createos/package.json create mode 100644 packages/sandbox-createos/scripts/custom-system-CLAUDE.md create mode 100644 packages/sandbox-createos/scripts/install-box.sh create mode 100644 packages/sandbox-createos/src/backend.ts create mode 100644 packages/sandbox-createos/src/build-attach.ts create mode 100644 packages/sandbox-createos/src/cli.ts create mode 100644 packages/sandbox-createos/src/client.ts create mode 100644 packages/sandbox-createos/src/createos-cli.ts create mode 100644 packages/sandbox-createos/src/credentials.ts create mode 100644 packages/sandbox-createos/src/env-loader.ts create mode 100644 packages/sandbox-createos/src/index.ts create mode 100644 packages/sandbox-createos/src/prepare.ts create mode 100644 packages/sandbox-createos/src/provider-module.ts create mode 100644 packages/sandbox-createos/src/retry.ts create mode 100644 packages/sandbox-createos/src/runtime-assets.ts create mode 100644 packages/sandbox-createos/test/build-attach.test.ts create mode 100644 packages/sandbox-createos/test/size.test.ts create mode 100644 packages/sandbox-createos/tsconfig.json create mode 100644 packages/sandbox-createos/tsup.config.ts diff --git a/README.md b/README.md index c587f8ee..92bc8917 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ agentbox claude > Run setup wizard? -> Yes # Also use a cloud: -agentbox hetzner claude # or vercel, daytona +agentbox hetzner claude # or vercel, daytona, e2b, createos # Ctrl+a d to detach, claude keep going, to reconnect later: agentbox attach 1 @@ -81,12 +81,12 @@ Uses `portless` to give box web apps the same URL from inside the box and on the ## Cloud Providers -| | local docker | remote docker | hetzner | daytona | vercel | e2b | -| ------------------- | ------------------------- | ---------------------- | ---------------------- | ------------------ | ------------------ | ------------------ | -| Support | ✅ | ✅ | ✅ | ⚠️ Partial | ✅ | ✅ | -| Base image | Dockerfile | Dockerfile (on the remote) | Setup script (Ubuntu) | Dockerfile | Setup script | Dockerfile (`Template.build`) | -| Live snapshots | ✅ | ✅ (`docker commit`) | ✅ | 🧪 Experimental | ✅ | ✅ | -| Private preview URLs| ✅ (portless or OrbStack) | ✅ (portless over SSH) | ✅ (portless) | ✅ (native) | ✅ (native) | ✅ (native) | +| | local docker | remote docker | hetzner | daytona | vercel | e2b | createos | +| ------------------- | ------------------------- | ---------------------- | ---------------------- | ------------------ | ------------------ | ------------------ | ----------------- | +| Support | ✅ | ✅ | ✅ | ⚠️ Partial | ✅ | ✅ | ⚠️ Partial | +| Base image | Dockerfile | Dockerfile (on the remote) | Setup script (Ubuntu) | Dockerfile | Setup script | Dockerfile (`Template.build`) | Rootfs + provision install | +| Live snapshots | ✅ | ✅ (`docker commit`) | ✅ | 🧪 Experimental | ✅ | ✅ | Pause/resume | +| Private preview URLs| ✅ (portless or OrbStack) | ✅ (portless over SSH) | ✅ (portless) | ✅ (native) | ✅ (native) | ✅ (native) | ✅ (native) | **Cloud setup** (optional — skip for local Docker) @@ -95,9 +95,10 @@ Uses `portless` to give box web apps the same URL from inside the box and on the - `agentbox hetzner login` — interactive Hetzner Cloud token setup, saved to `~/.agentbox/secrets.env` - `agentbox daytona login` — interactive Daytona API key setup, saved to `~/.agentbox/secrets.env` - `agentbox e2b login` — interactive E2B API key setup, saved to `~/.agentbox/secrets.env` +- `agentbox createos login` — interactive CreateOS API key setup, saved to `~/.agentbox/secrets.env` - `agentbox digitalocean login` — interactive DigitalOcean Personal Access Token setup, saved to `~/.agentbox/secrets.env` - `agentbox remote-docker doctor ` — run boxes on a machine you already own, over SSH. No login and no token: it connects as you, using your own `~/.ssh/config`. Then `agentbox docker: claude`. -- `agentbox prepare [--provider daytona|hetzner|vercel|e2b|digitalocean|docker:]` — build the image and initial snapshot (e2b builds from a Dockerfile via `Template.build()`) +- `agentbox prepare [--provider daytona|hetzner|vercel|e2b|createos|digitalocean|docker:]` — build the image and initial snapshot where supported; createos validates credentials/runtime assets and installs during provision - `agentbox hetzner claude`, `agentbox hetzner codex`, `agentbox hetzner create`, etc. ## How to use @@ -156,7 +157,7 @@ Full documentation lives at **[agent-box.sh/docs](https://agent-box.sh/docs)**: - [Quickstart](https://agent-box.sh/docs) and [Core concepts](https://agent-box.sh/docs/core-concepts) - [Teleport a project](https://agent-box.sh/docs/teleport-a-project), [Run an agent](https://agent-box.sh/docs/run-an-agent), [Access your box](https://agent-box.sh/docs/access-your-box) - [Configuration](https://agent-box.sh/docs/configuration), [Services & tasks](https://agent-box.sh/docs/services-and-tasks), [Sync & git](https://agent-box.sh/docs/sync-and-git) -- Cloud providers: [Hetzner](https://agent-box.sh/docs/hetzner), [Daytona](https://agent-box.sh/docs/daytona), [Vercel](https://agent-box.sh/docs/vercel), [E2B](https://agent-box.sh/docs/e2b), [DigitalOcean](https://agent-box.sh/docs/digitalocean) +- Cloud providers: [Hetzner](https://agent-box.sh/docs/hetzner), [Daytona](https://agent-box.sh/docs/daytona), [Vercel](https://agent-box.sh/docs/vercel), [E2B](https://agent-box.sh/docs/e2b), [CreateOS](https://agent-box.sh/docs/createos), [DigitalOcean](https://agent-box.sh/docs/digitalocean) - Full [CLI reference](https://agent-box.sh/docs/cli) ## Development @@ -211,4 +212,3 @@ Bug reports, docs fixes, and provider work are welcome — see [CONTRIBUTING.md] # License MIT. See [LICENSE](./LICENSE). - diff --git a/apps/cli/package.json b/apps/cli/package.json index 056f7dad..72571cd3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -83,6 +83,7 @@ "@agentbox/relay": "workspace:*", "@agentbox/sandbox-cloud": "workspace:*", "@agentbox/sandbox-core": "workspace:*", + "@agentbox/sandbox-createos": "workspace:*", "@agentbox/sandbox-daytona": "workspace:*", "@agentbox/sandbox-digitalocean": "workspace:*", "@agentbox/sandbox-remote-docker": "workspace:*", diff --git a/apps/cli/scripts/stage-runtime.mjs b/apps/cli/scripts/stage-runtime.mjs index 127187f8..8b92a0ea 100644 --- a/apps/cli/scripts/stage-runtime.mjs +++ b/apps/cli/scripts/stage-runtime.mjs @@ -25,6 +25,7 @@ const runtime = join(cliRoot, 'runtime'); const dockerCtx = join(runtime, 'docker'); const hetznerCtx = join(runtime, 'hetzner'); const digitaloceanCtx = join(runtime, 'digitalocean'); +const createosCtx = join(runtime, 'createos'); const daytonaCtx = join(runtime, 'daytona'); const vercelCtx = join(runtime, 'vercel'); const e2bCtx = join(runtime, 'e2b'); @@ -185,6 +186,31 @@ for (const [srcRel, destRel, exec] of digitaloceanFiles) { copy(srcRel, join(digitaloceanCtx, destRel), exec); } +// CreateOS provider — runtime assets uploaded into the fresh microVM during +// provision. CreateOS currently has no file-context template bake, so this +// mirrors the flat VPS install layout and runs per sandbox. +const createosFiles = [ + ['packages/sandbox-createos/scripts/install-box.sh', 'scripts/install-box.sh', true], + ['packages/ctl/dist/bin.cjs', 'ctl.cjs', true], + ['packages/sandbox-docker/scripts/agentbox-vnc-start', 'agentbox-vnc-start', true], + ['packages/sandbox-docker/scripts/agentbox-dockerd-start', 'agentbox-dockerd-start', true], + ['packages/sandbox-docker/scripts/agentbox-portless-trust', 'agentbox-portless-trust', true], + ['packages/sandbox-docker/scripts/agentbox-checkpoint-cleanup', 'agentbox-checkpoint-cleanup', true], + ['packages/sandbox-docker/scripts/agentbox-open', 'agentbox-open', true], + ['packages/sandbox-docker/scripts/gh-shim', 'gh-shim', true], + ['packages/sandbox-docker/scripts/git-shim', 'git-shim', true], + ['packages/sandbox-docker/scripts/ntn-shim', 'ntn-shim', true], + ['packages/sandbox-docker/scripts/linear-shim', 'linear-shim', true], + ['packages/sandbox-createos/scripts/custom-system-CLAUDE.md', 'custom-system-CLAUDE.md', false], + ['packages/sandbox-docker/scripts/claude-managed-settings.json', 'claude-managed-settings.json', false], + ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json', 'agentbox-codex-hooks.json', false], + ['packages/sandbox-docker/scripts/opencode-agentbox-plugin.js', 'opencode-agentbox-plugin.js', false], + ['apps/cli/share/agentbox-setup/SKILL.md', 'agentbox-setup-skill.md', false], +]; +for (const [srcRel, destRel, exec] of createosFiles) { + copy(srcRel, join(createosCtx, destRel), exec); +} + // Daytona provider — overlay files the daytona prepare step adds on top of // Dockerfile.box via Image.addLocalFile(). Resolver lives at // `packages/sandbox-daytona/src/dockerfile-context.ts` and looks for these diff --git a/apps/cli/src/commands/claude.ts b/apps/cli/src/commands/claude.ts index 9e3bca40..47843a6b 100644 --- a/apps/cli/src/commands/claude.ts +++ b/apps/cli/src/commands/claude.ts @@ -2,6 +2,7 @@ import { confirm, intro, log, outro, spinner } from '../lib/prompt.js'; import { findProjectRoot, loadEffectiveConfig, + PROVIDER_NAMES, resolveDefaultCheckpoint, type AttachOpenIn, type UserConfig, @@ -502,7 +503,7 @@ export const claudeCommand = new Command('claude') .option('--cpus ', 'CPU count cap (fractional ok, e.g. 1.5); unset = unlimited') .option('--pids-limit ', 'max process count (PIDs cgroup); unset = unlimited') .option('--disk ', 'best-effort writable-layer size (e.g. 10g); no-op on overlay2/macOS') - .option('--provider ', "sandbox backend: 'docker' (default) or 'daytona' for a cloud box") + .option('--provider ', `sandbox backend: ${PROVIDER_NAMES.join(', ')}. Default: docker.`) .option( '--from-branch ', "base the box's per-box branch on this ref (branch / tag / SHA) instead of HEAD. Branch/tag names are fetched from origin first.", diff --git a/apps/cli/src/commands/codex.ts b/apps/cli/src/commands/codex.ts index 04fe207b..2ba6dac9 100644 --- a/apps/cli/src/commands/codex.ts +++ b/apps/cli/src/commands/codex.ts @@ -5,6 +5,7 @@ import { confirm, intro, log, outro, spinner } from '../lib/prompt.js'; import { findProjectRoot, loadEffectiveConfig, + PROVIDER_NAMES, resolveDefaultCheckpoint, type AttachOpenIn, type UserConfig, @@ -435,7 +436,7 @@ export const codexCommand = new Command('codex') .option('--cpus ', 'CPU count cap (fractional ok, e.g. 1.5); unset = unlimited') .option('--pids-limit ', 'max process count (PIDs cgroup); unset = unlimited') .option('--disk ', 'best-effort writable-layer size (e.g. 10g); no-op on overlay2/macOS') - .option('--provider ', "sandbox backend: 'docker' (default) or 'daytona' for a cloud box") + .option('--provider ', `sandbox backend: ${PROVIDER_NAMES.join(', ')}. Default: docker.`) .option( '--from-branch ', "base the box's per-box branch on this ref (branch / tag / SHA) instead of HEAD. Branch/tag names are fetched from origin first.", diff --git a/apps/cli/src/commands/create.ts b/apps/cli/src/commands/create.ts index c4109975..73f4f865 100644 --- a/apps/cli/src/commands/create.ts +++ b/apps/cli/src/commands/create.ts @@ -3,6 +3,7 @@ import { bumpProjectGcCounter, findProjectRoot, loadEffectiveConfig, + PROVIDER_NAMES, pruneOrphanProjectConfigs, registerProject, resolveBoxImage, @@ -155,6 +156,9 @@ async function attachShell(record: BoxRecord): Promise { process.exit(code); } +const providerHelp = `sandbox backend: ${PROVIDER_NAMES.join(', ')}. Default: docker. ` + + "`docker:` runs the box on that machine's docker engine over SSH."; + export const createCommand = new Command('create') .description( 'Create and start a new agent box (Docker container with /workspace seeded via in-container git worktree)', @@ -163,7 +167,7 @@ export const createCommand = new Command('create') .option('-n, --name ', 'friendly box name (default: -)') .option( '--provider ', - "sandbox backend: docker (default), daytona, hetzner, digitalocean, vercel, e2b, remote-docker. `docker:` runs the box on that machine's docker engine over SSH.", + providerHelp, ) .option( '--remote-host ', diff --git a/apps/cli/src/commands/inbound.ts b/apps/cli/src/commands/inbound.ts index dd645fd6..83ae8a60 100644 --- a/apps/cli/src/commands/inbound.ts +++ b/apps/cli/src/commands/inbound.ts @@ -10,6 +10,8 @@ interface InboundOptions { yes?: boolean; } +const INBOUND_PROVIDERS = new Set(['hetzner', 'digitalocean']); + export const inboundCommand = new Command('inbound') .description( "Set a VPS box's inbound-access policy (hetzner / digitalocean per-box firewall). " + @@ -30,6 +32,26 @@ export const inboundCommand = new Command('inbound') try { const box = await resolveBoxOrExit(idOrName); const provider = await providerForBox(box); + const providerName = box.provider ?? 'docker'; + + if (!INBOUND_PROVIDERS.has(providerName)) { + if (opts.show || spec.length === 0) { + process.stdout.write( + `inbound: unsupported for provider '${providerName}' - only hetzner / digitalocean boxes have a per-box firewall.\n`, + ); + return; + } + log.error( + `inbound access control isn't supported for provider '${providerName}' - ` + + 'only hetzner / digitalocean boxes have a per-box firewall.', + ); + process.exit(2); + } + + if (!provider.setInbound) { + log.error(`inbound access control isn't available for provider '${providerName}'.`); + process.exit(2); + } if (opts.show || spec.length === 0) { const policy = box.cloud?.inbound ?? { mode: 'locked' as const, sources: [] }; @@ -43,14 +65,6 @@ export const inboundCommand = new Command('inbound') return; } - if (!provider.setInbound) { - log.error( - `inbound access control isn't supported for provider '${box.provider ?? 'docker'}' — ` + - 'only hetzner / digitalocean boxes have a per-box firewall.', - ); - process.exit(2); - } - const raw = spec.join(' '); const policy = parseInboundSpec(raw); // validates early (throws on a bad spec) diff --git a/apps/cli/src/commands/opencode.ts b/apps/cli/src/commands/opencode.ts index 3ca82665..d6d8fbb4 100644 --- a/apps/cli/src/commands/opencode.ts +++ b/apps/cli/src/commands/opencode.ts @@ -5,6 +5,7 @@ import { confirm, intro, log, outro, spinner, text } from '../lib/prompt.js'; import { findProjectRoot, loadEffectiveConfig, + PROVIDER_NAMES, resolveDefaultCheckpoint, type AttachOpenIn, type UserConfig, @@ -437,7 +438,7 @@ export const opencodeCommand = new Command('opencode') .option('--disk ', 'best-effort writable-layer size (e.g. 10g); no-op on overlay2/macOS') .option( '--provider ', - "sandbox backend: 'docker' (default) or 'daytona' for a cloud box", + `sandbox backend: ${PROVIDER_NAMES.join(', ')}. Default: docker.`, ) .option( '--from-branch ', diff --git a/apps/cli/src/commands/prepare.ts b/apps/cli/src/commands/prepare.ts index de2c3166..520aec7f 100644 --- a/apps/cli/src/commands/prepare.ts +++ b/apps/cli/src/commands/prepare.ts @@ -506,7 +506,7 @@ export const prepareCommand = new Command('prepare') ) .option( '-p, --provider ', - 'provider to prepare (docker | daytona | hetzner | vercel | e2b | digitalocean). Omit for status-only.', + 'provider to prepare (docker | daytona | hetzner | vercel | e2b | digitalocean | createos). Omit for status-only.', ) .option('-n, --name ', 'snapshot name (Daytona only; default: agentbox-base-)') .option('-f, --force', 'rebuild even if the image / snapshot already exists') @@ -526,7 +526,7 @@ export const prepareCommand = new Command('prepare') ) .option( '--size ', - 'bake-time VM size. daytona: cpu-memory-disk GB (e.g. 4-8-20). e2b: cpu-memory GB (e.g. 4-8). Overrides box.size / box.size. Ignored by docker/hetzner/vercel.', + 'bake-time VM size. daytona: cpu-memory-disk GB (e.g. 4-8-20). e2b: cpu-memory GB (e.g. 4-8). CreateOS accepts shape slugs on create. Overrides box.size / box.size.', ) .action(async (opts: PrepareOptions) => { // Status-only path: no provider, or explicit --status. diff --git a/apps/cli/src/commands/prune.ts b/apps/cli/src/commands/prune.ts index 166eb787..e4413d06 100644 --- a/apps/cli/src/commands/prune.ts +++ b/apps/cli/src/commands/prune.ts @@ -80,7 +80,7 @@ export const pruneCommand = new Command('prune') .option('-y, --yes', 'skip the confirmation prompt') .option( '--provider ', - 'restrict prune to a specific provider (docker | daytona | hetzner | vercel | e2b | digitalocean). For cloud providers, lists sandboxes that are not in this CLI\'s state.json and offers to delete them.', + 'restrict prune to a specific provider (docker | daytona | hetzner | vercel | e2b | digitalocean | createos). For cloud providers, lists sandboxes that are not in this CLI\'s state.json and offers to delete them.', ) .action(async (opts: PruneOptions) => { try { @@ -89,7 +89,7 @@ export const pruneCommand = new Command('prune') return; } if (opts.provider !== undefined && opts.provider !== 'docker') { - log.error(`unknown provider '${opts.provider}'; expected docker, daytona, hetzner, vercel, e2b, or digitalocean`); + log.error(`unknown provider '${opts.provider}'; expected docker, daytona, hetzner, vercel, e2b, digitalocean, or createos`); process.exit(2); } const dryRun = opts.dryRun ?? false; @@ -136,6 +136,7 @@ const CLOUD_PRUNE_PROVIDERS = [ 'vercel', 'e2b', 'digitalocean', + 'createos', 'remote-docker', ] as const; type CloudPruneProvider = (typeof CLOUD_PRUNE_PROVIDERS)[number]; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 818d6aba..8890a4d7 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -55,6 +55,7 @@ import { hetznerCommand } from '@agentbox/sandbox-hetzner/cli'; import { vercelCommand } from '@agentbox/sandbox-vercel/cli'; import { e2bCommand } from '@agentbox/sandbox-e2b/cli'; import { digitaloceanCommand } from '@agentbox/sandbox-digitalocean/cli'; +import { createosCommand } from '@agentbox/sandbox-createos/cli'; import { remoteDockerCommand } from '@agentbox/sandbox-remote-docker/cli'; import { destroyCommand } from './commands/destroy.js'; import { downloadCommand } from './commands/download.js'; @@ -186,6 +187,7 @@ program.addCommand(hetznerCommand); program.addCommand(vercelCommand); program.addCommand(e2bCommand); program.addCommand(digitaloceanCommand); +program.addCommand(createosCommand); program.addCommand(remoteDockerCommand); program.addCommand(dockerCommand); program.addCommand(updateCommand); diff --git a/apps/cli/src/lib/cloud-sizing.ts b/apps/cli/src/lib/cloud-sizing.ts index 5e60d564..b304fecd 100644 --- a/apps/cli/src/lib/cloud-sizing.ts +++ b/apps/cli/src/lib/cloud-sizing.ts @@ -28,7 +28,8 @@ export interface CloudSizingFlags { * - **all providers**: `size` — the generic VM-size string, `--size` flag first, * else `box.size` / `box.size`. Each backend interprets it natively * (hetzner: server type; daytona: `cpu-mem-disk` GB; vercel: vCPU count; - * e2b: baked at prepare time; docker: ignored). + * e2b: baked at prepare time; createos: shape slug or `cpu-memory[-disk]`; + * docker: ignored). * - **hetzner**: `location` — datacenter, `--location` flag first, else * `box.hetznerLocation`. * - **digitalocean**: `location` — region, `--location` flag first, else @@ -42,6 +43,7 @@ export interface CloudSizingFlags { * - **e2b**: `timeoutMs` — the session timeout the box is created with (and * records as `cloud.sessionTimeoutMs`, which seeds the host keepalive loop so * it can push the deadline forward while the agent is working). + * - **createos**: `timeoutMs` — mapped to CreateOS' sandbox auto-pause window. * - **daytona**: `timeoutMs` (auto-stop inactivity window, same keepalive rail * as e2b), `sandboxClass` (`linux-vm` | `container`) and `location` (region). * `location` carries only an EXPLICIT `box.daytonaRegion` — the class-derived @@ -91,6 +93,9 @@ export function cloudSizingProviderOptions( if (providerName === 'e2b') { out.timeoutMs = cfg.box.e2bTimeoutMs; } + if (providerName === 'createos') { + out.timeoutMs = cfg.box.createosTimeoutMs; + } if (providerName === 'remote-docker') { // Which machine runs the container. Unlike every other provider's options // this one is mandatory — there is no sensible default engine — so resolve diff --git a/apps/cli/src/provider/loaders.ts b/apps/cli/src/provider/loaders.ts index bcfee6f6..89273785 100644 --- a/apps/cli/src/provider/loaders.ts +++ b/apps/cli/src/provider/loaders.ts @@ -32,6 +32,7 @@ const IMPORTERS: Record Promise<{ providerModule: ProviderMo vercel: () => import('@agentbox/sandbox-vercel'), e2b: () => import('@agentbox/sandbox-e2b'), digitalocean: () => import('@agentbox/sandbox-digitalocean'), + createos: () => import('@agentbox/sandbox-createos'), 'remote-docker': () => import('@agentbox/sandbox-remote-docker'), }; diff --git a/apps/cli/test/argv-prefix.test.ts b/apps/cli/test/argv-prefix.test.ts index 2ed4e0ce..b20e8840 100644 --- a/apps/cli/test/argv-prefix.test.ts +++ b/apps/cli/test/argv-prefix.test.ts @@ -3,6 +3,7 @@ import { SUGARED_COMMANDS, rewriteProviderPrefix, } from '../src/provider/argv-prefix.js'; +import { PROVIDER_NAMES } from '@agentbox/config'; const NODE = '/usr/local/bin/node'; const BIN = '/path/to/agentbox'; @@ -19,7 +20,7 @@ describe('rewriteProviderPrefix', () => { }); it('rewrites every (provider × sugared-command) pair', () => { - for (const provider of ['docker', 'daytona', 'hetzner'] as const) { + for (const provider of PROVIDER_NAMES) { for (const sub of SUGARED_COMMANDS) { expect(rewriteProviderPrefix(argv(provider, sub))).toEqual( argv(sub, '--provider', provider), diff --git a/apps/cli/test/cloud-sizing.test.ts b/apps/cli/test/cloud-sizing.test.ts index e27d241c..3dea20b3 100644 --- a/apps/cli/test/cloud-sizing.test.ts +++ b/apps/cli/test/cloud-sizing.test.ts @@ -12,10 +12,12 @@ function makeCfg(box: Record = {}): EffectiveConfig { sizeHetzner: '', sizeVercel: '', sizeE2b: '', + sizeCreateos: '', hetznerLocation: 'nbg1', vercelTimeoutMs: 2_700_000, vercelNetworkPolicy: 'strict', e2bTimeoutMs: 120_000, + createosTimeoutMs: 180_000, daytonaClass: 'linux-vm', daytonaRegion: '', daytonaTimeoutMs: 1_500_000, @@ -44,6 +46,10 @@ describe('cloudSizingProviderOptions', () => { expect(cloudSizingProviderOptions('e2b', makeCfg())).toEqual({ timeoutMs: 120_000 }); }); + it('threads the createos auto-pause timeout for createos boxes', () => { + expect(cloudSizingProviderOptions('createos', makeCfg())).toEqual({ timeoutMs: 180_000 }); + }); + it('threads timeout / network policy for vercel boxes (no vcpus key)', () => { expect(cloudSizingProviderOptions('vercel', makeCfg())).toEqual({ timeoutMs: 2_700_000, @@ -97,11 +103,18 @@ describe('cloudSizingProviderOptions', () => { size: '4-8-20', location: 'nbg1', }); + expect(cloudSizingProviderOptions('createos', cfg)).toEqual({ + size: '4-8-20', + timeoutMs: 180_000, + }); }); it('prefers the per-provider size key over the generic one', () => { const cfg = makeCfg({ size: '4-8-20', sizeHetzner: 'cx33' }); expect(cloudSizingProviderOptions('hetzner', cfg)).toMatchObject({ size: 'cx33' }); + expect( + cloudSizingProviderOptions('createos', makeCfg({ size: '4-8-20', sizeCreateos: 's-2vcpu-2gb' })), + ).toMatchObject({ size: 's-2vcpu-2gb' }); }); it('lets the --size flag win over config, trimming whitespace', () => { @@ -189,7 +202,7 @@ describe('digitalocean project', () => { it('is ignored for every other provider', () => { const cfg = makeCfg({ digitaloceanProject: 'client-x' }); - for (const p of ['hetzner', 'daytona', 'vercel', 'e2b', 'docker']) { + for (const p of ['hetzner', 'daytona', 'vercel', 'e2b', 'createos', 'docker']) { expect(cloudSizingProviderOptions(p, cfg).project).toBeUndefined(); } }); diff --git a/apps/cli/test/commands.test.ts b/apps/cli/test/commands.test.ts index 7e2c9201..75698533 100644 --- a/apps/cli/test/commands.test.ts +++ b/apps/cli/test/commands.test.ts @@ -6,6 +6,7 @@ import { createCommand } from '../src/commands/create.js'; import { forkCommand } from '../src/commands/fork.js'; import { installCommand } from '../src/commands/install.js'; import { daytonaCommand } from '@agentbox/sandbox-daytona/cli'; +import { createosCommand } from '@agentbox/sandbox-createos/cli'; import { dockerCommand } from '../src/commands/docker.js'; import { destroyCommand } from '../src/commands/destroy.js'; import { statusCommand } from '../src/commands/status.js'; @@ -127,6 +128,14 @@ describe('lifecycle CLI surface', () => { expect(login.options.map((o) => o.long)).toContain('--status'); }); + it('createos has a login (default) subcommand with --status', () => { + expect(createosCommand.name()).toBe('createos'); + const subs = createosCommand.commands.map((c) => c.name()); + expect(subs).toContain('login'); + const login = createosCommand.commands.find((c) => c.name() === 'login')!; + expect(login.options.map((o) => o.long)).toContain('--status'); + }); + it('docker is registered as a provider-prefix group (no real subcommands)', () => { expect(dockerCommand.name()).toBe('docker'); expect(dockerCommand.commands).toHaveLength(0); diff --git a/apps/web/content/docs/cli.mdx b/apps/web/content/docs/cli.mdx index bcf1ab19..19f5555e 100644 --- a/apps/web/content/docs/cli.mdx +++ b/apps/web/content/docs/cli.mdx @@ -11,7 +11,7 @@ This is the command catalog. Conceptual depth lives in the linked task pages — **Box references.** Almost every command takes an optional `[box]` argument: a project index (`1`, `2`, …), the box name, an id (or id prefix), or the container name. Omit it and AgentBox uses the only box in the current project. Paused or stopped boxes are auto-resumed when you act on them. -**Provider sugar.** `agentbox ` is rewritten to `--provider ` before parsing. So `agentbox hetzner claude`, `agentbox digitalocean codex`, `agentbox vercel codex`, `agentbox e2b create`, and `agentbox docker create` all work — they're identical to passing `--provider`. +**Provider sugar.** `agentbox ` is rewritten to `--provider ` before parsing. So `agentbox hetzner claude`, `agentbox digitalocean codex`, `agentbox vercel codex`, `agentbox e2b create`, `agentbox createos claude`, and `agentbox docker create` all work — they're identical to passing `--provider`. A provider can be **host-qualified**: `agentbox docker: claude` runs the box on that machine's Docker engine over SSH (the [remote-docker](/docs/remote-docker) provider). `` is any SSH destination — an `~/.ssh/config` alias or `[user@]host[:port]` — so `agentbox docker:buildbox claude` and `agentbox create --provider docker:dev@10.0.0.9:2222` both work, as does `agentbox prepare --provider docker:buildbox`. @@ -55,7 +55,7 @@ agentbox fork --agent codex --attach-in background agentbox claude -- --model sonnet ``` -Shared create/agent flags include `-n, --name`, `-w, --workspace `, `--provider `, `--snapshot ` (start from a checkpoint), `--image `, `--build` (build the base image locally instead of pulling), `-y, --yes`, `--with-env`, `--with-playwright`, `--no-vnc`, `--portless` / `--no-portless`, `--from-branch `, `-b, --use-branch `, `--memory`, `--cpus`, `--disk`, `--size ` (cloud VM size — hetzner server type `cx33`, digitalocean Droplet slug `s-4vcpu-8gb`, daytona `4-8-20`, vercel vCPUs `4`), `--location ` (hetzner datacenter e.g. `fsn1`, or digitalocean region e.g. `fra1`), `--remote-host ` (remote-docker: the SSH destination whose Docker engine runs the box — same as `--provider docker:`), `--inbound ` (VPS firewall access: `locked` / `open` / a CIDR list — reach a box from a phone with the laptop off; see [remote access](/docs/access-your-box#use-a-box-with-your-laptop-offline)), `--carry-yes` / `--carry `, and `--dangerously-with-credentials` (copy a git credential into a cloud box so it pushes/pulls with your PC off — an interactive prompt asks token vs SSH; token pushes over HTTPS/unsigned, ssh copies your SSH key and signs. Interactive-only, no CI path; see [Independent boxes](/docs/sync-and-git#independent-boxes--dangerously-with-credentials)). +Shared create/agent flags include `-n, --name`, `-w, --workspace `, `--provider `, `--snapshot ` (start from a checkpoint), `--image `, `--build` (build the base image locally instead of pulling), `-y, --yes`, `--with-env`, `--with-playwright`, `--no-vnc`, `--portless` / `--no-portless`, `--from-branch `, `-b, --use-branch `, `--memory`, `--cpus`, `--disk`, `--size ` (cloud VM size — hetzner server type `cx33`, digitalocean Droplet slug `s-4vcpu-8gb`, daytona `4-8-20`, vercel vCPUs `4`, e2b `4-8`, createos shape slug or `2-2-20`), `--location ` (hetzner datacenter e.g. `fsn1`, or digitalocean region e.g. `fra1`), `--remote-host ` (remote-docker: the SSH destination whose Docker engine runs the box — same as `--provider docker:`), `--inbound ` (VPS firewall access: `locked` / `open` / a CIDR list — reach a box from a phone with the laptop off; see [remote access](/docs/access-your-box#use-a-box-with-your-laptop-offline)), `--carry-yes` / `--carry `, and `--dangerously-with-credentials` (copy a git credential into a cloud box so it pushes/pulls with your PC off — an interactive prompt asks token vs SSH; token pushes over HTTPS/unsigned, ssh copies your SSH key and signs. Interactive-only, no CI path; see [Independent boxes](/docs/sync-and-git#independent-boxes--dangerously-with-credentials)). Agent-specific: `--attach-in `, `-d, --no-attach`, `-i, --initial-prompt ` (background queue), and for `claude` only `-c, --continue`, `--resume `, `--plan `, and `--dangerously-skip-permissions` (on by default in boxes). `fork` adds `--agent`, `--session `, `--attach-in` (default `tab`), and a positional `[provider]` (`agentbox fork hetzner` is shorthand for `--provider hetzner`). When `--agent`/`--session` are omitted, `fork` autodetects the launching agent and current session from its env vars (Claude via `CLAUDECODE` / `CLAUDE_CODE_SESSION_ID`, Codex via `CODEX_THREAD_ID`), so a bare `agentbox fork` forks whichever agent you ran it from. @@ -342,6 +342,7 @@ agentbox prepare -p docker --build agentbox prepare -p hetzner --claude-install npm # install Claude via npm (native-CDN 403 fallback) agentbox prepare -p daytona --size 4-8-20 # bake daytona resources (cpu-mem-disk GB) agentbox prepare -p e2b --size 4-8 # bake e2b resources (cpu-mem GB) +agentbox prepare -p createos # validate credentials + runtime assets agentbox prepare -p hetzner --location fsn1 # datacenter for the bake VPS ``` @@ -362,12 +363,14 @@ agentbox docker:buildbox claude # a box on buildbox's docker engine, over SSH agentbox hetzner claude agentbox vercel codex agentbox e2b claude +agentbox createos claude # Log in / rotate credentials (saved to ~/.agentbox/secrets.env) agentbox hetzner login agentbox vercel login agentbox daytona login agentbox e2b login +agentbox createos login agentbox hetzner login --status # show what's configured (masked) # Provider tools @@ -381,9 +384,9 @@ agentbox remote-docker doctor [alias] # can this host host boxes? (ssh + d agentbox remote-docker remove # forget an alias (+ default + bake record; alias: rm) ``` -`docker` is the default and is pure sugar (no login). Each cloud provider has a `login` subcommand (run by default); hetzner adds `firewall`, daytona adds `resync`. `remote-docker` has **no login at all** — it connects as you, over your own `~/.ssh/config` — and offers `check` / `use` / `hosts` instead. E2B is the only cloud whose `prepare` builds the base image **directly from a Dockerfile** via the SDK's `Template.build()` — the others bake a one-time snapshot. +`docker` is the default and is pure sugar (no login). Each cloud provider has a `login` subcommand (run by default); hetzner adds `firewall`, daytona adds `resync`. `remote-docker` has **no login at all** — it connects as you, over your own `~/.ssh/config` — and offers `check` / `use` / `hosts` instead. E2B is the only cloud whose `prepare` builds the base image **directly from a Dockerfile** via the SDK's `Template.build()`; CreateOS prepare validates credentials/assets and installs the AgentBox runtime during each provision. -See [Local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [Hetzner](/docs/hetzner), [DigitalOcean](/docs/digitalocean), [Daytona](/docs/daytona), [Vercel](/docs/vercel), and [E2B](/docs/e2b). +See [Local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [Hetzner](/docs/hetzner), [DigitalOcean](/docs/digitalocean), [Daytona](/docs/daytona), [Vercel](/docs/vercel), [E2B](/docs/e2b), and [CreateOS](/docs/createos). ## Maintenance diff --git a/apps/web/content/docs/configuration.mdx b/apps/web/content/docs/configuration.mdx index 016d8a60..c34194de 100644 --- a/apps/web/content/docs/configuration.mdx +++ b/apps/web/content/docs/configuration.mdx @@ -98,7 +98,7 @@ The largest group. A pattern runs through it: a generic key plus per-provider ov | Key | Type | Default | Meaning | | --- | --- | --- | --- | -| `box.provider` | enum `docker`/`daytona`/`hetzner`/`vercel`/`e2b`/`digitalocean`/`remote-docker` | `docker` | backend new boxes are created on | +| `box.provider` | enum `docker`/`daytona`/`hetzner`/`vercel`/`e2b`/`createos`/`digitalocean`/`remote-docker` | `docker` | backend new boxes are created on | This is what plain `agentbox claude` (or `create`, `codex`, `opencode`) uses when you don't pass `--provider`. `agentbox install` offers to set it for you at the end of the wizard, so the provider you just logged in to and prepared becomes the one new boxes go to. Set it yourself at any time: @@ -109,7 +109,7 @@ agentbox config set box.provider hetzner # this project only A per-run `--provider docker` (or the `agentbox docker claude` prefix) still overrides it. -See [local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [Hetzner](/docs/hetzner), [Daytona](/docs/daytona), [Vercel](/docs/vercel), and [core concepts](/docs/core-concepts). +See [local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [Hetzner](/docs/hetzner), [Daytona](/docs/daytona), [Vercel](/docs/vercel), [E2B](/docs/e2b), [CreateOS](/docs/createos), and [core concepts](/docs/core-concepts). ### Resource limits (Docker) @@ -124,8 +124,8 @@ See [local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [H | Key | Type | Default | Meaning | | --- | --- | --- | --- | -| `box.size` | string | empty | generic cloud size, provider-interpreted: hetzner = server type (`cx33`), digitalocean = Droplet size slug (`s-4vcpu-8gb`), daytona = `cpu-memory-disk` GB (`4-8-20`), vercel = vCPU count (`1`/`2`/`4`/`8`), e2b = `cpu-memory` GB (`4-8`, baked at `prepare` time); docker ignores it | -| `box.sizeDaytona` / `box.sizeHetzner` / `box.sizeDigitalocean` / `box.sizeVercel` / `box.sizeE2b` / `box.sizeRemoteDocker` | string (advanced) | empty | per-provider override of `box.size` | +| `box.size` | string | empty | generic cloud size, provider-interpreted: hetzner = server type (`cx33`), digitalocean = Droplet size slug (`s-4vcpu-8gb`), daytona = `cpu-memory-disk` GB (`4-8-20`), vercel = vCPU count (`1`/`2`/`4`/`8`), e2b = `cpu-memory` GB (`4-8`, baked at `prepare` time), createos = shape slug (`s-2vcpu-2gb`) or `cpu-memory[-disk]` GB (`2-2-20`); docker ignores it | +| `box.sizeDaytona` / `box.sizeHetzner` / `box.sizeDigitalocean` / `box.sizeVercel` / `box.sizeE2b` / `box.sizeCreateos` / `box.sizeRemoteDocker` | string (advanced) | empty | per-provider override of `box.size` | | `box.sizeDocker` | string (advanced) | empty | reserved — docker uses `box.memory` / `box.cpus` / `box.disk` | | `box.daytonaClass` | string | `linux-vm` | Daytona [sandbox class](/docs/daytona#sandbox-class): `linux-vm` gives a true pause (CPU + memory frozen, running processes survive) and a ~1 min base bake, but runs only in `us-east-1`. `container` keeps the old behavior and any region. Changing it needs `agentbox prepare --provider daytona --force`. Daytona-only | | `box.daytonaRegion` | string | empty | Daytona region (`us`, `eu`, `us-east-1`). Empty derives it from the class — `linux-vm` ⇒ `us-east-1` (the only region with VM runners), `container` ⇒ the account default. Daytona-only | @@ -142,7 +142,7 @@ See [local Docker](/docs/local-docker), [Remote Docker](/docs/remote-docker), [H | Key | Type | Default | Meaning | | --- | --- | --- | --- | | `box.image` | string (advanced) | `agentbox/box:dev` | generic image ref; the default is a sentinel cloud backends read as "boot from the prepared base snapshot" | -| `box.imageDocker` / `box.imageDaytona` / `box.imageHetzner` / `box.imageDigitalocean` / `box.imageVercel` / `box.imageRemoteDocker` | string (advanced) | empty | per-provider override; the cloud ones are written by `agentbox prepare --provider `. Leave `box.imageRemoteDocker` empty — that provider derives a fingerprint-tagged ref and ensures it on the remote engine itself | +| `box.imageDocker` / `box.imageDaytona` / `box.imageHetzner` / `box.imageDigitalocean` / `box.imageVercel` / `box.imageE2b` / `box.imageCreateos` / `box.imageRemoteDocker` | string (advanced) | empty | per-provider override. For CreateOS this is a rootfs/template name such as `devbox:1`; most other cloud ones are written by `agentbox prepare --provider `. Leave `box.imageRemoteDocker` empty — that provider derives a fingerprint-tagged ref and ensures it on the remote engine itself | | `box.imageRegistry` | string (advanced, docker only) | `ghcr.io/madarco/agentbox/box` | registry to pull the prebuilt base from before building locally; empty = always build | @@ -154,7 +154,7 @@ Setting the generic `box.image` to a provider-native snapshot id breaks creates | Key | Type | Default | Meaning | | --- | --- | --- | --- | | `box.defaultCheckpoint` | string | empty | checkpoint new boxes start from when `--snapshot` isn't given; set via `agentbox checkpoint set-default` | -| `box.defaultCheckpointDocker` / `…Daytona` / `…Hetzner` / `…Digitalocean` / `…Vercel` / `…E2b` / `…RemoteDocker` | string (advanced) | empty | per-provider overrides; set via `checkpoint set-default --provider ` | +| `box.defaultCheckpointDocker` / `…Daytona` / `…Hetzner` / `…Digitalocean` / `…Vercel` / `…E2b` / `…Createos` / `…RemoteDocker` | string (advanced) | empty | per-provider overrides; set via `checkpoint set-default --provider ` | See [checkpoints and pausing](/docs/checkpoints-and-pausing). @@ -182,15 +182,16 @@ See [teleport a project](/docs/teleport-a-project), [environment](/docs/environm | --- | --- | --- | --- | | `box.isolateClaudeConfig` / `box.isolateCodexConfig` / `box.isolateOpencodeConfig` | bool | `false` | give the box its own agent config volume instead of the shared one | -### Vercel only +### Cloud session/network | Key | Type | Default | Meaning | | --- | --- | --- | --- | | `box.vercelTimeoutMs` | int | `2700000` (45 min) | max session length before auto-snapshot; persistent mode auto-resumes | | `box.vercelNetworkPolicy` | string | empty (`allow-all`) | egress lock: `allow-all`, `deny-all`, or a comma-separated domain allowlist (`github.com,*.npmjs.org`) | | `box.e2bTimeoutMs` | int | `2700000` (45 min) | session timeout a new `--provider e2b` box is created with before E2B auto-pauses it on inactivity; the host keepalive holds it open while the agent works (Hobby caps total session at ~1 h) | +| `box.createosTimeoutMs` | int | `2700000` (45 min) | idle timeout a new `--provider createos` box is created with; AgentBox maps it to CreateOS `auto_pause_after_seconds` | -See [Vercel](/docs/vercel). +See [Vercel](/docs/vercel), [E2B](/docs/e2b), and [CreateOS](/docs/createos). ## checkpoint.* diff --git a/apps/web/content/docs/createos.mdx b/apps/web/content/docs/createos.mdx new file mode 100644 index 00000000..eac816b6 --- /dev/null +++ b/apps/web/content/docs/createos.mdx @@ -0,0 +1,80 @@ +--- +title: CreateOS +description: Run your agents in a CreateOS cloud microVM with public preview URLs and pause/resume +--- + +Run agents in a CreateOS cloud sandbox with public HTTPS preview URLs and provider-level pause/resume. Each box is a remote microVM, but the AgentBox flow stays the same: seed the workspace from your host git state, start `agentbox-ctl`, and run Claude, Codex, or OpenCode inside `/workspace`. + +Switch per box with `--provider createos`, or pin it project-wide with `box.provider: createos` in [`agentbox.yaml`](/docs/agentbox-yaml). Comparing options? See [Daytona](/docs/daytona), [Vercel](/docs/vercel), [E2B](/docs/e2b), and [local Docker](/docs/local-docker). + + +CreateOS support is available in the **nightly** build of AgentBox. + + +## Set up + +Use the interactive wizard, or configure the provider directly: + +```bash +agentbox createos login +agentbox prepare --provider createos +``` + +Paste a CreateOS API key when prompted. AgentBox stores it as `CREATEOS_API_KEY` in `~/.agentbox/secrets.env`. For scripted setup, set `CREATEOS_API_KEY` directly. + +`prepare --provider createos` validates credentials, confirms the `createos` CLI is installed, and checks that the AgentBox runtime assets are present. Unlike E2B, CreateOS does not currently bake AgentBox's local runtime files into a reusable base template; each new sandbox installs the runtime into the selected rootfs during provision. + +## Use it + +```bash +agentbox createos claude +``` + +`agentbox createos create|claude|codex|opencode` is sugar for the same command with `--provider createos`. + +## Sizing and Rootfs + +`--size` accepts either a native CreateOS shape slug or AgentBox's compact `cpu-memory[-disk]` GB form: + +```bash +agentbox create --provider createos --size s-2vcpu-2gb +agentbox create --provider createos --size 2-2-20 +``` + +Pin the same value with `box.sizeCreateos` or the generic `box.size`. + +By default AgentBox boots CreateOS boxes from `devbox:1`. Override that with `--image ` or `box.imageCreateos`. + +## Limits + +| Limit | | +| --- | --- | +| Runtime install | Per sandbox provision; `prepare` validates only | +| Default shape | `s-2vcpu-2gb` | +| Default disk | 20 GiB | +| Default rootfs | `devbox:1` | +| Preview URL | Public HTTPS from CreateOS ingress; WebProxy runs on 8080 | +| Attach | `createos sandbox process run --pty` | +| SSH | none - AgentBox does not manage SSH keys for CreateOS boxes | +| Docker-in-Docker | installed in the box and auto-started by AgentBox | + +## Pause / Resume + +CreateOS boxes use the provider API for pause and resume: + +```bash +agentbox pause +agentbox start +``` + +The provider maps AgentBox's idle timeout to CreateOS `auto_pause_after_seconds`; configure it with `box.createosTimeoutMs`. + +## Caveats + +- **No reusable AgentBox bake yet** — `agentbox prepare --provider createos` is a readiness check, and `create` installs the runtime into each new sandbox. +- **Checkpoints are not provider-native yet** — use CreateOS pause/resume for lifecycle parking; provider snapshot restore can be added once AgentBox has a CreateOS template/snapshot capture path. +- **CreateOS-only config keys are ignored elsewhere** — `box.imageCreateos`, `box.defaultCheckpointCreateos`, `box.sizeCreateos`, and `box.createosTimeoutMs` only apply to CreateOS. + +## Related + +[CLI](/docs/cli) · [configuration](/docs/configuration) · [teleport a project](/docs/teleport-a-project) · [web apps and tunnels](/docs/web-apps-and-tunnels) · [sync and git](/docs/sync-and-git) diff --git a/apps/web/content/docs/meta.json b/apps/web/content/docs/meta.json index eef05579..a055fbe7 100644 --- a/apps/web/content/docs/meta.json +++ b/apps/web/content/docs/meta.json @@ -32,6 +32,7 @@ "daytona", "vercel", "e2b", + "createos", "---Community Providers---", "islo", "tenki", diff --git a/packages/config/schema/user-config.schema.json b/packages/config/schema/user-config.schema.json index eb45cf3a..42f72d5a 100644 --- a/packages/config/schema/user-config.schema.json +++ b/packages/config/schema/user-config.schema.json @@ -11,7 +11,7 @@ "additionalProperties": false, "properties": { "provider": { - "enum": ["docker", "daytona", "hetzner", "vercel", "e2b", "digitalocean"], + "enum": ["docker", "daytona", "hetzner", "vercel", "e2b", "digitalocean", "createos"], "description": "Sandbox backend new boxes are created on. Overridden per-run by --provider." }, "hostSnapshot": { "type": "boolean" }, @@ -22,6 +22,7 @@ "defaultCheckpointVercel": { "type": "string", "minLength": 1 }, "defaultCheckpointE2b": { "type": "string", "minLength": 1 }, "defaultCheckpointDigitalocean": { "type": "string", "minLength": 1 }, + "defaultCheckpointCreateos": { "type": "string", "minLength": 1 }, "defaultCheckpointRemoteDocker": { "type": "string", "minLength": 1 }, "size": { "type": "string", "minLength": 1 }, "sizeDocker": { "type": "string", "minLength": 1 }, @@ -30,6 +31,7 @@ "sizeVercel": { "type": "string", "minLength": 1 }, "sizeE2b": { "type": "string", "minLength": 1 }, "sizeDigitalocean": { "type": "string", "minLength": 1 }, + "sizeCreateos": { "type": "string", "minLength": 1 }, "sizeRemoteDocker": { "type": "string", "minLength": 1 }, "withPlaywright": { "type": "boolean" }, "claudeInstall": { "enum": ["native", "npm"] }, @@ -48,6 +50,7 @@ "imageVercel": { "type": "string", "minLength": 1 }, "imageE2b": { "type": "string", "minLength": 1 }, "imageDigitalocean": { "type": "string", "minLength": 1 }, + "imageCreateos": { "type": "string", "minLength": 1 }, "imageRemoteDocker": { "type": "string", "minLength": 1 }, "dockerCacheShared": { "type": "boolean" }, "memory": { "type": "integer", "minimum": 0 }, @@ -62,6 +65,7 @@ "hetznerLocation": { "type": "string", "minLength": 1 }, "digitaloceanRegion": { "type": "string", "minLength": 1 }, "digitaloceanProject": { "type": "string", "minLength": 1 }, + "createosTimeoutMs": { "type": "integer", "minimum": 0 }, "remoteDockerHost": { "type": "string", "minLength": 1 }, "inbound": { "type": "string", "minLength": 1 }, "vercelTimeoutMs": { "type": "integer", "minimum": 1 }, diff --git a/packages/config/src/image.ts b/packages/config/src/image.ts index e45d3c6e..a84199b6 100644 --- a/packages/config/src/image.ts +++ b/packages/config/src/image.ts @@ -3,7 +3,8 @@ * * Precedence (highest wins): * 1. `box.image` — per-provider override - * (`imageDocker` / `imageDaytona` / `imageHetzner` / `imageVercel`). + * (`imageDocker` / `imageDaytona` / `imageHetzner` / `imageVercel` / + * `imageE2b` / `imageCreateos` / ...). * 2. `box.image` — generic fallback (defaults to `agentbox/box:dev`, * which cloud backends recognize as a sentinel meaning "boot from * the provider's prepared base snapshot"). diff --git a/packages/config/src/providers.ts b/packages/config/src/providers.ts index 05b74037..9b558bf2 100644 --- a/packages/config/src/providers.ts +++ b/packages/config/src/providers.ts @@ -108,6 +108,18 @@ export const PROVIDERS = [ imageDesc: 'Per-provider override of `box.image` for digitalocean (numeric snapshot id). Written by `agentbox prepare --provider digitalocean`.', }, + { + name: 'createos', + kind: 'cloud', + label: 'CreateOS (cloud microVM)', + loginHint: 'paste an API key from CreateOS', + rebuildMinutes: '0', + blurb: 'CreateOS Sandboxes', + sizeDesc: + 'Per-provider override of `box.size` for createos. Shape slug (e.g. `s-2vcpu-2gb`) or `cpu-memory[-disk]` GB spec (e.g. `2-2-20`).', + imageDesc: + 'Per-provider override of `box.image` for createos (rootfs/template name, e.g. `devbox:1` or a custom template alias). Empty/default uses `devbox:1`.', + }, { name: 'remote-docker', kind: 'cloud', diff --git a/packages/config/src/size.ts b/packages/config/src/size.ts index 42b5b832..b8ff167f 100644 --- a/packages/config/src/size.ts +++ b/packages/config/src/size.ts @@ -13,6 +13,7 @@ * on the snapshot path. * - vercel: vCPU count — `1`, `2`, `4` or `8` (RAM is coupled at 2 GB/vCPU). * - e2b: `cpu-memory` GB spec (e.g. `4-8`), baked at prepare time. + * - createos: shape slug (e.g. `s-2vcpu-2gb`) or `cpu-memory[-disk]` GB. * - docker: reserved (docker uses memory/cpus/disk). The key exists for * surface uniformity. * diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index f93c2df6..27d2bb56 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -73,6 +73,7 @@ export interface UserConfig { defaultCheckpointVercel?: string; defaultCheckpointE2b?: string; defaultCheckpointDigitalocean?: string; + defaultCheckpointCreateos?: string; defaultCheckpointRemoteDocker?: string; /** * Generic VM-size fallback for cloud providers. Provider-interpreted: @@ -89,6 +90,7 @@ export interface UserConfig { sizeVercel?: string; sizeE2b?: string; sizeDigitalocean?: string; + sizeCreateos?: string; sizeRemoteDocker?: string; withPlaywright?: boolean; /** @@ -117,6 +119,7 @@ export interface UserConfig { imageVercel?: string; imageE2b?: string; imageDigitalocean?: string; + imageCreateos?: string; imageRemoteDocker?: string; imageRegistry?: string; dockerCacheShared?: boolean; @@ -132,6 +135,7 @@ export interface UserConfig { hetznerLocation?: string; digitaloceanRegion?: string; digitaloceanProject?: string; + createosTimeoutMs?: number; remoteDockerHost?: string; vercelTimeoutMs?: number; vercelNetworkPolicy?: string; @@ -258,6 +262,7 @@ export interface EffectiveConfig { defaultCheckpointVercel: string; defaultCheckpointE2b: string; defaultCheckpointDigitalocean: string; + defaultCheckpointCreateos: string; defaultCheckpointRemoteDocker: string; size: string; sizeDocker: string; @@ -266,6 +271,7 @@ export interface EffectiveConfig { sizeVercel: string; sizeE2b: string; sizeDigitalocean: string; + sizeCreateos: string; sizeRemoteDocker: string; withPlaywright: boolean; claudeInstall: ClaudeInstallMethod; @@ -284,6 +290,7 @@ export interface EffectiveConfig { imageVercel: string; imageE2b: string; imageDigitalocean: string; + imageCreateos: string; imageRemoteDocker: string; imageRegistry: string; dockerCacheShared: boolean; @@ -299,6 +306,7 @@ export interface EffectiveConfig { hetznerLocation: string; digitaloceanRegion: string; digitaloceanProject: string; + createosTimeoutMs: number; remoteDockerHost: string; vercelTimeoutMs: number; vercelNetworkPolicy: string; @@ -436,6 +444,7 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { defaultCheckpointVercel: '', defaultCheckpointE2b: '', defaultCheckpointDigitalocean: '', + defaultCheckpointCreateos: '', defaultCheckpointRemoteDocker: '', size: '', sizeDocker: '', @@ -444,6 +453,7 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { sizeVercel: '', sizeE2b: '', sizeDigitalocean: '', + sizeCreateos: '', sizeRemoteDocker: '', withPlaywright: false, claudeInstall: 'native', @@ -462,6 +472,7 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { imageVercel: '', imageE2b: '', imageDigitalocean: '', + imageCreateos: '', // Empty = the provider derives the fingerprint-tagged ref itself and ensures // it on the remote engine; set only to pin a hand-built image there. imageRemoteDocker: '', @@ -486,6 +497,7 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { // Empty = leave boxes in the account's default project (DigitalOcean's own // behavior). There is no sane default id to pick — it differs per account. digitaloceanProject: '', + createosTimeoutMs: 2_700_000, // Empty = no default remote engine; `--provider remote-docker` then errors // unless the SSH destination came from `docker:` / `--remote-host`. remoteDockerHost: '', @@ -816,6 +828,12 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ description: "DigitalOcean Project new --provider digitalocean boxes are placed in — a name or the project's UUID. Unset (the default) leaves boxes in the account's default project. Set it per repo via `agentbox.yaml`, or globally at `agentbox digitalocean login`. DigitalOcean-only; ignored by other providers.", }, + { + key: 'box.createosTimeoutMs', + type: 'int', + description: + 'Idle timeout (ms) for new --provider createos boxes. The backend maps it to CreateOS auto-pause seconds. Default 2700000 (45 min). CreateOS-only.', + }, { key: 'box.remoteDockerHost', type: 'string', diff --git a/packages/config/test/providers.test.ts b/packages/config/test/providers.test.ts index bd9cad14..6e973053 100644 --- a/packages/config/test/providers.test.ts +++ b/packages/config/test/providers.test.ts @@ -80,7 +80,7 @@ describe('provider table is the single source of truth', () => { }); it('the other paste-a-token providers stay paste-a-token', () => { - for (const name of ['hetzner', 'e2b', 'digitalocean']) { + for (const name of ['hetzner', 'e2b', 'digitalocean', 'createos']) { expect(hintFor(name)).toMatch(/paste/i); expect(hintFor(name)).not.toMatch(/browser|sign-?in/i); } diff --git a/packages/core/test/topology.test.ts b/packages/core/test/topology.test.ts index 1ea22c86..9a0db6ff 100644 --- a/packages/core/test/topology.test.ts +++ b/packages/core/test/topology.test.ts @@ -8,14 +8,14 @@ describe('resolveSyncTopology', () => { }); it('a cloud provider without a control-plane URL is classic cloud', () => { - for (const p of ['daytona', 'vercel', 'hetzner', 'e2b']) { + for (const p of ['daytona', 'vercel', 'hetzner', 'e2b', 'createos']) { expect(resolveSyncTopology(p, undefined)).toBe('cloud'); expect(resolveSyncTopology(p, '')).toBe('cloud'); // empty string is not a URL } }); it('a cloud provider with a control-plane URL is control-plane', () => { - for (const p of ['daytona', 'vercel', 'hetzner', 'e2b']) { + for (const p of ['daytona', 'vercel', 'hetzner', 'e2b', 'createos']) { expect(resolveSyncTopology(p, 'https://plane.example')).toBe('control-plane'); } }); diff --git a/packages/relay/src/host-actions.ts b/packages/relay/src/host-actions.ts index 2368222e..0ca5a58a 100644 --- a/packages/relay/src/host-actions.ts +++ b/packages/relay/src/host-actions.ts @@ -165,6 +165,10 @@ export async function resolveCloudBackend(name: string): Promise { const pkg = '@agentbox/sandbox-' + 'digitalocean'; return loadCloudBackend(pkg, async () => ((await import(pkg)) as { digitaloceanBackend: CloudBackend }).digitaloceanBackend); } + if (name === 'createos') { + const pkg = '@agentbox/sandbox-' + 'createos'; + return loadCloudBackend(pkg, async () => ((await import(pkg)) as { createosBackend: CloudBackend }).createosBackend); + } if (name === 'remote-docker') { const pkg = '@agentbox/sandbox-' + 'remote-docker'; return loadCloudBackend(pkg, async () => ((await import(pkg)) as { remoteDockerBackend: CloudBackend }).remoteDockerBackend); diff --git a/packages/sandbox-createos/package.json b/packages/sandbox-createos/package.json new file mode 100644 index 00000000..05abfa73 --- /dev/null +++ b/packages/sandbox-createos/package.json @@ -0,0 +1,44 @@ +{ + "name": "@agentbox/sandbox-createos", + "version": "0.0.0", + "private": true, + "description": "CreateOS Sandbox provider for AgentBox", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./cli": { + "types": "./dist/cli.d.ts", + "import": "./dist/cli.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src test", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist .turbo" + }, + "dependencies": { + "@agentbox/core": "workspace:*", + "@agentbox/sandbox-cloud": "workspace:*", + "@agentbox/sandbox-core": "workspace:*", + "@clack/prompts": "^0.9.0", + "commander": "^12.1.0" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/sandbox-createos/scripts/custom-system-CLAUDE.md b/packages/sandbox-createos/scripts/custom-system-CLAUDE.md new file mode 100644 index 00000000..e53ba25b --- /dev/null +++ b/packages/sandbox-createos/scripts/custom-system-CLAUDE.md @@ -0,0 +1,30 @@ +# AgentBox sandbox (createos provider) + +You are running inside an AgentBox sandbox: a dedicated CreateOS cloud microVM +provisioned for this box. Your user is `vscode` and you can use passwordless +sudo to run commands as root. The user's host filesystem is not visible from +here and nothing is bind-mounted. + +Docker is available. The box installs the docker engine and AgentBox starts +`dockerd` automatically, so `docker`, `docker buildx`, and `docker compose` +work directly from the `vscode` user. + +`/workspace` is a normal git checkout seeded from the host repo at create time. +Because there is no host bind-mount, plain `git` inside the box only affects +this box-local repo. For operations that must reach the host repo or its +remotes, use `agentbox-ctl git push|fetch|pull -- `; it RPCs to the host, +which runs git with the real credentials and writes back into the host worktree +state. + +For GitHub PR work, use `agentbox-ctl git pr [args...]`. For ad-hoc file +transfers between this box and the host, use `agentbox-ctl cp toHost`, `cp +fromHost`, or `agentbox-ctl download`. + +If an `agentbox.yaml` file is present, services start automatically. Check +status with `agentbox-ctl status`. + +To view web services, open `https://`. The +`AGENTBOX_BOX_HOST` env var is available in the box and the same URL works from +the host through CreateOS ingress. + +Box identity: `/etc/agentbox/box.env` and the `AGENTBOX_*` env vars. diff --git a/packages/sandbox-createos/scripts/install-box.sh b/packages/sandbox-createos/scripts/install-box.sh new file mode 100644 index 00000000..40143a1c --- /dev/null +++ b/packages/sandbox-createos/scripts/install-box.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +# AgentBox CreateOS per-sandbox runtime installer. +# +# CreateOS currently boots a generic rootfs and does not have an AgentBox +# template bake path that can carry local runtime files. The provider uploads +# these files to /tmp and runs this script during each provision. + +set -euo pipefail + +step() { printf '\n>>> BEGIN %s\n' "$1"; } +done_() { printf '<<< END %s\n' "$1"; } + +retry_backoff() { + local max=$1; shift + local attempt=1 + local -a waits=(60 240) + while true; do + if "$@"; then return 0; fi + if [ "$attempt" -ge "$max" ]; then return 1; fi + local w=${waits[$((attempt-1))]:-240} + echo "retry_backoff: attempt ${attempt}/${max} failed; backing off ${w}s" >&2 + sleep "$w" + attempt=$((attempt+1)) + done +} + +npm_global() { + npm --prefix /usr/local "$@" +} + +if [ "$(id -u)" -ne 0 ]; then + echo "install-box.sh: must run as root (got uid $(id -u))" >&2 + exit 64 +fi + +export DEBIAN_FRONTEND=noninteractive + +step "hostname resolution" +if command -v hostname >/dev/null 2>&1; then + hn="$(hostname)" + if [ -n "$hn" ] && ! grep -qE "(^|[[:space:]])${hn}($|[[:space:]])" /etc/hosts 2>/dev/null; then + printf '127.0.1.1 %s\n' "$hn" >> /etc/hosts + fi +fi +done_ "hostname resolution" + +step "apt base packages" +apt-get update -y -q +apt-get install -y -q --no-install-recommends \ + ca-certificates \ + git \ + git-lfs \ + tar \ + gzip \ + curl \ + wget \ + sudo \ + python3 \ + python3-pip \ + python3-venv \ + tmux \ + vim \ + libcap2-bin \ + rsync \ + xclip \ + autocutsel \ + locales \ + bash-completion +locale-gen en_US.UTF-8 >/dev/null 2>&1 || true +update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 >/dev/null 2>&1 || true +done_ "apt base packages" + +step "node sanity" +node_major=0 +node_path="" +node_path_resolved="" +if command -v node >/dev/null 2>&1; then + node_path="$(command -v node)" + node_path_resolved="$(readlink -f "$node_path" 2>/dev/null || printf '%s' "$node_path")" + node_major="$(node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0)" +fi +if [ "${node_major:-0}" -lt 20 ] || [[ "$node_path_resolved" == /root/* ]] || [ ! -x /usr/bin/node ]; then + curl -fsSL https://deb.nodesource.com/setup_24.x | bash - + apt-get install -y -q --no-install-recommends nodejs +fi +if [ -x /usr/bin/node ]; then + ln -sf /usr/bin/node /usr/local/bin/node +fi +if [ -x /usr/bin/npm ]; then + ln -sf /usr/bin/npm /usr/local/bin/npm +fi +if [ -x /usr/bin/npx ]; then + ln -sf /usr/bin/npx /usr/local/bin/npx +fi +hash -r +node --version +done_ "node sanity" + +step "vscode user + sudoers" +if ! id vscode >/dev/null 2>&1; then + useradd -m -s /bin/bash vscode +fi +install -d -m 0755 -o vscode -g vscode /home/vscode +echo 'vscode ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/90-agentbox-vscode +chmod 0440 /etc/sudoers.d/90-agentbox-vscode +visudo -cf /etc/sudoers >/dev/null +done_ "vscode user + sudoers" + +step "agentbox base dirs + /workspace ownership" +mkdir -p /workspace /run/agentbox /var/log/agentbox /var/lib/agentbox /etc/agentbox /etc/claude-code \ + /usr/local/share/agentbox +chmod 755 /workspace +chown vscode:vscode /workspace /run/agentbox /var/log/agentbox /var/lib/agentbox +done_ "agentbox base dirs + /workspace ownership" + +step "docker engine" +docker_packages=() +if ! command -v docker >/dev/null 2>&1; then + docker_packages+=(docker.io) +fi +if ! docker compose version >/dev/null 2>&1; then + docker_packages+=(docker-compose-v2) +fi +if ! docker buildx version >/dev/null 2>&1; then + docker_packages+=(docker-buildx) +fi +if ! command -v fuse-overlayfs >/dev/null 2>&1; then + docker_packages+=(fuse3 fuse-overlayfs) +fi +if ! command -v iptables >/dev/null 2>&1; then + docker_packages+=(iptables) +fi +if [ "${#docker_packages[@]}" -gt 0 ]; then + apt-get install -y -q --no-install-recommends "${docker_packages[@]}" +fi +groupadd -f docker +usermod -aG docker vscode +mkdir -p /etc/docker +printf '%s\n' '{ "iptables": true }' > /etc/docker/daemon.json +systemctl disable --now docker.service docker.socket 2>/dev/null || true +docker --version +docker compose version +docker buildx version +done_ "docker engine" + +step "node setcap" +NODE_BIN="$(readlink -f "$(command -v node)")" +setcap cap_net_bind_service=+ep "$NODE_BIN" || echo "install-box.sh: setcap failed (continuing)" +done_ "node setcap" + +step "corepack" +npm_global install -g --force corepack@latest 2>&1 | tail -2 || true +corepack enable pnpm yarn 2>/dev/null || true +sudo -u vscode -H mkdir -p /home/vscode/.cache/node/corepack +done_ "corepack" + +step "git config" +git config --system --add safe.directory '*' 2>/dev/null || true +sudo -u vscode -H git config --global --add safe.directory '*' 2>/dev/null || true +git lfs install --system --skip-repo 2>/dev/null || true +sudo -u vscode -H git lfs install --skip-repo 2>/dev/null || true +done_ "git config" + +step "agentbox-ctl install" +install -m 0755 /tmp/agentbox-ctl /usr/local/bin/agentbox-ctl +done_ "agentbox-ctl install" + +step "helper scripts" +install -m 0755 /tmp/agentbox-vnc-start /usr/local/bin/agentbox-vnc-start +install -m 0755 /tmp/agentbox-dockerd-start /usr/local/bin/agentbox-dockerd-start +install -m 0755 /tmp/agentbox-portless-trust /usr/local/bin/agentbox-portless-trust +install -m 0755 /tmp/agentbox-checkpoint-cleanup /usr/local/bin/agentbox-checkpoint-cleanup +install -m 0755 /tmp/agentbox-open /usr/local/bin/agentbox-open +ln -sf /usr/local/bin/agentbox-open /usr/local/bin/xdg-open +done_ "helper scripts" + +step "config files" +install -m 0644 /tmp/agentbox-custom-CLAUDE.md /etc/claude-code/CLAUDE.md +install -m 0644 /tmp/agentbox-managed-settings.json /etc/claude-code/managed-settings.json +install -m 0644 /tmp/agentbox-codex-hooks.json /usr/local/share/agentbox/codex-hooks.json +install -m 0644 /tmp/agentbox-setup-skill.md /usr/local/share/agentbox/setup-guide.md +cat > /etc/tmux.conf <<'TMUX' +set -g default-terminal "tmux-256color" +set -as terminal-overrides ",*:Tc" +set -as terminal-overrides ",*:RGB" +set -as terminal-features ",*:hyperlinks" +set -as terminal-features ",*:RGB" +set -g allow-passthrough on +set -g set-clipboard on +set -g extended-keys on +set -as terminal-features ",*:extkeys" +set -g mouse on +bind -T copy-mode WheelUpPane send -N2 -X scroll-up +bind -T copy-mode WheelDownPane send -N2 -X scroll-down +bind -T copy-mode-vi WheelUpPane send -N2 -X scroll-up +bind -T copy-mode-vi WheelDownPane send -N2 -X scroll-down +set -g history-limit 50000 +set -g escape-time 0 +TMUX +done_ "config files" + +step "credential pivot symlinks" +sudo -u vscode -H mkdir -p \ + /home/vscode/.claude \ + /home/vscode/.claude/skills/agentbox-setup \ + /home/vscode/.codex \ + /home/vscode/.local/share/opencode \ + /home/vscode/.agentbox-creds/claude \ + /home/vscode/.agentbox-creds/codex \ + /home/vscode/.agentbox-creds/opencode +sudo -u vscode -H ln -sf /home/vscode/.agentbox-creds/claude/.credentials.json \ + /home/vscode/.claude/.credentials.json +sudo -u vscode -H ln -sf /home/vscode/.agentbox-creds/codex/auth.json \ + /home/vscode/.codex/auth.json +sudo -u vscode -H ln -sf /home/vscode/.agentbox-creds/opencode/auth.json \ + /home/vscode/.local/share/opencode/auth.json +sudo -u vscode -H ln -sf /home/vscode/.claude/_claude.json /home/vscode/.claude.json +sudo -u vscode -H cp /usr/local/share/agentbox/setup-guide.md \ + /home/vscode/.claude/skills/agentbox-setup/SKILL.md +done_ "credential pivot symlinks" + +step "login-shell shim" +cat > /etc/profile.d/agentbox.sh <<'PROFILE' +if [ -r /etc/agentbox/box.env ]; then + set -a + . /etc/agentbox/box.env + set +a +fi +case ":$PATH:" in + *:/home/vscode/.local/bin:*) : ;; + *) PATH=/home/vscode/.local/bin:$PATH ;; +esac +PATH=/usr/local/bin:$(printf '%s' "$PATH" | sed -e 's#:/usr/local/bin:#:#g' -e 's#^/usr/local/bin:##' -e 's#:/usr/local/bin$##' -e 's#^/usr/local/bin$##') +export PATH +export COLORTERM=${COLORTERM:-truecolor} +export DISABLE_AUTOUPDATER=${DISABLE_AUTOUPDATER:-1} +export LANG=${LANG:-en_US.UTF-8} +export LC_ALL=${LC_ALL:-en_US.UTF-8} +export DISPLAY=${DISPLAY:-:1} +export AGENT_BROWSER_EXECUTABLE_PATH=${AGENT_BROWSER_EXECUTABLE_PATH:-/usr/local/bin/chromium} +export BROWSER=${BROWSER:-/usr/local/bin/agentbox-open} +case $- in + *i*) + if [ "$PWD" = "$HOME" ] && [ -d /workspace ]; then + cd /workspace + fi + ;; +esac +PROFILE +chmod 0644 /etc/profile.d/agentbox.sh +done_ "login-shell shim" + +step "VNC stack" +apt-get install -y -q --no-install-recommends \ + tigervnc-standalone-server xterm 2>&1 | tail -3 || \ + echo "install-box.sh: tigervnc install failed (VNC may be unavailable)" +python3 -m venv /usr/local/share/agentbox/venv 2>/dev/null || true +/usr/local/share/agentbox/venv/bin/pip install --quiet websockify 2>&1 | tail -2 || \ + echo "install-box.sh: websockify install failed (VNC may be unavailable)" +ln -sf /usr/local/share/agentbox/venv/bin/websockify /usr/local/bin/websockify +if [ ! -d /usr/local/share/novnc ]; then + git clone --depth 1 https://github.com/novnc/noVNC /usr/local/share/novnc 2>&1 | tail -2 || \ + echo "install-box.sh: noVNC clone failed (VNC may be unavailable)" +fi +sudo -u vscode -H mkdir -p /home/vscode/.vnc +done_ "VNC stack" + +step "agent CLIs" +npm_global install -g --force @openai/codex opencode-ai agent-browser 2>&1 | tail -3 || \ + echo "install-box.sh: one or more agent npm installs failed (continuing)" +done_ "agent CLIs" + +if [ "${AGENTBOX_CLAUDE_INSTALL:-native}" = "npm" ]; then + step "Claude Code npm" + npm_global install -g --force @anthropic-ai/claude-code + install -d -o vscode -g vscode /home/vscode/.local/bin + ln -sf "$(command -v claude)" /home/vscode/.local/bin/claude + chown -h vscode:vscode /home/vscode/.local/bin/claude + command -v claude >/dev/null || { echo "install-box.sh: npm claude install produced no claude on PATH" >&2; exit 71; } + done_ "Claude Code npm" +else + step "Claude Code native" + if ! retry_backoff 3 sudo -u vscode -H bash -lc \ + 'set -o pipefail; curl -fsSL https://claude.ai/install.sh | bash -s stable && command -v claude >/dev/null'; then + echo "install-box.sh: Claude native installer failed after 3 attempts" >&2 + exit 71 + fi + done_ "Claude Code native" +fi + +step "Chrome runtime libs" +apt-get install -y -q --no-install-recommends \ + libnss3 libnspr4 libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 libdrm2 \ + libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ + libgbm1 libpango-1.0-0 libcairo2 libasound2t64 \ + fonts-liberation +done_ "Chrome runtime libs" + +step "playwright Chromium" +npm_global install -g --force playwright 2>&1 | tail -3 +PLAYWRIGHT_BIN="$(command -v playwright || true)" +if [ -z "$PLAYWRIGHT_BIN" ]; then + echo "install-box.sh: playwright install produced no playwright on PATH" >&2 + exit 71 +fi +sudo -u vscode -H env PATH=/usr/local/bin:/usr/bin:/bin "$PLAYWRIGHT_BIN" install chromium +CHROME_BIN="$(sudo -u vscode -H bash -lc 'ls /home/vscode/.cache/ms-playwright/chromium-*/chrome-linux*/chrome 2>/dev/null | sort | tail -1')" +if [ -z "$CHROME_BIN" ] || [ ! -x "$CHROME_BIN" ]; then + echo "install-box.sh: could not resolve Playwright Chromium binary" >&2 + exit 70 +fi +LDD_OUT="$(ldd "$CHROME_BIN" 2>&1 || true)" +if printf '%s\n' "$LDD_OUT" | grep -q 'not found'; then + echo "install-box.sh: Chromium has unresolved shared libs:" >&2 + printf '%s\n' "$LDD_OUT" | grep 'not found' >&2 + exit 71 +fi +ln -sf "$CHROME_BIN" /usr/local/bin/chromium +done_ "playwright Chromium" + +step "relay shims" +install -m 0755 /tmp/agentbox-gh-shim /usr/local/bin/gh +install -m 0755 /tmp/agentbox-git-shim /usr/local/bin/git +install -m 0755 /tmp/agentbox-ntn-shim /usr/local/bin/ntn +ln -sf /usr/local/bin/ntn /usr/local/bin/notion +install -m 0755 /tmp/agentbox-linear-shim /usr/local/bin/linear +done_ "relay shims" + +step "apt cleanup" +apt-get clean -y -q 2>/dev/null || true +rm -rf /var/lib/apt/lists/* 2>/dev/null || true +done_ "apt cleanup" + +step "trim /tmp/agentbox-*" +rm -f /tmp/agentbox-ctl /tmp/agentbox-dockerd-start /tmp/agentbox-vnc-start \ + /tmp/agentbox-portless-trust /tmp/agentbox-checkpoint-cleanup /tmp/agentbox-open \ + /tmp/agentbox-gh-shim /tmp/agentbox-git-shim /tmp/agentbox-ntn-shim \ + /tmp/agentbox-linear-shim \ + /tmp/agentbox-custom-CLAUDE.md /tmp/agentbox-managed-settings.json \ + /tmp/agentbox-codex-hooks.json /tmp/agentbox-setup-skill.md +mv /tmp/agentbox-install.sh /var/log/agentbox/install-box.sh 2>/dev/null || true +done_ "trim /tmp/agentbox-*" + +printf '\n*** install-box.sh: complete - CreateOS sandbox ready.\n' diff --git a/packages/sandbox-createos/src/backend.ts b/packages/sandbox-createos/src/backend.ts new file mode 100644 index 00000000..0e604fb9 --- /dev/null +++ b/packages/sandbox-createos/src/backend.ts @@ -0,0 +1,342 @@ +import type { + CloudExecOptions, + CloudExecResult, + CloudFileEntry, + CloudHandle, + CloudPreviewUrl, + CloudProvisionRequest, + CloudSandboxSummary, + CloudState, +} from '@agentbox/core'; +import type { createCloudProvider } from '@agentbox/sandbox-cloud'; +import { quoteShellArgv } from '@agentbox/sandbox-cloud'; +import { CreateOsApiError, makeCreateOsClient, type CreateOsSandboxView } from './client.js'; +import { findStagedCliRuntimeRoot, resolveRuntimeAssets } from './runtime-assets.js'; +import { withCreateOsRetry } from './retry.js'; + +export const CREATEOS_DEFAULT_BOX_IMAGE_REF = 'agentbox/box:dev'; +export const CREATEOS_DEFAULT_RESOURCES = { cpu: 2, memory: 2, disk: 20 } as const; + +const DEFAULT_SHAPE = 's-2vcpu-2gb'; +const DEFAULT_ROOTFS = 'devbox:1'; +const DEFAULT_DISK_MIB = 20_480; +const CREATEOS_WEB_PORT = 8080; + +function mapState(status: string | undefined): CloudState { + switch (status) { + case 'running': + case 'creating': + case 'resuming': + return 'running'; + case 'paused': + case 'pausing': + return 'paused'; + case 'destroyed': + case 'destroying': + case 'failed': + return 'missing'; + default: + return 'missing'; + } +} + +export function parseCreateosSize(spec: string | undefined): { + shape: string; + resources?: { cpu?: number; memory?: number; disk?: number }; +} { + const trimmed = (spec ?? '').trim(); + if (!trimmed) return { shape: DEFAULT_SHAPE, resources: { ...CREATEOS_DEFAULT_RESOURCES } }; + const m = /^(\d+)-(\d+)(?:-(\d+))?$/.exec(trimmed); + if (!m) return { shape: trimmed }; + const cpu = Number(m[1]); + const memory = Number(m[2]); + const disk = m[3] ? Number(m[3]) : 20; + return { shape: `s-${String(cpu)}vcpu-${String(memory)}gb`, resources: { cpu, memory, disk } }; +} + +function rootfsFor(req: CloudProvisionRequest): string { + const image = req.snapshot ?? req.image; + if (!image || image === CREATEOS_DEFAULT_BOX_IMAGE_REF) return DEFAULT_ROOTFS; + return image; +} + +function diskMibFor(req: CloudProvisionRequest, parsedDiskGb: number | undefined): number { + const requested = req.resources?.disk ?? parsedDiskGb; + if (typeof requested === 'number' && Number.isFinite(requested) && requested > 0) { + return Math.round(requested * 1024); + } + return DEFAULT_DISK_MIB; +} + +function shellSingle(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +function envAssignments(env: Record | undefined): string { + if (!env || Object.keys(env).length === 0) return ''; + return Object.entries(env) + .map(([k, v]) => `${k}=${quoteShellArgv([v])}`) + .join(' '); +} + +function execScript(cmd: string, opts?: CloudExecOptions): string { + const parts: string[] = ['set -e']; + if (opts?.cwd) parts.push(`cd ${quoteShellArgv([opts.cwd])}`); + const env = envAssignments(opts?.env); + if (env) parts.push(`export ${env}`); + const user = opts?.user ?? 'vscode'; + if (user !== 'root') { + parts.push(`exec sudo -n -u ${quoteShellArgv([user])} bash -lc ${shellSingle(cmd)}`); + } else { + parts.push(cmd); + } + return parts.join('\n'); +} + +async function waitForState( + sandboxId: string, + target: CloudState, + deadlineMs: number, +): Promise { + const client = makeCreateOsClient(); + const deadline = Date.now() + deadlineMs; + for (;;) { + const view = await withCreateOsRetry( + { method: 'get', retryOnAmbiguous: true, attemptTimeoutMs: 15_000 }, + () => client.getSandbox(sandboxId), + ); + if (target === 'running' && view?.status === 'running') return; + if (target === 'paused' && view?.status === 'paused') return; + const state = mapState(view?.status); + if (state === 'missing') throw new Error(`createos: sandbox ${sandboxId} is ${view?.status ?? 'missing'}`); + if (Date.now() >= deadline) { + throw new Error(`createos: timed out waiting for sandbox ${sandboxId} to become ${target}`); + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } +} + +async function installRuntime(h: CloudHandle, log: (line: string) => void): Promise { + const client = makeCreateOsClient(); + const assets = resolveRuntimeAssets({ cliRuntimeRoot: findStagedCliRuntimeRoot() }); + for (const asset of assets) { + const remote = `/tmp/${asset.remoteBasename}`; + log(`createos: upload ${asset.name} -> ${remote}`); + await withCreateOsRetry( + { method: 'uploadFile', retryOnAmbiguous: true, attemptTimeoutMs: 300_000 }, + () => client.uploadFile(h.sandboxId, asset.localPath, remote), + ); + const mode = asset.remoteMode; + if (mode !== undefined) { + await withCreateOsRetry( + { method: 'chmod', retryOnAmbiguous: true, attemptTimeoutMs: 30_000 }, + () => client.exec(h.sandboxId, 'chmod', [mode.toString(8), remote]), + ); + } + } + const res = await withCreateOsRetry( + { method: 'installRuntime', retryOnAmbiguous: false, attemptTimeoutMs: 1_800_000, backoffMs: [] }, + () => client.exec(h.sandboxId, 'bash', ['/tmp/agentbox-install.sh']), + ); + if ((res.result?.exit_code ?? 0) !== 0) { + const stdout = res.result?.stdout?.trim(); + const stderr = res.result?.stderr?.trim(); + const details = [stdout, stderr].filter(Boolean).join('\n'); + throw new Error(`createos: runtime install failed: ${details}`); + } + if (res.result?.stdout) for (const line of res.result.stdout.split('\n')) if (line.trim()) log(line); +} + +function ingressUrl(view: CreateOsSandboxView | null, port: number, sandboxId: string): string { + const tmpl = view?.ingress_url_template; + if (tmpl) return tmpl.replace('', String(port)); + return `https://${sandboxId.replace(/^sb[_-]?/, '')}-${String(port)}.sb.createos.sh`; +} + +function isLifecycleInProgress(err: unknown, words: readonly string[]): boolean { + return err instanceof CreateOsApiError && err.statusCode === 409 && words.some((w) => err.body.includes(w)); +} + +/** + * CreateOS is shaped like the SDK-only cloud providers from AgentBox's point + * of view: no SSH, public ingress URLs, pause/resume through the provider API, + * and workspace/runtime operations over HTTP endpoints. + * + * Unlike E2B, CreateOS templates/rootfs builds do not currently accept an + * AgentBox file build context, so `agentbox prepare --provider createos` only + * validates credentials/assets and each provision installs the runtime into + * the fresh rootfs before the shared cloud scaffold seeds the workspace. + */ +export const createosBackend = { + name: 'createos', + webProxyPort: CREATEOS_WEB_PORT, + timeoutModel: 'inactivity', + + async provision(req: CloudProvisionRequest): Promise { + const client = makeCreateOsClient(); + const parsed = parseCreateosSize(req.size); + const log = req.onLog ?? (() => {}); + const created = await withCreateOsRetry( + { method: 'provision', retryOnAmbiguous: false, attemptTimeoutMs: 120_000, backoffMs: [] }, + () => + client.createSandbox({ + name: req.name, + shape: parsed.shape, + rootfs: rootfsFor(req), + disk_mib: diskMibFor(req, parsed.resources?.disk), + ingress_enabled: true, + auto_pause_after_seconds: req.timeoutMs + ? Math.max(60, Math.round(req.timeoutMs / 1000)) + : undefined, + envs: req.env, + egress: ['*'], + }), + ); + const handle: CloudHandle = { + sandboxId: created.id, + resources: { + cpu: created.vcpu ?? parsed.resources?.cpu, + memory: created.mem_mib ? created.mem_mib / 1024 : parsed.resources?.memory, + disk: created.disk_mib ? created.disk_mib / 1024 : parsed.resources?.disk, + }, + }; + await waitForState(handle.sandboxId, 'running', 120_000); + await installRuntime(handle, log); + log(`createos: created sandbox ${handle.sandboxId}`); + return handle; + }, + + async get(sandboxId: string): Promise { + const view = await withCreateOsRetry( + { method: 'get', retryOnAmbiguous: true }, + () => makeCreateOsClient().getSandbox(sandboxId), + ); + return view ? { sandboxId } : null; + }, + + async list(): Promise { + const items = await withCreateOsRetry( + { method: 'list', retryOnAmbiguous: true }, + () => makeCreateOsClient().listSandboxes(), + ); + return items + .filter((s) => s.name?.startsWith('agentbox-') || s.name !== undefined) + .filter((s) => mapState(s.status) !== 'missing') + .map((s) => ({ + sandboxId: s.id, + name: s.name, + createdAt: s.created_at, + state: mapState(s.status), + })); + }, + + async start(h: CloudHandle): Promise { + await this.resume(h); + }, + + async stop(h: CloudHandle): Promise { + await this.pause(h); + }, + + async pause(h: CloudHandle): Promise { + try { + await withCreateOsRetry( + { method: 'pause', retryOnAmbiguous: true, attemptTimeoutMs: 120_000 }, + () => makeCreateOsClient().pauseSandbox(h.sandboxId), + ); + } catch (err) { + if (!isLifecycleInProgress(err, ['sandbox is pausing', 'sandbox is paused'])) throw err; + } + await waitForState(h.sandboxId, 'paused', 120_000); + }, + + async resume(h: CloudHandle): Promise { + try { + await withCreateOsRetry( + { method: 'resume', retryOnAmbiguous: true, attemptTimeoutMs: 120_000 }, + () => makeCreateOsClient().resumeSandbox(h.sandboxId), + ); + } catch (err) { + if (!isLifecycleInProgress(err, ['sandbox is resuming', 'sandbox is running'])) throw err; + } + await waitForState(h.sandboxId, 'running', 120_000); + }, + + async destroy(h: CloudHandle): Promise { + await withCreateOsRetry( + { method: 'destroy', retryOnAmbiguous: true, attemptTimeoutMs: 120_000 }, + () => makeCreateOsClient().destroySandbox(h.sandboxId), + ); + }, + + async state(h: CloudHandle): Promise { + const view = await withCreateOsRetry( + { method: 'state', retryOnAmbiguous: true }, + () => makeCreateOsClient().getSandbox(h.sandboxId), + ); + return mapState(view?.status); + }, + + async exec(h: CloudHandle, cmd: string, opts?: CloudExecOptions): Promise { + const script = execScript(cmd, opts); + const res = await withCreateOsRetry( + { + method: 'exec', + retryOnAmbiguous: opts?.noRetry ? false : true, + attemptTimeoutMs: opts?.attemptTimeoutMs ?? 300_000, + backoffMs: opts?.noRetry ? [] : undefined, + }, + () => makeCreateOsClient().exec(h.sandboxId, 'bash', ['-lc', script]), + ); + const result = res.result; + return { + exitCode: result?.exit_code ?? 0, + stdout: result?.stdout ?? '', + stderr: result?.stderr ?? '', + }; + }, + + async uploadFile(h: CloudHandle, localPath: string, remotePath: string): Promise { + await withCreateOsRetry( + { method: 'uploadFile', retryOnAmbiguous: true, attemptTimeoutMs: 300_000 }, + () => makeCreateOsClient().uploadFile(h.sandboxId, localPath, remotePath), + ); + await this.exec(h, `chown vscode:vscode ${quoteShellArgv([remotePath])}`, { user: 'root' }).catch(() => {}); + }, + + async downloadFile(h: CloudHandle, remotePath: string, localPath: string): Promise { + await withCreateOsRetry( + { method: 'downloadFile', retryOnAmbiguous: true, attemptTimeoutMs: 300_000 }, + () => makeCreateOsClient().downloadFile(h.sandboxId, remotePath, localPath), + ); + }, + + async listFiles(h: CloudHandle, remoteDir: string): Promise { + const script = `node -e ${shellSingle( + "const fs=require('fs'); const dir=process.argv[1]; const out=fs.readdirSync(dir,{withFileTypes:true}).map(e=>({name:e.name,isDir:e.isDirectory()})); process.stdout.write(JSON.stringify(out));", + )} ${quoteShellArgv([remoteDir])}`; + const r = await this.exec(h, script); + if (r.exitCode !== 0) throw new Error(`createos: list files failed: ${r.stderr}`); + return JSON.parse(r.stdout) as CloudFileEntry[]; + }, + + async previewUrl(h: CloudHandle, port: number): Promise { + const view = await withCreateOsRetry( + { method: 'previewUrl', retryOnAmbiguous: true }, + () => makeCreateOsClient().getSandbox(h.sandboxId), + ); + return { url: ingressUrl(view, port, h.sandboxId) }; + }, + + async signedPreviewUrl(h: CloudHandle, port: number): Promise { + return this.previewUrl(h, port); + }, + + async renewTimeout(h: CloudHandle, targetDeadlineEpochMs: number): Promise { + const seconds = Math.max(60, Math.round((targetDeadlineEpochMs - Date.now()) / 1000)); + await withCreateOsRetry( + { method: 'renewTimeout', retryOnAmbiguous: true }, + () => makeCreateOsClient().patchSandbox(h.sandboxId, { auto_pause_after_seconds: seconds }), + ); + }, +} satisfies Parameters[0]; diff --git a/packages/sandbox-createos/src/build-attach.ts b/packages/sandbox-createos/src/build-attach.ts new file mode 100644 index 00000000..fe872b84 --- /dev/null +++ b/packages/sandbox-createos/src/build-attach.ts @@ -0,0 +1,85 @@ +/** + * `buildCreateosAttach` - the CreateOS provider's override of + * `Provider.buildAttach`. + * + * CreateOS has no AgentBox-managed SSH transport. The generic cloud scaffold's + * SSH-style attach path is therefore unusable. Instead we drive the official + * `createos` CLI's managed PTY command, mirroring the Vercel provider's CLI + * attach shape. + */ + +import { + type AttachKind, + type AttachSpec, + type BoxRecord, + type BuildAttachOptions, +} from '@agentbox/core'; +import { hostTermForCloud, renderInnerCommand } from '@agentbox/sandbox-cloud'; +import { detectCreateosCli } from './createos-cli.js'; +import { readCreateOsCredStatus } from './credentials.js'; + +export function buildCreateosAttachArgv(args: { + bin: string; + sandboxId: string; + kind: AttachKind; + inner: string; + detached?: boolean; +}): string[] { + const interactive = (args.kind === 'shell' || args.kind === 'agent') && !args.detached; + return [ + args.bin, + 'sandbox', + 'process', + 'run', + '--cwd', + '/workspace', + ...(interactive ? ['--pty'] : []), + args.sandboxId, + '--', + 'sudo', + '-u', + 'vscode', + '-H', + 'bash', + '-lc', + args.inner, + ]; +} + +export async function buildCreateosAttach( + box: BoxRecord, + kind: AttachKind, + opts?: BuildAttachOptions, +): Promise { + const sandboxId = box.cloud?.sandboxId; + if (!sandboxId) { + throw new Error(`createos box ${box.name} has no sandboxId - record is malformed`); + } + + const det = detectCreateosCli(); + if (!det.installed || !det.bin) { + throw new Error( + 'CreateOS interactive attach needs the `createos` CLI on PATH. Install it, then run `agentbox createos login`.', + ); + } + + const cred = readCreateOsCredStatus(); + if (!cred.token) { + throw new Error('CreateOS credentials not configured. Run `agentbox createos login` or set CREATEOS_API_KEY.'); + } + + const envPrelude = `export LANG=C.UTF-8 LC_ALL=C.UTF-8 TERM=${hostTermForCloud()}; `; + const inner = envPrelude + renderInnerCommand(kind, opts); + + return { + argv: buildCreateosAttachArgv({ + bin: det.bin, + sandboxId, + kind, + inner, + detached: opts?.detached, + }), + env: { CREATEOS_API_KEY: cred.token }, + }; +} + diff --git a/packages/sandbox-createos/src/cli.ts b/packages/sandbox-createos/src/cli.ts new file mode 100644 index 00000000..917df326 --- /dev/null +++ b/packages/sandbox-createos/src/cli.ts @@ -0,0 +1,56 @@ +import { log } from '@clack/prompts'; +import { Command } from 'commander'; +import { + ensureCreateOsCredentials, + maskKey, + readCreateOsCredStatus, + secretsPath, +} from './credentials.js'; + +interface LoginOpts { + status?: boolean; +} + +function printStatus(): void { + const s = readCreateOsCredStatus(); + if (s.auth === 'none') { + process.stdout.write( + 'createos: not configured\n run `agentbox createos login` to set up credentials\n', + ); + return; + } + const lines = ['createos: configured', ' auth: API key']; + if (s.token) lines.push(` token: ${maskKey(s.token)}`); + lines.push(` source: ${s.source}`); + if (s.source === 'secrets.env') lines.push(` file: ${secretsPath()}`); + process.stdout.write(lines.join('\n') + '\n'); +} + +const loginSub = new Command('login') + .description('Set up CreateOS credentials for sandbox boxes') + .option('--status', 'show what is currently configured and exit') + .action(async (opts: LoginOpts) => { + try { + if (opts.status) { + printStatus(); + return; + } + if (!process.stdin.isTTY) { + process.stderr.write( + 'createos login needs an interactive terminal - set CREATEOS_API_KEY in the environment for non-interactive use.\n', + ); + process.exitCode = 1; + return; + } + await ensureCreateOsCredentials({ force: true }); + } catch (err) { + log.error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + } + }); + +export const createosCommand = new Command('createos') + .description( + 'CreateOS sandbox provider - credentials, plus sugar for `--provider createos`', + ) + .addCommand(loginSub, { isDefault: true }); diff --git a/packages/sandbox-createos/src/client.ts b/packages/sandbox-createos/src/client.ts new file mode 100644 index 00000000..bacdbc92 --- /dev/null +++ b/packages/sandbox-createos/src/client.ts @@ -0,0 +1,224 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { ensureCreateOsEnvLoaded } from './env-loader.js'; + +export const DEFAULT_CREATEOS_ENDPOINT = 'https://api.sb.createos.sh'; + +interface JSendSuccess { + status: 'success'; + data: T; +} + +interface JSendFail { + status: 'fail'; + data?: unknown; +} + +interface JSendError { + status: 'error'; + message?: string; + code?: number; +} + +type JSend = JSendSuccess | JSendFail | JSendError; + +export interface CreateOsCreateSandboxRequest { + name: string; + shape: string; + rootfs?: string; + disk_mib?: number; + ingress_enabled?: boolean; + auto_pause_after_seconds?: number; + envs?: Record; + egress?: string[]; +} + +export interface CreateOsSandboxView { + id: string; + name?: string; + status: string; + ip?: string; + vcpu?: number; + mem_mib?: number; + disk_mib?: number; + created_at?: string; + shape?: string; + rootfs?: string; + ingress_enabled?: boolean; + ingress_url_template?: string; +} + +export interface CreateOsCreateSandboxResponse { + id: string; + name?: string; + ip?: string; + shape?: string; + rootfs?: string; + vcpu?: number; + mem_mib?: number; + disk_mib?: number; + ingress_url_template?: string; +} + +export interface CreateOsExecResponse { + result?: { + stdout?: string; + stderr?: string; + exit_code?: number; + }; +} + +interface Paged { + data: T[]; + pagination?: { + total: number; + limit: number; + offset: number; + count: number; + }; +} + +export class CreateOsApiError extends Error { + constructor( + readonly statusCode: number, + readonly body: string, + ) { + super(`createos API ${String(statusCode)}: ${body}`); + } +} + +export class CreateOsClient { + readonly endpoint: string; + readonly token: string; + + constructor(opts: { endpoint?: string; token?: string } = {}) { + ensureCreateOsEnvLoaded(); + this.endpoint = (opts.endpoint ?? process.env.CREATEOS_API_URL ?? DEFAULT_CREATEOS_ENDPOINT) + .replace(/\/+$/, ''); + this.token = opts.token ?? process.env.CREATEOS_API_KEY ?? ''; + if (!this.token) { + throw new Error( + 'CreateOS credentials not configured. Run `agentbox createos login` or set CREATEOS_API_KEY.', + ); + } + } + + async whoami(): Promise { + return this.request('GET', '/v1/whoami'); + } + + async createSandbox(req: CreateOsCreateSandboxRequest): Promise { + return this.request('POST', '/v1/sandboxes', req); + } + + async listSandboxes(): Promise { + const all: CreateOsSandboxView[] = []; + let offset = 0; + const limit = 500; + for (;;) { + const page = await this.request>( + 'GET', + `/v1/sandboxes?limit=${String(limit)}&offset=${String(offset)}`, + ); + all.push(...page.data); + const count = page.pagination?.count ?? page.data.length; + const total = page.pagination?.total ?? all.length; + if (count <= 0 || all.length >= total) break; + offset += count; + } + return all; + } + + async getSandbox(id: string): Promise { + try { + return await this.request('GET', `/v1/sandboxes/${encodeURIComponent(id)}`); + } catch (err) { + if (err instanceof CreateOsApiError && err.statusCode === 404) return null; + throw err; + } + } + + async patchSandbox(id: string, body: Record): Promise { + return this.request('PATCH', `/v1/sandboxes/${encodeURIComponent(id)}`, body); + } + + async pauseSandbox(id: string): Promise { + await this.request('POST', `/v1/sandboxes/${encodeURIComponent(id)}/pause`); + } + + async resumeSandbox(id: string): Promise { + await this.request('POST', `/v1/sandboxes/${encodeURIComponent(id)}/resume`); + } + + async destroySandbox(id: string): Promise { + try { + await this.request('DELETE', `/v1/sandboxes/${encodeURIComponent(id)}`); + } catch (err) { + if (err instanceof CreateOsApiError && err.statusCode === 404) return; + throw err; + } + } + + async exec(id: string, cmd: string, args: string[] = []): Promise { + return this.request('POST', `/v1/sandboxes/${encodeURIComponent(id)}/exec`, { + cmd, + args, + }); + } + + async uploadFile(id: string, localPath: string, remotePath: string): Promise { + const body = await readFile(localPath); + await this.raw( + 'PUT', + `/v1/sandboxes/${encodeURIComponent(id)}/files?path=${encodeURIComponent(remotePath)}`, + body, + 'application/octet-stream', + ); + } + + async downloadFile(id: string, remotePath: string, localPath: string): Promise { + const res = await this.raw( + 'GET', + `/v1/sandboxes/${encodeURIComponent(id)}/files?path=${encodeURIComponent(remotePath)}`, + ); + await writeFile(localPath, Buffer.from(await res.arrayBuffer())); + } + + private async request(method: string, path: string, body?: unknown): Promise { + const res = await this.raw( + method, + path, + body === undefined ? undefined : Buffer.from(JSON.stringify(body)), + body === undefined ? undefined : 'application/json', + ); + const text = await res.text(); + const parsed = text ? (JSON.parse(text) as JSend) : ({ status: 'success', data: undefined } as JSend); + if (parsed.status === 'success') return parsed.data; + if (parsed.status === 'error') { + throw new CreateOsApiError(res.status, parsed.message ?? text); + } + throw new CreateOsApiError(res.status, JSON.stringify(parsed.data ?? parsed)); + } + + private async raw( + method: string, + path: string, + body?: Buffer, + contentType?: string, + ): Promise { + const headers: Record = { + 'X-Api-Key': this.token, + 'User-Agent': 'agentbox-createos-provider', + }; + if (contentType) headers['Content-Type'] = contentType; + const res = await fetch(`${this.endpoint}${path}`, { method, headers, body }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new CreateOsApiError(res.status, text); + } + return res; + } +} + +export function makeCreateOsClient(): CreateOsClient { + return new CreateOsClient(); +} diff --git a/packages/sandbox-createos/src/createos-cli.ts b/packages/sandbox-createos/src/createos-cli.ts new file mode 100644 index 00000000..a3ad01fd --- /dev/null +++ b/packages/sandbox-createos/src/createos-cli.ts @@ -0,0 +1,29 @@ +import { spawnSync } from 'node:child_process'; + +export interface CreateosCliState { + installed: boolean; + bin?: string; + version?: string; +} + +let cached: CreateosCliState | null = null; + +export function detectCreateosCli(): CreateosCliState { + if (cached !== null) return cached; + const r = spawnSync('createos', ['version'], { encoding: 'utf8' }); + if (r.status === 0) { + cached = { + installed: true, + bin: 'createos', + version: (r.stdout ?? '').trim() || undefined, + }; + return cached; + } + cached = { installed: false }; + return cached; +} + +export function resetCreateosCliCache(): void { + cached = null; +} + diff --git a/packages/sandbox-createos/src/credentials.ts b/packages/sandbox-createos/src/credentials.ts new file mode 100644 index 00000000..44bbb22a --- /dev/null +++ b/packages/sandbox-createos/src/credentials.ts @@ -0,0 +1,113 @@ +import { homedir } from 'node:os'; +import { resolve } from 'node:path'; +import { hostOpenCommand, writeManagedSecrets, type CredSetResult } from '@agentbox/sandbox-core'; +import { + cancel, + confirm, + intro, + isCancel, + log, + note, + outro, + password, +} from '@clack/prompts'; +import { CreateOsClient } from './client.js'; +import { ensureCreateOsEnvLoaded, reloadCreateOsEnv } from './env-loader.js'; + +const DASHBOARD_KEYS_URL = 'https://createos.sh'; +const MANAGED_KEYS = ['CREATEOS_API_KEY'] as const; + +export interface EnsureCreateOsCredentialsOptions { + force?: boolean; +} + +export interface CreateOsCredStatus { + auth: 'key' | 'none'; + token?: string; + source: 'env' | 'secrets.env' | 'none'; +} + +function exitOnCancel(v: T | symbol): T { + if (isCancel(v)) { + cancel('Cancelled.'); + process.exit(130); + } + return v as T; +} + +export async function ensureCreateOsCredentials( + opts: EnsureCreateOsCredentialsOptions = {}, +): Promise { + ensureCreateOsEnvLoaded(); + if (!opts.force && readCreateOsCredStatus().auth !== 'none') return; + if (!process.stdin.isTTY) return; + + intro('CreateOS setup'); + note( + 'AgentBox needs a CreateOS API key to provision sandboxes. The key is stored in `~/.agentbox/secrets.env`.', + 'Credentials required', + ); + const openIt = exitOnCancel( + await confirm({ message: `Open ${DASHBOARD_KEYS_URL} to create a key?`, initialValue: true }), + ); + if (openIt) openDashboard(); + const key = exitOnCancel( + await password({ + message: 'Paste your CreateOS API key', + validate: (v) => (v.trim().length > 0 ? undefined : 'Cannot be empty'), + }), + ); + persistCredentials(key.trim()); + reloadCreateOsEnv(); + log.success(`CreateOS credentials saved to ${secretsPath()}`); + outro('Setup complete.'); +} + +export function setCreateOsCredentials(fields: Record): CredSetResult { + const apiKey = (fields.apiKey ?? '').trim(); + if (!apiKey) return { ok: false, error: 'apiKey is required', status: { configured: false } }; + persistCredentials(apiKey); + const cred = readCreateOsCredStatus(); + return { ok: true, status: { configured: cred.auth !== 'none', label: cred.auth } }; +} + +export async function validateCreateOsCredentials(): Promise { + try { + await new CreateOsClient().whoami(); + return true; + } catch { + return false; + } +} + +function persistCredentials(apiKey: string): void { + writeManagedSecrets(MANAGED_KEYS, { CREATEOS_API_KEY: apiKey }); +} + +function openDashboard(): void { + import('node:child_process') + .then(({ spawnSync }) => { + const r = spawnSync(hostOpenCommand(), [DASHBOARD_KEYS_URL], { stdio: 'ignore' }); + if (r.status !== 0) log.warn(`Could not auto-open the browser - visit ${DASHBOARD_KEYS_URL} manually.`); + }) + .catch(() => { + log.warn(`Could not auto-open the browser - visit ${DASHBOARD_KEYS_URL} manually.`); + }); +} + +export function readCreateOsCredStatus(): CreateOsCredStatus { + const shellHad = process.env.CREATEOS_API_KEY !== undefined; + ensureCreateOsEnvLoaded(); + const key = process.env.CREATEOS_API_KEY; + if (!key) return { auth: 'none', source: 'none' }; + return { auth: 'key', token: key, source: shellHad ? 'env' : 'secrets.env' }; +} + +export function secretsPath(): string { + return resolve(homedir(), '.agentbox', 'secrets.env'); +} + +export function maskKey(value: string): string { + if (value.length <= 8) return '*'.repeat(value.length); + return `${value.slice(0, 4)}...${'*'.repeat(8)}${value.slice(-4)}`; +} diff --git a/packages/sandbox-createos/src/env-loader.ts b/packages/sandbox-createos/src/env-loader.ts new file mode 100644 index 00000000..2cf23039 --- /dev/null +++ b/packages/sandbox-createos/src/env-loader.ts @@ -0,0 +1,41 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { resolve } from 'node:path'; + +let loaded = false; + +export function ensureCreateOsEnvLoaded(): void { + if (loaded) return; + loaded = true; + loadSecretsEnv(); +} + +export function reloadCreateOsEnv(): void { + loaded = false; + ensureCreateOsEnvLoaded(); +} + +function loadSecretsEnv(): void { + const file = resolve(homedir(), '.agentbox', 'secrets.env'); + if (!existsSync(file)) return; + const body = readFileSync(file, 'utf8'); + for (const raw of body.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + if (process.env[key] !== undefined) continue; + process.env[key] = unquoteEnvValue(line.slice(eq + 1).trim()); + } +} + +function unquoteEnvValue(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; +} diff --git a/packages/sandbox-createos/src/index.ts b/packages/sandbox-createos/src/index.ts new file mode 100644 index 00000000..37770d28 --- /dev/null +++ b/packages/sandbox-createos/src/index.ts @@ -0,0 +1,89 @@ +/** + * The CreateOS sandbox provider. It uses the shared cloud scaffold for + * provider-agnostic workspace seeding, ctl launch, state tracking, relay + * polling, and public preview URLs. + * + * One capability is overridden on top of the cloud scaffold: + * - `buildAttach` - CreateOS CLI managed PTY attach (CreateOS has no + * AgentBox-managed SSH transport). + * + * CreateOS currently differs from E2B/Daytona prepare: AgentBox cannot bake a + * reusable base image with local runtime files yet, so prepare validates + * credentials/runtime assets and provision installs the runtime into each + * fresh rootfs. + */ + +import type { Provider } from '@agentbox/core'; +import type { ProviderModule } from '@agentbox/sandbox-core'; +import { createCloudProvider } from '@agentbox/sandbox-cloud'; +import { + createosBackend, + CREATEOS_DEFAULT_BOX_IMAGE_REF, + CREATEOS_DEFAULT_RESOURCES, +} from './backend.js'; +import { ensureCreateOsCredentials, setCreateOsCredentials } from './credentials.js'; +import { doctorChecks, readCredStatusSummary } from './provider-module.js'; +import { prepareCreateos } from './prepare.js'; +import { buildCreateosAttach } from './build-attach.js'; + +const cloudProvider = createCloudProvider(createosBackend, { + defaultResources: { ...CREATEOS_DEFAULT_RESOURCES }, + launchDockerd: true, +}); + +export const createosProvider: Provider = { + ...cloudProvider, + prepare: prepareCreateos, + buildAttach: buildCreateosAttach, +}; + +export const providerModule: ProviderModule = { + provider: createosProvider, + backend: createosBackend, + ensureCredentials: ensureCreateOsCredentials, + readCredStatus: readCredStatusSummary, + setCredentials: (fields) => Promise.resolve(setCreateOsCredentials(fields)), + doctorChecks, +}; + +export { createosBackend, CREATEOS_DEFAULT_BOX_IMAGE_REF, CREATEOS_DEFAULT_RESOURCES }; +export { ensureCreateOsEnvLoaded, reloadCreateOsEnv } from './env-loader.js'; +export { + ensureCreateOsCredentials, + setCreateOsCredentials, + readCreateOsCredStatus, + secretsPath, + maskKey, + type CreateOsCredStatus, + type EnsureCreateOsCredentialsOptions, +} from './credentials.js'; +export { + CreateOsClient, + CreateOsApiError, + DEFAULT_CREATEOS_ENDPOINT, + makeCreateOsClient, + type CreateOsCreateSandboxRequest, + type CreateOsCreateSandboxResponse, + type CreateOsExecResponse, + type CreateOsSandboxView, +} from './client.js'; +export { + findStagedCliRuntimeRoot, + resolveRuntimeAssets, + RUNTIME_ASSETS, + candidatesFor, + type ResolvedAsset, + type RuntimeAsset, +} from './runtime-assets.js'; +export { + prepareCreateos, + type PrepareCreateosOptions, +} from './prepare.js'; +export { buildCreateosAttach, buildCreateosAttachArgv } from './build-attach.js'; +export { detectCreateosCli, resetCreateosCliCache, type CreateosCliState } from './createos-cli.js'; +export { + withCreateOsRetry, + isAttemptTimeout, + isRetriable, + type WithRetryOptions, +} from './retry.js'; diff --git a/packages/sandbox-createos/src/prepare.ts b/packages/sandbox-createos/src/prepare.ts new file mode 100644 index 00000000..303834e9 --- /dev/null +++ b/packages/sandbox-createos/src/prepare.ts @@ -0,0 +1,49 @@ +/** + * `agentbox prepare --provider createos`. + * + * CreateOS sandboxes boot from an API-selected rootfs/template. The public + * template build path is intentionally narrower than E2B's Dockerfile-backed + * build context and does not currently let AgentBox copy its local runtime + * assets into a reusable base image. Until that changes, prepare is a fast + * validation step and provision installs the same VPS runtime assets into each + * fresh sandbox before the shared cloud scaffold takes over. + */ + +import type { PrepareOptions, PrepareResult } from '@agentbox/core'; +import { + ensureCreateOsCredentials, + readCreateOsCredStatus, + validateCreateOsCredentials, +} from './credentials.js'; +import { detectCreateosCli } from './createos-cli.js'; +import { findStagedCliRuntimeRoot, resolveRuntimeAssets } from './runtime-assets.js'; + +export interface PrepareCreateosOptions extends PrepareOptions { + /** CLI runtime tree (set by the CLI to its dist neighbor). */ + cliRuntimeRoot?: string; + /** Repo root for the dev fallback (defaults to a cwd-walk). */ + repoRoot?: string; +} + +export async function prepareCreateos( + opts: PrepareCreateosOptions = {}, +): Promise { + await ensureCreateOsCredentials(); + if (readCreateOsCredStatus().auth === 'none') { + throw new Error('CreateOS credentials not configured. Run `agentbox createos login` or set CREATEOS_API_KEY.'); + } + if (!(await validateCreateOsCredentials())) { + throw new Error('CreateOS credentials could not be validated with `GET /v1/whoami`.'); + } + if (!detectCreateosCli().installed) { + throw new Error('CreateOS CLI not found on PATH. Install `createos` before preparing this provider.'); + } + resolveRuntimeAssets({ + cliRuntimeRoot: opts.cliRuntimeRoot ?? findStagedCliRuntimeRoot(), + repoRoot: opts.repoRoot, + }); + opts.onLog?.( + 'prepare-createos: credentials and runtime assets ok; runtime installs during each CreateOS sandbox provision', + ); + return { snapshotName: undefined }; +} diff --git a/packages/sandbox-createos/src/provider-module.ts b/packages/sandbox-createos/src/provider-module.ts new file mode 100644 index 00000000..6e354891 --- /dev/null +++ b/packages/sandbox-createos/src/provider-module.ts @@ -0,0 +1,43 @@ +import { errSummary, type CheckResult, type CredStatusSummary } from '@agentbox/sandbox-core'; +import { detectCreateosCli } from './createos-cli.js'; +import { readCreateOsCredStatus, validateCreateOsCredentials } from './credentials.js'; + +export function readCredStatusSummary(): CredStatusSummary { + const cred = readCreateOsCredStatus(); + return { configured: cred.auth !== 'none', label: cred.auth }; +} + +export async function doctorChecks(): Promise { + const cred = readCreateOsCredStatus(); + const cli = detectCreateosCli(); + const credRes: CheckResult = + cred.auth === 'none' + ? { + label: 'credentials', + status: 'warn', + detail: 'not configured', + hint: '`agentbox createos login`', + } + : { label: 'credentials', status: 'ok', detail: `${cred.auth} (${cred.source})` }; + const cliRes: CheckResult = cli.installed + ? { label: 'createos cli', status: 'ok', detail: cli.version ?? 'installed' } + : { + label: 'createos cli', + status: 'warn', + detail: 'not installed', + hint: 'install `createos` so AgentBox can attach to CreateOS boxes', + }; + if (cred.auth === 'none') return [credRes, cliRes]; + try { + const ok = await validateCreateOsCredentials(); + return [ + credRes, + cliRes, + ok + ? { label: 'api', status: 'ok', detail: 'whoami ok' } + : { label: 'api', status: 'warn', detail: 'whoami failed' }, + ]; + } catch (err) { + return [credRes, cliRes, { label: 'api', status: 'warn', detail: errSummary(err) }]; + } +} diff --git a/packages/sandbox-createos/src/retry.ts b/packages/sandbox-createos/src/retry.ts new file mode 100644 index 00000000..92e5e895 --- /dev/null +++ b/packages/sandbox-createos/src/retry.ts @@ -0,0 +1,118 @@ +/** + * Bounded retry wrapper for CreateOS API calls. Mirrors the Daytona/E2B + * provider helpers: lifecycle reads and idempotent operations can ride through + * transient 429/5xx/network failures, while non-idempotent provision opts out + * so a timeout cannot create a duplicate billable sandbox. + */ + +import { CreateOsApiError } from './client.js'; + +export interface WithRetryOptions { + method: string; + attemptTimeoutMs?: number; + backoffMs?: readonly number[]; + retryOnAmbiguous: boolean; + onRetry?: (line: string) => void; +} + +const DEFAULT_BACKOFF: readonly number[] = [1000, 2000, 4000]; +const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000; + +class AttemptTimeoutError extends Error { + constructor(method: string, ms: number) { + super(`createos ${method}: per-attempt timeout after ${String(ms)}ms`); + this.name = 'AttemptTimeoutError'; + } +} + +export function isAttemptTimeout(err: unknown): err is AttemptTimeoutError { + return err instanceof AttemptTimeoutError; +} + +export function isRetriable(err: unknown, allowAmbiguous: boolean): boolean { + if (err instanceof AttemptTimeoutError) return allowAmbiguous; + if (err instanceof CreateOsApiError) { + if (err.statusCode === 429) return true; + if (err.statusCode >= 500 && err.statusCode <= 599) return allowAmbiguous; + return false; + } + if (err && typeof err === 'object') { + const candidates: unknown[] = [err, (err as { cause?: unknown }).cause]; + for (const c of candidates) { + if (!c || typeof c !== 'object') continue; + const code = (c as { code?: unknown }).code; + if ( + code === 'ECONNRESET' || + code === 'ETIMEDOUT' || + code === 'ECONNABORTED' || + code === 'EAI_AGAIN' || + code === 'ECONNREFUSED' || + code === 'ENOTFOUND' || + code === 'UND_ERR_SOCKET' || + code === 'UND_ERR_CONNECT_TIMEOUT' + ) { + return allowAmbiguous; + } + } + } + return false; +} + +export async function withCreateOsRetry( + opts: WithRetryOptions, + fn: () => Promise, +): Promise { + const backoff = opts.backoffMs ?? DEFAULT_BACKOFF; + const maxAttempts = backoff.length + 1; + const timeoutMs = opts.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS; + const log = opts.onRetry ?? defaultRetryLog; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await raceTimeout(fn(), timeoutMs, opts.method); + } catch (err) { + const last = attempt === maxAttempts; + if (last || !isRetriable(err, opts.retryOnAmbiguous)) throw err; + const delay = backoff[attempt - 1] ?? backoff[backoff.length - 1] ?? 4000; + log( + `createos ${opts.method}: attempt ${String(attempt)} failed (${errorSummary(err)}); retrying in ${String(delay)}ms`, + ); + await sleep(delay); + } + } + throw new Error(`withCreateOsRetry: exhausted attempts for ${opts.method}`); +} + +function defaultRetryLog(line: string): void { + process.stderr.write(`\n[createos-retry] ${line}\n`); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function raceTimeout(p: Promise, ms: number, method: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + p, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new AttemptTimeoutError(method, ms)), ms); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +function errorSummary(err: unknown): string { + if (err instanceof CreateOsApiError) { + return `CreateOsApiError(${String(err.statusCode)}): ${truncate(err.body)}`; + } + if (err instanceof Error) return `${err.name}: ${truncate(err.message)}`; + return truncate(String(err)); +} + +function truncate(s: string, max = 160): string { + return s.length > max ? `${s.slice(0, max)}...` : s; +} diff --git a/packages/sandbox-createos/src/runtime-assets.ts b/packages/sandbox-createos/src/runtime-assets.ts new file mode 100644 index 00000000..38807195 --- /dev/null +++ b/packages/sandbox-createos/src/runtime-assets.ts @@ -0,0 +1,119 @@ +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SELF = dirname(fileURLToPath(import.meta.url)); + +export interface RuntimeAsset { + name: string; + remoteBasename: string; + remoteMode?: number; +} + +export interface ResolvedAsset extends RuntimeAsset { + localPath: string; +} + +export const RUNTIME_ASSETS: readonly RuntimeAsset[] = [ + { name: 'install-box.sh', remoteBasename: 'agentbox-install.sh', remoteMode: 0o755 }, + { name: 'agentbox-ctl', remoteBasename: 'agentbox-ctl', remoteMode: 0o755 }, + { name: 'agentbox-vnc-start', remoteBasename: 'agentbox-vnc-start', remoteMode: 0o755 }, + { name: 'agentbox-dockerd-start', remoteBasename: 'agentbox-dockerd-start', remoteMode: 0o755 }, + { name: 'agentbox-portless-trust', remoteBasename: 'agentbox-portless-trust', remoteMode: 0o755 }, + { name: 'agentbox-checkpoint-cleanup', remoteBasename: 'agentbox-checkpoint-cleanup', remoteMode: 0o755 }, + { name: 'agentbox-open', remoteBasename: 'agentbox-open', remoteMode: 0o755 }, + { name: 'gh-shim', remoteBasename: 'agentbox-gh-shim', remoteMode: 0o755 }, + { name: 'git-shim', remoteBasename: 'agentbox-git-shim', remoteMode: 0o755 }, + { name: 'ntn-shim', remoteBasename: 'agentbox-ntn-shim', remoteMode: 0o755 }, + { name: 'linear-shim', remoteBasename: 'agentbox-linear-shim', remoteMode: 0o755 }, + { name: 'custom-system-CLAUDE.md', remoteBasename: 'agentbox-custom-CLAUDE.md', remoteMode: 0o644 }, + { name: 'claude-managed-settings.json', remoteBasename: 'agentbox-managed-settings.json', remoteMode: 0o644 }, + { name: 'agentbox-codex-hooks.json', remoteBasename: 'agentbox-codex-hooks.json', remoteMode: 0o644 }, + { name: 'agentbox-setup-skill.md', remoteBasename: 'agentbox-setup-skill.md', remoteMode: 0o644 }, +] as const; + +export function findStagedCliRuntimeRoot(): string | undefined { + for (const c of [resolve(SELF, '..', 'runtime'), resolve(SELF, '..', '..', 'runtime')]) { + if (existsSync(resolve(c, 'createos', 'scripts', 'install-box.sh'))) return c; + } + return undefined; +} + +export function candidatesFor( + name: string, + opts: { cliRuntimeRoot?: string; repoRoot?: string } = {}, +): string[] { + const cliRoot = opts.cliRuntimeRoot; + const monorepo = opts.repoRoot ?? guessRepoRoot(); + const monorepoRelative: Record = { + 'install-box.sh': ['packages/sandbox-createos/scripts/install-box.sh'], + 'agentbox-ctl': ['packages/ctl/dist/bin.cjs'], + 'agentbox-vnc-start': ['packages/sandbox-docker/scripts/agentbox-vnc-start'], + 'agentbox-dockerd-start': ['packages/sandbox-docker/scripts/agentbox-dockerd-start'], + 'agentbox-portless-trust': ['packages/sandbox-docker/scripts/agentbox-portless-trust'], + 'agentbox-checkpoint-cleanup': ['packages/sandbox-docker/scripts/agentbox-checkpoint-cleanup'], + 'agentbox-open': ['packages/sandbox-docker/scripts/agentbox-open'], + 'gh-shim': ['packages/sandbox-docker/scripts/gh-shim'], + 'git-shim': ['packages/sandbox-docker/scripts/git-shim'], + 'ntn-shim': ['packages/sandbox-docker/scripts/ntn-shim'], + 'linear-shim': ['packages/sandbox-docker/scripts/linear-shim'], + 'custom-system-CLAUDE.md': ['packages/sandbox-createos/scripts/custom-system-CLAUDE.md'], + 'claude-managed-settings.json': ['packages/sandbox-docker/scripts/claude-managed-settings.json'], + 'agentbox-codex-hooks.json': ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], + 'agentbox-setup-skill.md': ['apps/cli/share/agentbox-setup/SKILL.md'], + }; + const cliRelative: Record = { + 'install-box.sh': ['createos/scripts/install-box.sh', 'hetzner/scripts/install-box.sh'], + 'agentbox-ctl': ['createos/ctl.cjs', 'hetzner/ctl.cjs'], + 'agentbox-vnc-start': ['createos/agentbox-vnc-start', 'docker/packages/sandbox-docker/scripts/agentbox-vnc-start'], + 'agentbox-dockerd-start': ['createos/agentbox-dockerd-start', 'docker/packages/sandbox-docker/scripts/agentbox-dockerd-start'], + 'agentbox-portless-trust': ['createos/agentbox-portless-trust', 'docker/packages/sandbox-docker/scripts/agentbox-portless-trust'], + 'agentbox-checkpoint-cleanup': ['createos/agentbox-checkpoint-cleanup', 'docker/packages/sandbox-docker/scripts/agentbox-checkpoint-cleanup'], + 'agentbox-open': ['createos/agentbox-open', 'docker/packages/sandbox-docker/scripts/agentbox-open'], + 'gh-shim': ['createos/gh-shim', 'docker/packages/sandbox-docker/scripts/gh-shim'], + 'git-shim': ['createos/git-shim', 'docker/packages/sandbox-docker/scripts/git-shim'], + 'ntn-shim': ['createos/ntn-shim', 'docker/packages/sandbox-docker/scripts/ntn-shim'], + 'linear-shim': ['createos/linear-shim', 'docker/packages/sandbox-docker/scripts/linear-shim'], + 'custom-system-CLAUDE.md': ['createos/custom-system-CLAUDE.md', 'hetzner/custom-system-CLAUDE.md'], + 'claude-managed-settings.json': ['createos/claude-managed-settings.json', 'docker/packages/sandbox-docker/scripts/claude-managed-settings.json'], + 'agentbox-codex-hooks.json': ['createos/agentbox-codex-hooks.json', 'docker/packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], + 'agentbox-setup-skill.md': ['createos/agentbox-setup-skill.md', 'docker/apps/cli/share/agentbox-setup/SKILL.md'], + }; + + const out: string[] = []; + if (cliRoot) for (const rel of cliRelative[name] ?? []) out.push(resolve(cliRoot, rel)); + for (const rel of monorepoRelative[name] ?? []) out.push(resolve(monorepo, rel)); + return out; +} + +export function resolveRuntimeAssets(opts: { + cliRuntimeRoot?: string; + repoRoot?: string; +} = {}): ResolvedAsset[] { + const out: ResolvedAsset[] = []; + const missing: Array<{ name: string; tried: string[] }> = []; + for (const asset of RUNTIME_ASSETS) { + const tried = candidatesFor(asset.name, opts); + const hit = tried.find((p) => existsSync(p)); + if (hit) out.push({ ...asset, localPath: hit }); + else missing.push({ name: asset.name, tried }); + } + if (missing.length > 0) { + const lines = missing.flatMap((m) => [` - ${m.name}: tried`, ...m.tried.map((p) => ` ${p}`)]); + throw new Error( + `createos: could not resolve runtime assets:\n${lines.join('\n')}\n\nRun \`pnpm -w build\` first so packages/ctl/dist/bin.cjs exists.`, + ); + } + return out; +} + +function guessRepoRoot(): string { + let cur = SELF; + for (let i = 0; i < 8; i++) { + if (existsSync(resolve(cur, 'pnpm-workspace.yaml'))) return cur; + const parent = dirname(cur); + if (parent === cur) break; + cur = parent; + } + return SELF; +} diff --git a/packages/sandbox-createos/test/build-attach.test.ts b/packages/sandbox-createos/test/build-attach.test.ts new file mode 100644 index 00000000..f6e8df48 --- /dev/null +++ b/packages/sandbox-createos/test/build-attach.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { buildCreateosAttachArgv } from '../src/build-attach.js'; + +describe('buildCreateosAttachArgv', () => { + it('uses CreateOS managed PTY for interactive agent attaches', () => { + expect( + buildCreateosAttachArgv({ + bin: 'createos', + sandboxId: 'sb_123', + kind: 'agent', + inner: 'exec tmux attach -t claude', + }), + ).toEqual([ + 'createos', + 'sandbox', + 'process', + 'run', + '--cwd', + '/workspace', + '--pty', + 'sb_123', + '--', + 'sudo', + '-u', + 'vscode', + '-H', + 'bash', + '-lc', + 'exec tmux attach -t claude', + ]); + }); + + it('does not allocate a PTY for detached pre-start commands', () => { + expect( + buildCreateosAttachArgv({ + bin: 'createos', + sandboxId: 'sb_123', + kind: 'agent', + inner: 'tmux new-session -d', + detached: true, + }), + ).not.toContain('--pty'); + }); +}); + diff --git a/packages/sandbox-createos/test/size.test.ts b/packages/sandbox-createos/test/size.test.ts new file mode 100644 index 00000000..cbb3e96a --- /dev/null +++ b/packages/sandbox-createos/test/size.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { parseCreateosSize } from '../src/backend.js'; + +describe('parseCreateosSize', () => { + it('uses the default CreateOS shape when unset', () => { + expect(parseCreateosSize(undefined)).toEqual({ + shape: 's-2vcpu-2gb', + resources: { cpu: 2, memory: 2, disk: 20 }, + }); + }); + + it('passes native shape slugs through unchanged', () => { + expect(parseCreateosSize('s-4vcpu-8gb')).toEqual({ shape: 's-4vcpu-8gb' }); + }); + + it('maps compact cpu-memory-disk specs to shape plus resource metadata', () => { + expect(parseCreateosSize('4-8-40')).toEqual({ + shape: 's-4vcpu-8gb', + resources: { cpu: 4, memory: 8, disk: 40 }, + }); + }); + + it('defaults the disk slot for compact cpu-memory specs', () => { + expect(parseCreateosSize('2-4')).toEqual({ + shape: 's-2vcpu-4gb', + resources: { cpu: 2, memory: 4, disk: 20 }, + }); + }); +}); + diff --git a/packages/sandbox-createos/tsconfig.json b/packages/sandbox-createos/tsconfig.json new file mode 100644 index 00000000..61408243 --- /dev/null +++ b/packages/sandbox-createos/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/sandbox-createos/tsup.config.ts b/packages/sandbox-createos/tsup.config.ts new file mode 100644 index 00000000..c832e8ae --- /dev/null +++ b/packages/sandbox-createos/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts', 'src/cli.ts'], + format: ['esm'], + target: 'node20', + clean: true, + dts: true, + sourcemap: true, + external: ['commander', '@clack/prompts'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3d86170..22257516 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@agentbox/sandbox-core': specifier: workspace:* version: link:../../packages/sandbox-core + '@agentbox/sandbox-createos': + specifier: workspace:* + version: link:../../packages/sandbox-createos '@agentbox/sandbox-daytona': specifier: workspace:* version: link:../../packages/sandbox-daytona @@ -526,6 +529,37 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.19)(lightningcss@1.32.0) + packages/sandbox-createos: + dependencies: + '@agentbox/core': + specifier: workspace:* + version: link:../core + '@agentbox/sandbox-cloud': + specifier: workspace:* + version: link:../sandbox-cloud + '@agentbox/sandbox-core': + specifier: workspace:* + version: link:../sandbox-core + '@clack/prompts': + specifier: ^0.9.0 + version: 0.9.1 + commander: + specifier: ^12.1.0 + version: 12.1.0 + devDependencies: + '@types/node': + specifier: ^22.10.1 + version: 22.19.19 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.22.3)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.19)(lightningcss@1.32.0) + packages/sandbox-daytona: dependencies: '@agentbox/config':