@agent-relay/sandbox 0.1.2 → 0.1.3

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.
Files changed (121) hide show
  1. package/README.md +226 -5
  2. package/dist/agent37/client.d.ts +115 -0
  3. package/dist/agent37/client.d.ts.map +1 -0
  4. package/dist/agent37/client.js +270 -0
  5. package/dist/agent37/client.js.map +1 -0
  6. package/dist/agent37/runtime.d.ts +415 -0
  7. package/dist/agent37/runtime.d.ts.map +1 -0
  8. package/dist/agent37/runtime.js +855 -0
  9. package/dist/agent37/runtime.js.map +1 -0
  10. package/dist/agentcore/bench.d.ts +93 -0
  11. package/dist/agentcore/bench.d.ts.map +1 -0
  12. package/dist/agentcore/bench.js +125 -0
  13. package/dist/agentcore/bench.js.map +1 -0
  14. package/dist/agentcore/capabilities.d.ts +169 -0
  15. package/dist/agentcore/capabilities.d.ts.map +1 -0
  16. package/dist/agentcore/capabilities.js +153 -0
  17. package/dist/agentcore/capabilities.js.map +1 -0
  18. package/dist/agentcore/config.d.ts +156 -0
  19. package/dist/agentcore/config.d.ts.map +1 -0
  20. package/dist/agentcore/config.js +12 -0
  21. package/dist/agentcore/config.js.map +1 -0
  22. package/dist/agentcore/internal/sdk.d.ts +181 -0
  23. package/dist/agentcore/internal/sdk.d.ts.map +1 -0
  24. package/dist/agentcore/internal/sdk.js +223 -0
  25. package/dist/agentcore/internal/sdk.js.map +1 -0
  26. package/dist/agentcore/runtime.d.ts +177 -0
  27. package/dist/agentcore/runtime.d.ts.map +1 -0
  28. package/dist/agentcore/runtime.js +706 -0
  29. package/dist/agentcore/runtime.js.map +1 -0
  30. package/dist/daytona/runtime.d.ts +23 -0
  31. package/dist/daytona/runtime.d.ts.map +1 -1
  32. package/dist/daytona/runtime.js +167 -5
  33. package/dist/daytona/runtime.js.map +1 -1
  34. package/dist/daytona/wire-supplement.d.ts +25 -0
  35. package/dist/daytona/wire-supplement.d.ts.map +1 -0
  36. package/dist/daytona/wire-supplement.js +16 -0
  37. package/dist/daytona/wire-supplement.js.map +1 -0
  38. package/dist/e2b/runtime.d.ts +107 -72
  39. package/dist/e2b/runtime.d.ts.map +1 -1
  40. package/dist/e2b/runtime.js +675 -190
  41. package/dist/e2b/runtime.js.map +1 -1
  42. package/dist/freestyle/capabilities.d.ts +28 -0
  43. package/dist/freestyle/capabilities.d.ts.map +1 -0
  44. package/dist/freestyle/capabilities.js +42 -0
  45. package/dist/freestyle/capabilities.js.map +1 -0
  46. package/dist/freestyle/config.d.ts +36 -0
  47. package/dist/freestyle/config.d.ts.map +1 -0
  48. package/dist/freestyle/config.js +2 -0
  49. package/dist/freestyle/config.js.map +1 -0
  50. package/dist/freestyle/internal/sdk.d.ts +69 -0
  51. package/dist/freestyle/internal/sdk.d.ts.map +1 -0
  52. package/dist/freestyle/internal/sdk.js +19 -0
  53. package/dist/freestyle/internal/sdk.js.map +1 -0
  54. package/dist/freestyle/runtime.d.ts +163 -0
  55. package/dist/freestyle/runtime.d.ts.map +1 -0
  56. package/dist/freestyle/runtime.js +798 -0
  57. package/dist/freestyle/runtime.js.map +1 -0
  58. package/dist/index.d.ts +35 -4
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +16 -2
  61. package/dist/index.js.map +1 -1
  62. package/dist/microsandbox/runtime.d.ts +793 -0
  63. package/dist/microsandbox/runtime.d.ts.map +1 -0
  64. package/dist/microsandbox/runtime.js +2303 -0
  65. package/dist/microsandbox/runtime.js.map +1 -0
  66. package/dist/modal/bench.d.ts +199 -0
  67. package/dist/modal/bench.d.ts.map +1 -0
  68. package/dist/modal/bench.js +450 -0
  69. package/dist/modal/bench.js.map +1 -0
  70. package/dist/modal/capabilities.d.ts +180 -0
  71. package/dist/modal/capabilities.d.ts.map +1 -0
  72. package/dist/modal/capabilities.js +159 -0
  73. package/dist/modal/capabilities.js.map +1 -0
  74. package/dist/modal/config.d.ts +192 -0
  75. package/dist/modal/config.d.ts.map +1 -0
  76. package/dist/modal/config.js +169 -0
  77. package/dist/modal/config.js.map +1 -0
  78. package/dist/modal/internal/sdk.d.ts +141 -0
  79. package/dist/modal/internal/sdk.d.ts.map +1 -0
  80. package/dist/modal/internal/sdk.js +51 -0
  81. package/dist/modal/internal/sdk.js.map +1 -0
  82. package/dist/modal/runtime.d.ts +271 -0
  83. package/dist/modal/runtime.d.ts.map +1 -0
  84. package/dist/modal/runtime.js +871 -0
  85. package/dist/modal/runtime.js.map +1 -0
  86. package/dist/mount-script.d.ts +16 -1
  87. package/dist/mount-script.d.ts.map +1 -1
  88. package/dist/mount-script.js +76 -15
  89. package/dist/mount-script.js.map +1 -1
  90. package/dist/orchestrator.d.ts +15 -0
  91. package/dist/orchestrator.d.ts.map +1 -1
  92. package/dist/orchestrator.js +13 -1
  93. package/dist/orchestrator.js.map +1 -1
  94. package/dist/port.d.ts +144 -6
  95. package/dist/port.d.ts.map +1 -1
  96. package/dist/port.js +27 -5
  97. package/dist/port.js.map +1 -1
  98. package/dist/types.d.ts +50 -1
  99. package/dist/types.d.ts.map +1 -1
  100. package/dist/vercel/bench.d.ts +169 -0
  101. package/dist/vercel/bench.d.ts.map +1 -0
  102. package/dist/vercel/bench.js +337 -0
  103. package/dist/vercel/bench.js.map +1 -0
  104. package/dist/vercel/capabilities.d.ts +106 -0
  105. package/dist/vercel/capabilities.d.ts.map +1 -0
  106. package/dist/vercel/capabilities.js +128 -0
  107. package/dist/vercel/capabilities.js.map +1 -0
  108. package/dist/vercel/config.d.ts +119 -0
  109. package/dist/vercel/config.d.ts.map +1 -0
  110. package/dist/vercel/config.js +8 -0
  111. package/dist/vercel/config.js.map +1 -0
  112. package/dist/vercel/internal/sdk.d.ts +173 -0
  113. package/dist/vercel/internal/sdk.d.ts.map +1 -0
  114. package/dist/vercel/internal/sdk.js +117 -0
  115. package/dist/vercel/internal/sdk.js.map +1 -0
  116. package/dist/vercel/runtime.d.ts +305 -0
  117. package/dist/vercel/runtime.d.ts.map +1 -0
  118. package/dist/vercel/runtime.js +1169 -0
  119. package/dist/vercel/runtime.js.map +1 -0
  120. package/docs/freestyle.md +110 -0
  121. package/package.json +35 -4
