humanish 0.137.0 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/cli/commands/runs.js +2 -2
  3. package/dist/cli/doctor.js +24 -0
  4. package/dist/keys/key-resolution.js +53 -2
  5. package/dist/routes/terminal/live-sandbox.d.ts +2 -0
  6. package/dist/routes/terminal/live-sandbox.js +8 -3
  7. package/dist/routes/terminal/plan.js +49 -6
  8. package/dist/routes/terminal/session.js +8 -1
  9. package/dist/run/bundle.d.ts +2 -1
  10. package/dist/run/cost-summary.js +9 -4
  11. package/dist/run/pricing.d.ts +11 -0
  12. package/dist/run/pricing.js +18 -5
  13. package/dist/run/reclaim-outcomes.d.ts +4 -1
  14. package/dist/run/reclaim-outcomes.js +8 -6
  15. package/dist/run/reclaim.d.ts +4 -1
  16. package/dist/run/reclaim.js +81 -34
  17. package/dist/run/sandbox-creates.d.ts +7 -4
  18. package/dist/run/sandbox-creates.js +17 -9
  19. package/dist/run/sandbox-receipts.d.ts +13 -5
  20. package/dist/run/sandbox-receipts.js +18 -6
  21. package/dist/study/composition-rules.js +11 -10
  22. package/dist/study/doctor.js +1 -1
  23. package/dist/study/parse/execution.js +5 -2
  24. package/dist/study/plan-types.d.ts +4 -1
  25. package/dist/study/requirements.js +7 -2
  26. package/dist/study/routing.d.ts +1 -1
  27. package/dist/study/routing.js +1 -1
  28. package/dist/study/types.d.ts +1 -1
  29. package/dist/substrates/e2b/desktop-resources.d.ts +4 -2
  30. package/dist/substrates/e2b/desktop-resources.js +2 -2
  31. package/dist/substrates/e2b/peer-install.js +1 -1
  32. package/dist/substrates/e2b/sandbox.d.ts +3 -0
  33. package/dist/substrates/e2b/sandbox.js +5 -4
  34. package/dist/substrates/modal/image.d.ts +9 -0
  35. package/dist/substrates/modal/image.js +36 -0
  36. package/dist/substrates/modal/sandbox.d.ts +26 -0
  37. package/dist/substrates/modal/sandbox.js +367 -0
  38. package/dist/substrates/modal/sdk.d.ts +112 -0
  39. package/dist/substrates/modal/sdk.js +61 -0
  40. package/docs/architecture/terminal-product-route.md +1 -1
  41. package/docs/contracts/run-bundle.md +4 -2
  42. package/docs/contracts/schemas.md +6 -5
  43. package/package.json +8 -2
