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.
Files changed (33) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/routes/computer-use/bundle-parts.d.ts +3 -0
  3. package/dist/routes/computer-use/bundle-parts.js +9 -1
  4. package/dist/routes/computer-use/bundle.js +3 -4
  5. package/dist/routes/computer-use/e2b-desktop/prepare.js +8 -4
  6. package/dist/routes/computer-use/fanout-bundle.js +3 -2
  7. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  8. package/dist/routes/computer-use/plan.js +66 -19
  9. package/dist/routes/computer-use/setup.js +14 -7
  10. package/dist/routes/computer-use/types.d.ts +3 -0
  11. package/dist/run/bundle-shape.js +1 -1
  12. package/dist/run/bundle.d.ts +3 -2
  13. package/dist/run/feedback-shape.js +1 -0
  14. package/dist/study/composition-rules.js +3 -2
  15. package/dist/study/parse/execution.js +2 -1
  16. package/dist/study/plan-base.d.ts +1 -0
  17. package/dist/study/plan-base.js +2 -0
  18. package/dist/study/plan-types.d.ts +2 -0
  19. package/dist/study/types.d.ts +1 -1
  20. package/dist/study/warnings.js +3 -1
  21. package/dist/substrates/e2b/sdk.d.ts +5 -0
  22. package/dist/substrates/e2b/sdk.js +12 -0
  23. package/dist/substrates/modal/commands.d.ts +16 -0
  24. package/dist/substrates/modal/commands.js +174 -0
  25. package/dist/substrates/modal/desktop.d.ts +16 -0
  26. package/dist/substrates/modal/desktop.js +195 -0
  27. package/dist/substrates/modal/image.d.ts +2 -0
  28. package/dist/substrates/modal/image.js +36 -1
  29. package/dist/substrates/modal/sandbox.d.ts +4 -0
  30. package/dist/substrates/modal/sandbox.js +68 -110
  31. package/docs/contracts/run-bundle.md +2 -1
  32. package/docs/contracts/schemas.md +4 -4
  33. package/package.json +1 -1
@@ -1,11 +1,20 @@
1
- import { MODAL_SANDBOX_USER, modalShellImage } from "./image.js";
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
- ...(options.envs === undefined ? {} : { env: { ...options.envs } }),
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
- return modalSandboxHandle(sandbox, profile, connection);
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
- /** 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 {
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 run(sandbox, `stat -c %s -- '${sandboxPath(path).replaceAll("'", "'\\''")}'`);
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` or `modal-terminal`), plus
340
- desktop device/resolution and timeouts. app-url subjects pair `e2b-desktop`
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.138.0",
3
+ "version": "0.139.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",