humanish 0.137.0 → 0.139.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 (63) hide show
  1. package/CHANGELOG.md +22 -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/computer-use/bundle-parts.d.ts +3 -0
  6. package/dist/routes/computer-use/bundle-parts.js +9 -1
  7. package/dist/routes/computer-use/bundle.js +3 -4
  8. package/dist/routes/computer-use/e2b-desktop/prepare.js +8 -4
  9. package/dist/routes/computer-use/fanout-bundle.js +3 -2
  10. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  11. package/dist/routes/computer-use/plan.js +66 -19
  12. package/dist/routes/computer-use/setup.js +14 -7
  13. package/dist/routes/computer-use/types.d.ts +3 -0
  14. package/dist/routes/terminal/live-sandbox.d.ts +2 -0
  15. package/dist/routes/terminal/live-sandbox.js +8 -3
  16. package/dist/routes/terminal/plan.js +49 -6
  17. package/dist/routes/terminal/session.js +8 -1
  18. package/dist/run/bundle-shape.js +1 -1
  19. package/dist/run/bundle.d.ts +5 -3
  20. package/dist/run/cost-summary.js +9 -4
  21. package/dist/run/feedback-shape.js +1 -0
  22. package/dist/run/pricing.d.ts +11 -0
  23. package/dist/run/pricing.js +18 -5
  24. package/dist/run/reclaim-outcomes.d.ts +4 -1
  25. package/dist/run/reclaim-outcomes.js +8 -6
  26. package/dist/run/reclaim.d.ts +4 -1
  27. package/dist/run/reclaim.js +81 -34
  28. package/dist/run/sandbox-creates.d.ts +7 -4
  29. package/dist/run/sandbox-creates.js +17 -9
  30. package/dist/run/sandbox-receipts.d.ts +13 -5
  31. package/dist/run/sandbox-receipts.js +18 -6
  32. package/dist/study/composition-rules.js +14 -12
  33. package/dist/study/doctor.js +1 -1
  34. package/dist/study/parse/execution.js +6 -2
  35. package/dist/study/plan-base.d.ts +1 -0
  36. package/dist/study/plan-base.js +2 -0
  37. package/dist/study/plan-types.d.ts +6 -1
  38. package/dist/study/requirements.js +7 -2
  39. package/dist/study/routing.d.ts +1 -1
  40. package/dist/study/routing.js +1 -1
  41. package/dist/study/types.d.ts +1 -1
  42. package/dist/study/warnings.js +3 -1
  43. package/dist/substrates/e2b/desktop-resources.d.ts +4 -2
  44. package/dist/substrates/e2b/desktop-resources.js +2 -2
  45. package/dist/substrates/e2b/peer-install.js +1 -1
  46. package/dist/substrates/e2b/sandbox.d.ts +3 -0
  47. package/dist/substrates/e2b/sandbox.js +5 -4
  48. package/dist/substrates/e2b/sdk.d.ts +5 -0
  49. package/dist/substrates/e2b/sdk.js +12 -0
  50. package/dist/substrates/modal/commands.d.ts +16 -0
  51. package/dist/substrates/modal/commands.js +174 -0
  52. package/dist/substrates/modal/desktop.d.ts +16 -0
  53. package/dist/substrates/modal/desktop.js +195 -0
  54. package/dist/substrates/modal/image.d.ts +11 -0
  55. package/dist/substrates/modal/image.js +71 -0
  56. package/dist/substrates/modal/sandbox.d.ts +30 -0
  57. package/dist/substrates/modal/sandbox.js +325 -0
  58. package/dist/substrates/modal/sdk.d.ts +112 -0
  59. package/dist/substrates/modal/sdk.js +61 -0
  60. package/docs/architecture/terminal-product-route.md +1 -1
  61. package/docs/contracts/run-bundle.md +5 -2
  62. package/docs/contracts/schemas.md +8 -7
  63. package/package.json +8 -2