@@ -0,0 +1,36 @@
1
+ const DEBIAN_BASE = "debian@sha256:7792b1f7702a86946cd518db72b6a407302c3e9bc1635634368b878189e8221c";
2
+ const DEBIAN_SNAPSHOT = "20260923T083042Z";
3
+ const SECURITY_SNAPSHOT = "20260922T123247Z";
4
+ /** The sandbox account commands run as, matching E2B's `user` and its home. */
5
+ export const MODAL_SANDBOX_USER = { name: "user", uid: 1000, home: "/home/user" };
6
+ const SNAPSHOT_SOURCES = `printf 'Types: deb\\nURIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/\\nSuites: trixie trixie-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\\nCheck-Valid-Until: no\\n\\nTypes: deb\\nURIs: http://snapshot.debian.org/archive/debian-security/${SECURITY_SNAPSHOT}/\\nSuites: trixie-security\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\\nCheck-Valid-Until: no\\n'` +
7
+ " > /etc/apt/sources.list.d/humanish.sources";
8
+ // What the terminal route's steps call: the Node bootstrap's prerequisites (curl, coreutils, tar,
9
+ // gzip, sudo), git for product installs, and python3 and unzip, which E2B's stock template has.
10
+ const SHELL_PACKAGES = [
11
+ "bash",
12
+ "ca-certificates",
13
+ "curl",
14
+ "git",
15
+ "gzip",
16
+ "less",
17
+ "procps",
18
+ "python3",
19
+ "sudo",
20
+ "tar",
21
+ "unzip",
22
+ "xz-utils",
23
+ ];
24
+ /** The terminal route's image: a Debian shell with a passwordless-sudo `user`, as on E2B. */
25
+ export function modalShellImage(client) {
26
+ const { name, uid, home } = MODAL_SANDBOX_USER;
27
+ // prose-check: script
28
+ return client.images
29
+ .fromRegistry(DEBIAN_BASE)
30
+ .dockerfileCommands([
31
+ "ENV DEBIAN_FRONTEND=noninteractive",
32
+ `RUN rm -f /etc/apt/sources.list /etc/apt/sources.list.d/* && ${SNAPSHOT_SOURCES} && apt-get update && apt-get install -y --no-install-recommends ${SHELL_PACKAGES.join(" ")} && rm -rf /var/lib/apt/lists/*`,
33
+ `RUN useradd --uid ${uid} --create-home --home-dir ${home} --shell /bin/bash ${name} && printf '${name} ALL=(ALL) NOPASSWD:ALL\\n' > /etc/sudoers.d/${name} && chmod 0440 /etc/sudoers.d/${name}`,
34
+ `WORKDIR ${home}`,
35
+ ]);
36
+ }
@@ -0,0 +1,26 @@
1
+ import type { E2BDesktopModule } from "../e2b/sdk.js";
2
+ import type { E2BAccount } from "../e2b/connection.js";
3
+ import { type ModalClient, type ModalConnection, type ModalImage, type ModalModule } from "./sdk.js";
4
+ /** The size and image of the sandboxes one module creates. */
5
+ export interface ModalSandboxProfile {
6
+ image: (client: ModalClient) => ModalImage;
7
+ /** Physical cores; Modal counts one core as two vCPU. */
8
+ cpu: number;
9
+ memoryMiB: number;
10
+ }
11
+ /**
12
+ * A sandbox port over one Modal account. `create` maps E2B's create options onto Modal's; `kill`,
13
+ * `getInfo` and `list` keep the E2B SDK's contracts, which release and reclaim read.
14
+ */
15
+ export declare function modalSandboxModule(client: ModalClient, connection: ModalConnection, profile: ModalSandboxProfile): E2BDesktopModule;
16
+ /**
17
+ * The calls on sandboxes that already exist, with the E2B SDK's contracts: `kill` is true when it
18
+ * ended a running sandbox and false when the sandbox was gone, `getInfo` throws an error named
19
+ * SandboxNotFoundError for a gone id, and `list` filters by every given tag. None of them creates
20
+ * anything, so `reclaim --check` stays read-only.
21
+ */
22
+ export declare function modalSandboxes(client: ModalClient, connection: ModalConnection): Required<E2BAccount>;
23
+ /** The Modal account `env` names, for release by id and reclaim. Throws when it names none. */
24
+ export declare function modalAccount(env: Readonly<Record<string, string | undefined>>, load?: () => Promise<ModalModule>): Promise<Required<E2BAccount>>;
25
+ /** A sandbox port that creates the terminal route's shell sandboxes on the Modal account `env` names. */
26
+ export declare function loadModalShellModule(env: Readonly<Record<string, string | undefined>>, load?: () => Promise<ModalModule>): Promise<E2BDesktopModule>;
@@ -0,0 +1,367 @@
1
+ import { MODAL_SANDBOX_USER, modalShellImage } from "./image.js";
2
+ import { isModalNotFoundError, loadModalModule, modalConnection, MODAL_TOKEN_ID_ENV, MODAL_TOKEN_SECRET_ENV, withoutTokens, } from "./sdk.js";
3
+ /** The Modal App every humanish sandbox is created in, inside the connection's environment. */
4
+ const MODAL_APP_NAME = "humanish";
5
+ /** How long a release waits for Modal to report a terminated sandbox finished; about 130 ms seen. */
6
+ const TERMINATE_WAIT_MS = 30_000;
7
+ /** Modal takes sandbox and command timeouts in whole seconds; a shorter one is rounded up. */
8
+ const wholeSeconds = (ms) => Math.ceil(ms / 1000) * 1000;
9
+ /** `fn`, with any error it throws stripped of the connection's tokens. */
10
+ function hidingTokens(connection, fn) {
11
+ return async (...args) => {
12
+ try {
13
+ return await fn(...args);
14
+ }
15
+ catch (error) {
16
+ throw withoutTokens(error, connection);
17
+ }
18
+ };
19
+ }
20
+ /** E2B's commands.run default, kept so a caller that sets no timeout gets the same bound. */
21
+ const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
22
+ /** Modal's own default sandbox lifetime is 5 minutes, as E2B's is. */
23
+ const DEFAULT_SANDBOX_TIMEOUT_MS = 5 * 60_000;
24
+ /** A Modal sandbox was asked for something only an E2B sandbox has. */
25
+ class ModalUnsupportedError extends Error {
26
+ constructor(what) {
27
+ super(`${what} is not available on Modal sandboxes in this version of humanish.`);
28
+ this.name = "ModalUnsupportedError";
29
+ }
30
+ }
31
+ /**
32
+ * A sandbox port over one Modal account. `create` maps E2B's create options onto Modal's; `kill`,
33
+ * `getInfo` and `list` keep the E2B SDK's contracts, which release and reclaim read.
34
+ */
35
+ export function modalSandboxModule(client, connection, profile) {
36
+ const humanishApp = appLookup(client, connection, true);
37
+ // The E2B SDK's second overload names a template first; Modal sandboxes have no templates.
38
+ async function create(templateOrOptions) {
39
+ if (typeof templateOrOptions === "string")
40
+ throw new ModalUnsupportedError("A desktop template");
41
+ const options = templateOrOptions;
42
+ const params = {
43
+ cpu: profile.cpu,
44
+ memoryMiB: profile.memoryMiB,
45
+ timeoutMs: wholeSeconds(options.timeoutMs ?? DEFAULT_SANDBOX_TIMEOUT_MS),
46
+ runtime: "vm",
47
+ workdir: MODAL_SANDBOX_USER.home,
48
+ ...(options.envs === undefined ? {} : { env: { ...options.envs } }),
49
+ ...(options.metadata === undefined ? {} : { tags: { ...options.metadata } }),
50
+ ...modalNetwork(options.network),
51
+ };
52
+ const sandbox = await client.sandboxes.create(await humanishApp(), profile.image(client), params);
53
+ return modalSandboxHandle(sandbox, profile, connection);
54
+ }
55
+ return {
56
+ Sandbox: {
57
+ create: hidingTokens(connection, create),
58
+ ...modalSandboxes(client, connection),
59
+ },
60
+ };
61
+ }
62
+ /** The humanish App in the connection's environment; only a create may make it. */
63
+ function appLookup(client, connection, createIfMissing) {
64
+ let app;
65
+ return () => (app ??= client.apps.fromName(MODAL_APP_NAME, {
66
+ ...(connection.environment === undefined ? {} : { environment: connection.environment }),
67
+ createIfMissing,
68
+ }));
69
+ }
70
+ /** A Modal request's default deadline, as E2B's SDK sets its own. */
71
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
72
+ /** How often a release asks whether a terminated sandbox has finished. */
73
+ const TERMINATE_POLL_MS = 500;
74
+ /**
75
+ * `request`, or a rejection once `ms` pass. The SDK takes no deadline of its own, so a stalled
76
+ * RPC would otherwise hold a release or reclaim with no end.
77
+ */
78
+ async function within(request, ms, what) {
79
+ let timer;
80
+ try {
81
+ return await Promise.race([
82
+ request,
83
+ new Promise((_resolve, reject) => {
84
+ timer = setTimeout(() => reject(new Error(`Modal did not answer ${what} within ${ms} ms.`)), ms);
85
+ }),
86
+ ]);
87
+ }
88
+ finally {
89
+ clearTimeout(timer);
90
+ }
91
+ }
92
+ /**
93
+ * The calls on sandboxes that already exist, with the E2B SDK's contracts: `kill` is true when it
94
+ * ended a running sandbox and false when the sandbox was gone, `getInfo` throws an error named
95
+ * SandboxNotFoundError for a gone id, and `list` filters by every given tag. None of them creates
96
+ * anything, so `reclaim --check` stays read-only.
97
+ */
98
+ export function modalSandboxes(client, connection) {
99
+ const existingApp = appLookup(client, connection, false);
100
+ const deadline = (options) => options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
101
+ /** The sandbox's exit code, null while it runs, or undefined when Modal has no such id. */
102
+ async function exitOf(sandboxId, ms) {
103
+ try {
104
+ const sandbox = await within(client.sandboxes.fromId(sandboxId), ms, "a sandbox lookup");
105
+ return await within(sandbox.poll(), ms, "a sandbox poll");
106
+ }
107
+ catch (error) {
108
+ if (isModalNotFoundError(error))
109
+ return undefined;
110
+ throw error;
111
+ }
112
+ }
113
+ async function kill(sandboxId, options) {
114
+ const ms = deadline(options);
115
+ if ((await exitOf(sandboxId, ms)) !== null)
116
+ return false;
117
+ const sandbox = await within(client.sandboxes.fromId(sandboxId), ms, "a sandbox lookup");
118
+ await within(sandbox.terminate(), ms, "a terminate");
119
+ // terminate returns before the sandbox stops. Polling, unlike the SDK's wait, ends when this
120
+ // loop does; a sandbox still running at the end leaves the kill unconfirmed.
121
+ for (const end = Date.now() + TERMINATE_WAIT_MS; Date.now() < end;) {
122
+ if ((await exitOf(sandboxId, ms)) !== null)
123
+ return true;
124
+ await new Promise((resolve) => setTimeout(resolve, TERMINATE_POLL_MS));
125
+ }
126
+ throw new Error(`Modal had not reported the sandbox finished ${TERMINATE_WAIT_MS / 1000} s after it was terminated.`);
127
+ }
128
+ async function getInfo(sandboxId, options) {
129
+ if ((await exitOf(sandboxId, deadline(options))) !== null)
130
+ throw sandboxNotFound(sandboxId);
131
+ return { sandboxId, state: "running" };
132
+ }
133
+ /** One page: Modal filters by tags server-side, and a run tags a few dozen sandboxes at most. */
134
+ async function listed(tags, ms) {
135
+ let app;
136
+ try {
137
+ app = await within(existingApp(), ms, "an app lookup");
138
+ }
139
+ catch (error) {
140
+ // No humanish App in this environment: no sandbox of any run is there.
141
+ if (isModalNotFoundError(error))
142
+ return [];
143
+ throw error;
144
+ }
145
+ const found = [];
146
+ const search = async () => {
147
+ for await (const sandbox of client.sandboxes.list({
148
+ appId: app.appId,
149
+ ...(tags === undefined ? {} : { tags }),
150
+ ...(connection.environment === undefined ? {} : { environment: connection.environment }),
151
+ }))
152
+ found.push({
153
+ sandboxId: sandbox.sandboxId,
154
+ metadata: await sandbox.getTags(),
155
+ state: "running",
156
+ });
157
+ };
158
+ await within(search(), ms, "a sandbox list");
159
+ return found;
160
+ }
161
+ return {
162
+ kill: hidingTokens(connection, kill),
163
+ getInfo: hidingTokens(connection, getInfo),
164
+ list: (options) => {
165
+ let read = false;
166
+ return {
167
+ get hasNext() {
168
+ return !read;
169
+ },
170
+ nextItems: hidingTokens(connection, () => {
171
+ read = true;
172
+ return listed(options?.query?.metadata, deadline(options));
173
+ }),
174
+ };
175
+ },
176
+ };
177
+ }
178
+ function sandboxNotFound(sandboxId) {
179
+ const error = new Error(`Modal has no running sandbox ${sandboxId}.`);
180
+ error.name = "SandboxNotFoundError";
181
+ return error;
182
+ }
183
+ const CIDR = /^[0-9a-f:.]+\/\d{1,3}$/i;
184
+ /**
185
+ * E2B's egress shapes that Modal can express: unrestricted, or deny-all with an allowlist of hosts
186
+ * and CIDRs. Header transforms and other deny lists have no Modal form, so plan validation refuses
187
+ * them first and this throws if one gets through.
188
+ */
189
+ function modalNetwork(network) {
190
+ if (network === undefined)
191
+ return {};
192
+ if (network.rules !== undefined && Object.keys(network.rules).length > 0)
193
+ throw new ModalUnsupportedError("An outbound header transform");
194
+ const allow = network.allowOut ?? [];
195
+ const deny = network.denyOut ?? [];
196
+ if (deny.length === 0 && allow.length === 0)
197
+ return {};
198
+ if (deny.length !== 1 || deny[0] !== "0.0.0.0/0")
199
+ throw new ModalUnsupportedError("An egress deny list other than 0.0.0.0/0");
200
+ if (allow.length === 0)
201
+ return { blockNetwork: true };
202
+ const cidrs = allow.filter((entry) => CIDR.test(entry));
203
+ const domains = allow.filter((entry) => !CIDR.test(entry));
204
+ return {
205
+ ...(cidrs.length === 0 ? {} : { outboundCidrAllowlist: cidrs }),
206
+ ...(domains.length === 0 ? {} : { outboundDomainAllowlist: domains }),
207
+ };
208
+ }
209
+ /** The argv that runs `command` in a login shell as the sandbox user, in their home. */
210
+ function asSandboxUser(command) {
211
+ const { uid } = MODAL_SANDBOX_USER;
212
+ return [
213
+ "setpriv",
214
+ `--reuid=${uid}`,
215
+ `--regid=${uid}`,
216
+ "--init-groups",
217
+ "--",
218
+ "bash",
219
+ "-lc",
220
+ command,
221
+ ];
222
+ }
223
+ const USER_ENV = {
224
+ HOME: MODAL_SANDBOX_USER.home,
225
+ USER: MODAL_SANDBOX_USER.name,
226
+ LOGNAME: MODAL_SANDBOX_USER.name,
227
+ };
228
+ /** The E2B SDK throws this shape on a non-zero exit; src/substrates/command-failure.ts reads it. */
229
+ class ModalCommandExitError extends Error {
230
+ exitCode;
231
+ stdout;
232
+ stderr;
233
+ constructor(exitCode, stdout, stderr) {
234
+ super(`Command exited with code ${exitCode}${stderr ? ` and error:\n${stderr}` : ""}`);
235
+ this.exitCode = exitCode;
236
+ this.stdout = stdout;
237
+ this.stderr = stderr;
238
+ this.name = "CommandExitError";
239
+ }
240
+ get error() {
241
+ return this.stderr;
242
+ }
243
+ }
244
+ async function collect(stream, onData) {
245
+ const decoder = new TextDecoder();
246
+ let text = "";
247
+ for await (const chunk of stream) {
248
+ const data = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
249
+ text += data;
250
+ if (onData !== undefined && data !== "")
251
+ await onData(data);
252
+ }
253
+ return text;
254
+ }
255
+ async function run(sandbox, command, options = {}) {
256
+ if (options.background === true || options.stdin === true)
257
+ throw new ModalUnsupportedError("A background command");
258
+ const timeoutMs = options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
259
+ let stdout;
260
+ let stderr;
261
+ let exitCode;
262
+ try {
263
+ const child = await sandbox.exec(asSandboxUser(command), {
264
+ env: { ...USER_ENV, ...options.envs },
265
+ ...(options.cwd === undefined ? {} : { workdir: options.cwd }),
266
+ ...(timeoutMs > 0 ? { timeoutMs: wholeSeconds(timeoutMs) } : {}),
267
+ });
268
+ // E2B starts a command with no stdin; a Modal exec keeps the pipe open, and a reader such as
269
+ // codex exec would wait on it until its timeout.
270
+ await child.closeStdin();
271
+ [stdout, stderr, exitCode] = await Promise.all([
272
+ collect(child.stdout, options.onStdout),
273
+ collect(child.stderr, options.onStderr),
274
+ child.wait(),
275
+ ]);
276
+ }
277
+ catch (error) {
278
+ // Modal ends a command at its timeout and reports it as a deadline; E2B names it TimeoutError.
279
+ if (error instanceof Error && /deadline exceeded/i.test(error.message)) {
280
+ const timeout = new Error(`Command did not finish within ${timeoutMs} ms.`, { cause: error });
281
+ timeout.name = "TimeoutError";
282
+ throw timeout;
283
+ }
284
+ throw error;
285
+ }
286
+ if (exitCode !== 0)
287
+ throw new ModalCommandExitError(exitCode, stdout, stderr);
288
+ return { exitCode, stdout, stderr };
289
+ }
290
+ /** Relative paths are under the sandbox user's home, as on E2B. */
291
+ function sandboxPath(path) {
292
+ return path.startsWith("/") ? path : `${MODAL_SANDBOX_USER.home}/${path}`;
293
+ }
294
+ async function writeFile(sandbox, path, data) {
295
+ const target = sandboxPath(path);
296
+ const child = await sandbox.exec(asSandboxUser('mkdir -p -- "$(dirname -- "$0")" && cat > "$0"').concat(target), { mode: "binary", env: USER_ENV });
297
+ await child.stdin.writeBytes(typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data));
298
+ // closeStdin ends input at offset 0, which Modal ignores once bytes were written.
299
+ await child.stdin.close();
300
+ const [stderr, exitCode] = await Promise.all([child.stderr.readBytes(), child.wait()]);
301
+ if (exitCode !== 0)
302
+ throw new ModalCommandExitError(exitCode, "", new TextDecoder().decode(stderr));
303
+ return { path: target };
304
+ }
305
+ function modalSandboxHandle(sandbox, profile, connection) {
306
+ const desktopOnly = (what) => () => Promise.reject(new ModalUnsupportedError(what));
307
+ return {
308
+ sandboxId: sandbox.sandboxId,
309
+ // Modal reports no allocation for a sandbox; the create's request is what it bills.
310
+ getInfo: () => Promise.resolve({ cpuCount: profile.cpu * 2, memoryMB: profile.memoryMiB }),
311
+ commands: {
312
+ run: hidingTokens(connection, (command, options) => run(sandbox, command, options)),
313
+ },
314
+ files: {
315
+ write: hidingTokens(connection, (path, data) => writeFile(sandbox, path, data)),
316
+ getInfo: hidingTokens(connection, async (path) => {
317
+ const result = await run(sandbox, `stat -c %s -- '${sandboxPath(path).replaceAll("'", "'\\''")}'`);
318
+ return { size: Number(result.stdout?.trim()) };
319
+ }),
320
+ },
321
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
322
+ launch: desktopOnly("A desktop application launch"),
323
+ screenshot: desktopOnly("A desktop screenshot"),
324
+ leftClick: desktopOnly("Desktop input"),
325
+ rightClick: desktopOnly("Desktop input"),
326
+ middleClick: desktopOnly("Desktop input"),
327
+ doubleClick: desktopOnly("Desktop input"),
328
+ moveMouse: desktopOnly("Desktop input"),
329
+ scroll: desktopOnly("Desktop input"),
330
+ write: desktopOnly("Desktop input"),
331
+ press: desktopOnly("Desktop input"),
332
+ drag: desktopOnly("Desktop input"),
333
+ stream: {
334
+ getAuthKey: () => {
335
+ throw new ModalUnsupportedError("A desktop stream");
336
+ },
337
+ getUrl: () => {
338
+ throw new ModalUnsupportedError("A desktop stream");
339
+ },
340
+ start: desktopOnly("A desktop stream"),
341
+ },
342
+ };
343
+ }
344
+ /** The Modal account `env` names, for release by id and reclaim. Throws when it names none. */
345
+ export async function modalAccount(env, load = loadModalModule) {
346
+ const connection = requireModalConnection(env);
347
+ const { ModalClient: Client } = await load();
348
+ return modalSandboxes(new Client(connection), connection);
349
+ }
350
+ /** The terminal route's shell: Codex needs little CPU, so it runs smaller than E2B's 8 vCPU desktop. */
351
+ const MODAL_SHELL_PROFILE = {
352
+ image: modalShellImage,
353
+ cpu: 2,
354
+ memoryMiB: 4096,
355
+ };
356
+ /** A sandbox port that creates the terminal route's shell sandboxes on the Modal account `env` names. */
357
+ export async function loadModalShellModule(env, load = loadModalModule) {
358
+ const connection = requireModalConnection(env);
359
+ const { ModalClient: Client } = await load();
360
+ return modalSandboxModule(new Client(connection), connection, MODAL_SHELL_PROFILE);
361
+ }
362
+ function requireModalConnection(env) {
363
+ const connection = modalConnection(env);
364
+ if (connection === undefined)
365
+ throw new Error(`No Modal credentials: set ${MODAL_TOKEN_ID_ENV} and ${MODAL_TOKEN_SECRET_ENV}, or run \`modal token new\`.`);
366
+ return connection;
367
+ }
@@ -0,0 +1,112 @@
1
+ /** Which Modal workspace and environment a call reaches. */
2
+ export interface ModalConnection {
3
+ tokenId: string;
4
+ tokenSecret: string;
5
+ /** Modal's own default environment is used when unset. */
6
+ environment?: string;
7
+ }
8
+ /** The variables a Modal connection is read from, as Modal's own CLI and SDK name them. */
9
+ export declare const MODAL_TOKEN_ID_ENV = "MODAL_TOKEN_ID";
10
+ export declare const MODAL_TOKEN_SECRET_ENV = "MODAL_TOKEN_SECRET";
11
+ /**
12
+ * The connection an env names, or undefined when either token is missing. The SDK would otherwise
13
+ * fall back to process.env and ~/.modal.toml, which a library caller's env may not match, and a
14
+ * release could then reach another workspace than the create.
15
+ */
16
+ export declare function modalConnection(env: Readonly<Record<string, string | undefined>>): ModalConnection | undefined;
17
+ export interface ModalReadStream extends AsyncIterable<string | Uint8Array> {
18
+ readText(): Promise<string>;
19
+ readBytes(): Promise<Uint8Array>;
20
+ }
21
+ export interface ModalProcess {
22
+ stdout: ModalReadStream;
23
+ stderr: ModalReadStream;
24
+ stdin: {
25
+ writeText(text: string): Promise<void>;
26
+ writeBytes(bytes: Uint8Array): Promise<void>;
27
+ /** End of input after what was written. */
28
+ close(): Promise<void>;
29
+ };
30
+ /** End of input at offset 0: it ends input only before anything was written. */
31
+ closeStdin(): Promise<void>;
32
+ wait(): Promise<number>;
33
+ }
34
+ export interface ModalExecParams {
35
+ mode?: "text" | "binary";
36
+ stdout?: "pipe" | "ignore";
37
+ stderr?: "pipe" | "ignore";
38
+ workdir?: string;
39
+ timeoutMs?: number;
40
+ env?: Record<string, string>;
41
+ }
42
+ export interface ModalSandbox {
43
+ readonly sandboxId: string;
44
+ exec(command: string[], params?: ModalExecParams): Promise<ModalProcess>;
45
+ terminate(): Promise<void>;
46
+ /** The exit code once the sandbox has finished, null while it runs. */
47
+ poll(): Promise<number | null>;
48
+ /** Resolves with the exit code once the sandbox has finished. */
49
+ wait(): Promise<number>;
50
+ getTags(): Promise<Record<string, string>>;
51
+ tunnels(timeoutMs?: number): Promise<Record<number, {
52
+ url: string;
53
+ }>>;
54
+ }
55
+ export interface ModalSandboxCreateParams {
56
+ cpu?: number;
57
+ memoryMiB?: number;
58
+ timeoutMs?: number;
59
+ workdir?: string;
60
+ env?: Record<string, string>;
61
+ encryptedPorts?: number[];
62
+ blockNetwork?: boolean;
63
+ outboundCidrAllowlist?: string[];
64
+ outboundDomainAllowlist?: string[];
65
+ runtime?: "gvisor" | "vm";
66
+ tags?: Record<string, string>;
67
+ }
68
+ export interface ModalImage {
69
+ dockerfileCommands(commands: string[]): ModalImage;
70
+ }
71
+ export interface ModalClient {
72
+ apps: {
73
+ fromName(name: string, params?: {
74
+ environment?: string;
75
+ createIfMissing?: boolean;
76
+ }): Promise<{
77
+ appId: string;
78
+ }>;
79
+ };
80
+ images: {
81
+ fromRegistry(tag: string): ModalImage;
82
+ };
83
+ sandboxes: {
84
+ create(app: {
85
+ appId: string;
86
+ }, image: ModalImage, params?: ModalSandboxCreateParams): Promise<ModalSandbox>;
87
+ fromId(sandboxId: string): Promise<ModalSandbox>;
88
+ list(params?: {
89
+ appId?: string;
90
+ tags?: Record<string, string>;
91
+ environment?: string;
92
+ }): AsyncIterable<ModalSandbox>;
93
+ };
94
+ close?(): void;
95
+ }
96
+ export interface ModalModule {
97
+ ModalClient: new (params: ModalConnection) => ModalClient;
98
+ }
99
+ export declare function loadModalModule(): Promise<ModalModule>;
100
+ /**
101
+ * `error` with the connection's tokens replaced in its message and stack. The SDK can quote a
102
+ * token, for instance when gRPC refuses one with a stray zero-width character as metadata, and a
103
+ * provider error reaches run warnings and reclaim receipts.
104
+ */
105
+ export declare function withoutTokens(error: unknown, connection: ModalConnection): unknown;
106
+ /**
107
+ * Whether `error` is Modal's answer that no sandbox has this id: a gRPC NOT_FOUND (code 5), which
108
+ * the SDK raises from the first call on a handle `fromId` returned for an unknown id.
109
+ */
110
+ export declare function isModalNotFoundError(error: unknown): boolean;
111
+ /** Modal ends a sandbox after 24 hours on every plan. */
112
+ export declare const MODAL_MAX_SANDBOX_MS: number;
@@ -0,0 +1,61 @@
1
+ // The optional `modal` peer: a lazy loader and the structural subset of its JavaScript SDK that
2
+ // humanish calls. Like @e2b/desktop it stays out of the published tarball, and these interfaces
3
+ // keep its types out of humanish's own declarations.
4
+ import { desktopPeerAdvice, humanishInstall } from "../e2b/peer-install.js";
5
+ /** The variables a Modal connection is read from, as Modal's own CLI and SDK name them. */
6
+ export const MODAL_TOKEN_ID_ENV = "MODAL_TOKEN_ID";
7
+ export const MODAL_TOKEN_SECRET_ENV = "MODAL_TOKEN_SECRET";
8
+ const MODAL_ENVIRONMENT_ENV = "MODAL_ENVIRONMENT";
9
+ /**
10
+ * The connection an env names, or undefined when either token is missing. The SDK would otherwise
11
+ * fall back to process.env and ~/.modal.toml, which a library caller's env may not match, and a
12
+ * release could then reach another workspace than the create.
13
+ */
14
+ export function modalConnection(env) {
15
+ const tokenId = env[MODAL_TOKEN_ID_ENV]?.trim();
16
+ const tokenSecret = env[MODAL_TOKEN_SECRET_ENV]?.trim();
17
+ if (!tokenId || !tokenSecret)
18
+ return undefined;
19
+ const environment = env[MODAL_ENVIRONMENT_ENV]?.trim();
20
+ return { tokenId, tokenSecret, ...(environment ? { environment } : {}) };
21
+ }
22
+ export async function loadModalModule() {
23
+ try {
24
+ return (await import("modal"));
25
+ }
26
+ catch (error) {
27
+ const value = error;
28
+ if (value.code === "ERR_MODULE_NOT_FOUND" && value.message?.includes("'modal'") === true) {
29
+ const { where, advice } = desktopPeerAdvice(humanishInstall(), "modal");
30
+ throw new Error(`Running on Modal requires the optional peer modal${where}. ${advice}`);
31
+ }
32
+ throw error;
33
+ }
34
+ }
35
+ /**
36
+ * `error` with the connection's tokens replaced in its message and stack. The SDK can quote a
37
+ * token, for instance when gRPC refuses one with a stray zero-width character as metadata, and a
38
+ * provider error reaches run warnings and reclaim receipts.
39
+ */
40
+ export function withoutTokens(error, connection) {
41
+ if (!(error instanceof Error))
42
+ return error;
43
+ const tokens = [connection.tokenId, connection.tokenSecret];
44
+ const hide = (text) => tokens.reduce((hidden, token) => hidden.replaceAll(token, "[REDACTED_MODAL_TOKEN]"), text);
45
+ error.message = hide(error.message);
46
+ if (error.stack !== undefined)
47
+ error.stack = hide(error.stack);
48
+ return error;
49
+ }
50
+ /**
51
+ * Whether `error` is Modal's answer that no sandbox has this id: a gRPC NOT_FOUND (code 5), which
52
+ * the SDK raises from the first call on a handle `fromId` returned for an unknown id.
53
+ */
54
+ export function isModalNotFoundError(error) {
55
+ if (error === null || typeof error !== "object")
56
+ return false;
57
+ const value = error;
58
+ return value.name === "NotFoundError" || value.code === 5;
59
+ }
60
+ /** Modal ends a sandbox after 24 hours on every plan. */
61
+ export const MODAL_MAX_SANDBOX_MS = 24 * 60 * 60_000;
@@ -28,7 +28,7 @@ fail-closed cross-validation, and forward-declared warnings.
28
28
  | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
