humanish 0.138.0 → 0.139.1
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/CHANGELOG.md +21 -0
- package/dist/actors/codex/operator-instructions.d.ts +5 -0
- package/dist/actors/codex/operator-instructions.js +9 -0
- package/dist/actors/codex/restricted-launch.d.ts +10 -2
- package/dist/actors/codex/restricted-launch.js +65 -16
- package/dist/actors/codex/restricted-participant.d.ts +4 -0
- package/dist/actors/codex/restricted-participant.js +9 -0
- package/dist/actors/codex/restricted-policy.d.ts +5 -2
- package/dist/actors/codex/restricted-policy.js +90 -72
- package/dist/actors/codex/restricted-session.d.ts +4 -0
- package/dist/actors/codex/restricted-session.js +8 -0
- package/dist/routes/computer-use/bundle-parts.d.ts +3 -0
- package/dist/routes/computer-use/bundle-parts.js +9 -1
- package/dist/routes/computer-use/bundle.js +3 -4
- package/dist/routes/computer-use/e2b-desktop/prepare.js +8 -4
- package/dist/routes/computer-use/fanout-bundle.js +3 -2
- package/dist/routes/computer-use/participant-deps.d.ts +1 -1
- package/dist/routes/computer-use/plan.js +66 -19
- package/dist/routes/computer-use/setup.js +14 -7
- package/dist/routes/computer-use/types.d.ts +3 -0
- package/dist/run/bundle-shape.js +1 -1
- package/dist/run/bundle.d.ts +3 -2
- package/dist/run/feedback-shape.js +1 -0
- package/dist/study/composition-rules.js +3 -2
- package/dist/study/doctor.d.ts +3 -0
- package/dist/study/doctor.js +11 -1
- package/dist/study/parse/execution.js +2 -1
- package/dist/study/plan-base.d.ts +1 -0
- package/dist/study/plan-base.js +2 -0
- package/dist/study/plan-types.d.ts +2 -0
- package/dist/study/types.d.ts +1 -1
- package/dist/study/warnings.js +3 -1
- package/dist/substrates/e2b/sdk.d.ts +5 -0
- package/dist/substrates/e2b/sdk.js +12 -0
- package/dist/substrates/modal/commands.d.ts +16 -0
- package/dist/substrates/modal/commands.js +174 -0
- package/dist/substrates/modal/desktop.d.ts +16 -0
- package/dist/substrates/modal/desktop.js +195 -0
- package/dist/substrates/modal/image.d.ts +2 -0
- package/dist/substrates/modal/image.js +36 -1
- package/dist/substrates/modal/sandbox.d.ts +4 -0
- package/dist/substrates/modal/sandbox.js +68 -110
- package/docs/contracts/run-bundle.md +2 -1
- package/docs/contracts/schemas.md +4 -4
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -7,3 +7,5 @@ export declare const MODAL_SANDBOX_USER: {
|
|
|
7
7
|
};
|
|
8
8
|
/** The terminal route's image: a Debian shell with a passwordless-sudo `user`, as on E2B. */
|
|
9
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;
|
|
@@ -21,15 +21,50 @@ const SHELL_PACKAGES = [
|
|
|
21
21
|
"unzip",
|
|
22
22
|
"xz-utils",
|
|
23
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
|
+
];
|
|
24
52
|
/** The terminal route's image: a Debian shell with a passwordless-sudo `user`, as on E2B. */
|
|
25
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) {
|
|
26
61
|
const { name, uid, home } = MODAL_SANDBOX_USER;
|
|
27
62
|
// prose-check: script
|
|
28
63
|
return client.images
|
|
29
64
|
.fromRegistry(DEBIAN_BASE)
|
|
30
65
|
.dockerfileCommands([
|
|
31
66
|
"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 ${
|
|
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/*`,
|
|
33
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}`,
|
|
34
69
|
`WORKDIR ${home}`,
|
|
35
70
|
]);
|
|
@@ -7,6 +7,8 @@ export interface ModalSandboxProfile {
|
|
|
7
7
|
/** Physical cores; Modal counts one core as two vCPU. */
|
|
8
8
|
cpu: number;
|
|
9
9
|
memoryMiB: number;
|
|
10
|
+
/** Start an X desktop after create and tunnel its stream, as @e2b/desktop's create does. */
|
|
11
|
+
desktop?: true;
|
|
10
12
|
}
|
|
11
13
|
/**
|
|
12
14
|
* A sandbox port over one Modal account. `create` maps E2B's create options onto Modal's; `kill`,
|
|
@@ -22,5 +24,7 @@ export declare function modalSandboxModule(client: ModalClient, connection: Moda
|
|
|
22
24
|
export declare function modalSandboxes(client: ModalClient, connection: ModalConnection): Required<E2BAccount>;
|
|
23
25
|
/** The Modal account `env` names, for release by id and reclaim. Throws when it names none. */
|
|
24
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>;
|
|
25
29
|
/** A sandbox port that creates the terminal route's shell sandboxes on the Modal account `env` names. */
|
|
26
30
|
export declare function loadModalShellModule(env: Readonly<Record<string, string | undefined>>, load?: () => Promise<ModalModule>): Promise<E2BDesktopModule>;
|
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
|
|
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";
|
|
2
13
|
import { isModalNotFoundError, loadModalModule, modalConnection, MODAL_TOKEN_ID_ENV, MODAL_TOKEN_SECRET_ENV, withoutTokens, } from "./sdk.js";
|
|
3
14
|
/** The Modal App every humanish sandbox is created in, inside the connection's environment. */
|
|
4
15
|
const MODAL_APP_NAME = "humanish";
|
|
5
16
|
/** How long a release waits for Modal to report a terminated sandbox finished; about 130 ms seen. */
|
|
6
17
|
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
18
|
/** `fn`, with any error it throws stripped of the connection's tokens. */
|
|
10
19
|
function hidingTokens(connection, fn) {
|
|
11
20
|
return async (...args) => {
|
|
@@ -17,8 +26,6 @@ function hidingTokens(connection, fn) {
|
|
|
17
26
|
}
|
|
18
27
|
};
|
|
19
28
|
}
|
|
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
29
|
/** Modal's own default sandbox lifetime is 5 minutes, as E2B's is. */
|
|
23
30
|
const DEFAULT_SANDBOX_TIMEOUT_MS = 5 * 60_000;
|
|
24
31
|
/** A Modal sandbox was asked for something only an E2B sandbox has. */
|
|
@@ -39,18 +46,41 @@ export function modalSandboxModule(client, connection, profile) {
|
|
|
39
46
|
if (typeof templateOrOptions === "string")
|
|
40
47
|
throw new ModalUnsupportedError("A desktop template");
|
|
41
48
|
const options = templateOrOptions;
|
|
49
|
+
const desktop = profile.desktop === true;
|
|
50
|
+
const env = { ...options.envs, ...(desktop ? { DISPLAY: MODAL_DESKTOP_DISPLAY } : {}) };
|
|
42
51
|
const params = {
|
|
43
52
|
cpu: profile.cpu,
|
|
44
53
|
memoryMiB: profile.memoryMiB,
|
|
45
54
|
timeoutMs: wholeSeconds(options.timeoutMs ?? DEFAULT_SANDBOX_TIMEOUT_MS),
|
|
46
55
|
runtime: "vm",
|
|
47
56
|
workdir: MODAL_SANDBOX_USER.home,
|
|
48
|
-
...(
|
|
57
|
+
...(Object.keys(env).length === 0 ? {} : { env }),
|
|
49
58
|
...(options.metadata === undefined ? {} : { tags: { ...options.metadata } }),
|
|
59
|
+
...(desktop ? { encryptedPorts: [MODAL_STREAM_PORT] } : {}),
|
|
50
60
|
...modalNetwork(options.network),
|
|
51
61
|
};
|
|
52
62
|
const sandbox = await client.sandboxes.create(await humanishApp(), profile.image(client), params);
|
|
53
|
-
|
|
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
|
+
}
|
|
54
84
|
}
|
|
55
85
|
return {
|
|
56
86
|
Sandbox: {
|
|
@@ -206,118 +236,30 @@ function modalNetwork(network) {
|
|
|
206
236
|
...(domains.length === 0 ? {} : { outboundDomainAllowlist: domains }),
|
|
207
237
|
};
|
|
208
238
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
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 {
|
|
239
|
+
function modalSandboxHandle(sandbox, profile, connection, hosts) {
|
|
240
|
+
const run = hidingTokens(connection, (command, options) => runCommand(sandbox, command, options));
|
|
241
|
+
const shell = {
|
|
308
242
|
sandboxId: sandbox.sandboxId,
|
|
309
243
|
// Modal reports no allocation for a sandbox; the create's request is what it bills.
|
|
310
244
|
getInfo: () => Promise.resolve({ cpuCount: profile.cpu * 2, memoryMB: profile.memoryMiB }),
|
|
311
|
-
commands: {
|
|
312
|
-
run: hidingTokens(connection, (command, options) => run(sandbox, command, options)),
|
|
313
|
-
},
|
|
245
|
+
commands: { run },
|
|
314
246
|
files: {
|
|
315
247
|
write: hidingTokens(connection, (path, data) => writeFile(sandbox, path, data)),
|
|
248
|
+
read: hidingTokens(connection, (path) => readFile(sandbox, path)),
|
|
316
249
|
getInfo: hidingTokens(connection, async (path) => {
|
|
317
|
-
const result = await
|
|
250
|
+
const result = await runCommand(sandbox, `stat -c %s -- '${sandboxPath(path).replaceAll("'", "'\\''")}'`);
|
|
318
251
|
return { size: Number(result.stdout?.trim()) };
|
|
319
252
|
}),
|
|
320
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,
|
|
321
263
|
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
322
264
|
launch: desktopOnly("A desktop application launch"),
|
|
323
265
|
screenshot: desktopOnly("A desktop screenshot"),
|
|
@@ -353,6 +295,22 @@ const MODAL_SHELL_PROFILE = {
|
|
|
353
295
|
cpu: 2,
|
|
354
296
|
memoryMiB: 4096,
|
|
355
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
|
+
}
|
|
356
314
|
/** A sandbox port that creates the terminal route's shell sandboxes on the Modal account `env` names. */
|
|
357
315
|
export async function loadModalShellModule(env, load = loadModalModule) {
|
|
358
316
|
const connection = requireModalConnection(env);
|
|
@@ -706,7 +706,8 @@ a provisioned clone records the clone's desktop as a `laneId: subject` line. A l
|
|
|
706
706
|
without `cost` has an unmeasured spend. A live terminal run's `cost` prices its shell sandbox
|
|
707
707
|
as a `desktop-minutes` line from its acquired-to-cleanup span and size: E2B's report
|
|
708
708
|
(`resourceSource: e2b.getInfo`), or the size humanish asked Modal for (`modal.create`), priced at
|
|
709
|
-
Modal's sandbox rate.
|
|
709
|
+
Modal's sandbox rate. A computer-use run on `execution.target: modal-desktop` records each
|
|
710
|
+
desktop's `providerResources[].provider` as `modal-desktop` and prices it the same way.
|
|
710
711
|
Its Codex `model-tokens` line prices the agent's tokens from the model passed as `--model`
|
|
711
712
|
(`actor.model`, else `gpt-5.6-sol`). Codex reports usage per turn, summed over the turn's
|
|
712
713
|
requests, so the line prices every token at the base tier and carries
|
|
@@ -336,9 +336,9 @@ 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`, `e2b-terminal
|
|
340
|
-
desktop device/resolution and timeouts. app-url subjects pair
|
|
341
|
-
or `local` with a computer-use actor, or `local` (or absent) with a
|
|
339
|
+
- `execution`: where it runs (`local`, `e2b-desktop`, `e2b-terminal`, `modal-desktop` or
|
|
340
|
+
`modal-terminal`), plus desktop device/resolution and timeouts. app-url subjects pair
|
|
341
|
+
`e2b-desktop`, `modal-desktop` or `local` with a computer-use actor, or `local` (or absent) with a
|
|
342
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
|
|
@@ -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 | modal-terminal`). Do not emit this schema.
|
|
1300
|
+
(`execution.target: local | e2b-desktop | e2b-terminal | modal-desktop | modal-terminal`). Do not emit this schema.
|
|
1301
1301
|
|
|
1302
1302
|
## Serve Result And Reserved Control-Plane Namespace
|
|
1303
1303
|
|