@molecule/api-code-sandbox-flyio 1.2.5 → 1.2.7

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.
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAWV,eAAe,EAGhB,MAAM,4BAA4B,CAAA;AAGnC,OAAO,EAAE,YAAY,EAAgC,MAAM,UAAU,CAAA;AAwBrE,OAAO,EAAmD,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAEhG,OAAO,KAAK,EAIV,WAAW,EAUZ,MAAM,YAAY,CAAA;AA20EnB;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,CAAC,EAAE,WAAW,EACpB,MAAM,CAAC,EAAE,YAAY,EACrB,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,eAAe,CAEjB"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAWV,eAAe,EAGhB,MAAM,4BAA4B,CAAA;AAGnC,OAAO,EAAE,YAAY,EAAgC,MAAM,UAAU,CAAA;AAwBrE,OAAO,EAAmD,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAEhG,OAAO,KAAK,EAIV,WAAW,EAUZ,MAAM,YAAY,CAAA;AAigFnB;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,CAAC,EAAE,WAAW,EACpB,MAAM,CAAC,EAAE,YAAY,EACrB,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,eAAe,CAEjB"}
package/dist/provider.js CHANGED
@@ -141,8 +141,9 @@ const IMPORT_EXTRACT_TIMEOUT_MS = 55_000;
141
141
  * sub-APIs asynchronously, so `POST /apps` can return before
142
142
  * `POST /apps/{app}/machines` sees the app — which 404s "app not found" for a
