@evolvingmachines/modal 0.0.55 → 0.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.cjs +4 -4
- package/dist/index.d.cts +862 -6
- package/dist/index.d.ts +862 -6
- package/dist/index.js +4 -4
- package/package.json +5 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
+
import { Sandbox } from 'modal';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GENERATED — DO NOT EDIT. `npm run generate:image-version` (repo root)
|
|
5
|
+
* rewrites this file from the evolve-all image build inputs under
|
|
6
|
+
* assets/docker/ (see assets/docker/image-digest.ts for the derivation).
|
|
7
|
+
* The value is content-addressed: same inputs → same tag, any input
|
|
8
|
+
* change → a new tag. The coherence test in
|
|
9
|
+
* packages/daytona/tests/unit/daytona-image-version.test.ts fails the
|
|
10
|
+
* suite whenever this checked-in copy is stale.
|
|
11
|
+
*/
|
|
12
|
+
declare const EVOLVE_IMAGE_VERSION = "c-880c264ce574";
|
|
13
|
+
|
|
1
14
|
/**
|
|
2
15
|
* Modal Sandbox Provider - Clean Architecture
|
|
3
16
|
*
|
|
4
|
-
* @requires modal >= 0.
|
|
17
|
+
* @requires modal >= 0.9.0
|
|
5
18
|
* @requires Node.js >= 18 (for ReadableStream support)
|
|
6
19
|
*
|
|
7
20
|
* Design principles:
|
|
@@ -13,9 +26,322 @@
|
|
|
13
26
|
*
|
|
14
27
|
* Modal-specific notes:
|
|
15
28
|
* - No native file APIs - uses exec() with stdin/stdout
|
|
16
|
-
* -
|
|
29
|
+
* - Named Volumes mount at create (`volumes`, keyed by in-box path, read-only
|
|
30
|
+
* or read-write) — Modal's "upload once, read from many sandboxes" store.
|
|
31
|
+
* modal@0.9.0 has no upload-to-Volume call, so a Volume is filled from
|
|
32
|
+
* INSIDE a sandbox that mounts it read-write (background commits while it
|
|
33
|
+
* runs, a final commit when it exits); this package adds none either
|
|
34
|
+
* - pause() not supported - throws error (use Evolve checkpoints for persistence)
|
|
17
35
|
* - Requires app context for sandbox creation
|
|
36
|
+
* - Hard 24h sandbox lifetime cap (ModalSandboxLifetimeError when exceeded)
|
|
37
|
+
* - Everything executes as root inside the sandbox; the `user` option is
|
|
38
|
+
* enforced through an `su <user> -c` wrapper (default user: "user")
|
|
39
|
+
* - Network policy maps to Modal's blockNetwork / outboundDomainAllowlist /
|
|
40
|
+
* outboundCidrAllowlist (domain allowlist admits TLS on port 443 only —
|
|
41
|
+
* plaintext destinations must be listed as IPs/CIDRs)
|
|
42
|
+
* - Modal exposes no metadata or public timestamps on sandboxes; both are
|
|
43
|
+
* stamped into sandbox tags at create time and read back via getTags()
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Modal's hard cap on sandbox lifetime (24 hours).
|
|
48
|
+
* Requests beyond this throw ModalSandboxLifetimeError.
|
|
49
|
+
*/
|
|
50
|
+
declare const MODAL_MAX_LIFETIME_MS: number;
|
|
51
|
+
/**
|
|
52
|
+
* Chunk size for stdin uploads, and the reason it is THIS big.
|
|
53
|
+
*
|
|
54
|
+
* Every writeBytes() call is one awaited unary TaskExecStdinWrite round trip
|
|
55
|
+
* to Modal, so upload time is set by how many messages a payload becomes, not
|
|
56
|
+
* by how many bytes it is. Measured on live sandboxes with a 180MiB file:
|
|
57
|
+
*
|
|
58
|
+
* 64KiB 311.6s (2880 messages — Node's default read size)
|
|
59
|
+
* 4MiB 36.2s
|
|
60
|
+
* 8MiB 30.1s <- this constant
|
|
61
|
+
* 16MiB 31.4s
|
|
62
|
+
*
|
|
63
|
+
* Below roughly 4MiB the upload is round-trip bound and shrinking the chunk
|
|
64
|
+
* makes it dramatically slower; above it the transport saturates near 6MiB/s
|
|
65
|
+
* and a bigger chunk buys nothing.
|
|
66
|
+
*
|
|
67
|
+
* Modal's 100MiB per-message cap (RESOURCE_EXHAUSTED at 104,857,600 bytes) is
|
|
68
|
+
* the CEILING this must stay under — it is not the reason for the value. Do
|
|
69
|
+
* not "play it safe" by trimming this toward the cap-satisfying end: 64KiB is
|
|
70
|
+
* equally cap-safe and ten times slower, which is exactly the state this
|
|
71
|
+
* constant was raised to fix.
|
|
72
|
+
*
|
|
73
|
+
* STANDING DECISION (chunk size stays 8MiB) and its trigger: a worker running
|
|
74
|
+
* 16 concurrent uploads buffers about 256MiB at this size, which is real
|
|
75
|
+
* pressure in a 1GB worker. The re-profile's saturation row runs exactly that
|
|
76
|
+
* 16-parallel case; the ruling is to move on ITS data, not on precaution. If
|
|
77
|
+
* that row shows memory pressure, dropping to 4MiB is a measured one-line
|
|
78
|
+
* change — 36.2s versus 30.1s, about 83% of the throughput for half the
|
|
79
|
+
* buffer — and everything above still holds.
|
|
80
|
+
*/
|
|
81
|
+
declare const MODAL_STDIN_CHUNK_BYTES: number;
|
|
82
|
+
/**
|
|
83
|
+
* Typed error for Modal's hard 24h sandbox lifetime cap.
|
|
84
|
+
* Long-running sessions must persist progress with Evolve checkpoints and
|
|
85
|
+
* resume in a fresh sandbox instead of extending the timeout.
|
|
86
|
+
*/
|
|
87
|
+
declare class ModalSandboxLifetimeError extends Error {
|
|
88
|
+
readonly requestedTimeoutMs: number;
|
|
89
|
+
constructor(requestedTimeoutMs: number);
|
|
90
|
+
}
|
|
91
|
+
/** Throws ModalSandboxLifetimeError when the timeout exceeds Modal's 24h cap. */
|
|
92
|
+
declare function validateTimeout(timeoutMs: number): void;
|
|
93
|
+
/**
|
|
94
|
+
* Typed error for an idle timeout Modal could not act on. Both bounds are
|
|
95
|
+
* refusals rather than clamps: silently raising a zero, or lowering a value past
|
|
96
|
+
* the lifetime cap, would hand back a box that dies on a schedule the caller
|
|
97
|
+
* never asked for.
|
|
98
|
+
*/
|
|
99
|
+
declare class ModalIdleTimeoutError extends Error {
|
|
100
|
+
readonly requestedIdleTimeoutMs: number;
|
|
101
|
+
constructor(requestedIdleTimeoutMs: number, reason: string);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Evolve's idle bound -> Modal's create params, same shape as mapNetworkPolicy
|
|
105
|
+
* and mapResources: provider-neutral option in, Modal fragment out.
|
|
106
|
+
*
|
|
107
|
+
* ABSENT MEANS ABSENT. Modal's own default is no idle timer at all, so an unset
|
|
108
|
+
* option must spread to nothing — inventing a default here would start killing
|
|
109
|
+
* boxes that today live out their lifetime, for every caller who never asked.
|
|
110
|
+
*
|
|
111
|
+
* An idle timeout has to be a positive span, and one above the 24h lifetime cap
|
|
112
|
+
* can never fire because the sandbox is already gone. Both are caller mistakes,
|
|
113
|
+
* and both throw rather than clamp: silently raising a zero or lowering an
|
|
114
|
+
* over-cap value hands back a box that dies on a schedule nobody chose.
|
|
115
|
+
*/
|
|
116
|
+
declare function mapIdleTimeout(idleTimeoutMs?: number): {
|
|
117
|
+
idleTimeoutMs?: number;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Typed error for a boot command Modal could not act on as given. A refusal
|
|
121
|
+
* rather than a repair, same law as the idle bound above: the one shape
|
|
122
|
+
* rejected (an empty argv) is indistinguishable on the wire from "no args",
|
|
123
|
+
* which boots the image's own ENTRYPOINT/CMD — exactly what an explicit
|
|
124
|
+
* bootCommand exists to displace.
|
|
125
|
+
*/
|
|
126
|
+
declare class ModalBootCommandError extends Error {
|
|
127
|
+
constructor(reason: string);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Evolve's boot command -> Modal's create params, same shape as
|
|
131
|
+
* mapIdleTimeout and mapResources: provider-neutral option in, Modal
|
|
132
|
+
* fragment out.
|
|
133
|
+
*
|
|
134
|
+
* ABSENT MEANS ABSENT: with no bootCommand the create carries no `command`,
|
|
135
|
+
* and the image's own ENTRYPOINT/CMD boots as the sandbox main process —
|
|
136
|
+
* Modal's documented default and this provider's behavior since its first
|
|
137
|
+
* release; inventing a keep-alive here would silently change what every
|
|
138
|
+
* existing caller's box runs.
|
|
139
|
+
*
|
|
140
|
+
* An EMPTY argv is refused, never forwarded: Modal reads empty args as "no
|
|
141
|
+
* args" (the wire shape is the same `entrypointArgs: []` an omitted command
|
|
142
|
+
* produces), so `bootCommand: []` would boot the entrypoint while looking
|
|
143
|
+
* like an override — omit the option to boot the image's command.
|
|
144
|
+
*/
|
|
145
|
+
declare function mapBootCommand(bootCommand?: readonly string[]): {
|
|
146
|
+
command?: string[];
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* One named Modal Volume to mount at sandbox creation — the value of the
|
|
150
|
+
* `volumes` create option, keyed by its absolute in-box mount path.
|
|
151
|
+
*/
|
|
152
|
+
interface ModalVolumeMount {
|
|
153
|
+
/** The Volume's name in the Modal workspace (client.volumes.fromName). */
|
|
154
|
+
name: string;
|
|
155
|
+
/**
|
|
156
|
+
* Mount read-only (default false). Enforced by Modal inside the box: a
|
|
157
|
+
* write on a read-only mount answers EROFS ("Read-only file system").
|
|
158
|
+
*/
|
|
159
|
+
readOnly?: boolean;
|
|
160
|
+
/**
|
|
161
|
+
* Create the Volume when no Volume of that name exists (default false —
|
|
162
|
+
* a missing Volume is a typed refusal, ModalVolumeError "not-found").
|
|
163
|
+
*/
|
|
164
|
+
createIfMissing?: boolean;
|
|
165
|
+
}
|
|
166
|
+
/** Why a Volume mount could not be honored. */
|
|
167
|
+
type ModalVolumeErrorReason =
|
|
168
|
+
/** The mount path is not an absolute in-box path (or is "/"). Refused offline. */
|
|
169
|
+
"invalid-mount-path"
|
|
170
|
+
/** The Volume name is blank. Refused offline. */
|
|
171
|
+
| "invalid-name"
|
|
172
|
+
/** No Volume of that name exists and createIfMissing was not set. */
|
|
173
|
+
| "not-found"
|
|
174
|
+
/** Modal could not resolve the Volume for another reason (its words in the message). */
|
|
175
|
+
| "resolve-failed";
|
|
176
|
+
/**
|
|
177
|
+
* Typed error for a `volumes` entry this provider cannot mount. Thrown by
|
|
178
|
+
* create() — the two offline reasons before any network call, the two
|
|
179
|
+
* resolve reasons before any sandbox exists, so a refused mount never leaves
|
|
180
|
+
* a box behind. `cause` carries Modal's own error on the resolve reasons.
|
|
181
|
+
*/
|
|
182
|
+
declare class ModalVolumeError extends Error {
|
|
183
|
+
readonly reason: ModalVolumeErrorReason;
|
|
184
|
+
readonly mountPath: string;
|
|
185
|
+
readonly volumeName: string;
|
|
186
|
+
constructor(reason: ModalVolumeErrorReason, mountPath: string, volumeName: string, message: string, cause?: unknown);
|
|
187
|
+
}
|
|
188
|
+
/** One validated Volume mount, ready to resolve. */
|
|
189
|
+
interface ResolvedVolumeMount {
|
|
190
|
+
mountPath: string;
|
|
191
|
+
name: string;
|
|
192
|
+
readOnly: boolean;
|
|
193
|
+
createIfMissing: boolean;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Evolve's `volumes` option -> the list create() resolves, same shape as
|
|
197
|
+
* mapIdleTimeout and mapBootCommand: option in, validated fragment out, and
|
|
198
|
+
* ABSENT MEANS ABSENT — no option (or an empty record) yields no mounts and
|
|
199
|
+
* the create call carries no `volumes` key at all.
|
|
200
|
+
*
|
|
201
|
+
* Offline refusals, before any app/image/Volume round trip: a mount path
|
|
202
|
+
* that is not absolute (Modal mounts by absolute path; a relative one has
|
|
203
|
+
* no meaning) or is "/" (mounting over the root filesystem), and a blank
|
|
204
|
+
* Volume name (fromName would answer with a confusing not-found).
|
|
205
|
+
*/
|
|
206
|
+
declare function mapVolumeMounts(volumes?: Record<string, ModalVolumeMount>): ResolvedVolumeMount[];
|
|
207
|
+
/**
|
|
208
|
+
* Wrap a command with cwd + env handling and (when not root) an
|
|
209
|
+
* `su <user> -c` wrapper.
|
|
210
|
+
*
|
|
211
|
+
* Modal sandboxes run as root by default (ignoring the Dockerfile USER
|
|
212
|
+
* directive), but Claude CLI and other tools refuse certain operations when
|
|
213
|
+
* running as root.
|
|
214
|
+
*
|
|
215
|
+
* Uses `su <user> -c` instead of `sudo -u <user>` because Claude CLI's
|
|
216
|
+
* --dangerously-skip-permissions flag refuses to run when it detects sudo.
|
|
217
|
+
*
|
|
218
|
+
* Uses base64 encoding to avoid shell escaping issues with complex commands
|
|
219
|
+
* that contain quotes, special characters, etc. Env vars are inlined because
|
|
220
|
+
* su does not preserve the environment the way `sudo -E` does.
|
|
221
|
+
*/
|
|
222
|
+
declare function wrapCommand(command: string, user: string, cwd?: string, envs?: Record<string, string>): string[];
|
|
223
|
+
/**
|
|
224
|
+
* Typed error for sizing requests Modal's create() cannot enforce.
|
|
225
|
+
* The installed Modal JS SDK sizes cpu (cores) and memoryMiB at create time
|
|
226
|
+
* only — there is no disk-size parameter, so a requested disk size would be
|
|
227
|
+
* silently ignored. Per the provider law (reject what you cannot enforce,
|
|
228
|
+
* never silently ignore) it is refused loudly here.
|
|
229
|
+
*/
|
|
230
|
+
declare class ModalResourcesError extends Error {
|
|
231
|
+
constructor(message: string);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Map Evolve's provider-neutral resources (cpu cores, memory GiB, disk GiB)
|
|
235
|
+
* onto Modal's create() params (cpu cores, memoryMiB). Fractional GiB rounds
|
|
236
|
+
* UP so the sandbox never gets less memory than requested. `disk` throws
|
|
237
|
+
* ModalResourcesError — the SDK cannot express it.
|
|
238
|
+
*/
|
|
239
|
+
/**
|
|
240
|
+
* Structural twin of SandboxCreateOptions["resources"] — spelled locally
|
|
241
|
+
* (rather than indexed off the SDK type) so this package can build against
|
|
242
|
+
* an SDK whose published type predates the GPU fields; the provider-parity
|
|
243
|
+
* conformance file still pins the two to each other.
|
|
244
|
+
*/
|
|
245
|
+
type ModalCreateResources = {
|
|
246
|
+
cpu?: number;
|
|
247
|
+
memory?: number;
|
|
248
|
+
disk?: number;
|
|
249
|
+
gpu?: number;
|
|
250
|
+
gpuTypes?: string[];
|
|
251
|
+
};
|
|
252
|
+
declare function mapResources(resources?: ModalCreateResources): {
|
|
253
|
+
cpu: number;
|
|
254
|
+
memoryMiB: number;
|
|
255
|
+
gpu?: string;
|
|
256
|
+
};
|
|
257
|
+
/** Modal create() params derived from Evolve's provider-neutral network policy. */
|
|
258
|
+
interface ModalNetworkCreateParams {
|
|
259
|
+
blockNetwork?: boolean;
|
|
260
|
+
outboundCidrAllowlist?: string[];
|
|
261
|
+
outboundDomainAllowlist?: string[];
|
|
262
|
+
}
|
|
263
|
+
/** Why a network destination cannot be mapped onto Modal's allowlist. */
|
|
264
|
+
type ModalNetworkPolicyReason = "port-unsupported" | "invalid-ipv4";
|
|
265
|
+
/**
|
|
266
|
+
* Typed error for destinations Modal's allowlist cannot express.
|
|
267
|
+
*
|
|
268
|
+
* Modal's allowlist filters hosts (domain allowlist) and IPs/CIDRs (CIDR
|
|
269
|
+
* allowlist) only — it has no notion of a port, and an invalid IPv4/CIDR
|
|
270
|
+
* would be silently forwarded to the API. Both are rejected loudly here
|
|
271
|
+
* instead of weakening or mangling the sandbox's egress policy.
|
|
18
272
|
*/
|
|
273
|
+
declare class ModalNetworkPolicyError extends Error {
|
|
274
|
+
readonly reason: ModalNetworkPolicyReason;
|
|
275
|
+
/** The offending destination. */
|
|
276
|
+
readonly destination?: string;
|
|
277
|
+
constructor(reason: ModalNetworkPolicyReason, message: string, destination?: string);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Map Evolve's provider-neutral network policy onto Modal create() params.
|
|
281
|
+
*
|
|
282
|
+
* - outbound "open" (or no policy) → no restrictions
|
|
283
|
+
* - outbound "blocked", no allowlist → blockNetwork: true (drops all egress)
|
|
284
|
+
* - outbound "blocked" with allowlist → outboundDomainAllowlist (hostnames,
|
|
285
|
+
* wildcards like "*.example.com") + outboundCidrAllowlist (IPs/CIDRs; bare
|
|
286
|
+
* IPs get /32 or /128 appended). Both lists are always set because Modal
|
|
287
|
+
* treats an unset list as "allow all" — an empty array means "allow none"
|
|
288
|
+
* for that class of destination. Note: Modal's domain allowlist only admits
|
|
289
|
+
* TLS traffic on port 443; plaintext destinations must be listed as CIDRs.
|
|
290
|
+
*/
|
|
291
|
+
declare function mapNetworkPolicy(network?: SandboxCreateOptions["network"]): ModalNetworkCreateParams;
|
|
292
|
+
/**
|
|
293
|
+
* The same policy expressed the way Modal's RUNTIME switch takes it: both
|
|
294
|
+
* allowlists, always.
|
|
295
|
+
*
|
|
296
|
+
* Two independent reasons, and the second outlives the first. Modal requires
|
|
297
|
+
* both today — "Both `outboundCidrAllowlist` and `outboundDomainAllowlist`
|
|
298
|
+
* must be provided" (modal@0.9.0 index.d.ts:8040) — but that is documented as
|
|
299
|
+
* temporary ("This requirement will be relaxed in a future release",
|
|
300
|
+
* index.d.ts:7917-7919). What does not change is the meaning of leaving one
|
|
301
|
+
* out: "`undefined` leaves that dimension unchanged, while a defined value
|
|
302
|
+
* replaces it" (index.d.ts:7913-7915). An omitted list therefore CARRIES OVER
|
|
303
|
+
* whatever the box already had — which for a switch whose whole contract is
|
|
304
|
+
* "replace the policy" is precisely the silent-widening bug: the dimension the
|
|
305
|
+
* new policy never mentions would keep the old policy's allowances. Stating
|
|
306
|
+
* both is what makes the switch a true replace, and it stays correct when
|
|
307
|
+
* partial updates become legal.
|
|
308
|
+
*
|
|
309
|
+
* `blockNetwork` has no runtime form at all, which is why a sealed policy
|
|
310
|
+
* becomes empty lists here: "an empty array blocks all egress for that
|
|
311
|
+
* dimension" (index.d.ts:7913-7915).
|
|
312
|
+
*
|
|
313
|
+
* Built on mapNetworkPolicy so the classification of a destination — what
|
|
314
|
+
* counts as a CIDR, what is rejected for carrying a port — is the SAME code
|
|
315
|
+
* the create path uses. A second copy of that logic is how an update ends up
|
|
316
|
+
* admitting what the create refused.
|
|
317
|
+
*
|
|
318
|
+
* Upstream: harbor modal.py:1236-1249 (`_dynamic_network_kwargs`).
|
|
319
|
+
*/
|
|
320
|
+
declare function dynamicNetworkPolicyParams(network?: SandboxCreateOptions["network"]): {
|
|
321
|
+
outboundCidrAllowlist: string[];
|
|
322
|
+
outboundDomainAllowlist: string[];
|
|
323
|
+
};
|
|
324
|
+
/**
|
|
325
|
+
* Whether this box must be created in the switchable shape: true when any
|
|
326
|
+
* declared phase policy differs from the boot policy. Order and duplicate
|
|
327
|
+
* destinations are not meaning (Modal applies a set), so they are normalized
|
|
328
|
+
* away before comparing — otherwise a caller listing the same hosts in a
|
|
329
|
+
* different order would arm dynamic mode for no reason.
|
|
330
|
+
*
|
|
331
|
+
* Upstream: harbor modal.py:1040-1047 (`_requires_dynamic_network`).
|
|
332
|
+
*/
|
|
333
|
+
declare function requiresDynamicNetwork(network: SandboxCreateOptions["network"], phases: SandboxCreateOptions["phaseNetworkPolicies"]): boolean;
|
|
334
|
+
/** Container registry family for an image tag. */
|
|
335
|
+
type ImageRegistry = "aws-ecr" | "gcp-artifact-registry" | "registry";
|
|
336
|
+
/** Detect which Modal image constructor an image tag needs. */
|
|
337
|
+
declare function resolveImageRegistry(tag: string): ImageRegistry;
|
|
338
|
+
/**
|
|
339
|
+
* Build a SandboxInfo from a sandbox's tags. Modal exposes no metadata or
|
|
340
|
+
* public timestamps, so image and startedAt come from the tags stamped at
|
|
341
|
+
* create time; for sandboxes not created by this SDK they are empty strings
|
|
342
|
+
* (never fabricated). endAt is always undefined — Modal does not expose it.
|
|
343
|
+
*/
|
|
344
|
+
declare function buildSandboxInfo(sandboxId: string, tags: Record<string, string>, fallbackImage?: string): SandboxInfo;
|
|
19
345
|
/** Result of a completed sandbox command */
|
|
20
346
|
interface SandboxCommandResult {
|
|
21
347
|
exitCode: number;
|
|
@@ -93,15 +419,159 @@ interface SandboxCreateOptions {
|
|
|
93
419
|
image?: string;
|
|
94
420
|
envs?: Record<string, string>;
|
|
95
421
|
metadata?: Record<string, string>;
|
|
422
|
+
/** Sandbox lifetime in ms. Modal hard-caps lifetime at 24h (MODAL_MAX_LIFETIME_MS). */
|
|
96
423
|
timeoutMs?: number;
|
|
424
|
+
/**
|
|
425
|
+
* Terminate the sandbox after this long with nothing running in it — the
|
|
426
|
+
* bound that reclaims a box whose client died, without waiting out the whole
|
|
427
|
+
* lifetime. Modal is the only provider with both clocks.
|
|
428
|
+
*
|
|
429
|
+
* OMITTED BY DEFAULT: Modal runs no idle timer unless asked. Modal counts a
|
|
430
|
+
* sandbox active while an exec is running, while its stdin is being written,
|
|
431
|
+
* or while a tunnel connection is open — file operations are not named in
|
|
432
|
+
* that list, and this adapter is safe only because it routes reads and writes
|
|
433
|
+
* through exec (`cat` / `cat >`). A future native filesystem path would need
|
|
434
|
+
* this re-checked.
|
|
435
|
+
*/
|
|
436
|
+
idleTimeoutMs?: number;
|
|
437
|
+
/**
|
|
438
|
+
* The argv the sandbox boots as its MAIN PROCESS, replacing the image's own
|
|
439
|
+
* ENTRYPOINT/CMD. Forwarded to Modal's Sandbox.create args — documented as
|
|
440
|
+
* "Set the CMD of the Sandbox, overriding any CMD of the container image"
|
|
441
|
+
* (modal.com/docs/reference/modal.Sandbox), and the override is TOTAL in
|
|
442
|
+
* operation, entrypoint included: Harbor's modal backend documents its
|
|
443
|
+
* forwarded args as overriding "the image's ENTRYPOINT/CMD" and must pass
|
|
444
|
+
* None "to inherit the image's command (e.g. dockerd-entrypoint.sh for the
|
|
445
|
+
* DinD image)" (harbor modal.py:1151-1155) — with args present that DinD
|
|
446
|
+
* entrypoint would not run at all.
|
|
447
|
+
*
|
|
448
|
+
* OMITTED, the image's own ENTRYPOINT/CMD boots as the main process, and a
|
|
449
|
+
* start program that exits takes the sandbox down with it (harbor
|
|
450
|
+
* modal.py:285-291). A caller that needs an INERT boot — the box held open
|
|
451
|
+
* while every process it runs arrives by exec, so whoever execs the image's
|
|
452
|
+
* start program is its ONLY launcher — passes a keep-alive here (Harbor's
|
|
453
|
+
* own is ["sh", "-c", "sleep infinity"]).
|
|
454
|
+
*
|
|
455
|
+
* An empty argv is REJECTED (ModalBootCommandError): on the wire it is the
|
|
456
|
+
* same "no args" an omitted option sends, so it would boot the entrypoint
|
|
457
|
+
* while reading like an override.
|
|
458
|
+
*/
|
|
459
|
+
bootCommand?: string[];
|
|
97
460
|
workingDirectory?: string;
|
|
461
|
+
/**
|
|
462
|
+
* Per-sandbox compute sizing: cpu in cores, memory in GiB — mapped to
|
|
463
|
+
* Modal's create-time cpu / memoryMiB requests (defaults when omitted:
|
|
464
|
+
* 4 cores / 4 GiB). `disk` is REJECTED with ModalResourcesError: the Modal
|
|
465
|
+
* JS SDK exposes no disk-size parameter, so a specific disk size cannot be
|
|
466
|
+
* enforced (containers get Modal's default disk quota).
|
|
467
|
+
*
|
|
468
|
+
* `gpu` + `gpuTypes` become Modal's "<TYPE>:<count>" GPU reservation
|
|
469
|
+
* ('any' when no types are named; the FIRST type when several — Modal
|
|
470
|
+
* reserves one type per sandbox; the type passes through verbatim and an
|
|
471
|
+
* unknown one gets Modal's own typed rejection at create).
|
|
472
|
+
*/
|
|
473
|
+
resources?: ModalCreateResources;
|
|
474
|
+
/**
|
|
475
|
+
* Provider-neutral outbound network policy, enforced by Modal's network
|
|
476
|
+
* stack. "blocked" with no allowedDestinations drops all egress; with
|
|
477
|
+
* allowedDestinations, hostnames go to Modal's domain allowlist (TLS/443
|
|
478
|
+
* only) and IPs/CIDRs to the CIDR allowlist.
|
|
479
|
+
*/
|
|
480
|
+
network?: {
|
|
481
|
+
outbound: "open" | "blocked";
|
|
482
|
+
allowedDestinations?: string[];
|
|
483
|
+
};
|
|
484
|
+
/**
|
|
485
|
+
* Every policy `updateNetwork()` may later be asked for on this box.
|
|
486
|
+
*
|
|
487
|
+
* LOAD-BEARING ON MODAL, unlike on the other providers. Modal's create call
|
|
488
|
+
* takes EITHER `blockNetwork: true` OR the two allowlists — each allowlist
|
|
489
|
+
* field is documented "Cannot be used with blockNetwork" (modal@0.9.0
|
|
490
|
+
* index.d.ts:7682-7686) — so the blunt `blockNetwork: true` box this adapter
|
|
491
|
+
* builds for a sealed policy has no allowlist to widen later. When a phase
|
|
492
|
+
* policy here differs from `network`, the adapter creates the box in the
|
|
493
|
+
* switchable shape instead: an empty `outboundCidrAllowlist` — "an empty
|
|
494
|
+
* array blocks all egress for that dimension" (index.d.ts:7913-7915) — plus
|
|
495
|
+
* a domain allowlist holding only the unresolvable sentinel, never an empty
|
|
496
|
+
* one (see withDomainFilteringEnabled: an empty domain list leaves Modal's
|
|
497
|
+
* domain filter switched OFF and unswitchable). Same zero egress, still
|
|
498
|
+
* switchable.
|
|
499
|
+
*
|
|
500
|
+
* Upstream: harbor modal.py:1040-1047 (`_requires_dynamic_network`) and
|
|
501
|
+
* :1169-1171 (`if self._dynamic_network: block_network = False`).
|
|
502
|
+
*/
|
|
503
|
+
phaseNetworkPolicies?: Array<{
|
|
504
|
+
outbound: "open" | "blocked";
|
|
505
|
+
allowedDestinations?: string[];
|
|
506
|
+
}>;
|
|
507
|
+
/**
|
|
508
|
+
* Named Modal Volumes mounted into the sandbox at create time, keyed by
|
|
509
|
+
* the ABSOLUTE in-box mount path. Each entry is resolved through
|
|
510
|
+
* client.volumes.fromName(name, { createIfMissing }) (modal@0.9.0
|
|
511
|
+
* index.d.ts:6456) and mounted with its own mount options
|
|
512
|
+
* (Volume.withMountOptions, index.d.ts:6499-6512) as the create call's
|
|
513
|
+
* `volumes` record (index.d.ts:7668). A read-only mount is enforced by
|
|
514
|
+
* Modal in the box (a write answers EROFS).
|
|
515
|
+
*
|
|
516
|
+
* WHAT A VOLUME IS FOR: bytes many sandboxes need — Modal's own guidance
|
|
517
|
+
* for data shared across sandboxes is to load it into a Volume once and
|
|
518
|
+
* mount it everywhere (modal.com/docs/guide/volumes, /guide/sandbox-files)
|
|
519
|
+
* — instead of moving them through this provider's per-message stdin
|
|
520
|
+
* path once per box (MODAL_STDIN_CHUNK_BYTES states that bound). Measured
|
|
521
|
+
* live 2026-09-08 (160 MiB, python:3.11-slim): a read from a mounted
|
|
522
|
+
* Volume 0.2-1.1 s (148-773 MiB/s) against 24-37 s per stdin upload.
|
|
523
|
+
*
|
|
524
|
+
* FILLING ONE: modal@0.9.0 exposes no upload-to-Volume call, so a Volume
|
|
525
|
+
* is written from INSIDE a sandbox that mounts it read-write; Modal
|
|
526
|
+
* commits the writes in the background while that sandbox runs and once
|
|
527
|
+
* more when it exits (the create call always sets allowBackgroundCommits,
|
|
528
|
+
* index.js:55511-55521), and a sandbox created after that exit sees the
|
|
529
|
+
* committed files at boot.
|
|
530
|
+
*
|
|
531
|
+
* ABSENT MEANS ABSENT: no option, no `volumes` key on the create call.
|
|
532
|
+
* Invalid entries are refused with ModalVolumeError before any network
|
|
533
|
+
* call; a Volume Modal cannot resolve is refused before any sandbox exists.
|
|
534
|
+
*/
|
|
535
|
+
volumes?: Record<string, ModalVolumeMount>;
|
|
536
|
+
/**
|
|
537
|
+
* Run all commands and file operations as this user (default "user"),
|
|
538
|
+
* enforced via an `su <user> -c` wrapper since Modal executes everything as
|
|
539
|
+
* root. Pass "root" to run directly as root with no wrapper.
|
|
540
|
+
*/
|
|
541
|
+
user?: string;
|
|
542
|
+
/** Home directory used by the SDK for agent config paths; not consumed by the provider. */
|
|
543
|
+
homeDir?: string;
|
|
98
544
|
}
|
|
99
545
|
/** Options for listing sandboxes */
|
|
100
546
|
interface SandboxListOptions {
|
|
547
|
+
/** Modal has no paused state; filters that exclude "running" match nothing. */
|
|
101
548
|
state?: ("running" | "paused")[];
|
|
102
549
|
metadata?: Record<string, string>;
|
|
103
550
|
limit?: number;
|
|
104
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* A COMPLETE (or admittedly incomplete) enumeration of the app's fleet.
|
|
554
|
+
*
|
|
555
|
+
* `complete` is the load-bearing field. Callers that need a whole fleet —
|
|
556
|
+
* orphan sweeps, lifecycle reconciliation — read a sandbox's ABSENCE from the
|
|
557
|
+
* list as evidence it is gone, so a truncated walk and a small fleet must never
|
|
558
|
+
* be the same answer. That includes a walk stopped by the caller's own `limit`:
|
|
559
|
+
* "you asked for ten and there are more" is a truncated fleet.
|
|
560
|
+
*/
|
|
561
|
+
interface SandboxListPage {
|
|
562
|
+
sandboxes: SandboxInfo[];
|
|
563
|
+
complete: boolean;
|
|
564
|
+
pagesFetched: number;
|
|
565
|
+
error?: string;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Sandboxes a single enumeration will walk before it gives up and reports
|
|
569
|
+
* itself incomplete. Modal's list is an async generator with no page size we
|
|
570
|
+
* control, so the ceiling is counted in SANDBOXES rather than pages — same
|
|
571
|
+
* purpose as the other providers' page caps: never return a short list that
|
|
572
|
+
* reads like a whole one.
|
|
573
|
+
*/
|
|
574
|
+
declare const MODAL_MAX_LIST_SANDBOXES = 10000;
|
|
105
575
|
/** Command execution capabilities */
|
|
106
576
|
interface SandboxCommands {
|
|
107
577
|
/** Run command and wait for completion */
|
|
@@ -132,6 +602,8 @@ interface SandboxFiles {
|
|
|
132
602
|
readStream(path: string): Promise<ReadableStream<Uint8Array>>;
|
|
133
603
|
/** Write from stream */
|
|
134
604
|
writeStream(path: string, stream: ReadableStream<Uint8Array>): Promise<void>;
|
|
605
|
+
/** Upload a local file by path, streamed off disk (never buffered whole) */
|
|
606
|
+
writeFromPath(sandboxPath: string, localPath: string): Promise<void>;
|
|
135
607
|
/** Get pre-signed upload URL for large files (expiration in seconds) */
|
|
136
608
|
uploadUrl(path: string, expiresInSeconds?: number): Promise<string>;
|
|
137
609
|
/** Get pre-signed download URL for large files (expiration in seconds) */
|
|
@@ -164,17 +636,50 @@ interface SandboxInstance {
|
|
|
164
636
|
kill(): Promise<void>;
|
|
165
637
|
/** Pause sandbox (preserves state) */
|
|
166
638
|
pause(): Promise<void>;
|
|
639
|
+
/** Replace the outbound network policy of the running sandbox. */
|
|
640
|
+
updateNetwork(network: {
|
|
641
|
+
outbound: "open" | "blocked";
|
|
642
|
+
allowedDestinations?: string[];
|
|
643
|
+
}): Promise<void>;
|
|
167
644
|
}
|
|
168
645
|
/** Sandbox lifecycle management */
|
|
169
646
|
interface SandboxProvider {
|
|
170
647
|
/** Provider type identifier */
|
|
171
648
|
readonly providerType: string;
|
|
649
|
+
/** Human-readable provider name for logging */
|
|
650
|
+
readonly name?: string;
|
|
651
|
+
/**
|
|
652
|
+
* TRUE on every build whose create() maps SandboxCreateOptions.bootCommand
|
|
653
|
+
* to Modal's create args. A consumer whose safety depends on an inert boot
|
|
654
|
+
* must CHECK this and refuse a provider without it: an older build would
|
|
655
|
+
* silently drop the unknown option and boot the image's own ENTRYPOINT/CMD
|
|
656
|
+
* as the sandbox main process.
|
|
657
|
+
*/
|
|
658
|
+
readonly supportsBootCommand?: boolean;
|
|
659
|
+
/**
|
|
660
|
+
* TRUE on every build whose create() maps SandboxCreateOptions.volumes to
|
|
661
|
+
* Modal's create-time Volume mounts. Same law as supportsBootCommand: an
|
|
662
|
+
* older build would silently drop the unknown option and boot a box with
|
|
663
|
+
* nothing mounted, so a consumer that relies on the mount checks this
|
|
664
|
+
* before it fills or reads a Volume.
|
|
665
|
+
*/
|
|
666
|
+
readonly supportsVolumes?: boolean;
|
|
172
667
|
/** Create new sandbox */
|
|
173
668
|
create(options: SandboxCreateOptions): Promise<SandboxInstance>;
|
|
174
669
|
/** Connect to existing sandbox */
|
|
175
670
|
connect(sandboxId: string, timeoutMs?: number): Promise<SandboxInstance>;
|
|
176
|
-
/** List sandboxes
|
|
671
|
+
/** List sandboxes, walking the whole app. `limit` bounds items returned. */
|
|
177
672
|
list(options?: SandboxListOptions): Promise<SandboxInfo[]>;
|
|
673
|
+
/** The same enumeration for fleet bookkeeping: never throws, reports completeness. */
|
|
674
|
+
listAll(options?: SandboxListOptions): Promise<SandboxListPage>;
|
|
675
|
+
/**
|
|
676
|
+
* Build or pull the sandbox image ahead of time so a later create() does not
|
|
677
|
+
* wait for it. Takes what `create({ image })` takes, resolved by the same
|
|
678
|
+
* path, and defaults to the provider's configured image. Optional on the
|
|
679
|
+
* same terms as the SDK contract: declared so every provider offering it
|
|
680
|
+
* offers the same signature.
|
|
681
|
+
*/
|
|
682
|
+
prepareImage?(image?: string): Promise<void>;
|
|
178
683
|
}
|
|
179
684
|
interface ModalConfig {
|
|
180
685
|
/** Modal app name. Default: "evolve-sandbox" */
|
|
@@ -187,8 +692,15 @@ interface ModalConfig {
|
|
|
187
692
|
tokenSecret?: string;
|
|
188
693
|
/** Modal API endpoint. Default: https://api.modal.com:443 */
|
|
189
694
|
endpoint?: string;
|
|
190
|
-
/** Docker image name (default: 'evolve-all'). Resolved through IMAGE_MAP or used as-is for custom images. */
|
|
695
|
+
/** Docker image name (default: 'evolve-all-<EVOLVE_IMAGE_VERSION>'). Resolved through IMAGE_MAP or used as-is for custom images; explicit names pass through untouched. */
|
|
191
696
|
imageName?: string;
|
|
697
|
+
/**
|
|
698
|
+
* Name of a Modal Secret holding registry credentials for private images.
|
|
699
|
+
* Required for AWS ECR (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
|
|
700
|
+
* AWS_REGION with read-only ECR IAM) and GCP Artifact Registry; optional
|
|
701
|
+
* for private Docker Hub images. Create one at https://modal.com/secrets
|
|
702
|
+
*/
|
|
703
|
+
imageSecretName?: string;
|
|
192
704
|
}
|
|
193
705
|
/** Internal resolved config with required credentials */
|
|
194
706
|
interface ResolvedModalConfig {
|
|
@@ -198,21 +710,344 @@ interface ResolvedModalConfig {
|
|
|
198
710
|
defaultTimeoutMs?: number;
|
|
199
711
|
endpoint?: string;
|
|
200
712
|
imageName?: string;
|
|
713
|
+
imageSecretName?: string;
|
|
714
|
+
}
|
|
715
|
+
declare class ModalCommands implements SandboxCommands {
|
|
716
|
+
private sandbox;
|
|
717
|
+
private user;
|
|
718
|
+
constructor(sandbox: Sandbox, user: string);
|
|
719
|
+
run(command: string, options?: SandboxRunOptions): Promise<SandboxCommandResult>;
|
|
720
|
+
spawn(command: string, options?: SandboxSpawnOptions): Promise<SandboxCommandHandle>;
|
|
721
|
+
list(): Promise<ProcessInfo[]>;
|
|
722
|
+
connect(_processId: string, _options?: SandboxConnectOptions): Promise<SandboxCommandHandle>;
|
|
723
|
+
sendStdin(_processId: string, _data: string): Promise<void>;
|
|
724
|
+
kill(processId: string): Promise<boolean>;
|
|
725
|
+
/**
|
|
726
|
+
* Accumulate stdout/stderr using for-await pattern (more reliable with Modal streams).
|
|
727
|
+
* Based on vibekit's approach which works correctly with Modal SDK.
|
|
728
|
+
*/
|
|
729
|
+
private accumulateStreams;
|
|
730
|
+
}
|
|
731
|
+
declare class ModalFiles implements SandboxFiles {
|
|
732
|
+
private sandbox;
|
|
733
|
+
private user;
|
|
734
|
+
constructor(sandbox: Sandbox, user: string);
|
|
735
|
+
/**
|
|
736
|
+
* Chown a path to the sandbox user so agent CLIs (running via the su
|
|
737
|
+
* wrapper) can access files created by root-level exec. No-op when the
|
|
738
|
+
* sandbox user is root.
|
|
739
|
+
*/
|
|
740
|
+
private chownToUser;
|
|
741
|
+
/**
|
|
742
|
+
* Write a payload to a process's stdin in MODAL_STDIN_CHUNK_BYTES slices.
|
|
743
|
+
* Each writeBytes() call becomes one gRPC TaskExecStdinWrite message and
|
|
744
|
+
* Modal rejects messages over 100MiB, so large files must be chunked
|
|
745
|
+
* (multi-hundred-MB payloads are common).
|
|
746
|
+
*/
|
|
747
|
+
private writeStdinChunked;
|
|
748
|
+
/**
|
|
749
|
+
* Stream a byte source into a process's stdin, buffering it into
|
|
750
|
+
* MODAL_STDIN_CHUNK_BYTES writes rather than forwarding the source's own
|
|
751
|
+
* chunk size.
|
|
752
|
+
*
|
|
753
|
+
* WHY this exists rather than the SDK's own file copy. Modal documents
|
|
754
|
+
* "convenience APIs for streaming file copies in both directions"
|
|
755
|
+
* (https://modal.com/docs/guide/sandbox-files), but the JS
|
|
756
|
+
* `filesystem.copyFromLocal()` in modal@0.9.0 streams the local file with
|
|
757
|
+
* a bare `createReadStream(localPath)` — Node's default 64KiB highWaterMark
|
|
758
|
+
* — and awaits one unary TaskExecStdinWrite per chunk. Modal's own Python
|
|
759
|
+
* SDK reads TASK_COMMAND_ROUTER_MAX_BUFFER_SIZE (16MiB) per chunk for the
|
|
760
|
+
* same operation, so the 64KiB default is a JS-side omission, not a
|
|
761
|
+
* transport limit. Since the size is not a parameter of copyFromLocal(),
|
|
762
|
+
* the native call cannot be made to send larger messages.
|
|
763
|
+
*
|
|
764
|
+
* Measured, 180MiB payload: 64KiB 311.6s (2880 messages), native
|
|
765
|
+
* copyFromLocal 308.7s, 4MiB 36.2s, 8MiB 30.1s, 16MiB 31.4s. The upload is
|
|
766
|
+
* round-trip bound until roughly 4MiB and throughput bound after it, so the
|
|
767
|
+
* fix is message SIZE rather than which sink receives the bytes — the
|
|
768
|
+
* exec-stdin sink stays and the chunking changes.
|
|
769
|
+
*
|
|
770
|
+
* Peak memory stays at one chunk plus the source's own, never the whole
|
|
771
|
+
* payload: bundles run to hundreds of MB while workers hold many trials in
|
|
772
|
+
* a small heap.
|
|
773
|
+
*/
|
|
774
|
+
private writeStdinCoalesced;
|
|
775
|
+
read(path: string): Promise<string | Uint8Array>;
|
|
776
|
+
write(path: string, content: string | Buffer | ArrayBuffer | Uint8Array): Promise<void>;
|
|
777
|
+
writeBatch(files: Array<{
|
|
778
|
+
path: string;
|
|
779
|
+
data: string | Buffer | ArrayBuffer | Uint8Array;
|
|
780
|
+
}>): Promise<void>;
|
|
781
|
+
makeDir(path: string): Promise<void>;
|
|
782
|
+
exists(path: string): Promise<boolean>;
|
|
783
|
+
list(path: string): Promise<FileInfo[]>;
|
|
784
|
+
remove(path: string): Promise<void>;
|
|
785
|
+
rename(oldPath: string, newPath: string): Promise<void>;
|
|
786
|
+
readStream(path: string): Promise<ReadableStream<Uint8Array>>;
|
|
787
|
+
writeStream(path: string, stream: ReadableStream<Uint8Array>): Promise<void>;
|
|
788
|
+
/**
|
|
789
|
+
* Upload a local file by PATH, chunk by chunk into the same `cat >` sink
|
|
790
|
+
* writeStream() uses — so peak memory is one chunk rather than the whole
|
|
791
|
+
* file, which is what makes a large artifact safe under concurrency.
|
|
792
|
+
*
|
|
793
|
+
* The read is sized to MODAL_STDIN_CHUNK_BYTES instead of Node's 64KiB
|
|
794
|
+
* default because every chunk costs one awaited round trip to Modal: the
|
|
795
|
+
* same 180MiB bundle took 311.6s at 64KiB and 30.1s at 8MiB (measured, one
|
|
796
|
+
* sandbox, same file). writeStdinCoalesced would batch a small-chunk stream
|
|
797
|
+
* anyway; asking the filesystem for whole chunks just avoids assembling
|
|
798
|
+
* them from 128 pieces.
|
|
799
|
+
*/
|
|
800
|
+
writeFromPath(sandboxPath: string, localPath: string): Promise<void>;
|
|
801
|
+
uploadUrl(_path: string, _expiresInSeconds?: number): Promise<string>;
|
|
802
|
+
downloadUrl(_path: string, _expiresInSeconds?: number): Promise<string>;
|
|
803
|
+
watchDir(_path: string, _onEvent: (event: FilesystemEvent) => void | Promise<void>, _options?: WatchOptions): Promise<WatchHandle>;
|
|
804
|
+
private toBuffer;
|
|
805
|
+
}
|
|
806
|
+
declare class ModalSandboxImpl implements SandboxInstance {
|
|
807
|
+
private sandbox;
|
|
808
|
+
readonly commands: SandboxCommands;
|
|
809
|
+
readonly files: SandboxFiles;
|
|
810
|
+
private readonly image?;
|
|
811
|
+
constructor(sandbox: Sandbox, image: string | undefined, user: string);
|
|
812
|
+
get sandboxId(): string;
|
|
813
|
+
getHost(port: number): Promise<string>;
|
|
814
|
+
isRunning(): Promise<boolean>;
|
|
815
|
+
getInfo(): Promise<SandboxInfo>;
|
|
816
|
+
/**
|
|
817
|
+
* Replace the running sandbox's outbound policy — Modal's
|
|
818
|
+
* `Sandbox.updateNetworkPolicy`, "Updates the outbound network policy of a
|
|
819
|
+
* running Sandbox. Established connections that the new policy no longer
|
|
820
|
+
* permits are terminated." (modal@0.9.0 index.d.ts:8035-8042).
|
|
821
|
+
*
|
|
822
|
+
* The policy is mapped by dynamicNetworkPolicyParams — the create path's own
|
|
823
|
+
* classification — so an update can never admit a destination the create
|
|
824
|
+
* would have refused.
|
|
825
|
+
*
|
|
826
|
+
* WHAT THIS CANNOT FIX: a box created with `blockNetwork: true` has no
|
|
827
|
+
* allowlist for Modal to widen, because create refuses the two together
|
|
828
|
+
* (index.d.ts:7682-7686). Declare `phaseNetworkPolicies` at create and the
|
|
829
|
+
* adapter builds the box switchable instead. Modal's refusal in that case is
|
|
830
|
+
* its own error, surfaced verbatim rather than reinterpreted here — guessing
|
|
831
|
+
* at a remote refusal is how a real quota or auth failure gets mislabelled.
|
|
832
|
+
*/
|
|
833
|
+
updateNetwork(network: {
|
|
834
|
+
outbound: "open" | "blocked";
|
|
835
|
+
allowedDestinations?: string[];
|
|
836
|
+
}): Promise<void>;
|
|
837
|
+
kill(): Promise<void>;
|
|
838
|
+
pause(): Promise<void>;
|
|
201
839
|
}
|
|
202
840
|
declare class ModalProvider implements SandboxProvider {
|
|
203
841
|
readonly providerType: "modal";
|
|
204
842
|
readonly name = "Modal";
|
|
843
|
+
/** create() maps bootCommand (see SandboxCreateOptions.bootCommand). */
|
|
844
|
+
readonly supportsBootCommand = true;
|
|
845
|
+
/** create() maps volumes (see SandboxCreateOptions.volumes). */
|
|
846
|
+
readonly supportsVolumes = true;
|
|
205
847
|
private readonly client;
|
|
206
848
|
private readonly appName;
|
|
207
849
|
private readonly defaultTimeoutMs;
|
|
208
850
|
private readonly imageName;
|
|
851
|
+
private readonly imageSecretName?;
|
|
209
852
|
private _app;
|
|
853
|
+
/**
|
|
854
|
+
* Sandbox user configured at create time, reapplied on connect() so the su
|
|
855
|
+
* wrapper keeps targeting the same account. In-memory only: a connect()
|
|
856
|
+
* from a fresh process falls back to the default "user" account — callers
|
|
857
|
+
* reconnecting across processes must recreate the provider and sandbox with
|
|
858
|
+
* the same user, or operations on user-owned files fail loudly.
|
|
859
|
+
*/
|
|
860
|
+
private readonly sandboxUsers;
|
|
210
861
|
constructor(config: ResolvedModalConfig);
|
|
211
862
|
private getApp;
|
|
863
|
+
/**
|
|
864
|
+
* Build a Modal Image for the resolved tag, routing private registries
|
|
865
|
+
* (AWS ECR, GCP Artifact Registry) through the configured Modal Secret.
|
|
866
|
+
*
|
|
867
|
+
* Digest-pinned ECR refs (`<repo>@sha256:<digest>`) are accepted by
|
|
868
|
+
* fromAwsEcr — verified LIVE against Modal 2026-08-21 (built and booted a
|
|
869
|
+
* sandbox from one). Modal's docs never say so (the modal.Image reference
|
|
870
|
+
* describes the parameter only as "Full ECR image URI", tag-form example),
|
|
871
|
+
* so treat the capability as observed behavior, not contract.
|
|
872
|
+
*/
|
|
873
|
+
private resolveImage;
|
|
874
|
+
/**
|
|
875
|
+
* Resolve a requested image name to the Modal image identity, eagerly
|
|
876
|
+
* building it when it is a registry reference and resolving WITHOUT a build
|
|
877
|
+
* when it is a published Modal image name. The ONE place that resolution
|
|
878
|
+
* happens, because create(), prepareImage() and publishImageAs() drifting
|
|
879
|
+
* apart is a silent failure: the prewarm would populate one image while
|
|
880
|
+
* trials created against another, and nothing would report the mismatch —
|
|
881
|
+
* only the cold-start cost prewarm was meant to remove would quietly come
|
|
882
|
+
* back.
|
|
883
|
+
*
|
|
884
|
+
* "Eagerly builds an Image on Modal" — the SDK's own description of
|
|
885
|
+
* Image.build(app) (modal@0.9.0, dist/index.d.ts). The call is idempotent:
|
|
886
|
+
* the same reference returns the same cached imageId, so the first caller
|
|
887
|
+
* pays the registry pull and later ones resolve quickly.
|
|
888
|
+
*/
|
|
889
|
+
private resolveAndBuildImage;
|
|
890
|
+
/**
|
|
891
|
+
* Build (or pull) an image on Modal ahead of time, so the sandbox that
|
|
892
|
+
* needs it later does not wait for it.
|
|
893
|
+
*
|
|
894
|
+
* Modal's own guidance: "To avoid blocking creation of new Sandboxes on
|
|
895
|
+
* rebuilding an invalidated Image, it's recommended to use Modal's named
|
|
896
|
+
* Images with sandboxes, rather than using inline Image definitions", and
|
|
897
|
+
* "Use `Image.build` to trigger Image builds as part of a deployment flow
|
|
898
|
+
* or at a regular interval (e.g., in a scheduled job or CI pipeline)"
|
|
899
|
+
* (https://modal.com/docs/guide/sandbox). This is that deployment-flow
|
|
900
|
+
* call: run it at publish time, and trial-time create() finds the image
|
|
901
|
+
* already built.
|
|
902
|
+
*
|
|
903
|
+
* `imageName` takes exactly what `create({ image })` takes and is resolved
|
|
904
|
+
* by the identical path, so callers prewarm the image they will actually
|
|
905
|
+
* run on rather than a reconstruction of it. Omitted, it prewarms the
|
|
906
|
+
* provider's configured default — again what create() would have chosen.
|
|
907
|
+
*
|
|
908
|
+
* There is deliberately no sizing parameter: on Modal, image identity is
|
|
909
|
+
* the registry reference alone, and CPU/memory/GPU are create-time sandbox
|
|
910
|
+
* options, so one eager build serves every sizing.
|
|
911
|
+
*
|
|
912
|
+
* OPERATIONAL TRAP, and prewarming at publish time is what makes it likely.
|
|
913
|
+
* From the same guide: "Modal treats external Image tags as immutable once
|
|
914
|
+
* pulled" and "Modal does not detect upstream changes to mutable tags like
|
|
915
|
+
* `:latest`". So prewarming a MUTABLE tag after re-pushing that tag warms
|
|
916
|
+
* the OLD image, and every later create() keeps launching the old image —
|
|
917
|
+
* quietly, because the reference still resolves. Modal's own remedy is to
|
|
918
|
+
* "update the tag in your deploy script (for example, `ubuntu:24.04` →
|
|
919
|
+
* `ubuntu:24.04-20240523`)".
|
|
920
|
+
*
|
|
921
|
+
* The versioned default (evolve-all-<c-hash>) is immune, because a content
|
|
922
|
+
* change moves the tag. The exposed legacy alias "evolve-all" is NOT: it
|
|
923
|
+
* maps to the mutable Docker Hub name, so prewarming it after a re-push
|
|
924
|
+
* warms the stale image. That alias is precisely why EVOLVE_IMAGE_VERSION
|
|
925
|
+
* exists — prewarm the versioned name unless you specifically want the
|
|
926
|
+
* account's already-pulled copy.
|
|
927
|
+
*/
|
|
928
|
+
/**
|
|
929
|
+
* Give a built image OUR name on Modal, so it can be found — and deleted —
|
|
930
|
+
* later by a name this platform minted rather than an id Modal minted.
|
|
931
|
+
*
|
|
932
|
+
* WHY THIS EXISTS. Every other provider hands back a named artifact: an e2b
|
|
933
|
+
* template alias, a daytona snapshot name. Modal's image identity is the
|
|
934
|
+
* registry reference plus an opaque server-side id, and its delete verb
|
|
935
|
+
* (`client.images.delete`) takes the ID — which only exists after a build and
|
|
936
|
+
* is never returned by any lookup we could do later from a reference alone.
|
|
937
|
+
* So a Modal image built for a dataset could never be reclaimed when that
|
|
938
|
+
* dataset was deleted; the platform recorded the honest refusal
|
|
939
|
+
* `store_unsupported` and the images accumulated.
|
|
940
|
+
*
|
|
941
|
+
* `Image.publish(name)` closes that: it binds a stable name to the built
|
|
942
|
+
* image, and `images.fromName(name)` resolves that name back to the id
|
|
943
|
+
* WITHOUT rebuilding (it is a plain `imageGetByTag` lookup). Named, findable,
|
|
944
|
+
* deletable — the same shape the other two providers already have.
|
|
945
|
+
*
|
|
946
|
+
* IDEMPOTENT BY CONSTRUCTION for our use: the alias is a content address, so
|
|
947
|
+
* re-publishing the same alias re-binds it to the image that same content
|
|
948
|
+
* built. A caller that publishes twice names the same bytes twice.
|
|
949
|
+
*
|
|
950
|
+
* The build goes through resolveAndBuildImage, the ONE pair every other path
|
|
951
|
+
* uses, so a published image and the image a trial creates against cannot be
|
|
952
|
+
* different images — the same law prepareImage keeps.
|
|
953
|
+
*
|
|
954
|
+
* MODAL-ONLY, deliberately not on the shared provider interface: e2b and
|
|
955
|
+
* daytona name their artifacts at creation and have nothing to publish. The
|
|
956
|
+
* platform feature-detects this method rather than every provider carrying a
|
|
957
|
+
* verb only one of them can honor.
|
|
958
|
+
*/
|
|
959
|
+
publishImageAs(alias: string, imageName?: string): Promise<string>;
|
|
960
|
+
prepareImage(imageName?: string): Promise<void>;
|
|
212
961
|
create(options: SandboxCreateOptions): Promise<SandboxInstance>;
|
|
962
|
+
/**
|
|
963
|
+
* The validated mounts -> Modal's `volumes` create record: each Volume
|
|
964
|
+
* resolved by name (client.volumes.fromName, createIfMissing as declared)
|
|
965
|
+
* and carrying its mount options (withMountOptions — readOnly stated
|
|
966
|
+
* explicitly both ways, since the SDK keeps undefined fields from any
|
|
967
|
+
* earlier call on the same Volume object). Modal's NotFoundError becomes
|
|
968
|
+
* ModalVolumeError "not-found", anything else "resolve-failed", both with
|
|
969
|
+
* Modal's error as the cause. Undefined when there is nothing to mount.
|
|
970
|
+
*/
|
|
971
|
+
private resolveVolumes;
|
|
213
972
|
connect(sandboxId: string, _timeoutMs?: number): Promise<SandboxInstance>;
|
|
214
|
-
|
|
973
|
+
/**
|
|
974
|
+
* List sandboxes, walking the whole app.
|
|
975
|
+
*
|
|
976
|
+
* This used to stop at a hardcoded default of 100 regardless of fleet size,
|
|
977
|
+
* which silently truncated any app with more — and said nothing about it
|
|
978
|
+
* while the shared SandboxProvider interface promised exhaustive listing.
|
|
979
|
+
* `limit` still bounds the sandboxes RETURNED, so a caller wanting one cheap
|
|
980
|
+
* sample asks for one; without it the answer is the whole app.
|
|
981
|
+
*
|
|
982
|
+
* The O(N)-round-trips warning below is unchanged and is the reason `limit`
|
|
983
|
+
* matters here more than on the other providers.
|
|
984
|
+
*/
|
|
985
|
+
list(options?: SandboxListOptions): Promise<SandboxInfo[]>;
|
|
986
|
+
/**
|
|
987
|
+
* The fleet-bookkeeping enumeration: same walk, never throws.
|
|
988
|
+
*
|
|
989
|
+
* Modal has no lifecycle webhooks, so absence from a list is the ONLY
|
|
990
|
+
* termination signal either lane gets — which makes the difference between
|
|
991
|
+
* "the app is empty" and "the enumeration stopped early" the difference
|
|
992
|
+
* between a quiet fleet and one about to be reclaimed.
|
|
993
|
+
*
|
|
994
|
+
* NOTE the divergence from `listSandboxIds` below, which returns an EMPTY set
|
|
995
|
+
* on failure on the grounds that partial results are worse than none for a
|
|
996
|
+
* terminal-state decision. This one returns what it saw alongside
|
|
997
|
+
* `complete: false`. Both are safe because `complete` is what callers branch
|
|
998
|
+
* on, and the shared type documents the choice; do not align one to the other
|
|
999
|
+
* without deciding which rule you want.
|
|
1000
|
+
*/
|
|
1001
|
+
listAll(options?: SandboxListOptions): Promise<SandboxListPage>;
|
|
1002
|
+
private walk;
|
|
1003
|
+
/**
|
|
1004
|
+
* Live sandbox ids for the whole app, in ONE streamed call and O(1) round
|
|
1005
|
+
* trips — the fleet-bookkeeping counterpart to `list()`.
|
|
1006
|
+
*
|
|
1007
|
+
* Exists because `list()` cannot be made cheap without dropping metadata from
|
|
1008
|
+
* its contract: it owes callers a populated `SandboxInfo.metadata`, and Modal
|
|
1009
|
+
* only serves tags per sandbox. Anything that just needs "which ids are
|
|
1010
|
+
* alive" — lifecycle polling, orphan sweeps, reconciliation — uses this and
|
|
1011
|
+
* pays one request no matter how large the fleet is.
|
|
1012
|
+
*
|
|
1013
|
+
* `complete` is the load-bearing field, not a nicety: absence from this list
|
|
1014
|
+
* is what callers read as "terminated", so a truncated or errored enumeration
|
|
1015
|
+
* MUST NOT be mistaken for an empty fleet. A caller that sees complete=false
|
|
1016
|
+
* has to leave rows alone rather than mass-marking live sandboxes dead.
|
|
1017
|
+
*/
|
|
1018
|
+
listSandboxIds(): Promise<{
|
|
1019
|
+
ids: Set<string>;
|
|
1020
|
+
complete: boolean;
|
|
1021
|
+
}>;
|
|
1022
|
+
}
|
|
1023
|
+
/** The streamed sandbox surface this walk needs — Modal's list() satisfies it. */
|
|
1024
|
+
interface ModalSandboxStream {
|
|
1025
|
+
sandboxId: string;
|
|
1026
|
+
getTags(): Promise<Record<string, string>>;
|
|
215
1027
|
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Drain Modal's sandbox generator into one answer, with an honest completeness
|
|
1030
|
+
* verdict.
|
|
1031
|
+
*
|
|
1032
|
+
* Separate from the provider because everything worth getting wrong lives here
|
|
1033
|
+
* and none of it needs a gRPC connection: the difference between "the caller
|
|
1034
|
+
* asked for ten" and "the app ran out", the ceiling that stops an unbounded
|
|
1035
|
+
* walk, and the rule that a failure mid-walk yields what it saw marked
|
|
1036
|
+
* INCOMPLETE rather than an exception or a short complete list.
|
|
1037
|
+
*
|
|
1038
|
+
* COST WARNING — this loop is O(N) ROUND TRIPS, not O(1). `list()` itself is one
|
|
1039
|
+
* streamed call, but `getTags()` is a separate gRPC request per sandbox
|
|
1040
|
+
* (`sandboxTagsGet`), so listing N sandboxes costs N+1 calls. That is fine for a
|
|
1041
|
+
* user listing their handful of boxes with metadata, and NOT fine for fleet-wide
|
|
1042
|
+
* bookkeeping: anything that only needs to know WHICH ids are alive must use
|
|
1043
|
+
* `listSandboxIds`, never this. Please do not "optimize" by reintroducing tag
|
|
1044
|
+
* reads into those paths.
|
|
1045
|
+
*
|
|
1046
|
+
* Exported for its test (`_testCollectSandboxes`).
|
|
1047
|
+
*/
|
|
1048
|
+
declare function collectSandboxes(iterate: () => AsyncIterable<ModalSandboxStream>, wanted?: number): Promise<SandboxListPage & {
|
|
1049
|
+
stoppedAtLimit: boolean;
|
|
1050
|
+
}>;
|
|
216
1051
|
/**
|
|
217
1052
|
* Create Modal sandbox provider.
|
|
218
1053
|
*
|
|
@@ -222,5 +1057,26 @@ declare class ModalProvider implements SandboxProvider {
|
|
|
222
1057
|
* @see https://github.com/evolving-machines-lab/evolve/issues/8
|
|
223
1058
|
*/
|
|
224
1059
|
declare function createModalProvider(config?: ModalConfig): SandboxProvider;
|
|
1060
|
+
declare const _testWrapCommand: typeof wrapCommand;
|
|
1061
|
+
declare const _testImageMap: Record<string, string>;
|
|
1062
|
+
declare const _testMapNetworkPolicy: typeof mapNetworkPolicy;
|
|
1063
|
+
declare const _testDynamicNetworkPolicyParams: typeof dynamicNetworkPolicyParams;
|
|
1064
|
+
declare const _testRequiresDynamicNetwork: typeof requiresDynamicNetwork;
|
|
1065
|
+
declare const _testMapResources: typeof mapResources;
|
|
1066
|
+
declare const _testResolveImageRegistry: typeof resolveImageRegistry;
|
|
1067
|
+
declare const _testBuildSandboxInfo: typeof buildSandboxInfo;
|
|
1068
|
+
declare const _testCollectSandboxes: typeof collectSandboxes;
|
|
1069
|
+
declare const _testValidateTimeout: typeof validateTimeout;
|
|
1070
|
+
declare const _testMapIdleTimeout: typeof mapIdleTimeout;
|
|
1071
|
+
declare const _testMapBootCommand: typeof mapBootCommand;
|
|
1072
|
+
declare const _testMapVolumeMounts: typeof mapVolumeMounts;
|
|
1073
|
+
/**
|
|
1074
|
+
* TYPE-ONLY handle on the concrete sandbox class, for the contract-conformance
|
|
1075
|
+
* seam. create() is declared to return the local SandboxInstance INTERFACE, so
|
|
1076
|
+
* a seam reading create()'s return type checks the interface and never the
|
|
1077
|
+
* class — which let a narrowed method on the class pass unnoticed. Exporting
|
|
1078
|
+
* the type (never the constructor) gives the seam the real methods to pin.
|
|
1079
|
+
*/
|
|
1080
|
+
type _testModalSandboxImpl = ModalSandboxImpl;
|
|
225
1081
|
|
|
226
|
-
export { type FileInfo, type FilesystemEvent, type ModalConfig, ModalProvider, type ProcessInfo, type SandboxCommandHandle, type SandboxCommandResult, type SandboxCommands, type SandboxConnectOptions, type SandboxCreateOptions, type SandboxFiles, type SandboxInfo, type SandboxInstance, type SandboxListOptions, type SandboxProvider, type SandboxRunOptions, type SandboxSpawnOptions, type WatchHandle, type WatchOptions, createModalProvider };
|
|
1082
|
+
export { EVOLVE_IMAGE_VERSION, type FileInfo, type FilesystemEvent, MODAL_MAX_LIFETIME_MS, MODAL_MAX_LIST_SANDBOXES, MODAL_STDIN_CHUNK_BYTES, ModalBootCommandError, ModalCommands, type ModalConfig, ModalFiles, ModalIdleTimeoutError, ModalNetworkPolicyError, type ModalNetworkPolicyReason, ModalProvider, ModalResourcesError, ModalSandboxLifetimeError, type ModalSandboxStream, ModalVolumeError, type ModalVolumeErrorReason, type ModalVolumeMount, type ProcessInfo, type SandboxCommandHandle, type SandboxCommandResult, type SandboxCommands, type SandboxConnectOptions, type SandboxCreateOptions, type SandboxFiles, type SandboxInfo, type SandboxInstance, type SandboxListOptions, type SandboxListPage, type SandboxProvider, type SandboxRunOptions, type SandboxSpawnOptions, type WatchHandle, type WatchOptions, _testBuildSandboxInfo, _testCollectSandboxes, _testDynamicNetworkPolicyParams, _testImageMap, _testMapBootCommand, _testMapIdleTimeout, _testMapNetworkPolicy, _testMapResources, _testMapVolumeMounts, type _testModalSandboxImpl, _testRequiresDynamicNetwork, _testResolveImageRegistry, _testValidateTimeout, _testWrapCommand, createModalProvider };
|