diff --git a/apps/cli/package.json b/apps/cli/package.json index e2480cb7..efdf5031 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -78,6 +78,7 @@ "@agentbox/sandbox-docker": "workspace:*", "@agentbox/sandbox-e2b": "workspace:*", "@agentbox/sandbox-hetzner": "workspace:*", + "@agentbox/sandbox-islo": "workspace:*", "@agentbox/sandbox-vercel": "workspace:*", "@types/node": "^22.10.1", "tsup": "^8.3.5", diff --git a/apps/cli/src/commands/checkpoint.ts b/apps/cli/src/commands/checkpoint.ts index d8558975..403a476a 100644 --- a/apps/cli/src/commands/checkpoint.ts +++ b/apps/cli/src/commands/checkpoint.ts @@ -35,7 +35,7 @@ import { providerForBox } from '../provider/registry.js'; import { handleLifecycleError } from './_errors.js'; /** Cloud backends that store snapshots under ~/.agentbox/cloud-checkpoints//. */ -const CLOUD_BACKENDS = ['daytona', 'hetzner', 'vercel', 'e2b'] as const; +const CLOUD_BACKENDS = ['daytona', 'hetzner', 'vercel', 'e2b', 'islo'] as const; type CloudBackend = (typeof CLOUD_BACKENDS)[number]; /** Lazily resolve a cloud provider's checkpoint capability (dynamic import keeps SDKs out of the hot path). */ @@ -49,6 +49,8 @@ async function cloudProviderFor(backend: CloudBackend): Promise', - 'set the default for only this provider (docker|daytona|hetzner|vercel); without it, sets the cross-provider fallback', + 'set the default for only this provider (docker|daytona|hetzner|vercel|e2b|islo); without it, sets the cross-provider fallback', ) .action(async (ref: string | undefined, opts: { clear?: boolean; provider?: string }) => { try { @@ -421,6 +423,7 @@ const rmSub = new Command('rm') ['box.defaultCheckpointHetzner', projectBox?.defaultCheckpointHetzner, cfg.effective.box.defaultCheckpointHetzner], ['box.defaultCheckpointVercel', projectBox?.defaultCheckpointVercel, cfg.effective.box.defaultCheckpointVercel], ['box.defaultCheckpointE2b', projectBox?.defaultCheckpointE2b, cfg.effective.box.defaultCheckpointE2b], + ['box.defaultCheckpointIslo', projectBox?.defaultCheckpointIslo, cfg.effective.box.defaultCheckpointIslo], ] as const; for (const [key, projectValue, effectiveValue] of defKeys) { if (projectValue === ref) { diff --git a/apps/cli/src/commands/create.ts b/apps/cli/src/commands/create.ts index 6233b0a3..8d01f76c 100644 --- a/apps/cli/src/commands/create.ts +++ b/apps/cli/src/commands/create.ts @@ -142,7 +142,10 @@ export const createCommand = new Command('create') ) .option('-w, --workspace ', 'host workspace to mount', process.cwd()) .option('-n, --name ', 'friendly box name (default: -)') - .option('--provider ', "sandbox backend: 'docker' (default) or 'daytona' (cloud)") + .option( + '--provider ', + 'sandbox backend: docker (default), daytona, hetzner, vercel, e2b, or islo', + ) .option( '--host-snapshot', 'APFS-clone the host workspace into a per-box scratch dir before seeding /workspace (stabilizes the tar-pipe source)', @@ -186,11 +189,11 @@ export const createCommand = new Command('create') ) .option( '--size ', - 'VM size for cloud providers. Hetzner: server type (e.g. cx33). Daytona: cpu-mem-disk GB (e.g. 4-8-20). Overrides box.size / box.size.', + 'VM size for cloud providers. Hetzner: server type (e.g. cx33). Daytona/Islo: cpu-mem-disk GB (e.g. 4-8-20). Overrides box.size / box.size.', ) .option( '--bundle-depth ', - 'cap commits shipped in the cloud-seed git bundle (daytona, hetzner). 0 = full history. Unset = adaptive (200 commits, re-bundle at 100 if >20 MB). Ignored for docker.', + 'cap commits shipped in the cloud-seed git bundle (cloud providers). 0 = full history. Unset = adaptive (200 commits, re-bundle at 100 if >20 MB). Ignored for docker.', (v) => { const n = Number.parseInt(v, 10); if (!Number.isInteger(n) || n < 0) @@ -233,7 +236,7 @@ export const createCommand = new Command('create') opts, resolveDefaultCheckpoint( cfg.effective, - providerName as 'docker' | 'daytona' | 'hetzner' | 'vercel', + providerName, ), ); // VM size: `--size` flag wins; otherwise the cascaded box.size / @@ -241,7 +244,7 @@ export const createCommand = new Command('create') // override beats a project-level per-provider key. const sizeDefault = resolveBoxSize( cfg.effective, - providerName as 'docker' | 'daytona' | 'hetzner' | 'vercel', + providerName, ); const effectiveSize = opts.size && opts.size.length > 0 ? opts.size : sizeDefault; // Box image: same precedence pattern as --size. `--image` wins; otherwise @@ -249,7 +252,7 @@ export const createCommand = new Command('create') // prepare --provider X`). const imageDefault = resolveBoxImage( cfg.effective, - providerName as 'docker' | 'daytona' | 'hetzner' | 'vercel', + providerName, ); const effectiveImage = opts.image && opts.image.length > 0 ? opts.image : imageDefault; @@ -381,7 +384,7 @@ export const createCommand = new Command('create') cfg.effective.box.withPlaywright || cfg.effective.browser.default !== 'agent-browser'; // --provider flag wins over box.provider config. The registry hands back // a DockerProvider for 'docker' and (once Phase 5 wires it) a cloud - // provider for 'daytona'; everything below is provider-neutral. + // provider for cloud backends; everything below is provider-neutral. const provider = await providerForCreate({ flag: opts.provider, config: cfg.effective }); let fromBranch: string | undefined; let useBranch: string | undefined; @@ -443,6 +446,11 @@ export const createCommand = new Command('create') networkPolicy: cfg.effective.box.vercelNetworkPolicy, } : {}), + ...(provider.name === 'islo' + ? { + networkPolicy: cfg.effective.box.isloGatewayProfile, + } + : {}), }, }); s.stop(`box ${result.record.container} ready`); diff --git a/apps/cli/src/commands/install.ts b/apps/cli/src/commands/install.ts index 747288ea..4972a1ee 100644 --- a/apps/cli/src/commands/install.ts +++ b/apps/cli/src/commands/install.ts @@ -337,6 +337,7 @@ const PROVIDER_HINTS: Record = { daytona: 'approve a browser sign-in link', vercel: 'installs the Vercel sandbox CLI, then a browser sign-in', e2b: 'paste an API key from the E2B dashboard', + islo: 'paste an Islo API key; run `islo ssh --setup` for interactive attach', }; const PROVIDER_LABEL: Record = { @@ -345,6 +346,7 @@ const PROVIDER_LABEL: Record = { daytona: 'Daytona (cloud sandbox)', vercel: 'Vercel (cloud microVM)', e2b: 'E2B (cloud microVM)', + islo: 'Islo (agent computer)', }; function ensureTty(): boolean { diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts index e7e90e45..8457e500 100644 --- a/apps/cli/src/help.ts +++ b/apps/cli/src/help.ts @@ -37,6 +37,7 @@ export const HELP_GROUPS: HelpGroup[] = [ 'hetzner', 'vercel', 'e2b', + 'islo', ], }, ]; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index f772d1bf..1d99a9a9 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -32,6 +32,7 @@ import { dockerCommand } from './commands/docker.js'; import { hetznerCommand } from '@agentbox/sandbox-hetzner/cli'; import { vercelCommand } from '@agentbox/sandbox-vercel/cli'; import { e2bCommand } from '@agentbox/sandbox-e2b/cli'; +import { isloCommand } from '@agentbox/sandbox-islo/cli'; import { destroyCommand } from './commands/destroy.js'; import { downloadCommand } from './commands/download.js'; import { driveCommand } from './commands/drive.js'; @@ -114,6 +115,7 @@ program.addCommand(daytonaCommand); program.addCommand(hetznerCommand); program.addCommand(vercelCommand); program.addCommand(e2bCommand); +program.addCommand(isloCommand); program.addCommand(dockerCommand); program.addCommand(updateCommand); program.addCommand(installCommand); diff --git a/apps/cli/src/lib/doctor-checks.ts b/apps/cli/src/lib/doctor-checks.ts index e367f7e1..1f40457e 100644 --- a/apps/cli/src/lib/doctor-checks.ts +++ b/apps/cli/src/lib/doctor-checks.ts @@ -29,14 +29,14 @@ export interface CheckResult { } export interface CheckGroup { - /** Group title: 'system' | 'docker' | 'daytona' | 'hetzner' | 'vercel' | 'e2b'. */ + /** Group title: 'system' | 'docker' | 'daytona' | 'hetzner' | 'vercel' | 'e2b' | 'islo'. */ title: string; results: CheckResult[]; } -export type ProviderName = 'docker' | 'daytona' | 'hetzner' | 'vercel' | 'e2b'; +export type ProviderName = 'docker' | 'daytona' | 'hetzner' | 'vercel' | 'e2b' | 'islo'; -const ALL_PROVIDERS: ProviderName[] = ['docker', 'daytona', 'hetzner', 'vercel', 'e2b']; +const ALL_PROVIDERS: ProviderName[] = ['docker', 'daytona', 'hetzner', 'vercel', 'e2b', 'islo']; const NODE_MIN_MAJOR = 20; const NODE_MIN_MINOR = 10; @@ -381,6 +381,48 @@ async function e2bChecks(): Promise { } } +async function isloChecks(): Promise { + try { + const mod = await import('@agentbox/sandbox-islo'); + const cred = mod.readIsloCredStatus(); + const credRes: CheckResult = + cred.auth === 'none' + ? { + label: 'credentials', + status: 'warn', + detail: 'API key not configured', + hint: '`agentbox islo login` or set ISLO_API_KEY', + } + : { + label: 'credentials', + status: 'ok', + detail: `${cred.auth} (${cred.source})`, + }; + return [ + credRes, + { + label: 'base url', + status: 'ok', + detail: cred.baseUrl, + }, + { + label: 'ssh attach', + status: 'info', + detail: 'requires Islo SSH setup', + hint: '`islo ssh --setup`', + }, + ]; + } catch (err) { + return [ + { + label: 'credentials', + status: 'warn', + detail: errSummary(err), + }, + ]; + } +} + /** * Probe a binary, treating ENOENT (missing on PATH) as a distinct outcome * from a non-zero exit. `execa({reject:false})` returns a result envelope @@ -554,6 +596,9 @@ export async function runProviderChecks(name: ProviderName): Promise case 'e2b': results = await e2bChecks(); break; + case 'islo': + results = await isloChecks(); + break; } return { title: name, results }; } diff --git a/apps/cli/src/provider/argv-prefix.ts b/apps/cli/src/provider/argv-prefix.ts index 350dd4af..387d72a2 100644 --- a/apps/cli/src/provider/argv-prefix.ts +++ b/apps/cli/src/provider/argv-prefix.ts @@ -2,7 +2,7 @@ * Provider-prefix argv sugar: * * agentbox [...rest] - * where provider ∈ {docker, daytona, hetzner, vercel} + * where provider ∈ {docker, daytona, hetzner, vercel, e2b, islo} * and subcmd ∈ SUGARED_COMMANDS * * ↓ rewritten before commander parses diff --git a/apps/cli/src/provider/cloud-backend.ts b/apps/cli/src/provider/cloud-backend.ts index f416bff0..17a4de52 100644 --- a/apps/cli/src/provider/cloud-backend.ts +++ b/apps/cli/src/provider/cloud-backend.ts @@ -17,6 +17,8 @@ export async function cloudBackendForProvider( return (await import('@agentbox/sandbox-vercel')).vercelBackend; case 'e2b': return (await import('@agentbox/sandbox-e2b')).e2bBackend; + case 'islo': + return (await import('@agentbox/sandbox-islo')).isloBackend; default: return null; } @@ -44,6 +46,8 @@ export async function currentCloudBaseFingerprintLive( return (await import('@agentbox/sandbox-vercel')).currentVercelBaseFingerprintLive(); case 'e2b': return (await import('@agentbox/sandbox-e2b')).currentE2bBaseFingerprintLive(); + case 'islo': + return undefined; default: return undefined; } diff --git a/apps/cli/src/provider/registry.ts b/apps/cli/src/provider/registry.ts index 44a4cb29..e7e839de 100644 --- a/apps/cli/src/provider/registry.ts +++ b/apps/cli/src/provider/registry.ts @@ -8,9 +8,9 @@ import type { EffectiveConfig } from '@agentbox/config'; import type { BoxRecord, Provider, ProviderName } from '@agentbox/core'; -export type KnownProviderName = 'docker' | 'daytona' | 'hetzner' | 'vercel' | 'e2b'; +export type KnownProviderName = 'docker' | 'daytona' | 'hetzner' | 'vercel' | 'e2b' | 'islo'; -const KNOWN: readonly KnownProviderName[] = ['docker', 'daytona', 'hetzner', 'vercel', 'e2b']; +const KNOWN: readonly KnownProviderName[] = ['docker', 'daytona', 'hetzner', 'vercel', 'e2b', 'islo']; export function isKnownProvider(name: string): name is KnownProviderName { return (KNOWN as readonly string[]).includes(name); @@ -63,6 +63,11 @@ export async function getProvider(name: ProviderName): Promise { await mod.ensureE2bCredentials(); return mod.e2bProvider; } + case 'islo': { + const mod = await import('@agentbox/sandbox-islo'); + await mod.ensureIsloCredentials(); + return mod.isloProvider; + } default: throw new Error(`unknown sandbox provider: ${String(name)}`); } diff --git a/apps/cli/src/types/node-pty-prebuilt-multiarch.d.ts b/apps/cli/src/types/node-pty-prebuilt-multiarch.d.ts new file mode 100644 index 00000000..8f57f431 --- /dev/null +++ b/apps/cli/src/types/node-pty-prebuilt-multiarch.d.ts @@ -0,0 +1,13 @@ +declare module '@homebridge/node-pty-prebuilt-multiarch' { + export function spawn( + file: string, + args: string[], + opts: { name: string; cols: number; rows: number; env: NodeJS.ProcessEnv }, + ): { + onData(cb: (data: string) => void): void; + onExit(cb: (event: { exitCode: number }) => void): void; + write(data: string): void; + resize(cols: number, rows: number): void; + kill(): void; + }; +} diff --git a/apps/cli/test/help.test.ts b/apps/cli/test/help.test.ts index 52419e92..f5655499 100644 --- a/apps/cli/test/help.test.ts +++ b/apps/cli/test/help.test.ts @@ -17,6 +17,7 @@ import { dockerCommand } from '../src/commands/docker.js'; import { hetznerCommand } from '@agentbox/sandbox-hetzner/cli'; import { vercelCommand } from '@agentbox/sandbox-vercel/cli'; import { e2bCommand } from '@agentbox/sandbox-e2b/cli'; +import { isloCommand } from '@agentbox/sandbox-islo/cli'; import { destroyCommand } from '../src/commands/destroy.js'; import { doctorCommand } from '../src/commands/doctor.js'; import { downloadCommand } from '../src/commands/download.js'; @@ -87,6 +88,7 @@ function buildProgram(): Command { dockerCommand, vercelCommand, e2bCommand, + isloCommand, gitCommand, doctorCommand, updateCommand, diff --git a/docs/islo-and-crabbox.md b/docs/islo-and-crabbox.md new file mode 100644 index 00000000..778e14e2 --- /dev/null +++ b/docs/islo-and-crabbox.md @@ -0,0 +1,48 @@ +# Islo and Crabbox + +AgentBox treats Islo as an experimental native cloud provider and Crabbox as a companion tool. + +## Islo Provider + +Use Islo when you want AgentBox boxes to run on persistent Islo agent computers: + +```sh +agentbox islo login +agentbox islo create +agentbox islo claude +``` + +The provider uses the Islo HTTP API for lifecycle, exec, shares, and snapshots. It maps the local-only `agentbox/box:dev` image sentinel to the published AgentBox image: + +```text +ghcr.io/madarco/agentbox/box:latest +``` + +For interactive attach commands such as `agentbox islo shell` or `agentbox islo claude`, run Islo's SSH setup once: + +```sh +islo ssh --setup +``` + +Optional config: + +```yaml +box: + provider: islo + sizeIslo: 2-4-20 + isloGatewayProfile: production-apis +``` + +`sizeIslo` uses `cpu-memory-disk` in GB, with memory converted to MB for Islo. + +## Crabbox Companion + +Crabbox is not an AgentBox provider. It owns a different workflow: lease remote capacity, sync a dirty checkout, run a command, stream output, collect evidence, and release the target. + +That makes Crabbox useful beside AgentBox for remote test/proof runs: + +```sh +crabbox run -- pnpm test +``` + +Do not route AgentBox boxes through Crabbox. AgentBox providers need long-lived box lifecycle, attach, screen/code/browser URLs, checkpoints, workspace seeding, and host relay behavior; Crabbox's Islo path is delegated command execution and does not provide that full box surface. diff --git a/packages/config/schema/user-config.schema.json b/packages/config/schema/user-config.schema.json index 7e7d3b37..3589a383 100644 --- a/packages/config/schema/user-config.schema.json +++ b/packages/config/schema/user-config.schema.json @@ -17,12 +17,14 @@ "defaultCheckpointHetzner": { "type": "string", "minLength": 1 }, "defaultCheckpointVercel": { "type": "string", "minLength": 1 }, "defaultCheckpointE2b": { "type": "string", "minLength": 1 }, + "defaultCheckpointIslo": { "type": "string", "minLength": 1 }, "size": { "type": "string", "minLength": 1 }, "sizeDocker": { "type": "string", "minLength": 1 }, "sizeDaytona": { "type": "string", "minLength": 1 }, "sizeHetzner": { "type": "string", "minLength": 1 }, "sizeVercel": { "type": "string", "minLength": 1 }, "sizeE2b": { "type": "string", "minLength": 1 }, + "sizeIslo": { "type": "string", "minLength": 1 }, "withPlaywright": { "type": "boolean" }, "withEnv": { "type": "boolean" }, "resyncOnStart": { "type": "boolean" }, @@ -37,6 +39,7 @@ "imageHetzner": { "type": "string", "minLength": 1 }, "imageVercel": { "type": "string", "minLength": 1 }, "imageE2b": { "type": "string", "minLength": 1 }, + "imageIslo": { "type": "string", "minLength": 1 }, "dockerCacheShared": { "type": "boolean" }, "memory": { "type": "integer", "minimum": 0 }, "cpus": { "type": "integer", "minimum": 0 }, @@ -46,6 +49,7 @@ "vercelVcpus": { "type": "integer", "minimum": 1 }, "vercelTimeoutMs": { "type": "integer", "minimum": 1 }, "vercelNetworkPolicy": { "type": "string" }, + "isloGatewayProfile": { "type": "string" }, "cpMaxBytes": { "type": "integer", "minimum": 1 } } }, diff --git a/packages/config/src/checkpoint.ts b/packages/config/src/checkpoint.ts index 856a52e9..67a4c0d2 100644 --- a/packages/config/src/checkpoint.ts +++ b/packages/config/src/checkpoint.ts @@ -4,7 +4,7 @@ * Precedence (highest wins): * 1. `box.defaultCheckpoint` — per-provider override * (`defaultCheckpointDocker` / `defaultCheckpointDaytona` / - * `defaultCheckpointHetzner`). + * `defaultCheckpointHetzner` / ...). * 2. `box.defaultCheckpoint` — global fallback (back-compat shape: every * pre-cloud config has this; no flag was needed). * 3. '' — no default. @@ -30,7 +30,9 @@ export function resolveDefaultCheckpoint( ? cfg.box.defaultCheckpointVercel : provider === 'e2b' ? cfg.box.defaultCheckpointE2b - : cfg.box.defaultCheckpointDocker; + : provider === 'islo' + ? cfg.box.defaultCheckpointIslo + : cfg.box.defaultCheckpointDocker; if (perProvider && perProvider.length > 0) return perProvider; return cfg.box.defaultCheckpoint; } @@ -48,11 +50,13 @@ export function defaultCheckpointConfigKey( | 'box.defaultCheckpointDaytona' | 'box.defaultCheckpointHetzner' | 'box.defaultCheckpointVercel' - | 'box.defaultCheckpointE2b' { + | 'box.defaultCheckpointE2b' + | 'box.defaultCheckpointIslo' { if (provider === 'docker') return 'box.defaultCheckpointDocker'; if (provider === 'daytona') return 'box.defaultCheckpointDaytona'; if (provider === 'hetzner') return 'box.defaultCheckpointHetzner'; if (provider === 'vercel') return 'box.defaultCheckpointVercel'; if (provider === 'e2b') return 'box.defaultCheckpointE2b'; + if (provider === 'islo') return 'box.defaultCheckpointIslo'; return 'box.defaultCheckpoint'; } diff --git a/packages/config/src/image.ts b/packages/config/src/image.ts index ea10469a..73f8ef70 100644 --- a/packages/config/src/image.ts +++ b/packages/config/src/image.ts @@ -3,7 +3,7 @@ * * Precedence (highest wins): * 1. `box.image` — per-provider override - * (`imageDocker` / `imageDaytona` / `imageHetzner` / `imageVercel`). + * (`imageDocker` / `imageDaytona` / `imageHetzner` / `imageVercel` / ...). * 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"). @@ -26,7 +26,9 @@ export function resolveBoxImage(cfg: EffectiveConfig, provider: ProviderKind | s ? cfg.box.imageVercel : provider === 'e2b' ? cfg.box.imageE2b - : cfg.box.imageDocker; + : provider === 'islo' + ? cfg.box.imageIslo + : cfg.box.imageDocker; if (perProvider && perProvider.length > 0) return perProvider; return cfg.box.image; } @@ -45,11 +47,13 @@ export function boxImageConfigKey( | 'box.imageDaytona' | 'box.imageHetzner' | 'box.imageVercel' - | 'box.imageE2b' { + | 'box.imageE2b' + | 'box.imageIslo' { if (provider === 'docker') return 'box.imageDocker'; if (provider === 'daytona') return 'box.imageDaytona'; if (provider === 'hetzner') return 'box.imageHetzner'; if (provider === 'vercel') return 'box.imageVercel'; if (provider === 'e2b') return 'box.imageE2b'; + if (provider === 'islo') return 'box.imageIslo'; return 'box.image'; } diff --git a/packages/config/src/size.ts b/packages/config/src/size.ts index 9a3fcc49..3b31a426 100644 --- a/packages/config/src/size.ts +++ b/packages/config/src/size.ts @@ -3,13 +3,14 @@ * * Precedence (highest wins): * 1. `box.size` — per-provider override - * (`sizeDocker` / `sizeDaytona` / `sizeHetzner` / `sizeVercel`). + * (`sizeDocker` / `sizeDaytona` / `sizeHetzner` / `sizeVercel` / ...). * 2. `box.size` — generic fallback. * 3. '' — no preference; backend uses its built-in default. * * Interpretation is provider-specific: * - hetzner: server type string (e.g. `cx33`). * - daytona: `cpu-memory-disk` GB spec (e.g. `4-8-20`). + * - islo: `cpu-memory-disk` GB spec (e.g. `2-4-20`). * - docker / vercel: reserved (docker uses memory/cpus/disk; vercel uses * vercelVcpus). The keys exist for surface uniformity. * @@ -30,7 +31,9 @@ export function resolveBoxSize(cfg: EffectiveConfig, provider: ProviderKind | st ? cfg.box.sizeVercel : provider === 'e2b' ? cfg.box.sizeE2b - : cfg.box.sizeDocker; + : provider === 'islo' + ? cfg.box.sizeIslo + : cfg.box.sizeDocker; if (perProvider && perProvider.length > 0) return perProvider; return cfg.box.size; } diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index b5659102..86078318 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -12,7 +12,7 @@ export type IdeFlavor = 'vscode' | 'cursor' | 'auto'; export type EngineKind = 'orbstack' | 'docker-desktop' | 'other' | 'auto'; export type BrowserKind = 'agent-browser' | 'playwright' | 'both'; /** Sandbox backend new boxes are created on. */ -export type ProviderKind = 'docker' | 'daytona' | 'hetzner' | 'vercel' | 'e2b'; +export type ProviderKind = 'docker' | 'daytona' | 'hetzner' | 'vercel' | 'e2b' | 'islo'; /** Where `agentbox claude|codex|opencode` opens the attached session when the host * shell is running inside tmux, cmux, or iTerm2. `same` keeps today's inline behavior. */ export type AttachOpenIn = 'split' | 'window' | 'tab' | 'same'; @@ -41,6 +41,7 @@ export interface UserConfig { defaultCheckpointHetzner?: string; defaultCheckpointVercel?: string; defaultCheckpointE2b?: string; + defaultCheckpointIslo?: string; /** * Generic VM-size fallback for cloud providers. Provider-interpreted: * Hetzner = server type string (e.g. `cx33`); Daytona = `cpu-memory-disk` @@ -54,6 +55,7 @@ export interface UserConfig { sizeHetzner?: string; sizeVercel?: string; sizeE2b?: string; + sizeIslo?: string; withPlaywright?: boolean; withEnv?: boolean; resyncOnStart?: boolean; @@ -73,6 +75,7 @@ export interface UserConfig { imageHetzner?: string; imageVercel?: string; imageE2b?: string; + imageIslo?: string; imageRegistry?: string; dockerCacheShared?: boolean; memory?: number; @@ -83,6 +86,7 @@ export interface UserConfig { vercelVcpus?: number; vercelTimeoutMs?: number; vercelNetworkPolicy?: string; + isloGatewayProfile?: string; cpMaxBytes?: number; }; checkpoint?: { @@ -177,12 +181,14 @@ export interface EffectiveConfig { defaultCheckpointHetzner: string; defaultCheckpointVercel: string; defaultCheckpointE2b: string; + defaultCheckpointIslo: string; size: string; sizeDocker: string; sizeDaytona: string; sizeHetzner: string; sizeVercel: string; sizeE2b: string; + sizeIslo: string; withPlaywright: boolean; withEnv: boolean; resyncOnStart: boolean; @@ -197,6 +203,7 @@ export interface EffectiveConfig { imageHetzner: string; imageVercel: string; imageE2b: string; + imageIslo: string; imageRegistry: string; dockerCacheShared: boolean; memory: number; @@ -207,6 +214,7 @@ export interface EffectiveConfig { vercelVcpus: number; vercelTimeoutMs: number; vercelNetworkPolicy: string; + isloGatewayProfile: string; cpMaxBytes: number; }; checkpoint: { @@ -320,12 +328,14 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { defaultCheckpointHetzner: '', defaultCheckpointVercel: '', defaultCheckpointE2b: '', + defaultCheckpointIslo: '', size: '', sizeDocker: '', sizeDaytona: '', sizeHetzner: '', sizeVercel: '', sizeE2b: '', + sizeIslo: '', withPlaywright: false, withEnv: false, resyncOnStart: true, @@ -340,6 +350,7 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { imageHetzner: '', imageVercel: '', imageE2b: '', + imageIslo: '', // Mirrors BOX_IMAGE_REGISTRY in @agentbox/sandbox-docker. Empty disables the // registry pull (always build the docker base image locally). imageRegistry: 'ghcr.io/madarco/agentbox/box', @@ -352,6 +363,7 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { vercelVcpus: 2, vercelTimeoutMs: 2_700_000, vercelNetworkPolicy: '', + isloGatewayProfile: '', cpMaxBytes: 100 * 1024 * 1024, }, checkpoint: { @@ -446,9 +458,9 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ { key: 'box.provider', type: 'enum', - enumValues: ['docker', 'daytona', 'hetzner', 'vercel', 'e2b'] as const, + enumValues: ['docker', 'daytona', 'hetzner', 'vercel', 'e2b', 'islo'] as const, description: - 'Sandbox backend new boxes are created on: local Docker containers, Daytona Cloud sandboxes, Hetzner Cloud VPSes, Vercel Sandboxes, or E2B microVMs.', + 'Sandbox backend new boxes are created on: local Docker containers, Daytona Cloud sandboxes, Hetzner Cloud VPSes, Vercel Sandboxes, E2B microVMs, or Islo agent computers.', }, { key: 'box.hostSnapshot', @@ -497,6 +509,13 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ 'Per-provider override of `box.defaultCheckpoint` for e2b. Wins over the global when set; set via `agentbox checkpoint set-default --provider e2b`.', advanced: true, }, + { + key: 'box.defaultCheckpointIslo', + type: 'string', + description: + 'Per-provider override of `box.defaultCheckpoint` for islo. Wins over the global when set; set via `agentbox checkpoint set-default --provider islo`.', + advanced: true, + }, { key: 'box.size', type: 'string', @@ -538,6 +557,13 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ 'Per-provider override of `box.size` for e2b. Reserved — e2b sizing is template-level (set at `agentbox prepare --provider e2b` time via --vcpus / --memory).', advanced: true, }, + { + key: 'box.sizeIslo', + type: 'string', + description: + 'Per-provider override of `box.size` for islo. Uses `cpu-memory-disk` GB spec (e.g. `2-4-20`); memory is converted to MB for the Islo API.', + advanced: true, + }, { key: 'checkpoint.maxLayers', type: 'int', @@ -624,6 +650,13 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ description: 'Per-provider override of `box.image` for e2b (template id or `name:tag`, e.g. `agentbox-base:latest`). Written by `agentbox prepare --provider e2b`.', advanced: true, }, + { + key: 'box.imageIslo', + type: 'string', + description: + 'Per-provider override of `box.image` for islo (container image ref). Empty/default uses the published AgentBox image `ghcr.io/madarco/agentbox/box:latest`.', + advanced: true, + }, { key: 'box.imageRegistry', type: 'string', @@ -692,6 +725,12 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ description: "Egress lock for new --provider vercel boxes: 'allow-all' (default, unset), 'deny-all', or a comma-separated domain allowlist (e.g. 'github.com,*.npmjs.org') that denies everything else. Vercel-only; ignored by other providers.", }, + { + key: 'box.isloGatewayProfile', + type: 'string', + description: + 'Gateway profile name for new --provider islo boxes. Use this for Islo network policy and credential injection; empty uses the Islo project/default profile.', + }, { key: 'claude.sessionName', type: 'string', diff --git a/packages/relay/vitest.config.ts b/packages/relay/vitest.config.ts new file mode 100644 index 00000000..f3b97679 --- /dev/null +++ b/packages/relay/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + fileParallelism: false, + }, +}); diff --git a/packages/sandbox-core/src/prepared-state.ts b/packages/sandbox-core/src/prepared-state.ts index 5739fb4c..1f68d64b 100644 --- a/packages/sandbox-core/src/prepared-state.ts +++ b/packages/sandbox-core/src/prepared-state.ts @@ -23,7 +23,7 @@ import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, resolve as pathResolve } from 'node:path'; -export type PreparedProviderKind = 'docker' | 'daytona' | 'hetzner' | 'vercel' | 'e2b'; +export type PreparedProviderKind = 'docker' | 'daytona' | 'hetzner' | 'vercel' | 'e2b' | 'islo'; /** * The cross-provider record. `TImage` is the provider's opaque image diff --git a/packages/sandbox-islo/package.json b/packages/sandbox-islo/package.json new file mode 100644 index 00000000..6e91ef76 --- /dev/null +++ b/packages/sandbox-islo/package.json @@ -0,0 +1,45 @@ +{ + "name": "@agentbox/sandbox-islo", + "version": "0.0.0", + "private": true, + "description": "Islo sandbox provider — CloudBackend over the Islo HTTP API", + "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", + "@islo-labs/sdk": "^0.0.24", + "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-islo/src/api.ts b/packages/sandbox-islo/src/api.ts new file mode 100644 index 00000000..9a64e7fe --- /dev/null +++ b/packages/sandbox-islo/src/api.ts @@ -0,0 +1,133 @@ +import { TokenProvider } from '@islo-labs/sdk'; +import { ensureIsloEnvLoaded } from './env-loader.js'; + +export const DEFAULT_ISLO_CONTROL_URL = 'https://api.islo.dev'; +export const DEFAULT_ISLO_COMPUTE_URL = 'https://ca.compute.islo.dev'; + +export class IsloApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly body: string, + ) { + super(message); + this.name = 'IsloApiError'; + } +} + +export interface IsloSandboxResponse { + created_at?: string; + id: string; + image?: string; + name: string; + status: string; +} + +export interface IsloListResponse { + items: T[]; + next_cursor?: string | null; +} + +export interface IsloExecResponse { + exec_id: string; + status: string; +} + +export interface IsloExecResultResponse { + exit_code?: number | null; + status: string; + stderr: string; + stdout: string; +} + +export interface IsloShareResponse { + port: number; + share_id: string; + url: string; +} + +export interface IsloSnapshotResponse { + name: string; + status: string; +} + +export function resolveApiKey(): string { + ensureIsloEnvLoaded(); + const key = process.env.AGENTBOX_ISLO_API_KEY ?? process.env.ISLO_API_KEY; + if (!key) { + throw new Error( + 'Islo credentials not configured.\n' + + 'Run `agentbox islo login` to paste an API key, run `islo login` and use the Islo CLI directly, ' + + 'or set ISLO_API_KEY / AGENTBOX_ISLO_API_KEY in the environment.', + ); + } + return key; +} + +export function hasUsableCredentials(): boolean { + ensureIsloEnvLoaded(); + return Boolean(process.env.AGENTBOX_ISLO_API_KEY ?? process.env.ISLO_API_KEY); +} + +export function resolveBaseUrl(): string { + ensureIsloEnvLoaded(); + return (process.env.AGENTBOX_ISLO_BASE_URL ?? process.env.ISLO_BASE_URL ?? DEFAULT_ISLO_COMPUTE_URL) + .replace(/\/+$/u, ''); +} + +export function resolveControlUrl(): string { + ensureIsloEnvLoaded(); + return (process.env.AGENTBOX_ISLO_CONTROL_URL ?? process.env.ISLO_CONTROL_URL ?? DEFAULT_ISLO_CONTROL_URL) + .replace(/\/+$/u, ''); +} + +export function isNotFound(err: unknown): boolean { + return err instanceof IsloApiError && err.status === 404; +} + +export async function isloJson( + method: string, + path: string, + opts: { body?: unknown; timeoutMs?: number } = {}, +): Promise { + const res = await isloFetch(method, path, { + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + contentType: opts.body === undefined ? undefined : 'application/json', + timeoutMs: opts.timeoutMs, + }); + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +export async function isloFetch( + method: string, + path: string, + opts: { body?: string | Uint8Array | Buffer; contentType?: string; timeoutMs?: number } = {}, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 60_000); + try { + const tokenProvider = new TokenProvider({ + baseUrl: resolveControlUrl(), + apiKey: resolveApiKey(), + }); + const token = await tokenProvider.getToken(); + const headers: Record = { + Authorization: `Bearer ${token}`, + }; + if (opts.contentType) headers['content-type'] = opts.contentType; + const res = await fetch(`${resolveBaseUrl()}${path}`, { + method, + headers, + body: opts.body, + signal: controller.signal, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new IsloApiError(`islo ${method} ${path}: ${String(res.status)} ${body}`, res.status, body); + } + return res; + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/sandbox-islo/src/backend.ts b/packages/sandbox-islo/src/backend.ts new file mode 100644 index 00000000..8ea5fbee --- /dev/null +++ b/packages/sandbox-islo/src/backend.ts @@ -0,0 +1,353 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import type { + CloudBackend, + CloudExecOptions, + CloudExecResult, + CloudFileEntry, + CloudHandle, + CloudPreviewUrl, + CloudProvisionRequest, + CloudSandboxSummary, + CloudState, +} from '@agentbox/core'; +import { + isloJson, + isNotFound, + type IsloExecResponse, + type IsloExecResultResponse, + type IsloListResponse, + type IsloSandboxResponse, + type IsloShareResponse, + type IsloSnapshotResponse, +} from './api.js'; + +export const DEFAULT_BOX_IMAGE_REF = 'agentbox/box:dev'; +export const DEFAULT_ISLO_IMAGE_REF = 'ghcr.io/madarco/agentbox/box:latest'; + +const BOX_USER = 'vscode'; +const POLL_INTERVAL_MS = 1_000; +const UPLOAD_CHUNK_CHARS = 48_000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function shq(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +function safeSandboxName(name: string): string { + const cleaned = name + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + return `agentbox-${cleaned || 'box'}-${Date.now().toString(36)}`; +} + +function imageFor(req: CloudProvisionRequest): string { + return !req.image || req.image === DEFAULT_BOX_IMAGE_REF ? DEFAULT_ISLO_IMAGE_REF : req.image; +} + +function mapState(status: string | undefined): CloudState { + switch ((status ?? '').toLowerCase()) { + case 'running': + case 'starting': + return 'running'; + case 'paused': + return 'paused'; + case 'stopped': + case 'stopping': + return 'stopped'; + case 'deleted': + case 'failed': + default: + return 'missing'; + } +} + +function parseSize(size: string | undefined): { cpu?: number; memory?: number; disk?: number } { + if (!size) return {}; + const m = /^(\d+(?:\.\d+)?)-(\d+)-(\d+)$/u.exec(size.trim()); + if (!m) return {}; + return { cpu: Number(m[1]), memory: Number(m[2]), disk: Number(m[3]) }; +} + +function isTerminalExecStatus(status: string): boolean { + return ['completed', 'succeeded', 'success', 'failed', 'error', 'cancelled', 'canceled'].includes( + status.toLowerCase(), + ); +} + +async function waitForExecResult( + sandboxName: string, + execId: string, + timeoutMs: number, +): Promise { + const started = Date.now(); + let last: IsloExecResultResponse | null = null; + while (Date.now() - started < timeoutMs) { + last = await isloJson( + 'GET', + `/sandboxes/${encodeURIComponent(sandboxName)}/exec/${encodeURIComponent(execId)}`, + { timeoutMs: 30_000 }, + ); + if (last.exit_code !== undefined && last.exit_code !== null) return last; + if (isTerminalExecStatus(last.status)) return last; + await sleep(POLL_INTERVAL_MS); + } + throw new Error( + `islo exec ${execId} timed out after ${String(timeoutMs)}ms` + + (last ? ` (last status: ${last.status})` : ''), + ); +} + +export const isloBackend: CloudBackend = { + name: 'islo', + + async provision(req: CloudProvisionRequest): Promise { + const size = parseSize(req.size); + const resources = { + cpu: req.resources?.cpu ?? size.cpu, + memory: req.resources?.memory ?? size.memory, + disk: req.resources?.disk ?? size.disk, + }; + const body: Record = { + name: safeSandboxName(req.name), + image: imageFor(req), + workdir: '/workspace', + env: req.env, + }; + if (req.snapshot) body.snapshot_name = req.snapshot; + if (resources.cpu && resources.cpu > 0) body.vcpus = resources.cpu; + if (resources.memory && resources.memory > 0) body.memory_mb = resources.memory * 1024; + if (resources.disk && resources.disk > 0) body.disk_gb = resources.disk; + if (req.networkPolicy) body.gateway_profile = req.networkPolicy; + + const sb = await isloJson('POST', '/sandboxes', { + body, + timeoutMs: 300_000, + }); + req.onLog?.(`islo: created sandbox ${sb.name} (${sb.id}) from ${String(body.image)}`); + return { sandboxId: sb.name }; + }, + + async get(sandboxId: string): Promise { + try { + await isloJson('GET', `/sandboxes/${encodeURIComponent(sandboxId)}`); + return { sandboxId }; + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + }, + + async list(): Promise { + const summaries: CloudSandboxSummary[] = []; + let cursor: string | null | undefined; + do { + const qs = new URLSearchParams({ name_prefix: 'agentbox-', limit: '100' }); + if (cursor) qs.set('cursor', cursor); + const page = await isloJson>( + 'GET', + `/sandboxes?${qs.toString()}`, + ); + for (const item of page.items ?? []) { + summaries.push({ + sandboxId: item.name, + name: item.name, + createdAt: item.created_at, + state: mapState(item.status), + }); + } + cursor = page.next_cursor; + } while (cursor); + return summaries; + }, + + async start(h: CloudHandle): Promise { + try { + await this.resume(h); + } catch (err) { + // A running sandbox can conflict on resume; exec will still work. + const msg = err instanceof Error ? err.message : String(err); + if (!/409|conflict/i.test(msg)) throw err; + } + }, + + async stop(h: CloudHandle): Promise { + await this.pause(h); + }, + + async pause(h: CloudHandle): Promise { + await isloJson( + 'POST', + `/sandboxes/${encodeURIComponent(h.sandboxId)}/pause`, + { timeoutMs: 180_000 }, + ); + }, + + async resume(h: CloudHandle): Promise { + await isloJson( + 'POST', + `/sandboxes/${encodeURIComponent(h.sandboxId)}/resume`, + { timeoutMs: 180_000 }, + ); + }, + + async destroy(h: CloudHandle): Promise { + try { + await isloJson('DELETE', `/sandboxes/${encodeURIComponent(h.sandboxId)}`, { + timeoutMs: 180_000, + }); + } catch (err) { + if (isNotFound(err)) return; + throw err; + } + }, + + async state(h: CloudHandle): Promise { + try { + const sb = await isloJson( + 'GET', + `/sandboxes/${encodeURIComponent(h.sandboxId)}`, + ); + return mapState(sb.status); + } catch (err) { + if (isNotFound(err)) return 'missing'; + throw err; + } + }, + + async exec(h: CloudHandle, cmd: string, opts?: CloudExecOptions): Promise { + const timeoutMs = opts?.attemptTimeoutMs ?? 120_000; + const body = { + command: ['bash', '-lc', cmd], + env: opts?.env, + timeout_secs: Math.max(1, Math.ceil(timeoutMs / 1000)), + user: opts?.user ?? BOX_USER, + workdir: opts?.cwd, + }; + const started = await isloJson( + 'POST', + `/sandboxes/${encodeURIComponent(h.sandboxId)}/exec`, + { body, timeoutMs: 60_000 }, + ); + const result = await waitForExecResult(h.sandboxId, started.exec_id, timeoutMs); + return { + exitCode: result.exit_code ?? (result.status.toLowerCase() === 'failed' ? 1 : 0), + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; + }, + + async uploadFile(h: CloudHandle, localPath: string, remotePath: string): Promise { + const data = await readFile(localPath); + const b64 = data.toString('base64'); + await this.exec( + h, + `mkdir -p ${shq(dirname(remotePath))} && : > ${shq(remotePath)} && chown ${BOX_USER}:${BOX_USER} ${shq(remotePath)} 2>/dev/null || true`, + { user: 'root', attemptTimeoutMs: 60_000, noRetry: true }, + ); + for (let i = 0; i < b64.length; i += UPLOAD_CHUNK_CHARS) { + const chunk = b64.slice(i, i + UPLOAD_CHUNK_CHARS); + const r = await this.exec( + h, + `printf %s ${shq(chunk)} | base64 -d >> ${shq(remotePath)}`, + { user: 'root', attemptTimeoutMs: 60_000, noRetry: true }, + ); + if (r.exitCode !== 0) { + throw new Error(`islo uploadFile failed for ${remotePath}: ${r.stderr || r.stdout}`); + } + } + await this.exec(h, `chown ${BOX_USER}:${BOX_USER} ${shq(remotePath)} 2>/dev/null || true`, { + user: 'root', + attemptTimeoutMs: 30_000, + noRetry: true, + }); + }, + + async downloadFile(h: CloudHandle, remotePath: string, localPath: string): Promise { + const r = await this.exec(h, `base64 < ${shq(remotePath)}`, { + user: 'root', + attemptTimeoutMs: 120_000, + noRetry: true, + }); + if (r.exitCode !== 0) { + throw new Error(`islo downloadFile failed for ${remotePath}: ${r.stderr || r.stdout}`); + } + await mkdir(dirname(localPath), { recursive: true }); + await writeFile(localPath, Buffer.from(r.stdout.replace(/\s+/g, ''), 'base64')); + }, + + async listFiles(h: CloudHandle, remoteDir: string): Promise { + const script = [ + 'python3 - <<\'PY\'', + 'import json, os, pathlib', + `p = pathlib.Path(${JSON.stringify(remoteDir)})`, + 'out = []', + 'for e in os.scandir(p):', + ' out.append({"name": e.name, "isDir": e.is_dir(follow_symlinks=False)})', + 'print(json.dumps(out))', + 'PY', + ].join('\n'); + const r = await this.exec(h, script, { user: 'root', attemptTimeoutMs: 60_000 }); + if (r.exitCode !== 0) { + throw new Error(`islo listFiles failed for ${remoteDir}: ${r.stderr || r.stdout}`); + } + return JSON.parse(r.stdout) as CloudFileEntry[]; + }, + + async previewUrl(h: CloudHandle, port: number): Promise { + const existing = await isloJson( + 'GET', + `/sandboxes/${encodeURIComponent(h.sandboxId)}/shares`, + ); + const share = existing.find((s) => s.port === port); + if (share) return { url: share.url }; + const created = await isloJson( + 'POST', + `/sandboxes/${encodeURIComponent(h.sandboxId)}/shares`, + { body: { port, ttl_seconds: 24 * 60 * 60 } }, + ); + return { url: created.url }; + }, + + async signedPreviewUrl(h: CloudHandle, port: number): Promise { + return this.previewUrl(h, port); + }, + + async attachArgv(h: CloudHandle): Promise { + return ['ssh', `islo@${h.sandboxId}.islo`]; + }, + + async createSnapshot(h: CloudHandle, snapshotName: string): Promise { + await isloJson('POST', '/snapshots/', { + body: { sandbox_name: h.sandboxId, name: snapshotName }, + timeoutMs: 600_000, + }); + }, + + async deleteSnapshot(snapshotName: string): Promise { + try { + await isloJson('DELETE', `/snapshots/${encodeURIComponent(snapshotName)}`); + } catch (err) { + if (isNotFound(err)) return; + throw err; + } + }, + + async snapshotExists(snapshotName: string): Promise { + try { + const snap = await isloJson( + 'GET', + `/snapshots/${encodeURIComponent(snapshotName)}`, + ); + return ['ready', 'created', 'completed'].includes(snap.status.toLowerCase()); + } catch (err) { + if (isNotFound(err)) return false; + return false; + } + }, +}; diff --git a/packages/sandbox-islo/src/cli.ts b/packages/sandbox-islo/src/cli.ts new file mode 100644 index 00000000..d8d41e4a --- /dev/null +++ b/packages/sandbox-islo/src/cli.ts @@ -0,0 +1,69 @@ +import { log } from '@clack/prompts'; +import { Command } from 'commander'; +import { + ensureIsloCredentials, + maskKey, + readIsloCredStatus, + secretsPath, +} from './credentials.js'; +import { DEFAULT_ISLO_IMAGE_REF } from './backend.js'; + +interface LoginOpts { + status?: boolean; +} + +function reportError(err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + log.error(message); + process.exitCode = 1; +} + +function printStatus(): void { + const s = readIsloCredStatus(); + if (s.auth === 'none') { + process.stdout.write( + 'islo: not configured\n' + + ' run `agentbox islo login` to set up an API key, or set ISLO_API_KEY\n', + ); + return; + } + const lines = ['islo: configured', ' auth: API key']; + if (s.token) lines.push(` token: ${maskKey(s.token)}`); + lines.push(` compute: ${s.baseUrl}`, ` control: ${s.controlUrl}`, ` source: ${s.source}`); + if (s.source === 'secrets.env') lines.push(` file: ${secretsPath()}`); + lines.push(` image: ${DEFAULT_ISLO_IMAGE_REF} (default for agentbox/box:dev)`); + process.stdout.write(lines.join('\n') + '\n'); +} + +const loginSub = new Command('login') + .description('Set up (or rotate) Islo credentials for sandbox boxes') + .option('--status', 'show what is currently configured (masked) and exit') + .action(async (opts: LoginOpts) => { + try { + if (opts.status) { + printStatus(); + return; + } + if (!process.stdin.isTTY) { + process.stderr.write( + 'islo login needs an interactive terminal — set ISLO_API_KEY or ' + + 'AGENTBOX_ISLO_API_KEY for non-interactive use.\n', + ); + process.exitCode = 1; + return; + } + await ensureIsloCredentials({ force: true }); + log.info( + 'For interactive attaches, also run `islo ssh --setup` once so `ssh islo@.islo` works.', + ); + } catch (err) { + reportError(err); + } + }); + +export const isloCommand = new Command('islo') + .description( + 'Islo sandbox provider — credentials, plus sugar for `--provider islo` ' + + '(e.g. `agentbox islo create|claude|codex|opencode`)', + ) + .addCommand(loginSub, { isDefault: true }); diff --git a/packages/sandbox-islo/src/credentials.ts b/packages/sandbox-islo/src/credentials.ts new file mode 100644 index 00000000..89b666e3 --- /dev/null +++ b/packages/sandbox-islo/src/credentials.ts @@ -0,0 +1,191 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; +import { + cancel, + confirm, + intro, + isCancel, + log, + note, + outro, + password, + text, +} from '@clack/prompts'; +import { + DEFAULT_ISLO_COMPUTE_URL, + hasUsableCredentials, + resolveBaseUrl, + resolveControlUrl, +} from './api.js'; +import { ensureIsloEnvLoaded, reloadIsloEnv } from './env-loader.js'; + +const DOCS_URL = 'https://docs.islo.dev/cli/authentication#api-key-authentication'; +const MANAGED_KEYS = ['AGENTBOX_ISLO_API_KEY', 'AGENTBOX_ISLO_BASE_URL'] as const; + +function exitOnCancel(v: T | symbol): T { + if (isCancel(v)) { + cancel('Cancelled.'); + process.exit(130); + } + return v as T; +} + +export interface EnsureIsloCredentialsOptions { + force?: boolean; +} + +export async function ensureIsloCredentials( + opts: EnsureIsloCredentialsOptions = {}, +): Promise { + ensureIsloEnvLoaded(); + if (!opts.force && hasUsableCredentials()) return; + if (!process.stdin.isTTY) return; + + intro('Islo setup'); + note( + 'AgentBox needs an Islo API key to provision Islo-backed boxes.\n' + + 'Create one with `islo api-key create --show`, then paste it below.\n' + + 'The key is stored in `~/.agentbox/secrets.env` (mode 0600).', + 'Credentials required', + ); + + const openIt = exitOnCancel( + await confirm({ + message: `Open ${DOCS_URL} for API-key docs?`, + initialValue: false, + }), + ); + if (openIt) openDocs(); + + const key = exitOnCancel( + await password({ + message: 'Paste your Islo API key', + validate: (v) => (v && v.trim().length > 0 ? undefined : 'Cannot be empty'), + }), + ); + + const baseUrl = exitOnCancel( + await text({ + message: 'Islo API base URL', + initialValue: process.env.AGENTBOX_ISLO_BASE_URL ?? process.env.ISLO_BASE_URL ?? DEFAULT_ISLO_COMPUTE_URL, + validate: (v) => (v && /^https?:\/\//u.test(v.trim()) ? undefined : 'Enter an http(s) URL'), + }), + ); + + persistCredentials({ apiKey: key.trim(), baseUrl: String(baseUrl).trim() }); + reloadIsloEnv(); + log.success(`Islo credentials saved to ${secretsPath()}`); + outro('Setup complete.'); +} + +function persistCredentials(creds: { apiKey: string; baseUrl: string }): void { + const record: Record = { AGENTBOX_ISLO_API_KEY: creds.apiKey }; + if (creds.baseUrl && creds.baseUrl !== DEFAULT_ISLO_COMPUTE_URL) { + record.AGENTBOX_ISLO_BASE_URL = creds.baseUrl; + } + writeManaged(record); +} + +function writeManaged(record: Record): void { + for (const k of MANAGED_KEYS) delete process.env[k]; + for (const [k, v] of Object.entries(record)) process.env[k] = v; + + const path = secretsPath(); + mkdirSync(dirname(path), { recursive: true }); + + let existing = ''; + if (existsSync(path)) { + try { + existing = readFileSync(path, 'utf8'); + } catch { + existing = ''; + } + } + const kept = existing + .split(/\r?\n/) + .filter((line) => { + const stripped = line.startsWith('export ') ? line.slice('export '.length) : line; + const eq = stripped.indexOf('='); + if (eq <= 0) return true; + const key = stripped.slice(0, eq).trim(); + return !(MANAGED_KEYS as readonly string[]).includes(key); + }) + .join('\n') + .replace(/\s+$/u, ''); + + const lines = Object.entries(record).map(([k, v]) => `${k}=${v}`); + const body = (kept ? `${kept}\n` : '') + lines.join('\n') + '\n'; + + const tmp = `${path}.tmp`; + writeFileSync(tmp, body, { mode: 0o600 }); + try { + chmodSync(tmp, 0o600); + } catch { + // best-effort + } + renameSync(tmp, path); + try { + chmodSync(path, 0o600); + } catch { + // best-effort + } +} + +function openDocs(): void { + import('node:child_process') + .then(({ spawnSync }) => { + const r = spawnSync(hostOpenCommand(), [DOCS_URL], { stdio: 'ignore' }); + if (r.status !== 0) log.warn(`Could not auto-open the browser — visit ${DOCS_URL} manually.`); + }) + .catch(() => { + log.warn(`Could not auto-open the browser — visit ${DOCS_URL} manually.`); + }); +} + +export function secretsPath(): string { + return resolve(homedir(), '.agentbox', 'secrets.env'); +} + +export interface IsloCredStatus { + auth: 'key' | 'none'; + token?: string; + baseUrl: string; + controlUrl: string; + source: 'env' | 'secrets.env' | 'none'; +} + +export function readIsloCredStatus(): IsloCredStatus { + const shellHad = + process.env.AGENTBOX_ISLO_API_KEY !== undefined || process.env.ISLO_API_KEY !== undefined; + ensureIsloEnvLoaded(); + const key = process.env.AGENTBOX_ISLO_API_KEY ?? process.env.ISLO_API_KEY; + if (!key) { + return { + auth: 'none', + source: 'none', + baseUrl: resolveBaseUrl(), + controlUrl: resolveControlUrl(), + }; + } + return { + auth: 'key', + token: key, + source: shellHad ? 'env' : 'secrets.env', + baseUrl: resolveBaseUrl(), + controlUrl: resolveControlUrl(), + }; +} + +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-islo/src/env-loader.ts b/packages/sandbox-islo/src/env-loader.ts new file mode 100644 index 00000000..76648649 --- /dev/null +++ b/packages/sandbox-islo/src/env-loader.ts @@ -0,0 +1,61 @@ +/** + * Islo env auto-loader. Host credentials live in `~/.agentbox/secrets.env` + * or the shell env, never in project `.env` files. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { resolve } from 'node:path'; + +const ISLO_KEYS = ['ISLO_API_KEY', 'ISLO_BASE_URL', 'AGENTBOX_ISLO_API_KEY', 'AGENTBOX_ISLO_BASE_URL'] as const; + +let loaded = false; + +export function ensureIsloEnvLoaded(): void { + if (loaded) return; + loaded = true; + importIsloFromFile(resolve(homedir(), '.agentbox', 'secrets.env'), ISLO_KEYS); +} + +export function reloadIsloEnv(): void { + loaded = false; + ensureIsloEnvLoaded(); +} + +function importIsloFromFile(path: string, keys: readonly string[]): void { + if (!existsSync(path)) return; + let body: string; + try { + body = readFileSync(path, 'utf8'); + } catch { + return; + } + const parsed = parseEnvFile(body); + for (const key of keys) { + if (process.env[key] !== undefined) continue; + const value = parsed[key]; + if (typeof value === 'string') process.env[key] = value; + } +} + +export function parseEnvFile(body: string): Record { + const out: Record = {}; + for (const rawLine of body.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith('#')) continue; + const stripped = line.startsWith('export ') ? line.slice('export '.length) : line; + const eq = stripped.indexOf('='); + if (eq <= 0) continue; + const key = stripped.slice(0, eq).trim(); + let value = stripped.slice(eq + 1).trim(); + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} diff --git a/packages/sandbox-islo/src/index.ts b/packages/sandbox-islo/src/index.ts new file mode 100644 index 00000000..53f3c883 --- /dev/null +++ b/packages/sandbox-islo/src/index.ts @@ -0,0 +1,23 @@ +import type { Provider } from '@agentbox/core'; +import { createCloudProvider } from '@agentbox/sandbox-cloud'; +import { DEFAULT_BOX_IMAGE_REF, DEFAULT_ISLO_IMAGE_REF, isloBackend } from './backend.js'; + +const cloudProvider = createCloudProvider(isloBackend, { + defaultResources: { cpu: 2, memory: 4, disk: 10 }, + launchDockerd: false, +}); + +export const isloProvider: Provider = { + ...cloudProvider, +}; + +export { DEFAULT_BOX_IMAGE_REF, DEFAULT_ISLO_IMAGE_REF, isloBackend }; +export { ensureIsloEnvLoaded, reloadIsloEnv } from './env-loader.js'; +export { + ensureIsloCredentials, + maskKey, + readIsloCredStatus, + secretsPath, + type EnsureIsloCredentialsOptions, + type IsloCredStatus, +} from './credentials.js'; diff --git a/packages/sandbox-islo/test/backend.test.ts b/packages/sandbox-islo/test/backend.test.ts new file mode 100644 index 00000000..a37e4377 --- /dev/null +++ b/packages/sandbox-islo/test/backend.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_ISLO_IMAGE_REF, isloBackend } from '../src/backend.js'; + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('isloBackend', () => { + const originalFetch = globalThis.fetch; + let keyCounter = 0; + + beforeEach(() => { + keyCounter += 1; + process.env.AGENTBOX_ISLO_API_KEY = `test-key-${String(keyCounter)}`; + process.env.AGENTBOX_ISLO_BASE_URL = 'https://compute.test.islo'; + process.env.AGENTBOX_ISLO_CONTROL_URL = 'https://control.test.islo'; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + delete process.env.AGENTBOX_ISLO_API_KEY; + delete process.env.AGENTBOX_ISLO_BASE_URL; + delete process.env.AGENTBOX_ISLO_CONTROL_URL; + vi.restoreAllMocks(); + }); + + function authResponse(url: string | URL | Request, init?: RequestInit): Response | null { + if (String(url) !== 'https://control.test.islo/auth/token') return null; + expect(init?.method).toBe('POST'); + return json({ session_token: `session-${String(keyCounter)}`, cookie_max_age: 600 }); + } + + it('provisions with the published AgentBox image for the local sentinel', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const auth = authResponse(url, init); + if (auth) return auth; + const body = JSON.parse(String(init?.body)) as Record; + expect(body.image).toBe(DEFAULT_ISLO_IMAGE_REF); + expect(body.vcpus).toBe(2); + expect(body.memory_mb).toBe(4096); + expect(body.disk_gb).toBe(10); + expect(body.gateway_profile).toBe('prod'); + return json({ + id: 'sb_1', + name: body.name, + image: body.image, + status: 'running', + }); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const h = await isloBackend.provision({ + name: 'My Box', + image: 'agentbox/box:dev', + resources: { cpu: 2, memory: 4, disk: 10 }, + networkPolicy: 'prod', + }); + + expect(h.sandboxId).toMatch(/^agentbox-my-box-/); + expect(fetchMock).toHaveBeenCalledWith( + 'https://compute.test.islo/sandboxes', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('executes a command by starting and polling an Islo exec', async () => { + const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const auth = authResponse(url, init); + if (auth) return auth; + const path = String(url); + if (path.endsWith('/sandboxes/sb/exec') && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as { command: string[]; user: string }; + expect(body.command).toEqual(['bash', '-lc', 'echo hi']); + expect(body.user).toBe('vscode'); + return json({ exec_id: 'ex_1', status: 'running' }); + } + if (path.endsWith('/sandboxes/sb/exec/ex_1')) { + return json({ + exec_id: 'ex_1', + status: 'completed', + exit_code: 0, + stdout: 'hi\n', + stderr: '', + truncated: false, + }); + } + throw new Error(`unexpected request ${path}`); + }); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(isloBackend.exec({ sandboxId: 'sb' }, 'echo hi')).resolves.toEqual({ + exitCode: 0, + stdout: 'hi\n', + stderr: '', + }); + }); + + it('reuses an existing share for preview URLs', async () => { + const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const auth = authResponse(url, init); + if (auth) return auth; + expect(String(url)).toBe('https://compute.test.islo/sandboxes/sb/shares'); + return json([{ share_id: 'sh_1', port: 80, url: 'https://share.test' }]); + }); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(isloBackend.previewUrl({ sandboxId: 'sb' }, 80)).resolves.toEqual({ + url: 'https://share.test', + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/sandbox-islo/tsconfig.json b/packages/sandbox-islo/tsconfig.json new file mode 100644 index 00000000..f24546c2 --- /dev/null +++ b/packages/sandbox-islo/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/sandbox-islo/tsup.config.ts b/packages/sandbox-islo/tsup.config.ts new file mode 100644 index 00000000..c832e8ae --- /dev/null +++ b/packages/sandbox-islo/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 eb5c1943..a821eba1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: '@agentbox/sandbox-hetzner': specifier: workspace:* version: link:../../packages/sandbox-hetzner + '@agentbox/sandbox-islo': + specifier: workspace:* + version: link:../../packages/sandbox-islo '@agentbox/sandbox-vercel': specifier: workspace:* version: link:../../packages/sandbox-vercel @@ -486,6 +489,40 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.19)(lightningcss@1.32.0) + packages/sandbox-islo: + 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 + '@islo-labs/sdk': + specifier: ^0.0.24 + version: 0.0.24 + 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-vercel: dependencies: '@agentbox/config': @@ -685,6 +722,7 @@ packages: '@daytonaio/sdk@0.179.0': resolution: {integrity: sha512-KZErEaClqW9arLb1xI78R/9T0eOGWSShcruszgwXP6MR53mVAFmmhQ/OOGFXpV+xQ8HYB1HYl0FcK2CTaxHNgA==} + deprecated: 'Moved to @daytona/sdk, same API, no breaking changes. Please update: npm uninstall @daytonaio/sdk && npm i @daytona/sdk' '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} @@ -1384,6 +1422,10 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@islo-labs/sdk@0.0.24': + resolution: {integrity: sha512-3rrMv+KIYj3yfOM3Qq5R3DUJ3Hy3dVul9hopmuCQYLy8wjsm45oe0/whWaykmdUkCw/NOLftP4v+oySJ316avA==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -5438,6 +5480,8 @@ snapshots: dependencies: minipass: 7.1.3 + '@islo-labs/sdk@0.0.24': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5