@@ -0,0 +1,2303 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ // ---------------------------------------------------------------------------
4
+ // Microsandbox provider for the SandboxRuntime + WorkflowRuntime contracts.
5
+ //
6
+ // The `microsandbox` npm package is imported LAZILY (`await import(...)`),
7
+ // never at module top level, so consumers on another provider neither bundle it
8
+ // nor need its platform-specific native addon installed. The SDK surface is
9
+ // modeled structurally below (read from microsandbox@0.6.11 `dist/*.d.ts`) so
10
+ // typecheck and unit tests do not hard-couple to the SDK type graph, and a fake
11
+ // SDK can drive every path without a live backend or an API key.
12
+ //
13
+ // Five provider facts shape this adapter and are worth stating up front,
14
+ // because each one breaks an assumption the other adapters in this package are
15
+ // allowed to make:
16
+ //
17
+ // 1. IDENTITY IS A NAME, NOT A SERVER-ASSIGNED ID. `Sandbox.builder(name)`,
18
+ // `Sandbox.get(name)` and `SandboxHandle.remove()` all address a sandbox by
19
+ // a caller-chosen name capped at 128 UTF-8 bytes. `RuntimeHandle.id`
20
+ // therefore carries that name, and an over-long name is rejected rather
21
+ // than truncated — truncating would silently alias two distinct sandboxes
22
+ // onto one identity.
23
+ //
24
+ // 2. BACKEND SELECTION IS PROCESS-WIDE GLOBAL STATE. The SDK exposes
25
+ // `setDefaultBackend(backend)` (permanent) and `withDefaultBackend(backend,
26
+ // fn)` (scoped, restored in a `finally`). The SDK documents that the scoped
27
+ // form is NOT task-local: concurrent work in the same process observes the
28
+ // temporary backend while the callback runs, so two overlapping calls on
29
+ // different backends would silently send one of them to the wrong place.
30
+ // This adapter therefore never calls `setDefaultBackend`, and routes the
31
+ // calls that DO read that slot — the SDK's default-dependent statics —
32
+ // through one PROCESS-GLOBAL gate (see `withBackendScope`): calls that want
33
+ // the same backend share a single open scope and still run concurrently; a
34
+ // call that wants a different backend waits until the current scope has
35
+ // closed. A resolved `Sandbox` or `SandboxHandle` is bound to the backend it
36
+ // was resolved on, so its exec, filesystem and lifecycle calls read no
37
+ // global state and are issued off the gate. That is a real mutual-exclusion
38
+ // guarantee for this adapter's own default-dependent calls, and the honest
39
+ // limit of it is stated on the gate: SDK calls made elsewhere in the
40
+ // process, outside this adapter, are not covered by it and can still
41
+ // observe the scoped backend.
42
+ //
43
+ // 3. THERE IS NO CREATE-TIMEOUT SETTER ON THE BUILDER. `maxDuration` and
44
+ // `idleTimeout` are sandbox LIFETIME budgets. Mapping the caller's
45
+ // `createTimeoutSeconds` onto either would kill every long-lived sandbox
46
+ // the moment the boot deadline elapsed, so the create deadline is enforced
47
+ // client-side instead — and a create that lands AFTER that deadline is
48
+ // reclaimed rather than leaked (see `launch`).
49
+ //
50
+ // 4. THE SDK REQUIRES NODE 22+ AND A VIRTUALIZATION-CAPABLE HOST.
51
+ // `microsandbox@0.6.x` declares `engines.node >= 22` and ships a
52
+ // platform-specific native addon (macOS arm64, Linux x64/arm64, Windows
53
+ // x64/arm64). Its published requirements are Linux with KVM, macOS on
54
+ // Apple Silicon, or Windows 10+ with WHP — that hardware requirement is
55
+ // what the LOCAL backend boots microVMs on; the cloud backend boots them
56
+ // remotely but still loads the same native client addon. This package's own
57
+ // floor stays Node 20, because every other adapter here runs there and the
58
+ // SDK is an OPTIONAL peer dependency — so the constraint is surfaced where
59
+ // it actually bites: the lazy import wraps a load failure with it.
60
+ //
61
+ // 5. AN ASYNC RUN HAS NO SERVER-SIDE COMMAND RECORD. The SDK's streaming
62
+ // `ExecHandle` is process-local and carries no id a later process could
63
+ // poll, so async runs are tracked by durable files in the guest. Everything
64
+ // that makes that safe — one-shot admission, adoption of a run whose
65
+ // submit response was lost, refusal to overwrite another run's state, and
66
+ // detection of a run whose process died without recording an exit code —
67
+ // lives in the two POSIX shell scripts below, not in the caller.
68
+ // ---------------------------------------------------------------------------
69
+ /** Max sandbox name length the SDK accepts, in UTF-8 bytes. */
70
+ const MAX_SANDBOX_NAME_BYTES = 128;
71
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
72
+ const DEFAULT_LOOKUP_TIMEOUT_MS = 10_000;
73
+ const DEFAULT_LIST_PAGE_SIZE = 100;
74
+ /**
75
+ * Hard bound on pages drained for one lookup. Reaching it is a failure, never
76
+ * a result — see the throw at the end of `collectByLabels`.
77
+ */
78
+ const MAX_LIST_PAGES = 1_000;
79
+ /**
80
+ * How long a call may wait for the process-global backend gate before it fails
81
+ * instead of waiting forever. See {@link MicrosandboxBackendBusyError}.
82
+ */
83
+ const DEFAULT_BACKEND_QUEUE_TIMEOUT_MS = 30_000;
84
+ const SCRIPT_LOG_READ_MAX_BYTES = 200_000;
85
+ /**
86
+ * Longest encoded run-state path segment before it is replaced by a digest.
87
+ * Well under the 255-byte filename limit every mainstream guest filesystem
88
+ * imposes, with room for the `out`/`exit`/`pid` leaves underneath it.
89
+ */
90
+ const MAX_RUN_SEGMENT_BYTES = 120;
91
+ /**
92
+ * Where the async-run wrapper parks its `cmd`/`pid`/`boot`/`out`/`exit` files
93
+ * inside the guest.
94
+ *
95
+ * `/tmp` is a POSIX guarantee of the guest filesystem, not a fact about any
96
+ * particular deployment, so it is a safe default rather than baked-in
97
+ * infrastructure. Consumers whose image mounts `/tmp` read-only override it
98
+ * with `runStateDir`.
99
+ */
100
+ const DEFAULT_RUN_STATE_DIR = "/tmp/microsandbox-run";
101
+ /** POSIX shell used to interpret a `runScript`/`startScript` command string. */
102
+ const DEFAULT_SHELL = "/bin/sh";
103
+ /**
104
+ * Where the guest's process table lives. Passed to the run scripts as an
105
+ * argument rather than written into them, so the same scripts can be executed
106
+ * against a synthetic procfs in tests; the value the adapter sends is never
107
+ * anything else, and is never taken from caller-supplied environment.
108
+ */
109
+ const GUEST_PROC_ROOT = "/proc";
110
+ // --- guest-side async run protocol -----------------------------------------
111
+ //
112
+ // Both scripts below are pure POSIX `sh` and take every value — including the
113
+ // caller's command — as a positional ARGUMENT, never as interpolated text. So
114
+ // no quoting of caller data happens anywhere on the host, and the exact bytes
115
+ // of the command reach the guest unmodified.
116
+ //
117
+ // The run directory doubles as the admission record. `mkdir` of a single
118
+ // directory is atomic on every POSIX filesystem, so it either claims the
119
+ // session or proves someone else already did — which is what makes a resubmit
120
+ // of the same session id incapable of starting a second process or of
121
+ // overwriting the first one's state.
122
+ /**
123
+ * Read a pid's START TIME (field 22 of `/proc/<pid>/stat`, in clock ticks
124
+ * since boot) into stdout, or fail with a non-zero status when procfs cannot
125
+ * answer.
126
+ *
127
+ * WHY IT IS PART OF RUN IDENTITY. A pid alone does not identify a process: the
128
+ * guest can recycle it, and a recycled pid answers `kill -0` exactly like the
129
+ * original, so a run whose process died would keep reporting RUNNING forever
130
+ * as soon as something else landed on its number. Start time is the field that
131
+ * makes the pair unique for the lifetime of a boot — two processes on the same
132
+ * pid cannot share it.
133
+ *
134
+ * The parse is byte-careful for one specific reason: field 2 is the executable
135
+ * name in parentheses and MAY CONTAIN SPACES AND PARENTHESES, so splitting the
136
+ * line on whitespace from the left is wrong. Every field after it is numeric or
137
+ * a single flag character, so the LAST `") "` in the line is always the end of
138
+ * that field — which is what `##*") "` finds. Start time is then the 20th field
139
+ * of the remainder (22 overall, less the pid and the name).
140
+ *
141
+ * @internal Shared by both scripts below; not part of the public API.
142
+ */
143
+ const MSB_STARTTIME_FN = [
144
+ // `$1` procfs root, `$2` pid. The root is a PARAMETER rather than a literal
145
+ // so the identity check is executable against a synthetic procfs in tests —
146
+ // on every platform, not only on hosts that have a real one. It is passed by
147
+ // the adapter as a positional argument and is never read from the
148
+ // environment, which the caller controls: a run's liveness verdict must not
149
+ // be redirectable by whoever submitted it.
150
+ "msb_starttime() {",
151
+ ' msb_st=$(cat "$1/$2/stat" 2>/dev/null) || return 1',
152
+ ' msb_rest=${msb_st##*") "}',
153
+ ' if [ "$msb_rest" = "$msb_st" ]; then return 1; fi',
154
+ " set -- $msb_rest",
155
+ ' if [ "$#" -lt 20 ]; then return 1; fi',
156
+ ' printf %s "${20}"',
157
+ "}",
158
+ ].join("\n");
159
+ /**
160
+ * Admit one async run.
161
+ *
162
+ * Arguments: `$1` command, `$2` run directory, `$3` its parent, `$4` shell.
163
+ *
164
+ * Prints exactly one of:
165
+ * - `ADMITTED <pid>` — this call created the run.
166
+ * - `CLAIMED <pid>` — the session was already admitted for THIS EXACT
167
+ * command, so its existing run is adopted. This is the outcome-unknown
168
+ * case: a submit whose response was lost is retried by the caller and
169
+ * resolves here, without ever starting a second process.
170
+ * - `CONFLICT` — the session is already admitted for a DIFFERENT
171
+ * command (or its record is unreadable). Nothing is started and nothing is
172
+ * overwritten; the caller gets a typed error.
173
+ *
174
+ * @internal Exported only so the protocol tests can execute it under a real
175
+ * `/bin/sh`. Not part of the package's public API.
176
+ */
177
+ export const MICROSANDBOX_RUN_ADMIT_SCRIPT = [
178
+ "set -u",
179
+ "cmd=$1",
180
+ "dir=$2",
181
+ "parent=$3",
182
+ "shell_path=$4",
183
+ "proc_root=$5",
184
+ MSB_STARTTIME_FN,
185
+ 'mkdir -p "$parent" 2>/dev/null || true',
186
+ 'if mkdir "$dir" 2>/dev/null; then',
187
+ // Record the command BEFORE starting anything: a crash between the two
188
+ // leaves a claimed-but-dead session, which the status probe reports as lost,
189
+ // rather than an unattributable running process.
190
+ ' printf %s "$cmd" > "$dir/cmd"',
191
+ // Boot identity, used by the status probe to tell "still running" from "the
192
+ // sandbox restarted and this pid now belongs to someone else". Absent on a
193
+ // guest without procfs, in which case the probe falls back to pid liveness.
194
+ ' cat "$proc_root/sys/kernel/random/boot_id" > "$dir/boot" 2>/dev/null || true',
195
+ // The command runs in a CHILD shell, so an `exit 7` inside it cannot skip
196
+ // the exit-code record: the child exits, the wrapper writes its status.
197
+ ' nohup "$shell_path" -c \'"$3" -c "$1" > "$2/out" 2>&1; printf %s "$?" > "$2/exit"\' msb-run "$cmd" "$dir" "$shell_path" > /dev/null 2>&1 &',
198
+ " run_pid=$!",
199
+ ' printf %s "$run_pid" > "$dir/pid"',
200
+ // Pid + start time is the run's identity. Written only when procfs actually
201
+ // answered: an EMPTY `start` file would be indistinguishable from "recorded
202
+ // a start time of nothing", and the probe would then compare against it.
203
+ " start_ticks=$(msb_starttime \"$proc_root\" \"$run_pid\") || start_ticks=''",
204
+ ' if [ -n "$start_ticks" ]; then printf %s "$start_ticks" > "$dir/start"; fi',
205
+ ' printf "ADMITTED %s\\n" "$run_pid"',
206
+ " exit 0",
207
+ "fi",
208
+ // `$(cat f)` strips EVERY trailing newline, so the sentinel-and-strip form
209
+ // is what makes the comparison byte-exact: a command that ends in a newline
210
+ // must still be recognised as the same command on an outcome-unknown retry.
211
+ 'existing=$(cat "$dir/cmd" 2>/dev/null; printf X) || existing=X',
212
+ 'existing=${existing%X}',
213
+ 'run_pid=$(cat "$dir/pid" 2>/dev/null) || run_pid=""',
214
+ 'if [ "$existing" = "$cmd" ] && [ -n "$run_pid" ]; then',
215
+ ' printf "CLAIMED %s\\n" "$run_pid"',
216
+ " exit 0",
217
+ "fi",
218
+ 'printf "CONFLICT\\n"',
219
+ ].join("\n");
220
+ /**
221
+ * Report one async run's outcome.
222
+ *
223
+ * Argument: `$1` run directory. Prints exactly one of `EXIT <code>`,
224
+ * `RUNNING`, `MISSING`, `UNKNOWN <reason>`, or `LOST <reason>`.
225
+ *
226
+ * The exit file is checked first and again last: the wrapper writes it as its
227
+ * final act, so re-reading after the liveness probe closes the window where a
228
+ * run finishes mid-probe and would otherwise read as lost.
229
+ *
230
+ * LIVENESS IS THREE CHECKS, not one, because each answers a different way of
231
+ * losing a run:
232
+ * - boot id, for "the sandbox restarted underneath it";
233
+ * - `kill -0`, for "the process is gone";
234
+ * - START TIME, for "the pid is alive but it is somebody else's now". Without
235
+ * the third, a recycled pid reports RUNNING forever, which is the one
236
+ * failure a poll loop cannot end on.
237
+ *
238
+ * FALLBACK, stated because it is a real reduction in what the probe can tell
239
+ * apart: when the guest has no procfs, admission records no start time and the
240
+ * probe degrades to boot id + pid liveness — exactly the behaviour before start
241
+ * time existed. The fallback is chosen by the ABSENCE of a recorded start time,
242
+ * never by a failure to read the current one: if a start time was recorded and
243
+ * the current read fails while the pid is alive, the probe emits an explicit
244
+ * UNKNOWN marker. That is neither proof of continued life nor proof of pid
245
+ * reuse, so the adapter turns it into a retryable status-probe error.
246
+ *
247
+ * @internal Exported only for the protocol tests (see above).
248
+ */
249
+ export const MICROSANDBOX_RUN_STATUS_SCRIPT = [
250
+ "set -u",
251
+ "dir=$1",
252
+ "proc_root=$2",
253
+ MSB_STARTTIME_FN,
254
+ 'if [ -f "$dir/exit" ]; then',
255
+ ' printf "EXIT %s\\n" "$(cat "$dir/exit" 2>/dev/null)"',
256
+ " exit 0",
257
+ "fi",
258
+ 'if [ ! -d "$dir" ]; then',
259
+ ' printf "MISSING\\n"',
260
+ " exit 0",
261
+ "fi",
262
+ 'boot=$(cat "$dir/boot" 2>/dev/null) || boot=""',
263
+ 'now=$(cat "$proc_root/sys/kernel/random/boot_id" 2>/dev/null) || now=""',
264
+ 'if [ -n "$boot" ] && [ -n "$now" ] && [ "$boot" != "$now" ]; then',
265
+ ' printf "LOST sandbox-restarted\\n"',
266
+ " exit 0",
267
+ "fi",
268
+ 'run_pid=$(cat "$dir/pid" 2>/dev/null) || run_pid=""',
269
+ 'if [ -z "$run_pid" ]; then',
270
+ // Admitted, pid not recorded yet: the admission call is still in flight.
271
+ ' printf "RUNNING\\n"',
272
+ " exit 0",
273
+ "fi",
274
+ 'if kill -0 "$run_pid" 2>/dev/null; then',
275
+ // The pid is alive. Is it still OUR process? Only a recorded start time can
276
+ // answer; without one the probe says RUNNING, as it did before.
277
+ ' recorded=$(cat "$dir/start" 2>/dev/null) || recorded=""',
278
+ ' if [ -n "$recorded" ]; then',
279
+ ' if ! current=$(msb_starttime "$proc_root" "$run_pid"); then',
280
+ ' printf "UNKNOWN starttime-unreadable\\n"',
281
+ " exit 0",
282
+ " fi",
283
+ ' if [ "$current" != "$recorded" ]; then',
284
+ ' printf "LOST pid-reused\\n"',
285
+ " exit 0",
286
+ " fi",
287
+ " fi",
288
+ ' printf "RUNNING\\n"',
289
+ " exit 0",
290
+ "fi",
291
+ 'if [ -f "$dir/exit" ]; then',
292
+ ' printf "EXIT %s\\n" "$(cat "$dir/exit" 2>/dev/null)"',
293
+ " exit 0",
294
+ "fi",
295
+ 'printf "LOST process-gone\\n"',
296
+ ].join("\n");
297
+ /**
298
+ * Read one run's captured output.
299
+ *
300
+ * Arguments: `$1` log path, `$2` byte cap. Exits 0 with the last `$2` bytes of
301
+ * the file, or 0 with NO output when the file is genuinely absent; any other
302
+ * failure exits non-zero.
303
+ *
304
+ * THE EXIT CODE IS THE WHOLE POINT. The previous form of this read was
305
+ * `tail -c N path 2>/dev/null || true`, which flattened three different
306
+ * situations — "the run has not written anything yet", "the log is
307
+ * unreadable", and "the guest call failed" — onto the same empty string. Empty
308
+ * output is a legitimate answer for a run that printed nothing, so a caller
309
+ * cannot tell that reading from an unreadable one. Absence is now the ONLY
310
+ * condition that yields empty-and-successful; everything else fails loudly and
311
+ * the adapter raises {@link MicrosandboxLogReadError}.
312
+ *
313
+ * The cap is read as one byte MORE than the caller's limit, so the adapter can
314
+ * see that a longer file exists and report `truncated` rather than handing back
315
+ * a tail that reads like a complete log.
316
+ *
317
+ * @internal Exported only for the protocol tests (see above).
318
+ */
319
+ export const MICROSANDBOX_RUN_LOG_SCRIPT = [
320
+ "set -u",
321
+ "path=$1",
322
+ "cap=$2",
323
+ // Absent is not an error: a run that has not yet written its first byte, and
324
+ // a run that printed nothing at all, both legitimately have no log.
325
+ // `-f` alone cannot make that distinction: it is false for BOTH a missing
326
+ // path and an existing directory/device/socket. A symlink is rejected too;
327
+ // the run protocol writes a regular file at this exact path.
328
+ 'if [ -L "$path" ]; then exit 1; fi',
329
+ 'if [ -f "$path" ]; then exec tail -c "$cap" "$path"; fi',
330
+ 'if [ -e "$path" ]; then exit 1; fi',
331
+ "exit 0",
332
+ ].join("\n");
333
+ /**
334
+ * A sandbox name exceeded the SDK's 128 UTF-8 byte cap.
335
+ *
336
+ * Raised instead of truncating: the name IS the identity, so a truncated name
337
+ * would collide two distinct sandboxes onto one addressable handle.
338
+ */
339
+ export class MicrosandboxNameTooLongError extends Error {
340
+ name = "MicrosandboxNameTooLongError";
341
+ sandboxName;
342
+ byteLength;
343
+ maxByteLength = MAX_SANDBOX_NAME_BYTES;
344
+ constructor(sandboxName, byteLength) {
345
+ super(`Microsandbox sandbox name is ${byteLength} UTF-8 bytes, exceeding the ${MAX_SANDBOX_NAME_BYTES}-byte limit`);
346
+ this.sandboxName = sandboxName;
347
+ this.byteLength = byteLength;
348
+ }
349
+ }
350
+ /**
351
+ * The create deadline supplied by the caller elapsed before the sandbox
352
+ * finished booting.
353
+ *
354
+ * The create itself is NOT abandoned: the SDK exposes no cancellation for it,
355
+ * so the adapter keeps watching that promise and, if the sandbox does finish
356
+ * booting afterwards, reclaims it (kill + remove) rather than leaving a
357
+ * running microVM nobody is waiting for and a name nobody can reuse.
358
+ */
359
+ export class MicrosandboxCreateTimeoutError extends Error {
360
+ name = "MicrosandboxCreateTimeoutError";
361
+ sandboxName;
362
+ timeoutMs;
363
+ constructor(sandboxName, timeoutMs) {
364
+ super(`Microsandbox sandbox "${sandboxName}" did not finish creating within ${timeoutMs}ms; a late create is reclaimed`);
365
+ this.sandboxName = sandboxName;
366
+ this.timeoutMs = timeoutMs;
367
+ }
368
+ }
369
+ /**
370
+ * A label lookup did not finish within its deadline.
371
+ *
372
+ * Raised rather than returning a partial page: a warm-lease decision made from
373
+ * a truncated listing is a decision made from data the caller cannot tell apart
374
+ * from "there is nothing else".
375
+ */
376
+ export class MicrosandboxLookupTimeoutError extends Error {
377
+ name = "MicrosandboxLookupTimeoutError";
378
+ timeoutMs;
379
+ constructor(timeoutMs, description) {
380
+ super(`Microsandbox sandbox lookup exceeded ${timeoutMs}ms while ${description}`);
381
+ this.timeoutMs = timeoutMs;
382
+ }
383
+ }
384
+ /**
385
+ * A label listing could not be drained to a trustworthy end.
386
+ *
387
+ * Raised rather than returning what was collected so far. A short list and a
388
+ * complete one are indistinguishable to the caller, so a drain that gave up on
389
+ * a cursor that never advances, or on a page body it cannot read, would answer
390
+ * "there is nothing else" — and a warm-lease decision or a quota count made
391
+ * from that answer is made from data the provider never actually supplied.
392
+ */
393
+ export class MicrosandboxPaginationError extends Error {
394
+ name = "MicrosandboxPaginationError";
395
+ pages;
396
+ constructor(pages, detail) {
397
+ super(`Microsandbox sandbox listing could not be drained after ${pages} page(s): ${detail}`);
398
+ this.pages = pages;
399
+ }
400
+ }
401
+ /**
402
+ * A call gave up waiting for the process-global backend gate.
403
+ *
404
+ * The gate exists because `withDefaultBackend` mutates ONE process-wide slot,
405
+ * so a call on backend A cannot run while backend B holds the scope. Normally
406
+ * the wait is short. It is NOT short when the scope holder has been abandoned:
407
+ * a create or a lookup that outlived its own client-side deadline returned a
408
+ * typed error to ITS caller, but the SDK exposes no cancellation, so the
409
+ * request is still in flight and the process-wide backend still has to be its
410
+ * own until it settles.
411
+ *
412
+ * Waiting forever in that situation deadlocks every other backend in the
413
+ * process. Running anyway would send this call to whatever backend the process
414
+ * default happens to hold, which is the one outcome the gate exists to prevent.
415
+ * So the queued call FAILS, loudly and with a typed error the caller can retry
416
+ * on — the honest third option.
417
+ */
418
+ export class MicrosandboxBackendBusyError extends Error {
419
+ name = "MicrosandboxBackendBusyError";
420
+ waitedMs;
421
+ constructor(waitedMs) {
422
+ super(`Microsandbox backend gate was held by another backend for ${waitedMs}ms; this call was not sent `
423
+ + "rather than being sent to the wrong backend. A scope held this long usually means an SDK call "
424
+ + "outlived its client-side deadline and is still in flight.");
425
+ this.waitedMs = waitedMs;
426
+ }
427
+ }
428
+ /**
429
+ * `startScript` was called for a session id that is already admitted for a
430
+ * DIFFERENT command.
431
+ *
432
+ * Nothing was started and nothing was overwritten. A session id is the identity
433
+ * of one run's durable state; reusing it for another command would either
434
+ * strand the first run or report its exit code as the second one's.
435
+ */
436
+ export class MicrosandboxSessionConflictError extends Error {
437
+ name = "MicrosandboxSessionConflictError";
438
+ sessionId;
439
+ constructor(sessionId) {
440
+ super(`Microsandbox run session "${sessionId}" is already admitted for a different command; nothing was submitted`);
441
+ this.sessionId = sessionId;
442
+ }
443
+ }
444
+ /**
445
+ * An admitted run can no longer produce an exit code.
446
+ *
447
+ * The wrapper writes the exit file as its final act, so a run whose process is
448
+ * gone without one — killed, out of memory, or interrupted by a sandbox
449
+ * restart — will never complete. Raised so a poll loop ends with a terminal
450
+ * outcome instead of asking forever.
451
+ */
452
+ export class MicrosandboxRunLostError extends Error {
453
+ name = "MicrosandboxRunLostError";
454
+ sessionId;
455
+ commandId;
456
+ reason;
457
+ constructor(sessionId, commandId, reason) {
458
+ super(`Microsandbox run "${sessionId}" is no longer completable: ${reason}`);
459
+ this.sessionId = sessionId;
460
+ this.commandId = commandId;
461
+ this.reason = reason;
462
+ }
463
+ }
464
+ /**
465
+ * `getExecLogs` was asked for a terminal result while the run is still going.
466
+ *
467
+ * The bootstrap-plane `ExecResult` has no "unfinished" value — its `exitCode`
468
+ * is a number — so returning one here would have to invent a code. Callers poll
469
+ * `getExecStatus` and read logs once it reports terminal.
470
+ */
471
+ export class MicrosandboxRunNotFinishedError extends Error {
472
+ name = "MicrosandboxRunNotFinishedError";
473
+ sessionId;
474
+ commandId;
475
+ constructor(sessionId, commandId) {
476
+ super(`Microsandbox run "${sessionId}" has not finished; its exit code is not known yet`);
477
+ this.sessionId = sessionId;
478
+ this.commandId = commandId;
479
+ }
480
+ }
481
+ /**
482
+ * The process-wide default backend could not be restored, so no later
483
+ * default-dependent SDK static may be issued from this process.
484
+ *
485
+ * `withDefaultBackend` sets one process-global slot and restores it on the way
486
+ * out. When the RESTORE is what failed, the slot holds an unknown value: not
487
+ * necessarily this runtime's backend, not necessarily the previous one. Every
488
+ * static the adapter calls reads that slot, so the only two honest options are
489
+ * to guess or to stop. This adapter stops — permanently, for the life of the
490
+ * process, because nothing it is willing to do can re-establish the truth.
491
+ * (Calling `setDefaultBackend` to force a known value would mutate the host
492
+ * process on behalf of a library, which this adapter never does.)
493
+ *
494
+ * Sandbox and handle instances resolved BEFORE the failure are unaffected and
495
+ * still usable: the SDK binds each one to the backend it was resolved on (its
496
+ * typings call this "backend retained by this sandbox"), so their exec,
497
+ * filesystem and lifecycle calls read no global state.
498
+ */
499
+ export class MicrosandboxBackendPoisonedError extends Error {
500
+ name = "MicrosandboxBackendPoisonedError";
501
+ constructor(cause) {
502
+ super("Microsandbox cannot issue any further backend-dependent SDK call from this process: restoring the "
503
+ + "process-wide default backend failed, so its current value is unknown and a call issued now could "
504
+ + `run against the wrong backend. Restart the process. Underlying error: ${errorMessage(cause)}`, { cause });
505
+ }
506
+ }
507
+ /**
508
+ * A command finished, but the SDK reported no exit code for it.
509
+ *
510
+ * The bootstrap-plane `ExecResult.exitCode` is a number, so this adapter would
511
+ * have to INVENT one — and the only plausible invention, `0`, is the value that
512
+ * says "this succeeded". A command whose outcome the provider did not report is
513
+ * not a command that succeeded, so the caller is told the outcome is unknown
514
+ * instead of being told a comfortable lie it cannot detect.
515
+ */
516
+ export class MicrosandboxUnknownOutcomeError extends Error {
517
+ name = "MicrosandboxUnknownOutcomeError";
518
+ sandboxName;
519
+ constructor(sandboxName) {
520
+ super(`Microsandbox exec on sandbox "${sandboxName}" completed without an exit code, so its outcome is `
521
+ + "unknown; it is reported as unknown rather than defaulted to success");
522
+ this.sandboxName = sandboxName;
523
+ }
524
+ }
525
+ /**
526
+ * The status probe for an async run did not produce a verdict.
527
+ *
528
+ * Distinct from {@link MicrosandboxRunLostError}, which IS a verdict — the run
529
+ * is over and cannot complete. This error says the adapter learned NOTHING:
530
+ * the guest call failed, a required guest-state read was unavailable, or it
531
+ * answered something this protocol does not define. Returning "still running"
532
+ * for any of those would be a positive claim the probe never made, and a poll
533
+ * loop reading it would wait for an outcome that may already have happened.
534
+ *
535
+ * It is safe to retry: nothing about the run was changed by asking.
536
+ */
537
+ export class MicrosandboxStatusProbeError extends Error {
538
+ name = "MicrosandboxStatusProbeError";
539
+ sessionId;
540
+ commandId;
541
+ /**
542
+ * `"transport"` — the probe call or a required guest-state read failed.
543
+ * `"unrecognized"` — it answered off-protocol.
544
+ */
545
+ reason;
546
+ /** Retrying is harmless; the probe has no side effects on the run. */
547
+ retryable = true;
548
+ constructor(sessionId, commandId, reason, detail, cause) {
549
+ super(`Microsandbox could not determine the status of run "${sessionId}" (${reason}): ${detail}. `
550
+ + "The run's state is unchanged and the probe may be retried; it is NOT reported as still running, "
551
+ + "because that would be an observation this probe did not make.", cause === undefined ? undefined : { cause });
552
+ this.sessionId = sessionId;
553
+ this.commandId = commandId;
554
+ this.reason = reason;
555
+ }
556
+ }
557
+ /**
558
+ * A run's captured output could not be read.
559
+ *
560
+ * Raised instead of returning `""`. An empty log is a legitimate outcome — a
561
+ * command that printed nothing has one — so a failed read that answered `""`
562
+ * would be indistinguishable from a real result, and the caller would record
563
+ * "the command produced no output" as a fact about a read that never happened.
564
+ * A genuinely ABSENT log file still yields `""`, which is the one case where
565
+ * empty is the truth.
566
+ */
567
+ export class MicrosandboxLogReadError extends Error {
568
+ name = "MicrosandboxLogReadError";
569
+ sessionId;
570
+ path;
571
+ constructor(sessionId, path, detail, cause) {
572
+ super(`Microsandbox could not read the log for run "${sessionId}" at ${path}: ${detail}. `
573
+ + "Reported as a failure rather than as empty output, which would be indistinguishable from a run "
574
+ + "that printed nothing.", cause === undefined ? undefined : { cause });
575
+ this.sessionId = sessionId;
576
+ this.path = path;
577
+ }
578
+ }
579
+ /**
580
+ * `startScript`/`startExec` was given a `timeoutMs`, which this adapter cannot
581
+ * honour as the port defines it.
582
+ *
583
+ * The port's `timeoutMs` is the COMMAND's lifetime. For a synchronous
584
+ * `runScript` that is exactly what the SDK's `ExecOptionsBuilder.timeout` gives,
585
+ * so the sync path honours it. An async run is different: it is detached inside
586
+ * the guest by the durable wrapper and outlives the submit call, so the submit
587
+ * call's timeout bounds nothing about the command.
588
+ *
589
+ * The adapter previously applied it to the submit call anyway. That is the
590
+ * failure mode this error exists to remove — a caller that asked for a 30s
591
+ * command budget got a 30s SUBMIT budget and a command that runs forever, with
592
+ * nothing in the result to say so.
593
+ *
594
+ * It is refused rather than approximated because the honest enforcement is not
595
+ * available here: killing the run's shell on expiry would leave that shell's
596
+ * own descendants running, so the adapter would report a terminated run while
597
+ * the work continued — a fabricated outcome, which is worse than a refusal. Use
598
+ * `maxDurationSeconds` for a sandbox-lifetime bound, or put the bound in the
599
+ * command itself (`timeout 30 ...`), where the guest can enforce it properly.
600
+ */
601
+ export class MicrosandboxRunTimeoutUnsupportedError extends Error {
602
+ name = "MicrosandboxRunTimeoutUnsupportedError";
603
+ sessionId;
604
+ timeoutMs;
605
+ constructor(sessionId, timeoutMs) {
606
+ super(`Microsandbox cannot apply a ${timeoutMs}ms command timeout to the async run "${sessionId}": the port `
607
+ + "defines `timeoutMs` as the command's lifetime, and an async run is detached in the guest, so the "
608
+ + "submit call's timeout would bound nothing. Nothing was submitted. Bound the sandbox with "
609
+ + "`maxDurationSeconds`, or put the timeout inside the command.");
610
+ this.sessionId = sessionId;
611
+ this.timeoutMs = timeoutMs;
612
+ }
613
+ }
614
+ let currentBackendScope = null;
615
+ const backendScopeWaiters = [];
616
+ /**
617
+ * The waiter the gate is currently RESERVED for, if any.
618
+ *
619
+ * Waking the head of the queue is not by itself FIFO: between the wake and the
620
+ * woken caller re-checking the gate, a brand-new arrival can observe a free
621
+ * gate and take it, so the queue's order decides nothing and a waiter can be
622
+ * skipped repeatedly. While this is set the gate is spoken for, and every
623
+ * caller other than this one must queue — which is what actually makes the
624
+ * handoff ordered.
625
+ */
626
+ let backendGateHandoffTo = null;
627
+ /**
628
+ * Set once the SDK fails to RESTORE the process-wide default backend, after
629
+ * which no default-dependent static may be issued from this process again.
630
+ *
631
+ * Deliberately permanent and deliberately module-scoped: the damage is to the
632
+ * SDK's process-global slot, so it is not a property of any one runtime
633
+ * instance, and no action this adapter is willing to take can re-establish
634
+ * what that slot now holds. See {@link MicrosandboxBackendPoisonedError}.
635
+ *
636
+ * The FLAG carries whether the gate is poisoned; the cause is kept beside it.
637
+ * Using the cause as its own sentinel would lose exactly the rejections that
638
+ * carry no value — `Promise.reject()`, `reject(null)` — and those poison the
639
+ * process default just as thoroughly as a rejection with an `Error` does.
640
+ */
641
+ let backendGatePoisoned = false;
642
+ let backendGatePoisonCause;
643
+ /**
644
+ * Reset the module-global gate. TEST-ONLY.
645
+ *
646
+ * The gate is process-global on purpose, and poisoning it is permanent on
647
+ * purpose — which makes the poison path untestable in-process without a way
648
+ * back. Exported from the module but NOT from the package barrel, so it is
649
+ * reachable from this file's tests and from nowhere a consumer imports.
650
+ *
651
+ * @internal
652
+ */
653
+ export function __resetBackendGateForTests() {
654
+ currentBackendScope = null;
655
+ backendScopeWaiters.splice(0);
656
+ backendGateHandoffTo = null;
657
+ backendGatePoisoned = false;
658
+ backendGatePoisonCause = undefined;
659
+ }
660
+ /**
661
+ * How many callers are queued on the gate right now. TEST-ONLY.
662
+ *
663
+ * The leak this exists to catch is INVISIBLE from the outside: a waiter that
664
+ * timed out still reports the same typed `MicrosandboxBackendBusyError` to its
665
+ * caller whether or not it deregistered itself, so a test written against
666
+ * observable behaviour alone passes against the bug. The only discriminating
667
+ * signal is the length of the queue itself, so the queue is what the test
668
+ * asserts on.
669
+ *
670
+ * @internal
671
+ */
672
+ export function __backendGateWaiterCountForTests() {
673
+ return backendScopeWaiters.length;
674
+ }
675
+ /**
676
+ * Is this runtime bound to the LOCAL backend?
677
+ *
678
+ * Two capability claims hinge on it — snapshot support and isolation — so it is
679
+ * a named predicate rather than an inline `=== "local"` repeated at each site.
680
+ */
681
+ function isLocalBackend(backend) {
682
+ return backend === "local";
683
+ }
684
+ /**
685
+ * Stable identity for a backend value. The API key is hashed rather than
686
+ * stored, so a long-lived comparison key never holds the secret itself.
687
+ */
688
+ function backendKey(backend) {
689
+ if (backend === "local") {
690
+ return "local";
691
+ }
692
+ if ("profile" in backend) {
693
+ return `cloud:profile:${backend.profile}`;
694
+ }
695
+ const digest = createHash("sha256").update(backend.apiKey, "utf8").digest("hex").slice(0, 16);
696
+ return `cloud:url:${backend.url ?? ""}:key:${digest}`;
697
+ }
698
+ async function withBackendScope(sdk, backend, fn, queueTimeoutMs, signal) {
699
+ const key = backendKey(backend);
700
+ const queueDeadline = Date.now() + queueTimeoutMs;
701
+ // Set once this caller has been handed the gate as the FIFO head. It may
702
+ // then take the gate even though callers are still queued BEHIND it — which
703
+ // is the whole point of the handoff — but it owes the queue a promotion if
704
+ // it then leaves without taking the gate.
705
+ let holdsTurn = false;
706
+ const yieldTurn = () => {
707
+ if (holdsTurn) {
708
+ holdsTurn = false;
709
+ promoteNextBackendWaiter();
710
+ }
711
+ };
712
+ try {
713
+ for (;;) {
714
+ if (backendGatePoisoned) {
715
+ throw new MicrosandboxBackendPoisonedError(backendGatePoisonCause);
716
+ }
717
+ if (signal?.aborted) {
718
+ // ADMISSION CANCELLED. The caller's overall deadline has already
719
+ // expired, so `fn` must never run: a static admitted after its own
720
+ // deadline is a call the caller has stopped waiting for and will
721
+ // never read the result of.
722
+ throw signal.reason;
723
+ }
724
+ const open = currentBackendScope;
725
+ // JOINING IS THE STARVATION RISK, so it is conditional on an empty queue.
726
+ // A same-backend call may share an open scope only when NOBODY is waiting
727
+ // and the gate is not already promised to a waiter; the moment a call on
728
+ // another backend has queued, later same-backend arrivals queue behind it
729
+ // too. Without that condition a steady stream of same-backend work keeps
730
+ // the scope permanently occupied and the other backend never runs — which
731
+ // is the failure this gate's own comment claimed it prevented.
732
+ if (open &&
733
+ !open.closing &&
734
+ open.key === key &&
735
+ (holdsTurn || (backendScopeWaiters.length === 0 && backendGateHandoffTo === null))) {
736
+ open.active += 1;
737
+ // Joining does not hold the gate — the open scope does — so a turn
738
+ // taken here is handed straight back to the queue.
739
+ yieldTurn();
740
+ try {
741
+ // The scope may still be opening; running before it is entered would
742
+ // run against whatever backend the process last had.
743
+ await open.entered;
744
+ return await fn();
745
+ }
746
+ finally {
747
+ await leaveBackendScope(open);
748
+ }
749
+ }
750
+ if (open ||
751
+ (!holdsTurn && (backendScopeWaiters.length > 0 || backendGateHandoffTo !== null))) {
752
+ // BOUNDED. An abandoned scope holder (a create or lookup that outlived
753
+ // its client-side deadline) is still in flight and still owns the
754
+ // process-wide backend, so this wait can otherwise never end.
755
+ const remainingMs = queueDeadline - Date.now();
756
+ if (remainingMs <= 0) {
757
+ throw new MicrosandboxBackendBusyError(queueTimeoutMs);
758
+ }
759
+ let timer;
760
+ let wake;
761
+ const woken = new Promise((resolve) => {
762
+ wake = () => resolve("woken");
763
+ });
764
+ const waiter = { key, wake };
765
+ backendScopeWaiters.push(waiter);
766
+ let onAbort;
767
+ const outcomes = [
768
+ woken,
769
+ new Promise((resolve) => {
770
+ timer = setTimeout(() => resolve("timeout"), remainingMs);
771
+ }),
772
+ ];
773
+ if (signal) {
774
+ outcomes.push(new Promise((resolve) => {
775
+ onAbort = () => resolve("aborted");
776
+ signal.addEventListener("abort", onAbort, { once: true });
777
+ }));
778
+ }
779
+ const outcome = await Promise.race(outcomes);
780
+ if (timer) {
781
+ clearTimeout(timer);
782
+ }
783
+ if (signal && onAbort) {
784
+ signal.removeEventListener("abort", onAbort);
785
+ }
786
+ if (outcome === "woken") {
787
+ // The gate was reserved for THIS waiter by name, so nothing else can
788
+ // have taken it in between. Consume the reservation and re-check.
789
+ backendGateHandoffTo = null;
790
+ holdsTurn = true;
791
+ continue;
792
+ }
793
+ // Deregister. The waiter list is no longer spliced wholesale on
794
+ // release, but a timed-out or cancelled waiter left behind would still
795
+ // be woken later and, worse, would hold a handoff the gate then waits
796
+ // on forever — so it both leaves the queue and passes on any turn it
797
+ // was given in the same tick.
798
+ dropBackendWaiter(waiter);
799
+ if (outcome === "aborted") {
800
+ throw signal.reason;
801
+ }
802
+ throw new MicrosandboxBackendBusyError(queueTimeoutMs);
803
+ }
804
+ return await openBackendScope(sdk, backend, key, fn, () => {
805
+ // Taking the gate consumes the turn: ownership now lives in the scope,
806
+ // and the queue is promoted when that scope releases.
807
+ holdsTurn = false;
808
+ });
809
+ }
810
+ }
811
+ finally {
812
+ yieldTurn();
813
+ }
814
+ }
815
+ /**
816
+ * Open a NEW scope on a free gate and run `fn` inside it.
817
+ *
818
+ * Split out from {@link withBackendScope} only so the queueing loop above stays
819
+ * readable; it is never called on a gate that is already held.
820
+ */
821
+ async function openBackendScope(sdk, backend, key, fn, onTaken) {
822
+ {
823
+ let release;
824
+ const drained = new Promise((resolve) => {
825
+ release = resolve;
826
+ });
827
+ let markEntered;
828
+ let failEntry;
829
+ const entered = new Promise((resolve, reject) => {
830
+ markEntered = resolve;
831
+ failEntry = reject;
832
+ });
833
+ // Nobody may await `entered` before its rejection handler is attached
834
+ // below, and an unobserved rejection would otherwise surface as a process
835
+ // warning; the catch here is the handler of record.
836
+ entered.catch(() => undefined);
837
+ const scope = {
838
+ key,
839
+ active: 1,
840
+ closing: false,
841
+ entered,
842
+ release,
843
+ exited: Promise.resolve(),
844
+ };
845
+ currentBackendScope = scope;
846
+ onTaken();
847
+ // Distinguishes the two ways `withDefaultBackend` can reject, which need
848
+ // opposite handling: before the callback ran, nothing of ours executed and
849
+ // the process default was never changed; after it ran, the rejection can
850
+ // only be the SDK failing to RESTORE, and the slot is then unknown.
851
+ let callbackRan = false;
852
+ let scopeCall;
853
+ try {
854
+ // The scope stays open for as long as anyone is inside it: the SDK's
855
+ // callback resolves only once the last participant has left.
856
+ scopeCall = Promise.resolve(sdk.withDefaultBackend(backend, () => {
857
+ callbackRan = true;
858
+ markEntered();
859
+ return drained;
860
+ }));
861
+ }
862
+ catch (error) {
863
+ if (callbackRan) {
864
+ // The SDK entered our callback and then threw on the SAME stack while
865
+ // restoring the previous process default. `Promise.resolve(...)`
866
+ // never receives a value in this form, so the asynchronous rejection
867
+ // handler below cannot observe it; callback entry is nevertheless the
868
+ // decisive proof that the global slot was changed and is now unknown.
869
+ backendGatePoisoned = true;
870
+ backendGatePoisonCause = error;
871
+ }
872
+ // Pre-callback, this is a harmless synchronous failure to push the scope.
873
+ // Post-callback, freeing the now-poisoned gate wakes queued callers so
874
+ // they fail immediately with MicrosandboxBackendPoisonedError.
875
+ abandonBackendScope(scope);
876
+ throw error;
877
+ }
878
+ scope.exited = scopeCall.then(() => undefined, (error) => {
879
+ if (!callbackRan) {
880
+ // Rejected BEFORE the callback ran: the scope was never entered, so
881
+ // the process default was never changed and nothing ran under it.
882
+ // `entered` rejects and this call fails closed, below.
883
+ failEntry(error);
884
+ return;
885
+ }
886
+ // Rejected AFTER the callback ran. The callback returns a promise that
887
+ // resolves only once the last participant has left, so the SDK had
888
+ // already re-entered its own teardown: this rejection is the RESTORE
889
+ // failing. The process-wide slot now holds an unknown value.
890
+ //
891
+ // Swallowing it — which is what returning here would do — hands the
892
+ // gate to the next backend as if the previous one had been cleanly
893
+ // restored, and that call then runs against whatever the slot actually
894
+ // holds. So it is recorded as poison for every later static, and
895
+ // rethrown so the participant that closed the scope is TOLD rather
896
+ // than left believing the call completed cleanly.
897
+ backendGatePoisoned = true;
898
+ backendGatePoisonCause = error;
899
+ throw error;
900
+ });
901
+ try {
902
+ await entered;
903
+ }
904
+ catch (error) {
905
+ // FAIL CLOSED. Running the call anyway would send this runtime's work to
906
+ // whatever backend the process default happens to hold.
907
+ abandonBackendScope(scope);
908
+ throw error;
909
+ }
910
+ try {
911
+ return await fn();
912
+ }
913
+ finally {
914
+ await leaveBackendScope(scope);
915
+ }
916
+ }
917
+ }
918
+ async function leaveBackendScope(scope) {
919
+ scope.active -= 1;
920
+ if (scope.active > 0 || scope.closing) {
921
+ // EARLY LEAVER. Its own call finished, but the restore it depends on has
922
+ // not happened yet: the scope closes only when the LAST participant
923
+ // leaves. Returning here would let a joined caller report success while
924
+ // the very same scope goes on to fail its restore — two callers running
925
+ // concurrently against one backend, one told the truth and one not.
926
+ //
927
+ // Awaiting cannot deadlock: this participant has already decremented, so
928
+ // the count it is waiting on no longer includes itself, and `exited`
929
+ // settles as soon as the last one leaves.
930
+ await scope.exited;
931
+ return;
932
+ }
933
+ scope.closing = true;
934
+ scope.release();
935
+ try {
936
+ // Wait for the SDK to restore the previous backend BEFORE any queued call
937
+ // on a different backend is allowed to open its own scope.
938
+ await scope.exited;
939
+ }
940
+ finally {
941
+ // Released even when the restore FAILED. The gate is poisoned by then, so
942
+ // waking the queue does not let anyone through — it converts a wait that
943
+ // would otherwise run to its timeout into an immediate, typed refusal.
944
+ releaseBackendGate(scope);
945
+ }
946
+ }
947
+ /** Free the gate for a scope that never opened. */
948
+ function abandonBackendScope(scope) {
949
+ scope.closing = true;
950
+ scope.release();
951
+ releaseBackendGate(scope);
952
+ }
953
+ function releaseBackendGate(scope) {
954
+ if (currentBackendScope === scope) {
955
+ currentBackendScope = null;
956
+ }
957
+ promoteNextBackendWaiter();
958
+ }
959
+ /**
960
+ * Hand the free gate to the OLDEST waiter, and to that waiter alone.
961
+ *
962
+ * Waking every waiter at once and letting them re-race is not FIFO by any
963
+ * definition: all of them find the gate free, and which one actually takes it
964
+ * is decided by microtask scheduling order, so a waiter can lose that race
965
+ * arbitrarily many times while later arrivals win it. Reserving the gate for
966
+ * one named waiter is what makes the queue's order mean something.
967
+ */
968
+ function promoteNextBackendWaiter() {
969
+ if (currentBackendScope !== null || backendGateHandoffTo !== null) {
970
+ return;
971
+ }
972
+ const next = backendScopeWaiters.shift();
973
+ if (!next) {
974
+ return;
975
+ }
976
+ backendGateHandoffTo = next;
977
+ next.wake();
978
+ }
979
+ /**
980
+ * Remove a waiter that will never take the gate, and pass on any reservation
981
+ * it was holding.
982
+ *
983
+ * The reservation matters more than the queue slot: a waiter that timed out in
984
+ * the same tick it was promoted still owns `backendGateHandoffTo`, and every
985
+ * other caller defers to that reservation — so dropping it without promoting a
986
+ * successor wedges the gate permanently on a caller that has already left.
987
+ */
988
+ function dropBackendWaiter(waiter) {
989
+ const queued = backendScopeWaiters.indexOf(waiter);
990
+ if (queued !== -1) {
991
+ backendScopeWaiters.splice(queued, 1);
992
+ }
993
+ if (backendGateHandoffTo === waiter) {
994
+ backendGateHandoffTo = null;
995
+ promoteNextBackendWaiter();
996
+ }
997
+ }
998
+ /**
999
+ * One page of a label listing, validated before anything is read out of it.
1000
+ *
1001
+ * A listing is the ANSWER a warm-lease decision is made from, so a page this
1002
+ * adapter cannot read is a failure, never an empty result: quietly returning a
1003
+ * short list is indistinguishable to the caller from "there is nothing else",
1004
+ * and the caller then launches a sandbox it did not need or under-counts a
1005
+ * quota it is enforcing.
1006
+ */
1007
+ function readSandboxPage(page, seenCursors, pageNumber) {
1008
+ if (typeof page !== "object" || page === null) {
1009
+ throw new MicrosandboxPaginationError(pageNumber, "the provider returned a page that is not an object");
1010
+ }
1011
+ const record = page;
1012
+ if (!Array.isArray(record.sandboxes)) {
1013
+ throw new MicrosandboxPaginationError(pageNumber, "the provider returned a page whose sandbox list is unreadable");
1014
+ }
1015
+ // THE ENVELOPE IS NOT THE DATA. An array of unusable entries is exactly as
1016
+ // unreadable as a missing array, and it fails the same way: every entry is
1017
+ // dropped by the state filter, the drain ends, and the caller is told there
1018
+ // is nothing warm — from a page the provider did in fact return sandboxes on.
1019
+ // A handle is only usable if it has an addressable name, because the name IS
1020
+ // the identity here, and a status, because the state filter reads it.
1021
+ const sandboxes = record.sandboxes.map((entry, index) => {
1022
+ if (typeof entry !== "object" || entry === null) {
1023
+ throw new MicrosandboxPaginationError(pageNumber, `entry ${index} of the page is not a sandbox object`);
1024
+ }
1025
+ const { name, status } = entry;
1026
+ if (typeof name !== "string" || name.trim() === "") {
1027
+ throw new MicrosandboxPaginationError(pageNumber, `entry ${index} of the page has no usable sandbox name, so it cannot be addressed`);
1028
+ }
1029
+ if (typeof status !== "string" || status.trim() === "") {
1030
+ throw new MicrosandboxPaginationError(pageNumber, `sandbox "${name}" was returned without a usable status, so it cannot be filtered by state`);
1031
+ }
1032
+ return entry;
1033
+ });
1034
+ const cursor = record.nextCursor;
1035
+ if (cursor === undefined || cursor === null || cursor === "") {
1036
+ return { sandboxes };
1037
+ }
1038
+ if (typeof cursor !== "string") {
1039
+ throw new MicrosandboxPaginationError(pageNumber, "the provider returned a next-page cursor that is not a string");
1040
+ }
1041
+ // EVERY cursor already used, not just the previous one. A backend that walks
1042
+ // A → B → A advances on each individual step, so comparing against only the
1043
+ // last cursor sees progress forever while re-serving the same two pages —
1044
+ // the drain then ends on the 1000-page bound at best, and duplicates every
1045
+ // sandbox it collected on the way.
1046
+ if (seenCursors.has(cursor)) {
1047
+ throw new MicrosandboxPaginationError(pageNumber, `the provider returned a next-page cursor it has already served, so the listing is cycling rather than advancing`);
1048
+ }
1049
+ return { sandboxes, nextCursor: cursor };
1050
+ }
1051
+ export class MicrosandboxRuntime {
1052
+ id = "microsandbox";
1053
+ /**
1054
+ * Bootstrap-plane capabilities. These describe what THIS ADAPTER exposes
1055
+ * through the port, not everything the SDK can do — the same convention the
1056
+ * Daytona adapter follows.
1057
+ *
1058
+ * TWO OF THESE ARE BACKEND-SENSITIVE, and both were previously reported as
1059
+ * flat process-wide constants — which published a claim about the cloud
1060
+ * backend that this package cannot stand behind. They are now derived from
1061
+ * the backend this instance is bound to.
1062
+ *
1063
+ * - `pty: false` — the SDK's `ExecOptionsBuilder.tty(true)` and
1064
+ * `ExecHandle.resize()` are real, but the port has no pty method and this
1065
+ * adapter never allocates one.
1066
+ * - `snapshots` — LOCAL only, and the claim is about THIS ADAPTER's boot
1067
+ * path, not about the provider's hosted service. `launch` sources a
1068
+ * sandbox through the builder's `fromSnapshot`, which consumes a
1069
+ * host-local artifact: the installed SDK's typings describe `Snapshot` as
1070
+ * "an artifact on disk" and resolve one under
1071
+ * `~/.microsandbox/snapshots/<name>/`. This adapter never transfers that
1072
+ * artifact anywhere, so on a remote backend there is nothing for a create
1073
+ * to resolve — hence `false`, and a constructor that refuses the pairing.
1074
+ * Note the adapter only ever CONSUMES a snapshot; it never creates one, so
1075
+ * `SandboxHandle.snapshot()` is not on this path.
1076
+ * - `isolation` — `'strong'` on LOCAL, `'unknown'` on CLOUD, and both values
1077
+ * describe what this package has ESTABLISHED rather than what any provider
1078
+ * documentation says. Locally the SDK boots each sandbox as a microVM with
1079
+ * its own guest kernel on a virtualization-capable host, and the installed
1080
+ * package states that requirement itself (Node 22+, a native addon, KVM /
1081
+ * Apple Silicon / WHP), so `'strong'` rests on something checkable here.
1082
+ * For the cloud backend this adapter observes nothing about isolation,
1083
+ * region placement or resource enforcement and measures nothing against
1084
+ * them, so it reports `'unknown'` — which is a statement about this
1085
+ * package's evidence, not an assertion that the guarantee is absent. See
1086
+ * {@link IsolationLevel}.
1087
+ * - `persistentHandle: true` — a sandbox is re-resolvable by name from a
1088
+ * fresh process via `Sandbox.get(name)` + `connect()`, and
1089
+ * `launchDetached` sets `detached(true)` so it outlives this process.
1090
+ * - `streamingLogs: false` — the SDK ships `logStream({follow:true})` and
1091
+ * `execStream`, but this adapter's log path is a durable file read, so
1092
+ * claiming a streaming capability here would be claiming a code path that
1093
+ * does not exist.
1094
+ *
1095
+ * NOT SUPPORTED, and deliberately absent rather than silently ignored: custom
1096
+ * or published PORTS. The SDK builder exposes `port()`/`portBind()`/`portUdp()`,
1097
+ * but the ports this package targets have no public-port surface to express
1098
+ * them, so this adapter never calls them and never implies a reachable port.
1099
+ */
1100
+ capabilities;
1101
+ /**
1102
+ * Both true, and declared rather than left to default so the reasoning is on
1103
+ * the record. `warmLease`: `Sandbox.listWith(b => b.labels(...))` is a real
1104
+ * server-side label query with cursor pagination, so a warm-lease lookup is
1105
+ * meaningful. `lifecycle`: `start`/`stop` map onto `SandboxHandle.start()` /
1106
+ * `SandboxHandle.stop()`, which genuinely resume and halt a microVM — unlike
1107
+ * the E2B adapter, where both are no-ops. Both apply to sandboxes this
1108
+ * runtime OWNS; an attached sandbox is deliberately left alone.
1109
+ */
1110
+ declaredCapabilities = { warmLease: true, lifecycle: true };
1111
+ backend;
1112
+ image;
1113
+ snapshot;
1114
+ homeDir;
1115
+ defaultWorkdir;
1116
+ cpus;
1117
+ memoryMiB;
1118
+ idleTimeoutSeconds;
1119
+ maxDurationSeconds;
1120
+ replaceExisting;
1121
+ namePrefix;
1122
+ runStateDir;
1123
+ shell;
1124
+ connectTimeoutMs;
1125
+ lookupTimeoutMs;
1126
+ listPageSize;
1127
+ backendQueueTimeoutMs;
1128
+ injectedSdk;
1129
+ onReclaimFailure;
1130
+ sdkPromise;
1131
+ // What this runtime knows about each sandbox name: the live instance
1132
+ // resolved in this process (if any) and whether this runtime owns it. A
1133
+ // cross-request access (an async-run poll tick) that misses the instance
1134
+ // re-resolves by name via `Sandbox.get(name).connect()` — the reattach that
1135
+ // lets a run outlive the request that started it.
1136
+ registry = new Map();
1137
+ // Reclamations of creates that landed after their deadline, keyed by name.
1138
+ // A later launch of the same name waits for one so the two cannot race.
1139
+ pendingReclaims = new Map();
1140
+ constructor(options) {
1141
+ if (!options.image && !options.snapshot) {
1142
+ throw new Error("MicrosandboxRuntime requires either `image` or `snapshot`: neither has a default that is correct for another consumer");
1143
+ }
1144
+ if (options.image && options.snapshot) {
1145
+ throw new Error("MicrosandboxRuntime accepts `image` or `snapshot`, not both: a sandbox has exactly one rootfs source");
1146
+ }
1147
+ // Refused HERE — before the lazy `import("microsandbox")`, before any SDK
1148
+ // call, before a single byte leaves the process. Deferring it to `launch`
1149
+ // would surface it as an opaque remote failure on a path the caller was
1150
+ // told existed.
1151
+ //
1152
+ // WHAT THIS REFUSAL RESTS ON, stated exactly, because a capability claim is
1153
+ // only worth what its evidence is. It is NOT a claim about what the vendor's
1154
+ // hosted service does or does not implement — this package has no way to
1155
+ // observe that. It is a statement about the ADAPTER'S OWN CONTRACT plus one
1156
+ // fact that is checkable in the installed package:
1157
+ //
1158
+ // - checkable: a snapshot is a HOST-LOCAL ARTIFACT. `microsandbox@0.6.11`'s
1159
+ // `native/index.d.ts` declares `Snapshot` as "A snapshot artifact on
1160
+ // disk", resolves `SandboxHandle.snapshot(name)` "under
1161
+ // `~/.microsandbox/snapshots/<name>/`", and offers `Snapshot.listDir(dir)`
1162
+ // to walk a directory of them. `builder.fromSnapshot(pathOrName)` consumes
1163
+ // that artifact.
1164
+ // - contract: this adapter only ever CONSUMES a snapshot by path or name
1165
+ // from the calling host, and it does not transfer one anywhere. A create
1166
+ // issued against a remote backend therefore has nothing this adapter has
1167
+ // put within its reach.
1168
+ //
1169
+ // So the pairing is refused because THIS ADAPTER cannot make it work, which
1170
+ // is a fact about code in this repository. `capabilities.snapshots` reports
1171
+ // the same thing per backend, and neither claims anything about the hosted
1172
+ // service's own capabilities.
1173
+ if (options.snapshot && !isLocalBackend(options.backend)) {
1174
+ throw new Error("MicrosandboxRuntime cannot boot from a `snapshot` on the cloud backend: a snapshot is a host-local "
1175
+ + "artifact (the SDK's own typings resolve one under ~/.microsandbox/snapshots/ and describe it as an "
1176
+ + "artifact on disk), and this adapter consumes it from the calling host without transferring it, so a "
1177
+ + "create issued against a remote backend has nothing to resolve. "
1178
+ + "Use `image` on the cloud backend, or set `backend: \"local\"` to boot from a snapshot.");
1179
+ }
1180
+ this.backend = options.backend;
1181
+ if (options.image !== undefined) {
1182
+ this.image = options.image;
1183
+ }
1184
+ if (options.snapshot !== undefined) {
1185
+ this.snapshot = options.snapshot;
1186
+ }
1187
+ this.homeDir = options.homeDir;
1188
+ if (options.workdir !== undefined) {
1189
+ this.defaultWorkdir = options.workdir;
1190
+ }
1191
+ if (options.cpus !== undefined) {
1192
+ this.cpus = options.cpus;
1193
+ }
1194
+ if (options.memoryMiB !== undefined) {
1195
+ this.memoryMiB = options.memoryMiB;
1196
+ }
1197
+ if (options.idleTimeoutSeconds !== undefined) {
1198
+ this.idleTimeoutSeconds = options.idleTimeoutSeconds;
1199
+ }
1200
+ if (options.maxDurationSeconds !== undefined) {
1201
+ this.maxDurationSeconds = options.maxDurationSeconds;
1202
+ }
1203
+ this.capabilities = {
1204
+ pty: false,
1205
+ snapshots: isLocalBackend(options.backend),
1206
+ isolation: isLocalBackend(options.backend) ? "strong" : "unknown",
1207
+ persistentHandle: true,
1208
+ streamingLogs: false,
1209
+ };
1210
+ this.replaceExisting = options.replaceExisting ?? false;
1211
+ this.namePrefix = options.namePrefix ?? "";
1212
+ this.runStateDir = (options.runStateDir ?? DEFAULT_RUN_STATE_DIR).replace(/\/+$/, "");
1213
+ this.shell = options.shell ?? DEFAULT_SHELL;
1214
+ this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
1215
+ this.lookupTimeoutMs = options.lookupTimeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS;
1216
+ this.listPageSize = options.listPageSize ?? DEFAULT_LIST_PAGE_SIZE;
1217
+ this.backendQueueTimeoutMs = options.backendQueueTimeoutMs ?? DEFAULT_BACKEND_QUEUE_TIMEOUT_MS;
1218
+ if (options.sdk !== undefined) {
1219
+ this.injectedSdk = options.sdk;
1220
+ }
1221
+ if (options.onReclaimFailure !== undefined) {
1222
+ this.onReclaimFailure = options.onReclaimFailure;
1223
+ }
1224
+ }
1225
+ // --- lookup --------------------------------------------------------------
1226
+ async findByLabels(labels, options = {}) {
1227
+ // Exclusions are applied DURING the drain, never to the collected page:
1228
+ // filtering afterwards would let a page full of already-claimed sandboxes
1229
+ // answer "nothing warm available" while the next page held a free one.
1230
+ const handles = await this.collectByLabels(labels, {
1231
+ states: options.states === undefined ? ["STARTED"] : options.states,
1232
+ cap: 1,
1233
+ requestSize: options.limit ?? options.pageSize,
1234
+ excludeIds: options.excludeIds,
1235
+ timeoutMs: options.timeoutMs,
1236
+ claim: options.owned ?? false,
1237
+ description: "listing matching sandboxes",
1238
+ });
1239
+ return handles[0] ?? null;
1240
+ }
1241
+ async findAllByLabels(labels, options = {}) {
1242
+ return this.collectByLabels(labels, {
1243
+ states: options.states === undefined ? ["STARTED"] : options.states,
1244
+ // `limit` caps RESULTS as well as sizing the request; `pageSize` only
1245
+ // sizes the request. Treating a page size as a result cap would silently
1246
+ // truncate a listing the caller asked to receive in full. A non-integral
1247
+ // or negative cap is floored onto zero, which `collectByLabels` answers
1248
+ // without a call — the same rule `countByLabels` applies to `maxCount`.
1249
+ ...resultCap(options.limit),
1250
+ requestSize: options.limit ?? options.pageSize,
1251
+ excludeIds: options.excludeIds,
1252
+ timeoutMs: options.timeoutMs,
1253
+ claim: options.owned ?? false,
1254
+ description: "listing matching sandboxes",
1255
+ });
1256
+ }
1257
+ async countByLabels(labels, options = {}) {
1258
+ const handles = await this.collectByLabels(labels, {
1259
+ states: options.states === undefined ? ["STARTED"] : options.states,
1260
+ ...resultCap(options.maxCount),
1261
+ requestSize: options.limit ?? options.pageSize,
1262
+ timeoutMs: options.timeoutMs,
1263
+ description: "counting matching sandboxes",
1264
+ });
1265
+ return handles.length;
1266
+ }
1267
+ /**
1268
+ * Drain the cursor-paginated, server-side label listing, keeping the entries
1269
+ * that match `states` and are not excluded, and stopping as soon as `cap`
1270
+ * such handles are collected or the lookup deadline elapses.
1271
+ */
1272
+ async collectByLabels(labels, options) {
1273
+ // A cap of zero is answerable without a network call, and answering it
1274
+ // WITH one is how a cap of zero used to return a result: the cap was
1275
+ // checked only after an entry had already been collected. Negative and
1276
+ // fractional caps normalize onto it rather than meaning something
1277
+ // accidental.
1278
+ if (options.cap !== undefined && options.cap <= 0) {
1279
+ return [];
1280
+ }
1281
+ // Request size, resolved exactly as Daytona/E2B/local resolve it, so a
1282
+ // caller that tuned one provider's lookup gets the same request shape here
1283
+ // — except that a zero or negative size is not a request shape at all, so
1284
+ // it falls back to the configured page size instead of being sent.
1285
+ const limit = positivePageSize(options.requestSize) ?? this.listPageSize;
1286
+ const excluded = new Set(options.excludeIds ?? []);
1287
+ const deadline = this.lookupDeadline(options.timeoutMs);
1288
+ const handles = [];
1289
+ const seenCursors = new Set();
1290
+ let cursor;
1291
+ // Bounded so a backend that keeps handing back fresh cursors cannot spin
1292
+ // this loop forever. The deadline bounds it in wall-clock terms too.
1293
+ for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
1294
+ const cursorForPage = cursor;
1295
+ const raw = await this.awaitWithinCancelling((signal) => this.withBackendStatic((sdk) => sdk.Sandbox.listWith((builder) => {
1296
+ let configured = builder.limit(limit);
1297
+ if (hasEntries(labels)) {
1298
+ configured = configured.labels(labels);
1299
+ }
1300
+ if (cursorForPage) {
1301
+ configured = configured.cursor(cursorForPage);
1302
+ }
1303
+ return configured;
1304
+ }), signal), deadline, options.description);
1305
+ // Recorded BEFORE the page is validated, so the cursor that fetched this
1306
+ // page counts as seen: a provider that hands back the cursor it was just
1307
+ // given is the degenerate cycle, and it has to fail on the same check as
1308
+ // the longer A → B → A one.
1309
+ if (cursorForPage !== undefined) {
1310
+ seenCursors.add(cursorForPage);
1311
+ }
1312
+ const result = readSandboxPage(raw, seenCursors, page + 1);
1313
+ for (const entry of result.sandboxes) {
1314
+ if (!matchesState(entry.status, options.states) || excluded.has(entry.name)) {
1315
+ continue;
1316
+ }
1317
+ if (options.claim !== undefined) {
1318
+ this.register(entry.name, { owned: options.claim });
1319
+ }
1320
+ handles.push(handleFromSandboxHandle(entry));
1321
+ if (options.cap !== undefined && handles.length >= options.cap) {
1322
+ return handles;
1323
+ }
1324
+ }
1325
+ if (!result.nextCursor) {
1326
+ return handles;
1327
+ }
1328
+ cursor = result.nextCursor;
1329
+ }
1330
+ // THE BOUND IS A FAILURE, NOT AN ANSWER. Falling out of this loop meant
1331
+ // returning what had been collected so far, which is the exact shape of a
1332
+ // complete listing — the caller cannot tell a drain that gave up after a
1333
+ // thousand pages from one that genuinely ended, so it under-counts a quota
1334
+ // or launches a sandbox it already had. Every other way this drain can end
1335
+ // badly already throws; this one now does too.
1336
+ throw new MicrosandboxPaginationError(MAX_LIST_PAGES, `the listing still had more pages at the ${MAX_LIST_PAGES}-page safety bound, so it cannot be drained `
1337
+ + "to a trustworthy end; no partial result is returned");
1338
+ }
1339
+ async getById(id, options = {}) {
1340
+ const entry = await this.lookupHandle(id);
1341
+ if (!entry) {
1342
+ return null;
1343
+ }
1344
+ // `undefined` means "the caller did not filter"; `null` means "any state".
1345
+ const states = options.states === undefined ? null : options.states;
1346
+ if (!matchesState(entry.status, states)) {
1347
+ return null;
1348
+ }
1349
+ // Ownership defaults to FALSE, exactly as it does on the Daytona adapter:
1350
+ // resolving a sandbox by name is an attach, and an attach is not a claim.
1351
+ // `stop`, `start` and `destroy` refuse to touch a sandbox that was never
1352
+ // claimed, so a lease-reattach path cannot delete a sandbox it borrowed.
1353
+ this.register(id, { owned: options.owned ?? false });
1354
+ const handle = handleFromSandboxHandle(entry);
1355
+ if (options.homeDir !== undefined) {
1356
+ handle.homeDir = options.homeDir;
1357
+ }
1358
+ if (options.workdir !== undefined) {
1359
+ handle.workdir = options.workdir;
1360
+ }
1361
+ return handle;
1362
+ }
1363
+ // --- launch --------------------------------------------------------------
1364
+ async launch(options = {}) {
1365
+ return this.createSandbox(options, false);
1366
+ }
1367
+ async launchDetached(options = {}) {
1368
+ // A detached sandbox keeps running after this Node process exits, so a
1369
+ // short-lived request context can return before the workload finishes and a
1370
+ // later tick can reattach by name.
1371
+ return this.createSandbox(options, true);
1372
+ }
1373
+ async createSandbox(options, detached) {
1374
+ const name = this.resolveName(options.name);
1375
+ // A previous create under this name may still be being reclaimed. Waiting
1376
+ // keeps the reclamation from killing the sandbox this call is about to
1377
+ // create under the same name.
1378
+ const pending = this.pendingReclaims.get(name);
1379
+ if (pending) {
1380
+ await pending;
1381
+ }
1382
+ const workdir = options.workdir ?? this.defaultWorkdir;
1383
+ const timeoutMs = options.createTimeoutSeconds
1384
+ ? options.createTimeoutSeconds * 1000
1385
+ : undefined;
1386
+ const controller = timeoutMs === undefined ? undefined : new AbortController();
1387
+ let createStarted = false;
1388
+ const create = this.withBackendStatic(async (sdk) => {
1389
+ // Once this flips, `builder.create()` is reached in the same synchronous
1390
+ // turn: every preceding builder method is synchronous. A deadline after
1391
+ // this point cannot cancel provider work, so the late-create watcher
1392
+ // below must retain responsibility for its eventual outcome.
1393
+ createStarted = true;
1394
+ let builder = sdk.Sandbox.builder(name);
1395
+ builder = this.snapshot
1396
+ ? builder.fromSnapshot(this.snapshot)
1397
+ : builder.image(this.image);
1398
+ if (this.cpus !== undefined) {
1399
+ builder = builder.cpus(this.cpus);
1400
+ }
1401
+ if (this.memoryMiB !== undefined) {
1402
+ builder = builder.memory(this.memoryMiB);
1403
+ }
1404
+ if (this.idleTimeoutSeconds !== undefined) {
1405
+ builder = builder.idleTimeout(this.idleTimeoutSeconds);
1406
+ }
1407
+ if (this.maxDurationSeconds !== undefined) {
1408
+ builder = builder.maxDuration(this.maxDurationSeconds);
1409
+ }
1410
+ if (workdir !== undefined) {
1411
+ builder = builder.workdir(workdir);
1412
+ }
1413
+ if (hasEntries(options.env)) {
1414
+ builder = builder.envs(options.env);
1415
+ }
1416
+ const labels = mergeLabels(options.labels, options.label);
1417
+ if (hasEntries(labels)) {
1418
+ builder = builder.labels(labels);
1419
+ }
1420
+ if (detached) {
1421
+ builder = builder.detached(true);
1422
+ }
1423
+ if (this.replaceExisting) {
1424
+ builder = builder.replace();
1425
+ }
1426
+ return builder.create();
1427
+ }, controller?.signal);
1428
+ // The builder has no create-timeout setter, and `maxDuration` is a sandbox
1429
+ // LIFETIME budget — mapping the caller's boot deadline onto it would kill
1430
+ // every long-lived sandbox the moment that deadline elapsed. So the create
1431
+ // deadline is enforced here instead.
1432
+ let sandbox;
1433
+ if (timeoutMs !== undefined) {
1434
+ try {
1435
+ sandbox = await withDeadline(create, timeoutMs, name, controller);
1436
+ }
1437
+ catch (error) {
1438
+ if (error instanceof MicrosandboxCreateTimeoutError && createStarted) {
1439
+ this.reclaimLateCreate(name, create);
1440
+ }
1441
+ throw error;
1442
+ }
1443
+ }
1444
+ else {
1445
+ sandbox = await create;
1446
+ }
1447
+ this.register(name, { sandbox, owned: true });
1448
+ const handle = { id: name, state: "STARTED", homeDir: this.homeDir };
1449
+ if (workdir !== undefined) {
1450
+ handle.workdir = workdir;
1451
+ }
1452
+ return handle;
1453
+ }
1454
+ /**
1455
+ * Take responsibility for a create that lost the race with its deadline.
1456
+ *
1457
+ * The SDK offers no way to cancel an in-flight create, so the promise is
1458
+ * watched instead of dropped. Two things follow from that, and both matter:
1459
+ * the eventual rejection is consumed here (an abandoned rejected promise is
1460
+ * an unhandled rejection, which crashes a Node process configured to treat
1461
+ * them as fatal), and an eventual SUCCESS is reclaimed — the caller already
1462
+ * saw a failure, so a sandbox nobody is waiting for would otherwise burn
1463
+ * provider resources and hold its name against the next launch.
1464
+ */
1465
+ reclaimLateCreate(name, create) {
1466
+ const reclaimed = create.then(async () => {
1467
+ try {
1468
+ await this.forceDestroy(name);
1469
+ }
1470
+ catch (error) {
1471
+ // The reclamation cannot re-throw here — the promise it lives on is
1472
+ // detached and no caller is awaiting it, so a rejection becomes an
1473
+ // unhandled rejection instead of ever reaching a `.catch`. But
1474
+ // "cannot re-throw" was previously implemented as SILENTLY DROP,
1475
+ // which is what let the cloud-backend destroy leak land: the caller
1476
+ // saw a clean timeout error, the sandbox stayed running, and no
1477
+ // observer was told. The hook makes the failure recoverable in the
1478
+ // sense that actually matters — a downstream logger or pager sees
1479
+ // it — while a later `launch` of the same name still waits on
1480
+ // `pendingReclaims` and gets a shot at reclaiming it.
1481
+ //
1482
+ // Called INSIDE this catch so a throwing hook still crashes here
1483
+ // rather than in the caller — a runtime option's misbehaviour must
1484
+ // not surprise an unrelated caller of `launch`.
1485
+ if (this.onReclaimFailure) {
1486
+ try {
1487
+ this.onReclaimFailure(name, error);
1488
+ }
1489
+ catch {
1490
+ // Deliberately swallowed: a hook that throws its own error must
1491
+ // not be able to escalate a background reclamation failure into
1492
+ // an unhandled rejection. The original teardown failure is
1493
+ // already visible via any prior hook call and via the sandbox
1494
+ // still being addressable by name on the provider.
1495
+ }
1496
+ }
1497
+ }
1498
+ }, () => {
1499
+ // The create failed on its own: there is nothing to reclaim, and the
1500
+ // caller already has the timeout error.
1501
+ });
1502
+ const tracked = reclaimed.finally(() => {
1503
+ if (this.pendingReclaims.get(name) === tracked) {
1504
+ this.pendingReclaims.delete(name);
1505
+ }
1506
+ });
1507
+ this.pendingReclaims.set(name, tracked);
1508
+ }
1509
+ resolveName(requested) {
1510
+ const name = requested ?? `${this.namePrefix}${randomUUID()}`;
1511
+ const byteLength = Buffer.byteLength(name, "utf8");
1512
+ if (byteLength > MAX_SANDBOX_NAME_BYTES) {
1513
+ throw new MicrosandboxNameTooLongError(name, byteLength);
1514
+ }
1515
+ return name;
1516
+ }
1517
+ // --- exec ----------------------------------------------------------------
1518
+ /**
1519
+ * Bootstrap-plane exec. Same call as `runScript`, narrower result shape.
1520
+ *
1521
+ * `ExecResult.exitCode` is a `number`, and `RunScriptResult.exitCode` is
1522
+ * `number | null`, so this is where a missing outcome would have to be
1523
+ * invented. It is not: a `null` becomes a typed error rather than the `0`
1524
+ * that would report an unobserved command as a successful one.
1525
+ */
1526
+ async exec(handle, command, options = {}) {
1527
+ const result = await this.runScript(handle, {
1528
+ command,
1529
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
1530
+ ...(options.env !== undefined ? { env: options.env } : {}),
1531
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
1532
+ });
1533
+ if (result.exitCode === null) {
1534
+ throw new MicrosandboxUnknownOutcomeError(handle.id);
1535
+ }
1536
+ return {
1537
+ output: result.output,
1538
+ exitCode: result.exitCode,
1539
+ ...(result.truncated ? { truncated: true } : {}),
1540
+ };
1541
+ }
1542
+ async runScript(handle, options) {
1543
+ const sandbox = await this.requireSandbox(handle);
1544
+ const cwd = options.cwd ?? handle.workdir;
1545
+ // `shell(script)` takes no options, so a configurable POSIX shell with
1546
+ // `-c` is what carries cwd / env / timeout onto a command string. The
1547
+ // command travels as an argv element, so nothing quotes or rewrites it.
1548
+ // Off the gate: `sandbox` is bound to the backend it was resolved on, so
1549
+ // this reads no process-wide state — and holding the gate for the whole
1550
+ // command would block every other backend in the process meanwhile.
1551
+ const output = await sandbox.execWith(this.shell, (builder) => {
1552
+ let configured = builder.args(["-c", options.command]);
1553
+ if (cwd !== undefined) {
1554
+ configured = configured.cwd(cwd);
1555
+ }
1556
+ if (hasEntries(options.env)) {
1557
+ configured = configured.envs(options.env);
1558
+ }
1559
+ if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
1560
+ configured = configured.timeout(options.timeoutMs);
1561
+ }
1562
+ return configured;
1563
+ });
1564
+ const stdout = output.stdout();
1565
+ const stderr = output.stderr();
1566
+ // A non-zero exit is a RESULT here, not an exception: the SDK resolves
1567
+ // `ExecOutput` with `code`/`success` rather than throwing.
1568
+ return {
1569
+ output: combineOutput(stdout, stderr),
1570
+ ...(stdout ? { stdout } : {}),
1571
+ ...(stderr ? { stderr } : {}),
1572
+ exitCode: typeof output.code === "number" ? output.code : null,
1573
+ };
1574
+ }
1575
+ /**
1576
+ * Submit one durable async run.
1577
+ *
1578
+ * The guest wrapper (see {@link MICROSANDBOX_RUN_ADMIT_SCRIPT}) claims the
1579
+ * session's run directory with an atomic `mkdir`, so this is idempotent in
1580
+ * the way that actually matters for an outcome-unknown submit: a retry of
1581
+ * the SAME command adopts the run that is already there (`reconciled: true`)
1582
+ * instead of starting a second one, and a retry with a DIFFERENT command is
1583
+ * refused instead of overwriting the first run's state.
1584
+ */
1585
+ async startScript(handle, options) {
1586
+ const sessionId = options.sessionId ?? `run-${handle.id}-${randomUUID()}`;
1587
+ // REFUSED BEFORE ANYTHING IS SUBMITTED, so a caller that asked for a
1588
+ // command budget it will not get is told before a process exists rather
1589
+ // than after one is running unbounded. See the error's own docs for why it
1590
+ // is refused instead of approximated.
1591
+ if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
1592
+ throw new MicrosandboxRunTimeoutUnsupportedError(sessionId, options.timeoutMs);
1593
+ }
1594
+ const sandbox = await this.requireSandbox(handle);
1595
+ const dir = this.scriptRunDir(sessionId);
1596
+ const cwd = options.cwd ?? handle.workdir;
1597
+ const output = await sandbox.execWith(this.shell, (builder) => {
1598
+ let configured = builder.args([
1599
+ "-c",
1600
+ MICROSANDBOX_RUN_ADMIT_SCRIPT,
1601
+ "msb-admit",
1602
+ options.command,
1603
+ dir,
1604
+ this.runStateDir,
1605
+ this.shell,
1606
+ GUEST_PROC_ROOT,
1607
+ ]);
1608
+ if (cwd !== undefined) {
1609
+ configured = configured.cwd(cwd);
1610
+ }
1611
+ if (hasEntries(options.env)) {
1612
+ configured = configured.envs(options.env);
1613
+ }
1614
+ // No `timeout(...)` here, and that is the point: the only timeout this
1615
+ // call could set is the SUBMIT call's, and the port's `timeoutMs` means
1616
+ // the COMMAND's lifetime. Setting it here would satisfy the type and
1617
+ // silently mean something else, so a `timeoutMs` is refused above
1618
+ // instead.
1619
+ return configured;
1620
+ });
1621
+ const marker = output.stdout().trim();
1622
+ if (marker.startsWith("ADMITTED ")) {
1623
+ return { sessionId, commandId: marker.slice("ADMITTED ".length).trim() };
1624
+ }
1625
+ if (marker.startsWith("CLAIMED ")) {
1626
+ return {
1627
+ sessionId,
1628
+ commandId: marker.slice("CLAIMED ".length).trim(),
1629
+ reconciled: true,
1630
+ };
1631
+ }
1632
+ if (marker === "CONFLICT") {
1633
+ throw new MicrosandboxSessionConflictError(sessionId);
1634
+ }
1635
+ throw new Error(`Microsandbox async run admission for session "${sessionId}" returned no verdict (exit ${output.code}): ${summarize(output.stderr() || marker)}`);
1636
+ }
1637
+ async getScriptStatus(handle, sessionId, commandId) {
1638
+ const sandbox = await this.requireSandbox(handle);
1639
+ const dir = this.scriptRunDir(sessionId);
1640
+ let marker;
1641
+ let probeExit;
1642
+ let probeStderr = "";
1643
+ try {
1644
+ const output = await sandbox.execWith(this.shell, (builder) => builder.args(["-c", MICROSANDBOX_RUN_STATUS_SCRIPT, "msb-status", dir, GUEST_PROC_ROOT]));
1645
+ marker = (output.stdout() ?? "").trim();
1646
+ probeExit = typeof output.code === "number" ? output.code : undefined;
1647
+ probeStderr = output.stderr() ?? "";
1648
+ }
1649
+ catch (error) {
1650
+ // A FAILED PROBE SAYS NOTHING ABOUT THE RUN, which is exactly why it can
1651
+ // no longer be reported as `{ exitCode: null }`. That value means "asked,
1652
+ // and it is still running" — a positive observation this call did not
1653
+ // make. A caller polling on it treats a broken transport as a healthy
1654
+ // long-running command and waits out an outcome that may already exist.
1655
+ throw new MicrosandboxStatusProbeError(sessionId, commandId, "transport", `the probe call failed: ${errorMessage(error)}`, error);
1656
+ }
1657
+ if (probeExit !== undefined && probeExit !== 0) {
1658
+ // The call was delivered but the probe script itself failed — an
1659
+ // unreadable run directory, a guest without `/bin/sh`. Same reasoning.
1660
+ throw new MicrosandboxStatusProbeError(sessionId, commandId, "transport", `the probe exited ${probeExit}: ${summarize(probeStderr || marker)}`);
1661
+ }
1662
+ if (marker.startsWith("EXIT ")) {
1663
+ const parsed = Number.parseInt(marker.slice("EXIT ".length).trim(), 10);
1664
+ if (Number.isFinite(parsed)) {
1665
+ return { exitCode: parsed };
1666
+ }
1667
+ // The wrapper finished and recorded something unreadable. Inventing a
1668
+ // code would be a lie and reporting "running" would poll forever.
1669
+ throw new MicrosandboxRunLostError(sessionId, commandId, "its recorded exit code is unreadable");
1670
+ }
1671
+ if (marker === "RUNNING") {
1672
+ return { exitCode: null };
1673
+ }
1674
+ if (marker === "UNKNOWN starttime-unreadable") {
1675
+ throw new MicrosandboxStatusProbeError(sessionId, commandId, "transport", "the run recorded a process start time, but its current start time could not be read from procfs");
1676
+ }
1677
+ if (marker === "MISSING") {
1678
+ throw new MicrosandboxRunLostError(sessionId, commandId, "its run-state directory is gone");
1679
+ }
1680
+ if (marker.startsWith("LOST")) {
1681
+ const reason = marker.slice("LOST".length).trim();
1682
+ throw new MicrosandboxRunLostError(sessionId, commandId, describeLostReason(reason));
1683
+ }
1684
+ // Unrecognized output is an unreadable probe, NOT a verdict — and least of
1685
+ // all the verdict "still running". Reporting one here is how a poll loop
1686
+ // runs forever against a guest whose probe is answering something this
1687
+ // protocol never defined.
1688
+ throw new MicrosandboxStatusProbeError(sessionId, commandId, "unrecognized", `the probe answered ${summarize(marker)}, which is not a verdict this protocol defines`);
1689
+ }
1690
+ async getScriptLogs(handle, sessionId, commandId) {
1691
+ const sandbox = await this.requireSandbox(handle);
1692
+ const outPath = `${this.scriptRunDir(sessionId)}/out`;
1693
+ const log = await this.readRunLog(sandbox, sessionId, outPath, SCRIPT_LOG_READ_MAX_BYTES);
1694
+ // exitCode stays null: `getScriptStatus` is the single source of truth for
1695
+ // the exit code, matching the Daytona, E2B and local adapters.
1696
+ return {
1697
+ output: log.output,
1698
+ exitCode: null,
1699
+ cmdId: commandId,
1700
+ // Present only when the read actually bounded something. An absent
1701
+ // `truncated` means the log is complete — never "unknown".
1702
+ ...(log.truncated ? { truncated: true } : {}),
1703
+ };
1704
+ }
1705
+ // --- bootstrap-plane async exec aliases ----------------------------------
1706
+ async startExec(handle, command, options = {}) {
1707
+ return this.startScript(handle, { command, ...options });
1708
+ }
1709
+ async getExecStatus(handle, sessionId, commandId) {
1710
+ return this.getScriptStatus(handle, sessionId, commandId);
1711
+ }
1712
+ /**
1713
+ * Terminal result of an async run: its captured output AND the exit code the
1714
+ * run actually recorded.
1715
+ *
1716
+ * The exit code comes from `getScriptStatus`, never from the log read.
1717
+ * `getScriptLogs` reports `exitCode: null` by design, and defaulting that to
1718
+ * `0` here would report every unfinished — and every lost — run as a success.
1719
+ */
1720
+ async getExecLogs(handle, sessionId, commandId) {
1721
+ const status = await this.getScriptStatus(handle, sessionId, commandId);
1722
+ if (status.exitCode === null) {
1723
+ throw new MicrosandboxRunNotFinishedError(sessionId, commandId);
1724
+ }
1725
+ const logs = await this.getScriptLogs(handle, sessionId, commandId);
1726
+ return {
1727
+ output: logs.output,
1728
+ exitCode: status.exitCode,
1729
+ // Carried through rather than dropped: the bootstrap plane's consumer is
1730
+ // the one that would otherwise read a tail as the whole output.
1731
+ ...(logs.truncated ? { truncated: true } : {}),
1732
+ };
1733
+ }
1734
+ // --- files ---------------------------------------------------------------
1735
+ /**
1736
+ * Put one file into the guest.
1737
+ *
1738
+ * A `string` source is a HOST PATH and a `Buffer` source is file CONTENT —
1739
+ * the same split the Daytona adapter uses, so this class's `uploadFile` and
1740
+ * `uploadBundle` cannot disagree with each other.
1741
+ */
1742
+ async uploadFile(handle, source, destination) {
1743
+ const sandbox = await this.requireSandbox(handle);
1744
+ const fs = sandbox.fs();
1745
+ await this.ensureParentDir(fs, destination);
1746
+ if (typeof source === "string") {
1747
+ await fs.copyFromHost(source, destination);
1748
+ return;
1749
+ }
1750
+ await fs.write(destination, toUint8Array(source));
1751
+ }
1752
+ async uploadBundle(handle, options) {
1753
+ for (const file of options.files) {
1754
+ await this.uploadFile(handle, file.source, file.destination);
1755
+ }
1756
+ }
1757
+ async downloadFile(handle, source, destination) {
1758
+ const sandbox = await this.requireSandbox(handle);
1759
+ const fs = sandbox.fs();
1760
+ if (destination) {
1761
+ await fs.copyToHost(source, destination);
1762
+ return;
1763
+ }
1764
+ const bytes = await fs.read(source);
1765
+ return Buffer.from(bytes);
1766
+ }
1767
+ async getHomeDir(handle) {
1768
+ // The guest home directory is a property of the image, which this package
1769
+ // does not choose, so it is injected rather than probed.
1770
+ return handle.homeDir ?? this.homeDir;
1771
+ }
1772
+ // --- lifecycle -----------------------------------------------------------
1773
+ //
1774
+ // Every method below changes remote state, so every one of them first asks
1775
+ // whether this runtime OWNS the sandbox. A sandbox is owned when this runtime
1776
+ // launched it, or when the caller said so explicitly via
1777
+ // `getById(id, { owned: true })`. Anything else was borrowed — a warm lease
1778
+ // found by label, a sandbox another process launched — and borrowing does not
1779
+ // confer the right to halt, boot, or delete it.
1780
+ async start(handle) {
1781
+ if (!this.isOwned(handle.id)) {
1782
+ // Not ours: booting someone else's stopped sandbox would charge them for
1783
+ // a microVM they chose to have stopped.
1784
+ return handle;
1785
+ }
1786
+ const entry = await this.lookupHandle(handle.id);
1787
+ if (!entry) {
1788
+ throw new Error(`Microsandbox sandbox "${handle.id}" is no longer available`);
1789
+ }
1790
+ const sandbox = await entry.start();
1791
+ this.register(handle.id, { sandbox });
1792
+ return { ...handle, state: "STARTED" };
1793
+ }
1794
+ async stop(handle) {
1795
+ if (!this.isOwned(handle.id)) {
1796
+ return;
1797
+ }
1798
+ const entry = await this.lookupHandle(handle.id);
1799
+ if (!entry) {
1800
+ // Already gone: stopping is idempotent, so this is success, not an error.
1801
+ return;
1802
+ }
1803
+ this.forgetInstance(handle.id);
1804
+ await entry.stop();
1805
+ }
1806
+ /**
1807
+ * Halt the sandbox AND drop its database record — but only if this runtime
1808
+ * owns it.
1809
+ *
1810
+ * Both halves of the teardown matter: the name is the identity, so leaving a
1811
+ * stopped record behind would make the next `launch` under that name collide.
1812
+ * The ownership check matters more: `destroy` is the one call here that
1813
+ * cannot be undone, and a lease-reattach path that resolved a borrowed
1814
+ * sandbox by name must not be able to delete it. An unowned (or unknown)
1815
+ * handle drops this runtime's local state and makes no remote call at all.
1816
+ */
1817
+ async destroy(handle) {
1818
+ if (!this.isOwned(handle.id)) {
1819
+ this.registry.delete(handle.id);
1820
+ return;
1821
+ }
1822
+ this.forgetInstance(handle.id);
1823
+ await this.forceDestroy(handle.id);
1824
+ // Dropped only after the remote teardown succeeded: keeping the ownership
1825
+ // record through a failure is what lets the caller retry it.
1826
+ this.registry.delete(handle.id);
1827
+ }
1828
+ /**
1829
+ * Stop-if-running + remove by name, with no ownership check.
1830
+ *
1831
+ * Private on purpose: the only callers are `destroy` (which has already
1832
+ * checked) and the reclamation of a create that landed after its deadline
1833
+ * (which is reclaiming a sandbox this runtime itself asked for).
1834
+ *
1835
+ * Backend-safe order — this is what makes the destroy path work on the
1836
+ * hosted cloud backend. `SandboxHandle.kill()` is not implemented there:
1837
+ * the SDK answers it with `UnsupportedError` (code `"unsupported"`,
1838
+ * message `"Sandbox::kill is not supported by this backend: use
1839
+ * Sandbox::stop"`), and the previous code re-threw that error before
1840
+ * `remove()` was ever reached, so every cloud destroy left a running
1841
+ * sandbox behind. `stop()` works on both backends, so the sequence is:
1842
+ *
1843
+ * 1. Try `kill()` — a stronger, faster teardown that local supports.
1844
+ * 2. If the backend answers `Unsupported`, fall back to `stop()` — the
1845
+ * call the hosted backend documents.
1846
+ * 3. Either way, `remove()` clears the record (which requires the
1847
+ * sandbox to be stopped, and cannot delete a still-running one).
1848
+ *
1849
+ * `already-stopped` and `not-found` on the first-step call are both fine
1850
+ * because they mean the same thing for the caller: the sandbox is
1851
+ * quiescent by the time `remove()` runs. Any other error is fatal to the
1852
+ * teardown and re-thrown, so the caller retains responsibility.
1853
+ */
1854
+ async forceDestroy(name) {
1855
+ const entry = await this.lookupHandle(name);
1856
+ if (!entry) {
1857
+ return;
1858
+ }
1859
+ try {
1860
+ await entry.kill();
1861
+ }
1862
+ catch (error) {
1863
+ if (isSandboxNotFound(error) || isAlreadyStopped(error)) {
1864
+ // Already quiescent; drop to remove.
1865
+ }
1866
+ else if (isUnsupportedByBackend(error)) {
1867
+ // Hosted backend path: kill is not implemented, so `stop` is the
1868
+ // documented equivalent. A `not-found`/`already-stopped` here is
1869
+ // still fine — same reasoning as the kill catch above. Any other
1870
+ // error means the sandbox is NOT quiescent and remove would fail
1871
+ // anyway, so we surface it.
1872
+ try {
1873
+ await entry.stop();
1874
+ }
1875
+ catch (stopError) {
1876
+ if (!isSandboxNotFound(stopError) && !isAlreadyStopped(stopError)) {
1877
+ throw stopError;
1878
+ }
1879
+ }
1880
+ }
1881
+ else {
1882
+ throw error;
1883
+ }
1884
+ }
1885
+ try {
1886
+ await entry.remove();
1887
+ }
1888
+ catch (error) {
1889
+ if (!isSandboxNotFound(error)) {
1890
+ throw error;
1891
+ }
1892
+ }
1893
+ }
1894
+ // --- internals -----------------------------------------------------------
1895
+ async sdk() {
1896
+ if (this.injectedSdk) {
1897
+ return this.injectedSdk;
1898
+ }
1899
+ if (!this.sdkPromise) {
1900
+ // Lazy — keeps `microsandbox` and its platform-specific native addon out
1901
+ // of every non-microsandbox consumer.
1902
+ const loading = import("microsandbox").then((mod) => mod, (error) => {
1903
+ // A cached rejection would fail every later call for the life of the
1904
+ // process, so the slot is cleared and the constraint that most often
1905
+ // explains the failure is stated instead of buried.
1906
+ if (this.sdkPromise === loading) {
1907
+ delete this.sdkPromise;
1908
+ }
1909
+ throw new Error("MicrosandboxRuntime could not load its optional peer dependency \"microsandbox\" (>=0.6.11 <0.7.0). "
1910
+ + "That package declares Node.js >= 22 and ships a platform-specific native addon "
1911
+ + "(macOS arm64, Linux x64/arm64, Windows x64/arm64); its local backend additionally requires "
1912
+ + `hardware virtualization (KVM, Apple Silicon, or WHP). Underlying error: ${errorMessage(error)}`, { cause: error });
1913
+ });
1914
+ this.sdkPromise = loading;
1915
+ }
1916
+ return this.sdkPromise;
1917
+ }
1918
+ /**
1919
+ * Run one DEFAULT-DEPENDENT SDK static with this runtime's backend in scope.
1920
+ *
1921
+ * `setDefaultBackend` is never called from this adapter: constructing a
1922
+ * runtime must not mutate the host process. The scoped form IS process-wide
1923
+ * while it is open, which is why every static goes through the module's
1924
+ * backend gate (see `withBackendScope`) rather than opening its own scope.
1925
+ *
1926
+ * ONLY the three statics this adapter calls belong here — `Sandbox.builder`
1927
+ * (through its terminal `create()`), `Sandbox.get` and `Sandbox.listWith`.
1928
+ * Operations on a resolved `Sandbox` or `SandboxHandle` are bound to the
1929
+ * backend they were resolved on and are issued directly, off the gate.
1930
+ */
1931
+ async withBackendStatic(fn, signal) {
1932
+ const sdk = await this.sdk();
1933
+ return withBackendScope(sdk, this.backend, () => fn(sdk), this.backendQueueTimeoutMs, signal);
1934
+ }
1935
+ async lookupHandle(name) {
1936
+ try {
1937
+ return (await this.withBackendStatic((sdk) => sdk.Sandbox.get(name))) ?? null;
1938
+ }
1939
+ catch (error) {
1940
+ if (isSandboxNotFound(error)) {
1941
+ return null;
1942
+ }
1943
+ throw error;
1944
+ }
1945
+ }
1946
+ register(name, patch) {
1947
+ const existing = this.registry.get(name);
1948
+ const sandbox = patch.sandbox ?? existing?.sandbox;
1949
+ const entry = {
1950
+ // Sticky: an attach that does not claim ownership cannot demote a
1951
+ // sandbox this runtime launched, because demoting it would leave a
1952
+ // microVM this process is responsible for with nothing able to reclaim
1953
+ // it.
1954
+ owned: (existing?.owned ?? false) || (patch.owned ?? false),
1955
+ ...(sandbox ? { sandbox } : {}),
1956
+ };
1957
+ this.registry.set(name, entry);
1958
+ return entry;
1959
+ }
1960
+ isOwned(name) {
1961
+ return this.registry.get(name)?.owned ?? false;
1962
+ }
1963
+ /** Drop the live connection but keep what this runtime knows about the name. */
1964
+ forgetInstance(name) {
1965
+ const existing = this.registry.get(name);
1966
+ if (!existing) {
1967
+ return;
1968
+ }
1969
+ this.registry.set(name, { owned: existing.owned });
1970
+ }
1971
+ async requireSandbox(handle) {
1972
+ const cached = this.registry.get(handle.id)?.sandbox;
1973
+ if (cached) {
1974
+ return cached;
1975
+ }
1976
+ const entry = await this.lookupHandle(handle.id);
1977
+ if (!entry) {
1978
+ throw new Error(`Microsandbox sandbox "${handle.id}" is no longer available`);
1979
+ }
1980
+ // `connect` attaches WITHOUT taking lifecycle ownership, so a poll tick
1981
+ // that reattaches cannot accidentally stop a sandbox it did not launch.
1982
+ const sandbox = await entry.connectWithTimeout(this.connectTimeoutMs);
1983
+ this.register(handle.id, { sandbox });
1984
+ return sandbox;
1985
+ }
1986
+ async ensureParentDir(fs, destination) {
1987
+ const parent = parentDir(destination);
1988
+ if (!parent) {
1989
+ return;
1990
+ }
1991
+ try {
1992
+ await fs.mkdir(parent);
1993
+ }
1994
+ catch {
1995
+ // Best effort: an already-present directory is the common case and must
1996
+ // not fail the upload that follows.
1997
+ }
1998
+ }
1999
+ /**
2000
+ * Read one run's captured output, bounded, without turning a failure into an
2001
+ * empty log.
2002
+ *
2003
+ * The read goes through {@link MICROSANDBOX_RUN_LOG_SCRIPT} rather than
2004
+ * `fs.readToString` for two reasons that both matter to the caller: an
2005
+ * ABSENT log is a success with no output (a run that has printed nothing has
2006
+ * one), and everything else — an unreadable file, a failed guest call — is a
2007
+ * failure. The previous form could not tell those apart, because it answered
2008
+ * `""` to all of them.
2009
+ *
2010
+ * One byte MORE than the cap is requested, so a longer log is detectable
2011
+ * rather than silently tailed: the extra byte is what turns "here is the
2012
+ * output" into "here is the last `maxBytes` of it".
2013
+ */
2014
+ async readRunLog(sandbox, sessionId, path, maxBytes) {
2015
+ let output;
2016
+ try {
2017
+ output = await sandbox.execWith(this.shell, (builder) => builder.args([
2018
+ "-c",
2019
+ MICROSANDBOX_RUN_LOG_SCRIPT,
2020
+ "msb-log",
2021
+ path,
2022
+ String(maxBytes + 1),
2023
+ ]));
2024
+ }
2025
+ catch (error) {
2026
+ throw new MicrosandboxLogReadError(sessionId, path, `the guest call failed: ${errorMessage(error)}`, error);
2027
+ }
2028
+ if (typeof output.code !== "number") {
2029
+ throw new MicrosandboxLogReadError(sessionId, path, `the read completed without an exit code, so its outcome is unknown: ${summarize(output.stderr() ?? "")}`);
2030
+ }
2031
+ if (output.code !== 0) {
2032
+ throw new MicrosandboxLogReadError(sessionId, path, `the read exited ${output.code}: ${summarize(output.stderr() ?? "")}`);
2033
+ }
2034
+ const text = output.stdout() ?? "";
2035
+ const bytes = Buffer.from(text, "utf8");
2036
+ if (bytes.byteLength <= maxBytes) {
2037
+ return { output: text, truncated: false };
2038
+ }
2039
+ // More than the cap came back, so the log is longer than what is being
2040
+ // returned. The caller is handed the TAIL and told it is one.
2041
+ return {
2042
+ output: bytes.subarray(bytes.byteLength - maxBytes).toString("utf8"),
2043
+ truncated: true,
2044
+ };
2045
+ }
2046
+ lookupDeadline(timeoutMs) {
2047
+ const requested = timeoutMs ?? this.lookupTimeoutMs;
2048
+ const normalized = Number.isFinite(requested) && requested > 0
2049
+ ? Math.max(1, Math.ceil(requested))
2050
+ : this.lookupTimeoutMs;
2051
+ return { endsAt: Date.now() + normalized, timeoutMs: normalized };
2052
+ }
2053
+ /**
2054
+ * Run `build` under the ONE overall deadline, cancelling its admission when
2055
+ * that deadline expires.
2056
+ *
2057
+ * Racing a timer against the operation is not enough on its own. The gate is
2058
+ * a queue, so a lookup that gives up while queued is still queued: it can be
2059
+ * admitted later and issue a static against the process default long after
2060
+ * the caller stopped waiting for it. The signal is what actually withdraws
2061
+ * it from the queue.
2062
+ */
2063
+ async awaitWithinCancelling(build, deadline, description) {
2064
+ const controller = new AbortController();
2065
+ try {
2066
+ return await this.awaitWithin(build(controller.signal), deadline, description);
2067
+ }
2068
+ catch (error) {
2069
+ controller.abort(error instanceof MicrosandboxLookupTimeoutError
2070
+ ? error
2071
+ : new MicrosandboxLookupTimeoutError(deadline.timeoutMs, description));
2072
+ throw error;
2073
+ }
2074
+ }
2075
+ async awaitWithin(operation, deadline, description) {
2076
+ const remainingMs = deadline.endsAt - Date.now();
2077
+ if (remainingMs <= 0) {
2078
+ throw new MicrosandboxLookupTimeoutError(deadline.timeoutMs, description);
2079
+ }
2080
+ let timer;
2081
+ try {
2082
+ return await Promise.race([
2083
+ operation,
2084
+ new Promise((_resolve, reject) => {
2085
+ timer = setTimeout(() => reject(new MicrosandboxLookupTimeoutError(deadline.timeoutMs, description)), remainingMs);
2086
+ }),
2087
+ ]);
2088
+ }
2089
+ finally {
2090
+ if (timer) {
2091
+ clearTimeout(timer);
2092
+ }
2093
+ }
2094
+ }
2095
+ /**
2096
+ * Guest directory holding one session's run state.
2097
+ *
2098
+ * The encoding is reversible, so two different session ids can never land on
2099
+ * one directory. A sanitizing replacement cannot promise that: it maps `a/b`
2100
+ * and `a_b` onto the same path, which is enough to hand one run's exit code
2101
+ * to the other.
2102
+ */
2103
+ scriptRunDir(sessionId) {
2104
+ return `${this.runStateDir}/${encodeRunSegment(sessionId)}`;
2105
+ }
2106
+ }
2107
+ // --- helpers ---------------------------------------------------------------
2108
+ /**
2109
+ * Normalize an SDK status onto the `STARTED`/`STOPPED` vocabulary the delivery
2110
+ * path reasons about.
2111
+ *
2112
+ * `draining` reads as STOPPED on purpose: a draining sandbox is on its way
2113
+ * down, so handing it back as a warm lease would hand a caller a sandbox that
2114
+ * is about to disappear underneath it.
2115
+ */
2116
+ function normalizeStatus(status) {
2117
+ return status === "running" ? "STARTED" : "STOPPED";
2118
+ }
2119
+ function matchesState(status, states) {
2120
+ if (states === null) {
2121
+ return true;
2122
+ }
2123
+ return states.includes(normalizeStatus(status));
2124
+ }
2125
+ function handleFromSandboxHandle(entry) {
2126
+ return {
2127
+ id: entry.name,
2128
+ state: normalizeStatus(entry.status),
2129
+ ...(entry.createdAt ? { createdAt: entry.createdAt.toISOString() } : {}),
2130
+ ...(entry.updatedAt ? { updatedAt: entry.updatedAt.toISOString() } : {}),
2131
+ };
2132
+ }
2133
+ function mergeLabels(labels, label) {
2134
+ return {
2135
+ ...(labels ?? {}),
2136
+ ...(label ? { label } : {}),
2137
+ };
2138
+ }
2139
+ function combineOutput(stdout, stderr) {
2140
+ if (stdout && stderr) {
2141
+ return stdout.endsWith("\n") || stderr.startsWith("\n")
2142
+ ? `${stdout}${stderr}`
2143
+ : `${stdout}\n${stderr}`;
2144
+ }
2145
+ return stdout || stderr || "";
2146
+ }
2147
+ /**
2148
+ * Reversible path-segment encoding for a session id.
2149
+ *
2150
+ * Every byte outside `[A-Za-z0-9_-]` becomes `%XX`, including `%` itself, so
2151
+ * distinct ids always produce distinct segments. An id long enough to threaten
2152
+ * the guest filesystem's 255-byte filename limit collapses to a digest instead;
2153
+ * the leading `.` cannot be produced by the encoder, so a digest segment can
2154
+ * never be confused with an encoded one.
2155
+ */
2156
+ function encodeRunSegment(sessionId) {
2157
+ const encoded = sessionId.replace(/[^A-Za-z0-9_-]/gu, (character) => [...Buffer.from(character, "utf8")]
2158
+ .map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`)
2159
+ .join(""));
2160
+ if (Buffer.byteLength(encoded, "utf8") <= MAX_RUN_SEGMENT_BYTES) {
2161
+ return encoded;
2162
+ }
2163
+ return `.${createHash("sha256").update(sessionId, "utf8").digest("hex")}`;
2164
+ }
2165
+ /** Exact when the SDK's typed error code is present; textual only as a fallback. */
2166
+ function isSandboxNotFound(error) {
2167
+ if (typeof error !== "object" || error === null) {
2168
+ return false;
2169
+ }
2170
+ const code = error.code;
2171
+ if (typeof code === "string") {
2172
+ // The SDK tags every error with a `MicrosandboxErrorCode`, so when a code is
2173
+ // present it is authoritative and no message sniffing happens at all.
2174
+ return code === "sandboxNotFound";
2175
+ }
2176
+ const name = error.name;
2177
+ if (name === "SandboxNotFoundError") {
2178
+ return true;
2179
+ }
2180
+ const message = error.message;
2181
+ return typeof message === "string" && /sandbox\s+not\s+found/i.test(message);
2182
+ }
2183
+ function isAlreadyStopped(error) {
2184
+ if (typeof error !== "object" || error === null) {
2185
+ return false;
2186
+ }
2187
+ const message = error.message;
2188
+ return typeof message === "string" && /already\s+stopped|not\s+running/i.test(message);
2189
+ }
2190
+ /**
2191
+ * True when the SDK signals the backend does not implement a lifecycle call
2192
+ * the adapter tried to use, e.g. `SandboxHandle.kill` on the hosted
2193
+ * (cloud) backend which throws `UnsupportedError` with code `"unsupported"`.
2194
+ *
2195
+ * Uses the SDK's typed code first (authoritative when present, per
2196
+ * `MicrosandboxErrorCode` in `errors.d.ts`), and only falls back to the
2197
+ * error name when a producer forwarded the exception without preserving the
2198
+ * code — the message text is deliberately not inspected because the
2199
+ * caller-visible sentence "use Sandbox::stop" is a formatting detail of one
2200
+ * SDK version, not an interoperable contract.
2201
+ */
2202
+ function isUnsupportedByBackend(error) {
2203
+ if (typeof error !== "object" || error === null) {
2204
+ return false;
2205
+ }
2206
+ const code = error.code;
2207
+ if (typeof code === "string") {
2208
+ return code === "unsupported" || code === "unsupportedOperation";
2209
+ }
2210
+ const name = error.name;
2211
+ return name === "UnsupportedError" || name === "UnsupportedOperationError";
2212
+ }
2213
+ /** Turn the status script's `LOST <reason>` token into something a human reads. */
2214
+ function describeLostReason(reason) {
2215
+ if (reason === "sandbox-restarted") {
2216
+ return "the sandbox restarted while it was running";
2217
+ }
2218
+ if (reason === "pid-reused") {
2219
+ return "its process is gone and the guest has since reused its pid for something else";
2220
+ }
2221
+ return "its process is gone and it never recorded an exit code";
2222
+ }
2223
+ function errorMessage(error) {
2224
+ return error instanceof Error ? error.message : String(error);
2225
+ }
2226
+ /** Bound a provider string before it is interpolated into an error message. */
2227
+ function summarize(value) {
2228
+ const trimmed = value.trim();
2229
+ return trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed || "(no output)";
2230
+ }
2231
+ async function withDeadline(promise, timeoutMs, sandboxName, controller) {
2232
+ let timer;
2233
+ try {
2234
+ return await Promise.race([
2235
+ promise,
2236
+ new Promise((_resolve, reject) => {
2237
+ timer = setTimeout(() => {
2238
+ const error = new MicrosandboxCreateTimeoutError(sandboxName, timeoutMs);
2239
+ // A caller still waiting for the backend gate has not issued any
2240
+ // provider work, so aborting withdraws it from admission entirely.
2241
+ // Once its callback started, AbortSignal intentionally cannot cancel
2242
+ // the provider promise; reclaimLateCreate keeps watching that path.
2243
+ controller?.abort(error);
2244
+ reject(error);
2245
+ }, timeoutMs);
2246
+ }),
2247
+ ]);
2248
+ }
2249
+ finally {
2250
+ if (timer) {
2251
+ clearTimeout(timer);
2252
+ }
2253
+ }
2254
+ }
2255
+ function parentDir(path) {
2256
+ const index = path.lastIndexOf("/");
2257
+ if (index <= 0) {
2258
+ return null;
2259
+ }
2260
+ return path.slice(0, index);
2261
+ }
2262
+ function toUint8Array(buffer) {
2263
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
2264
+ }
2265
+ /**
2266
+ * A page-request size the provider can actually be asked for, or `undefined`
2267
+ * when the caller's value is not one.
2268
+ *
2269
+ * `limit: 0` and `limit: -1` are not page sizes; sending either would ask the
2270
+ * backend to interpret them, and the two mainstream interpretations —
2271
+ * "everything" and "nothing" — are opposite. The caller's configured default
2272
+ * is used instead, and the RESULT cap (a separate concern, resolved by the
2273
+ * caller of this helper) is what honours a zero.
2274
+ */
2275
+ /**
2276
+ * Normalize a caller's RESULT cap onto `collectByLabels`'s `cap` option.
2277
+ *
2278
+ * Returns a spreadable fragment so "no cap" is the absence of the key rather
2279
+ * than a sentinel. The three edges are decided, not accidental:
2280
+ * - `undefined` — no cap; drain the whole listing.
2281
+ * - `Infinity` — no cap; it is the explicit spelling of the same thing.
2282
+ * - anything else, INCLUDING `NaN`, negatives and fractions — floored onto a
2283
+ * non-negative integer, with `NaN` becoming `0`. A cap nobody can interpret
2284
+ * resolves to "return nothing", never to "return everything": the first is
2285
+ * visibly wrong to the caller, the second silently drains a listing it asked
2286
+ * to bound.
2287
+ */
2288
+ function resultCap(requested) {
2289
+ if (requested === undefined || requested === Number.POSITIVE_INFINITY) {
2290
+ return {};
2291
+ }
2292
+ return { cap: Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : 0 };
2293
+ }
2294
+ function positivePageSize(requested) {
2295
+ if (requested === undefined || !Number.isFinite(requested) || requested < 1) {
2296
+ return undefined;
2297
+ }
2298
+ return Math.floor(requested);
2299
+ }
2300
+ function hasEntries(record) {
2301
+ return !!record && Object.keys(record).length > 0;
2302
+ }
2303
+ //# sourceMappingURL=runtime.js.map