29
29
  | `subject.source` | `terminal-product` |
30
30
  | `subject.product` | `{ name, publicSurfaces[], install?, workdir?, upload? }`: the only world the agent sees |
31
- | `execution.target` | `e2b-terminal` (or absent → implied) |
31
+ | `execution.target` | `e2b-terminal` (or absent → implied), or `modal-terminal` for a Modal sandbox |
32
32
  | `execution.terminal` | `{ transport: exec-stream, stdin: disabled }` |
33
33
  | `execution.runtimeAuth` | `openai-egress` (default) or opt-in `openai-env`; names-only durable evidence |
34
34
  | `execution.runtime.version` | Optional exact `@openai/codex` version; observed before keyed execution |
@@ -703,8 +703,10 @@ Dry runs omit `cost`. A live run that spends nothing records an explicit zero: n
703
703
  `breakdown` lines, `estimatedTotalUsd: 0`, `ratesAsOf: null` and `fullyEstimated: true`.
704
704
  A local scripted run (no model request, no hosted desktop) is such a run; a scripted run on
705
705
  a provisioned clone records the clone's desktop as a `laneId: subject` line. A live bundle
706
- without `cost` has an unmeasured spend. A live terminal run's `cost` prices the E2B terminal
707
- sandbox as a `desktop-minutes` line from its acquired-to-cleanup span and `e2b.getInfo` size.
706
+ without `cost` has an unmeasured spend. A live terminal run's `cost` prices its shell sandbox
707
+ as a `desktop-minutes` line from its acquired-to-cleanup span and size: E2B's report
708
+ (`resourceSource: e2b.getInfo`), or the size humanish asked Modal for (`modal.create`), priced at
709
+ Modal's sandbox rate.
708
710
  Its Codex `model-tokens` line prices the agent's tokens from the model passed as `--model`
