smolmachines 1.26.1 → 1.27.0

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.
package/README.md CHANGED
@@ -87,10 +87,32 @@ try {
87
87
  }
88
88
  ```
89
89
 
90
- For a local VM whose published service starts through `exec()`, set
91
- `waitForPorts: false` on creation. The SDK still waits for the guest agent,
92
- so `exec()` is safe; call `waitUntilReady()` once the service is listening.
93
- The default keeps waiting for all published ports. This option is local-only.
90
+ Local machines also accept a saved Docker/OCI image archive or an unpacked rootfs
91
+ as `image`. The SDK stages an archive once and reuses its content hash; a
92
+ missing local path fails immediately with the path in the error instead of
93
+ trying to pull it from a registry. Build your image first, then save it:
94
+
95
+ ```sh
96
+ docker save my-agent:latest -o my-agent.tar
97
+ ```
98
+
99
+ ```ts
100
+ const m = await Machine.create(
101
+ { image: './my-agent.tar', network: false },
102
+ { target: 'local' },
103
+ );
104
+ try {
105
+ console.log((await m.exec(['sh', '-c', 'pwd'])).stdout);
106
+ } finally {
107
+ await m.delete();
108
+ }
109
+ ```
110
+
111
+ When a published service starts through `exec()` (local or cloud), set
112
+ `waitForPorts: false` on creation. The SDK waits for the guest agent so
113
+ `exec()` can start the listener; call `waitUntilReady()` once the service is
114
+ listening. The default waits for all published ports. Select
115
+ `{ target: 'cloud' }` to use the same flow on Smol Cloud.
94
116
 
95
117
  ```ts