143
143
  * few seconds. Observed live during boot on 2026-08-08 (surfaced to the user as
144
- * "Failed to start sandbox"). Six attempts span the client's 1+2+4+8+16 s
145
- * backoff ≈ 31 s, comfortably past the observed window.
144
+ * "Failed to start sandbox"). Six attempts span the client's 1+2+4+8+10 s
145
+ * backoff ≈ 25 s (the fifth step is clamped at the client's 10 s cap),
146
+ * comfortably past the observed window.
146
147
  */
147
148
  const MACHINE_CREATE_ATTEMPTS = 6;
148
149
  /**
@@ -365,8 +366,12 @@ class FlyioSandboxProvider {
365
366
  * is what the throwaway `verifyEgress()` app wants: it never connects to
366
367
  * anything but the probe targets, and allocating for an app that is deleted
367
368
  * seconds later would leak one address per probe on every target app.
368
- * @returns Target app name → allocated private address, for recording in the
369
- * Machine's metadata. Empty when no private services are declared.
369
+ * @returns `routes`: target app name → allocated private address, for
370
+ * recording in the Machine's metadata (empty when no private services are
371
+ * declared); `allocated`: the subset of `routes` this call created, which
372
+ * the caller releases when the boot fails and the app is NOT discarded
373
+ * (an app that pre-existed is kept, and with it the addresses it already
374
+ * held — only this call's own allocations are safe to take back).
370
375
  */
371
376
  async ensureApp(app, options = {}) {
372
377
  const existing = await this.client.request(`/apps/${app}`, { nullOn: [404] });
@@ -399,9 +404,9 @@ class FlyioSandboxProvider {
399
404
  await this.assignSharedIpv4(app);
400
405
  await this.applyEgressPolicy(app);
401
406
  if (options.privateRoutes === false)
402
- return { routes: {}, created: createdHere };
407
+ return { routes: {}, allocated: {}, created: createdHere };
403
408
  return {
404
- routes: await this.ensurePrivateRoutes(app, Boolean(existing)),
409
+ ...(await this.ensurePrivateRoutes(app, Boolean(existing))),
405
410
  created: createdHere,
406
411
  };
407
412
  }
@@ -450,25 +455,52 @@ class FlyioSandboxProvider {
450
455
  * allocated. Fly's own `IPAssignment` carries no network, so a listing on the
451
456
  * target app cannot answer "does this project already have a route" — the
452
457
  * Machine metadata this provider wrote is the only record.
458
+ *
459
+ * A failure part-way down the list RELEASES the addresses this call had
460
+ * already allocated before rethrowing: the caller's own failure path discards
461
+ * the app, which is a different resource on a different app than the addresses
462
+ * (see the allocation loop).
453
463
  * @param app - The Fly app name (which is also its 6PN network name).
454
464
  * @param appExisted - Whether the app was already there, in which case its
455
465
  * Machines may carry addresses allocated by an earlier boot.
456
- * @returns Target app name → private address.
466
+ * @returns `routes`: target app name → private address (what the Machine's
467
+ * metadata records); `allocated`: the subset of `routes` THIS call created —
468
+ * the only addresses safe to release when the boot fails after allocation,
469
+ * since the recorded ones belong to Machines an earlier boot may still run.
457
470
  */
458
471
  async ensurePrivateRoutes(app, appExisted) {
459
472
  const services = this.privateServices();
460
473
  const network = this.networkFor(app);
461
474
  if (!services?.length || !network)
462
- return {};
475
+ return { routes: {}, allocated: {} };
463
476
  const routes = appExisted ? await this.recordedPrivateRoutes(app) : {};
464
- for (const service of services) {
465
- if (routes[service.app])
466
- continue;
467
- const ip = await this.allocatePrivateAddress(service, network);
468
- if (ip)
469
- routes[service.app] = ip;
477
+ // The addresses THIS call allocated, tracked apart from `routes` so a
478
+ // failure part-way down the next list can release exactly them. The caller's
479
+ // failure path can only discard the APP (`discardCreatedApp(app, {})`), and
480
+ // these addresses live on OTHER apps — so without this, an address already
481
+ // allocated for an earlier service stays behind on its target app, inside a
482
+ // 6PN whose app is gone, and Fly's listing does not report which network an
483
+ // address belongs to: nothing could ever find it there to release it.
484
+ const allocated = {};
485
+ try {
486
+ for (const service of services) {
487
+ if (routes[service.app])
488
+ continue;
489
+ const ip = await this.allocatePrivateAddress(service, network);
490
+ if (ip) {
491
+ routes[service.app] = ip;
492
+ allocated[service.app] = ip;
493
+ }
494
+ }
495
+ }
496
+ catch (error) {
497
+ // Only what this call allocated. An address recorded by an EARLIER boot
498
+ // (`appExisted`) is left alone: the app survived this failure and may
499
+ // still hold a Machine reaching through that route.
500
+ await this.releasePrivateRoutes(allocated);
501
+ throw error;
470
502
  }
471
- return routes;
503
+ return { routes, allocated };
472
504
  }
473
505
  /**
474
506
  * Reads the private addresses an earlier boot recorded on this app's Machines.
@@ -927,13 +959,26 @@ class FlyioSandboxProvider {
927
959
  // leave in a Machine config.
928
960
  assertPrivateRoutesForEnv({ ...config.env, ...config.selfDeliveredEnv }, this.privateServices());
929
961
  const app = this.resolveApp(config.projectId);
930
- const { routes: privateRoutes, created } = await this.ensureApp(app);
962
+ const { routes: privateRoutes, allocated, created } = await this.ensureApp(app);
931
963
  try {
932
964
  return await this.createInApp(app, privateRoutes, config, manifest);
933
965
  }
934
966
  catch (error) {
935
- if (created && this.appPerProject())
967
+ if (created && this.appPerProject()) {
968
+ // The whole app goes, addresses included — discardCreatedApp releases
969
+ // every route it was handed (a created app holds only this call's).
936
970
  await this.discardCreatedApp(app, privateRoutes);
971
+ }
972
+ else {
973
+ // The app SURVIVES this failure (it pre-existed, or it is the shared
974
+ // app), so the addresses this call allocated would otherwise stay
975
+ // behind on their target apps with nothing recording them: the
976
+ // Machine that would have carried the metadata was never created, and
977
+ // Fly's listing cannot say which network an address belongs to. Take
978
+ // back exactly this call's own allocations — never the routes an
979
+ // earlier boot recorded, whose Machines may still be running.
980
+ await this.releasePrivateRoutes(allocated);
981
+ }
937
982
  throw error;
938
983
  }
939
984
  }
@@ -1038,7 +1083,35 @@ class FlyioSandboxProvider {
1038
1083
  // `412 failed_precondition: machine not running` until the Machine is up.
1039
1084
  // Wait for `started` here so `create` always hands back a live sandbox, as
1040
1085
  // the JSDoc promises — the template path already does this via restore.
1041
- await this.waitForStarted(app, machine.id);
1086
+ try {
1087
+ await this.waitForStarted(app, machine.id);
1088
+ }
1089
+ catch (error) {
1090
+ // The boot failed after the Machine existed. This call created it and
1091
+ // handed it to NOBODY — the caller got a rejection, not a handle — so
1092
+ // leaving it behind strands state nothing else ever cleans up: a Machine
1093
+ // that reached `started` bills compute until it is reaped, and one that
1094
+ // did not keeps the app's single-attach volume claimed (the next
1095
+ // create's stale-Machine sweep only runs if this project is retried).
1096
+ // The created-app failure path below cannot do this either: it deletes
1097
+ // the APP, and `discardCreatedApp` deliberately has no Machine-delete
1098
+ // step — an app delete that runs under a live Machine either fails or
1099
+ // hides the Machine. Destroy the Machine first (the same order
1100
+ // `restoreTemplate`'s failure path and `destroy()` use), then rethrow
1101
+ // the boot failure. Best-effort: the boot failure is what the caller
1102
+ // must see; a failed cleanup is logged like every other cleanup here.
1103
+ try {
1104
+ await this.client.request(`/apps/${app}/machines/${machine.id}?force=true`, {
1105
+ method: 'DELETE',
1106
+ nullOn: [404],
1107
+ });
1108
+ }
1109
+ catch (cleanupError) {
1110
+ logger.warn('Failed to destroy the Fly Machine whose boot did not reach started — it will keep ' +
1111
+ 'billing (and holding its volume) until it is reaped', { app, machineId: machine.id, error: cleanupError });
1112
+ }
1113
+ throw error;
1114
+ }
1042
1115
  return this.buildSandbox(app, machine.id, 'running');
1043
1116
  }
1044
1117
  /**
@@ -1097,6 +1170,14 @@ class FlyioSandboxProvider {
1097
1170
  * In per-project mode the Machines live in many apps, so this pages through
1098
1171
  * `GET /v1/orgs/{org}/machines` and keeps the ones carrying this provider's
1099
1172
  * `managed` metadata. In shared-app mode it lists that app directly.
1173
+ *
1174
+ * Only the APP-scoped 404 is an absence (`nullOn: [404]` on
1175
+ * `/apps/{app}/machines` — the app is gone, so its Machines are too). A 404
1176
+ * from the ORG-scoped listing is not: an org with no Machines answers 200
1177
+ * with an empty list, so a 404 there means the org is unknown to the token —
1178
+ * a failed query. Swallowing it as `[]` would tell a reaper the fleet is
1179
+ * clean when nothing had been looked at, the same conflation
1180
+ * `appsToEnumerate()` refuses for the org's app listing.
1100
1181
  * @param _userId - Reserved for future per-user filtering; currently ignored,
1101
1182
  * matching the Docker provider.
1102
1183
  * @returns Every managed sandbox.
@@ -1118,7 +1199,7 @@ class FlyioSandboxProvider {
1118
1199
  const query = new URLSearchParams({ limit: String(LIST_PAGE_SIZE) });
1119
1200
  if (cursor)
1120
1201
  query.set('cursor', cursor);
1121
- const response = await this.client.request(`/orgs/${this.orgSlug()}/machines?${query.toString()}`, { nullOn: [404] });
1202
+ const response = await this.client.request(`/orgs/${this.orgSlug()}/machines?${query.toString()}`);
1122
1203
  for (const machine of response?.machines ?? []) {
1123
1204
  if (machine.config?.metadata?.[managedKey] !== 'true')
1124
1205
  continue;
@@ -1713,7 +1794,12 @@ class FlyioSandboxProvider {
1713
1794
  *
1714
1795
  * A Machine that is already `started` may reject a redundant start. Rather
1715
1796
  * than pattern-matching an error string, the state is re-read and the call
1716
- * treated as successful when the Machine is in fact running.
1797
+ * treated as successful when the Machine is in fact running. A Machine still
1798
+ * `starting` (its own start lost to a concurrent one — the proxy's autostart
1799
+ * racing a `wake()`) is NOT that case: only `waitForStarted` may end this
1800
+ * call, or it resolves against a Machine that is still booting and the
1801
+ * caller's first `exec` fails with 412 "machine not running" — the exact
1802
+ * failure this method's wait exists to prevent.
1717
1803
  * @param app - The Fly app name.
1718
1804
  * @param machineId - The Fly Machine id.
1719
1805
  */
@@ -1725,7 +1811,7 @@ class FlyioSandboxProvider {
1725
1811
  const machine = await this.client
1726
1812
  .request(`/apps/${app}/machines/${machineId}`, { nullOn: [404] })
1727
1813
  .catch(() => null);
1728
- if (machine && (machine.state === 'started' || machine.state === 'starting')) {
1814
+ if (machine?.state === 'started') {
1729
1815
  logger.debug('Fly Machine start rejected but the Machine is already running', {
1730
1816
  app,
1731
1817
  machineId,
@@ -1733,7 +1819,9 @@ class FlyioSandboxProvider {
1733
1819
  });
1734
1820
  return;
1735
1821
  }
1736
- throw error;
1822
+ if (machine?.state !== 'starting')
1823
+ throw error;
1824
+ logger.debug('Fly Machine start rejected but the Machine is already starting — waiting for started', { app, machineId });
1737
1825
  }
1738
1826
  await this.waitForStarted(app, machineId);
1739
1827
  }
@@ -1858,10 +1946,12 @@ class FlyioSandboxProvider {
1858
1946
  throw new Error(t('codeSandbox.flyio.error.writeFailed', { path, error: result.stderr }, { defaultValue: `Failed to write ${path}: ${result.stderr}` }));
1859
1947
  }
1860
1948
  };
1861
- // Chunked for the same reason as the Docker provider: the whole command
1862
- // is one `sh -c` argument, capped at MAX_ARG_STRLEN (128 KB). Each chunk
1863
- // is a multiple of 4 base64 characters (= 3 decoded bytes) so it decodes
1864
- // standalone and the bytes concatenate exactly.
1949
+ // Chunked for the same reason as WRITE_CHUNK_BASE64 above: the whole
1950
+ // command is one argument to Fly's exec API, which silently rejects
1951
+ // anything over ~15 KB (FLY_EXEC_MAX_ARG_BYTES) — NOT the OS
1952
+ // MAX_ARG_STRLEN (128 KB) a Docker-based provider is bounded by. Each
1953
+ // chunk is a multiple of 4 base64 characters (= 3 decoded bytes) so it
1954
+ // decodes standalone and the bytes concatenate exactly.
1865
1955
  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 }));
1866
1956
  for (let i = WRITE_CHUNK_BASE64; i < base64.length; i += WRITE_CHUNK_BASE64) {
1867
1957
  failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(i, i + WRITE_CHUNK_BASE64))} | base64 -d >> ${quoted}`, { timeout: FILE_OP_TIMEOUT_MS }));
@@ -1955,9 +2045,26 @@ class FlyioSandboxProvider {
1955
2045
  const store = provider.templateContext().store;
1956
2046
  if (store) {
1957
2047
  const key = `${store.prefix}/.imports/${machineId}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e9).toString(36)}.tar.gz`;
2048
+ // Whether the uploaded object may still exist. Every exit from this
2049
+ // block removes it — not just the success one: the upload is the
2050
+ // SECOND write of this import, and a throw after it (a failing
2051
+ // `presignGet`, an exec that times out or loses the connection) used to
2052
+ // fall straight into the chunked fallback below with a multi-MB object
2053
+ // left in the bucket. The presigned URL expires on its own; the OBJECT
2054
+ // does not, so "it expires anyway" was only ever true of the URL.
2055
+ let uploaded = false;
1958
2056
  try {
1959
2057
  const putUrl = await store.presignPut(key, IMPORT_PRESIGN_EXPIRY_SECONDS);
2058
+ uploaded = true;
1960
2059
  const putRes = await fetch(putUrl, { method: 'PUT', body: gz });
2060
+ // The PUT's body is never read (a presigned store answers an empty
2061
+ // 2xx or a short error document): release it on EVERY path, or the
2062
+ // connection stays stranded in the pool until GC reclaims it — the
2063
+ // same release every other discarded response body here gets.
2064
+ await putRes.body?.cancel().catch((_error) => {
2065
+ // Best-effort release only; the fallback decision below runs on
2066
+ // the already-known status.
2067
+ });
1961
2068
  if (!putRes.ok) {
1962
2069
  throw new Error(`presigned PUT to the object store failed: ${putRes.status}`);
1963
2070
  }
@@ -1966,20 +2073,47 @@ class FlyioSandboxProvider {
1966
2073
  'set -e',
1967
2074
  `mkdir -p ${quotedPath}`,
1968
2075
  'archive=$(mktemp /tmp/mol-import-XXXXXX.tar.gz)',
1969
- `curl --fail-with-body -sS --retry 3 --retry-connrefused -o "$archive" ${shellQuote(getUrl)}`,
1970
- `tar -C ${quotedPath} --no-same-owner --no-same-permissions -xzf "$archive"`,
2076
+ // The archive is removed whether the DOWNLOAD or the extract
2077
+ // SUCCEEDS or fails: under `set -e`, a bare `curl`/`tar` line
2078
+ // aborted the script before any cleanup — a failed curl (an
2079
+ // expired presign, a reset mid-body) left its partial download
2080
+ // in the sandbox's /tmp until the Machine died, exactly like
2081
+ // the failed extracts `&& rm` used to leave. The `|| { }` guard
2082
+ // keeps `set -e` from aborting before the cleanup and re-raises
2083
+ // the real exit code.
2084
+ `curl --fail-with-body -sS --retry 3 --retry-connrefused -o "$archive" ${shellQuote(getUrl)} || { rc=$?; rm -f "$archive"; exit $rc; }`,
2085
+ `tar -C ${quotedPath} --no-same-owner --no-same-permissions -xzf "$archive" || { rc=$?; rm -f "$archive"; exit $rc; }`,
1971
2086
  'rm -f "$archive"',
1972
2087
  ].join('\n'), { timeout: IMPORT_EXTRACT_TIMEOUT_MS });
1973
- // Best-effort: the temp object expires anyway, but delete it promptly.
2088
+ // Best-effort, but a failure is not silent: only the presigned URL
2089
+ // expires on its own — the OBJECT does not (see the comment above
2090
+ // the `uploaded` flag), so a failed removal here strands the
2091
+ // archive in the bucket exactly like the failure path below does,
2092
+ // and is logged at the same level so it is findable. Cleared
2093
+ // first: past this point the removal is under way, so the catch
2094
+ // below must not queue a second one for the same key (a non-zero
2095
+ // extract exit still throws from `failIfError`).
2096
+ uploaded = false;
1974
2097
  store.remove([key]).catch((error) => {
1975
- logger.debug('importFiles temp object cleanup failed', { key, error });
2098
+ logger.warn('importFiles temp object cleanup failed — it stays in the object store', {
2099
+ key,
2100
+ error,
2101
+ });
1976
2102
  });
1977
2103
  failIfError(result, 'download');
1978
2104
  return;
1979
2105
  }
1980
2106
  catch (error) {
1981
2107
  // The store path is unavailable or failed — fall through to the
1982
- // chunked-exec transport rather than failing the whole import.
2108
+ // chunked-exec transport rather than failing the whole import. Any
2109
+ // object this attempt uploaded goes first: the fallback re-imports
2110
+ // the same archive under a DIFFERENT key on the workspace volume, so
2111
+ // nothing else would ever remove this one.
2112
+ if (uploaded) {
2113
+ await store.remove([key]).catch((cleanupError) => {
2114
+ logger.warn('Could not remove the uploaded import archive after a failed import — it stays in the object store', { key, error: cleanupError });
2115
+ });
2116
+ }
1983
2117
  logger.warn('importFiles via object store failed; falling back to chunked exec', {
1984
2118
  error,
1985
2119
  });
@@ -1989,15 +2123,46 @@ class FlyioSandboxProvider {
1989
2123
  // configured. The temp tar lives on the WORKSPACE VOLUME, not /tmp — the
1990
2124
  // chunked write and the extract are separate exec calls and a Machine
1991
2125
  // with autostop can cold-start between them, wiping /tmp (rootfs) while
1992
- // the ext4 volume at /workspace persists.
2126
+ // the ext4 volume at /workspace persists. The name is unique per call
2127
+ // (same shape as the store path's key): a fixed, machineId-only name
2128
+ // made two concurrent imports to one sandbox interleave their `>>`
2129
+ // appends into ONE file, so each extracted a mix of both archives.
1993
2130
  const base64 = gz.toString('base64');
1994
- const tmp = `${WORKSPACE_PATH}/.mol-import-${machineId}.tgz`;
2131
+ const tmp = `${WORKSPACE_PATH}/.mol-import-${machineId}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e9).toString(36)}.tgz`;
1995
2132
  const quotedTmp = shellQuote(tmp);
1996
- failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(0, IMPORT_WRITE_CHUNK_BASE64))} | base64 -d > ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
1997
- for (let i = IMPORT_WRITE_CHUNK_BASE64; i < base64.length; i += IMPORT_WRITE_CHUNK_BASE64) {
1998
- failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(i, i + IMPORT_WRITE_CHUNK_BASE64))} | base64 -d >> ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
2133
+ // A failed WRITE step leaves a partial archive behind on the durable
2134
+ // workspace volume — the chunked transport is the fallback for a
2135
+ // multi-MB archive, so a mid-write failure (a 429, a full disk) used to
2136
+ // strand every byte written so far until the Machine died. Removed
2137
+ // best-effort on any write failure; the extract step below cleans up
2138
+ // after ITSELF (inside the sandbox), so it needs no outer cleanup.
2139
+ const discardTempArchive = async () => {
2140
+ try {
2141
+ await this.exec(`rm -f ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS });
2142
+ }
2143
+ catch (cleanupError) {
2144
+ logger.warn('Could not remove the temp import archive after a failed import — it stays on the workspace volume', { tmp, error: cleanupError });
2145
+ }
2146
+ };
2147
+ try {
2148
+ failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(0, IMPORT_WRITE_CHUNK_BASE64))} | base64 -d > ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
2149
+ for (let i = IMPORT_WRITE_CHUNK_BASE64; i < base64.length; i += IMPORT_WRITE_CHUNK_BASE64) {
2150
+ failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(i, i + IMPORT_WRITE_CHUNK_BASE64))} | base64 -d >> ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
2151
+ }
2152
+ }
2153
+ catch (error) {
2154
+ await discardTempArchive();
2155
+ throw error;
1999
2156
  }