709
711
  (`actor.model`, else `gpt-5.6-sol`). Codex reports usage per turn, summed over the turn's
710
712
  requests, so the line prices every token at the base tier and carries
@@ -336,10 +336,10 @@ The rest of this section names keys by their v3 spelling:
336
336
  - `subject.appUrl`: a loopback http(s) URL a computer-use actor drives, or (on the
337
337
  external-public shared-world route) the non-loopback public deployment used as the
338
338
  shared plane (with `publicTarget` + `allowPublicTargets`);
339
- - `execution`: where it runs (`local`, `e2b-desktop`, or `e2b-terminal`), plus
339
+ - `execution`: where it runs (`local`, `e2b-desktop`, `e2b-terminal` or `modal-terminal`), plus
340
340
  desktop device/resolution and timeouts. app-url subjects pair `e2b-desktop`
341
341
  or `local` with a computer-use actor, or `local` (or absent) with a
342
- scripted-browser actor; terminal-product subjects pair `e2b-terminal` (or absent → implied)
342
+ scripted-browser actor; terminal-product subjects pair `e2b-terminal` or `modal-terminal` (or absent → `e2b-terminal`)
343
343
  with a registered terminal actor. clone and local-tree subjects require an
344
344
  explicit `e2b-desktop`: any other target, or none, fails to parse, and the