@@ -0,0 +1,195 @@
1
+ // A desktop in a Modal sandbox, with @e2b/desktop's behavior: the same Xvfb and XFCE start, the
2
+ // same xdotool commands for each input, scrot for screenshots and x11vnc behind noVNC for the live
3
+ // stream. The executor and every e2b-desktop module were tuned against that SDK, so this follows it
4
+ // command for command (the Sandbox class of @e2b/desktop 2.4.0) rather than improving on it.
5
+ import { randomBytes } from "node:crypto";
6
+ import { shellQuote } from "../shell.js";
7
+ /** The X display and the ports the desktop's stream uses, as @e2b/desktop sets them. */
8
+ export const MODAL_DESKTOP_DISPLAY = ":0";
9
+ const VNC_PORT = 5900;
10
+ export const MODAL_STREAM_PORT = 6080;
11
+ const KEYS = {
12
+ alt: "Alt_L",
13
+ alt_left: "Alt_L",
14
+ alt_right: "Alt_R",
15
+ backspace: "BackSpace",
16
+ break: "Pause",
17
+ caps_lock: "Caps_Lock",
18
+ cmd: "Super_L",
19
+ command: "Super_L",
20
+ control: "Control_L",
21
+ control_left: "Control_L",
22
+ control_right: "Control_R",
23
+ ctrl: "Control_L",
24
+ del: "Delete",
25
+ delete: "Delete",
26
+ down: "Down",
27
+ end: "End",
28
+ enter: "Return",
29
+ esc: "Escape",
30
+ escape: "Escape",
31
+ f1: "F1",
32
+ f2: "F2",
33
+ f3: "F3",
34
+ f4: "F4",
35
+ f5: "F5",
36
+ f6: "F6",
37
+ f7: "F7",
38
+ f8: "F8",
39
+ f9: "F9",
40
+ f10: "F10",
41
+ f11: "F11",
42
+ f12: "F12",
43
+ home: "Home",
44
+ insert: "Insert",
45
+ left: "Left",
46
+ menu: "Menu",
47
+ meta: "Meta_L",
48
+ num_lock: "Num_Lock",
49
+ page_down: "Page_Down",
50
+ page_up: "Page_Up",
51
+ pause: "Pause",
52
+ print: "Print",
53
+ right: "Right",
54
+ scroll_lock: "Scroll_Lock",
55
+ shift: "Shift_L",
56
+ shift_left: "Shift_L",
57
+ shift_right: "Shift_R",
58
+ space: "space",
59
+ super: "Super_L",
60
+ super_left: "Super_L",
61
+ super_right: "Super_R",
62
+ tab: "Tab",
63
+ up: "Up",
64
+ win: "Super_L",
65
+ windows: "Super_L",
66
+ };
67
+ const mapKey = (key) => KEYS[key.toLowerCase()] ?? key.toLowerCase();
68
+ /** Run `command` every `intervalMs` until it exits 0, for at most `timeoutMs`. */
69
+ async function waitUntil(run, command, timeoutMs, intervalMs = 500) {
70
+ for (const end = Date.now() + timeoutMs; Date.now() < end;) {
71
+ try {
72
+ await run(command);
73
+ return true;
74
+ }
75
+ catch (error) {
76
+ if (error.name !== "CommandExitError")
77
+ throw error;
78
+ }
79
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
80
+ }
81
+ return false;
82
+ }
83
+ /** Start Xvfb at the requested size, then an XFCE session, as @e2b/desktop's create does. */
84
+ export async function startModalDesktop(run, options) {
85
+ const [width, height] = options.resolution ?? [1024, 768];
86
+ const display = MODAL_DESKTOP_DISPLAY;
87
+ await run(`setsid -f Xvfb ${display} -ac -screen 0 ${width}x${height}x24 -retro -dpi ${options.dpi ?? 96} -nolisten tcp -nolisten unix </dev/null >/tmp/xvfb.log 2>&1`);
88
+ if (!(await waitUntil(run, `xdpyinfo -display ${display} >/dev/null`, 10_000)))
89
+ throw new Error("Could not start Xvfb");
90
+ await run("setsid -f startxfce4 </dev/null >/tmp/xfce4.log 2>&1");
91
+ if (!(await waitUntil(run, "pgrep -x xfce4-session >/dev/null && pgrep -x xfwm4 >/dev/null && pgrep -x xfdesktop >/dev/null", 60_000, 1_000)))
92
+ throw new Error("Could not start XFCE");
93
+ }
94
+ /**
95
+ * The desktop half of a sandbox handle. `hosts` maps each tunneled port to the host Modal serves it
96
+ * on; `readBytes` reads a file in the sandbox.
97
+ */
98
+ export function modalDesktopSurface(run, readBytes, hosts) {
99
+ const getHost = (port) => {
100
+ const host = hosts.get(port);
101
+ if (host === undefined)
102
+ throw new Error(`Port ${port} has no Modal tunnel in this sandbox.`);
103
+ return host;
104
+ };
105
+ const moveMouse = async (x, y) => {
106
+ await run(`xdotool mousemove --sync ${x} ${y}`);
107
+ };
108
+ // The SDK moves only when both coordinates are truthy, so a click at x or y 0 clicks in place.
109
+ const clickAt = async (x, y, click) => {
110
+ if (x && y)
111
+ await moveMouse(x, y);
112
+ await run(`xdotool click ${click}`);
113
+ };
114
+ let streamUrl;
115
+ let password;
116
+ return {
117
+ getHost,
118
+ async screenshot() {
119
+ const path = `/tmp/screenshot-${randomBytes(8).toString("hex")}.png`;
120
+ await run(`scrot --pointer ${path}`);
121
+ try {
122
+ return await readBytes(path);
123
+ }
124
+ finally {
125
+ void run(`rm -f ${path}`).catch(() => { });
126
+ }
127
+ },
128
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
129
+ leftClick: (x, y) => clickAt(x, y, "1"),
130
+ rightClick: (x, y) => clickAt(x, y, "3"),
131
+ middleClick: (x, y) => clickAt(x, y, "2"),
132
+ doubleClick: (x, y) => clickAt(x, y, "--repeat 2 1"),
133
+ moveMouse,
134
+ async getCursorPosition() {
135
+ const result = await run("xdotool getmouselocation");
136
+ const match = (result.stdout ?? "").match(/x:(\d+)\s+y:(\d+)/);
137
+ if (!match)
138
+ throw new Error(`Failed to parse cursor position from output: ${result.stdout}`);
139
+ return { x: Number.parseInt(match[1], 10), y: Number.parseInt(match[2], 10) };
140
+ },
141
+ async scroll(direction = "down", amount = 1) {
142
+ await run(`xdotool click --repeat ${amount} ${direction === "up" ? "4" : "5"}`);
143
+ },
144
+ async write(text) {
145
+ for (let at = 0; at < text.length; at += 25)
146
+ await run(`xdotool type --delay 75 -- ${shellQuote(text.slice(at, at + 25))}`);
147
+ },
148
+ async press(key) {
149
+ await run(`xdotool key ${Array.isArray(key) ? key.map(mapKey).join("+") : mapKey(key)}`);
150
+ },
151
+ async drag(from, to) {
152
+ await moveMouse(...from);
153
+ await run("xdotool mousedown 1");
154
+ await moveMouse(...to);
155
+ await run("xdotool mouseup 1");
156
+ },
157
+ async open(fileOrUrl) {
158
+ await run(`setsid -f xdg-open ${fileOrUrl} </dev/null >/dev/null 2>&1`);
159
+ },
160
+ async launch(application, uri) {
161
+ await run(`setsid -f gtk-launch ${application} ${uri ?? ""} </dev/null >/dev/null 2>&1`);
162
+ },
163
+ stream: {
164
+ getAuthKey() {
165
+ if (password === undefined)
166
+ throw new Error("Unable to retrieve stream auth key, check if requireAuth is enabled");
167
+ return password;
168
+ },
169
+ getUrl({ autoConnect = true, viewOnly = false, resize = "scale", authKey } = {}) {
170
+ if (streamUrl === undefined)
171
+ throw new Error("Server is not running");
172
+ const url = new URL(streamUrl);
173
+ if (autoConnect)
174
+ url.searchParams.set("autoconnect", "true");
175
+ if (viewOnly)
176
+ url.searchParams.set("view_only", "true");
177
+ if (resize)
178
+ url.searchParams.set("resize", resize);
179
+ if (authKey)
180
+ url.searchParams.set("password", authKey);
181
+ return url.toString();
182
+ },
183
+ async start({ requireAuth = false, windowId } = {}) {
184
+ password = requireAuth ? randomBytes(12).toString("base64url") : undefined;
185
+ streamUrl = new URL(`https://${getHost(MODAL_STREAM_PORT)}/vnc.html`);
186
+ if (password !== undefined)
187
+ await run(`mkdir -p ~/.vnc && x11vnc -storepasswd ${password} ~/.vnc/passwd`);
188
+ await run(`x11vnc -bg -display ${MODAL_DESKTOP_DISPLAY} -forever -wait 50 -shared -rfbport ${VNC_PORT} ${password === undefined ? "-nopw" : "-usepw"} 2>/tmp/x11vnc_stderr.log${windowId === undefined ? "" : ` -id ${windowId}`}`);
189
+ await run(`setsid -f websockify --web /usr/share/novnc ${MODAL_STREAM_PORT} localhost:${VNC_PORT} </dev/null >/tmp/novnc.log 2>&1`);
190
+ if (!(await waitUntil(run, `netstat -tuln | grep ":${MODAL_STREAM_PORT} "`, 10_000)))
191
+ throw new Error("Could not start noVNC server");
192
+ },
193
+ },
194
+ };
195
+ }
@@ -0,0 +1,11 @@
1
+ import type { ModalClient, ModalImage } from "./sdk.js";
2
+ /** The sandbox account commands run as, matching E2B's `user` and its home. */
3
+ export declare const MODAL_SANDBOX_USER: {
4
+ readonly name: "user";
5
+ readonly uid: 1000;
6
+ readonly home: "/home/user";
7
+ };
8
+ /** The terminal route's image: a Debian shell with a passwordless-sudo `user`, as on E2B. */
9
+ export declare function modalShellImage(client: ModalClient): ModalImage;
10
+ /** The computer-use route's image: the shell's packages and an XFCE desktop with Chromium. */
11
+ export declare function modalDesktopImage(client: ModalClient): ModalImage;
@@ -0,0 +1,71 @@
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
+ // What the e2b-desktop modules call in a desktop, beside the shell's packages: Xvfb and an XFCE
25
+ // session as @e2b/desktop starts them, xdotool and scrot for input and screenshots, x11vnc and
26
+ // noVNC for the stream, the X utilities the geometry and screen probes run, wmctrl, ffmpeg and
27
+ // PulseAudio for recordings, Chromium, and fonts for Latin, Chinese, Japanese, Korean and emoji
28
+ // text.
29
+ const DESKTOP_PACKAGES = [
30
+ "chromium",
31
+ "dbus-x11",
32
+ "ffmpeg",
33
+ "fonts-dejavu",
34
+ "fonts-noto-cjk",
35
+ "fonts-noto-color-emoji",
36
+ "net-tools",
37
+ "novnc",
38
+ "pulseaudio",
39
+ "pulseaudio-utils",
40
+ "scrot",
41
+ "websockify",
42
+ "wmctrl",
43
+ "x11-utils",
44
+ "x11-xserver-utils",
45
+ "x11vnc",
46
+ "xdg-utils",
47
+ "xdotool",
48
+ "xfce4",
49
+ "xfce4-terminal",
50
+ "xvfb",
51
+ ];
52
+ /** The terminal route's image: a Debian shell with a passwordless-sudo `user`, as on E2B. */
53
+ export function modalShellImage(client) {
54
+ return debianImage(client, SHELL_PACKAGES);
55
+ }
56
+ /** The computer-use route's image: the shell's packages and an XFCE desktop with Chromium. */
57
+ export function modalDesktopImage(client) {
58
+ return debianImage(client, [...SHELL_PACKAGES, ...DESKTOP_PACKAGES]);
59
+ }
60
+ function debianImage(client, packages) {
61
+ const { name, uid, home } = MODAL_SANDBOX_USER;
62
+ // prose-check: script
63
+ return client.images
64
+ .fromRegistry(DEBIAN_BASE)
65
+ .dockerfileCommands([
66
+ "ENV DEBIAN_FRONTEND=noninteractive",
67
+ `RUN rm -f /etc/apt/sources.list /etc/apt/sources.list.d/* && ${SNAPSHOT_SOURCES} && apt-get update && apt-get install -y --no-install-recommends ${packages.join(" ")} && rm -rf /var/lib/apt/lists/*`,
68
+ `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}`,
69
+ `WORKDIR ${home}`,
70
+ ]);
71
+ }
@@ -0,0 +1,30 @@
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
+ /** Start an X desktop after create and tunnel its stream, as @e2b/desktop's create does. */
11
+ desktop?: true;
12
+ }
13
+ /**
14
+ * A sandbox port over one Modal account. `create` maps E2B's create options onto Modal's; `kill`,
15
+ * `getInfo` and `list` keep the E2B SDK's contracts, which release and reclaim read.
16
+ */
17
+ export declare function modalSandboxModule(client: ModalClient, connection: ModalConnection, profile: ModalSandboxProfile): E2BDesktopModule;
18
+ /**
19
+ * The calls on sandboxes that already exist, with the E2B SDK's contracts: `kill` is true when it
20
+ * ended a running sandbox and false when the sandbox was gone, `getInfo` throws an error named
21
+ * SandboxNotFoundError for a gone id, and `list` filters by every given tag. None of them creates
22
+ * anything, so `reclaim --check` stays read-only.
23
+ */
24
+ export declare function modalSandboxes(client: ModalClient, connection: ModalConnection): Required<E2BAccount>;
25
+ /** The Modal account `env` names, for release by id and reclaim. Throws when it names none. */
26
+ export declare function modalAccount(env: Readonly<Record<string, string | undefined>>, load?: () => Promise<ModalModule>): Promise<Required<E2BAccount>>;
27
+ /** A sandbox port that creates the computer-use route's desktops on the Modal account `env` names. */
28
+ export declare function loadModalDesktopModule(env: Readonly<Record<string, string | undefined>>, load?: () => Promise<ModalModule>): Promise<E2BDesktopModule>;
29
+ /** A sandbox port that creates the terminal route's shell sandboxes on the Modal account `env` names. */
30
+ export declare function loadModalShellModule(env: Readonly<Record<string, string | undefined>>, load?: () => Promise<ModalModule>): Promise<E2BDesktopModule>;
@@ -0,0 +1,325 @@
1
+ // Modal sandboxes behind the sandbox port the E2B routes already use (E2BDesktopModule and
2
+ // E2BDesktopSandbox in src/substrates/e2b/sdk.ts). acquire() in src/substrates/e2b/sandbox.ts,
3
+ // the receipts, the release by id and reclaim then work the same on both providers.
4
+ //
5
+ // Modal runs every exec as root and ignores the image's user setting, so each command drops to the
6
+ // sandbox user here, the account E2B runs commands as. Sandboxes use Modal's VM runtime: its
7
+ // default gVisor runtime sets no_new_privs on every process, so the user's sudo, which the Node
8
+ // bootstrap and product installs run, cannot work there.
9
+ import { E2BDesktopStartupError, reportDesktopAllocation } from "../e2b/sdk.js";
10
+ import { readBytes, readFile, runCommand, sandboxPath, wholeSeconds, writeFile, } from "./commands.js";
11
+ import { MODAL_DESKTOP_DISPLAY, MODAL_STREAM_PORT, modalDesktopSurface, startModalDesktop, } from "./desktop.js";
12
+ import { MODAL_SANDBOX_USER, modalDesktopImage, modalShellImage } from "./image.js";
13
+ import { isModalNotFoundError, loadModalModule, modalConnection, MODAL_TOKEN_ID_ENV, MODAL_TOKEN_SECRET_ENV, withoutTokens, } from "./sdk.js";
14
+ /** The Modal App every humanish sandbox is created in, inside the connection's environment. */
15
+ const MODAL_APP_NAME = "humanish";
16
+ /** How long a release waits for Modal to report a terminated sandbox finished; about 130 ms seen. */
17
+ const TERMINATE_WAIT_MS = 30_000;
18
+ /** `fn`, with any error it throws stripped of the connection's tokens. */
19
+ function hidingTokens(connection, fn) {
20
+ return async (...args) => {
21
+ try {
22
+ return await fn(...args);
23
+ }
24
+ catch (error) {
25
+ throw withoutTokens(error, connection);
26
+ }
27
+ };
28
+ }
29
+ /** Modal's own default sandbox lifetime is 5 minutes, as E2B's is. */
30
+ const DEFAULT_SANDBOX_TIMEOUT_MS = 5 * 60_000;
31
+ /** A Modal sandbox was asked for something only an E2B sandbox has. */
32
+ class ModalUnsupportedError extends Error {
33
+ constructor(what) {
34
+ super(`${what} is not available on Modal sandboxes in this version of humanish.`);
35
+ this.name = "ModalUnsupportedError";
36
+ }
37
+ }
38
+ /**
39
+ * A sandbox port over one Modal account. `create` maps E2B's create options onto Modal's; `kill`,
40
+ * `getInfo` and `list` keep the E2B SDK's contracts, which release and reclaim read.
41
+ */
42
+ export function modalSandboxModule(client, connection, profile) {
43
+ const humanishApp = appLookup(client, connection, true);
44
+ // The E2B SDK's second overload names a template first; Modal sandboxes have no templates.
45
+ async function create(templateOrOptions) {
46
+ if (typeof templateOrOptions === "string")
47
+ throw new ModalUnsupportedError("A desktop template");
48
+ const options = templateOrOptions;
49
+ const desktop = profile.desktop === true;
50
+ const env = { ...options.envs, ...(desktop ? { DISPLAY: MODAL_DESKTOP_DISPLAY } : {}) };
51
+ const params = {
52
+ cpu: profile.cpu,
53
+ memoryMiB: profile.memoryMiB,
54
+ timeoutMs: wholeSeconds(options.timeoutMs ?? DEFAULT_SANDBOX_TIMEOUT_MS),
55
+ runtime: "vm",
56
+ workdir: MODAL_SANDBOX_USER.home,
57
+ ...(Object.keys(env).length === 0 ? {} : { env }),
58
+ ...(options.metadata === undefined ? {} : { tags: { ...options.metadata } }),
59
+ ...(desktop ? { encryptedPorts: [MODAL_STREAM_PORT] } : {}),
60
+ ...modalNetwork(options.network),
61
+ };
62
+ const sandbox = await client.sandboxes.create(await humanishApp(), profile.image(client), params);
63
+ if (!desktop)
64
+ return modalSandboxHandle(sandbox, profile, connection, new Map());
65
+ // The id reaches the run's journal before the desktop starts, as E2B's create guard reports it.
66
+ reportDesktopAllocation(options, sandbox.sandboxId);
67
+ try {
68
+ const run = (command, runOptions) => runCommand(sandbox, command, runOptions);
69
+ await startModalDesktop(run, {
70
+ ...(options.resolution === undefined ? {} : { resolution: options.resolution }),
71
+ ...(options.dpi === undefined ? {} : { dpi: options.dpi }),
72
+ });
73
+ const tunnels = await sandbox.tunnels(30_000);
74
+ const hosts = new Map(Object.entries(tunnels).map(([port, tunnel]) => [Number(port), new URL(tunnel.url).host]));
75
+ return modalSandboxHandle(sandbox, profile, connection, hosts);
76
+ }
77
+ catch (error) {
78
+ const gone = await modalSandboxes(client, connection)
79
+ .kill(sandbox.sandboxId)
80
+ .then(() => "killed")
81
+ .catch(() => "unconfirmed");
82
+ throw new E2BDesktopStartupError(error, gone);
83
+ }
84
+ }
85
+ return {
86
+ Sandbox: {
87
+ create: hidingTokens(connection, create),
88
+ ...modalSandboxes(client, connection),
89
+ },
90
+ };
91
+ }
92
+ /** The humanish App in the connection's environment; only a create may make it. */
93
+ function appLookup(client, connection, createIfMissing) {
94
+ let app;
95
+ return () => (app ??= client.apps.fromName(MODAL_APP_NAME, {
96
+ ...(connection.environment === undefined ? {} : { environment: connection.environment }),
97
+ createIfMissing,
98
+ }));
99
+ }
100
+ /** A Modal request's default deadline, as E2B's SDK sets its own. */
101
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
102
+ /** How often a release asks whether a terminated sandbox has finished. */
103
+ const TERMINATE_POLL_MS = 500;
104
+ /**
105
+ * `request`, or a rejection once `ms` pass. The SDK takes no deadline of its own, so a stalled
106
+ * RPC would otherwise hold a release or reclaim with no end.
107
+ */
108
+ async function within(request, ms, what) {
109
+ let timer;
110
+ try {
111
+ return await Promise.race([
112
+ request,
113
+ new Promise((_resolve, reject) => {
114
+ timer = setTimeout(() => reject(new Error(`Modal did not answer ${what} within ${ms} ms.`)), ms);
115
+ }),
116
+ ]);
117
+ }
118
+ finally {
119
+ clearTimeout(timer);
120
+ }
121
+ }
122
+ /**
123
+ * The calls on sandboxes that already exist, with the E2B SDK's contracts: `kill` is true when it
124
+ * ended a running sandbox and false when the sandbox was gone, `getInfo` throws an error named
125
+ * SandboxNotFoundError for a gone id, and `list` filters by every given tag. None of them creates
126
+ * anything, so `reclaim --check` stays read-only.
127
+ */
128
+ export function modalSandboxes(client, connection) {
129
+ const existingApp = appLookup(client, connection, false);
130
+ const deadline = (options) => options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
131
+ /** The sandbox's exit code, null while it runs, or undefined when Modal has no such id. */
132
+ async function exitOf(sandboxId, ms) {
133
+ try {
134
+ const sandbox = await within(client.sandboxes.fromId(sandboxId), ms, "a sandbox lookup");
135
+ return await within(sandbox.poll(), ms, "a sandbox poll");
136
+ }
137
+ catch (error) {
138
+ if (isModalNotFoundError(error))
139
+ return undefined;
140
+ throw error;
141
+ }
142
+ }
143
+ async function kill(sandboxId, options) {
144
+ const ms = deadline(options);
145
+ if ((await exitOf(sandboxId, ms)) !== null)
146
+ return false;
147
+ const sandbox = await within(client.sandboxes.fromId(sandboxId), ms, "a sandbox lookup");
148
+ await within(sandbox.terminate(), ms, "a terminate");
149
+ // terminate returns before the sandbox stops. Polling, unlike the SDK's wait, ends when this
150
+ // loop does; a sandbox still running at the end leaves the kill unconfirmed.
151
+ for (const end = Date.now() + TERMINATE_WAIT_MS; Date.now() < end;) {
152
+ if ((await exitOf(sandboxId, ms)) !== null)
153
+ return true;
154
+ await new Promise((resolve) => setTimeout(resolve, TERMINATE_POLL_MS));
155
+ }
156
+ throw new Error(`Modal had not reported the sandbox finished ${TERMINATE_WAIT_MS / 1000} s after it was terminated.`);
157
+ }
158
+ async function getInfo(sandboxId, options) {
159
+ if ((await exitOf(sandboxId, deadline(options))) !== null)
160
+ throw sandboxNotFound(sandboxId);
161
+ return { sandboxId, state: "running" };
162
+ }
163
+ /** One page: Modal filters by tags server-side, and a run tags a few dozen sandboxes at most. */
164
+ async function listed(tags, ms) {
165
+ let app;
166
+ try {
167
+ app = await within(existingApp(), ms, "an app lookup");
168
+ }
169
+ catch (error) {
170
+ // No humanish App in this environment: no sandbox of any run is there.
171
+ if (isModalNotFoundError(error))
172
+ return [];
173
+ throw error;
174
+ }
175
+ const found = [];
176
+ const search = async () => {
177
+ for await (const sandbox of client.sandboxes.list({
178
+ appId: app.appId,
179
+ ...(tags === undefined ? {} : { tags }),
180
+ ...(connection.environment === undefined ? {} : { environment: connection.environment }),
181
+ }))
182
+ found.push({
183
+ sandboxId: sandbox.sandboxId,
184
+ metadata: await sandbox.getTags(),
185
+ state: "running",
186
+ });
187
+ };
188
+ await within(search(), ms, "a sandbox list");
189
+ return found;
190
+ }
191
+ return {
192
+ kill: hidingTokens(connection, kill),
193
+ getInfo: hidingTokens(connection, getInfo),
194
+ list: (options) => {
195
+ let read = false;
196
+ return {
197
+ get hasNext() {
198
+ return !read;
199
+ },
200
+ nextItems: hidingTokens(connection, () => {
201
+ read = true;
202
+ return listed(options?.query?.metadata, deadline(options));
203
+ }),
204
+ };
205
+ },
206
+ };
207
+ }
208
+ function sandboxNotFound(sandboxId) {
209
+ const error = new Error(`Modal has no running sandbox ${sandboxId}.`);
210
+ error.name = "SandboxNotFoundError";
211
+ return error;
212
+ }
213
+ const CIDR = /^[0-9a-f:.]+\/\d{1,3}$/i;
214
+ /**
215
+ * E2B's egress shapes that Modal can express: unrestricted, or deny-all with an allowlist of hosts
216
+ * and CIDRs. Header transforms and other deny lists have no Modal form, so plan validation refuses
217
+ * them first and this throws if one gets through.
218
+ */
219
+ function modalNetwork(network) {
220
+ if (network === undefined)
221
+ return {};
222
+ if (network.rules !== undefined && Object.keys(network.rules).length > 0)
223
+ throw new ModalUnsupportedError("An outbound header transform");
224
+ const allow = network.allowOut ?? [];
225
+ const deny = network.denyOut ?? [];
226
+ if (deny.length === 0 && allow.length === 0)
227
+ return {};
228
+ if (deny.length !== 1 || deny[0] !== "0.0.0.0/0")
229
+ throw new ModalUnsupportedError("An egress deny list other than 0.0.0.0/0");
230
+ if (allow.length === 0)
231
+ return { blockNetwork: true };
232
+ const cidrs = allow.filter((entry) => CIDR.test(entry));
233
+ const domains = allow.filter((entry) => !CIDR.test(entry));
234
+ return {
235
+ ...(cidrs.length === 0 ? {} : { outboundCidrAllowlist: cidrs }),
236
+ ...(domains.length === 0 ? {} : { outboundDomainAllowlist: domains }),
237
+ };
238
+ }
239
+ function modalSandboxHandle(sandbox, profile, connection, hosts) {
240
+ const run = hidingTokens(connection, (command, options) => runCommand(sandbox, command, options));
241
+ const shell = {
242
+ sandboxId: sandbox.sandboxId,
243
+ // Modal reports no allocation for a sandbox; the create's request is what it bills.
244
+ getInfo: () => Promise.resolve({ cpuCount: profile.cpu * 2, memoryMB: profile.memoryMiB }),
245
+ commands: { run },
246
+ files: {
247
+ write: hidingTokens(connection, (path, data) => writeFile(sandbox, path, data)),
248
+ read: hidingTokens(connection, (path) => readFile(sandbox, path)),
249
+ getInfo: hidingTokens(connection, async (path) => {
250
+ const result = await runCommand(sandbox, `stat -c %s -- '${sandboxPath(path).replaceAll("'", "'\\''")}'`);
251
+ return { size: Number(result.stdout?.trim()) };
252
+ }),
253
+ },
254
+ };
255
+ if (profile.desktop === true)
256
+ return {
257
+ ...shell,
258
+ ...modalDesktopSurface(run, hidingTokens(connection, (path) => readBytes(sandbox, path)), hosts),
259
+ };
260
+ const desktopOnly = (what) => () => Promise.reject(new ModalUnsupportedError(what));
261
+ return {
262
+ ...shell,
263
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
264
+ launch: desktopOnly("A desktop application launch"),
265
+ screenshot: desktopOnly("A desktop screenshot"),
266
+ leftClick: desktopOnly("Desktop input"),
267
+ rightClick: desktopOnly("Desktop input"),
268
+ middleClick: desktopOnly("Desktop input"),
269
+ doubleClick: desktopOnly("Desktop input"),
270
+ moveMouse: desktopOnly("Desktop input"),
271
+ scroll: desktopOnly("Desktop input"),
272
+ write: desktopOnly("Desktop input"),
273
+ press: desktopOnly("Desktop input"),
274
+ drag: desktopOnly("Desktop input"),
275
+ stream: {
276
+ getAuthKey: () => {
277
+ throw new ModalUnsupportedError("A desktop stream");
278
+ },
279
+ getUrl: () => {
280
+ throw new ModalUnsupportedError("A desktop stream");
281
+ },
282
+ start: desktopOnly("A desktop stream"),
283
+ },
284
+ };
285
+ }
286
+ /** The Modal account `env` names, for release by id and reclaim. Throws when it names none. */
287
+ export async function modalAccount(env, load = loadModalModule) {
288
+ const connection = requireModalConnection(env);
289
+ const { ModalClient: Client } = await load();
290
+ return modalSandboxes(new Client(connection), connection);
291
+ }
292
+ /** The terminal route's shell: Codex needs little CPU, so it runs smaller than E2B's 8 vCPU desktop. */
293
+ const MODAL_SHELL_PROFILE = {
294
+ image: modalShellImage,
295
+ cpu: 2,
296
+ memoryMiB: 4096,
297
+ };
298
+ /**
299
+ * The computer-use route's desktop: 4 vCPU and 8 GiB, under E2B's 8 vCPU stock desktop, since a
300
+ * participant's browser and XFCE use little CPU between actions.
301
+ */
302
+ const MODAL_DESKTOP_PROFILE = {
303
+ image: modalDesktopImage,
304
+ cpu: 2,
305
+ memoryMiB: 8192,
306
+ desktop: true,
307
+ };
308
+ /** A sandbox port that creates the computer-use route's desktops on the Modal account `env` names. */
309
+ export async function loadModalDesktopModule(env, load = loadModalModule) {
310
+ const connection = requireModalConnection(env);
311
+ const { ModalClient: Client } = await load();
312
+ return modalSandboxModule(new Client(connection), connection, MODAL_DESKTOP_PROFILE);
313
+ }
314
+ /** A sandbox port that creates the terminal route's shell sandboxes on the Modal account `env` names. */
315
+ export async function loadModalShellModule(env, load = loadModalModule) {
316
+ const connection = requireModalConnection(env);
317
+ const { ModalClient: Client } = await load();
318
+ return modalSandboxModule(new Client(connection), connection, MODAL_SHELL_PROFILE);
319
+ }
320
+ function requireModalConnection(env) {
321
+ const connection = modalConnection(env);
322
+ if (connection === undefined)
323
+ throw new Error(`No Modal credentials: set ${MODAL_TOKEN_ID_ENV} and ${MODAL_TOKEN_SECRET_ENV}, or run \`modal token new\`.`);
324
+ return connection;
325
+ }