humanish 0.138.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.
- package/CHANGELOG.md +11 -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/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,174 @@
|
|
|
1
|
+
// Commands and files in a Modal sandbox, with the E2B SDK's contracts, which the routes were written
|
|
2
|
+
// against: a non-zero exit throws CommandExitError, a timeout throws TimeoutError, relative paths
|
|
3
|
+
// are under the user's home, and a background command returns a handle to wait on, kill or feed.
|
|
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. Modal has no way to signal one exec, so a
|
|
7
|
+
// background command records its pid in a file and is killed by that pid.
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { ReadableStream } from "node:stream/web";
|
|
10
|
+
import { shellQuote } from "../shell.js";
|
|
11
|
+
import { MODAL_SANDBOX_USER } from "./image.js";
|
|
12
|
+
/** E2B's commands.run default, kept so a caller that sets no timeout gets the same bound. */
|
|
13
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
|
|
14
|
+
/** Modal takes sandbox and command timeouts in whole seconds; a shorter one is rounded up. */
|
|
15
|
+
export const wholeSeconds = (ms) => Math.ceil(ms / 1000) * 1000;
|
|
16
|
+
/** The argv that runs `command` in a login shell as the sandbox user, in their home. */
|
|
17
|
+
function asSandboxUser(command) {
|
|
18
|
+
const { uid } = MODAL_SANDBOX_USER;
|
|
19
|
+
return [
|
|
20
|
+
"setpriv",
|
|
21
|
+
`--reuid=${uid}`,
|
|
22
|
+
`--regid=${uid}`,
|
|
23
|
+
"--init-groups",
|
|
24
|
+
"--",
|
|
25
|
+
"bash",
|
|
26
|
+
"-lc",
|
|
27
|
+
command,
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
const USER_ENV = {
|
|
31
|
+
HOME: MODAL_SANDBOX_USER.home,
|
|
32
|
+
USER: MODAL_SANDBOX_USER.name,
|
|
33
|
+
LOGNAME: MODAL_SANDBOX_USER.name,
|
|
34
|
+
};
|
|
35
|
+
/** The E2B SDK throws this shape on a non-zero exit; src/substrates/command-failure.ts reads it. */
|
|
36
|
+
class ModalCommandExitError extends Error {
|
|
37
|
+
exitCode;
|
|
38
|
+
stdout;
|
|
39
|
+
stderr;
|
|
40
|
+
constructor(exitCode, stdout, stderr) {
|
|
41
|
+
super(`Command exited with code ${exitCode}${stderr ? ` and error:\n${stderr}` : ""}`);
|
|
42
|
+
this.exitCode = exitCode;
|
|
43
|
+
this.stdout = stdout;
|
|
44
|
+
this.stderr = stderr;
|
|
45
|
+
this.name = "CommandExitError";
|
|
46
|
+
}
|
|
47
|
+
get error() {
|
|
48
|
+
return this.stderr;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function collect(stream, onData) {
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
let text = "";
|
|
54
|
+
for await (const chunk of stream) {
|
|
55
|
+
const data = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
56
|
+
text += data;
|
|
57
|
+
if (onData !== undefined && data !== "")
|
|
58
|
+
await onData(data);
|
|
59
|
+
}
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
/** Modal ends a command at its timeout and reports it as a deadline; E2B names it TimeoutError. */
|
|
63
|
+
function named(error, timeoutMs) {
|
|
64
|
+
if (!(error instanceof Error) || !/deadline exceeded/i.test(error.message))
|
|
65
|
+
return error;
|
|
66
|
+
const timeout = new Error(`Command did not finish within ${timeoutMs} ms.`, { cause: error });
|
|
67
|
+
timeout.name = "TimeoutError";
|
|
68
|
+
return timeout;
|
|
69
|
+
}
|
|
70
|
+
/** Run `command` as the sandbox user, to completion or, with `background`, returning a handle. */
|
|
71
|
+
export async function runCommand(sandbox, command, options = {}) {
|
|
72
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
73
|
+
const pidFile = options.background === true ? `/tmp/humanish-exec-${randomUUID()}.pid` : "";
|
|
74
|
+
const line = options.background === true
|
|
75
|
+
? `printf '%s\\n' "$$" > ${pidFile}; exec bash -c ${shellQuote(command)}`
|
|
76
|
+
: command;
|
|
77
|
+
let finished;
|
|
78
|
+
let child;
|
|
79
|
+
try {
|
|
80
|
+
child = await sandbox.exec(asSandboxUser(line), {
|
|
81
|
+
env: { ...USER_ENV, ...options.envs },
|
|
82
|
+
...(options.cwd === undefined ? {} : { workdir: options.cwd }),
|
|
83
|
+
...(timeoutMs > 0 ? { timeoutMs: wholeSeconds(timeoutMs) } : {}),
|
|
84
|
+
});
|
|
85
|
+
// E2B starts a command with no stdin unless asked; a Modal exec keeps the pipe open, and a
|
|
86
|
+
// reader such as codex exec would wait on it until its timeout.
|
|
87
|
+
if (options.stdin !== true)
|
|
88
|
+
await child.closeStdin();
|
|
89
|
+
const running = child;
|
|
90
|
+
finished = Promise.all([
|
|
91
|
+
collect(running.stdout, options.onStdout),
|
|
92
|
+
collect(running.stderr, options.onStderr),
|
|
93
|
+
running.wait(),
|
|
94
|
+
]).then(([stdout, stderr, exitCode]) => {
|
|
95
|
+
if (exitCode !== 0)
|
|
96
|
+
throw new ModalCommandExitError(exitCode, stdout, stderr);
|
|
97
|
+
return { exitCode, stdout, stderr };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
throw named(error, timeoutMs);
|
|
102
|
+
}
|
|
103
|
+
if (options.background !== true)
|
|
104
|
+
return finished.catch((error) => {
|
|
105
|
+
throw named(error, timeoutMs);
|
|
106
|
+
});
|
|
107
|
+
// The caller decides when to wait; an exit nobody waits for is not an unhandled rejection.
|
|
108
|
+
finished.catch(() => { });
|
|
109
|
+
const running = child;
|
|
110
|
+
// E2B's handle carries the process's pid, which owners such as the recorder check.
|
|
111
|
+
const pid = await backgroundPid(sandbox, pidFile);
|
|
112
|
+
return {
|
|
113
|
+
...(pid === undefined ? {} : { pid }),
|
|
114
|
+
wait: () => finished,
|
|
115
|
+
kill: async () => {
|
|
116
|
+
const killed = await runCommand(sandbox, `kill -KILL "$(cat ${pidFile})" 2>/dev/null && echo killed || true`, { timeoutMs: 10_000 });
|
|
117
|
+
return killed.stdout?.trim() === "killed";
|
|
118
|
+
},
|
|
119
|
+
sendStdin: (data) => typeof data === "string" ? running.stdin.writeText(data) : running.stdin.writeBytes(data),
|
|
120
|
+
closeStdin: () => running.stdin.close(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** How long a background command has to record its pid before its handle reports none. */
|
|
124
|
+
const PID_WAIT_MS = 5_000;
|
|
125
|
+
/** The pid a background command wrote to `pidFile`, or undefined if none arrived in time. */
|
|
126
|
+
async function backgroundPid(sandbox, pidFile) {
|
|
127
|
+
for (const end = Date.now() + PID_WAIT_MS; Date.now() < end;) {
|
|
128
|
+
const read = await runCommand(sandbox, `cat ${pidFile} 2>/dev/null || true`, {
|
|
129
|
+
timeoutMs: 10_000,
|
|
130
|
+
});
|
|
131
|
+
const pid = Number(read.stdout?.trim());
|
|
132
|
+
if (Number.isSafeInteger(pid) && pid > 0)
|
|
133
|
+
return pid;
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
/** Relative paths are under the sandbox user's home, as on E2B. */
|
|
139
|
+
export function sandboxPath(path) {
|
|
140
|
+
return path.startsWith("/") ? path : `${MODAL_SANDBOX_USER.home}/${path}`;
|
|
141
|
+
}
|
|
142
|
+
export async function writeFile(sandbox, path, data) {
|
|
143
|
+
const target = sandboxPath(path);
|
|
144
|
+
const child = await sandbox.exec(asSandboxUser('mkdir -p -- "$(dirname -- "$0")" && cat > "$0"').concat(target), { mode: "binary", env: USER_ENV });
|
|
145
|
+
await child.stdin.writeBytes(typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data));
|
|
146
|
+
await child.stdin.close();
|
|
147
|
+
const [stderr, exitCode] = await Promise.all([child.stderr.readBytes(), child.wait()]);
|
|
148
|
+
if (exitCode !== 0)
|
|
149
|
+
throw new ModalCommandExitError(exitCode, "", new TextDecoder().decode(stderr));
|
|
150
|
+
return { path: target };
|
|
151
|
+
}
|
|
152
|
+
/** A file's bytes as a stream, read as the sandbox user. */
|
|
153
|
+
export async function readFile(sandbox, path) {
|
|
154
|
+
const child = await sandbox.exec(asSandboxUser('cat -- "$0"').concat(sandboxPath(path)), {
|
|
155
|
+
mode: "binary",
|
|
156
|
+
env: USER_ENV,
|
|
157
|
+
});
|
|
158
|
+
await child.closeStdin();
|
|
159
|
+
const bytes = async function* () {
|
|
160
|
+
for await (const chunk of child.stdout)
|
|
161
|
+
yield typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
|
|
162
|
+
const exitCode = await child.wait();
|
|
163
|
+
if (exitCode !== 0)
|
|
164
|
+
throw new ModalCommandExitError(exitCode, "", await child.stderr.readText());
|
|
165
|
+
};
|
|
166
|
+
return ReadableStream.from(bytes());
|
|
167
|
+
}
|
|
168
|
+
/** A file's bytes, whole. */
|
|
169
|
+
export async function readBytes(sandbox, path) {
|
|
170
|
+
const parts = [];
|
|
171
|
+
for await (const chunk of await readFile(sandbox, path))
|
|
172
|
+
parts.push(chunk);
|
|
173
|
+
return Buffer.concat(parts);
|
|
174
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { E2BCommandResult, E2BCommandRunOptions, E2BDesktopSandbox } from "../e2b/sdk.js";
|
|
2
|
+
type Run = (command: string, options?: E2BCommandRunOptions) => Promise<E2BCommandResult>;
|
|
3
|
+
/** The X display and the ports the desktop's stream uses, as @e2b/desktop sets them. */
|
|
4
|
+
export declare const MODAL_DESKTOP_DISPLAY = ":0";
|
|
5
|
+
export declare const MODAL_STREAM_PORT = 6080;
|
|
6
|
+
/** Start Xvfb at the requested size, then an XFCE session, as @e2b/desktop's create does. */
|
|
7
|
+
export declare function startModalDesktop(run: Run, options: {
|
|
8
|
+
resolution?: [number, number];
|
|
9
|
+
dpi?: number;
|
|
10
|
+
}): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* The desktop half of a sandbox handle. `hosts` maps each tunneled port to the host Modal serves it
|
|
13
|
+
* on; `readBytes` reads a file in the sandbox.
|
|
14
|
+
*/
|
|
15
|
+
export declare function modalDesktopSurface(run: Run, readBytes: (path: string) => Promise<Uint8Array>, hosts: ReadonlyMap<number, string>): Omit<E2BDesktopSandbox, "sandboxId" | "getInfo" | "commands" | "files">;
|
|
16
|
+
export {};
|
|
@@ -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>;
|