345
345
  computer-use and scripted-browser routes refuse it when a library caller skips
@@ -1297,7 +1297,7 @@ items: []
1297
1297
  Reserved: `humanish.substrate.v1` is named here for layering intent but has
1298
1298
  never shipped; no code emits or validates it. Substrate truth today lives
1299
1299
  inside run bundles (per-stream transport and status) and study execution config
1300
- (`execution.target: local | e2b-desktop | e2b-terminal`). Do not emit this schema.
1300
+ (`execution.target: local | e2b-desktop | e2b-terminal | modal-terminal`). Do not emit this schema.
1301
1301
 
1302
1302
  ## Serve Result And Reserved Control-Plane Namespace
1303
1303
 
@@ -1578,12 +1578,13 @@ Three new `.v1` schema tags ship, all additive and optional so
1578
1578
  lines remain readable.
1579
1579
  A desktop line's optional `desktop` object records `minutes`,
1580
1580
  `durationBasis: host-acquired-to-cleanup`, observed `resources` (`cpuCount`,
1581
- `memoryMiB`), `resourceSource: e2b.getInfo`, and `usdPerSecond` when priceable.
1581
+ `memoryMiB`), `resourceSource` (`e2b.getInfo`, or `modal.create` for the size humanish asked
1582
+ Modal for), and `usdPerSecond` when priceable.
1582
1583
  Failed metadata reads record `resourceUnavailableReason`; no resource guess is used.
1583
1584
  Resource metadata and missing/unsupported rates produce a null line. A kept or
1584
1585
  unconfirmed allocation adds `desktop_lifetime_incomplete` as a second null line.
1585
1586
  Live terminal-product runs emit the same summary: one `desktop-minutes` line for
1586
- the E2B shell sandbox (its acquired-to-cleanup span and `e2b.getInfo` size) and
1587
+ the shell sandbox (its acquired-to-cleanup span and its `e2b.getInfo` or `modal.create` size) and
1587
1588
  one `model-tokens` line for the Codex participant, priced from the model the route passed
1588
1589
  to Codex (`gpt-5.6-sol` unless the study declares one) with `basis:
1589
1590
  aggregated_turns_base_rate`, or `null` with `no_token_usage`. Older terminal bundles record