@molecule/api-code-sandbox-flyio 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1425 @@
1
+ /**
2
+ * Fly.io Machines implementation of `SandboxProvider`.
3
+ *
4
+ * Each sandbox is one Fly Machine — a Firecracker microVM — inside a Fly app,
5
+ * managed over the Machines API at `https://api.machines.dev/v1`.
6
+ *
7
+ * @module
8
+ */
9
+ import { getLogger } from '@molecule/api-bond';
10
+ import { t } from '@molecule/api-i18n';
11
+ import { FlyApiClient, FlyApiError, normalizeApiUrl } from './api.js';
12
+ import { buildEgressProbeCommand, DEFAULT_EGRESS_POLICY_NAME, DEFAULT_EGRESS_PROBE_TARGETS, extractPolicyId, parseEgressAllowedPorts, parseEgressProbeTargets, verdictForProbeExit, } from './egress.js';
13
+ import { execCommand, toExecResult } from './exec.js';
14
+ import { assertPrivateRoutesForEnv, decodePrivateRoutes, DEFAULT_PRIVATE_IP_TYPE, encodePrivateRoutes, flycastHost, mergeEgressPorts, parsePrivateServices, PRIVATE_ROUTES_METADATA_SUFFIX, } from './flycast.js';
15
+ import { appNameForProject, mapMachineState, parseSandboxId, toSandboxId } from './ids.js';
16
+ import { createTemplateStore, MAX_PRESIGN_EXPIRY_SECONDS } from './storage.js';
17
+ import * as templates from './templates.js';
18
+ import { shellQuote } from './utilities.js';
19
+ const logger = getLogger();
20
+ /**
21
+ * Default sandbox image. Fly pulls images itself, so this MUST be a reference
22
+ * Fly can resolve — a tag in the org's `registry.fly.io` repository or a public
23
+ * registry. A local `molecule-sandbox:latest` is invisible to Fly.
24
+ */
25
+ const DEFAULT_IMAGE = 'registry.fly.io/molecule-sandbox:latest';
26
+ /** Default Fly region for Machines and volumes. */
27
+ const DEFAULT_REGION = 'iad';
28
+ /** Default per-project Fly app name prefix. */
29
+ const DEFAULT_APP_PREFIX = 'mol-sandbox';
30
+ /** Default metadata key prefix identifying Machines this provider manages. */
31
+ const DEFAULT_METADATA_PREFIX = 'molecule-sandbox';
32
+ /** Default internal port the preview service forwards to (the Vite dev server). */
33
+ const DEFAULT_PREVIEW_PORT = 5173;
34
+ /** Working directory every exec and file operation is relative to. */
35
+ const WORKSPACE_PATH = '/workspace';
36
+ /** Default volume size in GB when `SandboxConfig.resources.diskMB` is unset. */
37
+ const DEFAULT_VOLUME_GB = 10;
38
+ /** Page size for the org-wide Machine listing. */
39
+ const LIST_PAGE_SIZE = 200;
40
+ /** Hard cap on pages fetched while listing, so a bad cursor cannot loop forever. */
41
+ const LIST_MAX_PAGES = 50;
42
+ /**
43
+ * Chunk size for base64-encoded `writeFile` payloads. `sh -c` receives the whole
44
+ * command as ONE argument, which Linux caps at MAX_ARG_STRLEN (128 KB), so a
45
+ * file over ~45 KB has to be written across several appends.
46
+ */
47
+ const WRITE_CHUNK_BASE64 = 60_000;
48
+ /**
49
+ * Budget for the file-operation execs (`cat`, `ls`, `rm`, chunked writes), in ms.
50
+ *
51
+ * This MUST stay inside `DIRECT_EXEC_BUDGET_SECONDS` so file operations take the
52
+ * single-call exec path. Leaving it at the default budget sent every file read
53
+ * through the detach-and-poll path instead — three extra round-trips and a
54
+ * minimum two-second poll wait for a `cat`, and a chunked write large enough to
55
+ * be re-encoded past the launcher's argument limit.
56
+ */
57
+ const FILE_OP_TIMEOUT_MS = 30_000;
58
+ /**
59
+ * Fly's own ceiling on `GET /v1/apps/{app}/machines/{id}/wait`: "This request
60
+ * will block for up to 60 seconds."
61
+ */
62
+ const MAX_WAIT_SECONDS = 60;
63
+ /** Guest size for the throwaway `verifyEgress()` probe Machine. */
64
+ const PROBE_MEMORY_MB = 256;
65
+ /**
66
+ * How long the probe Machine's init command sleeps. It is destroyed explicitly
67
+ * once the probe has run; this plus `auto_destroy` is the backstop that reaps it
68
+ * if this process dies mid-check.
69
+ */
70
+ const PROBE_LIFETIME_SECONDS = 300;
71
+ /** Default per-connection timeout for the egress probe, in ms. */
72
+ const DEFAULT_PROBE_TIMEOUT_MS = 3000;
73
+ /** Extra seconds allowed for the probe exec beyond its connection budget. */
74
+ const PROBE_EXEC_HEADROOM_SECONDS = 10;
75
+ /**
76
+ * Fly.io Machines implementation of `SandboxProvider`.
77
+ *
78
+ * Lifecycle mapping (every endpoint verified against
79
+ * https://docs.machines.dev/openapi.json):
80
+ *
81
+ * | Interface | Fly Machines API |
82
+ * |---|---|
83
+ * | `create` | `POST /v1/apps/{app}/machines` (after ensuring the app exists) |
84
+ * | `get` | `GET /v1/apps/{app}/machines/{id}` |
85
+ * | `list` | `GET /v1/orgs/{org}/machines`, or `GET /v1/apps/{app}/machines` |
86
+ * | `destroy` | `DELETE /v1/apps/{app}/machines/{id}?force=true` |
87
+ * | `start` / `wake` | `POST /v1/apps/{app}/machines/{id}/start` |
88
+ * | `stop` | `POST /v1/apps/{app}/machines/{id}/stop` |
89
+ * | `sleep` | `POST /v1/apps/{app}/machines/{id}/suspend` |
90
+ * | `exec` and all file ops | `POST /v1/apps/{app}/machines/{id}/exec` |
91
+ */
92
+ class FlyioSandboxProvider {
93
+ name = 'flyio';
94
+ config;
95
+ client;
96
+ metadataPrefix;
97
+ /** Emit the `onFileChange` unsupported warning once per provider, not once per sandbox. */
98
+ warnedNoFileWatch = false;
99
+ /**
100
+ * Memoized template store. `undefined` means "not resolved yet", `null` means
101
+ * "resolved, and not configured" — resolution is deferred to first use so a
102
+ * secrets bond can populate the environment after this singleton is built,
103
+ * exactly like the API token.
104
+ */
105
+ templateStoreCache;
106
+ /**
107
+ * Creates the provider.
108
+ * @param config - Fly-specific configuration; every field also has an env fallback.
109
+ * @param client - Injectable API client, for tests. Built from `config` when omitted.
110
+ * @param store - Injectable template object store, for tests. Resolved from
111
+ * `config` (and the environment) on first use when omitted; pass `null` to
112
+ * model an operator who configured no store at all.
113
+ */
114
+ constructor(config = {}, client, store) {
115
+ this.config = config;
116
+ this.metadataPrefix = config.metadataPrefix ?? DEFAULT_METADATA_PREFIX;
117
+ if (store !== undefined)
118
+ this.templateStoreCache = store;
119
+ this.client =
120
+ client ??
121
+ new FlyApiClient({
122
+ // Resolved lazily on each request so a secrets bond can populate the
123
+ // environment after the provider singleton is constructed.
124
+ token: () => this.config.apiToken ?? process.env.FLY_API_TOKEN ?? process.env.FLY_ACCESS_TOKEN,
125
+ baseUrl: normalizeApiUrl(config.apiUrl ?? process.env.FLY_API_HOSTNAME),
126
+ timeoutMs: config.requestTimeoutMs,
127
+ });
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // Configuration resolution
131
+ // ---------------------------------------------------------------------------
132
+ /**
133
+ * Resolves the Fly organization slug that owns sandbox apps.
134
+ * @returns The org slug.
135
+ */
136
+ orgSlug() {
137
+ return this.config.orgSlug ?? process.env.FLY_ORG_SLUG ?? 'personal';
138
+ }
139
+ /**
140
+ * Resolves the Fly region for Machines and volumes.
141
+ * @returns The region code.
142
+ */
143
+ region() {
144
+ return this.config.region ?? process.env.FLY_REGION ?? DEFAULT_REGION;
145
+ }
146
+ /**
147
+ * Whether each project gets its own Fly app on its own custom 6PN network.
148
+ * @returns `true` unless explicitly disabled.
149
+ */
150
+ appPerProject() {
151
+ if (this.config.appPerProject !== undefined)
152
+ return this.config.appPerProject;
153
+ return process.env.FLY_SANDBOX_APP_PER_PROJECT !== 'false';
154
+ }
155
+ /**
156
+ * Resolves the Fly app a project's Machine belongs to.
157
+ *
158
+ * In the default per-project mode this is `<prefix>-<projectId>`, which is
159
+ * also the app's custom 6PN network name — so one tenant's sandbox cannot
160
+ * reach another's over private networking. The shared-app mode puts every
161
+ * tenant on ONE 6PN, which is the same cross-tenant exposure the Docker
162
+ * bond's shared `bridge` network has, and is refused in production.
163
+ * @param projectId - The project id from `SandboxConfig`.
164
+ * @returns The Fly app name.
165
+ * @throws {Error} When shared-app mode is selected in production, or with no app name.
166
+ */
167
+ resolveApp(projectId) {
168
+ if (this.appPerProject()) {
169
+ return appNameForProject(this.config.appPrefix ?? process.env.FLY_SANDBOX_APP_PREFIX ?? DEFAULT_APP_PREFIX, projectId);
170
+ }
171
+ const shared = this.config.appName ?? process.env.FLY_SANDBOX_APP;
172
+ if (process.env.NODE_ENV === 'production') {
173
+ throw new Error('Shared-app mode is forbidden in production (via FLY_SANDBOX_APP_PER_PROJECT=false or ' +
174
+ 'config.appPerProject=false): every Machine in one Fly app shares a 6PN private ' +
175
+ "network, so one tenant's sandbox can reach another tenant's dev-server ports by " +
176
+ 'private IPv6. Use the default per-project apps, each on its own custom 6PN.');
177
+ }
178
+ if (!shared) {
179
+ throw new Error(t('codeSandbox.flyio.error.noAppName', undefined, {
180
+ defaultValue: 'Shared-app mode needs an app name — set FLY_SANDBOX_APP or config.appName, ' +
181
+ 'or leave config.appPerProject at its default (true).',
182
+ }));
183
+ }
184
+ logger.warn('Fly sandboxes are running in shared-app mode — every tenant shares one 6PN private ' +
185
+ 'network and can reach every other tenant sandbox by private IPv6. Use per-project apps.', { app: shared });
186
+ return shared;
187
+ }
188
+ /**
189
+ * Builds the Fly Proxy service exposing a sandbox's preview port, or `undefined`
190
+ * when public exposure is disabled.
191
+ *
192
+ * `autostop: 'suspend'` is the scale-to-zero behaviour this bond exists for:
193
+ * Fly Proxy suspends the Machine when it goes idle and resumes it from its
194
+ * memory snapshot on the next request, so an idle sandbox bills only for its
195
+ * rootfs and volume.
196
+ * @returns The service definition, or `undefined`.
197
+ */
198
+ previewService() {
199
+ if (this.config.publicService === false)
200
+ return undefined;
201
+ return {
202
+ protocol: 'tcp',
203
+ internal_port: this.config.previewPort ?? DEFAULT_PREVIEW_PORT,
204
+ ports: [
205
+ { port: 80, handlers: ['http'], force_https: true },
206
+ { port: 443, handlers: ['tls', 'http'] },
207
+ ],
208
+ autostart: true,
209
+ autostop: this.config.autostop ?? 'suspend',
210
+ min_machines_running: 0,
211
+ };
212
+ }
213
+ // ---------------------------------------------------------------------------
214
+ // App + volume provisioning
215
+ // ---------------------------------------------------------------------------
216
+ /**
217
+ * Resolves the custom 6PN network a project's app lives on, or `undefined` in
218
+ * shared-app mode (where the app is on the org's default network).
219
+ * @param app - The Fly app name.
220
+ * @returns The network name, or `undefined`.
221
+ */
222
+ networkFor(app) {
223
+ if (!this.appPerProject())
224
+ return undefined;
225
+ return this.config.network ?? app;
226
+ }
227
+ /**
228
+ * Resolves the apps every sandbox must reach across its per-project 6PN.
229
+ *
230
+ * Empty in shared-app mode: that app sits on the org's default network, where
231
+ * `<app>.internal` already resolves and Flycast would add nothing.
232
+ * @returns The declared services, or `undefined` when none are configured.
233
+ */
234
+ privateServices() {
235
+ if (!this.appPerProject())
236
+ return undefined;
237
+ return (this.config.privateServices ?? parsePrivateServices(process.env.FLY_SANDBOX_PRIVATE_SERVICES));
238
+ }
239
+ /**
240
+ * Idempotently ensures the Fly app exists, creating it on its own custom 6PN
241
+ * network in per-project mode, applies the egress network policy, and
242
+ * allocates the Flycast routes that let the sandbox reach the tenant database
243
+ * and the egress proxy.
244
+ *
245
+ * A Fly app's network CANNOT be changed after creation, so an app that already
246
+ * exists is used as-is — this never silently "fixes" an app that was created
247
+ * on the shared org network. The egress policy IS re-applied to an existing
248
+ * app, because Fly applies a policy at Machine boot ("restart or redeploy the
249
+ * Machines for changes to take effect") and this runs before the Machine is
250
+ * created.
251
+ * @param app - The Fly app name.
252
+ * @param options - `privateRoutes: false` skips the Flycast allocation, which
253
+ * is what the throwaway `verifyEgress()` app wants: it never connects to
254
+ * anything but the probe targets, and allocating for an app that is deleted
255
+ * seconds later would leak one address per probe on every target app.
256
+ * @returns Target app name → allocated private address, for recording in the
257
+ * Machine's metadata. Empty when no private services are declared.
258
+ */
259
+ async ensureApp(app, options = {}) {
260
+ const existing = await this.client.request(`/apps/${app}`, { nullOn: [404] });
261
+ if (!existing) {
262
+ const body = { name: app, org_slug: this.orgSlug() };
263
+ const network = this.networkFor(app);
264
+ if (network)
265
+ body.network = network;
266
+ let created = true;
267
+ try {
268
+ await this.client.request(`/apps`, { method: 'POST', body });
269
+ logger.info('Created Fly sandbox app', { app, network: body.network });
270
+ }
271
+ catch (error) {
272
+ // 409/422 means another concurrent boot created the same app first, which
273
+ // is exactly the outcome we wanted. Anything else is a real failure.
274
+ if (error instanceof FlyApiError && (error.status === 409 || error.status === 422)) {
275
+ logger.debug('Fly sandbox app already exists', { app });
276
+ created = false;
277
+ }
278
+ else {
279
+ throw error;
280
+ }
281
+ }
282
+ if (created && this.config.assignSharedIpv4)
283
+ await this.assignSharedIpv4(app);
284
+ }
285
+ await this.applyEgressPolicy(app);
286
+ if (options.privateRoutes === false)
287
+ return {};
288
+ return this.ensurePrivateRoutes(app, Boolean(existing));
289
+ }
290
+ /**
291
+ * Allocates a Flycast private address on every declared service app, INTO this
292
+ * project's 6PN, so `<service>.flycast` resolves from inside the sandbox.
293
+ *
294
+ * A failure here is FATAL, for the same reason the egress policy's is: a
295
+ * sandbox whose route to its own database was never created boots perfectly,
296
+ * reports healthy, and then fails to connect somewhere deep inside the user's
297
+ * own application — which is indistinguishable from the user's code being
298
+ * wrong. Failing the boot with the app name and the remedy is the only honest
299
+ * outcome.
300
+ *
301
+ * Self-healing rather than create-once: an app that already exists is checked
302
+ * against the routes recorded on its Machines and any MISSING service is
303
+ * allocated. Fly's own `IPAssignment` carries no network, so a listing on the
304
+ * target app cannot answer "does this project already have a route" — the
305
+ * Machine metadata this provider wrote is the only record.
306
+ * @param app - The Fly app name (which is also its 6PN network name).
307
+ * @param appExisted - Whether the app was already there, in which case its
308
+ * Machines may carry addresses allocated by an earlier boot.
309
+ * @returns Target app name → private address.
310
+ */
311
+ async ensurePrivateRoutes(app, appExisted) {
312
+ const services = this.privateServices();
313
+ const network = this.networkFor(app);
314
+ if (!services?.length || !network)
315
+ return {};
316
+ const routes = appExisted ? await this.recordedPrivateRoutes(app) : {};
317
+ for (const service of services) {
318
+ if (routes[service.app])
319
+ continue;
320
+ const ip = await this.allocatePrivateAddress(service, network);
321
+ if (ip)
322
+ routes[service.app] = ip;
323
+ }
324
+ return routes;
325
+ }
326
+ /**
327
+ * Reads the private addresses an earlier boot recorded on this app's Machines.
328
+ * @param app - The Fly app name.
329
+ * @returns Target app name → private address; empty when nothing is recorded.
330
+ */
331
+ async recordedPrivateRoutes(app) {
332
+ const key = `${this.metadataPrefix}.${PRIVATE_ROUTES_METADATA_SUFFIX}`;
333
+ try {
334
+ const machines = await this.client.request(`/apps/${app}/machines`, {
335
+ nullOn: [404],
336
+ });
337
+ for (const machine of machines ?? []) {
338
+ const recorded = machine.config?.metadata?.[key];
339
+ if (recorded)
340
+ return decodePrivateRoutes(recorded);
341
+ }
342
+ }
343
+ catch (error) {
344
+ // Only the reuse optimization is lost. Falling through allocates the
345
+ // routes again, which costs an address rather than a broken sandbox —
346
+ // and the alternative (treating an unreadable listing as "routes exist")
347
+ // is exactly the "I could not look" ⇒ "it is fine" conflation this
348
+ // provider refuses everywhere else.
349
+ logger.warn('Could not read the Fly sandbox app’s Machines for recorded private routes — ' +
350
+ 'reallocating, which may leave an unreferenced address on the target apps', { app, error });
351
+ }
352
+ return {};
353
+ }
354
+ /**
355
+ * Allocates ONE Flycast private address on a target app, for this project's
356
+ * network.
357
+ *
358
+ * `POST /v1/apps/{app}/ip_assignments` with `{type, network, org_slug}` — the
359
+ * REST equivalent of `fly ips allocate-v6 --private --network <network>`. The
360
+ * request schema (`assignIPRequest`) is in Fly's OpenAPI specification; the
361
+ * `type` value is not enumerated there, so it is configurable.
362
+ * @param service - The declared target app and the port sandboxes dial it at.
363
+ * @param network - The project's 6PN, which requests will originate from.
364
+ * @returns The allocated address, or `undefined` when Fly reports the address
365
+ * already exists and cannot be re-read.
366
+ * @throws {Error} When the allocation fails, or succeeds without returning an
367
+ * address — an allocation this provider can neither verify nor release later
368
+ * is untracked state, and the boot is the last moment it can be reported.
369
+ */
370
+ async allocatePrivateAddress(service, network) {
371
+ let assignment;
372
+ try {
373
+ assignment = await this.client.request(`/apps/${service.app}/ip_assignments`, {
374
+ method: 'POST',
375
+ body: {
376
+ type: this.config.privateIpAssignmentType ?? DEFAULT_PRIVATE_IP_TYPE,
377
+ network,
378
+ org_slug: this.orgSlug(),
379
+ },
380
+ });
381
+ }
382
+ catch (error) {
383
+ // A conflict means an address for this (app, network) pair is already
384
+ // there — from a boot that crashed between allocating and recording. The
385
+ // ROUTE is what the sandbox needs and it exists, so this is not a boot
386
+ // failure; it is an address this provider can no longer release, because
387
+ // Fly's listing does not report which network an address belongs to.
388
+ if (error instanceof FlyApiError && (error.status === 409 || error.status === 422)) {
389
+ logger.warn('A Fly private address for this project’s network already exists on the target app — ' +
390
+ 'the route works, but this one cannot be released on destroy. Reconcile with ' +
391
+ '`fly ips list --app <target>`.', { target: service.app, network, status: error.status });
392
+ return undefined;
393
+ }
394
+ throw new Error(t('codeSandbox.flyio.error.privateRouteFailed', { app: service.app, network }, {
395
+ defaultValue: `Could not allocate a Flycast private address on Fly app "${service.app}" for ` +
396
+ `network "${network}", so a sandbox in that network cannot reach ` +
397
+ `"${flycastHost(service.app)}:${service.port}". Check that the API token may ` +
398
+ `allocate IPs on "${service.app}", that the app exists in organization ` +
399
+ `"${this.orgSlug()}", and that it declares an [http_service] or [services] section ` +
400
+ '— Flycast routes through Fly Proxy and does nothing without one.',
401
+ }), { cause: error });
402
+ }
403
+ if (!assignment?.ip) {
404
+ throw new Error(t('codeSandbox.flyio.error.privateRouteNoAddress', { app: service.app, network }, {
405
+ defaultValue: `Fly allocated a private address on app "${service.app}" for network "${network}" ` +
406
+ 'but returned no address, so it can never be released. Check ' +
407
+ `\`fly ips list --app ${service.app}\` and remove the orphan.`,
408
+ }));
409
+ }
410
+ logger.info('Allocated a Fly private (Flycast) address for a sandbox network', {
411
+ target: service.app,
412
+ network,
413
+ host: flycastHost(service.app),
414
+ port: service.port,
415
+ });
416
+ return assignment.ip;
417
+ }
418
+ /**
419
+ * Releases the Flycast addresses a project's sandbox recorded.
420
+ *
421
+ * Best-effort: the sandbox is being destroyed either way, and an address left
422
+ * behind is a reconciliation item (`fly ips list --app <target>`) rather than
423
+ * a failed teardown. It routes into a 6PN that no longer has any app in it.
424
+ * @param routes - Target app name → private address, from Machine metadata.
425
+ */
426
+ async releasePrivateRoutes(routes) {
427
+ for (const [target, ip] of Object.entries(routes)) {
428
+ try {
429
+ await this.client.request(`/apps/${target}/ip_assignments/${encodeURIComponent(ip)}`, {
430
+ method: 'DELETE',
431
+ nullOn: [404],
432
+ });
433
+ logger.debug('Released a Fly private (Flycast) address', { target, ip });
434
+ }
435
+ catch (error) {
436
+ logger.warn('Failed to release a Fly private (Flycast) address — it will keep pointing at a 6PN ' +
437
+ 'with no apps in it until it is reconciled with `fly ips list`', { target, ip, error });
438
+ }
439
+ }
440
+ }
441
+ /**
442
+ * Resolves the ports the egress network policy allows, or `undefined` when no
443
+ * policy should be applied.
444
+ *
445
+ * The operator's list is UNIONED with the port of every declared private
446
+ * service, because those two are one decision wearing two hats: a policy is
447
+ * deny-by-default once any rule exists, so declaring
448
+ * `molecule-pg-tenant:5432` and leaving the policy at `tcp:3128` would drop
449
+ * every database connection in the fleet with no diagnostic but a timeout.
450
+ * Deriving it is what lets the operator documentation keep saying "never widen
451
+ * this list" — see {@link mergeEgressPorts}.
452
+ * @returns The allowed ports, or `undefined`.
453
+ * @throws {Error} When configured with an EMPTY list. Fly documents the deny
454
+ * default as a consequence of an `allow` rule existing and says nothing
455
+ * about a rule with no ports, so accepting `[]` would ship a security
456
+ * control whose behaviour is a guess.
457
+ */
458
+ egressAllowedPorts() {
459
+ return mergeEgressPorts(this.configuredEgressPorts(), this.privateServices());
460
+ }
461
+ /**
462
+ * Resolves ONLY the ports the operator configured, before the declared private
463
+ * services are merged in.
464
+ * @returns The configured ports, or `undefined` when no policy should be applied.
465
+ * @throws {Error} When configured with an EMPTY list.
466
+ */
467
+ configuredEgressPorts() {
468
+ const configured = this.config.egressAllowedPorts;
469
+ if (configured) {
470
+ if (configured.length === 0) {
471
+ throw new Error(t('codeSandbox.flyio.error.emptyEgressPorts', undefined, {
472
+ defaultValue: 'config.egressAllowedPorts is empty. Fly derives its deny-all default from an ' +
473
+ 'existing allow rule and does not document a rule with no ports, so this provider ' +
474
+ 'refuses to send one. Omit the option for no policy, or allow at least one port.',
475
+ }));
476
+ }
477
+ return configured;
478
+ }
479
+ return parseEgressAllowedPorts(process.env.FLY_SANDBOX_EGRESS_ALLOWED_PORTS);
480
+ }
481
+ /**
482
+ * Applies this provider's egress network policy to an app, updating the
483
+ * existing policy in place when one is already there.
484
+ *
485
+ * A failure here is FATAL to the caller, deliberately. The operator asked for
486
+ * an egress restriction; booting a sandbox for untrusted code without it — and
487
+ * logging a warning nobody reads — is the trade this whole capability exists to
488
+ * refuse.
489
+ * @param app - The Fly app name.
490
+ * @returns Nothing; resolves once the policy is in place (or none was configured).
491
+ */
492
+ async applyEgressPolicy(app) {
493
+ const ports = this.egressAllowedPorts();
494
+ if (!ports)
495
+ return;
496
+ const name = this.config.egressPolicyName ?? DEFAULT_EGRESS_POLICY_NAME;
497
+ let id;
498
+ try {
499
+ // NO TRAILING SLASH. Verified against a real Fly org (2026-08-05):
500
+ // GET /apps/{app}/network_policies -> 200 []
501
+ // GET /apps/{app}/network_policies/ -> 404 "404 page not found"
502
+ // With the slash this always 404'd, and `nullOn: [404]` turned that into a
503
+ // silent downgrade — every apply became a create, stacking a duplicate
504
+ // policy per Machine instead of updating the one this provider owns.
505
+ const listed = await this.client.request(`/apps/${app}/network_policies`, {
506
+ nullOn: [404],
507
+ });
508
+ id = extractPolicyId(listed, name);
509
+ }
510
+ catch (error) {
511
+ // Only the UPDATE-in-place optimization is lost: the POST below still
512
+ // applies the policy, it just risks stacking a duplicate with identical
513
+ // allow rules. Enforcement is what matters, so this does not fail the boot.
514
+ logger.warn('Could not list Fly network policies — applying the egress policy as a create', {
515
+ app,
516
+ error,
517
+ });
518
+ }
519
+ const policy = {
520
+ ...(id ? { id } : {}),
521
+ name,
522
+ selector: { all: true },
523
+ rules: [{ action: 'allow', direction: 'egress', ports }],
524
+ };
525
+ await this.client.request(`/apps/${app}/network_policies`, { method: 'POST', body: policy });
526
+ logger.info('Applied Fly egress network policy', { app, name, ports, updated: Boolean(id) });
527
+ }
528
+ /**
529
+ * Requests a shared Anycast IPv4 for the app so `<app>.fly.dev` serves traffic.
530
+ *
531
+ * Non-fatal: a sandbox is still fully usable over exec and private networking
532
+ * without a public address, so an allocation failure degrades the preview URL
533
+ * rather than failing the boot.
534
+ * @param app - The Fly app name.
535
+ */
536
+ async assignSharedIpv4(app) {
537
+ try {
538
+ await this.client.request(`/apps/${app}/ip_assignments`, {
539
+ method: 'POST',
540
+ body: {
541
+ type: this.config.ipAssignmentType ?? 'shared_v4',
542
+ org_slug: this.orgSlug(),
543
+ region: this.region(),
544
+ },
545
+ });
546
+ }
547
+ catch (error) {
548
+ logger.warn('Failed to assign a shared IPv4 to the Fly sandbox app — the public preview URL will ' +
549
+ 'not resolve until an address is allocated', { app, error });
550
+ }
551
+ }
552
+ /**
553
+ * Sanitizes a caller-supplied volume name into a conservative form.
554
+ *
555
+ * Fly does NOT document the character rules for volume names, so this reduces
556
+ * to `[A-Za-z0-9_]`, which is the form every Fly example uses. Callers pass
557
+ * names like `mol-<uuid>`; the hyphens become underscores.
558
+ * @param name - The caller's volume name.
559
+ * @returns A sanitized Fly volume name.
560
+ */
561
+ volumeName(name) {
562
+ return name.replace(/[^A-Za-z0-9_]/g, '_');
563
+ }
564
+ /**
565
+ * Idempotently ensures a volume exists in the app and returns its id.
566
+ *
567
+ * A Fly volume is app-scoped and attaches to exactly ONE Machine, which is why
568
+ * volumes are provisioned here — inside `create()`, where the app, region and
569
+ * size are all known — rather than through the core's optional
570
+ * `createVolume(name)`; see the module `@remarks`.
571
+ * @param app - The Fly app name.
572
+ * @param name - The caller's volume name.
573
+ * @param sizeGB - Size to create the volume with, in whole GB.
574
+ * @returns The Fly volume id.
575
+ */
576
+ async ensureVolume(app, name, sizeGB) {
577
+ const flyName = this.volumeName(name);
578
+ const existing = await this.findVolume(app, flyName);
579
+ if (existing)
580
+ return existing.id;
581
+ const created = await this.client.request(`/apps/${app}/volumes`, {
582
+ method: 'POST',
583
+ body: {
584
+ name: flyName,
585
+ region: this.region(),
586
+ size_gb: sizeGB,
587
+ encrypted: true,
588
+ },
589
+ });
590
+ if (!created?.id) {
591
+ throw new Error(t('codeSandbox.flyio.error.volumeCreateFailed', { name: flyName }, { defaultValue: `Fly volume "${flyName}" was created but the API returned no id.` }));
592
+ }
593
+ return created.id;
594
+ }
595
+ /**
596
+ * Finds a volume in an app by name, ignoring ones already being destroyed.
597
+ * @param app - The Fly app name.
598
+ * @param flyName - The sanitized Fly volume name.
599
+ * @returns The matching volume, or `null`.
600
+ */
601
+ async findVolume(app, flyName) {
602
+ const volumes = await this.client.request(`/apps/${app}/volumes`, {
603
+ nullOn: [404],
604
+ });
605
+ if (!Array.isArray(volumes))
606
+ return null;
607
+ return (volumes.find((volume) => volume.name === flyName && volume.state !== 'destroyed' && volume.state !== 'destroying') ?? null);
608
+ }
609
+ // ---------------------------------------------------------------------------
610
+ // SandboxProvider
611
+ // ---------------------------------------------------------------------------
612
+ /**
613
+ * Creates a sandbox as a Fly Machine.
614
+ *
615
+ * Unlike the Docker provider — where `create` produces a stopped container —
616
+ * the Machines API LAUNCHES the Machine as part of `POST .../machines`. The
617
+ * returned sandbox therefore already reports a live state, and `start()` is
618
+ * idempotent.
619
+ *
620
+ * `config.templateId` is honored by RESTORING the template's archive into the
621
+ * new Machine before the sandbox is handed back, and a missing template is a
622
+ * hard failure — booting the base image instead would return a healthy-looking
623
+ * sandbox whose filesystem is not the one the caller named. Note the one place
624
+ * this provider cannot match the core's wording: on Fly a template is a
625
+ * filesystem, not an image, so `templateId` does not override `image`. The
626
+ * image still selects the OS and toolchain; the template supplies the captured
627
+ * paths on top of it.
628
+ * @param config - Project id, image, env, volume name, template, labels and resource limits.
629
+ * @returns A `Sandbox` bound to the new Machine.
630
+ */
631
+ async create(config) {
632
+ // Resolved BEFORE anything is provisioned, so a bad template id costs no app,
633
+ // no volume and no Machine.
634
+ let manifest = null;
635
+ if (config.templateId) {
636
+ const found = await templates.readTemplate(this.templateContext(), config.templateId);
637
+ if (!found) {
638
+ throw new Error(t('codeSandbox.flyio.error.templateMissing', { templateId: config.templateId }, {
639
+ defaultValue: `Cannot create a sandbox from template "${config.templateId}": it does not exist. ` +
640
+ 'Commit it first with commitTemplate().',
641
+ }));
642
+ }
643
+ manifest = found.manifest;
644
+ }
645
+ // Also resolved BEFORE anything is provisioned: a sandbox told to dial a
646
+ // `.flycast` host nobody declared would boot healthy and then fail to
647
+ // connect inside the user's own application.
648
+ assertPrivateRoutesForEnv(config.env, this.privateServices());
649
+ const app = this.resolveApp(config.projectId);
650
+ const privateRoutes = await this.ensureApp(app);
651
+ const metadata = {
652
+ [`${this.metadataPrefix}.projectId`]: config.projectId,
653
+ [`${this.metadataPrefix}.managed`]: 'true',
654
+ // Recorded so `destroy()` can release exactly the addresses this project
655
+ // allocated. Fly's IP listing does not report which network an address
656
+ // belongs to, so nothing else can reconstruct this.
657
+ ...(Object.keys(privateRoutes).length
658
+ ? {
659
+ [`${this.metadataPrefix}.${PRIVATE_ROUTES_METADATA_SUFFIX}`]: encodePrivateRoutes(privateRoutes),
660
+ }
661
+ : {}),
662
+ // Recorded for operators reconciling which sandboxes came from which
663
+ // template. It is NOT what `inUse` is derived from — see `templates.ts`.
664
+ ...(config.templateId ? { [`${this.metadataPrefix}.templateId`]: config.templateId } : {}),
665
+ };
666
+ // Caller labels are merged FIRST so the provider's own managed keys always
667
+ // win — a caller can never clobber `managed`/`projectId`.
668
+ if (config.labels && typeof config.labels === 'object' && !Array.isArray(config.labels)) {
669
+ for (const [key, value] of Object.entries(config.labels)) {
670
+ if (key in metadata)
671
+ continue;
672
+ metadata[key] = String(value);
673
+ }
674
+ }
675
+ const machineConfig = {
676
+ image: config.image ?? this.config.baseImage ?? process.env.FLY_SANDBOX_IMAGE ?? DEFAULT_IMAGE,
677
+ env: config.env,
678
+ metadata,
679
+ guest: {
680
+ cpus: config.resources?.cpu ?? this.config.defaultCpu ?? 1,
681
+ cpu_kind: this.config.defaultCpuKind ?? 'shared',
682
+ memory_mb: config.resources?.memoryMB ?? this.config.defaultMemoryMB ?? 1024,
683
+ },
684
+ // `no` matches the Docker provider: a sandbox whose main process exits
685
+ // stays stopped for the caller to inspect, rather than silently looping.
686
+ // Fly's default when unset is `on-failure`.
687
+ restart: { policy: 'no' },
688
+ auto_destroy: false,
689
+ };
690
+ if (config.volumeName) {
691
+ metadata[`${this.metadataPrefix}.volumeName`] = config.volumeName;
692
+ const sizeGB = config.resources?.diskMB
693
+ ? Math.max(1, Math.ceil(config.resources.diskMB / 1024))
694
+ : (this.config.defaultVolumeGB ?? DEFAULT_VOLUME_GB);
695
+ const volumeId = await this.ensureVolume(app, config.volumeName, sizeGB);
696
+ machineConfig.mounts = [{ volume: volumeId, path: WORKSPACE_PATH }];
697
+ }
698
+ const service = this.previewService();
699
+ if (service)
700
+ machineConfig.services = [service];
701
+ const machine = await this.client.request(`/apps/${app}/machines`, {
702
+ method: 'POST',
703
+ body: { region: this.region(), config: machineConfig },
704
+ });
705
+ if (!machine?.id) {
706
+ throw new Error(t('codeSandbox.flyio.error.createFailed', { app }, { defaultValue: `Fly Machine creation in app "${app}" returned no Machine id.` }));
707
+ }
708
+ if (manifest && config.templateId) {
709
+ await this.restoreTemplate(app, machine.id, config.templateId, manifest);
710
+ // The restore waited for `started` and ran a command in it, so the state
711
+ // read back from the creation response is stale by definition.
712
+ return this.buildSandbox(app, machine.id, 'running');
713
+ }
714
+ return this.buildSandbox(app, machine.id, mapMachineState(machine.state ?? 'created'));
715
+ }
716
+ /**
717
+ * Retrieves an existing sandbox by its composite `<app>:<machineId>` id.
718
+ * @param id - The sandbox id returned by `create`.
719
+ * @returns The sandbox, or `null` when the id is malformed or the Machine is gone.
720
+ */
721
+ async get(id) {
722
+ let app;
723
+ let machineId;
724
+ try {
725
+ ;
726
+ ({ app, machineId } = parseSandboxId(id));
727
+ }
728
+ catch (error) {
729
+ logger.warn('Cannot get Fly sandbox — malformed sandbox id', { id, error });
730
+ return null;
731
+ }
732
+ try {
733
+ const machine = await this.client.request(`/apps/${app}/machines/${machineId}`, {
734
+ nullOn: [404],
735
+ });
736
+ if (!machine)
737
+ return null;
738
+ return this.buildSandbox(app, machine.id, mapMachineState(machine.state ?? 'created'));
739
+ }
740
+ catch (error) {
741
+ logger.debug('Failed to get Fly Machine', { id, error });
742
+ return null;
743
+ }
744
+ }
745
+ /**
746
+ * Lists every sandbox this provider manages.
747
+ *
748
+ * In per-project mode the Machines live in many apps, so this pages through
749
+ * `GET /v1/orgs/{org}/machines` and keeps the ones carrying this provider's
750
+ * `managed` metadata. In shared-app mode it lists that app directly.
751
+ * @param _userId - Reserved for future per-user filtering; currently ignored,
752
+ * matching the Docker provider.
753
+ * @returns Every managed sandbox.
754
+ */
755
+ async list(_userId) {
756
+ const managedKey = `${this.metadataPrefix}.managed`;
757
+ if (!this.appPerProject()) {
758
+ const app = this.resolveApp('unused');
759
+ const machines = await this.client.request(`/apps/${app}/machines`, {
760
+ nullOn: [404],
761
+ });
762
+ return (machines ?? [])
763
+ .filter((machine) => machine.config?.metadata?.[managedKey] === 'true')
764
+ .map((machine) => this.buildSandbox(app, machine.id, mapMachineState(machine.state ?? 'created')));
765
+ }
766
+ const sandboxes = [];
767
+ let cursor;
768
+ for (let page = 0; page < LIST_MAX_PAGES; page++) {
769
+ const query = new URLSearchParams({ limit: String(LIST_PAGE_SIZE) });
770
+ if (cursor)
771
+ query.set('cursor', cursor);
772
+ const response = await this.client.request(`/orgs/${this.orgSlug()}/machines?${query.toString()}`, { nullOn: [404] });
773
+ for (const machine of response?.machines ?? []) {
774
+ if (machine.config?.metadata?.[managedKey] !== 'true')
775
+ continue;
776
+ sandboxes.push(this.buildSandbox(machine.app_name, machine.id, mapMachineState(machine.state ?? 'created')));
777
+ }
778
+ cursor = response?.next_cursor || undefined;
779
+ if (!cursor)
780
+ break;
781
+ if (page === LIST_MAX_PAGES - 1) {
782
+ // Fly still had a cursor: this listing is TRUNCATED, and a caller that
783
+ // reaps or reconciles from it would leave the unseen Machines running
784
+ // and billing forever. Say so rather than returning a short list that
785
+ // looks complete. Fly's `limit` is advisory ("Responses may be shorter,
786
+ // or even empty, even when more machines remain"), so a large org can
787
+ // reach this cap well before LIST_MAX_PAGES * LIST_PAGE_SIZE Machines.
788
+ logger.warn('Fly org-wide Machine listing hit its page cap and is TRUNCATED — some managed ' +
789
+ 'sandboxes are missing from this result', { org: this.orgSlug(), pages: LIST_MAX_PAGES, returned: sandboxes.length });
790
+ }
791
+ }
792
+ return sandboxes;
793
+ }
794
+ /**
795
+ * Destroys a sandbox.
796
+ *
797
+ * In per-project mode the whole Fly app is deleted after the Machine, which
798
+ * also removes its volumes and its custom 6PN network — leaving an empty app
799
+ * behind would leak both. In shared-app mode only the Machine and its own
800
+ * volumes are removed.
801
+ *
802
+ * The Flycast addresses this project holds live on OTHER apps (the tenant
803
+ * database, the egress proxy), so deleting the app does not touch them. They
804
+ * are read from the Machine's metadata FIRST — the Machine is the only record
805
+ * of which address belongs to which network — and released last.
806
+ * @param id - The composite sandbox id.
807
+ */
808
+ async destroy(id) {
809
+ const { app, machineId } = parseSandboxId(id);
810
+ let volumeIds = [];
811
+ let privateRoutes = {};
812
+ try {
813
+ const machine = await this.client.request(`/apps/${app}/machines/${machineId}`, {
814
+ nullOn: [404],
815
+ });
816
+ privateRoutes = decodePrivateRoutes(machine?.config?.metadata?.[`${this.metadataPrefix}.${PRIVATE_ROUTES_METADATA_SUFFIX}`]);
817
+ if (!this.appPerProject()) {
818
+ volumeIds = (machine?.config?.mounts ?? [])
819
+ .map((mount) => mount.volume)
820
+ .filter((volume) => Boolean(volume));
821
+ }
822
+ }
823
+ catch (error) {
824
+ // Best-effort metadata gathering; the Machine may already be gone.
825
+ // Destruction proceeds either way — but say so, because an unread
826
+ // metadata block means the private addresses below are NOT released.
827
+ logger.warn('Could not read the Fly Machine before destroy — its volumes and any private (Flycast) ' +
828
+ 'addresses it recorded will not be cleaned up', { id, error });
829
+ }
830
+ try {
831
+ await this.client.request(`/apps/${app}/machines/${machineId}?force=true`, {
832
+ method: 'DELETE',
833
+ nullOn: [404],
834
+ });
835
+ }
836
+ catch (error) {
837
+ logger.warn('Failed to destroy Fly Machine', { id, error });
838
+ }
839
+ if (this.appPerProject()) {
840
+ try {
841
+ await this.client.request(`/apps/${app}`, { method: 'DELETE', nullOn: [404] });
842
+ }
843
+ catch (error) {
844
+ logger.warn('Failed to delete Fly sandbox app — its volumes and 6PN network may leak', {
845
+ app,
846
+ error,
847
+ });
848
+ }
849
+ await this.releasePrivateRoutes(privateRoutes);
850
+ return;
851
+ }
852
+ for (const volumeId of volumeIds) {
853
+ try {
854
+ await this.client.request(`/apps/${app}/volumes/${volumeId}`, {
855
+ method: 'DELETE',
856
+ nullOn: [404],
857
+ });
858
+ }
859
+ catch (error) {
860
+ logger.warn('Failed to remove Fly volume', { app, volumeId, error });
861
+ }
862
+ }
863
+ await this.releasePrivateRoutes(privateRoutes);
864
+ }
865
+ // ---------------------------------------------------------------------------
866
+ // Templates
867
+ // ---------------------------------------------------------------------------
868
+ /**
869
+ * Builds the context the template capability runs against.
870
+ *
871
+ * The store is resolved once and memoized, so a boot that checks for a
872
+ * template does not rebuild an S3 client per call.
873
+ * @returns The template context.
874
+ */
875
+ templateContext() {
876
+ if (this.templateStoreCache === undefined) {
877
+ this.templateStoreCache = createTemplateStore(this.config);
878
+ if (this.templateStoreCache) {
879
+ logger.info('Fly sandbox templates are enabled', {
880
+ store: this.templateStoreCache.describe,
881
+ });
882
+ }
883
+ }
884
+ return {
885
+ store: this.templateStoreCache,
886
+ exec: async (app, machineId, command, timeoutMs) =>
887
+ // `cwd: '/'` rather than the workspace: a freshly-booted Machine that is
888
+ // about to be restored INTO has no `/workspace` yet, and `execCommand`
889
+ // starts every script with a `cd` that would fail there.
890
+ execCommand(this.rawExec(app, machineId), command, { cwd: '/', timeout: timeoutMs }, '/'),
891
+ ensureStarted: async (app, machineId) => {
892
+ await this.startMachine(app, machineId);
893
+ },
894
+ parseSandboxId,
895
+ presignExpirySeconds: Math.max(60, Math.min(this.config.templateUrlExpirySeconds ?? templates.DEFAULT_PRESIGN_EXPIRY_SECONDS, MAX_PRESIGN_EXPIRY_SECONDS)),
896
+ // Floored only against a zero/negative budget: how long a capture may take
897
+ // is the operator's call, and a low value simply fails the transfer rather
898
+ // than doing anything unsafe.
899
+ transferTimeoutMs: Math.max(1000, this.config.templateTransferTimeoutMs ?? templates.DEFAULT_TRANSFER_TIMEOUT_MS),
900
+ maxArchiveBytes: Math.max(1, Math.min(this.config.templateMaxArchiveBytes ?? templates.MAX_ARCHIVE_BYTES, templates.MAX_ARCHIVE_BYTES)),
901
+ warn: (message, meta) => logger.warn(message, meta),
902
+ debug: (message, meta) => logger.debug(message, meta),
903
+ };
904
+ }
905
+ /**
906
+ * Capture a sandbox's filesystem as a reusable template.
907
+ *
908
+ * See `templates.ts` for why this is a tar in object storage rather than
909
+ * anything Fly-native: Fly cannot produce an image from a running Machine, and
910
+ * a volume snapshot cannot cross the per-project app boundary this bond
911
+ * creates for tenant isolation.
912
+ * @param options - What to capture and what to call it.
913
+ * @returns The template as it now exists.
914
+ */
915
+ async commitTemplate(options) {
916
+ return templates.commitTemplate(this.templateContext(), options);
917
+ }
918
+ /**
919
+ * Read one template by the caller's identifier.
920
+ * @param templateId - The caller's identifier.
921
+ * @returns The template, or `null` when no template has that id.
922
+ */
923
+ async getTemplate(templateId) {
924
+ return templates.getTemplate(this.templateContext(), templateId);
925
+ }
926
+ /**
927
+ * Enumerate templates in the configured store.
928
+ * @param options - Narrowing by id prefix.
929
+ * @returns Every matching template.
930
+ */
931
+ async listTemplates(options) {
932
+ return templates.listTemplates(this.templateContext(), options);
933
+ }
934
+ /**
935
+ * Delete a template, refusing while a restore is still reading it.
936
+ * @param templateId - The caller's identifier.
937
+ */
938
+ async removeTemplate(templateId) {
939
+ return templates.removeTemplate(this.templateContext(), templateId);
940
+ }
941
+ /**
942
+ * Restores a template into a freshly-created Machine.
943
+ *
944
+ * A failure here DESTROYS the Machine. The alternative is to hand back a
945
+ * sandbox that started cleanly and whose filesystem is not the one that was
946
+ * asked for — the same silent-wrong-contents failure that makes a missing
947
+ * template a hard error rather than a fallback to the base image.
948
+ * @param app - The Fly app name.
949
+ * @param machineId - The Fly Machine id.
950
+ * @param templateId - The caller's template identifier.
951
+ * @param manifest - The CONTROL-PLANE manifest, whose `capturePaths` bound what is extracted.
952
+ * @throws {Error} When the restore fails; the Machine is destroyed first.
953
+ */
954
+ async restoreTemplate(app, machineId, templateId, manifest) {
955
+ const ctx = this.templateContext();
956
+ const store = templates.requireStore(ctx);
957
+ const leaseId = `${machineId}-${Date.now().toString(36)}`.replace(/[^A-Za-z0-9_.-]/g, '_');
958
+ let lease = null;
959
+ try {
960
+ // The restore runs through exec, so the Machine has to be up. `create`
961
+ // launches it but returns as soon as Fly accepts the request.
962
+ await this.waitForStarted(app, machineId);
963
+ lease = await templates.acquireRestoreLease(ctx, templateId, leaseId);
964
+ const url = await store.presignGet(templates.archiveKey(store, templateId), ctx.presignExpirySeconds);
965
+ const result = await ctx.exec(app, machineId, templates.buildRestoreCommand(manifest.capturePaths, url), ctx.transferTimeoutMs);
966
+ if (result.exitCode !== 0) {
967
+ throw new Error(t('codeSandbox.flyio.error.templateRestoreFailed', { templateId, exitCode: String(result.exitCode) }, {
968
+ defaultValue: `Restoring template "${templateId}" into Fly Machine ${machineId} failed ` +
969
+ `(exit ${result.exitCode}): ${result.stderr.slice(0, 500) || result.stdout.slice(0, 500)}`,
970
+ }));
971
+ }
972
+ }
973
+ catch (error) {
974
+ try {
975
+ await this.client.request(`/apps/${app}/machines/${machineId}?force=true`, {
976
+ method: 'DELETE',
977
+ nullOn: [404],
978
+ });
979
+ }
980
+ catch (cleanupError) {
981
+ logger.warn('Failed to destroy the Fly Machine whose template restore failed — it will keep ' +
982
+ 'billing until it is reaped', { app, machineId, error: cleanupError });
983
+ }
984
+ throw error;
985
+ }
986
+ finally {
987
+ if (lease)
988
+ await templates.releaseRestoreLease(ctx, lease);
989
+ }
990
+ }
991
+ // ---------------------------------------------------------------------------
992
+ // Egress verification
993
+ // ---------------------------------------------------------------------------
994
+ /**
995
+ * PROVES whether a sandbox Machine can open raw connections to the public
996
+ * internet, by running one and watching what happens.
997
+ *
998
+ * **Why it is built this way.** Fly's tenant isolation (custom 6PN networks)
999
+ * answers a different question — whether one app can reach ANOTHER app — and
1000
+ * says nothing about the public internet. The only mechanism Fly documents for
1001
+ * egress is a **network policy**
1002
+ * (https://fly.io/docs/machines/guides-examples/network-policies/), an
1003
+ * app-scoped object whose `allow` rules make everything else in that direction
1004
+ * drop. Reading that policy back would be attestation, not observation, and the
1005
+ * failure this capability replaced was exactly an attestation that stayed true
1006
+ * on paper after it stopped being true in fact. So this creates a throwaway
1007
+ * Machine through the SAME provisioning path every sandbox goes through
1008
+ * (`ensureApp`, which applies the same policy), attempts raw TCP connects to
1009
+ * literal public IPs from inside it, and reports what the sockets did.
1010
+ *
1011
+ * `open` and `filtered` are therefore observations. Everything else — no
1012
+ * targets, no API token, the Machine never booting, a probe image without
1013
+ * `node`, an exec that returned no status — is `inconclusive`, never
1014
+ * `filtered`.
1015
+ *
1016
+ * **What a `filtered` verdict here does and does not mean.** It means raw
1017
+ * egress to the probed ports is dropped. It does NOT mean host-level control:
1018
+ * Fly network policies match protocol and port only, with no host or CIDR
1019
+ * matching, so a host allowlist can only be built by allowing the port of an
1020
+ * egress proxy and routing sandbox traffic through it. It also cannot speak
1021
+ * for Machines that were already running when their app's policy changed —
1022
+ * Fly applies a policy at Machine boot ("restart or redeploy the Machines for
1023
+ * changes to take effect"), which is why `ensureApp` applies it before every
1024
+ * Machine is created.
1025
+ *
1026
+ * Costs one Machine boot and (in per-project mode) one app create/delete per
1027
+ * call, so callers should cache the verdict rather than probing per request.
1028
+ * @returns What was observed about egress from a Fly sandbox Machine.
1029
+ */
1030
+ async verifyEgress() {
1031
+ const targets = parseEgressProbeTargets(this.config.egressProbeTargets ??
1032
+ process.env.SANDBOX_EGRESS_PROBE_TARGETS ??
1033
+ DEFAULT_EGRESS_PROBE_TARGETS);
1034
+ if (targets.length === 0) {
1035
+ return {
1036
+ state: 'inconclusive',
1037
+ detail: 'No valid literal ip:port egress probe targets are configured.',
1038
+ remediation: 'Set SANDBOX_EGRESS_PROBE_TARGETS (or config.egressProbeTargets) to comma-separated ' +
1039
+ 'literal ip:port values — hostnames are rejected, and IPv6 literals must be bracketed.',
1040
+ };
1041
+ }
1042
+ // parseInt yields NaN (not undefined) for an absent or junk value, so the
1043
+ // fallback is a finiteness check rather than `??`.
1044
+ const configured = this.config.egressProbeTimeoutMs ??
1045
+ Number.parseInt(process.env.SANDBOX_EGRESS_PROBE_TIMEOUT_MS ?? '', 10);
1046
+ const timeoutMs = Number.isFinite(configured)
1047
+ ? Math.min(15_000, Math.max(500, configured))
1048
+ : DEFAULT_PROBE_TIMEOUT_MS;
1049
+ let app = null;
1050
+ let machineId = null;
1051
+ let policyPorts;
1052
+ try {
1053
+ policyPorts = this.egressAllowedPorts();
1054
+ app = this.resolveApp(this.probeProjectId());
1055
+ // The SAME policy a real sandbox gets (private-service ports included, so
1056
+ // the probe observes the real posture), but NO Flycast allocation: this
1057
+ // app is deleted seconds from now, and allocating would leave one
1058
+ // unreleasable address per probe on every target app — every 15 minutes.
1059
+ await this.ensureApp(app, { privateRoutes: false });
1060
+ machineId = await this.createProbeMachine(app);
1061
+ await this.waitForStarted(app, machineId);
1062
+ const seconds = Math.min(MAX_WAIT_SECONDS, Math.max(1, Math.ceil(timeoutMs / 1000) + PROBE_EXEC_HEADROOM_SECONDS));
1063
+ const result = toExecResult(await this.rawExec(app, machineId)(['sh', '-c', buildEgressProbeCommand(targets, timeoutMs)], seconds));
1064
+ return verdictForProbeExit(result.exitCode, { app, targets, policyPorts });
1065
+ }
1066
+ catch (error) {
1067
+ // A probe that could not RUN proves nothing. Reporting `filtered` here is
1068
+ // precisely the lie this method exists to make impossible.
1069
+ return {
1070
+ state: 'inconclusive',
1071
+ detail: `The Fly egress probe could not run: ${error instanceof Error ? error.message : String(error)}`,
1072
+ remediation: 'Check that FLY_API_TOKEN can create apps and Machines in the organization, and that ' +
1073
+ 'the probe image is pullable by Fly and contains `node` and `sleep`.',
1074
+ };
1075
+ }
1076
+ finally {
1077
+ await this.destroyProbe(app, machineId);
1078
+ }
1079
+ }
1080
+ /**
1081
+ * Generates the synthetic project id the throwaway probe app is named from.
1082
+ * Hex only, so {@link appNameForProject} never has to rewrite it.
1083
+ * @returns A project id such as `egress-probe-1a2b3c4d5e6f`.
1084
+ */
1085
+ probeProjectId() {
1086
+ let hex = '';
1087
+ while (hex.length < 12)
1088
+ hex += Math.floor(Math.random() * 0xffffffff).toString(16);
1089
+ return `egress-probe-${hex.slice(0, 12)}`;
1090
+ }
1091
+ /**
1092
+ * Creates the throwaway probe Machine.
1093
+ *
1094
+ * It runs the sandbox image (so it observes egress from the same image real
1095
+ * sandboxes run) with `init.exec` overriding the entrypoint — Fly documents
1096
+ * `exec` as overriding "any other startup command line, either in our API or in
1097
+ * your Docker container definition" — so the Machine is guaranteed to stay up
1098
+ * long enough to exec into, whatever the image's own entrypoint does. Proxy
1099
+ * environment variables are blanked: a probe that politely used a proxy would
1100
+ * report the proxy's policy rather than the network's.
1101
+ * @param app - The Fly app to create it in.
1102
+ * @returns The new Machine's id.
1103
+ * @throws {Error} When the API returns no Machine id.
1104
+ */
1105
+ async createProbeMachine(app) {
1106
+ const config = {
1107
+ image: this.config.egressProbeImage ??
1108
+ this.config.baseImage ??
1109
+ process.env.FLY_SANDBOX_IMAGE ??
1110
+ DEFAULT_IMAGE,
1111
+ env: {
1112
+ HTTP_PROXY: '',
1113
+ HTTPS_PROXY: '',
1114
+ http_proxy: '',
1115
+ https_proxy: '',
1116
+ NO_PROXY: '',
1117
+ no_proxy: '',
1118
+ },
1119
+ // NOT the `managed` metadata: a probe Machine must never appear in list().
1120
+ metadata: { [`${this.metadataPrefix}.egressProbe`]: 'true' },
1121
+ guest: { cpus: 1, cpu_kind: 'shared', memory_mb: PROBE_MEMORY_MB },
1122
+ init: { exec: ['sleep', String(PROBE_LIFETIME_SECONDS)] },
1123
+ restart: { policy: 'no' },
1124
+ // Backstop: reaps the Machine when the sleep ends, even if this process
1125
+ // dies before its explicit cleanup runs.
1126
+ auto_destroy: true,
1127
+ };
1128
+ const machine = await this.client.request(`/apps/${app}/machines`, {
1129
+ method: 'POST',
1130
+ body: { region: this.region(), config },
1131
+ });
1132
+ if (!machine?.id) {
1133
+ throw new Error(t('codeSandbox.flyio.error.probeCreateFailed', { app }, { defaultValue: `Fly egress probe Machine creation in app "${app}" returned no id.` }));
1134
+ }
1135
+ return machine.id;
1136
+ }
1137
+ /**
1138
+ * Removes the probe Machine and, in per-project mode, its throwaway app.
1139
+ *
1140
+ * Best-effort by design: a cleanup failure must not turn a completed
1141
+ * observation into an error, and `auto_destroy` plus the bounded init sleep
1142
+ * already reap the Machine on their own.
1143
+ * @param app - The probe app, or `null` when it was never resolved.
1144
+ * @param machineId - The probe Machine id, or `null` when it was never created.
1145
+ */
1146
+ async destroyProbe(app, machineId) {
1147
+ if (!app)
1148
+ return;
1149
+ if (machineId) {
1150
+ try {
1151
+ await this.client.request(`/apps/${app}/machines/${machineId}?force=true`, {
1152
+ method: 'DELETE',
1153
+ nullOn: [404],
1154
+ });
1155
+ }
1156
+ catch (error) {
1157
+ logger.warn('Failed to remove the Fly egress probe Machine', { app, machineId, error });
1158
+ }
1159
+ }
1160
+ // Never in shared-app mode: that app holds real sandboxes.
1161
+ if (!this.appPerProject())
1162
+ return;
1163
+ try {
1164
+ await this.client.request(`/apps/${app}`, { method: 'DELETE', nullOn: [404] });
1165
+ }
1166
+ catch (error) {
1167
+ logger.warn('Failed to delete the Fly egress probe app — it may leak an empty app', {
1168
+ app,
1169
+ error,
1170
+ });
1171
+ }
1172
+ }
1173
+ // ---------------------------------------------------------------------------
1174
+ // Sandbox
1175
+ // ---------------------------------------------------------------------------
1176
+ /**
1177
+ * Issues one Fly exec call against a Machine.
1178
+ * @param app - The Fly app name.
1179
+ * @param machineId - The Fly Machine id.
1180
+ * @returns A transport callback for {@link execCommand}.
1181
+ */
1182
+ rawExec(app, machineId) {
1183
+ return async (command, timeoutSeconds) => {
1184
+ const response = await this.client.request(`/apps/${app}/machines/${machineId}/exec`, {
1185
+ method: 'POST',
1186
+ body: { command, timeout: timeoutSeconds },
1187
+ // The API's own timeout bounds the command; allow the HTTP request a
1188
+ // little longer so the transport never fires first.
1189
+ timeoutMs: (timeoutSeconds + 15) * 1000,
1190
+ });
1191
+ return response ?? {};
1192
+ };
1193
+ }
1194
+ /**
1195
+ * Starts (or resumes) a Machine, tolerating the case where it is already up.
1196
+ *
1197
+ * A Machine that is already `started` may reject a redundant start. Rather
1198
+ * than pattern-matching an error string, the state is re-read and the call
1199
+ * treated as successful when the Machine is in fact running.
1200
+ * @param app - The Fly app name.
1201
+ * @param machineId - The Fly Machine id.
1202
+ */
1203
+ async startMachine(app, machineId) {
1204
+ try {
1205
+ await this.client.request(`/apps/${app}/machines/${machineId}/start`, { method: 'POST' });
1206
+ }
1207
+ catch (error) {
1208
+ const machine = await this.client
1209
+ .request(`/apps/${app}/machines/${machineId}`, { nullOn: [404] })
1210
+ .catch(() => null);
1211
+ if (machine && (machine.state === 'started' || machine.state === 'starting')) {
1212
+ logger.debug('Fly Machine start rejected but the Machine is already running', {
1213
+ app,
1214
+ machineId,
1215
+ state: machine.state,
1216
+ });
1217
+ return;
1218
+ }
1219
+ throw error;
1220
+ }
1221
+ await this.waitForStarted(app, machineId);
1222
+ }
1223
+ /**
1224
+ * Blocks until a Machine actually reaches `started`.
1225
+ *
1226
+ * `POST .../start` returns as soon as Fly ACCEPTS the request, not when the
1227
+ * Machine is running — so without this, `start()` and `wake()` resolve against
1228
+ * a Machine that is still booting and the caller's very next `exec` fails on a
1229
+ * Machine that is not up. Fly's `wait` endpoint exists for exactly this and
1230
+ * blocks server-side for up to 60 s.
1231
+ * @param app - The Fly app name.
1232
+ * @param machineId - The Fly Machine id.
1233
+ * @throws {Error} When the Machine has not started within the budget.
1234
+ */
1235
+ async waitForStarted(app, machineId) {
1236
+ const seconds = Math.max(1, Math.min(this.config.startTimeoutSeconds ?? MAX_WAIT_SECONDS, MAX_WAIT_SECONDS));
1237
+ try {
1238
+ await this.client.request(`/apps/${app}/machines/${machineId}/wait?state=started&timeout=${seconds}`,
1239
+ // One attempt: a retry would re-block for the whole budget, and the
1240
+ // fallback below re-reads the state anyway.
1241
+ { attempts: 1, timeoutMs: (seconds + 15) * 1000 });
1242
+ }
1243
+ catch (error) {
1244
+ // The wait endpoint returning an error is not itself proof the Machine
1245
+ // failed — re-read the authoritative state before failing the caller.
1246
+ const machine = await this.client
1247
+ .request(`/apps/${app}/machines/${machineId}`, { nullOn: [404] })
1248
+ .catch(() => null);
1249
+ if (machine?.state === 'started')
1250
+ return;
1251
+ throw new Error(t('codeSandbox.flyio.error.startTimeout', { app, machineId, seconds: String(seconds), state: machine?.state ?? 'unknown' }, {
1252
+ defaultValue: `Fly Machine ${machineId} in app "${app}" did not reach "started" within ` +
1253
+ `${seconds}s (last observed state: ${machine?.state ?? 'unknown'}).`,
1254
+ }), { cause: error });
1255
+ }
1256
+ }
1257
+ /**
1258
+ * Builds the `Sandbox` facade for one Machine.
1259
+ * @param app - The Fly app name.
1260
+ * @param machineId - The Fly Machine id.
1261
+ * @param initialStatus - Status derived from the Machine's current Fly state.
1262
+ * @returns A `Sandbox` bound to that Machine.
1263
+ */
1264
+ buildSandbox(app, machineId, initialStatus) {
1265
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
1266
+ const provider = this;
1267
+ const exec = provider.rawExec(app, machineId);
1268
+ return {
1269
+ id: toSandboxId(app, machineId),
1270
+ status: initialStatus,
1271
+ previewUrl: provider.renderPreviewUrl(app, machineId),
1272
+ async start() {
1273
+ await provider.startMachine(app, machineId);
1274
+ this.status = 'running';
1275
+ },
1276
+ async stop() {
1277
+ await provider.client.request(`/apps/${app}/machines/${machineId}/stop`, { method: 'POST' });
1278
+ this.status = 'stopped';
1279
+ },
1280
+ /**
1281
+ * SUSPENDS the Machine. Fly snapshots the microVM's memory to disk, so the
1282
+ * next `wake()` resumes in a few hundred ms instead of cold-booting, and
1283
+ * a suspended Machine bills for storage only. This mapping — not a plain
1284
+ * stop — is why this bond exists.
1285
+ */
1286
+ async sleep() {
1287
+ await provider.client.request(`/apps/${app}/machines/${machineId}/suspend`, {
1288
+ method: 'POST',
1289
+ });
1290
+ this.status = 'sleeping';
1291
+ },
1292
+ /**
1293
+ * RESUMES the Machine. Fly has no separate resume endpoint: `start` on a
1294
+ * suspended Machine restores the memory snapshot, falling back to a cold
1295
+ * boot (with the rootfs preserved) when the snapshot cannot be used.
1296
+ */
1297
+ async wake() {
1298
+ await provider.startMachine(app, machineId);
1299
+ this.status = 'running';
1300
+ },
1301
+ async exec(command, opts) {
1302
+ return await execCommand(exec, command, opts, WORKSPACE_PATH);
1303
+ },
1304
+ async readFile(path) {
1305
+ const result = await this.exec(`cat ${shellQuote(path)}`, { timeout: FILE_OP_TIMEOUT_MS });
1306
+ if (result.exitCode !== 0) {
1307
+ throw new Error(t('codeSandbox.flyio.error.readFailed', { path, error: result.stderr }, { defaultValue: `Failed to read ${path}: ${result.stderr}` }));
1308
+ }
1309
+ return result.stdout;
1310
+ },
1311
+ async writeFile(path, content) {
1312
+ const base64 = Buffer.from(content).toString('base64');
1313
+ const quoted = shellQuote(path);
1314
+ const failIfError = (result) => {
1315
+ if (result.exitCode !== 0) {
1316
+ throw new Error(t('codeSandbox.flyio.error.writeFailed', { path, error: result.stderr }, { defaultValue: `Failed to write ${path}: ${result.stderr}` }));
1317
+ }
1318
+ };
1319
+ // Chunked for the same reason as the Docker provider: the whole command
1320
+ // is one `sh -c` argument, capped at MAX_ARG_STRLEN (128 KB). Each chunk
1321
+ // is a multiple of 4 base64 characters (= 3 decoded bytes) so it decodes
1322
+ // standalone and the bytes concatenate exactly.
1323
+ failIfError(await this.exec(`mkdir -p "$(dirname ${quoted})" && printf %s ${shellQuote(base64.slice(0, WRITE_CHUNK_BASE64))} | base64 -d > ${quoted}`, { timeout: FILE_OP_TIMEOUT_MS }));
1324
+ for (let i = WRITE_CHUNK_BASE64; i < base64.length; i += WRITE_CHUNK_BASE64) {
1325
+ failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(i, i + WRITE_CHUNK_BASE64))} | base64 -d >> ${quoted}`, { timeout: FILE_OP_TIMEOUT_MS }));
1326
+ }
1327
+ },
1328
+ async readDir(path) {
1329
+ // Trailing slash so `ls` follows a symlinked directory.
1330
+ const dirPath = path.endsWith('/') ? path : `${path}/`;
1331
+ // Deliberately NOT piped: a pipeline reports the LAST command's status,
1332
+ // which would mask a missing directory. The `total` header is filtered
1333
+ // in JS instead.
1334
+ const result = await this.exec(`ls -la --time-style=+%s ${shellQuote(dirPath)}`, {
1335
+ timeout: FILE_OP_TIMEOUT_MS,
1336
+ });
1337
+ // A missing directory must THROW. An empty array means "exists and is
1338
+ // empty", and conflating the two sends an AI executor chasing files
1339
+ // that were never there.
1340
+ if (result.exitCode !== 0) {
1341
+ throw new Error(t('codeSandbox.flyio.error.readDirFailed', { path, error: result.stderr }, { defaultValue: `Failed to list ${path}: ${result.stderr}` }));
1342
+ }
1343
+ return result.stdout
1344
+ .trim()
1345
+ .split('\n')
1346
+ .filter(Boolean)
1347
+ .filter((line) => !/^total\s/.test(line))
1348
+ .filter((line) => {
1349
+ const name = line.split(/\s+/).slice(6).join(' ');
1350
+ return name !== '.' && name !== '..';
1351
+ })
1352
+ .map((line) => {
1353
+ const parts = line.split(/\s+/);
1354
+ const isDir = line.startsWith('d');
1355
+ const isSymlink = line.startsWith('l');
1356
+ const size = Number.parseInt(parts[4] ?? '0', 10);
1357
+ const rawName = parts.slice(6).join(' ');
1358
+ const arrowIndex = rawName.indexOf(' -> ');
1359
+ const name = isSymlink && arrowIndex !== -1 ? rawName.slice(0, arrowIndex) : rawName;
1360
+ const symlinkTarget = isSymlink && arrowIndex !== -1 ? rawName.slice(arrowIndex + 4) : undefined;
1361
+ return {
1362
+ name,
1363
+ type: isDir ? 'directory' : 'file',
1364
+ size: isDir ? undefined : size,
1365
+ ...(symlinkTarget ? { symlinkTarget } : {}),
1366
+ };
1367
+ });
1368
+ },
1369
+ async deleteFile(path) {
1370
+ const result = await this.exec(`rm -rf ${shellQuote(path)}`, {
1371
+ timeout: FILE_OP_TIMEOUT_MS,
1372
+ });
1373
+ if (result.exitCode !== 0) {
1374
+ throw new Error(t('codeSandbox.flyio.error.deleteFailed', { path, error: result.stderr }, { defaultValue: `Failed to delete ${path}: ${result.stderr}` }));
1375
+ }
1376
+ },
1377
+ getPreviewUrl(port) {
1378
+ return provider.renderPreviewUrl(app, machineId, port);
1379
+ },
1380
+ /**
1381
+ * NOT SUPPORTED on Fly. See the module `@remarks` — the Machines API has
1382
+ * no filesystem-event channel, and the only alternative (polling through
1383
+ * the rate-limited exec endpoint) would spend the caller's whole API
1384
+ * budget to report changes seconds late. The callback is never invoked;
1385
+ * the returned unsubscribe function is a real no-op.
1386
+ */
1387
+ onFileChange(_cb) {
1388
+ if (!provider.warnedNoFileWatch) {
1389
+ provider.warnedNoFileWatch = true;
1390
+ logger.warn('onFileChange is not supported by the Fly.io sandbox provider — the Machines API ' +
1391
+ 'exposes no filesystem-event channel. The callback will never fire; poll with ' +
1392
+ 'readDir/readFile if you need change detection.');
1393
+ }
1394
+ return () => { };
1395
+ },
1396
+ };
1397
+ }
1398
+ /**
1399
+ * Renders the preview URL for a Machine from the configured template.
1400
+ * @param app - The Fly app name.
1401
+ * @param machineId - The Fly Machine id.
1402
+ * @param port - Port to substitute for `{port}`; defaults to the preview port.
1403
+ * @returns The rendered URL.
1404
+ */
1405
+ renderPreviewUrl(app, machineId, port) {
1406
+ const template = this.config.previewUrlTemplate ?? 'https://{app}.fly.dev';
1407
+ return template
1408
+ .replace(/\{app\}/g, app)
1409
+ .replace(/\{machineId\}/g, machineId)
1410
+ .replace(/\{port\}/g, String(port ?? this.config.previewPort ?? DEFAULT_PREVIEW_PORT));
1411
+ }
1412
+ }
1413
+ /**
1414
+ * Creates a Fly.io Machines sandbox provider.
1415
+ * @param config - Optional Fly configuration: API token/URL, org slug, app
1416
+ * naming and isolation mode, region, base image, guest sizing, preview
1417
+ * service, and the preview URL template. Every field has an env fallback.
1418
+ * @param client - Injectable Machines API client, for tests.
1419
+ * @param store - Injectable template object store, for tests. Resolved from the
1420
+ * template settings (or their env fallbacks) on first use when omitted.
1421
+ * @returns A `SandboxProvider` backed by Fly Machines.
1422
+ */
1423
+ export function createProvider(config, client, store) {
1424
+ return new FlyioSandboxProvider(config, client, store);
1425
+ }