From b2e6a0d55f78b10aa39883e99b58a3cc471d0bac Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 20 Aug 2026 16:07:00 +0200 Subject: [PATCH 1/6] feat: add Freestyle sandbox provider Session-Id: 01a01f56-15ae-7d92-8c92-bad9d9735cbb Session-Id: 01a020f3-cbce-70b3-9f11-361f8d6ce854 Session-Id: c298d314-9a7f-42d8-9a79-f18379251030 Session-Id: c298d314-9a7f-42d8-9a79-f18379251030 --- README.md | 21 + docs/freestyle.md | 72 +++ package-lock.json | 229 ++++++++- package.json | 8 +- src/freestyle/capabilities.ts | 53 ++ src/freestyle/config.ts | 32 ++ src/freestyle/internal/sdk.ts | 88 ++++ src/freestyle/runtime.test.ts | 445 +++++++++++++++++ src/freestyle/runtime.ts | 904 ++++++++++++++++++++++++++++++++++ src/index.ts | 28 ++ 10 files changed, 1878 insertions(+), 2 deletions(-) create mode 100644 docs/freestyle.md create mode 100644 src/freestyle/capabilities.ts create mode 100644 src/freestyle/config.ts create mode 100644 src/freestyle/internal/sdk.ts create mode 100644 src/freestyle/runtime.test.ts create mode 100644 src/freestyle/runtime.ts diff --git a/README.md b/README.md index dfabdca..22a8a5a 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,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. ## Development diff --git a/docs/freestyle.md b/docs/freestyle.md new file mode 100644 index 0000000..696cc2f --- /dev/null +++ b/docs/freestyle.md @@ -0,0 +1,72 @@ +# 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 for testing and future promotion, but +`lifecycle` remains false. The 2026-08-20 validation account rejected persistent +VM creation, so stop/start followed by exec could not be observed. Snapshot, +fork, PTY survival, and never-idle behavior remain unadvertised for the same +reason. Exec is buffered by the SDK; `streamingLogs` is false. + +The same live run did establish deletion behavior: a clean canary and seven +sequential VMs were deleted, four successful concurrent VMs plus one late +timed-out allocation were reconciled by the run ledger, and a fresh exact-prefix +audit found zero live resources. `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. + +## 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 e586d36..2a0e620 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@daytonaio/sdk": "^0.205.1", "@types/node": "^22", "e2b": "^2.35.0", + "freestyle": "0.1.63", "tsx": "^4.20.6", "typescript": "^5.9.3" }, @@ -20,7 +21,8 @@ }, "peerDependencies": { "@daytonaio/sdk": ">=0.205.0 <0.206.0", - "e2b": ">=2.35.0 <3.0.0" + "e2b": ">=2.35.0 <3.0.0", + "freestyle": "0.1.63" }, "peerDependenciesMeta": { "@daytonaio/sdk": { @@ -28,6 +30,9 @@ }, "e2b": { "optional": true + }, + "freestyle": { + "optional": true } } }, @@ -2373,6 +2378,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", @@ -2408,6 +2621,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 6b535cd..ce693c1 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "files": [ "dist", + "docs/freestyle.md", "README.md", "LICENSE", "package.json" @@ -26,7 +27,8 @@ }, "peerDependencies": { "@daytonaio/sdk": ">=0.205.0 <0.206.0", - "e2b": ">=2.35.0 <3.0.0" + "e2b": ">=2.35.0 <3.0.0", + "freestyle": "0.1.63" }, "peerDependenciesMeta": { "@daytonaio/sdk": { @@ -34,12 +36,16 @@ }, "e2b": { "optional": true + }, + "freestyle": { + "optional": true } }, "devDependencies": { "@daytonaio/sdk": "^0.205.1", "@types/node": "^22", "e2b": "^2.35.0", + "freestyle": "0.1.63", "tsx": "^4.20.6", "typescript": "^5.9.3" }, diff --git a/src/freestyle/capabilities.ts b/src/freestyle/capabilities.ts new file mode 100644 index 0000000..af51d3a --- /dev/null +++ b/src/freestyle/capabilities.ts @@ -0,0 +1,53 @@ +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, + // The adapter implements stop/start as a probe surface. This flag is promoted + // only after stop -> settled state -> start -> exec succeeds live. + 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, + 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..53f505a --- /dev/null +++ b/src/freestyle/runtime.test.ts @@ -0,0 +1,445 @@ +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 } = mockRuntime({ + config: { createTimeoutMs: 5 }, + create: () => new Promise(() => {}), + }); + await assert.rejects(runtime.launch(), FreestyleCreateTimeoutError); + await runtime.destroy({ id: "vm_1" }); + }); + + 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("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("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"]); + }); +}); diff --git a/src/freestyle/runtime.ts b/src/freestyle/runtime.ts new file mode 100644 index 0000000..a38ef43 --- /dev/null +++ b/src/freestyle/runtime.ts @@ -0,0 +1,904 @@ +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(); + + 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 name = this.ownedName(options.name?.trim() || options.label?.trim()); + const client = await this.client(timeoutMs); + const created = await withDeadline( + client.vms.create({ + name, + persistence: this.persistence, + ...(this.snapshotId ? { snapshotId: this.snapshotId } : {}), + ...(this.idleTimeoutSeconds !== undefined + ? { idleTimeoutSeconds: this.idleTimeoutSeconds } + : {}), + }), + timeoutMs, + () => new FreestyleCreateTimeoutError(timeoutMs), + ); + 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); + await withDeadline( + client.vms.delete({ vmId: handle.id }), + this.requestTimeoutMs, + () => new FreestyleLookupTimeoutError(this.requestTimeoutMs, "delete request"), + ); + const verified = await this.waitUntilDeleted(handle.id); + if (!verified) { + // Retain the registration so a caller can retry cleanup. + throw new FreestyleDestroyVerificationError( + handle.id, + this.lifecycleSettleTimeoutMs, + ); + } + this.registrations.delete(handle.id); + } + + // 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; + + 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"); + } + return response.vms.filter(isFreestyleVmListItem); + } + + 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)); +} diff --git a/src/index.ts b/src/index.ts index 9eb6976..7ebc748 100644 --- a/src/index.ts +++ b/src/index.ts @@ -116,6 +116,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 { LocalSandboxRuntime } from "./local/runtime.js"; export type { LocalSandboxRuntimeOptions } from "./local/runtime.js"; From dc0a445ebf000d49703d855e6c5aa6aebe3e70d0 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 20 Aug 2026 23:05:46 +0200 Subject: [PATCH 2/6] test: verify Freestyle unified capability routing Session-Id: 01a020f3-cbce-70b3-9f11-361f8d6ce854 Session-Id: c298d314-9a7f-42d8-9a79-f18379251030 Session-Id: c298d314-9a7f-42d8-9a79-f18379251030 --- src/index.test.ts | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) 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); +}); From 0608aeaba6ecc8b3c5a9cd1e0a21f83938d209b9 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 20 Aug 2026 23:08:48 +0200 Subject: [PATCH 3/6] fix(freestyle): fail closed on malformed VM listings Session-Id: 01a020f3-cbce-70b3-9f11-361f8d6ce854 Session-Id: c298d314-9a7f-42d8-9a79-f18379251030 Session-Id: c298d314-9a7f-42d8-9a79-f18379251030 --- src/freestyle/runtime.test.ts | 16 +++++++++++++++- src/freestyle/runtime.ts | 6 +++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/freestyle/runtime.test.ts b/src/freestyle/runtime.test.ts index 53f505a..070fbeb 100644 --- a/src/freestyle/runtime.test.ts +++ b/src/freestyle/runtime.test.ts @@ -422,7 +422,7 @@ describe("FreestyleRuntime lifecycle and verified cleanup", () => { assert.equal(calls.delete.length, 0); }); - it("destroy accepts deleted:true as verified absence and drops ownership only afterward", async () => { + 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 }]], }); @@ -432,6 +432,20 @@ describe("FreestyleRuntime lifecycle and verified cleanup", () => { 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 }, diff --git a/src/freestyle/runtime.ts b/src/freestyle/runtime.ts index a38ef43..58940bf 100644 --- a/src/freestyle/runtime.ts +++ b/src/freestyle/runtime.ts @@ -594,7 +594,11 @@ export class FreestyleRuntime implements SandboxRuntime, WorkflowRuntime { if (!response || !Array.isArray(response.vms)) { throw new Error("Freestyle VM list response is malformed"); } - return response.vms.filter(isFreestyleVmListItem); + 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 { From 651e295afe0af2f1c6e1598c99ebb183f77cc00a Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 22 Aug 2026 09:39:53 +0200 Subject: [PATCH 4/6] docs(freestyle): replace unobserved lifecycle claim with measured evidence The adapter shipped a claim that stop/start could not be observed because the validation account rejected persistent VM creation. Live probing on 2026-08-22 against freestyle@0.1.63 observed it, and the real answer is more specific than "unknown". Lifecycle is a function of the configured persistence: ephemeral stop destroys the VM rather than stopping it; the adapter raises "disappeared while waiting for stopped" sticky stop settles to `stopped`, start returns to STARTED, exec works, and the filesystem survives the round trip -- four consecutive cycles on one VM, marker file intact persistent still rejected at create with PERSISTENT_VMS_NOT_ALLOWED So `lifecycle: false` stays, but for a different and now-stated reason: it is true under sticky and false under ephemeral, and one shared constant cannot say that. Promoting it means deriving it from `options.persistence`, which is left as a follow-up rather than folded into a validated adapter. Also records the one provider-side INTERNAL_ERROR seen on start (5 of 6 starts succeeded) so callers know to retry, and the account-wide zero-VM audit from the 2026-08-22 revalidation. No behavioral change: comments and documentation only. Co-Authored-By: Claude Opus 5 Session-Id: c298d314-9a7f-42d8-9a79-f18379251030 Session-Id: c298d314-9a7f-42d8-9a79-f18379251030 --- docs/freestyle.md | 49 +++++++++++++++++++++++++++-------- src/freestyle/capabilities.ts | 10 +++++-- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/docs/freestyle.md b/docs/freestyle.md index 696cc2f..90a5823 100644 --- a/docs/freestyle.md +++ b/docs/freestyle.md @@ -29,17 +29,44 @@ 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 for testing and future promotion, but -`lifecycle` remains false. The 2026-08-20 validation account rejected persistent -VM creation, so stop/start followed by exec could not be observed. Snapshot, -fork, PTY survival, and never-idle behavior remain unadvertised for the same -reason. Exec is buffered by the SDK; `streamingLogs` is false. - -The same live run did establish deletion behavior: a clean canary and seven -sequential VMs were deleted, four successful concurrent VMs plus one late -timed-out allocation were reconciled by the run ledger, and a fresh exact-prefix -audit found zero live resources. `freestyleObservedCapabilities.cleanupVerified` -records only that measured fact. A width-five create probe returned four handles +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. diff --git a/src/freestyle/capabilities.ts b/src/freestyle/capabilities.ts index af51d3a..70aff19 100644 --- a/src/freestyle/capabilities.ts +++ b/src/freestyle/capabilities.ts @@ -11,8 +11,12 @@ 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, - // The adapter implements stop/start as a probe surface. This flag is promoted - // only after stop -> settled state -> start -> exec succeeds live. + // 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; @@ -44,6 +48,8 @@ export const freestyleObservedCapabilities: FreestyleObservedCapabilities = { // 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, From 5dc15063e005e4e667ca12dba41461ef49e59ec7 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 22 Aug 2026 10:48:38 +0200 Subject: [PATCH 5/6] test(freestyle): assert the stalled-create test's actual claim The test titled "leaves no false registration" ended on a bare `destroy({ id: "vm_1" })` that asserted nothing. `destroy` returns silently for an unregistered handle, so the test passed whether or not the timed-out `launch` had registered the VM -- exactly the condition it claims to rule out. Assert that no delete request was issued, which is the observable evidence that no registration survived. Raised by CodeRabbit on #22. Co-Authored-By: Claude Opus 5 Session-Id: c298d314-9a7f-42d8-9a79-f18379251030 --- src/freestyle/runtime.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/freestyle/runtime.test.ts b/src/freestyle/runtime.test.ts index 070fbeb..e0b6320 100644 --- a/src/freestyle/runtime.test.ts +++ b/src/freestyle/runtime.test.ts @@ -239,12 +239,16 @@ describe("FreestyleRuntime launch, lookup, and ownership", () => { }); it("turns a stalled create into a typed deadline and leaves no false registration", async () => { - const { runtime } = mockRuntime({ + 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 () => { From 99bcbfd78b6f63cd7479754d2d107e2a411ee44a Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 22 Aug 2026 12:46:14 +0200 Subject: [PATCH 6/6] fix(freestyle): reconcile outcome-unknown create and delete against authoritative list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two outcome-unknown paths were leaking billable state: - `launch()` timeout (threads #22:217, #22:233): `withDeadline` abandons the client-side wait but does not abort the SDK call. If Freestyle finished allocating after we rejected, the VM was billed with no handle in reach. Now the raw create promise is held past the deadline race; on typed timeout the runtime schedules a background reconciliation that awaits the late allocation and issues a verified `destroy` for it. Only names the runtime generated with a fresh UUID are reconciled by name — deterministic slug names (caller-supplied input) can collide across concurrent launches, so matching a live sibling by name is refused. `close()` drains pending reconciliations so short-lived processes and tests can wait for cleanup. - `destroy()` on lost delete response (thread #22:525): the request was awaited under `withDeadline` and any throw surfaced before verification could run, so a lost response for a delete the provider actually performed reported failure and retained ownership. Now the failure is captured, the authoritative list is consulted, and verified absence resolves the teardown cleanly. Only an unverified failure keeps the registration and re-throws — preserving the real transport error rather than a verification-timeout error so the caller can decide whether to retry. Modeled on Modal's `trackReconciliation` / `close()` drain and Vercel's verify-after-delete pattern. Tests: three new cases that fail against pre-fix code — late-create allocation is destroyed after `close()`, caller-supplied names are not reconciled by name, and a lost delete response with a gone VM resolves as success. The still-visible failure retains ownership and surfaces the original error. Co-Authored-By: Claude Opus 4.7 Session-Id: bf4d283a-8287-4d01-8530-23c54a94533b --- docs/freestyle.md | 11 ++ src/freestyle/runtime.test.ts | 217 ++++++++++++++++++++++++++++++++++ src/freestyle/runtime.ts | 170 +++++++++++++++++++++++--- 3 files changed, 380 insertions(+), 18 deletions(-) diff --git a/docs/freestyle.md b/docs/freestyle.md index 90a5823..d8aa0e3 100644 --- a/docs/freestyle.md +++ b/docs/freestyle.md @@ -70,6 +70,17 @@ 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 diff --git a/src/freestyle/runtime.test.ts b/src/freestyle/runtime.test.ts index e0b6320..84ec4ad 100644 --- a/src/freestyle/runtime.test.ts +++ b/src/freestyle/runtime.test.ts @@ -460,4 +460,221 @@ describe("FreestyleRuntime lifecycle and verified cleanup", () => { 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 index 58940bf..b8fa182 100644 --- a/src/freestyle/runtime.ts +++ b/src/freestyle/runtime.ts @@ -170,6 +170,7 @@ export class FreestyleRuntime implements SandboxRuntime, WorkflowRuntime { private readonly pollIntervalMs: number; private readonly injectedClientFactory?: FreestyleClientFactory; private readonly registrations = new Map(); + private readonly reconciliations = new Set>(); constructor( options: FreestyleRuntimeOptions, @@ -201,20 +202,47 @@ export class FreestyleRuntime implements SandboxRuntime, WorkflowRuntime { const timeoutMs = options.createTimeoutSeconds && options.createTimeoutSeconds > 0 ? Math.ceil(options.createTimeoutSeconds * 1_000) : this.createTimeoutMs; - const name = this.ownedName(options.name?.trim() || options.label?.trim()); + 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); - const created = await withDeadline( - client.vms.create({ - name, - persistence: this.persistence, - ...(this.snapshotId ? { snapshotId: this.snapshotId } : {}), - ...(this.idleTimeoutSeconds !== undefined - ? { idleTimeoutSeconds: this.idleTimeoutSeconds } - : {}), - }), - timeoutMs, - () => new FreestyleCreateTimeoutError(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"); } @@ -518,13 +546,29 @@ export class FreestyleRuntime implements SandboxRuntime, WorkflowRuntime { return; } const client = await this.client(this.requestTimeoutMs); - await withDeadline( - client.vms.delete({ vmId: handle.id }), - this.requestTimeoutMs, - () => new FreestyleLookupTimeoutError(this.requestTimeoutMs, "delete request"), - ); + // 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, @@ -534,6 +578,22 @@ export class FreestyleRuntime implements SandboxRuntime, WorkflowRuntime { 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?: ( @@ -552,6 +612,64 @@ export class FreestyleRuntime implements SandboxRuntime, WorkflowRuntime { 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); @@ -906,3 +1024,19 @@ async function withDeadline( 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. + } +}