smolmachines 1.27.3 → 1.29.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/LICENSE ADDED
@@ -0,0 +1,190 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to the Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ Copyright 2024-2026 smolvm contributors
179
+
180
+ Licensed under the Apache License, Version 2.0 (the "License");
181
+ you may not use this file except in compliance with the License.
182
+ You may obtain a copy of the License at
183
+
184
+ http://www.apache.org/licenses/LICENSE-2.0
185
+
186
+ Unless required by applicable law or agreed to in writing, software
187
+ distributed under the License is distributed on an "AS IS" BASIS,
188
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189
+ See the License for the specific language governing permissions and
190
+ limitations under the License.
package/README.md CHANGED
@@ -205,10 +205,14 @@ vLLM's unrestricted adapter loader.
205
205
  ```ts
206
206
  import { Machine } from 'smolmachines';
207
207
 
208
- const m = await Machine.create({ resources: { cpus: 2, memoryMb: 1024 } });
208
+ // A machine of an image. The image is pulled once; later machines of the same
209
+ // image start from it in about a second.
210
+ const m = await Machine.create({
211
+ image: 'python:3.12',
212
+ resources: { cpus: 2, memoryMb: 1024, network: true },
213
+ });
209
214
  try {
210
- // Run a command in a container image
211
- const res = await m.run('python:3.12', ['python', '-c', 'print(2 ** 10)']);
215
+ const res = await m.exec(['python', '-c', 'print(2 ** 10)']);
212
216
  res.assertSuccess();
213
217
  console.log(res.stdout); // "1024\n"
214
218
 
package/dist/assets.js CHANGED
@@ -90,7 +90,9 @@ function wireBundledAssets() {
90
90
  if (!assets.libDir) {
91
91
  assets.libDir = nativeDir;
92
92
  }
93
- const rootfsTar = (0, node_path_1.join)(nativeDir, 'agent-rootfs.tar');
93
+ // Shipped zstd-compressed; an older or locally built package has the plain tar.
94
+ const compressedRootfs = (0, node_path_1.join)(nativeDir, 'agent-rootfs.tar.zst');
95
+ const rootfsTar = (0, node_fs_1.existsSync)(compressedRootfs) ? compressedRootfs : (0, node_path_1.join)(nativeDir, 'agent-rootfs.tar');
94
96
  if (!assets.agentRootfs && !assets.agentRootfsTar && (0, node_fs_1.existsSync)(rootfsTar)) {
95
97
  assets.agentRootfsTar = rootfsTar;
96
98
  }
package/dist/binding.d.ts CHANGED
@@ -77,6 +77,10 @@ export interface MachineSummary {
77
77
  branchable: boolean
78
78
  /** Creation time, seconds since the Unix epoch. */
79
79
  createdAt: number
80
+ /** CPU, memory and disk sizes, readable whether or not it runs. */
81
+ resources: MachineSizes
82
+ /** Published ports, readable whether or not it runs. */
83
+ ports: Array<PublishedPort>
80
84
  }
81
85
  /** Whether this host can run local machines, from the engine's own checks. */
82
86
  export interface HostAvailability {
@@ -214,6 +218,45 @@ export interface ExecOptions {
214
218
  * machines only; a bare VM rejects it rather than run as root.
215
219
  */
216
220
  user?: string
221
+ /**
222
+ * Text written to the command's stdin, which is then closed. It is
223
+ * passed over the agent connection and never written to the guest disk.
224
+ */
225
+ stdin?: string
226
+ }
227
+ /** How a stop or delete treats work still running on the machine. */
228
+ export interface StopOptions {
229
+ /**
230
+ * Wait up to this many seconds for running commands and file transfers
231
+ * to finish first. What is still running then fails with code
232
+ * `INTERRUPTED`. Default: stop at once.
233
+ */
234
+ drainSecs?: number
235
+ /**
236
+ * Seconds the workload gets to exit after its stop signal, instead of
237
+ * the machine's stop timeout; 0 kills it at once. Stop only.
238
+ */
239
+ timeoutSecs?: number
240
+ }
241
+ /** CPU, memory and disk sizes of a machine. */
242
+ export interface MachineSizes {
243
+ /** vCPUs. */
244
+ cpus: number
245
+ /** Memory in MiB. */
246
+ memoryMib: number
247
+ /** Storage disk in GiB. */
248
+ storageGib: number
249
+ /** Container overlay disk in GiB. */
250
+ overlayGib: number
251
+ }
252
+ /** One published port of a machine. */
253
+ export interface PublishedPort {
254
+ /** Host port. */
255
+ host: number
256
+ /** Guest port. */
257
+ guest: number
258
+ /** `"tcp"` or `"udp"`. */
259
+ protocol: string
217
260
  }
218
261
  /** Options for writing a file into the VM. */
219
262
  export interface FileWriteOptions {
@@ -297,9 +340,21 @@ export declare class NapiMachine {
297
340
  /**
298
341
  * Attach to an existing machine by name, starting it if stopped
299
342
  * (start-or-reconnect). Lets a persisted machine be re-opened in a new
300
- * process — backs the SDK's local `Machine.connect()`.
343
+ * process — backs the SDK's local `Machine.connect()`. The boot runs off
344
+ * the JavaScript thread, so the event loop keeps serving while it waits.
301
345
  */
302
- static connect(name: string, interceptorAddress?: string | undefined | null, interceptorToken?: string | undefined | null): NapiMachine
346
+ static connect(name: string, interceptorAddress?: string | undefined | null, interceptorToken?: string | undefined | null): Promise<NapiMachine>
347
+ /**
348
+ * Open a handle to an existing machine without starting or connecting to
349
+ * it, to read its state, stop, resize or delete it. Fails when no machine
350
+ * of that name exists.
351
+ */
352
+ static open(name: string): NapiMachine
353
+ /**
354
+ * Delete a machine by name, whether it runs or not, without booting it.
355
+ * A machine that does not exist is already deleted.
356
+ */
357
+ static deleteByName(name: string, options?: StopOptions | undefined | null): Promise<void>
303
358
  /**
304
359
  * Every machine in this host's shared machine database, in name order.
305
360
  * The database is shared with the CLI and every other embedder on the
@@ -328,7 +383,7 @@ export declare class NapiMachine {
328
383
  /**
329
384
  * Start a command and return a live `ExecStream` of its output events. A
330
385
  * worker thread drives the engine's `exec_streaming_with` and feeds an mpsc
331
- * channel the stream drains incrementally (no buffering).
386
+ * channel the stream drains incrementally (apart from partial UTF-8).
332
387
  */
333
388
  execStream(command: Array<string>, options?: ExecOptions | undefined | null): ExecStream
334
389
  /** Get the child PID if the VM is running. */
@@ -391,18 +446,45 @@ export declare class NapiMachine {
391
446
  execStreaming(command: Array<string>, options?: ExecOptions | undefined | null): Promise<Array<ExecStreamEvent>>
392
447
  /** Copy guest-local staged mounts back to their host sources. */
393
448
  sync(): Promise<void>
394
- /** Stop the machine VM gracefully. */
395
- stop(): Promise<void>
449
+ /**
450
+ * Stop the machine VM gracefully: its workload gets its stop signal and
451
+ * stop timeout first. A command or file transfer still running fails
452
+ * with code `INTERRUPTED` unless `drainSecs` lets it finish.
453
+ */
454
+ stop(options?: StopOptions | undefined | null): Promise<void>
396
455
  /** Save execution durably and stop the machine. */
397
456
  pause(): Promise<void>
398
457
  resume(): Promise<void>
399
- /** Stop the machine and clean up all storage (disks, config). */
400
- delete(): Promise<void>
458
+ /**
459
+ * The machine's published ports, read from its record whether or not it
460
+ * runs.
461
+ */
462
+ publishedPorts(): Array<PublishedPort>
463
+ /**
464
+ * Make `ports` the machine's published ports. A running machine changes
465
+ * its TCP ports now; UDP ports, or any port of a machine not on
466
+ * virtio-net, change only while it is stopped.
467
+ */
468
+ setPorts(ports: Array<PublishedPort>): Promise<void>
469
+ /**
470
+ * Pack this stopped machine into a `.smolmachine` at `output`; returns
471
+ * the artifact's path.
472
+ */
473
+ pack(output: string): Promise<string>
474
+ /** Create the stopped machine `name` from a `.smolmachine` artifact. */
475
+ static createFromPack(name: string, pack: string): Promise<NapiMachine>
476
+ /**
477
+ * Stop the machine and clean up all storage (disks, config). A command or
478
+ * file transfer still running fails with code `INTERRUPTED` unless
479
+ * `drainSecs` lets it finish.
480
+ */
481
+ delete(options?: StopOptions | undefined | null): Promise<void>
401
482
  }
402
483
  /**
403
484
  * A live, incremental exec stream. `next()` resolves with the next event as it
404
485
  * arrives (off the libuv loop via spawn_blocking), or `null` once the command
405
- * exits and the channel closes. Mirrors the Python SDK's ExecStream iterator.
486
+ * exits and the channel closes. Only an incomplete UTF-8 code point is held
487
+ * across events. Mirrors the Python SDK's ExecStream iterator.
406
488
  */
407
489
  export declare class ExecStream {
408
490
  next(): Promise<ExecStreamEvent | null>
package/dist/errors.d.ts CHANGED
@@ -15,8 +15,28 @@ export interface CloudErrorDetail {
15
15
  /** How long the server asked callers to wait before retrying (ms). */
16
16
  retryAfterMs?: number | undefined;
17
17
  }
18
+ /** The stable codes a `SmolError` carries, to branch on instead of matching
19
+ * message text (which may change). Unknown codes from a newer engine or
20
+ * control plane still type-check as plain strings.
21
+ *
22
+ * - `NOT_FOUND`: no such machine, file (`readFile` of a missing path),
23
+ * image or checkpoint.
24
+ * - `INTERRUPTED`: a `stop()` or `delete()` cut off this command or file
25
+ * transfer (local). `MACHINE_INTERRUPTED`: the same, on the cloud.
26
+ * - `CONFLICT`: the machine's state does not allow it now (it is being
27
+ * stopped or deleted, it has dependent branches, ...). `INVALID_STATE`:
28
+ * the same, from an older engine path.
29
+ * - `INVALID_CONFIG`: a usage error in the arguments. `CONFIG_ERROR`: the
30
+ * engine refused a configuration value. `NOT_SUPPORTED`: not on this
31
+ * target.
32
+ * - `KVM_UNAVAILABLE`, `HYPERVISOR_UNAVAILABLE`: this host cannot run
33
+ * machines. `STORAGE_ERROR`, `MOUNT_ERROR`: disk or mount failures.
34
+ * - `COMMAND_FAILED`: a command exited non-zero (`assertSuccess()`).
35
+ * - `TIMEOUT`, `CONNECTION`, `UNAUTHORIZED`: cloud transport failures.
36
+ * - `SMOLVM_ERROR`: anything not classified above. */
37
+ export type SmolErrorCode = "NOT_FOUND" | "INTERRUPTED" | "MACHINE_INTERRUPTED" | "CONFLICT" | "INVALID_STATE" | "FORBIDDEN" | "INVALID_CONFIG" | "CONFIG_ERROR" | "NOT_SUPPORTED" | "KVM_UNAVAILABLE" | "HYPERVISOR_UNAVAILABLE" | "STORAGE_ERROR" | "MOUNT_ERROR" | "COMMAND_FAILED" | "TIMEOUT" | "CONNECTION" | "UNAUTHORIZED" | "SMOLVM_ERROR" | (string & {});
18
38
  export declare class SmolError extends Error {
19
- readonly code: string;
39
+ readonly code: SmolErrorCode;
20
40
  /** HTTP status, for an error the cloud API returned. */
21
41
  readonly status?: number | undefined;
22
42
  /** The server's stable problem code, for an error the cloud API returned. */
@@ -27,7 +47,7 @@ export declare class SmolError extends Error {
27
47
  readonly retryable: boolean;
28
48
  /** How long the server asked callers to wait before retrying (ms). */
29
49
  readonly retryAfterMs?: number | undefined;
30
- constructor(code: string, message: string, detail?: CloudErrorDetail);
50
+ constructor(code: SmolErrorCode, message: string, detail?: CloudErrorDetail);
31
51
  }
32
52
  /** The active backend can't serve this operation (e.g. volumes on local). */
33
53
  export declare class NotSupportedError extends SmolError {
package/dist/index.d.ts CHANGED
@@ -20,6 +20,7 @@ export { Machine, Episode, CacheDisk, Job } from './machine';
20
20
  export { RolloutClient, RolloutError, adapterSha256 } from './rollout';
21
21
  export type { BuildRolloutJobOptions, EnsureRolloutExecutorOptions, RolloutBatchItem, RolloutClientOptions, RolloutCompletion, RolloutExecutorInfo, RolloutGenerateResponse, RolloutJob, RolloutPolicyInfo, RolloutPrompt, RolloutSampling, } from './rollout';
22
22
  export { SmolError, NotSupportedError, InvalidConfigError, ExecutionError } from './errors';
23
+ export type { SmolErrorCode } from './errors';
23
24
  export type { CloudCheckpointInfo } from './transport';
24
25
  export type { LocalAvailability, LocalUnavailableCode } from './availability';
25
- export type { MachineConfig, CredentialSpec, CacheDiskRef, CacheDiskInfo, CacheDiskVersion, CreateCacheDiskOptions, PublishedCacheDisk, NetworkPolicy, RestoreCheckpointOptions, MachineState, ResourceSpec, MountSpec, PortSpec, BranchOptions, BranchBatchOptions, CheckpointOptions, ResizeOptions, MachineResources, ForkOptions, ForkBatchOptions, AssignOptions, ExecOptions, JobInfo, JobOptions, JobOutput, JobOutputOptions, JobState, JobWaitOptions, ExecResult, MachineUsageReport, ShareLink, PortableCheckpointInfo, MachineUsageTotals, MachineCostBreakdown, ImageInfo, MachineSummary, ListOptions, ExecEvent, ConnectOptions, EgressInterceptor, StartOptions, ResumeOptions, WaitReadyOptions, PortEndpoint, } from './types';
26
+ export type { MachineConfig, CredentialSpec, CacheDiskRef, CacheDiskInfo, CacheDiskVersion, CreateCacheDiskOptions, PublishedCacheDisk, NetworkPolicy, RestoreCheckpointOptions, MachineState, ResourceSpec, MountSpec, PortSpec, BranchOptions, BranchBatchOptions, CheckpointOptions, ResizeOptions, MachineResources, ForkOptions, ForkBatchOptions, AssignOptions, ExecOptions, JobInfo, JobOptions, JobOutput, JobOutputOptions, JobState, JobWaitOptions, ExecResult, MachineUsageReport, ShareLink, PortableCheckpointInfo, MachineUsageTotals, MachineCostBreakdown, ImageInfo, MachineSummary, MachineSizes, PublishedPort, StopOptions, ListOptions, ExecEvent, ConnectOptions, EgressInterceptor, StartOptions, ResumeOptions, WaitReadyOptions, PortEndpoint, } from './types';
package/dist/machine.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { type LocalAvailability } from "./availability";
11
11
  import { type CloudCheckpointInfo, type Transport } from "./transport";
12
- import type { CacheDiskInfo, CreateCacheDiskOptions, PublishedCacheDisk, MachineResources, ResizeOptions, AssignOptions, BranchBatchOptions, BranchOptions, CheckpointOptions, ConnectOptions, ExecEvent, ExecOptions, ExecResult, ForkBatchOptions, ForkOptions, ImageInfo, JobInfo, JobOptions, JobOutput, JobOutputOptions, JobWaitOptions, ListOptions, MachineConfig, MachineSummary, NetworkPolicy, RestoreCheckpointOptions, StartOptions, ResumeOptions, MachineUsageReport, ShareLink, PortableCheckpointInfo, PortEndpoint, PortSpec, WaitReadyOptions } from "./types";
12
+ import type { CacheDiskInfo, CreateCacheDiskOptions, PublishedCacheDisk, MachineResources, ResizeOptions, AssignOptions, BranchBatchOptions, BranchOptions, CheckpointOptions, ConnectOptions, ExecEvent, ExecOptions, ExecResult, ForkBatchOptions, ForkOptions, ImageInfo, JobInfo, JobOptions, JobOutput, JobOutputOptions, JobWaitOptions, ListOptions, MachineConfig, MachineSummary, NetworkPolicy, RestoreCheckpointOptions, StartOptions, ResumeOptions, MachineUsageReport, ShareLink, PortableCheckpointInfo, PortEndpoint, PortSpec, PublishedPort, StopOptions, WaitReadyOptions } from "./types";
13
13
  export declare class Machine {
14
14
  private readonly transport;
15
15
  private constructor();
@@ -42,6 +42,22 @@ export declare class Machine {
42
42
  * @param conn backend selection (local by default; cloud via `{ target: 'cloud', apiKey }` or `SMOL_CLOUD_TOKEN`)
43
43
  */
44
44
  static connect(id: string, conn?: ConnectOptions): Promise<Machine>;
45
+ /**
46
+ * Open a handle to an EXISTING machine without starting it or waiting for
47
+ * it — to describe, stop, resize or delete it, including one that can no
48
+ * longer boot (its image is gone, the host lost KVM). `state()` reports
49
+ * `"stopped"` for a stopped machine. Call `start()` to boot it.
50
+ * - local: by name; rejects with code `NOT_FOUND` when none exists.
51
+ * - cloud: by id or name, as `connect`.
52
+ */
53
+ static open(id: string, conn?: ConnectOptions): Promise<Machine>;
54
+ /**
55
+ * Delete a machine by name (cloud: id or name) without a handle and without
56
+ * booting it, whether it runs or not. Deleting one that does not exist
57
+ * succeeds. `drainSecs` lets running commands and transfers finish first
58
+ * (local).
59
+ */
60
+ static delete(id: string, conn?: ConnectOptions, opts?: Pick<StopOptions, "drainSecs">): Promise<void>;
45
61
  /**
46
62
  * List every machine the target knows about — including ones created by
47
63
  * other processes, or by this one before it last restarted. Locally that is
@@ -170,8 +186,43 @@ export declare class Machine {
170
186
  * resumes saved memory (restored or paused) keeps its network backend, so a
171
187
  * policy that would need a different one is refused. (local) */
172
188
  setNetworkPolicy(policy: NetworkPolicy): Promise<void>;
173
- /** Stop the machine. */
174
- stop(): Promise<void>;
189
+ /** The machine's published ports, whether or not it runs. On the cloud each
190
+ * host port is the node's, and a port not placed yet reports host 0. */
191
+ ports(): Promise<PublishedPort[]>;
192
+ /**
193
+ * Publish a guest port on the host — on a running machine too, without a
194
+ * restart. Without `host`, a free host port is picked. Returns the mapping
195
+ * now in effect. A running machine takes a TCP port now; a UDP port, or a
196
+ * machine not on virtio-net networking, needs it stopped. On the cloud the
197
+ * machine's node picks the host port (`host` is ignored) and ports are TCP;
198
+ * reach the port with `connect` or the machine's URL.
199
+ */
200
+ publish(port: {
201
+ guest: number;
202
+ host?: number;
203
+ protocol?: "tcp" | "udp";
204
+ }): Promise<PublishedPort>;
205
+ /**
206
+ * Stop publishing a port, matched by `guest` port (every host port
207
+ * forwarding to it) or by `host` port. Connections already open carry on.
208
+ */
209
+ unpublish(port: {
210
+ guest?: number;
211
+ host?: number;
212
+ protocol?: "tcp" | "udp";
213
+ }): Promise<void>;
214
+ /**
215
+ * Pack this stopped machine into a `.smolmachine` at `path`: its disks and
216
+ * configuration in one artifact that stays on this host. Create machines
217
+ * from it with `Machine.create({ image: path })`. Returns the artifact's
218
+ * path. (local)
219
+ */
220
+ pack(path: string): Promise<string>;
221
+ /** Stop the machine. Its workload is sent its stop signal and given its
222
+ * stop timeout to save state and exit first. A command or file transfer
223
+ * still running rejects with code `INTERRUPTED` unless `drainSecs` lets it
224
+ * finish; new ones are refused while the stop runs. */
225
+ stop(opts?: StopOptions): Promise<void>;
175
226
  /** Save RAM and disk durably, then stop at that execution boundary. */
176
227
  pause(): Promise<void>;
177
228
  /** Resume saved execution in this machine. */
@@ -183,7 +234,7 @@ export declare class Machine {
183
234
  * `{ includeUsage: true }` to also get the fully settled usage + cost back
184
235
  * in one call — the control plane takes a final metering sample before
185
236
  * teardown, so nothing accrues after the returned report. */
186
- delete(): Promise<void>;
237
+ delete(opts?: Pick<StopOptions, "drainSecs">): Promise<void>;
187
238
  delete(opts: {
188
239
  includeUsage: true;
189
240
  }): Promise<MachineUsageReport>;
@@ -200,6 +251,16 @@ export declare class Machine {
200
251
  /** Revoke this machine's anonymous share link (cloud target). The existing
201
252
  * URL immediately stops granting access. */
202
253
  unshare(): Promise<void>;
254
+ /** A URL for each published port that reaches it with no login (cloud
255
+ * target), keyed by guest port: for a client that cannot send an API key,
256
+ * such as a sandbox framework calling its tunnel URLs. Anyone holding a link
257
+ * reaches the port, so treat it as a credential. The machine keeps one
258
+ * token, so calling again returns the same links; a port published later
259
+ * appears on the next call. {@link revokePortLinks} revokes them. */
260
+ portLinks(): Promise<Record<number, string>>;
261
+ /** Revoke this machine's port links (cloud target): every link stops working
262
+ * at once, and the next {@link portLinks} issues new ones. */
263
+ revokePortLinks(): Promise<void>;
203
264
  /** Publish this machine's cache disk, the version it started from plus
204
265
  * everything it wrote, as its cache disk's next version (cloud target).
205
266
  * Stop the machine first: a running machine is still writing it. */
package/dist/machine.js CHANGED
@@ -24,10 +24,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.CacheDisk = exports.Episode = exports.Job = exports.Machine = void 0;
25
25
  const node_crypto_1 = require("node:crypto");
26
26
  const node_path_1 = require("node:path");
27
+ const node_net_1 = require("node:net");
27
28
  const errors_1 = require("./errors");
28
29
  const native_1 = require("./native");
29
30
  const availability_1 = require("./availability");
30
31
  const transport_1 = require("./transport");
32
+ /** A host port free right now on loopback, for a publish that names none. */
33
+ function freeHostPort() {
34
+ return new Promise((resolve, reject) => {
35
+ const server = (0, node_net_1.createServer)();
36
+ server.once("error", reject);
37
+ server.listen(0, "127.0.0.1", () => {
38
+ const address = server.address();
39
+ const port = typeof address === "object" && address ? address.port : 0;
40
+ server.close(() => (port ? resolve(port) : reject(new Error("no free port"))));
41
+ });
42
+ });
43
+ }
31
44
  function makeExecResult(r) {
32
45
  const success = r.exitCode === 0;
33
46
  return {
@@ -93,6 +106,26 @@ class Machine {
93
106
  static async connect(id, conn = {}) {
94
107
  return new Machine(await (0, transport_1.connectTransport)(id, conn));
95
108
  }
109
+ /**
110
+ * Open a handle to an EXISTING machine without starting it or waiting for
111
+ * it — to describe, stop, resize or delete it, including one that can no
112
+ * longer boot (its image is gone, the host lost KVM). `state()` reports
113
+ * `"stopped"` for a stopped machine. Call `start()` to boot it.
114
+ * - local: by name; rejects with code `NOT_FOUND` when none exists.
115
+ * - cloud: by id or name, as `connect`.
116
+ */
117
+ static async open(id, conn = {}) {
118
+ return new Machine(await (0, transport_1.openTransport)(id, conn));
119
+ }
120
+ /**
121
+ * Delete a machine by name (cloud: id or name) without a handle and without
122
+ * booting it, whether it runs or not. Deleting one that does not exist
123
+ * succeeds. `drainSecs` lets running commands and transfers finish first
124
+ * (local).
125
+ */
126
+ static async delete(id, conn = {}, opts) {
127
+ return (0, transport_1.deleteMachine)(id, conn, opts);
128
+ }
96
129
  /**
97
130
  * List every machine the target knows about — including ones created by
98
131
  * other processes, or by this one before it last restarted. Locally that is
@@ -322,9 +355,62 @@ class Machine {
322
355
  setNetworkPolicy(policy) {
323
356
  return this.transport.setNetworkPolicy(policy);
324
357
  }
325
- /** Stop the machine. */
326
- stop() {
327
- return this.transport.stop();
358
+ /** The machine's published ports, whether or not it runs. On the cloud each
359
+ * host port is the node's, and a port not placed yet reports host 0. */
360
+ ports() {
361
+ return this.transport.ports();
362
+ }
363
+ /**
364
+ * Publish a guest port on the host — on a running machine too, without a
365
+ * restart. Without `host`, a free host port is picked. Returns the mapping
366
+ * now in effect. A running machine takes a TCP port now; a UDP port, or a
367
+ * machine not on virtio-net networking, needs it stopped. On the cloud the
368
+ * machine's node picks the host port (`host` is ignored) and ports are TCP;
369
+ * reach the port with `connect` or the machine's URL.
370
+ */
371
+ async publish(port) {
372
+ const protocol = port.protocol ?? "tcp";
373
+ const host = port.host ?? (await freeHostPort());
374
+ const current = await this.transport.ports();
375
+ const mapping = { host, guest: port.guest, protocol };
376
+ const next = current.filter((p) => !(p.host === host && p.protocol === protocol));
377
+ await this.transport.setPorts([...next, mapping]);
378
+ // Report what took effect: on the cloud the node chose the host port.
379
+ const now = await this.transport.ports();
380
+ const same = (p) => p.guest === port.guest && p.protocol === protocol;
381
+ return now.find((p) => same(p) && p.host === host) ?? now.find(same) ?? mapping;
382
+ }
383
+ /**
384
+ * Stop publishing a port, matched by `guest` port (every host port
385
+ * forwarding to it) or by `host` port. Connections already open carry on.
386
+ */
387
+ async unpublish(port) {
388
+ if (port.guest === undefined && port.host === undefined) {
389
+ throw new errors_1.InvalidConfigError("unpublish needs a guest or a host port.");
390
+ }
391
+ const protocol = port.protocol ?? "tcp";
392
+ const current = await this.transport.ports();
393
+ const next = current.filter((p) => p.protocol !== protocol ||
394
+ (port.guest !== undefined && p.guest !== port.guest) ||
395
+ (port.host !== undefined && p.host !== port.host));
396
+ if (next.length !== current.length)
397
+ await this.transport.setPorts(next);
398
+ }
399
+ /**
400
+ * Pack this stopped machine into a `.smolmachine` at `path`: its disks and
401
+ * configuration in one artifact that stays on this host. Create machines
402
+ * from it with `Machine.create({ image: path })`. Returns the artifact's
403
+ * path. (local)
404
+ */
405
+ pack(path) {
406
+ return this.transport.pack(path);
407
+ }
408
+ /** Stop the machine. Its workload is sent its stop signal and given its
409
+ * stop timeout to save state and exit first. A command or file transfer
410
+ * still running rejects with code `INTERRUPTED` unless `drainSecs` lets it
411
+ * finish; new ones are refused while the stop runs. */
412
+ stop(opts) {
413
+ return this.transport.stop(opts);
328
414
  }
329
415
  /** Save RAM and disk durably, then stop at that execution boundary. */
330
416
  pause() { return this.transport.pause(); }
@@ -340,7 +426,7 @@ class Machine {
340
426
  delete(opts) {
341
427
  if (opts?.includeUsage)
342
428
  return this.transport.deleteWithUsage();
343
- return this.transport.delete();
429
+ return this.transport.delete(opts?.drainSecs !== undefined ? { drainSecs: opts.drainSecs } : undefined);
344
430
  }
345
431
  /** Metered usage + cost for this machine (cloud target). Usage records
346
432
  * survive deletion for 30 days. CPU/memory accrue via a rollup that samples
@@ -361,6 +447,20 @@ class Machine {
361
447
  unshare() {
362
448
  return this.transport.unshare();
363
449
  }
450
+ /** A URL for each published port that reaches it with no login (cloud
451
+ * target), keyed by guest port: for a client that cannot send an API key,
452
+ * such as a sandbox framework calling its tunnel URLs. Anyone holding a link
453
+ * reaches the port, so treat it as a credential. The machine keeps one
454
+ * token, so calling again returns the same links; a port published later
455
+ * appears on the next call. {@link revokePortLinks} revokes them. */
456
+ portLinks() {
457
+ return this.transport.portLinks();
458
+ }
459
+ /** Revoke this machine's port links (cloud target): every link stops working
460
+ * at once, and the next {@link portLinks} issues new ones. */
461
+ revokePortLinks() {
462
+ return this.transport.revokePortLinks();
463
+ }
364
464
  /** Publish this machine's cache disk, the version it started from plus
365
465
  * everything it wrote, as its cache disk's next version (cloud target).
366
466
  * Stop the machine first: a running machine is still writing it. */
package/dist/native.d.ts CHANGED
@@ -16,6 +16,11 @@ export interface NativeExecOptions {
16
16
  workdir?: string | undefined;
17
17
  timeoutSecs?: number | undefined;
18
18
  user?: string | undefined;
19
+ stdin?: string | undefined;
20
+ }
21
+ export interface NativeStopOptions {
22
+ drainSecs?: number | undefined;
23
+ timeoutSecs?: number | undefined;
19
24
  }
20
25
  export interface NativeHostMount {
21
26
  source: string;
@@ -72,6 +77,17 @@ export interface NativeMachineSummary {
72
77
  detached: boolean;
73
78
  branchable: boolean;
74
79
  createdAt: number;
80
+ resources: {
81
+ cpus: number;
82
+ memoryMib: number;
83
+ storageGib: number;
84
+ overlayGib: number;
85
+ };
86
+ ports: {
87
+ host: number;
88
+ guest: number;
89
+ protocol: string;
90
+ }[];
75
91
  }
76
92
  export interface NativeExecResult {
77
93
  exitCode: number;
@@ -143,15 +159,29 @@ export interface NapiMachine {
143
159
  cidrs?: string[];
144
160
  hosts?: string[];
145
161
  }): void;
146
- stop(): Promise<void>;
162
+ stop(options?: NativeStopOptions): Promise<void>;
163
+ publishedPorts(): {
164
+ host: number;
165
+ guest: number;
166
+ protocol: string;
167
+ }[];
168
+ setPorts(ports: {
169
+ host: number;
170
+ guest: number;
171
+ protocol: string;
172
+ }[]): Promise<void>;
173
+ pack(output: string): Promise<string>;
147
174
  pause(): Promise<void>;
148
175
  resume(): Promise<void>;
149
- delete(): Promise<void>;
176
+ delete(options?: NativeStopOptions): Promise<void>;
150
177
  }
151
178
  export interface NapiMachineCtor {
152
179
  new (config: NativeMachineConfig): NapiMachine;
153
180
  prepareImage(image: string): Promise<string>;
154
- connect(name: string, interceptorAddress?: string, interceptorToken?: string): NapiMachine;
181
+ connect(name: string, interceptorAddress?: string, interceptorToken?: string): Promise<NapiMachine>;
182
+ open(name: string): NapiMachine;
183
+ deleteByName(name: string, options?: NativeStopOptions): Promise<void>;
184
+ createFromPack(name: string, pack: string): Promise<NapiMachine>;
155
185
  list(): Promise<NativeMachineSummary[]>;
156
186
  restoreCheckpoint(name: string, artifact: string, keepIdentity?: boolean): NapiMachine;
157
187
  exportCheckpoint(source: string, output: string): number;
@@ -7,7 +7,7 @@
7
7
  * Cloud-only/local-only capability gaps surface as `NotSupportedError`.
8
8
  */
9
9
  import { type NativeMachineConfig } from "./native";
10
- import type { CheckpointOptions, JobInfo, JobOptions, JobOutput, JobOutputOptions, CacheDiskInfo, CreateCacheDiskOptions, PublishedCacheDisk, MachineResources, ResizeOptions, ConnectOptions, NetworkPolicy, RestoreCheckpointOptions, ExecEvent, ExecOptions, EgressInterceptor, AssignOptions, ForkBatchOptions, ForkOptions, ImageInfo, ListOptions, MachineConfig, MachineSummary, MachineUsageReport, ShareLink, PortableCheckpointInfo, PortEndpoint, PortSpec, WaitReadyOptions } from "./types";
10
+ import type { CheckpointOptions, JobInfo, JobOptions, JobOutput, JobOutputOptions, CacheDiskInfo, CreateCacheDiskOptions, PublishedCacheDisk, MachineResources, ResizeOptions, ConnectOptions, NetworkPolicy, RestoreCheckpointOptions, ExecEvent, ExecOptions, EgressInterceptor, AssignOptions, ForkBatchOptions, ForkOptions, ImageInfo, ListOptions, MachineConfig, MachineSummary, MachineUsageReport, ShareLink, PortableCheckpointInfo, PortEndpoint, PortSpec, PublishedPort, StopOptions, WaitReadyOptions } from "./types";
11
11
  /** Raw exec result (the ergonomic wrapper is added in machine.ts). */
12
12
  export interface RawExec {
13
13
  /** The detached process's pid, for a background exec. */
@@ -58,11 +58,17 @@ export interface Transport {
58
58
  readonly machineId: string;
59
59
  sync(): Promise<void>;
60
60
  setNetworkPolicy(policy: NetworkPolicy): Promise<void>;
61
- stop(): Promise<void>;
61
+ /** Published ports, whether or not the machine runs. */
62
+ ports(): Promise<PublishedPort[]>;
63
+ /** Replace the published ports. The cloud takes TCP guest ports and picks host ports. */
64
+ setPorts(ports: PublishedPort[]): Promise<void>;
65
+ /** Pack the stopped machine into a `.smolmachine`. (local) */
66
+ pack(output: string): Promise<string>;
67
+ stop(opts?: StopOptions): Promise<void>;
62
68
  pause(): Promise<void>;
63
69
  resume(waitUntilReady?: boolean): Promise<void>;
64
70
  start(interceptor?: EgressInterceptor, waitUntilReady?: boolean): Promise<void>;
65
- delete(): Promise<void>;
71
+ delete(opts?: StopOptions): Promise<void>;
66
72
  /** Cloud only: delete and return the settled usage + cost in one call. */
67
73
  deleteWithUsage(): Promise<MachineUsageReport>;
68
74
  /** Cloud only: metered usage + cost; readable up to 30 days after delete. */
@@ -72,6 +78,10 @@ export interface Transport {
72
78
  share(): Promise<ShareLink>;
73
79
  /** Cloud only: revoke the machine's anonymous share link. */
74
80
  unshare(): Promise<void>;
81
+ /** Cloud only: a login-free URL for each published port, by port. */
82
+ portLinks(): Promise<Record<number, string>>;
83
+ /** Cloud only: revoke the machine's port links. */
84
+ revokePortLinks(): Promise<void>;
75
85
  /** Cloud only: publish a stopped machine's cache disk as its next version. */
76
86
  publishCacheDisk(): Promise<PublishedCacheDisk>;
77
87
  checkpoint(output?: string, options?: CheckpointOptions): Promise<PortableCheckpointInfo>;
@@ -175,6 +185,18 @@ export declare function makeTransport(config: MachineConfig, conn: ConnectOption
175
185
  * the account. Labels tell one owner's machines from another's.
176
186
  */
177
187
  export declare function listMachines(conn: ConnectOptions, options?: ListOptions): Promise<MachineSummary[]>;
188
+ /**
189
+ * A handle to an existing machine that does not start it or wait for it —
190
+ * to read its state, stop, resize or delete it. Locally it fails with
191
+ * `NOT_FOUND` when no machine has that name; on the cloud it looks the
192
+ * machine up by id or name.
193
+ */
194
+ export declare function openTransport(id: string, conn: ConnectOptions): Promise<Transport>;
195
+ /**
196
+ * Delete a machine by name or id without a handle, whether or not it runs,
197
+ * and without booting it. Deleting a machine that does not exist succeeds.
198
+ */
199
+ export declare function deleteMachine(id: string, conn: ConnectOptions, opts?: StopOptions): Promise<void>;
178
200
  /**
179
201
  * Attach to an EXISTING machine without creating a new one — for driving a
180
202
  * machine made elsewhere (another process, the console, the API).
package/dist/transport.js CHANGED
@@ -25,6 +25,8 @@ exports.sdkMayRetry = sdkMayRetry;
25
25
  exports.selectsCloud = selectsCloud;
26
26
  exports.makeTransport = makeTransport;
27
27
  exports.listMachines = listMachines;
28
+ exports.openTransport = openTransport;
29
+ exports.deleteMachine = deleteMachine;
28
30
  exports.connectTransport = connectTransport;
29
31
  exports.deleteCheckpointTransport = deleteCheckpointTransport;
30
32
  exports.restoreCheckpointTransport = restoreCheckpointTransport;
@@ -42,6 +44,11 @@ const node_os_1 = require("node:os");
42
44
  const node_path_1 = require("node:path");
43
45
  const native_1 = require("./native");
44
46
  const errors_1 = require("./errors");
47
+ /** A cloud machine's published ports, as the SDK reports ports everywhere. A
48
+ * port not placed on a node yet has no host port, reported as 0. */
49
+ function cloudPorts(m) {
50
+ return (m.ports ?? []).map((p) => ({ host: p.hostPort ?? 0, guest: p.port, protocol: "tcp" }));
51
+ }
45
52
  /** Byte-exact exec output from a cloud response: prefer the base64 field
46
53
  * (binary-safe, untruncated), fall back to the UTF-8 bytes of the lossy text
47
54
  * when a control predates it or the value is malformed. */
@@ -139,10 +146,22 @@ function toNativeExecOptions(opts) {
139
146
  workdir: opts.workdir,
140
147
  timeoutSecs: opts.timeout,
141
148
  user: opts.user,
149
+ stdin: stdinText(opts.stdin),
142
150
  env: opts.env &&
143
151
  Object.entries(opts.env).map(([key, value]) => ({ key, value })),
144
152
  };
145
153
  }
154
+ /** `ExecOptions.stdin` as the text the agent protocol carries. */
155
+ function stdinText(stdin) {
156
+ if (stdin === undefined || typeof stdin === "string")
157
+ return stdin;
158
+ try {
159
+ return new TextDecoder("utf-8", { fatal: true }).decode(stdin);
160
+ }
161
+ catch {
162
+ throw new errors_1.InvalidConfigError("exec stdin must be UTF-8 text.");
163
+ }
164
+ }
146
165
  /** Drain an exec stream into an exec result, for an `exec` that must be
147
166
  * abortable (only the streaming path can kill a running command). */
148
167
  async function collectExec(events, signal) {
@@ -163,6 +182,13 @@ async function collectExec(events, signal) {
163
182
  }
164
183
  throw new errors_1.SmolError("SMOLVM_ERROR", "command stream ended without an exit status");
165
184
  }
185
+ /** The cloud stop and delete take no drain or stop timeout yet; say so
186
+ * rather than ignore them. */
187
+ function refuseCloudStopOptions(opts) {
188
+ if (opts?.drainSecs !== undefined || opts?.timeoutSecs !== undefined) {
189
+ throw new errors_1.NotSupportedError("drainSecs and timeoutSecs are local-only.");
190
+ }
191
+ }
166
192
  /** The control plane echoes the user it ran a command as; an older one, which
167
193
  * predates the field, echoes nothing because it silently dropped it. */
168
194
  function assertCloudUserEcho(response, requested) {
@@ -510,6 +536,38 @@ class LocalTransport {
510
536
  throw (0, errors_1.wrapNativeError)(e);
511
537
  }
512
538
  }
539
+ async ports() {
540
+ try {
541
+ return this.inner.publishedPorts().map((p) => ({
542
+ host: p.host,
543
+ guest: p.guest,
544
+ protocol: p.protocol === "udp" ? "udp" : "tcp",
545
+ }));
546
+ }
547
+ catch (e) {
548
+ throw (0, errors_1.wrapNativeError)(e);
549
+ }
550
+ }
551
+ async setPorts(ports) {
552
+ try {
553
+ await this.inner.setPorts(ports.map((p) => {
554
+ if (p.host === undefined)
555
+ throw new errors_1.InvalidConfigError(`port ${p.guest} has no host port`);
556
+ return { host: p.host, guest: p.guest, protocol: p.protocol };
557
+ }));
558
+ }
559
+ catch (e) {
560
+ throw (0, errors_1.wrapNativeError)(e);
561
+ }
562
+ }
563
+ async pack(output) {
564
+ try {
565
+ return await this.inner.pack((0, node_path_1.resolve)(output));
566
+ }
567
+ catch (e) {
568
+ throw (0, errors_1.wrapNativeError)(e);
569
+ }
570
+ }
513
571
  async setNetworkPolicy(policy) {
514
572
  try {
515
573
  this.inner.setEgressPolicy(egressPolicy(policy));
@@ -518,10 +576,10 @@ class LocalTransport {
518
576
  throw (0, errors_1.wrapNativeError)(e);
519
577
  }
520
578
  }
521
- async stop() {
579
+ async stop(opts) {
522
580
  liveLocal.delete(this);
523
581
  try {
524
- await this.inner.stop();
582
+ await this.inner.stop(opts && { drainSecs: opts.drainSecs, timeoutSecs: opts.timeoutSecs });
525
583
  }
526
584
  catch (e) {
527
585
  throw (0, errors_1.wrapNativeError)(e);
@@ -566,10 +624,10 @@ class LocalTransport {
566
624
  if (waitUntilReady)
567
625
  await this.waitUntilReady();
568
626
  }
569
- async delete() {
627
+ async delete(opts) {
570
628
  liveLocal.delete(this);
571
629
  try {
572
- await this.inner.delete();
630
+ await this.inner.delete(opts && { drainSecs: opts.drainSecs });
573
631
  }
574
632
  catch (e) {
575
633
  throw (0, errors_1.wrapNativeError)(e);
@@ -587,6 +645,12 @@ class LocalTransport {
587
645
  async unshare() {
588
646
  throw new errors_1.NotSupportedError("unshare() is cloud-only; a local machine has no share link to revoke.");
589
647
  }
648
+ async portLinks() {
649
+ throw new errors_1.NotSupportedError("portLinks() is cloud-only; a local machine's ports are on this host (see endpoint()).");
650
+ }
651
+ async revokePortLinks() {
652
+ throw new errors_1.NotSupportedError("revokePortLinks() is cloud-only; a local machine has no port links.");
653
+ }
590
654
  async publishCacheDisk() {
591
655
  throw new errors_1.NotSupportedError("publishCacheDisk() is cloud-only; a local machine has no cache disk.");
592
656
  }
@@ -1102,6 +1166,7 @@ class CloudTransport {
1102
1166
  timeoutSeconds: opts?.timeout ?? null,
1103
1167
  ...(opts?.user !== undefined ? { user: opts.user } : {}),
1104
1168
  ...(opts?.background ? { background: true } : {}),
1169
+ ...(opts?.stdin !== undefined ? { stdin: stdinText(opts.stdin) ?? null } : {}),
1105
1170
  };
1106
1171
  // The command may legitimately run far longer than the default cloud
1107
1172
  // timeout, so size the request abort timeout off the request's own timeout
@@ -1327,10 +1392,29 @@ class CloudTransport {
1327
1392
  async sync() {
1328
1393
  throw new errors_1.NotSupportedError("sync() is local-only because cloud machines cannot carry host-directory mounts.");
1329
1394
  }
1395
+ async ports() {
1396
+ const m = await cloudFetch(this.conn, "GET", `/v1/machines/${this.id}`);
1397
+ return cloudPorts(m);
1398
+ }
1399
+ async setPorts(ports) {
1400
+ // The cloud publishes TCP guest ports and picks each one's host port on the
1401
+ // machine's node; clients reach them through connect, tunnel and the proxy.
1402
+ if (ports.some((p) => p.protocol === "udp")) {
1403
+ throw new errors_1.NotSupportedError("UDP ports are local-only; the cloud publishes TCP ports.");
1404
+ }
1405
+ const guests = [...new Set(ports.map((p) => p.guest))];
1406
+ await cloudFetch(this.conn, "PUT", `/v1/machines/${this.id}/ports`, {
1407
+ json: { ports: guests.map((port) => ({ port })) },
1408
+ });
1409
+ }
1410
+ async pack() {
1411
+ throw new errors_1.NotSupportedError("pack() is local-only; use checkpoint() on the cloud.");
1412
+ }
1330
1413
  async setNetworkPolicy() {
1331
1414
  throw new errors_1.NotSupportedError("setNetworkPolicy() is local-only.");
1332
1415
  }
1333
- async stop() {
1416
+ async stop(opts) {
1417
+ refuseCloudStopOptions(opts);
1334
1418
  await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/stop`, { idempotent: true });