2000
- failIfError(await this.exec(`mkdir -p ${quotedPath} && tar -xzf ${quotedTmp} -C ${quotedPath} --no-same-owner --no-same-permissions && rm -f ${quotedTmp}`, { timeout: IMPORT_EXTRACT_TIMEOUT_MS }), 'extract');
2157
+ failIfError(await this.exec(
2158
+ // The temp archive is removed whether the extract SUCCEEDS or fails:
2159
+ // `&& rm` after tar left a failed extract's multi-MB archive behind
2160
+ // on the WORKSPACE VOLUME — the durable path this fallback
2161
+ // deliberately writes to — until the next import overwrote it.
2162
+ // `; rc=$?; rm …; exit $rc` cleans up unconditionally and still
2163
+ // reports tar's real exit code (the same shape exec.ts's script
2164
+ // spill uses).
2165
+ `mkdir -p ${quotedPath} && tar -xzf ${quotedTmp} -C ${quotedPath} --no-same-owner --no-same-permissions; rc=$?; rm -f ${quotedTmp}; exit $rc`, { timeout: IMPORT_EXTRACT_TIMEOUT_MS }), 'extract');
2001
2166
  },
2002
2167
  getPreviewUrl(port) {
2003
2168
  return provider.renderPreviewUrl(app, machineId, port);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-code-sandbox-flyio",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "description": "Fly.io Machines (Firecracker microVM) code sandbox provider",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",