96
118
  const m = await Machine.create(
@@ -109,10 +131,13 @@ try {
109
131
  }
110
132
  ```
111
133
 
112
- When reconnecting to clean up a local machine whose published service has
134
+ When reconnecting to a running local machine whose published service has
113
135
  stopped, `Machine.connect(name, { target: 'local', waitForPorts: false })`
114
- attaches after the agent is ready, so `delete()` can run without a listener.
115
- The default continues waiting for all published ports.
136
+ attaches after the agent is ready, without waiting for the listener. Cloud
137
+ `connect()` normally attaches without waiting; with `waitForPorts: false`, it
138
+ waits for a successful guest-agent command without requiring the port to be
139
+ open. Connect without that flag to inspect or delete a stopped or paused cloud
140
+ machine.
116
141
 
117
142
  To reach a service **inside** the VM, use the authenticated connect bridge —
118
143
  **no Cloudflare/localhost.run tunnel, no public exposure, no egress allow-list.**
@@ -215,6 +240,9 @@ Existing network connections may need to reconnect.
215
240
  - `machine.exec(command, opts?)` / `machine.run(image, command, opts?)` → `ExecResult`.
216
241
  - `machine.execStream(command, opts?)` → `AsyncGenerator<ExecEvent>`.
217
242
  - `machine.readFile(path)` / `machine.writeFile(path, data, mode?)`.
243
+ - `machine.readFileStream(path)` yields `Uint8Array` chunks without buffering
244
+ the full Cloud download; stopping iteration cancels the response. Local
245
+ machines currently yield one buffered chunk.
218
246
  - `machine.pullImage(image)` / `machine.listImages()`.
219
247
  - `machine.branch(name, options?)` / `machine.branchBatch(options)`.
220
248
  - `machine.checkpoint(output, { store? })`, `Machine.restoreCheckpoint(...)`,
package/dist/assets.d.ts CHANGED
@@ -1,17 +1,21 @@
1
1
  /** Auto-wiring for bundled native assets.
2
2
  *
3
- * Points the engine at the package's bundled, signed boot helper and hypervisor
4
- * libraries so the SDK works on a plain `node` with no manual env setup:
5
- * - SMOLVM_BOOT_BINARY → bundled `smol-vmm` helper (handles `_boot-vm`; on
6
- * macOS codesigned with `com.apple.security.hypervisor`, so the user's
7
- * `node` needs no entitlement).
8
- * - SMOLVM_LIB_DIR → the dir holding libkrun/libkrunfw.
9
- * - SMOLVM_AGENT_ROOTFS_TAR → bundled guest rootfs tarball (the engine
10
- * extracts it on first use), so a plain `npm i` is fully self-contained.
3
+ * Resolves the package's bundled, signed boot helper, hypervisor libraries and
4
+ * guest rootfs so the SDK works on a plain `node` with no manual env setup:
5
+ * - bootBinary → bundled `smol-vmm` helper (handles `_boot-vm`; on macOS
6
+ * codesigned with `com.apple.security.hypervisor`, so the user's `node`
7
+ * needs no entitlement).
8
+ * - libDir → the dir holding libkrun/libkrunfw.
9
+ * - agentRootfsTar → bundled guest rootfs tarball (the engine extracts it
10
+ * on first use), so a plain `npm i` is fully self-contained.
11
11
  *
12
- * A user-provided value always wins. Called by `native.ts` right before the
13
- * addon loads — i.e. on first LOCAL use, never at import — so importing the
14
- * SDK (e.g. for cloud-only use) leaves `process.env` untouched.
12
+ * The resolved paths are handed to the engine in-process by
13
+ * `configureRuntimeAssets` and are never written to `process.env`: a `smolvm`
14
+ * CLI this program spawns must not inherit this package's helper (it exits
15
+ * when its parent does) or its libraries. A user-provided SMOLVM_BOOT_BINARY,
16
+ * SMOLVM_LIB_DIR or SMOLVM_AGENT_ROOTFS(_TAR) always wins. Called by
17
+ * `native.ts` right before the addon loads — i.e. on first LOCAL use, never at
18
+ * import.
15
19
  */
16
20
  /** The per-platform npm package that carries the native addon, boot helper,
17
21
  * hypervisor libraries and guest rootfs for each supported
package/dist/assets.js CHANGED
@@ -1,18 +1,22 @@
1
1
  "use strict";
2
2
  /** Auto-wiring for bundled native assets.
3
3
  *
4
- * Points the engine at the package's bundled, signed boot helper and hypervisor
5
- * libraries so the SDK works on a plain `node` with no manual env setup:
6
- * - SMOLVM_BOOT_BINARY → bundled `smol-vmm` helper (handles `_boot-vm`; on
7
- * macOS codesigned with `com.apple.security.hypervisor`, so the user's
8
- * `node` needs no entitlement).
9
- * - SMOLVM_LIB_DIR → the dir holding libkrun/libkrunfw.
10
- * - SMOLVM_AGENT_ROOTFS_TAR → bundled guest rootfs tarball (the engine
11
- * extracts it on first use), so a plain `npm i` is fully self-contained.
4
+ * Resolves the package's bundled, signed boot helper, hypervisor libraries and
5
+ * guest rootfs so the SDK works on a plain `node` with no manual env setup:
6
+ * - bootBinary → bundled `smol-vmm` helper (handles `_boot-vm`; on macOS
7
+ * codesigned with `com.apple.security.hypervisor`, so the user's `node`
8
+ * needs no entitlement).
9
+ * - libDir → the dir holding libkrun/libkrunfw.
10
+ * - agentRootfsTar → bundled guest rootfs tarball (the engine extracts it
11
+ * on first use), so a plain `npm i` is fully self-contained.
12
12
  *
13
- * A user-provided value always wins. Called by `native.ts` right before the
14
- * addon loads — i.e. on first LOCAL use, never at import — so importing the
15
- * SDK (e.g. for cloud-only use) leaves `process.env` untouched.
13
+ * The resolved paths are handed to the engine in-process by
14
+ * `configureRuntimeAssets` and are never written to `process.env`: a `smolvm`
15
+ * CLI this program spawns must not inherit this package's helper (it exits
16
+ * when its parent does) or its libraries. A user-provided SMOLVM_BOOT_BINARY,
17
+ * SMOLVM_LIB_DIR or SMOLVM_AGENT_ROOTFS(_TAR) always wins. Called by
18
+ * `native.ts` right before the addon loads — i.e. on first LOCAL use, never at
19
+ * import.
16
20
  */
17
21
  Object.defineProperty(exports, "__esModule", { value: true });
18
22
  exports.PLATFORM_PACKAGES = void 0;
@@ -77,18 +81,18 @@ function wireBundledAssets() {
77
81
  if (!(0, node_fs_1.existsSync)(nativeDir))
78
82
  continue;
79
83
  const helper = (0, node_path_1.join)(nativeDir, helperName);
84
+ // Resolved paths are handed to the engine by `configureRuntimeAssets`, never
85
+ // written to `process.env`: a `smolvm` CLI this program spawns must not
86
+ // inherit this package's helper, libraries or rootfs.
80
87
  if (!assets.bootBinary && (0, node_fs_1.existsSync)(helper)) {
81
88
  assets.bootBinary = helper;
82
- process.env.SMOLVM_BOOT_BINARY = helper;
83
89
  }
84
90
  if (!assets.libDir) {
85
91
  assets.libDir = nativeDir;
86
- process.env.SMOLVM_LIB_DIR = nativeDir;
87
92
  }
88
93
  const rootfsTar = (0, node_path_1.join)(nativeDir, 'agent-rootfs.tar');
89
94
  if (!assets.agentRootfs && !assets.agentRootfsTar && (0, node_fs_1.existsSync)(rootfsTar)) {
90
95
  assets.agentRootfsTar = rootfsTar;
91
- process.env.SMOLVM_AGENT_ROOTFS_TAR = rootfsTar;
92
96
  }
93
97
  break;
94
98
  }
package/dist/binding.d.ts CHANGED
@@ -287,6 +287,11 @@ export declare class NapiMachine {
287
287
  * for a failed boot. Never throws.
288
288
  */
289
289
  static checkHost(): HostAvailability
290
+ /**
291
+ * Stage a local archive or rootfs directory before creating the machine.
292
+ * Large archives are hashed off the JavaScript event loop.
293
+ */
294
+ static prepareImage(image: string): Promise<string>
290
295
  /** Create a new machine. Does not start the VM yet — call `start()`. */
291
296
  constructor(config: MachineConfig)
292
297
  /**
package/dist/machine.d.ts CHANGED
@@ -15,8 +15,8 @@ export declare class Machine {
15
15
  private constructor();
16
16
  /**
17
17
  * Create and start a machine. By default this waits for the guest agent and
18
- * all published ports. On local machines, `waitForPorts: false` returns when
19
- * the agent is ready so `exec()` can start a service on a published port.
18
+ * all published ports. `waitForPorts: false` returns when the agent is
19
+ * ready so `exec()` can start a service on a published port.
20
20
  * A cloud state of `"started"` alone means only that the VM process launched.
21
21
  *
22
22
  * @param config machine configuration (a name is generated if omitted; `image`
@@ -29,11 +29,14 @@ export declare class Machine {
29
29
  * machine made elsewhere (another process, the console, the REST API).
30
30
  * - local (default): re-opens a persisted machine by NAME, starting it if
31
31
  * stopped — pairs with `Machine.create({ name, … }, …)` + `persistent`.
32
+ * A paused machine stays paused; call `resume()` to restore its saved
33
+ * execution before running commands.
32
34
  * Set `conn.waitForPorts: false` to attach after agent readiness when a
33
35
  * published service is not listening (for example, to delete it).
34
36
  * - cloud: looks up the machine by id; throws if it doesn't exist.
35
- * This does not wait for readiness; call `waitUntilReady()` before `exec`,
36
- * using a connect endpoint, or expecting the workload to respond.
37
+ * This does not wait for readiness by default. With `waitForPorts: false`,
38
+ * it waits for the guest agent so `exec()` can start a published service.
39
+ * Call `waitUntilReady()` before expecting the published service to respond.
37
40
  *
38
41
  * @param id local machine name, or cloud machine id (`mach-…`)
39
42
  * @param conn backend selection (local by default; cloud via `{ target: 'cloud', apiKey }` or `SMOL_CLOUD_TOKEN`)
@@ -136,6 +139,10 @@ export declare class Machine {
136
139
  execStream(command: string[], opts?: ExecOptions): AsyncGenerator<ExecEvent>;
137
140
  /** Read a file from the machine. */
138
141
  readFile(path: string): Promise<Buffer>;
142
+ /** Read a file in chunks without buffering the entire Cloud response.
143
+ * Local machines currently yield one buffered chunk from the native binding.
144
+ * Stopping iteration early cancels the Cloud HTTP download. */
145
+ readFileStream(path: string): AsyncGenerator<Uint8Array>;
139
146
  /** Write a file into the machine. */
140
147
  writeFile(path: string, data: string | Uint8Array, mode?: number): Promise<void>;
141
148
  /** Pull an OCI image into the machine's storage. (local) */
package/dist/machine.js CHANGED
@@ -57,8 +57,8 @@ class Machine {
57
57
  }
58
58
  /**
59
59
  * Create and start a machine. By default this waits for the guest agent and
60
- * all published ports. On local machines, `waitForPorts: false` returns when
61
- * the agent is ready so `exec()` can start a service on a published port.
60
+ * all published ports. `waitForPorts: false` returns when the agent is
61
+ * ready so `exec()` can start a service on a published port.
62
62
  * A cloud state of `"started"` alone means only that the VM process launched.
63
63
  *
64
64
  * @param config machine configuration (a name is generated if omitted; `image`
@@ -78,11 +78,14 @@ class Machine {
78
78
  * machine made elsewhere (another process, the console, the REST API).
79
79
  * - local (default): re-opens a persisted machine by NAME, starting it if
80
80
  * stopped — pairs with `Machine.create({ name, … }, …)` + `persistent`.
81
+ * A paused machine stays paused; call `resume()` to restore its saved
82
+ * execution before running commands.
81
83
  * Set `conn.waitForPorts: false` to attach after agent readiness when a
82
84
  * published service is not listening (for example, to delete it).
83
85
  * - cloud: looks up the machine by id; throws if it doesn't exist.
84
- * This does not wait for readiness; call `waitUntilReady()` before `exec`,
85
- * using a connect endpoint, or expecting the workload to respond.
86
+ * This does not wait for readiness by default. With `waitForPorts: false`,
87
+ * it waits for the guest agent so `exec()` can start a published service.
88
+ * Call `waitUntilReady()` before expecting the published service to respond.
86
89
  *
87
90
  * @param id local machine name, or cloud machine id (`mach-…`)
88
91
  * @param conn backend selection (local by default; cloud via `{ target: 'cloud', apiKey }` or `SMOL_CLOUD_TOKEN`)
@@ -262,6 +265,12 @@ class Machine {
262
265
  readFile(path) {
263
266
  return this.transport.readFile(path);
264
267
  }
268
+ /** Read a file in chunks without buffering the entire Cloud response.
269
+ * Local machines currently yield one buffered chunk from the native binding.
270
+ * Stopping iteration early cancels the Cloud HTTP download. */
271
+ readFileStream(path) {
272
+ return this.transport.readFileStream(path);
273
+ }
265
274
  /** Write a file into the machine. */
266
275
  writeFile(path, data, mode) {
267
276
  const buf = typeof data === "string" ? Buffer.from(data) : Buffer.from(data);
package/dist/native.d.ts CHANGED
@@ -150,6 +150,7 @@ export interface NapiMachine {
150
150
  }
151
151
  export interface NapiMachineCtor {
152
152
  new (config: NativeMachineConfig): NapiMachine;
153
+ prepareImage(image: string): Promise<string>;
153
154
  connect(name: string, interceptorAddress?: string, interceptorToken?: string): NapiMachine;
154
155
  list(): Promise<NativeMachineSummary[]>;
155
156
  restoreCheckpoint(name: string, artifact: string, keepIdentity?: boolean): NapiMachine;
@@ -41,6 +41,7 @@ export interface Transport {
41
41
  run(image: string, command: string[], opts?: ExecOptions): Promise<RawExec>;
42
42
  execStream(command: string[], opts?: ExecOptions): AsyncGenerator<ExecEvent>;
43
43
  readFile(path: string): Promise<Buffer>;
44
+ readFileStream(path: string): AsyncGenerator<Uint8Array>;
44
45
  writeFile(path: string, data: Buffer, mode?: number): Promise<void>;
45
46
  pullImage(image: string): Promise<ImageInfo>;
46
47
  listImages(): Promise<ImageInfo[]>;
package/dist/transport.js CHANGED
@@ -471,6 +471,11 @@ class LocalTransport {
471
471
  throw (0, errors_1.wrapNativeError)(e);
472
472
  }
473
473
  }
474
+ // The native local binding currently returns a Buffer; keep the same API
475
+ // across targets until native streaming is available.
476
+ async *readFileStream(path) {
477
+ yield await this.readFile(path);
478
+ }
474
479
  async writeFile(path, data, mode) {
475
480
  try {
476
481
  await this.inner.writeFile(path, data, mode === undefined ? undefined : { mode });
@@ -815,6 +820,8 @@ async function cloudFetch(conn, method, path, opts = {}) {
815
820
  ? "UNAUTHORIZED"
816
821
  : "SMOLVM_ERROR", `cloud ${method} ${path} → ${res.status}${text ? `: ${text}` : ""}${rid ? ` [request id: ${rid}]` : ""}`);
817
822
  }
823
+ if (opts.accept === "response")
824
+ return res;
818
825
  if (opts.accept === "text")
819
826
  return (await res.text());
820
827
  if (opts.accept === "bytes") {
@@ -836,11 +843,11 @@ async function cloudFetch(conn, method, path, opts = {}) {
836
843
  * caller keeps polling until this passes or the deadline hits. */
837
844
  async function agentReachable(conn, id) {
838
845
  try {
839
- await cloudFetch(conn, "POST", `/v1/machines/${id}/exec`, {
846
+ const result = await cloudFetch(conn, "POST", `/v1/machines/${id}/exec`, {
840
847
  json: { command: ["sh", "-c", "true"] },
841
848
  timeoutMs: 15_000,
842
849
  });
843
- return true;
850
+ return result?.exitCode === 0;
844
851
  }
845
852
  catch {
846
853
  return false;
@@ -855,8 +862,10 @@ async function agentReachable(conn, id) {
855
862
  * `started` is NOT yet usable: the guest is still booting, and acting then is
856
863
  * the classic teardown race (works on a slow cold start, times out on a warm
857
864
  * one). Older control planes omit `ready`; there we fall back to the coarse
858
- * `started`/`running` state so this never hangs against them. */
859
- async function waitForReady(conn, id, timeoutMs = 120_000, intervalMs = 1_000) {
865
+ * `started`/`running` state so this never hangs against them.
866
+ * With `waitForPorts: false`, probe the agent even if ports are published:
867
+ * the caller must be able to exec the service before any port can open. */
868
+ async function waitForReady(conn, id, timeoutMs = 120_000, intervalMs = 1_000, waitForPorts = true) {
860
869
  const start = Date.now();
861
870
  const deadline = start + timeoutMs;
862
871
  for (;;) {
@@ -882,8 +891,15 @@ async function waitForReady(conn, id, timeoutMs = 120_000, intervalMs = 1_000) {
882
891
  if (state === "stopped" || state === "deleted") {
883
892
  throw new errors_1.SmolError("SMOLVM_ERROR", `machine ${id} entered ${state} before becoming ready`);
884
893
  }
894
+ // With deferred ports, even `started` and legacy servers' absent `ready`
895
+ // are insufficient: only a successful in-guest exec proves agent readiness.
896
+ if (!waitForPorts && (state === "started" || state === "running")) {
897
+ if (await agentReachable(conn, id))
898
+ return;
899
+ }
885
900
  // Back-compat: `ready` absent entirely → old server, gate on state.
886
- if (m &&
901
+ if (waitForPorts &&
902
+ m &&
887
903
  m.ready === undefined &&
888
904
  (state === "started" || state === "running"))
889
905
  return;
@@ -893,7 +909,8 @@ async function waitForReady(conn, id, timeoutMs = 120_000, intervalMs = 1_000) {
893
909
  // into). After a grace (so `ready` can flip first and this never preempts a
894
910
  // machine legitimately about to become ready), confirm the guest agent is
895
911
  // reachable directly (a trivial exec) and treat that as ready.
896
- if ((state === "started" || state === "running") &&
912
+ if (waitForPorts &&
913
+ (state === "started" || state === "running") &&
897
914
  !m?.ports?.length &&
898
915
  Date.now() - start >= NO_PORT_READY_PROBE_GRACE_MS &&
899
916
  (await agentReachable(conn, id)))
@@ -1155,6 +1172,51 @@ class CloudTransport {
1155
1172
  timeoutMs: transferTimeoutMs(exports.CLOUD_MAX_FILE_BYTES),
1156
1173
  });
1157
1174
  }
1175
+ async *readFileStream(path) {
1176
+ const route = `/v1/machines/${this.id}/files/${encodePath(path)}`;
1177
+ const res = await cloudFetch(this.conn, "GET", route, {
1178
+ accept: "response",
1179
+ timeoutMs: transferTimeoutMs(exports.CLOUD_MAX_FILE_BYTES),
1180
+ });
1181
+ // A caller-supplied byte reader may transform the response. Honor it just
1182
+ // as readFile does, even when the injected fetch exposes a body stream.
1183
+ if (this.conn.readResponseBytes) {
1184
+ const bytes = await this.conn.readResponseBytes(res);
1185
+ if (bytes.length > 0)
1186
+ yield bytes;
1187
+ return;
1188
+ }
1189
+ if (!res.body)
1190
+ return;
1191
+ const reader = res.body.getReader();
1192
+ try {
1193
+ for (;;) {
1194
+ let timer;
1195
+ let chunk;
1196
+ try {
1197
+ chunk = await Promise.race([
1198
+ reader.read(),
1199
+ new Promise((_, reject) => {
1200
+ timer = setTimeout(() => reject(new errors_1.SmolError("TIMEOUT", `cloud GET ${route} stalled for ${CLOUD_TIMEOUT_MS}ms`)), CLOUD_TIMEOUT_MS);
1201
+ }),
1202
+ ]);
1203
+ }
1204
+ finally {
1205
+ if (timer !== undefined)
1206
+ clearTimeout(timer);
1207
+ }
1208
+ if (chunk.done)
1209
+ return;
1210
+ if (chunk.value.length > 0)
1211
+ yield chunk.value;
1212
+ }
1213
+ }
1214
+ finally {
1215
+ // `break`, a thrown error, or a stalled read must close the HTTP body.
1216
+ await reader.cancel().catch(() => { });
1217
+ reader.releaseLock();
1218
+ }
1219
+ }
1158
1220
  async writeFile(path, data, mode) {
1159
1221
  checkCloudFileSize(path, data.length);
1160
1222
  await cloudFetch(this.conn, "PUT", `/v1/machines/${this.id}/files/${encodePath(path)}`, {
@@ -1481,8 +1543,6 @@ async function makeTransport(config, conn) {
1481
1543
  // run the workload as the image's default user.
1482
1544
  if (config.user !== undefined)
1483
1545
  throw new errors_1.NotSupportedError("user is local-only.");
1484
- if (config.waitForPorts === false || conn.waitForPorts === false)
1485
- throw new errors_1.NotSupportedError("waitForPorts: false is local-only.");
1486
1546
  // Cloud is settled: NOW the CLI's stored login may supply the credential
1487
1547
  // and endpoint, which is the reuse `smol auth login` promises.
1488
1548
  const { apiKey: cliKey, endpoint: cliUrl } = cliSession(conn.target);
@@ -1586,7 +1646,7 @@ async function makeTransport(config, conn) {
1586
1646
  startError = e instanceof Error ? e.message : String(e);
1587
1647
  }
1588
1648
  try {
1589
- await waitForReady(cloudConn, id);
1649
+ await waitForReady(cloudConn, id, 120_000, 1_000, (config.waitForPorts ?? conn.waitForPorts) !== false);
1590
1650
  }
1591
1651
  catch (e) {
1592
1652
  if (startError !== undefined && e instanceof errors_1.SmolError)
@@ -1612,6 +1672,9 @@ async function makeTransport(config, conn) {
1612
1672
  // Validate the config before loading the engine, so a config the local
1613
1673
  // target cannot honour fails the same way whether or not it is installed.
1614
1674
  const nativeConfig = toNativeConfig(name, config);
1675
+ if (nativeConfig.image) {
1676
+ nativeConfig.image = await (0, native_1.getNapiMachine)().prepareImage(nativeConfig.image);
1677
+ }
1615
1678
  const inner = new ((0, native_1.getNapiMachine)())(nativeConfig);
1616
1679
  // A detached machine is nobody's to stop on a signal — it was created to
1617
1680
  // survive this process — and its branches inherit that.
@@ -1738,9 +1801,10 @@ async function connectTransport(id, conn) {
1738
1801
  const transport = new LocalTransport(conn.egressInterceptor
1739
1802
  ? (0, native_1.getNapiMachine)().connect(id, conn.egressInterceptor.address, conn.egressInterceptor.token)
1740
1803
  : (0, native_1.getNapiMachine)().connect(id), false, conn.egressInterceptor, conn.handleSignals ?? true);
1741
- // A frozen checkpoint is intentionally not agent-ready; it remains
1742
- // connectable so callers can fork its retained snapshot.
1743
- if ((await transport.state()) !== "frozen" && conn.waitForPorts !== false) {
1804
+ // Frozen and durably paused machines are intentionally not agent-ready.
1805
+ // A paused machine must be resumed explicitly before it can serve work.
1806
+ const state = await transport.state();
1807
+ if (state !== "frozen" && state !== "paused" && state !== "pausing" && conn.waitForPorts !== false) {
1744
1808
  await transport.waitUntilReady();
1745
1809
  }
1746
1810
  return transport;
@@ -1751,8 +1815,6 @@ async function connectTransport(id, conn) {
1751
1815
  }
1752
1816
  if (conn.egressInterceptor)
1753
1817
  throw new errors_1.NotSupportedError("egressInterceptor is local-only.");
1754
- if (conn.waitForPorts === false)
1755
- throw new errors_1.NotSupportedError("waitForPorts: false is local-only.");
1756
1818
  // As in makeTransport: the CLI-login fallback applies only once the cloud
1757
1819
  // target is already selected.
1758
1820
  const { apiKey: cliKey, endpoint: cliUrl } = cliSession(conn.target);
@@ -1783,7 +1845,11 @@ async function connectTransport(id, conn) {
1783
1845
  throw e;
1784
1846
  m = hit;
1785
1847
  }
1786
- return new CloudTransport(cloudConn, m.name ?? id, m.id ?? id);
1848
+ const resolvedId = m.id ?? id;
1849
+ if (conn.waitForPorts === false && m.state !== "frozen") {
1850
+ await waitForReady(cloudConn, resolvedId, 120_000, 1_000, false);
1851
+ }
1852
+ return new CloudTransport(cloudConn, m.name ?? id, resolvedId);
1787
1853
  }
1788
1854
  /** Restore a durable cloud checkpoint into a new machine, then return only once
1789
1855
  * the restored guest is ready. */
package/dist/types.d.ts CHANGED
@@ -207,11 +207,11 @@ export interface MachineConfig {
207
207
  * allocates the host port; readiness includes that published port accepting
208
208
  * connections. */
209
209
  ports?: PortSpec[];
210
- /** Locally, return from `Machine.create()` after the guest agent is ready,
211
- * even if a published port is not accepting connections yet. Use this when
212
- * `exec()` starts the service: the default port wait would deadlock before
213
- * `exec()` can run. Call `machine.waitUntilReady()` after starting it.
214
- * Default true; false is local-only and rejected on cloud. */
210
+ /** Return from `Machine.create()` after the guest agent is ready, even if a
211
+ * published port is not accepting connections yet. Use this when `exec()`
212
+ * starts the service: the default port wait would deadlock before `exec()`
213
+ * can run. Call `machine.waitUntilReady()` after starting it.
214
+ * Default true. On cloud, false probes in-guest execution before returning. */
215
215
  waitForPorts?: boolean;
216
216
  /** Resource allocation. */
217
217
  resources?: ResourceSpec;
@@ -497,11 +497,10 @@ export interface PortableCheckpointInfo {
497
497
  }
498
498
  /** Selects and configures the backend. Local (embedded) is the default. */
499
499
  export interface ConnectOptions {
500
- /** Locally, allow `Machine.connect()` to return after the guest agent is
501
- * ready even when a published port is not serving. Useful when reconnecting
502
- * to stop or delete a worker whose listener has exited. Default true;
503
- * false is local-only and rejected on cloud. For `create`, put this setting
504
- * on `MachineConfig` instead. */
500
+ /** On local machines, allow `Machine.connect()` to return without waiting
501
+ * for published ports. On cloud, false waits for successful in-guest exec
502
+ * without requiring a published listener; the default connection only
503
+ * attaches to the existing machine. For `create`, set this on MachineConfig. */
505
504
  waitForPorts?: boolean;
506
505
  /** Binding for a stopped local machine that requires intercepted egress. */
507
506
  egressInterceptor?: EgressInterceptor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smolmachines",
3
- "version": "1.26.1",
3
+ "version": "1.27.0",
4
4
  "description": "Embed isolated microVM sandboxes directly in your code — no server to run.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "smol-machines",
@@ -51,8 +51,9 @@
51
51
  "test:e2e": "napi build --platform --js binding.js --dts binding.d.ts && tsx test/e2e.ts",
52
52
  "test:staged": "tsx test/staged-mount-e2e.ts",
53
53
  "test:agent-ready": "tsx test/local-agent-ready.ts",
54
+ "test:paused-connect": "tsx test/local-paused-connect.ts",
54
55
  "gen:openapi": "bash scripts/gen-openapi.sh",
55
- "test:cloud": "tsx test/cloud-mock.ts",
56
+ "test:cloud": "tsx test/cloud-mock.ts && tsx test/cloud-deferred-ports.ts",
56
57
  "test:unit": "tsx test/unit.ts"
57
58
  },
58
59
  "devDependencies": {
@@ -63,8 +64,8 @@
63
64
  "typescript": "^5.6.0"
64
65
  },
65
66
  "optionalDependencies": {
66
- "smolmachines-darwin-arm64": "1.26.1",
67
- "smolmachines-linux-x64-gnu": "1.26.1",
68
- "smolmachines-linux-arm64-gnu": "1.26.1"
67
+ "smolmachines-darwin-arm64": "1.27.0",
68
+ "smolmachines-linux-x64-gnu": "1.27.0",
69
+ "smolmachines-linux-arm64-gnu": "1.27.0"
69
70
  }
70
71
  }