1335
1419
  }
1336
1420
  async pause() {
@@ -1408,7 +1492,8 @@ class CloudTransport {
1408
1492
  // Cancelling twice is harmless, so a 503 may be resent.
1409
1493
  return cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/jobs/${encodeURIComponent(jobId)}/cancel`, { idempotent: true });
1410
1494
  }
1411
- async delete() {
1495
+ async delete(opts) {
1496
+ refuseCloudStopOptions(opts);
1412
1497
  await cloudFetch(this.conn, "DELETE", `/v1/machines/${this.id}`);
1413
1498
  }
1414
1499
  async deleteWithUsage() {
@@ -1429,6 +1514,13 @@ class CloudTransport {
1429
1514
  async unshare() {
1430
1515
  await cloudFetch(this.conn, "DELETE", `/v1/machines/${this.id}/share`);
1431
1516
  }
1517
+ async portLinks() {
1518
+ const out = await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/port-links`);
1519
+ return Object.fromEntries(Object.entries(out.urls ?? {}).map(([port, url]) => [Number(port), url]));
1520
+ }
1521
+ async revokePortLinks() {
1522
+ await cloudFetch(this.conn, "DELETE", `/v1/machines/${this.id}/port-links`);
1523
+ }
1432
1524
  async publishCacheDisk() {
1433
1525
  // Flattening and uploading a large cache can take minutes.
1434
1526
  return cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/cache-disk/publish`, { timeoutMs: 30 * 60 * 1000 });
@@ -1800,6 +1892,21 @@ async function makeTransport(config, conn) {
1800
1892
  throw new errors_1.InvalidConfigError("egressInterceptor cannot be combined with branchable machines.");
1801
1893
  }
1802
1894
  let transport;
1895
+ // A `.smolmachine` image is a packed machine: create from it (as
1896
+ // `smolvm machine create --from`), then start it.
1897
+ if (config.image?.endsWith(".smolmachine")) {
1898
+ const owned = !config.detach && (conn.handleSignals ?? true);
1899
+ try {
1900
+ const inner = await (0, native_1.getNapiMachine)().createFromPack(name, (0, node_path_1.resolve)(config.image));
1901
+ transport = new LocalTransport(inner, owned, interceptor, owned);
1902
+ await transport.start(interceptor, config.waitForPorts !== false);
1903
+ return transport;
1904
+ }
1905
+ catch (e) {
1906
+ await transport?.delete().catch(() => { });
1907
+ throw (0, errors_1.wrapNativeError)(e);
1908
+ }
1909
+ }
1803
1910
  try {
1804
1911
  // Validate the config before loading the engine, so a config the local
1805
1912
  // target cannot honour fails the same way whether or not it is installed.
@@ -1862,6 +1969,16 @@ async function listMachines(conn, options = {}) {
1862
1969
  detached: r.detached,
1863
1970
  branchable: r.branchable,
1864
1971
  createdAt: new Date(r.createdAt * 1000).toISOString(),
1972
+ resources: {
1973
+ cpus: r.resources.cpus,
1974
+ memoryMib: r.resources.memoryMib,
1975
+ storageGib: r.resources.storageGib,
1976
+ },
1977
+ ports: r.ports.map((p) => ({
1978
+ host: p.host,
1979
+ guest: p.guest,
1980
+ protocol: p.protocol === "udp" ? "udp" : "tcp",
1981
+ })),
1865
1982
  }));
1866
1983
  }
1867
1984
  else {
@@ -1903,6 +2020,21 @@ async function listMachines(conn, options = {}) {
1903
2020
  detached: true,
1904
2021
  branchable: m.branchable ?? m.forkable ?? false,
1905
2022
  createdAt: m.createdAt,
2023
+ ...(m.resources?.cpus != null &&
2024
+ m.resources.memoryMb != null && {
2025
+ resources: {
2026
+ cpus: m.resources.cpus,
2027
+ memoryMib: m.resources.memoryMb,
2028
+ storageGib: m.resources.diskGb ?? 20,
2029
+ },
2030
+ }),
2031
+ ...(m.ports && {
2032
+ ports: m.ports.map((p) => ({
2033
+ ...(p.hostPort != null && { host: p.hostPort }),
2034
+ guest: p.port,
2035
+ protocol: p.protocol === "udp" ? "udp" : "tcp",
2036
+ })),
2037
+ }),
1906
2038
  }));
1907
2039
  }
1908
2040
  const want = Object.entries(options.labels ?? {});
@@ -1910,6 +2042,44 @@ async function listMachines(conn, options = {}) {
1910
2042
  return all;
1911
2043
  return all.filter((m) => want.every(([k, v]) => m.labels[k] === v));
1912
2044
  }
2045
+ /**
2046
+ * A handle to an existing machine that does not start it or wait for it —
2047
+ * to read its state, stop, resize or delete it. Locally it fails with
2048
+ * `NOT_FOUND` when no machine has that name; on the cloud it looks the
2049
+ * machine up by id or name.
2050
+ */
2051
+ async function openTransport(id, conn) {
2052
+ if (!selectsCloud(conn)) {
2053
+ try {
2054
+ return new LocalTransport((0, native_1.getNapiMachine)().open(id), false, undefined, conn.handleSignals ?? true);
2055
+ }
2056
+ catch (e) {
2057
+ throw (0, errors_1.wrapNativeError)(e);
2058
+ }
2059
+ }
2060
+ // The cloud connect looks the machine up without starting it, and waits
2061
+ // for nothing unless asked to.
2062
+ const { waitForPorts: _ignored, ...rest } = conn;
2063
+ return connectTransport(id, rest);
2064
+ }
2065
+ /**
2066
+ * Delete a machine by name or id without a handle, whether or not it runs,
2067
+ * and without booting it. Deleting a machine that does not exist succeeds.
2068
+ */
2069
+ async function deleteMachine(id, conn, opts) {
2070
+ if (!selectsCloud(conn)) {
2071
+ try {
2072
+ await (0, native_1.getNapiMachine)().deleteByName(id, opts && { drainSecs: opts.drainSecs });
2073
+ }
2074
+ catch (e) {
2075
+ throw (0, errors_1.wrapNativeError)(e);
2076
+ }
2077
+ return;
2078
+ }
2079
+ refuseCloudStopOptions(opts);
2080
+ const transport = await openTransport(id, conn);
2081
+ await transport.delete();
2082
+ }
1913
2083
  /**
1914
2084
  * Attach to an EXISTING machine without creating a new one — for driving a
1915
2085
  * machine made elsewhere (another process, the console, the API).
@@ -1930,9 +2100,11 @@ async function connectTransport(id, conn) {
1930
2100
  try {
1931
2101
  // Connecting borrows an existing machine. A signal in this client must
1932
2102
  // not stop a durable checkpoint owned by another process/controller.
2103
+ // The native connect boots off the JavaScript thread, so a slow boot
2104
+ // does not hold the event loop.
1933
2105
  const transport = new LocalTransport(conn.egressInterceptor
1934
- ? (0, native_1.getNapiMachine)().connect(id, conn.egressInterceptor.address, conn.egressInterceptor.token)
1935
- : (0, native_1.getNapiMachine)().connect(id), false, conn.egressInterceptor, conn.handleSignals ?? true);
2106
+ ? await (0, native_1.getNapiMachine)().connect(id, conn.egressInterceptor.address, conn.egressInterceptor.token)
2107
+ : await (0, native_1.getNapiMachine)().connect(id), false, conn.egressInterceptor, conn.handleSignals ?? true);
1936
2108
  // Frozen and durably paused machines are intentionally not agent-ready.
1937
2109
  // A paused machine must be resumed explicitly before it can serve work.
1938
2110
  const state = await transport.state();
package/dist/types.d.ts CHANGED
@@ -297,6 +297,37 @@ export interface MachineSummary {
297
297
  branchable: boolean;
298
298
  /** When the machine was created (RFC3339). */
299
299
  createdAt: string;
300
+ /** CPU, memory and disk sizes, whether or not the machine runs. Missing
301
+ * only from a control plane that does not report them. */
302
+ resources?: MachineSizes;
303
+ /** Published ports, whether or not the machine runs. `host` is the host
304
+ * port forwarding to `guest`; on the cloud it is the node port, set once
305
+ * the machine has been placed. */
306
+ ports?: PublishedPort[];
307
+ }
308
+ /** CPU, memory and disk sizes of a machine, as `Machine.list()` reports them. */
309
+ export interface MachineSizes {
310
+ cpus: number;
311
+ memoryMib: number;
312
+ storageGib: number;
313
+ }
314
+ /** A published port, as `Machine.list()` reports it. */
315
+ export interface PublishedPort {
316
+ host?: number;
317
+ guest: number;
318
+ protocol: "tcp" | "udp";
319
+ }
320
+ /** How `stop()` and `delete()` treat work still running on the machine. */
321
+ export interface StopOptions {
322
+ /** Wait up to this many seconds for running commands and file transfers to
323
+ * finish before stopping. Whatever still runs then rejects with
324
+ * `SmolError` code `INTERRUPTED`. Default: stop at once. (local) */
325
+ drainSecs?: number;
326
+ /** Seconds the workload (the image ENTRYPOINT/CMD) gets to exit after its
327
+ * stop signal before it is killed, instead of the machine's own stop
328
+ * timeout; `0` kills it at once. `stop()` only; `delete()` never waits.
329
+ * (local) */
330
+ timeoutSecs?: number;
300
331
  }
301
332
  /** Filters for `Machine.list()`. */
302
333
  export interface ListOptions {
@@ -346,6 +377,10 @@ export interface ExecOptions {
346
377
  * `NotSupportedError` — before running anything — if it predates them. It is
347
378
  * never silently ignored. */
348
379
  user?: string;
380
+ /** Text written to the command's stdin, which is then closed. Passed over
381
+ * the agent connection and never written to the machine's disk, so it can
382
+ * carry a script or a secret. A `Uint8Array` must be UTF-8. */
383
+ stdin?: string | Uint8Array;
349
384
  /** Abort the command. Locally the command is killed in the machine and the
350
385
  * call (or the `execStream` iteration) rejects with `signal.reason`. On the
351
386
  * cloud target the request is cancelled and the call rejects the same way;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smolmachines",
3
- "version": "1.27.3",
3
+ "version": "1.29.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",
@@ -54,7 +54,8 @@
54
54
  "test:paused-connect": "tsx test/local-paused-connect.ts",
55
55
  "gen:openapi": "bash scripts/gen-openapi.sh",
56
56
  "test:cloud": "tsx test/cloud-mock.ts && tsx test/cloud-deferred-ports.ts",
57
- "test:unit": "tsx test/unit.ts"
57
+ "test:unit": "tsx test/unit.ts",
58
+ "test:host-daemon": "tsx test/host-daemon-e2e.ts"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@napi-rs/cli": "^2.18.4",
@@ -64,8 +65,8 @@
64
65
  "typescript": "^5.6.0"
65
66
  },
66
67
  "optionalDependencies": {
67
- "smolmachines-darwin-arm64": "1.27.3",
68
- "smolmachines-linux-x64-gnu": "1.27.3",
69
- "smolmachines-linux-arm64-gnu": "1.27.3"
68
+ "smolmachines-darwin-arm64": "1.29.0",
69
+ "smolmachines-linux-x64-gnu": "1.29.0",
70
+ "smolmachines-linux-arm64-gnu": "1.29.0"
70
71
  }
71
72
  }