import { remapKeysToCamel } from "./internal/config.js"; import { mapNapiError, withMappedErrors } from "./internal/error-mapping.js"; import { validateStopTimeout } from "./internal/stop.js"; import { compactionResultFromJson, type DiskCompactionOptions, type DiskCompactionResult, } from "./compact.js"; import { modificationPlanFromJson, modifyOptionsToNapi, type ModifyOptions, type SandboxModificationPlan, } from "./modify.js"; import { napi, type NapiAttachOptionsBuilder, type NapiExecOptionsBuilder, type NapiPullProgressCreate, type NapiPullProgressEvent, type NapiPullProgressStream, type NapiSandbox, type NapiSandboxBuilderSetters, type NapiRestoreBuilderSetters, type NapiSandboxConfig, type NapiSandboxListOptions, type NapiSandboxPage, type NapiSnapshotSeed, } from "./internal/napi.js"; import { ExecHandle, ExecOutput } from "./exec.js"; import { SandboxFsOps } from "./fs.js"; import { LogEntry, LogStream, type LogReadOptions, type LogStreamOptions, logEntryFromNapi, logReadOptionsToNapi, logStreamOptionsToNapi, } from "./logs.js"; import { SandboxHandle, type DestroyOptions, type RestartOptions, type SandboxStopResult, } from "./sandbox-handle.js"; import type { SandboxStatus } from "./sandbox-status.js"; import type { SandboxMetrics } from "./metrics.js"; import { metricsFromNapi } from "./internal/metrics.js"; import { MetricsStream } from "./metrics-stream.js"; import { SandboxSshOps } from "./ssh.js"; /** * Fluent builder for a sandbox. Returned by `Sandbox.builder(name)`. * Sandbox names are limited to 128 UTF-8 bytes. * * The instance IS the napi-rs `SandboxBuilder` class — every setter is a * native call, no TS-side reimplementation. Only the terminal `create()` * method is wrapped here so it returns a TS * `Sandbox` (which adds `Symbol.asyncDispose`, error-mapping, and a few * sync getters on top of the native handle). */ // `interface ... extends NapiSandboxBuilderSetters` is the form that // preserves polymorphic `this` through chained calls — the napi // builder is split into a setters-only base + a terminals interface // (`internal/napi.ts`) precisely so we can extend the base here and // add the TS-flavored terminals (which return TS `Sandbox` / // `PullProgressCreate` instead of the napi shapes). An `Omit<...> & // {...}` type alias would lose the override on every chained `this` // return, leaving `b.image(...).create()` inferred as // `Promise`. export type SandboxConfig = NapiSandboxConfig; export type CpuPlacement = "inherit" | "auto" | "spread" | "compact"; export interface SandboxBuilder extends NapiSandboxBuilderSetters { /** Create a new sandbox, preserving strict name-conflict behavior. */ create(): Promise; /** * Connect to and run the persisted sandbox with this name, or create it if absent. * Builder options are used only for creation; existing configuration wins. */ connectOrCreate(): Promise; createWithPullProgress(): Promise; /** Image preparation, snapshot backing and activation progress. Await the result for success. */ createWithProgress(): Promise; } /** Restore a snapshot or archive as a detached sandbox with explicit host bindings. */ export interface RestoreBuilder extends NapiRestoreBuilderSetters { restore(): Promise; restoreWithProgress(): Promise; } export interface SandboxPingResult { readonly name: string; readonly latencyMs: number; } export interface SandboxTouchResult { readonly name: string; readonly activitySeq: number; } /** One named child or startup error from a capture-once batch, in input order. */ export type ForkOutcome = | { name: string; sandbox: Sandbox; error?: never } | { name: string; sandbox?: never; error: Error }; /** @deprecated Use ForkOutcome instead. */ export type BranchOutcome = ForkOutcome; /** An unmapped external filesystem or a mismatch accepted during relaxed restore. */ export interface ExternalMountWarning { readonly guestPath: string; readonly reason: string; readonly staleInodes: readonly bigint[]; } /** One page returned by `Sandbox.list()` or `Sandbox.listWith()`. */ export interface SandboxPage { sandboxes: SandboxHandle[]; nextCursor?: string; } /** Fluent options for one paginated sandbox list request. */ export class SandboxListBuilder { private readonly options: NapiSandboxListOptions = {}; limit(limit: number): this { this.options.limit = limit; return this; } cursor(cursor: string): this { this.options.cursor = cursor; return this; } label(key: string, value: string): this { this.options.labels ??= {}; this.options.labels[key] = value; return this; } labels(labels: Record): this { this.options.labels = { ...this.options.labels, ...labels }; return this; } /** @internal */ toNapi(): NapiSandboxListOptions { return this.options; } } function sandboxPageFromNapi(page: NapiSandboxPage): SandboxPage { return { sandboxes: page.sandboxes.map((handle) => new SandboxHandle(handle)), nextCursor: page.nextCursor, }; } /** * Pair returned by `SandboxBuilder.createWithPullProgress()` — * the per-layer progress event stream plus a method to await the * final `Sandbox`. */ export class PullProgressCreate { /** Cancel creation; awaitSandbox() rejects when cancellation is observed. */ cancel(): void { this.inner.cancel(); } /** @internal */ private readonly inner: NapiPullProgressCreate; /** @internal */ private readonly name: string; /** @internal */ constructor(inner: NapiPullProgressCreate, name: string) { this.inner = inner; this.name = name; } /** * The progress event stream. Iterate with `for await...of` or poll * with `.recv()`. The stream closes once the pull completes. */ get progress(): NapiPullProgressStream { return this.inner.progress; } /** * Async iterator helper: equivalent to `for await (const ev of c.progress)`. * Lets you write `for await (const ev of c) { … }` directly. */ [Symbol.asyncIterator](): AsyncIterator { return this.inner.progress[Symbol.asyncIterator](); } /** Await the sandbox. Resolves once pull + boot finishes. */ async awaitSandbox(): Promise { const inner = await withMappedErrors(() => this.inner.awaitSandbox()); // Full restores can auto-detach even without an explicit detached builder option. // Only the completed native handle knows whether disposal owns this lifecycle. return new Sandbox(inner, this.name); } } /** Creation-wide progress; ignoring events does not cancel or delay creation. */ export class CreationProgressCreate { private readonly creation: PullProgressCreate; /** @internal */ constructor(inner: NapiPullProgressCreate, name: string) { this.creation = new PullProgressCreate(inner, name); } get progress(): import("./creation-progress.js").CreationProgressStream { return this.creation.progress as unknown as import("./creation-progress.js").CreationProgressStream; } [Symbol.asyncIterator](): AsyncIterator { return this.progress[Symbol.asyncIterator](); } cancel(): void { this.creation.cancel(); } awaitSandbox(): Promise { return this.creation.awaitSandbox(); } } export class Sandbox implements AsyncDisposable { /** Prepare restoration; no VM starts until the builder's restore terminal. */ static restore(snapshot: NapiSnapshotSeed): RestoreBuilder { const builder = typeof snapshot === "string" ? new napi.RestoreBuilder(snapshot) : new napi.RestoreBuilder(snapshot.reference, snapshot.referenceKind); const restore = builder.restore.bind(builder); const progress = builder.restoreWithProgress.bind(builder); let name = ""; const setName = builder.name.bind(builder); builder.name = (value: string) => { setName(value); name = value; return builder; }; (builder as unknown as RestoreBuilder).restore = async () => new Sandbox(await withMappedErrors(restore), name); (builder as unknown as RestoreBuilder).restoreWithProgress = async () => new CreationProgressCreate(await withMappedErrors(progress), name); return builder as unknown as RestoreBuilder; } /** @internal */ readonly inner: NapiSandbox; /** Sandbox name. Names are limited to 128 UTF-8 bytes. */ readonly name: string; /** Stable identity that changes when this name is removed and recreated. */ readonly id: string; readonly ownsLifecycle: boolean; /** Backend retained by this sandbox. */ readonly backendKind: "local" | "cloud"; /** @internal use `Sandbox.builder(name).create()` */ constructor( inner: NapiSandbox, name: string, ownsLifecycle = inner.ownsLifecycle, ) { this.inner = inner; this.name = name; this.id = inner.id; this.ownsLifecycle = ownsLifecycle; this.backendKind = inner.backendKind; } // -- statics ------------------------------------------------------------ /** Begin building a new sandbox. Names are limited to 128 UTF-8 bytes. */ static builder(name: string): SandboxBuilder { const nb = new napi.SandboxBuilder(name); const origCreate = nb.create.bind(nb); const origConnectOrCreate = nb.connectOrCreate.bind(nb); const origCreateWithPP = nb.createWithPullProgress.bind(nb); const origCreateWithProgress = nb.createWithProgress?.bind(nb); // Override the terminals so they return a TS Sandbox. (nb as unknown as { create: () => Promise }).create = async () => { const inner = await withMappedErrors(() => origCreate()); return new Sandbox(inner, name); }; ( nb as unknown as { connectOrCreate: () => Promise } ).connectOrCreate = async () => { const inner = await withMappedErrors(() => origConnectOrCreate()); // An existing running sandbox is connected without taking ownership, // while a newly created or restarted sandbox follows detached mode. return new Sandbox(inner, name); }; ( nb as unknown as { createWithPullProgress: () => Promise; } ).createWithPullProgress = async () => { const raw = await withMappedErrors(() => origCreateWithPP()); return new PullProgressCreate(raw, name); }; (nb as unknown as { createWithProgress: () => Promise }).createWithProgress = async () => { if (!origCreateWithProgress) throw new Error("Installed native SDK does not support creation progress"); const raw = await withMappedErrors(() => origCreateWithProgress()); return new CreationProgressCreate(raw, name); }; return nb as unknown as SandboxBuilder; } /** * Resume an existing stopped sandbox in attached mode. * Names are limited to 128 UTF-8 bytes. */ static async start(name: string): Promise { const inner = await withMappedErrors(() => napi.Sandbox.start(name)); return new Sandbox(inner, name, /*ownsLifecycle*/ true); } /** * Resume an existing stopped sandbox in detached mode. * Names are limited to 128 UTF-8 bytes. */ static async startDetached(name: string): Promise { const inner = await withMappedErrors(() => napi.Sandbox.startDetached(name), ); return new Sandbox(inner, name, /*ownsLifecycle*/ false); } /** * Look up a database handle for an existing sandbox. * Names are limited to 128 UTF-8 bytes. */ static async get(name: string): Promise { const h = await withMappedErrors(() => napi.Sandbox.get(name)); return new SandboxHandle(h); } /** List the first page of known sandboxes. */ static async list(): Promise { const page = await withMappedErrors(() => napi.Sandbox.list()); return sandboxPageFromNapi(page); } /** List a configured page of sandboxes. */ static async listWith( configure: (list: SandboxListBuilder) => SandboxListBuilder, ): Promise { const options = configure(new SandboxListBuilder()).toNapi(); const page = await withMappedErrors(() => napi.Sandbox.listWith(options), ); return sandboxPageFromNapi(page); } /** * Remove a stopped sandbox from the database. * Names are limited to 128 UTF-8 bytes. */ static async remove(name: string): Promise { await withMappedErrors(() => napi.Sandbox.remove(name)); } // -- exec --------------------------------------------------------------- async execDefault(): Promise { const raw = await withMappedErrors(() => this.inner.execDefault()); return new ExecOutput(raw); } async execDefaultWith( configure: (b: NapiExecOptionsBuilder) => NapiExecOptionsBuilder, ): Promise { const builder = configure(new napi.ExecOptionsBuilder()); const raw = await withMappedErrors(() => this.inner.execDefaultWithBuilder(builder), ); return new ExecOutput(raw); } async execDefaultStream(): Promise { const raw = await withMappedErrors(() => this.inner.execDefaultStream()); return new ExecHandle(raw); } async execDefaultStreamWith( configure: (b: NapiExecOptionsBuilder) => NapiExecOptionsBuilder, ): Promise { const builder = configure(new napi.ExecOptionsBuilder()); const raw = await withMappedErrors(() => this.inner.execDefaultStreamWithBuilder(builder), ); return new ExecHandle(raw); } async exec(cmd: string, args?: Iterable): Promise { const argv = args ? Array.from(args) : undefined; const raw = await withMappedErrors(() => this.inner.exec(cmd, argv)); return new ExecOutput(raw); } async execWith( cmd: string, configure: (b: NapiExecOptionsBuilder) => NapiExecOptionsBuilder, ): Promise { const builder = configure(new napi.ExecOptionsBuilder()); const raw = await withMappedErrors(() => this.inner.execWithBuilder(cmd, builder), ); return new ExecOutput(raw); } async execStream(cmd: string, args?: Iterable): Promise { const argv = args ? Array.from(args) : undefined; const raw = await withMappedErrors(() => this.inner.execStream(cmd, argv), ); return new ExecHandle(raw); } async execStreamWith( cmd: string, configure: (b: NapiExecOptionsBuilder) => NapiExecOptionsBuilder, ): Promise { const builder = configure(new napi.ExecOptionsBuilder()); const raw = await withMappedErrors(() => this.inner.execStreamWithBuilder(cmd, builder), ); return new ExecHandle(raw); } async shell(script: string): Promise { const raw = await withMappedErrors(() => this.inner.shell(script)); return new ExecOutput(raw); } async shellStream(script: string): Promise { const raw = await withMappedErrors(() => this.inner.shellStream(script)); return new ExecHandle(raw); } // -- attach ------------------------------------------------------------- async attachDefault(): Promise { return await withMappedErrors(() => this.inner.attachDefault()); } async attachDefaultWith( configure: (b: NapiAttachOptionsBuilder) => NapiAttachOptionsBuilder, ): Promise { const builder = configure(new napi.AttachOptionsBuilder()); return await withMappedErrors(() => this.inner.attachDefaultWithBuilder(builder), ); } async attach(cmd: string, args?: Iterable): Promise { const argv = args ? Array.from(args) : undefined; return await withMappedErrors(() => this.inner.attach(cmd, argv)); } async attachWith( cmd: string, configure: (b: NapiAttachOptionsBuilder) => NapiAttachOptionsBuilder, ): Promise { const builder = configure(new napi.AttachOptionsBuilder()); return await withMappedErrors(() => this.inner.attachWithBuilder(cmd, builder), ); } async attachShell(): Promise { return await withMappedErrors(() => this.inner.attachShell()); } // -- filesystem --------------------------------------------------------- fs(): SandboxFsOps { return new SandboxFsOps(this.inner.fs()); } // -- ssh ---------------------------------------------------------------- ssh(): SandboxSshOps { return new SandboxSshOps(this.inner); } // -- config ------------------------------------------------------------- /** * The full configuration this sandbox was created with — image, cpus, * memory, env, mounts, etc. The shape mirrors `SandboxBuilder.build()`. */ async config(): Promise { const json = await withMappedErrors(() => this.inner.configJson()); return remapKeysToCamel(JSON.parse(json)) as SandboxConfig; } // -- logs --------------------------------------------------------------- /** * Read captured output from this sandbox's `exec.log`. * * Backed by an on-disk JSON Lines file the runtime writes via the * relay tap. Works on running and stopped sandboxes alike — no * protocol traffic. Default sources are user output: `stdout`, * `stderr`, and pty-merged `output`. */ async logs(opts?: LogReadOptions): Promise { const napiOpts = logReadOptionsToNapi(opts); const raw = await withMappedErrors(() => this.inner.logs(napiOpts)); return raw.map(logEntryFromNapi); } /** * Stream captured output as it appears, with optional follow. * * Backed by the same on-disk `exec.log` as {@link logs}, but * yields entries lazily. Pass `{ follow: true }` to keep the * stream open past current EOF and pick up new entries as they * are written; otherwise the stream drains the current contents * and ends. Each yielded {@link LogEntry} carries an opaque * `cursor` that can be passed back via * {@link LogStreamOptions.fromCursor} to resume. */ async logStream(opts?: LogStreamOptions): Promise { const napiOpts = logStreamOptionsToNapi(opts); const raw = await withMappedErrors(() => this.inner.logStream(napiOpts)); return new LogStream(raw); } // -- metrics ------------------------------------------------------------ async metrics(): Promise { const raw = await withMappedErrors(() => this.inner.metrics()); return metricsFromNapi(raw); } /** * Check whether agentd is reachable without refreshing idle activity. */ async ping(): Promise { return await withMappedErrors(() => this.inner.ping()); } /** * Explicitly refresh this sandbox's idle activity timer. */ async touch(): Promise { return await withMappedErrors(() => this.inner.touch()); } /** * Plan or apply a sandbox modification. With `dryRun: true` the plan is * computed without applying anything. */ async modify(opts?: ModifyOptions): Promise { const raw = await withMappedErrors(() => this.inner.modify(modifyOptionsToNapi(opts)), ); return modificationPlanFromJson(raw); } /** Compact sealed root and owned-data disk layers without rewriting existing snapshots. */ async compact(opts?: DiskCompactionOptions): Promise { const raw = await withMappedErrors(() => this.inner.compact(opts?.layers, opts?.dryRun, opts?.disk, opts?.rootDiskOnly), ); return compactionResultFromJson(raw); } /** Stream metrics snapshots at the given interval (in milliseconds). */ async metricsStream(intervalMs: number): Promise { const raw = await withMappedErrors(() => this.inner.metricsStream(intervalMs), ); return new MetricsStream(raw); } // -- lifecycle ---------------------------------------------------------- /** Read warnings for unmapped external filesystems and accepted restore mismatches. */ async restoreWarnings(): Promise { return withMappedErrors(() => this.inner.restoreWarnings()); } /** Wait indefinitely for graceful completion and runtime ownership release; never implicitly kills. */ async stop(): Promise { await withMappedErrors(() => this.inner.stop()); } /** @deprecated Use fork() for live execution duplication. */ async branch(name: string, options: { recordIntegrity?: boolean; guestFlush?: import("./snapshot.js").GuestFlush } = {}): Promise { return this.fork(name, options); } /** @deprecated Use forkMany() for capture-once live duplication. */ async branchMany(names: string[], options: { recordIntegrity?: boolean; guestFlush?: import("./snapshot.js").GuestFlush } = {}): Promise { return this.forkMany(names, options); } /** Create an independent local CoW child without a durable full snapshot. */ async fork(name: string, options: { recordIntegrity?: boolean; guestFlush?: import("./snapshot.js").GuestFlush } = {}): Promise { const child = await withMappedErrors(() => this.inner.fork(name, options.recordIntegrity, options.guestFlush)); return new Sandbox(child, name, false); } /** Capture once; return each named child's startup outcome in input order. */ async forkMany(names: string[], options: { recordIntegrity?: boolean; guestFlush?: import("./snapshot.js").GuestFlush } = {}): Promise { const outcomes = await withMappedErrors(() => this.inner.forkMany(names, options.recordIntegrity, options.guestFlush)); return outcomes.map(o => o.sandbox ? { name: o.name, sandbox: new Sandbox(o.sandbox, o.name, false) } : { name: o.name, error: mapNapiError(new Error(o.error ?? "Child startup failed")) as Error }); } /** Suspend this resident VM without creating a snapshot. */ async pause(options: { guestFlush?: import("./snapshot.js").GuestFlush } = {}): Promise { await withMappedErrors(() => this.inner.pause(options.guestFlush)); } /** Explicit resident resume; no snapshot is created. */ async resume(): Promise { await withMappedErrors(() => this.inner.resume()); } async requestStop(): Promise { await withMappedErrors(() => this.inner.requestStop()); } /** One total budget; StopTimeoutError on expiry without killing. Zero never dispatches shutdown. */ async stopWithTimeout(timeoutMs: number): Promise { validateStopTimeout(timeoutMs); await withMappedErrors(() => this.inner.stopWithTimeout(timeoutMs)); } async kill(): Promise { await withMappedErrors(() => this.inner.kill()); } async requestKill(): Promise { await withMappedErrors(() => this.inner.requestKill()); } async killWithTimeout(timeoutMs: number): Promise { await withMappedErrors(() => this.inner.killWithTimeout(timeoutMs)); } async requestDrain(): Promise { await withMappedErrors(() => this.inner.requestDrain()); } /** * Wait until this exact persisted sandbox reaches `status`. * * This method has no built-in timeout. A same-name replacement is rejected * instead of silently redirecting the wait to the new identity. */ async waitForStatus(status: SandboxStatus): Promise { const raw = await withMappedErrors(() => this.inner.waitForStatus(status)); return new SandboxHandle(raw); } /** * Stop and start this exact persisted sandbox. * * Graceful shutdown and a ten-second convergence timeout are the defaults. * A created, stopped, or crashed sandbox starts directly. */ async restart(options?: RestartOptions): Promise { const raw = await withMappedErrors(() => this.inner.restart(options)); return new Sandbox(raw, this.name); } /** * Stop and remove this exact persisted sandbox. * * Graceful shutdown and a ten-second convergence timeout are the defaults. * The identity check refuses to remove a same-name replacement. */ async destroy(options?: DestroyOptions): Promise { await withMappedErrors(() => this.inner.destroy(options)); } async waitUntilStopped(): Promise { return sandboxStopResultFromNapi( await withMappedErrors(() => this.inner.waitUntilStopped()), ); } /** * Consume this handle without stopping the sandbox. New guest and filesystem * operations on this handle are rejected; already admitted operations retain * their connection. Await operations first when their completion matters. */ async detach(): Promise { await withMappedErrors(() => this.inner.detach()); } async [Symbol.asyncDispose](): Promise { if (!this.ownsLifecycle) return; try { await this.inner.stop(); } catch { // best-effort dispose } } } function sandboxStopResultFromNapi(result: { name: string; status: string; exitCode?: number | null; signal?: number | null; observedAt: number; source?: string | null; }): SandboxStopResult { return { name: result.name, status: result.status as SandboxStopResult["status"], exitCode: result.exitCode ?? null, signal: result.signal ?? null, observedAt: new Date(result.observedAt), source: result.source ?? null, }; }