diff --git a/README.md b/README.md index c182c4a..477b603 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,27 @@ other fields once suspected missing (`autoDestroyAt`, `autoPauseInterval`, `processSandboxDto()` copies `sandboxClass`/`warmPoolId` and a subsequent SDK bump picks that up — `runtime.test.ts`'s `DaytonaRuntime smoke` suite has a load-bearing regression test that fails once that happens. +### Freestyle runtime contract + +`FreestyleRuntime` uses an explicit API key, home directory, ownership-name +prefix, and persistence policy. It never reads ambient credentials. Freestyle +does not expose labels on VM creation, so label lookup and warm leasing remain +unsupported; ownership and cleanup are scoped to collision-safe names under the +configured prefix. Deleted list rows are treated as gone only when the provider +sets `deleted: true`. + +The adapter exposes buffered exec, file transfer, reattachment, owned-resource +listing, and verified deletion. Stop/start methods exist as a conservative +probe surface, but lifecycle remains undeclared because the live validation +account could not create a persistent VM. PTY, snapshots, streaming logs, fork, +and never-idle behavior are likewise not advertised without the required live +proof through this package's public port. + +The official SDK is isolated under `src/freestyle/internal/`; public +configuration and capability metadata do not import vendor types. All create, +lookup, exec, lifecycle, and deletion operations have explicit deadlines. See +[the Freestyle adapter notes](./docs/freestyle.md) for dependency provenance, +provider constraints, and capability evidence. ### Vercel Sandbox runtime contract diff --git a/docs/freestyle.md b/docs/freestyle.md new file mode 100644 index 0000000..d8aa0e3 --- /dev/null +++ b/docs/freestyle.md @@ -0,0 +1,110 @@ +# Freestyle adapter + +## Configuration and ownership + +Install the exact peer used to validate this adapter: + +```bash +npm install freestyle@0.1.63 +``` + +Construct `FreestyleRuntime` with an explicit `apiKey`, `defaultHomeDir`, +`namePrefix`, and `persistence`. The adapter does not read `process.env` and +does not choose a vendor tier on the caller's behalf. Launch-time environment +variables are rejected because the provider create operation has no equivalent +field; silently dropping them would violate the shared port. + +Freestyle create has no label field. `findByLabels`, `findAllByLabels`, and +`countByLabels` therefore return no lease match, and `warmLease` is false. +`listOwned` filters the provider's authoritative VM list by the exact configured +name prefix. Mutation methods require an owned handle; external attachments are +read/exec capable but cannot be started, stopped, or deleted by default. + +## Lifecycle and cleanup + +Provider stop, start, suspend, and delete calls may return before the VM reaches +a settled state. The adapter polls the authoritative list and waits through +transitional states. Start reapplies the caller's exact idle-timeout setting; +it does not invent a one-year timeout or invoke an undocumented suspend +workaround. Deletion succeeds only after the VM is absent or its retained list +row has `deleted: true`. + +The lifecycle methods are implemented, but the declared `lifecycle` flag +remains false, because whether they work is a function of the configured +persistence and a single static flag cannot say that. Measured live on +2026-08-22 against `freestyle@0.1.63`: + +| Persistence | `stop` | `start` | exec after start | filesystem across stop/start | +| --- | --- | --- | --- | --- | +| `ephemeral` | VM is **destroyed**, not stopped | n/a | n/a | n/a | +| `sticky` | settles to `stopped` | works (5 of 6 attempts) | works | preserved | +| `persistent` | rejected at create: `PERSISTENT_VMS_NOT_ALLOWED` (plan limit) | n/a | n/a | n/a | + +Under `ephemeral` — the persistence the adapter was validated with — `stop` does +not stop the VM: the adapter observes it leave the provider listing entirely and +raises `Freestyle VM "" disappeared while waiting for stopped`. Declaring +`lifecycle: true` would therefore be wrong for the configuration most callers +start from. + +Under `sticky` the capability is real and was proven end to end: four +consecutive stop → start → exec cycles on one VM all succeeded, with a marker +file written before the first stop still readable after the fourth start. One +earlier `start` in the same session returned a provider-side +`INTERNAL_ERROR: Internal server error` (5 of 6 starts succeeded overall), so +callers driving `sticky` lifecycle should expect to retry `start`. + +Promoting the flag honestly requires deriving it from `options.persistence` +rather than flipping the shared constant; that change is deliberately not made +here. Snapshot, fork, PTY survival, and never-idle behavior remain unadvertised +— this package's ports do not reach them. Exec is buffered by the SDK; +`streamingLogs` is false. + +Deletion behavior is established. The 2026-08-20 run deleted a clean canary and +seven sequential VMs, reconciled four concurrent VMs plus one late timed-out +allocation through the run ledger, and found zero live resources in a fresh +exact-prefix audit. The 2026-08-22 revalidation repeated this on the final +source: every VM created across the revalidation and the lifecycle probes was +deleted, and an account-wide audit afterwards returned zero VM rows in total. +`freestyleObservedCapabilities.cleanupVerified` records only that measured +fact. A width-five create probe returned four handles +while the fifth encountered burst-quota 429 retries and crossed the explicit +120-second deadline; capacity is not declared as an adapter capability. + +Late-create reconciliation. When a `launch()` call rejects with +`FreestyleCreateTimeoutError` and the caller did not supply a name (so the +runtime generated a fresh UUID-based one), the adapter now schedules a +background cleanup: it awaits the underlying SDK call, and if the provider +eventually hands back a VM under that unique name, it issues a verified +`destroy` for it. Caller-supplied names produce deterministic slug names that +two concurrent launches can share, so those are not reconciled by name — they +remain the caller's responsibility to sweep. Short-lived processes that need +to know all late allocations have been reclaimed should `await runtime.close()` +before exit; long-lived hosts can ignore it. + +## Provider shape and pricing caveat + +The official VM documentation describes a default of 4 vCPU, 8 GB memory, and +20 GB storage, and the pricing page says the free tier cannot select custom VM +sizing. The validation run observed 4 vCPU, 8192 MiB memory, and 16000 MiB +rootfs through the SDK list response. Comparisons must preserve that delivered +shape rather than normalize it to the documented 20 GB value. + +- VM docs: +- lifecycle docs: +- pricing: + +## Dependency and design provenance + +- npm package: `freestyle@0.1.63` (exact, not a range) +- npm integrity: + `sha512-sNmr4UHr9abaEQeNzOra4csLrU72qjwPm4lA1i3IYM9pbmkvQ8aOOH/61yawNSVIhxqIoYO9xBcrMNwjqZaWMw==` +- published package `gitHead`: `d8cd601120da42348ea1b440b8d2bf9bdce4947b` +- lockfile: `package-lock.json` records the resolved tarball and integrity + +The architecture was also compared with Amika's Apache-2.0 sandbox provider +design at commit `1870db202a07eb388cacaec22e681f4c564150eb`: +. +The SDK isolation, SDK-free configuration/capabilities, construction-time +capability reconciliation, and provider-neutral provisioning principles were +reimplemented against this repository's existing port; no Amika source was +copied. diff --git a/package-lock.json b/package-lock.json index 4b45ce6..2fef389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@types/node": "^22", "@vercel/sandbox": "3.0.1", "e2b": "^2.35.0", + "freestyle": "0.1.63", "microsandbox": "^0.6.11", "modal": "^0.9.0", "tsx": "^4.20.6", @@ -25,6 +26,7 @@ "@daytonaio/sdk": ">=0.205.0 <0.206.0", "@vercel/sandbox": ">=3.0.1 <4.0.0", "e2b": ">=2.35.0 <3.0.0", + "freestyle": "0.1.63", "microsandbox": ">=0.6.11 <0.7.0", "modal": ">=0.9.0 <0.10.0" }, @@ -38,6 +40,9 @@ "e2b": { "optional": true }, + "freestyle": { + "optional": true + }, "microsandbox": { "optional": true }, @@ -2702,6 +2707,214 @@ "dev": true, "license": "MIT" }, + "node_modules/freestyle": { + "version": "0.1.63", + "resolved": "https://registry.npmjs.org/freestyle/-/freestyle-0.1.63.tgz", + "integrity": "sha512-sNmr4UHr9abaEQeNzOra4csLrU72qjwPm4lA1i3IYM9pbmkvQ8aOOH/61yawNSVIhxqIoYO9xBcrMNwjqZaWMw==", + "dev": true, + "bin": { + "freestyle": "cli.mjs", + "freestyle-sandboxes": "cli.mjs" + }, + "optionalDependencies": { + "dotenv": "^17.3.1", + "glob": "^13.0.0", + "yargs": "^18.0.0" + } + }, + "node_modules/freestyle/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/freestyle/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/freestyle/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/freestyle/node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/freestyle/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/freestyle/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/freestyle/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/freestyle/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/freestyle/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/freestyle/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/freestyle/node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/freestyle/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "optional": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2737,6 +2950,20 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", diff --git a/package.json b/package.json index 5d117b7..c8eb760 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "files": [ "dist", + "docs/freestyle.md", "README.md", "LICENSE", "package.json" @@ -28,6 +29,7 @@ "@daytonaio/sdk": ">=0.205.0 <0.206.0", "@vercel/sandbox": ">=3.0.1 <4.0.0", "e2b": ">=2.35.0 <3.0.0", + "freestyle": "0.1.63", "microsandbox": ">=0.6.11 <0.7.0", "modal": ">=0.9.0 <0.10.0" }, @@ -41,6 +43,9 @@ "e2b": { "optional": true }, + "freestyle": { + "optional": true + }, "microsandbox": { "optional": true }, @@ -53,6 +58,7 @@ "@types/node": "^22", "@vercel/sandbox": "3.0.1", "e2b": "^2.35.0", + "freestyle": "0.1.63", "microsandbox": "^0.6.11", "modal": "^0.9.0", "tsx": "^4.20.6", diff --git a/src/freestyle/capabilities.ts b/src/freestyle/capabilities.ts new file mode 100644 index 0000000..70aff19 --- /dev/null +++ b/src/freestyle/capabilities.ts @@ -0,0 +1,59 @@ +import type { + DeclaredSandboxRuntimeCapabilities, +} from "../port.js"; +import type { RuntimeCapabilities } from "../types.js"; + +/** + * SDK-free capability metadata. Behavioral claims stay conservative until a + * live negative/positive probe establishes them against the pinned SDK/API. + */ +export const freestyleSandboxCapabilities = { + // Freestyle create has no label field. Name-scoped ownership is useful for + // cleanup, but it is not a server-side label lease implementation. + warmLease: false, + // Measured live 2026-08-22: stop/start/exec round-trips cleanly under + // `sticky` persistence, but under `ephemeral` — what this adapter is + // validated with — `stop` destroys the VM rather than stopping it. The + // capability is therefore a function of `options.persistence`, which one + // shared constant cannot express, so it stays false rather than claiming + // something untrue for the default configuration. See docs/freestyle.md. + lifecycle: false, +} as const satisfies DeclaredSandboxRuntimeCapabilities; + +export const freestyleWorkflowCapabilities = { + // The SDK has PTY and snapshot APIs, but this package's WorkflowRuntime port + // exposes neither operation. Capability means reachable through this port. + pty: false, + snapshots: false, + isolation: "strong", + persistentHandle: true, + // vm.exec waits and buffers stdout/stderr; no streaming surface is exposed. + streamingLogs: false, +} as const satisfies RuntimeCapabilities; + +export type FreestyleObservedCapabilities = { + cleanupVerified: boolean; + fork: boolean; + lifecycle: boolean; + neverIdle: boolean; + ptySurvival: boolean; + snapshotCapture: boolean; + streamingExec: boolean; + warmLease: boolean; +}; + +/** Cells not yet proven live remain false rather than inferred from SDK shape. */ +export const freestyleObservedCapabilities: FreestyleObservedCapabilities = { + // Promoted after the 2026-08-20 live canary plus n=7/concurrency run: every + // ledgered VM was absent or deleted:true in a fresh post-run prefix audit. + cleanupVerified: true, + fork: false, + // False as a *declaration*, not as an observation: see the note above. Under + // `sticky` persistence stop -> start -> exec was observed working live. + lifecycle: false, + neverIdle: false, + ptySurvival: false, + snapshotCapture: false, + streamingExec: false, + warmLease: false, +}; diff --git a/src/freestyle/config.ts b/src/freestyle/config.ts new file mode 100644 index 0000000..3bfb260 --- /dev/null +++ b/src/freestyle/config.ts @@ -0,0 +1,32 @@ +/** SDK-free configuration for the Freestyle adapter. */ +export type FreestylePersistence = + | { type: "persistent" } + | { type: "sticky"; priority: number } + | { type: "ephemeral" }; + +export interface FreestyleRuntimeOptions { + /** Freestyle server API key. Never read from ambient process state. */ + apiKey: string; + /** Optional API origin override; omitted to use the pinned SDK's endpoint. */ + baseUrl?: string; + /** Image-specific home directory. Required because no universal default exists. */ + defaultHomeDir: string; + /** Prefix used to identify resources this runtime is allowed to own. */ + namePrefix: string; + /** Optional opaque snapshot id used as the create source. */ + snapshotId?: string; + /** Explicit persistence policy; the adapter never selects a vendor tier. */ + persistence: FreestylePersistence; + /** Forwarded exactly on create and start. `null` means never idle. */ + idleTimeoutSeconds?: number | null; + /** Default client-side request deadline. */ + requestTimeoutMs?: number; + /** Default total deadline for create when the caller does not provide one. */ + createTimeoutMs?: number; + /** Default total deadline for list/get/count operations. */ + lookupTimeoutMs?: number; + /** Deadline for a lifecycle operation to reach a settled state. */ + lifecycleSettleTimeoutMs?: number; + /** Lifecycle and delete-verification polling interval. */ + pollIntervalMs?: number; +} diff --git a/src/freestyle/internal/sdk.ts b/src/freestyle/internal/sdk.ts new file mode 100644 index 0000000..77eb1f3 --- /dev/null +++ b/src/freestyle/internal/sdk.ts @@ -0,0 +1,88 @@ +import type { FreestyleRuntimeOptions } from "../config.js"; + +export type FreestyleVmState = + | "building" + | "starting" + | "running" + | "stopping" + | "suspending" + | "suspended" + | "stopped" + | "lost" + | string; + +export interface FreestyleVmListItem { + id: string; + name?: string | null; + state: FreestyleVmState; + deleted?: boolean | null; + createdAt?: string | null; + lastNetworkActivity?: string | null; + sizing?: { + vcpuCount?: number; + memSizeMib?: number; + rootfsSizeMb?: number; + } | null; +} + +export interface FreestyleVmLike { + readonly vmId?: string; + start(options?: { idleTimeoutSeconds?: number | null }): Promise; + stop(): Promise; + exec(options: string | { + command: string; + terminal?: string; + timeoutMs?: number; + }): Promise<{ + stdout?: string | null; + stderr?: string | null; + statusCode?: number | null; + }>; + fs: { + readFile(path: string): Promise; + writeFile(path: string, content: Buffer): Promise; + readTextFile(path: string): Promise; + writeTextFile(path: string, content: string): Promise; + }; +} + +export interface FreestyleClientLike { + readonly vms: { + create(options?: { + snapshotId?: string | null; + name?: string | null; + idleTimeoutSeconds?: number | null; + persistence?: FreestyleRuntimeOptions["persistence"]; + }): Promise<{ vm: FreestyleVmLike; vmId: string; domains?: string[] }>; + list(): Promise<{ vms: FreestyleVmListItem[] }>; + ref(options: { vmId: string }): FreestyleVmLike; + get?(options: { vmId: string }): Promise<{ vm: FreestyleVmLike }>; + delete(options: { vmId: string }): Promise; + }; +} + +export type FreestyleClientFactory = ( + requestTimeoutMs: number, +) => Promise | FreestyleClientLike; + +/** The only module that imports the vendor SDK. */ +export async function createOfficialFreestyleClient( + options: Pick, + requestTimeoutMs: number, +): Promise { + const { Freestyle } = await import("freestyle"); + // Keep one absolute signal for the lifetime of this short-lived client. The + // official SDK retries some failures internally; creating a fresh timeout in + // every retry would silently reset the caller's deadline. + const deadlineSignal = AbortSignal.timeout(requestTimeoutMs); + return new Freestyle({ + apiKey: options.apiKey, + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}), + fetch: (url, init) => globalThis.fetch(url, { + ...init, + signal: init?.signal + ? AbortSignal.any([init.signal, deadlineSignal]) + : deadlineSignal, + }), + }) as unknown as FreestyleClientLike; +} diff --git a/src/freestyle/runtime.test.ts b/src/freestyle/runtime.test.ts new file mode 100644 index 0000000..84ec4ad --- /dev/null +++ b/src/freestyle/runtime.test.ts @@ -0,0 +1,680 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { FreestyleRuntimeOptions } from "./config.js"; +import type { + FreestyleClientLike, + FreestyleVmLike, + FreestyleVmListItem, +} from "./internal/sdk.js"; +import { + buildFreestyleCommand, + FreestyleCreateTimeoutError, + FreestyleDestroyVerificationError, + FreestyleLaunchEnvironmentUnsupportedError, + FreestyleRuntime, + FreestyleUnknownExitCodeError, +} from "./runtime.js"; + +const BASE_OPTIONS: FreestyleRuntimeOptions = { + apiKey: "unit-test-key-never-sent", + defaultHomeDir: "/root", + namePrefix: "cmpfree-test", + persistence: { type: "persistent" }, + pollIntervalMs: 1, + lifecycleSettleTimeoutMs: 25, + lookupTimeoutMs: 25, + requestTimeoutMs: 25, + createTimeoutMs: 25, +}; + +type Calls = { + clientTimeouts: number[]; + create: unknown[]; + delete: string[]; + exec: unknown[]; + list: number; + ref: string[]; + start: unknown[]; + stop: number; + writeFile: Array<{ path: string; bytes: Buffer }>; +}; + +function mockRuntime(options: { + config?: Partial; + create?: () => Promise<{ vm: FreestyleVmLike; vmId: string }>; + exec?: (input: unknown) => Promise<{ + stdout?: string | null; + stderr?: string | null; + statusCode?: number | null; + }>; + states?: Array; +} = {}): { + runtime: FreestyleRuntime; + calls: Calls; + vm: FreestyleVmLike; +} { + const calls: Calls = { + clientTimeouts: [], + create: [], + delete: [], + exec: [], + list: 0, + ref: [], + start: [], + stop: 0, + writeFile: [], + }; + let stateIndex = 0; + const states = options.states ?? [[{ + id: "vm_1", + name: "cmpfree-test-one", + state: "running", + deleted: false, + }]]; + const vm: FreestyleVmLike = { + async start(input = {}) { + calls.start.push(input); + }, + async stop() { + calls.stop += 1; + }, + async exec(input) { + calls.exec.push(input); + return options.exec + ? options.exec(input) + : { stdout: "", stderr: "", statusCode: 0 }; + }, + fs: { + async readFile() { + return Buffer.from("downloaded", "utf8"); + }, + async writeFile(path, bytes) { + calls.writeFile.push({ path, bytes: Buffer.from(bytes) }); + }, + async readTextFile() { + return "downloaded"; + }, + async writeTextFile() {}, + }, + }; + const client: FreestyleClientLike = { + vms: { + async create(input) { + calls.create.push(input); + return options.create + ? options.create() + : { vm, vmId: "vm_1", domains: [] }; + }, + async list() { + calls.list += 1; + const page = states[Math.min(stateIndex, states.length - 1)] ?? []; + stateIndex += 1; + return { vms: page }; + }, + ref({ vmId }) { + calls.ref.push(vmId); + return vm; + }, + async get() { + throw new Error("vms.get must not be used as a state probe"); + }, + async delete({ vmId }) { + calls.delete.push(vmId); + }, + }, + }; + const runtime = new FreestyleRuntime( + { ...BASE_OPTIONS, ...options.config }, + { + clientFactory: (timeoutMs) => { + calls.clientTimeouts.push(timeoutMs); + return client; + }, + }, + ); + return { runtime, calls, vm }; +} + +describe("FreestyleRuntime public contract", () => { + it("constructs without SDK I/O and advertises only live-observed cleanup", () => { + let factoryCalls = 0; + const runtime = new FreestyleRuntime(BASE_OPTIONS, { + clientFactory: () => { + factoryCalls += 1; + throw new Error("must not construct a client"); + }, + }); + + assert.equal(factoryCalls, 0); + assert.deepEqual(runtime.declaredCapabilities, { + warmLease: false, + lifecycle: false, + }); + assert.deepEqual(runtime.capabilities, { + pty: false, + snapshots: false, + isolation: "strong", + persistentHandle: true, + streamingLogs: false, + }); + assert.equal(runtime.observedCapabilities.cleanupVerified, true); + assert.equal(runtime.observedCapabilities.neverIdle, false); + }); + + it("rejects missing credential/config instead of consulting ambient state", () => { + for (const patch of [ + { apiKey: "" }, + { defaultHomeDir: "" }, + { namePrefix: "" }, + { namePrefix: "not safe/name" }, + ]) { + assert.throws( + () => new FreestyleRuntime({ ...BASE_OPTIONS, ...patch }), + ); + } + assert.throws( + () => new FreestyleRuntime({ ...BASE_OPTIONS, persistence: undefined as never }), + /persistence policy is required/, + ); + }); + + it("real-SDK contract: pinned runtime exposes methods missing from its public declarations", async () => { + const { Freestyle } = await import("freestyle"); + const client = new Freestyle({ + apiKey: "contract-only-not-sent", + fetch: async () => { + throw new Error("network must not be used by structural contract test"); + }, + }); + const vm = client.vms.ref({ vmId: "structural-only" }) as unknown as Record; + + for (const method of ["start", "stop", "suspend", "getInfo", "fork", "snapshot", "exec"]) { + assert.equal(typeof vm[method], "function", `${method} missing from runtime`); + } + assert.equal(typeof (vm.pty as Record).open, "function"); + }); +}); + +describe("FreestyleRuntime launch, lookup, and ownership", () => { + it("launch forwards the explicit persistence/source/idle policy and caller deadline", async () => { + const { runtime, calls } = mockRuntime({ + config: { + snapshotId: "opaque-snapshot-id", + idleTimeoutSeconds: null, + }, + }); + const handle = await runtime.launch({ + name: "cmpfree-test-run-001", + labels: { owner: "unit" }, + createTimeoutSeconds: 3, + workdir: "/workspace", + }); + + assert.equal(handle.id, "vm_1"); + assert.equal(handle.workdir, "/workspace"); + assert.deepEqual(calls.create, [{ + name: "cmpfree-test-run-001", + persistence: { type: "persistent" }, + snapshotId: "opaque-snapshot-id", + idleTimeoutSeconds: null, + }]); + assert.equal(calls.clientTimeouts[0], 3_000); + }); + + it("prefixes foreign caller names with a collision-resistant digest", async () => { + const { runtime, calls } = mockRuntime(); + await runtime.launch({ name: "user supplied name" }); + const name = (calls.create[0] as { name: string }).name; + assert.match(name, /^cmpfree-test-user-supplied-name-[a-f0-9]{8}$/u); + }); + + it("rejects launch env before any SDK call instead of silently dropping it", async () => { + const { runtime, calls } = mockRuntime(); + await assert.rejects( + runtime.launch({ env: { SECRET: "not-actually-secret" } }), + FreestyleLaunchEnvironmentUnsupportedError, + ); + assert.equal(calls.create.length, 0); + }); + + it("turns a stalled create into a typed deadline and leaves no false registration", async () => { + const { runtime, calls } = mockRuntime({ + config: { createTimeoutMs: 5 }, + create: () => new Promise(() => {}), + }); + await assert.rejects(runtime.launch(), FreestyleCreateTimeoutError); + // destroy() returns silently for an unregistered handle, so the absence of + // a throw proves nothing on its own; the delete call is the real evidence + // that the stalled create left no registration behind. + await runtime.destroy({ id: "vm_1" }); + assert.deepEqual(calls.delete, []); + }); + + it("never turns name ownership into a fake server-side label lease", async () => { + const { runtime, calls } = mockRuntime(); + assert.equal(await runtime.findByLabels({ owner: "unit" }), null); + assert.deepEqual(await runtime.findAllByLabels({ owner: "unit" }), []); + assert.equal(await runtime.countByLabels({ owner: "unit" }), 0); + assert.equal(calls.list, 0); + }); + + it("lists only prefix-owned live VMs and consumes runtime-only name/sizing fields", async () => { + const { runtime } = mockRuntime({ + states: [[ + { + id: "owned", + name: "cmpfree-test-run-a", + state: "running", + sizing: { vcpuCount: 4, memSizeMib: 8192, rootfsSizeMb: 20480 }, + }, + { id: "deleted", name: "cmpfree-test-old", state: "stopped", deleted: true }, + { id: "foreign", name: "somebody-else", state: "running" }, + ]], + }); + + assert.deepEqual(await runtime.listOwned(), [{ + id: "owned", + name: "cmpfree-test-run-a", + state: "STARTED", + deleted: false, + sizing: { vcpus: 4, memoryMiB: 8192, storageMiB: 20480 }, + }]); + assert.deepEqual(await runtime.listOwned({ includeDeleted: true, states: null }), [ + { + id: "owned", + name: "cmpfree-test-run-a", + state: "STARTED", + deleted: false, + sizing: { vcpus: 4, memoryMiB: 8192, storageMiB: 20480 }, + }, + { + id: "deleted", + name: "cmpfree-test-old", + state: "STOPPED", + deleted: true, + }, + ]); + }); + + it("reattaches via the authoritative list, treats deleted:true as gone, and never calls vms.get", async () => { + const { runtime, calls } = mockRuntime({ + states: [[{ id: "vm_1", name: "cmpfree-test-one", state: "suspended" }]], + }); + const handle = await runtime.getById("vm_1", { states: ["STOPPED"], owned: true }); + assert.equal(handle?.state, "STOPPED"); + assert.equal(calls.list, 1); + + const { runtime: deletedRuntime } = mockRuntime({ + states: [[{ id: "vm_1", name: "cmpfree-test-one", state: "stopped", deleted: true }]], + }); + assert.equal(await deletedRuntime.getById("vm_1"), null); + }); +}); + +describe("FreestyleRuntime exec and files", () => { + it("passes cwd/env through quoted shell syntax and propagates command/request timeouts", async () => { + const { runtime, calls } = mockRuntime({ + config: { requestTimeoutMs: 10 }, + exec: async () => ({ stdout: "out\n", stderr: "err\n", statusCode: 7 }), + }); + const handle = await runtime.launch(); + const result = await runtime.runScript(handle, { + command: "printf done", + cwd: "/tmp/a b", + env: { VALUE: "it's safe" }, + timeoutMs: 50, + }); + + assert.equal(result.exitCode, 7); + assert.equal(result.output, "out\nerr\n"); + assert.deepEqual(calls.exec.at(-1), { + command: "export VALUE='it'\\''s safe'\ncd '/tmp/a b' || exit $?\nprintf done", + timeoutMs: 50, + }); + assert.equal(calls.clientTimeouts.at(-1), 5_050); + }); + + it("rejects shell-unsafe env names before vm.exec", async () => { + const { runtime, calls } = mockRuntime(); + const handle = await runtime.launch(); + await assert.rejects( + runtime.runScript(handle, { command: "true", env: { "BAD-NAME": "x" } }), + /Invalid Freestyle environment variable name/, + ); + assert.equal(calls.exec.length, 0); + }); + + it("preserves missing statusCode as unknown and exec never fabricates zero", async () => { + const { runtime } = mockRuntime({ exec: async () => ({ stdout: "maybe" }) }); + const handle = await runtime.launch(); + assert.equal((await runtime.runScript(handle, { command: "true" })).exitCode, null); + await assert.rejects(runtime.exec(handle, "true"), FreestyleUnknownExitCodeError); + }); + + it("uploads exact bytes, creates the parent, verifies the bundle, and downloads bytes", async () => { + let execIndex = 0; + const { runtime, calls } = mockRuntime({ + exec: async () => { + execIndex += 1; + return { stdout: "", stderr: "", statusCode: 0 }; + }, + }); + const handle = await runtime.launch(); + await runtime.uploadBundle(handle, { + files: [{ source: Buffer.from([0, 1, 2, 255]), destination: "/workspace/a.bin" }], + }); + assert.equal(execIndex, 2); + assert.equal(calls.writeFile[0]?.path, "/workspace/a.bin"); + assert.deepEqual([...calls.writeFile[0]!.bytes], [0, 1, 2, 255]); + assert.equal((await runtime.downloadFile(handle, "/workspace/a.bin") as Buffer).toString(), "downloaded"); + }); + + it("buildFreestyleCommand rejects unsafe names and quotes values/cwd", () => { + assert.equal( + buildFreestyleCommand("echo ok", { cwd: "/a b", env: { A: "x'y" } }), + "export A='x'\\''y'\ncd '/a b' || exit $?\necho ok", + ); + assert.throws( + () => buildFreestyleCommand("true", { env: { "A;touch /tmp/pwn": "x" } }), + ); + }); +}); + +describe("FreestyleRuntime lifecycle and verified cleanup", () => { + it("stop waits for the provider's accepted request to settle", async () => { + const { runtime, calls } = mockRuntime({ + states: [ + [{ id: "vm_1", name: "cmpfree-test-one", state: "running" }], + [{ id: "vm_1", name: "cmpfree-test-one", state: "stopping" }], + [{ id: "vm_1", name: "cmpfree-test-one", state: "stopped" }], + ], + }); + const handle = await runtime.launch(); + await runtime.stop(handle); + assert.equal(calls.stop, 1); + assert.equal(handle.state, "STOPPED"); + assert.ok(calls.list >= 3); + }); + + it("start waits out a transitional stop and reapplies null idle timeout exactly", async () => { + const { runtime, calls } = mockRuntime({ + config: { idleTimeoutSeconds: null }, + states: [ + [{ id: "vm_1", name: "cmpfree-test-one", state: "stopping" }], + [{ id: "vm_1", name: "cmpfree-test-one", state: "stopping" }], + [{ id: "vm_1", name: "cmpfree-test-one", state: "stopped" }], + [{ id: "vm_1", name: "cmpfree-test-one", state: "stopped" }], + [{ id: "vm_1", name: "cmpfree-test-one", state: "running" }], + ], + }); + const handle = await runtime.launch(); + await runtime.start(handle); + assert.deepEqual(calls.start, [{ idleTimeoutSeconds: null }]); + assert.equal(handle.state, "STARTED"); + }); + + it("default-unowned attachments cannot start, stop, or delete a foreign VM", async () => { + const { runtime, calls } = mockRuntime(); + const handle = await runtime.getById("vm_1"); + assert.ok(handle); + await runtime.stop(handle!); + await runtime.start(handle!); + await runtime.destroy(handle!); + assert.equal(calls.stop, 0); + assert.equal(calls.start.length, 0); + assert.equal(calls.delete.length, 0); + }); + + it("must-fire: destroy accepts deleted:true as verified absence and drops ownership only afterward", async () => { + const { runtime, calls } = mockRuntime({ + states: [[{ id: "vm_1", name: "cmpfree-test-one", state: "stopped", deleted: true }]], + }); + const handle = await runtime.launch(); + await runtime.destroy(handle); + await runtime.destroy(handle); + assert.deepEqual(calls.delete, ["vm_1"]); + }); + + it("must-not-fire: a malformed retained row cannot masquerade as verified absence", async () => { + const malformed = { + id: "vm_1", + name: "cmpfree-test-one", + deleted: false, + } as unknown as FreestyleVmListItem; + const { runtime, calls } = mockRuntime({ states: [[malformed]] }); + const handle = await runtime.launch(); + + await assert.rejects(runtime.destroy(handle), /VM list item 0 is malformed/u); + await assert.rejects(runtime.destroy(handle), /VM list item 0 is malformed/u); + assert.deepEqual(calls.delete, ["vm_1", "vm_1"]); + }); + + it("retains ownership when delete cannot be verified so cleanup can retry", async () => { + const { runtime, calls } = mockRuntime({ + config: { lifecycleSettleTimeoutMs: 4, pollIntervalMs: 1 }, + states: [[{ id: "vm_1", name: "cmpfree-test-one", state: "stopped", deleted: false }]], + }); + const handle = await runtime.launch(); + await assert.rejects(runtime.destroy(handle), FreestyleDestroyVerificationError); + await assert.rejects(runtime.destroy(handle), FreestyleDestroyVerificationError); + assert.deepEqual(calls.delete, ["vm_1", "vm_1"]); + }); + + it("delete request that fails but leaves the VM already gone resolves as verified success", async () => { + // Simulates the outcome-unknown delete path (response lost or timeout + // crossed) where the provider actually performed the deletion. The + // authoritative listing reports deleted:true, so destroy must resolve + // rather than retain ownership on a VM that no longer exists. + let stateIndex = 0; + const states: FreestyleVmListItem[][] = [ + [{ id: "vm_1", name: "cmpfree-test-one", state: "running", deleted: false }], + [{ id: "vm_1", name: "cmpfree-test-one", state: "stopped", deleted: true }], + ]; + const calls = { delete: [] as string[], list: 0 }; + const vm: FreestyleVmLike = { + async start() {}, + async stop() {}, + async exec() { return { stdout: "", stderr: "", statusCode: 0 }; }, + fs: { + async readFile() { return Buffer.alloc(0); }, + async writeFile() {}, + async readTextFile() { return ""; }, + async writeTextFile() {}, + }, + }; + const client: FreestyleClientLike = { + vms: { + async create() { return { vm, vmId: "vm_1", domains: [] }; }, + async list() { + calls.list += 1; + const page = states[Math.min(stateIndex, states.length - 1)] ?? []; + stateIndex += 1; + return { vms: page }; + }, + ref() { return vm; }, + async get() { throw new Error("must not use vms.get"); }, + async delete({ vmId }) { + calls.delete.push(vmId); + // Provider performed the delete but the response is lost. + throw new Error("simulated transport failure after provider deletion"); + }, + }, + }; + const runtime = new FreestyleRuntime( + { ...BASE_OPTIONS, lifecycleSettleTimeoutMs: 25, pollIntervalMs: 1 }, + { clientFactory: () => client }, + ); + const handle = await runtime.launch(); + await runtime.destroy(handle); + assert.deepEqual(calls.delete, ["vm_1"]); + // Ownership is dropped because verification confirmed absence: a retry + // is a no-op instead of a second delete against a gone VM. + await runtime.destroy(handle); + assert.deepEqual(calls.delete, ["vm_1"]); + }); + + it("delete request that fails and the VM is still visible surfaces the original transport error", async () => { + // Same outcome-unknown path, but the provider did NOT perform the + // deletion. The verified-absence check must NOT convert a real failure + // into a silent success, and the caller must see the original error so + // they can decide whether to retry. + const calls = { delete: [] as string[] }; + const vm: FreestyleVmLike = { + async start() {}, + async stop() {}, + async exec() { return { stdout: "", stderr: "", statusCode: 0 }; }, + fs: { + async readFile() { return Buffer.alloc(0); }, + async writeFile() {}, + async readTextFile() { return ""; }, + async writeTextFile() {}, + }, + }; + const client: FreestyleClientLike = { + vms: { + async create() { return { vm, vmId: "vm_1", domains: [] }; }, + async list() { + return { vms: [{ id: "vm_1", name: "cmpfree-test-one", state: "running", deleted: false }] }; + }, + ref() { return vm; }, + async get() { throw new Error("must not use vms.get"); }, + async delete({ vmId }) { + calls.delete.push(vmId); + throw new Error("simulated real delete failure"); + }, + }, + }; + const runtime = new FreestyleRuntime( + { ...BASE_OPTIONS, lifecycleSettleTimeoutMs: 4, pollIntervalMs: 1 }, + { clientFactory: () => client }, + ); + const handle = await runtime.launch(); + await assert.rejects(runtime.destroy(handle), /simulated real delete failure/u); + // Registration is retained so a retry is possible. + await assert.rejects(runtime.destroy(handle), /simulated real delete failure/u); + assert.deepEqual(calls.delete, ["vm_1", "vm_1"]); + }); +}); + +describe("FreestyleRuntime reconciles orphaned creates", () => { + it("late create allocation for a generated-unique name is destroyed after close()", async () => { + // Provider accepts the create but the response outlives the client-side + // deadline. `launch` must reject typed, and the background reconciliation + // must destroy the late-allocated VM once close() is awaited. + const calls = { create: 0, delete: [] as string[], list: 0 }; + let resolveCreate: ((value: { vm: FreestyleVmLike; vmId: string; domains: string[] }) => void) | undefined; + let createdName: string | undefined; + let stateIndex = 0; + const vm: FreestyleVmLike = { + async start() {}, + async stop() {}, + async exec() { return { stdout: "", stderr: "", statusCode: 0 }; }, + fs: { + async readFile() { return Buffer.alloc(0); }, + async writeFile() {}, + async readTextFile() { return ""; }, + async writeTextFile() {}, + }, + }; + const client: FreestyleClientLike = { + vms: { + create(input) { + calls.create += 1; + createdName = input?.name ?? undefined; + return new Promise((resolve) => { + resolveCreate = resolve; + }); + }, + async list() { + calls.list += 1; + const pages: FreestyleVmListItem[][] = [ + // First lookup after late allocation: the VM is visible. + [{ id: "vm_late", name: createdName ?? "unknown", state: "running", deleted: false }], + // waitUntilDeleted polls until the row reports deleted:true. + [{ id: "vm_late", name: createdName ?? "unknown", state: "stopped", deleted: true }], + ]; + const page = pages[Math.min(stateIndex, pages.length - 1)] ?? []; + stateIndex += 1; + return { vms: page }; + }, + ref() { return vm; }, + async get() { throw new Error("must not use vms.get"); }, + async delete({ vmId }) { + calls.delete.push(vmId); + }, + }, + }; + const runtime = new FreestyleRuntime( + { + ...BASE_OPTIONS, + createTimeoutMs: 5, + lifecycleSettleTimeoutMs: 50, + pollIntervalMs: 1, + }, + { clientFactory: () => client }, + ); + + // No caller-supplied name — the runtime generates a UUID-based name, + // which is unambiguously safe to reconcile. + await assert.rejects(runtime.launch(), FreestyleCreateTimeoutError); + assert.equal(calls.delete.length, 0); + assert.ok(createdName, "provider create was invoked"); + + // Late allocation arrives after the client-side deadline fired. + resolveCreate!({ vm, vmId: "vm_late", domains: [] }); + + // Draining pending reconciliations must issue the delete for the late VM. + await runtime.close(); + assert.deepEqual(calls.delete, ["vm_late"]); + }); + + it("late create allocation for a caller-supplied name is NOT reconciled by name", async () => { + // Deterministic slug names are shared across concurrent launches, so a + // "matching-name" reconciliation could destroy a sibling's live VM. The + // adapter must refuse to reconcile in this case, leaving the late VM to + // an out-of-band prefix sweep (documented in docs/freestyle.md). + const calls = { create: 0, delete: [] as string[], list: 0 }; + let resolveCreate: ((value: { vm: FreestyleVmLike; vmId: string; domains: string[] }) => void) | undefined; + const vm: FreestyleVmLike = { + async start() {}, + async stop() {}, + async exec() { return { stdout: "", stderr: "", statusCode: 0 }; }, + fs: { + async readFile() { return Buffer.alloc(0); }, + async writeFile() {}, + async readTextFile() { return ""; }, + async writeTextFile() {}, + }, + }; + const client: FreestyleClientLike = { + vms: { + create() { + calls.create += 1; + return new Promise((resolve) => { + resolveCreate = resolve; + }); + }, + async list() { calls.list += 1; return { vms: [] }; }, + ref() { return vm; }, + async get() { throw new Error("must not use vms.get"); }, + async delete({ vmId }) { calls.delete.push(vmId); }, + }, + }; + const runtime = new FreestyleRuntime( + { ...BASE_OPTIONS, createTimeoutMs: 5 }, + { clientFactory: () => client }, + ); + + await assert.rejects( + runtime.launch({ name: "caller-supplied-name" }), + FreestyleCreateTimeoutError, + ); + resolveCreate!({ vm, vmId: "vm_late", domains: [] }); + await runtime.close(); + // Reconciliation intentionally skipped for caller-named launches. + assert.deepEqual(calls.delete, []); + // No listing call was needed because reconciliation was skipped. + assert.equal(calls.list, 0); + }); +}); diff --git a/src/freestyle/runtime.ts b/src/freestyle/runtime.ts new file mode 100644 index 0000000..b8fa182 --- /dev/null +++ b/src/freestyle/runtime.ts @@ -0,0 +1,1042 @@ +import { Buffer } from "node:buffer"; +import { createHash, randomUUID } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; + +import type { + AsyncRunStartResult, + AsyncRunStatus, + RunScriptResult, + SandboxCountOptions, + SandboxLookupOptions, + SandboxRuntime, +} from "../port.js"; +import type { + ExecOptions, + ExecResult, + LaunchOptions, + RuntimeHandle, + WorkflowRuntime, +} from "../types.js"; +import { + freestyleObservedCapabilities, + freestyleSandboxCapabilities, + freestyleWorkflowCapabilities, +} from "./capabilities.js"; +import type { FreestyleRuntimeOptions } from "./config.js"; +import { + createOfficialFreestyleClient, + type FreestyleClientFactory, + type FreestyleClientLike, + type FreestyleVmLike, + type FreestyleVmListItem, +} from "./internal/sdk.js"; + +const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; +const DEFAULT_CREATE_TIMEOUT_MS = 120_000; +const DEFAULT_LOOKUP_TIMEOUT_MS = 10_000; +const DEFAULT_LIFECYCLE_SETTLE_TIMEOUT_MS = 120_000; +const DEFAULT_POLL_INTERVAL_MS = 500; +const COMMAND_REQUEST_GRACE_MS = 5_000; + +export class FreestyleCreateTimeoutError extends Error { + constructor(readonly timeoutMs: number) { + super(`Freestyle create did not complete within ${timeoutMs}ms`); + this.name = "FreestyleCreateTimeoutError"; + } +} + +export class FreestyleLookupTimeoutError extends Error { + constructor(readonly timeoutMs: number, readonly operation: string) { + super(`Freestyle ${operation} did not complete within ${timeoutMs}ms`); + this.name = "FreestyleLookupTimeoutError"; + } +} + +export class FreestyleLifecycleTimeoutError extends Error { + constructor( + readonly sandboxId: string, + readonly expected: string, + readonly lastState: string, + readonly timeoutMs: number, + ) { + super( + `Freestyle VM "${sandboxId}" did not reach ${expected} within ${timeoutMs}ms ` + + `(last state: "${lastState}")`, + ); + this.name = "FreestyleLifecycleTimeoutError"; + } +} + +export class FreestyleDestroyVerificationError extends Error { + constructor(readonly sandboxId: string, readonly timeoutMs: number) { + super( + `Freestyle VM "${sandboxId}" delete was accepted but absence was not verified ` + + `within ${timeoutMs}ms`, + ); + this.name = "FreestyleDestroyVerificationError"; + } +} + +export class FreestyleUnknownExitCodeError extends Error { + constructor() { + super("Freestyle exec response omitted statusCode; refusing to treat it as success"); + this.name = "FreestyleUnknownExitCodeError"; + } +} + +export class FreestyleLaunchEnvironmentUnsupportedError extends Error { + constructor() { + super( + "Freestyle vms.create has no environment field; launch env must be provisioned " + + "provider-neutrally after create", + ); + this.name = "FreestyleLaunchEnvironmentUnsupportedError"; + } +} + +export class FreestyleCapabilityMismatchError extends Error { + constructor(message: string) { + super(`Freestyle capability declaration mismatch: ${message}`); + this.name = "FreestyleCapabilityMismatchError"; + } +} + +export interface FreestyleAttachedVmOptions { + states?: readonly string[] | null; + owned?: boolean; + homeDir?: string; + workdir?: string; +} + +export interface FreestyleListOwnedOptions { + includeDeleted?: boolean; + states?: readonly string[] | null; + timeoutMs?: number; +} + +export interface FreestyleOwnedVm { + id: string; + name: string; + state: string; + deleted: boolean; + createdAt?: string; + sizing?: { + vcpus?: number; + memoryMiB?: number; + storageMiB?: number; + }; +} + +export interface FreestyleBundleFile { + /** Host path when a string; exact file content when a Buffer. */ + source: string | Buffer; + destination: string; +} + +export interface FreestyleUploadBundleOptions { + files: FreestyleBundleFile[]; + manifest?: unknown; + manifestPath?: string; +} + +/** Test-only injection seam. Not exported from the package barrel. */ +export interface FreestyleRuntimeDependencies { + clientFactory?: FreestyleClientFactory; +} + +type RegisteredVm = { + owned: boolean; + labels: Readonly>; + state?: string; +}; + +export class FreestyleRuntime implements SandboxRuntime, WorkflowRuntime { + readonly id = "freestyle"; + readonly capabilities = freestyleWorkflowCapabilities; + readonly declaredCapabilities = freestyleSandboxCapabilities; + readonly observedCapabilities = freestyleObservedCapabilities; + + private readonly apiKey: string; + private readonly baseUrl?: string; + private readonly defaultHomeDir: string; + private readonly namePrefix: string; + private readonly snapshotId?: string; + private readonly persistence: FreestyleRuntimeOptions["persistence"]; + private readonly idleTimeoutSeconds?: number | null; + private readonly requestTimeoutMs: number; + private readonly createTimeoutMs: number; + private readonly lookupTimeoutMs: number; + private readonly lifecycleSettleTimeoutMs: number; + private readonly pollIntervalMs: number; + private readonly injectedClientFactory?: FreestyleClientFactory; + private readonly registrations = new Map(); + private readonly reconciliations = new Set>(); + + constructor( + options: FreestyleRuntimeOptions, + dependencies: FreestyleRuntimeDependencies = {}, + ) { + this.apiKey = required(options.apiKey, "Freestyle API key"); + this.defaultHomeDir = required(options.defaultHomeDir, "Freestyle default home directory"); + this.namePrefix = validateNamePrefix(options.namePrefix); + this.baseUrl = optionalTrimmed(options.baseUrl); + this.snapshotId = optionalTrimmed(options.snapshotId); + this.persistence = validatePersistence(options.persistence); + this.idleTimeoutSeconds = validateIdleTimeout(options.idleTimeoutSeconds); + this.requestTimeoutMs = positiveDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS); + this.createTimeoutMs = positiveDuration(options.createTimeoutMs, DEFAULT_CREATE_TIMEOUT_MS); + this.lookupTimeoutMs = positiveDuration(options.lookupTimeoutMs, DEFAULT_LOOKUP_TIMEOUT_MS); + this.lifecycleSettleTimeoutMs = positiveDuration( + options.lifecycleSettleTimeoutMs, + DEFAULT_LIFECYCLE_SETTLE_TIMEOUT_MS, + ); + this.pollIntervalMs = positiveDuration(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS); + this.injectedClientFactory = dependencies.clientFactory; + assertFreestyleCapabilityImplementation(this); + } + + async launch(options: LaunchOptions = {}): Promise { + if (hasEntries(options.env)) { + throw new FreestyleLaunchEnvironmentUnsupportedError(); + } + const timeoutMs = options.createTimeoutSeconds && options.createTimeoutSeconds > 0 + ? Math.ceil(options.createTimeoutSeconds * 1_000) + : this.createTimeoutMs; + const callerName = options.name?.trim() || options.label?.trim(); + const name = this.ownedName(callerName); + // Only a name we generated with a fresh UUID is safe to reconcile by name. + // Deterministic slug names (derived from caller-supplied input) can collide + // across concurrent launches — matching on such a name could destroy a + // sibling's live VM. Leave those documented as unreconciled. + const reconcilableName = callerName ? null : name; + const client = await this.client(timeoutMs); + // Hold the raw create promise so it outlives the client-side deadline + // race. `withDeadline` only abandons the wait — the SDK call can still + // complete after we reject, and Freestyle may then hand back a billed VM + // that this adapter never registered. Reconcile that outcome in the + // background so the caller sees a clean rejection without leaking a VM. + const pending = client.vms.create({ + name, + persistence: this.persistence, + ...(this.snapshotId ? { snapshotId: this.snapshotId } : {}), + ...(this.idleTimeoutSeconds !== undefined + ? { idleTimeoutSeconds: this.idleTimeoutSeconds } + : {}), + }); + let created: Awaited>; + try { + created = await withDeadline( + pending, + timeoutMs, + () => new FreestyleCreateTimeoutError(timeoutMs), + ); + } catch (error) { + if (error instanceof FreestyleCreateTimeoutError && reconcilableName) { + this.trackReconciliation( + this.reconcileOrphanedCreate(pending, reconcilableName), + ); + } else { + // Nothing to reconcile — either not our timeout, or a caller-supplied + // name we cannot uniquely correlate. Swallow any late rejection so + // the runtime does not emit an unhandledRejection warning. + this.trackReconciliation(swallow(pending)); + } + throw error; + } + if (!created || typeof created.vmId !== "string" || !created.vmId.trim()) { + throw new Error("Freestyle create response is missing vmId"); + } + const id = created.vmId.trim(); + this.register(id, { + owned: true, + labels: options.labels ?? {}, + state: "STARTED", + }); + return { + id, + state: "STARTED", + homeDir: this.defaultHomeDir, + ...(options.workdir ? { workdir: options.workdir } : {}), + }; + } + + async findByLabels( + _labels: Record, + _options: SandboxLookupOptions = {}, + ): Promise { + // Freestyle vms.create has no label field. Returning a name-based guess here + // would turn ownership naming into a false warm-lease capability. + return null; + } + + async findAllByLabels( + _labels: Record, + _options: SandboxLookupOptions = {}, + ): Promise { + return []; + } + + async countByLabels( + _labels: Record, + _options: SandboxCountOptions = {}, + ): Promise { + return 0; + } + + async listOwned(options: FreestyleListOwnedOptions = {}): Promise { + const items = await this.listRemote(options.timeoutMs ?? this.lookupTimeoutMs, "owned VM list"); + const states = options.states === undefined ? null : options.states; + return items + .filter((item) => typeof item.name === "string" && this.isOwnedName(item.name)) + .filter((item) => options.includeDeleted || !item.deleted) + .filter((item) => matchesListedState(item, states, options.includeDeleted === true)) + .map((item) => ({ + id: item.id, + name: item.name!, + state: normalizeState(item.state), + deleted: Boolean(item.deleted), + ...(item.createdAt ? { createdAt: item.createdAt } : {}), + ...(item.sizing + ? { + sizing: { + ...(typeof item.sizing.vcpuCount === "number" + ? { vcpus: item.sizing.vcpuCount } + : {}), + ...(typeof item.sizing.memSizeMib === "number" + ? { memoryMiB: item.sizing.memSizeMib } + : {}), + ...(typeof item.sizing.rootfsSizeMb === "number" + ? { storageMiB: item.sizing.rootfsSizeMb } + : {}), + }, + } + : {}), + })); + } + + async getById( + id: string, + options: FreestyleAttachedVmOptions = {}, + ): Promise { + const item = await this.remoteById(id, this.lookupTimeoutMs); + if (!item || item.deleted) { + return null; + } + const states = options.states === undefined ? null : options.states; + if (!matchesState(item, states)) { + return null; + } + this.register(id, { + owned: options.owned ?? false, + labels: {}, + state: normalizeState(item.state), + }); + return { + id, + state: normalizeState(item.state), + homeDir: options.homeDir ?? this.defaultHomeDir, + ...(options.workdir ? { workdir: options.workdir } : {}), + ...(item.createdAt ? { createdAt: item.createdAt } : {}), + ...(item.lastNetworkActivity ? { lastActivityAt: item.lastNetworkActivity } : {}), + }; + } + + async runScript( + handle: RuntimeHandle, + options: ExecOptions & { command: string; sessionId?: string }, + ): Promise { + this.requireRegistered(handle); + const command = buildFreestyleCommand(options.command, options); + const commandTimeoutMs = options.timeoutMs && options.timeoutMs > 0 + ? Math.ceil(options.timeoutMs) + : undefined; + const requestTimeoutMs = commandTimeoutMs + ? Math.max(this.requestTimeoutMs, commandTimeoutMs + COMMAND_REQUEST_GRACE_MS) + : this.requestTimeoutMs; + const vm = await this.vm(handle.id, requestTimeoutMs); + const result = await withDeadline( + vm.exec({ + command, + ...(commandTimeoutMs ? { timeoutMs: commandTimeoutMs } : {}), + }), + requestTimeoutMs, + () => new FreestyleLookupTimeoutError(requestTimeoutMs, "exec request"), + ); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + return { + output: combineOutput(stdout, stderr), + ...(stdout ? { stdout } : {}), + ...(stderr ? { stderr } : {}), + exitCode: typeof result.statusCode === "number" ? result.statusCode : null, + }; + } + + async exec( + handle: RuntimeHandle, + command: string, + options: ExecOptions = {}, + ): Promise { + const result = await this.runScript(handle, { command, ...options }); + if (result.exitCode === null) { + throw new FreestyleUnknownExitCodeError(); + } + return { output: result.output, exitCode: result.exitCode }; + } + + async uploadFile( + handle: RuntimeHandle, + source: string | Buffer, + destination: string, + ): Promise { + this.requireRegistered(handle); + const bytes = typeof source === "string" ? await readFile(source) : source; + const parent = parentDirectory(destination); + if (parent) { + const mkdir = await this.runScript(handle, { + command: `mkdir -p ${shellSingleQuote(parent)}`, + timeoutMs: 30_000, + }); + if (mkdir.exitCode !== 0) { + throw new Error(`Failed to create Freestyle upload directory "${parent}"`); + } + } + const vm = await this.vm(handle.id, this.requestTimeoutMs); + await withDeadline( + vm.fs.writeFile(destination, bytes), + this.requestTimeoutMs, + () => new FreestyleLookupTimeoutError(this.requestTimeoutMs, "file upload"), + ); + } + + async uploadBundle( + handle: RuntimeHandle, + options: FreestyleUploadBundleOptions, + ): Promise { + const destinations: string[] = []; + for (const file of options.files) { + await this.uploadFile(handle, file.source, file.destination); + destinations.push(file.destination); + } + if (options.manifest !== undefined) { + const path = options.manifestPath ?? "/workspace/manifest.json"; + await this.uploadFile( + handle, + Buffer.from(JSON.stringify(options.manifest, null, 2), "utf8"), + path, + ); + destinations.push(path); + } + if (destinations.length > 0) { + const verified = await this.runScript(handle, { + command: destinations + .map((path) => `test -f ${shellSingleQuote(path)}`) + .join(" && "), + timeoutMs: 30_000, + }); + if (verified.exitCode !== 0) { + throw new Error("Failed to verify uploaded Freestyle bundle files"); + } + } + } + + async downloadFile( + handle: RuntimeHandle, + source: string, + destination?: string, + ): Promise { + this.requireRegistered(handle); + const vm = await this.vm(handle.id, this.requestTimeoutMs); + const bytes = Buffer.from(await withDeadline( + vm.fs.readFile(source), + this.requestTimeoutMs, + () => new FreestyleLookupTimeoutError(this.requestTimeoutMs, "file download"), + )); + if (destination) { + await writeFile(destination, bytes); + return; + } + return bytes; + } + + async getHomeDir(handle: RuntimeHandle): Promise { + this.requireRegistered(handle); + handle.homeDir = handle.homeDir ?? this.defaultHomeDir; + return handle.homeDir; + } + + async stop(handle: RuntimeHandle): Promise { + const entry = this.registrations.get(handle.id); + if (!entry || !entry.owned) { + return; + } + const current = await this.requireRemote(handle.id); + if (current.state === "stopped" || current.state === "suspended") { + entry.state = "STOPPED"; + handle.state = "STOPPED"; + return; + } + if (current.state === "stopping") { + await this.waitForState(handle.id, new Set(["stopped"]), "stopped"); + } else if (current.state === "suspending") { + await this.waitForState(handle.id, new Set(["suspended"]), "suspended"); + } else { + const vm = await this.vm(handle.id, this.requestTimeoutMs); + await withDeadline( + vm.stop(), + this.requestTimeoutMs, + () => new FreestyleLookupTimeoutError(this.requestTimeoutMs, "stop request"), + ); + await this.waitForState(handle.id, new Set(["stopped"]), "stopped"); + } + entry.state = "STOPPED"; + handle.state = "STOPPED"; + } + + async start(handle: RuntimeHandle): Promise { + const entry = this.registrations.get(handle.id); + if (!entry || !entry.owned) { + return handle; + } + let current = await this.requireRemote(handle.id); + if (current.state === "running") { + handle.state = "STARTED"; + entry.state = "STARTED"; + return handle; + } + if (current.state === "starting" || current.state === "building") { + await this.waitForState(handle.id, new Set(["running"]), "running"); + handle.state = "STARTED"; + entry.state = "STARTED"; + return handle; + } + if (current.state === "suspending") { + await this.waitForState(handle.id, new Set(["suspended"]), "suspended"); + current = await this.requireRemote(handle.id); + } else if (current.state === "stopping") { + await this.waitForState(handle.id, new Set(["stopped"]), "stopped"); + current = await this.requireRemote(handle.id); + } + if (current.state === "lost") { + throw new Error(`Freestyle VM "${handle.id}" is lost and cannot be started`); + } + const vm = await this.vm(handle.id, this.requestTimeoutMs); + await withDeadline( + vm.start( + this.idleTimeoutSeconds !== undefined + ? { idleTimeoutSeconds: this.idleTimeoutSeconds } + : {}, + ), + this.requestTimeoutMs, + () => new FreestyleLookupTimeoutError(this.requestTimeoutMs, "start request"), + ); + await this.waitForState(handle.id, new Set(["running"]), "running"); + entry.state = "STARTED"; + handle.state = "STARTED"; + return handle; + } + + async destroy(handle: RuntimeHandle): Promise { + const entry = this.registrations.get(handle.id); + if (!entry) { + return; + } + if (!entry.owned) { + this.registrations.delete(handle.id); + return; + } + const client = await this.client(this.requestTimeoutMs); + // The delete call and the verification share one teardown, so a lost + // response or a request that crosses `requestTimeoutMs` must not report + // failure without first checking the authoritative list. If the provider + // actually performed the delete, `waitUntilDeleted` will confirm it and + // teardown resolves cleanly; only an unverified failure retains ownership. + let deleteError: unknown; + try { + await withDeadline( + client.vms.delete({ vmId: handle.id }), + this.requestTimeoutMs, + () => new FreestyleLookupTimeoutError(this.requestTimeoutMs, "delete request"), + ); + } catch (error) { + deleteError = error; + } + const verified = await this.waitUntilDeleted(handle.id); + if (!verified) { + if (deleteError !== undefined) { + // Delete request failed and the VM is still visible: surface the + // original transport failure so the caller can decide whether to + // retry, and retain the registration so cleanup remains reachable. + throw deleteError; + } + // Retain the registration so a caller can retry cleanup. + throw new FreestyleDestroyVerificationError( + handle.id, + this.lifecycleSettleTimeoutMs, + ); + } + this.registrations.delete(handle.id); + } + + /** + * Drain in-flight orphaned-create reconciliations. + * + * A `launch()` whose client-side deadline fires may schedule a background + * delete for a VM the provider still allocates. Short-lived processes and + * tests must wait for those before exiting, or the reconciliation is what + * gets leaked. Long-lived hosts can ignore it; either way, this method is + * safe to call more than once and does not surface reconciliation errors. + */ + async close(): Promise { + if (this.reconciliations.size === 0) { + return; + } + await Promise.allSettled([...this.reconciliations]); + } + + // Freestyle has no durable async process object through this adapter. Omit + // the four optional async methods rather than exposing an unpollable submit. + declare readonly startScript?: ( + handle: RuntimeHandle, + options: { + command: string; + sessionId?: string; + timeoutMs?: number; + env?: Record; + suppressInputEcho?: boolean; + }, + ) => Promise; + declare readonly getScriptStatus?: ( + handle: RuntimeHandle, + sessionId: string, + commandId: string, + ) => Promise; + + /** + * Terminate a create that outlived its client-side deadline. + * + * Called only for names this runtime generated with a fresh UUID, so a + * match in the provider listing is unambiguously our own late allocation. + * All failure modes are silent by design: `launch()` has already rejected, + * so there is no caller to raise to. `close()` awaits pending + * reconciliations before the process exits. + */ + private async reconcileOrphanedCreate( + pending: Promise<{ vm: FreestyleVmLike; vmId: string } | unknown>, + name: string, + ): Promise { + let vmId: string | undefined; + try { + const settled = await pending; + if ( + settled + && typeof settled === "object" + && typeof (settled as { vmId?: unknown }).vmId === "string" + && (settled as { vmId: string }).vmId.trim() + ) { + vmId = (settled as { vmId: string }).vmId.trim(); + } + } catch { + // Create ultimately rejected — nothing was allocated, nothing to clean. + return; + } + if (!vmId) { + // No id came back. Fall back to the authoritative listing to find any + // row the provider allocated under the name we asked for. + try { + const owned = await this.listOwned({ states: null }); + const match = owned.find((vm) => vm.name === name); + if (!match) { + return; + } + vmId = match.id; + } catch { + return; + } + } + // Register just long enough to satisfy destroy()'s ownership gate and + // then reuse the same verified-absence teardown the normal path uses. + this.register(vmId, { owned: true, labels: {}, state: "STARTED" }); + try { + await this.destroy({ id: vmId, state: "STARTED", homeDir: this.defaultHomeDir }); + } catch { + // Best effort. The next `listOwned` sweep or a follow-up cleanup pass + // is the safety net; there is no useful action from here. + } + } + + private trackReconciliation(work: Promise): void { + this.reconciliations.add(work); + void work.finally(() => this.reconciliations.delete(work)); + } + + private async client(requestTimeoutMs: number): Promise { + if (this.injectedClientFactory) { + return this.injectedClientFactory(requestTimeoutMs); + } + return createOfficialFreestyleClient( + { apiKey: this.apiKey, ...(this.baseUrl ? { baseUrl: this.baseUrl } : {}) }, + requestTimeoutMs, + ); + } + + private async vm(id: string, requestTimeoutMs: number): Promise { + const client = await this.client(requestTimeoutMs); + return client.vms.ref({ vmId: id }); + } + + private register(id: string, next: RegisteredVm): void { + const existing = this.registrations.get(id); + this.registrations.set(id, { + owned: existing?.owned === true || next.owned, + labels: hasEntries(next.labels) ? { ...next.labels } : existing?.labels ?? {}, + state: next.state ?? existing?.state, + }); + } + + private requireRegistered(handle: RuntimeHandle): RegisteredVm { + const entry = this.registrations.get(handle.id); + if (!entry) { + throw new Error(`Freestyle runtime handle "${handle.id}" is not attached`); + } + return entry; + } + + private async listRemote(timeoutMs: number, operation: string): Promise { + const client = await this.client(timeoutMs); + const response = await withDeadline( + client.vms.list(), + timeoutMs, + () => new FreestyleLookupTimeoutError(timeoutMs, operation), + ); + if (!response || !Array.isArray(response.vms)) { + throw new Error("Freestyle VM list response is malformed"); + } + const malformedIndex = response.vms.findIndex((item) => !isFreestyleVmListItem(item)); + if (malformedIndex !== -1) { + throw new Error(`Freestyle VM list item ${malformedIndex} is malformed`); + } + return response.vms; + } + + private async remoteById(id: string, timeoutMs: number): Promise { + const items = await this.listRemote(timeoutMs, `lookup for VM "${id}"`); + return items.find((item) => item.id === id) ?? null; + } + + private async requireRemote(id: string): Promise { + const item = await this.remoteById(id, this.lookupTimeoutMs); + if (!item || item.deleted) { + throw new Error(`Freestyle VM "${id}" is no longer available`); + } + return item; + } + + private async waitForState( + id: string, + expected: ReadonlySet, + description: string, + ): Promise { + const deadline = Date.now() + this.lifecycleSettleTimeoutMs; + let lastState = "unknown"; + for (;;) { + const remainingMs = Math.max(1, deadline - Date.now()); + const item = await this.remoteById(id, Math.min(this.lookupTimeoutMs, remainingMs)); + if (!item || item.deleted) { + throw new Error(`Freestyle VM "${id}" disappeared while waiting for ${description}`); + } + lastState = item.state; + if (expected.has(item.state)) { + return item; + } + if (item.state === "lost") { + throw new Error(`Freestyle VM "${id}" became lost while waiting for ${description}`); + } + if (Date.now() >= deadline) { + throw new FreestyleLifecycleTimeoutError( + id, + description, + lastState, + this.lifecycleSettleTimeoutMs, + ); + } + await delay(this.pollIntervalMs); + } + } + + private async waitUntilDeleted(id: string): Promise { + const deadline = Date.now() + this.lifecycleSettleTimeoutMs; + for (;;) { + const remainingMs = Math.max(1, deadline - Date.now()); + const item = await this.remoteById(id, Math.min(this.lookupTimeoutMs, remainingMs)); + // Freestyle may keep a soft-deleted row in vms.list; deleted:true is as + // authoritative as absence and must not be mistaken for a leaked VM. + if (!item || item.deleted === true) { + return true; + } + if (Date.now() >= deadline) { + return false; + } + await delay(this.pollIntervalMs); + } + } + + private ownedName(requested?: string): string { + if (!requested) { + return `${this.namePrefix}-${randomUUID().replaceAll("-", "").slice(0, 16)}`; + } + if (this.isOwnedName(requested)) { + return requested; + } + const slug = requested + .normalize("NFKC") + .replace(/[^A-Za-z0-9._-]+/gu, "-") + .replace(/^-+|-+$/gu, "") || "sandbox"; + const digest = createHash("sha256").update(requested).digest("hex").slice(0, 8); + return `${this.namePrefix}-${slug}-${digest}`; + } + + private isOwnedName(name: string): boolean { + return name === this.namePrefix || name.startsWith(`${this.namePrefix}-`); + } +} + +/** + * Construction-time reconciliation between SDK-free declarations and the + * implemented surface. The registry is exhaustive over every capability key. + * Behavioral false claims (warmLease/lifecycle) may retain probe methods; a + * true claim is never allowed without the necessary implementation. + */ +function assertFreestyleCapabilityImplementation(runtime: FreestyleRuntime): void { + const workflowRegistry: Record = { + pty: false, + snapshots: false, + isolation: "strong", + persistentHandle: typeof runtime.getById === "function", + streamingLogs: false, + }; + for (const key of Object.keys(workflowRegistry) as Array) { + if (workflowRegistry[key] !== freestyleWorkflowCapabilities[key]) { + throw new FreestyleCapabilityMismatchError(String(key)); + } + } + const outerRegistry: Record = { + warmLease: false, + lifecycle: typeof runtime.start === "function" && typeof runtime.stop === "function", + }; + if (freestyleSandboxCapabilities.warmLease && !outerRegistry.warmLease) { + throw new FreestyleCapabilityMismatchError("warmLease"); + } + if (freestyleSandboxCapabilities.lifecycle && !outerRegistry.lifecycle) { + throw new FreestyleCapabilityMismatchError("lifecycle"); + } + const observedRegistry: Record = { + cleanupVerified: + typeof runtime.destroy === "function" && typeof runtime.listOwned === "function", + fork: false, + lifecycle: outerRegistry.lifecycle, + neverIdle: false, + ptySurvival: false, + snapshotCapture: false, + streamingExec: false, + warmLease: outerRegistry.warmLease, + }; + for (const key of Object.keys(observedRegistry) as Array) { + if (freestyleObservedCapabilities[key] && !observedRegistry[key]) { + throw new FreestyleCapabilityMismatchError(String(key)); + } + } +} + +export function buildFreestyleCommand(command: string, options: ExecOptions = {}): string { + const statements: string[] = []; + for (const [name, value] of Object.entries(options.env ?? {})) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) { + throw new Error(`Invalid Freestyle environment variable name "${name}"`); + } + statements.push(`export ${name}=${shellSingleQuote(value)}`); + } + if (options.cwd) { + statements.push(`cd ${shellSingleQuote(options.cwd)} || exit $?`); + } + statements.push(command); + return statements.join("\n"); +} + +function validatePersistence( + value: FreestyleRuntimeOptions["persistence"], +): FreestyleRuntimeOptions["persistence"] { + if (!value || typeof value !== "object") { + throw new Error("Freestyle persistence policy is required"); + } + if (value.type === "persistent" || value.type === "ephemeral") { + return { type: value.type }; + } + if ( + value.type === "sticky" + && Number.isFinite(value.priority) + && Number.isInteger(value.priority) + ) { + return { type: "sticky", priority: value.priority }; + } + throw new Error("Freestyle persistence policy is invalid"); +} + +function validateIdleTimeout(value: number | null | undefined): number | null | undefined { + if (value === undefined || value === null) { + return value; + } + if (!Number.isFinite(value) || value < 0) { + throw new Error("Freestyle idleTimeoutSeconds must be null or a non-negative number"); + } + return Math.ceil(value); +} + +function validateNamePrefix(value: string): string { + const prefix = required(value, "Freestyle name prefix"); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(prefix)) { + throw new Error( + "Freestyle name prefix must contain only letters, numbers, dot, underscore, or hyphen", + ); + } + return prefix; +} + +function required(value: string, description: string): string { + const normalized = value?.trim(); + if (!normalized) { + throw new Error(`${description} is required`); + } + return normalized; +} + +function optionalTrimmed(value?: string): string | undefined { + const normalized = value?.trim(); + return normalized || undefined; +} + +function positiveDuration(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) && value > 0 + ? Math.ceil(value) + : fallback; +} + +function hasEntries(value?: object): boolean { + return !!value && Object.keys(value).length > 0; +} + +function isFreestyleVmListItem(value: unknown): value is FreestyleVmListItem { + return Boolean( + value + && typeof value === "object" + && typeof (value as { id?: unknown }).id === "string" + && (value as { id: string }).id.trim() + && typeof (value as { state?: unknown }).state === "string" + && (value as { state: string }).state.trim(), + ); +} + +function normalizeState(state: string): string { + switch (state.toLowerCase()) { + case "running": + return "STARTED"; + case "stopped": + case "suspended": + return "STOPPED"; + case "building": + case "starting": + return "STARTING"; + case "stopping": + case "suspending": + return "STOPPING"; + case "lost": + return "FAILED"; + default: + return state.toUpperCase(); + } +} + +function normalizeRequestedState(state: string): string { + const normalized = state.toUpperCase(); + if (normalized === "RUNNING") return "STARTED"; + if (normalized === "PAUSED" || normalized === "SUSPENDED") return "STOPPED"; + return normalized; +} + +function matchesState( + item: Pick, + states: readonly string[] | null, +): boolean { + if (item.deleted) return false; + if (states === null) return true; + const actual = normalizeState(item.state); + return states.some((state) => normalizeRequestedState(state) === actual); +} + +function matchesListedState( + item: Pick, + states: readonly string[] | null, + includeDeleted: boolean, +): boolean { + if (item.deleted && !includeDeleted) return false; + if (states === null) return true; + const actual = normalizeState(item.state); + return states.some((state) => normalizeRequestedState(state) === actual); +} + +function combineOutput(stdout: string, stderr: string): string { + if (stdout && stderr) { + return stdout.endsWith("\n") || stderr.startsWith("\n") + ? `${stdout}${stderr}` + : `${stdout}\n${stderr}`; + } + return stdout || stderr || ""; +} + +function parentDirectory(destination: string): string | null { + const normalized = destination.trim().replace(/\/+$/gu, ""); + const index = normalized.lastIndexOf("/"); + return index > 0 ? normalized.slice(0, index) : null; +} + +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/gu, `'\\''`)}'`; +} + +async function withDeadline( + operation: Promise, + timeoutMs: number, + timeoutError: () => Error, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(timeoutError()), timeoutMs); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +function delay(timeoutMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, timeoutMs)); +} + +/** + * Consume a promise's eventual settlement without acting on it. + * + * Used when `launch()` has already rejected and there is no productive + * reconciliation to run (e.g. a caller-supplied name we cannot uniquely + * correlate). Without this, a late create rejection would surface as an + * unhandledRejection warning even though nothing wants the result. + */ +async function swallow(operation: Promise): Promise { + try { + await operation; + } catch { + // Ignored on purpose. + } +} diff --git a/src/index.test.ts b/src/index.test.ts index 1b76377..a2de312 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,8 +1,39 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { PACKAGE_NAME } from "./index.js"; +import { + FreestyleRuntime, + PACKAGE_NAME, + resolveSandboxRuntimeCapabilities, +} from "./index.js"; test("package entry point is importable", () => { assert.equal(PACKAGE_NAME, "@agent-relay/sandbox"); }); + +test("must-fire: public Freestyle adapter resolves supported unified capabilities", () => { + const runtime = new FreestyleRuntime({ + apiKey: "structural-only-never-sent", + defaultHomeDir: "/root", + namePrefix: "freestyle-public-contract", + persistence: { type: "ephemeral" }, + }); + + assert.equal(runtime.id, "freestyle"); + assert.equal(resolveSandboxRuntimeCapabilities(runtime).reattach, true); +}); + +test("must-not-fire: public Freestyle adapter never promotes unsupported unified capabilities", () => { + const runtime = new FreestyleRuntime({ + apiKey: "structural-only-never-sent", + defaultHomeDir: "/root", + namePrefix: "freestyle-public-contract", + persistence: { type: "ephemeral" }, + }); + const capabilities = resolveSandboxRuntimeCapabilities(runtime); + + assert.equal(capabilities.asyncExec, false); + assert.equal(capabilities.detachedLaunch, false); + assert.equal(capabilities.warmLease, false); + assert.equal(capabilities.lifecycle, false); +}); diff --git a/src/index.ts b/src/index.ts index f78e4a9..daf93d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,6 +140,34 @@ export type { Agent37FetchResponse, } from "./agent37/client.js"; +export { + FreestyleCapabilityMismatchError, + FreestyleCreateTimeoutError, + FreestyleDestroyVerificationError, + FreestyleLaunchEnvironmentUnsupportedError, + FreestyleLifecycleTimeoutError, + FreestyleLookupTimeoutError, + FreestyleRuntime, + FreestyleUnknownExitCodeError, +} from "./freestyle/runtime.js"; +export type { + FreestyleAttachedVmOptions, + FreestyleBundleFile, + FreestyleListOwnedOptions, + FreestyleOwnedVm, + FreestyleUploadBundleOptions, +} from "./freestyle/runtime.js"; +export type { + FreestylePersistence, + FreestyleRuntimeOptions, +} from "./freestyle/config.js"; +export { + freestyleObservedCapabilities, + freestyleSandboxCapabilities, + freestyleWorkflowCapabilities, +} from "./freestyle/capabilities.js"; +export type { FreestyleObservedCapabilities } from "./freestyle/capabilities.js"; + export { VercelCapabilityMismatchError, VercelDestroyVerificationError,