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
package/CHANGELOG.md CHANGED
@@ -8,6 +8,28 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.139.0: Computer-use studies can run on Modal desktops (2026-10-19)
12
+
13
+ humanish 0.139.0 can run a computer-use study's desktops on Modal. Set
14
+ `execution.target: modal-desktop` on an `app-url` study and each participant gets its own Modal
15
+ sandbox running the same desktop the E2B route uses: Xvfb, XFCE, xdotool input, screenshots, a
16
+ live noVNC stream, Chromium and recordings. The same study file runs on E2B or Modal
17
+ with only the target changed. Clone and local-tree subjects, shared worlds, E2B templates and
18
+ camera or speech media stay on E2B.
19
+
20
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.139.0)
21
+
22
+ ## 0.138.0: A terminal study can run on Modal (2026-10-19)
23
+
24
+ humanish 0.138.0 can run a terminal study's shell on Modal as well as E2B. Set
25
+ `execution.target: modal-terminal` and `execution.runtimeAuth: openai-env`, sign in with
26
+ `modal token new`, and install the optional `modal` package beside humanish. The sandbox runs as a
27
+ non-root user with sudo, as on E2B, lives up to Modal's 24 hours, is journaled and reclaimed by id
28
+ like an E2B sandbox, and is priced at Modal's sandbox rate. Desktop studies still run on E2B or a
29
+ local VM.
30
+
31
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.138.0)
32
+
11
33
  ## 0.137.0: Each browser participant can record its requests (2026-10-16)
12
34
 
13
35
  humanish 0.137.0 can record the requests each hosted browser participant makes. With
@@ -203,12 +203,12 @@ export function registerRuntimeCommands(parent, io) {
203
203
  export function registerReclaimCommand(parent, io) {
204
204
  parent
205
205
  .command("reclaim")
206
- .description("Kill an interrupted run's sandboxes: the exact ids journaled in its sandbox-receipts.ndjson, and any sandbox E2B lists with this run's owner tags (a sandbox whose id never reached a receipt). It lists only sandboxes that match every tag of this run. --check asks E2B whether each still exists and kills nothing. Needs E2B_API_KEY in the environment or in --dotenv.")
206
+ .description("Kill an interrupted run's sandboxes: the exact ids journaled in its sandbox-receipts.ndjson, and any sandbox the run's provider lists with this run's owner tags (a sandbox whose id never reached a receipt). It lists only sandboxes that match every tag of this run. --check asks the provider whether each still exists and kills nothing. Needs E2B_API_KEY, or MODAL_TOKEN_ID and MODAL_TOKEN_SECRET for a run on Modal, in the environment or in --dotenv.")
207
207
  .summary("Stop or check an interrupted run's sandboxes.")
208
208
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
209
209
  .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
210
210
  .addOption(new Option("--preflight", "Reclaim sandboxes left by interrupted `humanish study check` probes (journaled in .humanish/preflight) instead of a run's.").conflicts("run"))
211
- .option("--check", "Ask E2B whether each sandbox still exists; kill nothing and write nothing. Exits 0 only when all are gone.")
211
+ .option("--check", "Ask the provider whether each sandbox still exists; kill nothing and write nothing. Exits 0 only when all are gone.")
212
212
  .option("--dotenv <path>", DOTENV_OPTION_DESCRIPTION)
213
213
  .addOption(envFileAliasOption())
214
214
  .option("--json", JSON_OPTION_DESCRIPTION)
@@ -96,6 +96,7 @@ export async function doctor(cwdInput, options = {}) {
96
96
  const checks = [
97
97
  ...(await projectChecks(projectRoot)),
98
98
  await desktopSdkCheck(setup),
99
+ ...(setup?.keys.includes("MODAL_TOKEN_ID") === true ? [await modalSdkCheck()] : []),
99
100
  sandboxCeilingCheck(env),
100
101
  concurrentSandboxesCheck(env),
101
102
  terminalSurfaceCheck(),
@@ -239,6 +240,25 @@ async function desktopSdkCheck(setup) {
239
240
  : missingDesktopSdkMessage(humanishInstall()),
240
241
  };
241
242
  }
243
+ /** The optional `modal` peer, reported only for a study that runs on Modal, which needs it. */
244
+ async function modalSdkCheck() {
245
+ const present = await safeCheck(async () => {
246
+ try {
247
+ await import("modal");
248
+ return true;
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ });
254
+ return {
255
+ name: "modal sdk",
256
+ ok: present,
257
+ message: present
258
+ ? "optional peer modal is installed; provider access is not tested"
259
+ : `optional peer modal is absent, and this study runs on Modal. ${desktopPeerAdvice(humanishInstall(), "modal").advice}`,
260
+ };
261
+ }
242
262
  /**
243
263
  * The stakeholder surface. Reported as capability, never as a gate: the TUI is optional,
244
264
  * and `doctor` is itself mostly run by agents through a pipe, where a TTY requirement says
@@ -288,6 +308,10 @@ async function probeDoctorKeys(cwd, env, study, keyDeps, dotenv) {
288
308
  if (study) {
289
309
  const { resolveStudyManifest } = await import("../study/discover.js");
290
310
  const resolved = await resolveStudyManifest(cwd, study);
311
+ // A study on Modal reads the Modal token pair, which no other study needs.
312
+ if (resolved.ok && resolved.config.execution?.target === "modal-terminal")
313
+ for (const name of ["MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"])
314
+ keyNames.add(name);
291
315
  if (resolved.ok && resolved.config.comms?.email?.kind === "real") {
292
316
  const { receivingRequiredKey } = await import("../comms/setup.js");
293
317
  receivingKey = await receivingRequiredKey(cwd, resolved.config.comms.email.connection);
@@ -7,6 +7,7 @@
7
7
  // examples document;
8
8
  // 3. the owning vendor's native store, where one exists:
9
9
  // E2B_API_KEY <- ~/.e2b/config.json (written by `e2b auth login`);
10
+ // MODAL_TOKEN_ID, MODAL_TOKEN_SECRET <- ~/.modal.toml (written by `modal token new`);
10
11
  // GH_TOKEN <- `gh auth token` (the gh CLI's credential chain);
11
12
  // 4. the humanish user-level store `$XDG_CONFIG_HOME/humanish/keys.env`, written only by
12
13
  // `humanish keys set` (0600, prompted or --stdin), the fallback for vendors that ship
@@ -20,6 +21,7 @@ import { spawn } from "node:child_process";
20
21
  import { chmodSync, closeSync, constants as fsConstants, existsSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, writeSync, } from "node:fs";
21
22
  import { homedir } from "node:os";
22
23
  import path from "node:path";
24
+ import { parse as parseToml } from "smol-toml";
23
25
  import { loadEnvFile } from "./env-file.js";
24
26
  import { cli } from "../cli/invocation.js";
25
27
  import { humanishConfigFile } from "../cli/user-config.js";
@@ -48,6 +50,14 @@ const PROVIDER_KEYS = [
48
50
  listed: { order: 1, use: "hosted desktops" },
49
51
  doctor: true,
50
52
  },
53
+ {
54
+ name: "MODAL_TOKEN_ID",
55
+ vendor: "modal",
56
+ login: "modal token new",
57
+ },
58
+ { name: "MODAL_TOKEN_SECRET", login: "modal token new" },
59
+ // Which Modal environment sandboxes are created in; not a secret, but it names the account.
60
+ { name: "MODAL_ENVIRONMENT" },
51
61
  { name: "ANTHROPIC_API_KEY", vendor: "anthropic" },
52
62
  {
53
63
  name: "GH_TOKEN",
@@ -81,6 +91,36 @@ export function userKeyStorePath(env, deps = {}) {
81
91
  function e2bConfigPath(deps) {
82
92
  return path.join(deps.homeDir ?? homedir(), ".e2b", "config.json");
83
93
  }
94
+ /**
95
+ * The token pair and environment of the Modal CLI's profile, as Modal picks it: MODAL_PROFILE
96
+ * when set, else the profile marked `active`, else the only profile. Null when there is none.
97
+ */
98
+ function readModalProfile(env, deps) {
99
+ try {
100
+ const file = env.MODAL_CONFIG_PATH?.trim() || path.join(deps.homeDir ?? homedir(), ".modal.toml");
101
+ if (!isRegularFile(file))
102
+ return null;
103
+ const profiles = [];
104
+ for (const [name, values] of Object.entries(parseToml(readFileSync(file, "utf8"))))
105
+ if (values !== null && typeof values === "object" && !Array.isArray(values))
106
+ profiles.push([name, values]);
107
+ const named = env.MODAL_PROFILE?.trim();
108
+ const profile = named
109
+ ? profiles.find(([name]) => name === named)?.[1]
110
+ : (profiles.find(([, values]) => values.active === true)?.[1] ??
111
+ (profiles.length === 1 ? profiles[0]?.[1] : undefined));
112
+ const text = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
113
+ const tokenId = text(profile?.token_id);
114
+ const tokenSecret = text(profile?.token_secret);
115
+ if (tokenId === undefined || tokenSecret === undefined)
116
+ return null;
117
+ const environment = text(profile?.environment);
118
+ return { tokenId, tokenSecret, ...(environment === undefined ? {} : { environment }) };
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
84
124
  /** Default exec: bounded, quiet, stdin closed; null on any failure. Never throws. */
85
125
  function defaultExecText(command, args, timeoutMs, env) {
86
126
  return new Promise((resolve) => {
@@ -205,7 +245,18 @@ export async function discoverProviderKeys(args) {
205
245
  if (teamKey !== null)
206
246
  fill("E2B_API_KEY", teamKey, "~/.e2b/config.json (e2b auth login)");
207
247
  }
208
- // Rung 3b: the gh CLI's credential chain. Only consulted when neither GitHub env name is
248
+ // Rung 3b: the modal CLI's profile. Both tokens come from the same profile or neither does.
249
+ if (fillable("MODAL_TOKEN_ID") && fillable("MODAL_TOKEN_SECRET")) {
250
+ const profile = readModalProfile(env, deps);
251
+ if (profile !== null) {
252
+ const source = "~/.modal.toml (modal token new)";
253
+ fill("MODAL_TOKEN_ID", profile.tokenId, source);
254
+ fill("MODAL_TOKEN_SECRET", profile.tokenSecret, source);
255
+ if (profile.environment !== undefined && fillable("MODAL_ENVIRONMENT"))
256
+ fill("MODAL_ENVIRONMENT", profile.environment, source);
257
+ }
258
+ }
259
+ // Rung 3c: the gh CLI's credential chain. Only consulted when neither GitHub env name is
209
260
  // present; fills GH_TOKEN (the name humanish reads first). gh runs without any provider key in
210
261
  // its environment, so the keys the rungs above just filled stay in this process.
211
262
  if (fillable("GH_TOKEN") && fillable("GITHUB_TOKEN")) {
@@ -265,7 +316,7 @@ export function describeMissingKeys(names, env) {
265
316
  const strict = env.HUMANISH_STRICT_KEYS?.trim() === "1";
266
317
  const chain = strict
267
318
  ? "key discovery is disabled (HUMANISH_STRICT_KEYS=1); only process env and --dotenv are read"
268
- : `also checked ${PROJECT_OVERLAY_RELATIVE}, ~/.e2b/config.json, gh auth token, and ${path.join("~", ".config", "humanish", "keys.env")}`;
319
+ : `also checked ${PROJECT_OVERLAY_RELATIVE}, ~/.e2b/config.json, ~/.modal.toml, gh auth token, and ${path.join("~", ".config", "humanish", "keys.env")}`;
269
320
  return `${chain}. Fill: ${hints}.`;
270
321
  }
271
322
  /**
@@ -15,7 +15,10 @@ export declare function describeSubjectState(state: RunSubjectProvenance["state"
15
15
  /** Human-readable provenance line for the single-participant subject.provenance event of a clone
16
16
  * or local-tree subject: claims "cloned/packed and served" only when it actually happened. */
17
17
  export declare function subjectProvenanceMessage(provenance: RunSubjectProvenance, publicAppUrl: string, dryRun: boolean, hasSession: boolean): string;
18
+ /** The label a run records for the desktop `execution.target` names. */
19
+ export declare function desktopSubstrate(target: string | undefined): "local-desktop" | "modal-desktop" | "e2b-desktop";
18
20
  export declare function providerResourcesForOutcome(args: {
21
+ provider: "e2b-desktop" | "modal-desktop";
19
22
  outcome: ParticipantRunOutcome | undefined;
20
23
  createdAt: string;
21
24
  ids: ParticipantIds;
@@ -73,6 +73,14 @@ export function subjectProvenanceMessage(provenance, publicAppUrl, dryRun, hasSe
73
73
  }
74
74
  return "Subject local-tree packing attempted; archive digest unresolved (provisioning failed before resolution)";
75
75
  }
76
+ /** The label a run records for the desktop `execution.target` names. */
77
+ export function desktopSubstrate(target) {
78
+ return target === "local"
79
+ ? "local-desktop"
80
+ : target === "modal-desktop"
81
+ ? "modal-desktop"
82
+ : "e2b-desktop";
83
+ }
76
84
  export function providerResourcesForOutcome(args) {
77
85
  if (args.outcome?.sandboxId === undefined) {
78
86
  return [];
@@ -80,7 +88,7 @@ export function providerResourcesForOutcome(args) {
80
88
  return [
81
89
  {
82
90
  schema: "humanish.provider-resource.v1",
83
- provider: "e2b-desktop",
91
+ provider: args.provider,
84
92
  kind: "sandbox",
85
93
  id: args.outcome.sandboxId,
86
94
  owner: "humanish",
@@ -6,7 +6,7 @@ import { judgeOneParticipant, judgeParticipants } from "../../run/judge.js";
6
6
  import { renderReviewMarkdown } from "../../run/review-markdown.js";
7
7
  import { desktopSpanToMinutes } from "../../run/cost-summary.js";
8
8
  import { e2bDesktopTemplate } from "../../substrates/e2b/sandbox.js";
9
- import { providerResourcesForOutcome } from "./bundle-parts.js";
9
+ import { desktopSubstrate, providerResourcesForOutcome } from "./bundle-parts.js";
10
10
  import { participantFactsOf } from "./participant-facts.js";
11
11
  import { buildCuaFanoutBundle } from "./fanout-bundle.js";
12
12
  import { buildSingleParticipantBundle } from "./single-bundle.js";
@@ -96,9 +96,7 @@ function singleParticipantArgs(base, state) {
96
96
  desktopRoute: base.plan.runner.desktop !== "in-process",
97
97
  substrate: base.plan.runner.desktop === "in-process"
98
98
  ? "local-filesystem"
99
- : plan.residual.execution?.target === "local"
100
- ? "local-desktop"
101
- : "e2b-desktop",
99
+ : desktopSubstrate(plan.residual.execution?.target),
102
100
  ...(outcome?.desktopGeometry === undefined ? {} : { desktopGeometry: outcome.desktopGeometry }),
103
101
  ...(outcome?.recording === undefined ? {} : { recording: outcome.recording }),
104
102
  ...(outcome?.networkLog === undefined ? {} : { network: outcome.networkLog }),
@@ -127,6 +125,7 @@ function singleParticipantArgs(base, state) {
127
125
  ...(desktopTemplate === undefined ? {} : { desktopTemplate }),
128
126
  ...(outcome?.desktopBrowser === undefined ? {} : { desktopBrowser: outcome.desktopBrowser }),
129
127
  providerResources: providerResourcesForOutcome({
128
+ provider: base.plan.residual.execution?.target === "modal-desktop" ? "modal-desktop" : "e2b-desktop",
130
129
  outcome,
131
130
  createdAt: base.run.createdAt,
132
131
  ids: spec,
@@ -11,6 +11,7 @@ import { observeDesktopResources } from "../../../substrates/e2b/desktop-resourc
11
11
  import { acquireE2BDesktopSandbox, e2bDesktopTemplate, withOneRetryOnTransientE2BError, } from "../../../substrates/e2b/sandbox.js";
12
12
  import { loadE2BDesktopModule } from "../../../substrates/e2b/sdk.js";
13
13
  import { e2bShell } from "../../../substrates/e2b/shell.js";
14
+ import { loadModalDesktopModule } from "../../../substrates/modal/sandbox.js";
14
15
  import { attachReceivingInbox, participantCommsEnv, startCommsCatch } from "./comms.js";
15
16
  import { participantSubjectEnv } from "../types.js";
16
17
  import { plural } from "../../../run/text.js";
@@ -30,11 +31,14 @@ export async function acquireParticipantDesktop(ctx, state) {
30
31
  // Off-app comms: the base-URL env is injected at sandbox create, so the app reads it at
31
32
  // boot; the catch starts right after create.
32
33
  const commsEnv = participantCommsEnv(ctx.comms);
33
- const desktopModule = await (deps.desktopModule ?? loadE2BDesktopModule)();
34
+ const provider = deps.sandboxProvider ?? "e2b";
35
+ const desktopModule = await (deps.desktopModule ??
36
+ (provider === "modal" ? () => loadModalDesktopModule(env) : loadE2BDesktopModule))();
34
37
  // An explicit template wins. Speech gets the versioned media image; ordinary
35
- // browser studies retain the SDK default desktop.
38
+ // browser studies retain the SDK default desktop. A Modal desktop has one image.
36
39
  const acquired = await acquireE2BDesktopSandbox({
37
40
  module: desktopModule,
41
+ provider,
38
42
  options: {
39
43
  apiKey: deps.e2bApiKey,
40
44
  requestTimeoutMs: deps.requestTimeoutMs,
@@ -67,7 +71,7 @@ export async function acquireParticipantDesktop(ctx, state) {
67
71
  dpi: 96,
68
72
  lifecycle: { onTimeout: "kill" },
69
73
  },
70
- template: e2bDesktopTemplate(residual),
74
+ template: provider === "modal" ? undefined : e2bDesktopTemplate(residual),
71
75
  retry: {
72
76
  // The default loader reclaims an acquired handle before retrying failed desktop startup.
73
77
  // Its error names the cleanup outcome; pre-construction allocation failures remain unowned.
@@ -92,7 +96,7 @@ export async function acquireParticipantDesktop(ctx, state) {
92
96
  state.sandboxId = acquired.allocation.resourceId;
93
97
  // The billed span starts the instant the sandbox exists.
94
98
  state.sandboxCreatedAtMs = deps.now();
95
- const desktopResources = await observeDesktopResources(desktop);
99
+ const desktopResources = await observeDesktopResources(desktop, undefined, provider === "modal" ? "modal.create" : "e2b.getInfo");
96
100
  state.desktopResources = desktopResources;
97
101
  if ("reason" in desktopResources) {
98
102
  warnings.push(`Desktop resource size unavailable (${desktopResources.reason}); compute cost remains unpriced.`);
@@ -5,7 +5,7 @@ import { cuaGoalSource } from "../../actors/goal-source.js";
5
5
  import { redactEncodedText } from "../../evidence/encoded-text.js";
6
6
  import { REVIEW_SCHEMA, bundleArtifacts, bundleHead, } from "../../run/bundle.js";
7
7
  import { aggregateTaskFunnels, formatParticipantOutcomes, formatRunTaskFunnel, gapsListClause, skippedSentence, withCuaReviewProvenance, } from "../../run/outcomes.js";
8
- import { providerResourcesForOutcome, publicSafeAppUrlLabel, streakGaps } from "./bundle-parts.js";
8
+ import { desktopSubstrate, providerResourcesForOutcome, publicSafeAppUrlLabel, streakGaps, } from "./bundle-parts.js";
9
9
  import { subjectName } from "../../run/subject-name.js";
10
10
  import { participantFeedbackCandidates } from "./participant-feedback.js";
11
11
  import { participantFactsOf } from "./participant-facts.js";
@@ -179,7 +179,7 @@ function fanoutFeedbackCandidates(args) {
179
179
  runId: args.run.runId,
180
180
  scenarioId: `cua-${plan.studyId}`,
181
181
  adapterId: plan.studyId,
182
- substrate: plan.residual.execution?.target === "local" ? "local-desktop" : "e2b-desktop",
182
+ substrate: desktopSubstrate(plan.residual.execution?.target),
183
183
  participants: specs.map((spec, index) => {
184
184
  const outcome = outcomes?.[index];
185
185
  return {
@@ -241,6 +241,7 @@ export function buildCuaFanoutBundle(args) {
241
241
  const desktopTemplate = e2bDesktopTemplate(plan.residual);
242
242
  const desktopBrowser = fanoutDesktopBrowser(args);
243
243
  const providerResources = (outcomes ?? []).flatMap((outcome) => providerResourcesForOutcome({
244
+ provider: plan.residual.execution?.target === "modal-desktop" ? "modal-desktop" : "e2b-desktop",
244
245
  outcome,
245
246
  createdAt: args.run.createdAt,
246
247
  ids: outcome.spec,
@@ -36,7 +36,7 @@ interface DesktopRunFacts {
36
36
  }
37
37
  /** The fields one desktop route sets and the other does not. Undefined leaves a field out. */
38
38
  type DesktopRouteOverrides = {
39
- [K in "createDesktop" | "createProvider" | "inProcessExecutor" | "localTreeArchiveBuffer" | "desktopFiles" | "externalComms" | "receiving" | "screenMismatchPolicy"]?: CuaParticipantDeps[K] | undefined;
39
+ [K in "createDesktop" | "createProvider" | "inProcessExecutor" | "localTreeArchiveBuffer" | "desktopFiles" | "externalComms" | "receiving" | "screenMismatchPolicy" | "sandboxProvider"]?: CuaParticipantDeps[K] | undefined;
40
40
  };
41
41
  /**
42
42
  * One run's participant deps. Call it once per run: the run budget it creates is the one ledger
@@ -2,6 +2,7 @@
2
2
  // in its order and with its codes, then the plan the run uses. What stays in the route reads
3
3
  // external state: committed personas, a rerun's source run, keys, the local agent, subject env
4
4
  // values, spend-cap prices and the comms catch.
5
+ import { MODAL_MAX_SANDBOX_MS } from "../../substrates/modal/sdk.js";
5
6
  import { DESKTOP_FILES_ROUTE_REASON } from "../../study/desktop-files.js";
6
7
  import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
7
8
  import { actorRegistry, isCuaActorDescriptor, } from "../../actors/registry.js";
@@ -206,12 +207,14 @@ function startsOf(config, runner, limits, ceilingMs) {
206
207
  const declared = config.execution?.concurrency;
207
208
  const n = runner.participants.length;
208
209
  const hosted = runner.desktop === "e2b-desktop";
210
+ // The E2B plan's limit binds only E2B desktops; Modal's container limit is far above a study's.
211
+ const e2bHosted = hosted && runner.provider === "e2b";
209
212
  const atOnce = participantsAtOnce({
210
213
  concurrency: boundedConcurrency(declared, n),
211
214
  declared: declared !== undefined,
212
215
  participants: n,
213
216
  }, {
214
- ...(hosted ? { sandboxes: limits.concurrentSandboxes } : {}),
217
+ ...(e2bHosted ? { sandboxes: limits.concurrentSandboxes } : {}),
215
218
  cap: limits.concurrencyCap,
216
219
  });
217
220
  if (!atOnce.ok)
@@ -265,6 +268,54 @@ function timeZoneRunnerReason(desktop, participants) {
265
268
  ? `${declared} This study runs on the local browser runtime. Remove actor.clock and participants[].clock, or set execution.target: e2b-desktop.`
266
269
  : `${declared} This study drives your own browser through RunStudyOptions.inProcess. Remove actor.clock and participants[].clock, and set the zone in that browser yourself, or run on a hosted desktop.`;
267
270
  }
271
+ /** Who hosts the study's desktops and the longest sandbox they allow, or why it cannot run. */
272
+ function hostingOf(config, e2bCeiling) {
273
+ if (config.execution?.target !== "modal-desktop")
274
+ return e2bCeiling.ok
275
+ ? { provider: "e2b", ceiling: e2bCeiling }
276
+ : { refusal: e2bCeiling.message };
277
+ const refusal = modalDesktopRefusal(config);
278
+ return refusal === undefined
279
+ ? { provider: "modal", ceiling: { ms: MODAL_MAX_SANDBOX_MS } }
280
+ : { refusal };
281
+ }
282
+ /** The app-url subject the study names, and the subject a hosted desktop provisions. */
283
+ function subjectsOf(config) {
284
+ const appUrl = config.subject.appUrl ?? "";
285
+ const declaredServeUrl = config.subject.serve?.url;
286
+ const serveUrl = declaredServeUrl === undefined ? {} : { serveUrl: declaredServeUrl };
287
+ const appUrlSubject = {
288
+ kind: "app-url",
289
+ appUrl,
290
+ publicTargets: config.policies?.allowPublicTargets === true,
291
+ ...serveUrl,
292
+ };
293
+ const product = config.subject.product;
294
+ const hosted = config.subject.source === "desktop-cli" && product !== undefined
295
+ ? { kind: "desktop-cli", product, ...serveUrl }
296
+ : (provisionedSubject(config) ?? appUrlSubject);
297
+ return { appUrl, appUrlSubject, hosted };
298
+ }
299
+ /**
300
+ * What a study on a Modal desktop cannot have that one on an E2B desktop can, or undefined. The
301
+ * Modal desktop runs an app-url subject in Chromium; provisioned subjects, a terminal subject,
302
+ * E2B templates and the speech template are E2B's.
303
+ */
304
+ function modalDesktopRefusal(config) {
305
+ const desktop = config.execution?.desktop;
306
+ if (config.subject.source !== "app-url")
307
+ return `A Modal desktop runs app-url subjects only, and this study's subject is ${config.subject.source}. Set execution.target: e2b-desktop.`;
308
+ if (config.route === "shared-world")
309
+ return "A shared-world study runs on E2B desktops only. Set execution.target: e2b-desktop.";
310
+ if (desktop?.template !== undefined)
311
+ return "execution.desktop.template names an E2B template, which a Modal desktop cannot use. Remove it, or set execution.target: e2b-desktop.";
312
+ if (desktop?.media !== undefined)
313
+ return "execution.desktop.media needs the E2B speech or camera template, which a Modal desktop does not have. Remove it, or set execution.target: e2b-desktop.";
314
+ const browser = desktop?.browser;
315
+ if (browser !== undefined && browser !== "default" && browser !== "chromium")
316
+ return `A Modal desktop has Chromium only, and execution.desktop.browser is ${browser}. Set it to chromium or default, or set execution.target: e2b-desktop.`;
317
+ return undefined;
318
+ }
268
319
  /** What the participants declare that this runner cannot do, with its code, or undefined. */
269
320
  function runnerRefusal(desktop, participants) {
270
321
  const files = desktopFilesRunnerReason(desktop, participants);
@@ -304,9 +355,10 @@ export function planComputerUseStudy(config, input) {
304
355
  if (!descriptor || !isCuaActorDescriptor(descriptor))
305
356
  return refuse("in-scope", "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED", `actor.type "${actorType}" is not a registered computer-use actor.`);
306
357
  const actor = descriptor.id;
307
- const ceiling = input.sandboxCeiling;
308
- if (!ceiling.ok)
309
- return refuse("in-scope", "HUMANISH_COMPUTER_USE_SUBJECT_INVALID", ceiling.message, actor);
358
+ const hosting = hostingOf(config, input.sandboxCeiling);
359
+ if ("refusal" in hosting)
360
+ return refuse("in-scope", "HUMANISH_COMPUTER_USE_SUBJECT_INVALID", hosting.refusal, actor);
361
+ const { provider, ceiling } = hosting;
310
362
  const rejection = cuaStudyRejection(config, hasRunSession, driving, declaredSubjectRoute(config, driving), ceiling.ms);
311
363
  if (rejection)
312
364
  return refuse("in-scope", rejection.code, rejection.message, actor);
@@ -335,19 +387,7 @@ export function planComputerUseStudy(config, input) {
335
387
  return refuse("after-personas", "HUMANISH_COMPUTER_USE_FANOUT_INVALID", "Fan-out to more than one participant is not supported on the in-process route (RunStudyOptions.inProcess): fan-out provisions one independent E2B desktop per participant, which the in-process route deliberately skips. Run a single in-process participant, or fan out on the E2B route.", actor);
336
388
  if (first === undefined)
337
389
  throw new Error("computerUseParticipants returned no participant");
338
- const appUrl = config.subject.appUrl ?? "";
339
- const declaredServeUrl = config.subject.serve?.url;
340
- const serveUrl = declaredServeUrl === undefined ? {} : { serveUrl: declaredServeUrl };
341
- const appUrlSubject = {
342
- kind: "app-url",
343
- appUrl,
344
- publicTargets: config.policies?.allowPublicTargets === true,
345
- ...serveUrl,
346
- };
347
- const product = config.subject.product;
348
- const hosted = source === "desktop-cli" && product !== undefined
349
- ? { kind: "desktop-cli", product, ...serveUrl }
350
- : (provisionedSubject(config) ?? appUrlSubject);
390
+ const { appUrl, appUrlSubject, hosted } = subjectsOf(config);
351
391
  const brain = brainOf(config, driving.createProvider);
352
392
  const runner = driving.inProcess
353
393
  ? {
@@ -358,7 +398,13 @@ export function planComputerUseStudy(config, input) {
358
398
  }
359
399
  : isLocalBrowserStudy(config)
360
400
  ? { desktop: "local-vm", brain, participants: [first, ...rest], subject: appUrlSubject }
361
- : { desktop: "e2b-desktop", brain, participants: [first, ...rest], subject: hosted };
401
+ : {
402
+ desktop: "e2b-desktop",
403
+ provider,
404
+ brain,
405
+ participants: [first, ...rest],
406
+ subject: hosted,
407
+ };
362
408
  const unsupported = runnerRefusal(runner.desktop, participants);
363
409
  if (unsupported)
364
410
  return refuse("in-scope", unsupported.code, unsupported.message, actor);
@@ -382,7 +428,8 @@ export function planComputerUseStudy(config, input) {
382
428
  requirements: base.dryRun || runner.desktop === "in-process"
383
429
  ? []
384
430
  : desktopRequirements(config, {
385
- e2b: runner.desktop === "e2b-desktop",
431
+ e2b: runner.desktop === "e2b-desktop" && runner.provider === "e2b",
432
+ modal: runner.desktop === "e2b-desktop" && runner.provider === "modal",
386
433
  brain,
387
434
  localVm: runner.desktop === "local-vm",
388
435
  externalCatch: !provisioned,
@@ -125,13 +125,8 @@ export async function admitCuaRun(plan, input, config) {
125
125
  // Read keys once into locals (names only; values never logged or persisted).
126
126
  const openaiApiKey = env.OPENAI_API_KEY?.trim() ?? "";
127
127
  const e2bApiKey = env.E2B_API_KEY?.trim() ?? "";
128
- // Literal scrubber for every known provisioned value (no secret "shape" to pattern-match).
129
- // Dry and in-process runs scrub both keys too: a mission or persona can still quote them.
130
- const secrets = new RunSecrets([
131
- openaiApiKey,
132
- e2bApiKey,
133
- ...subjectEnvNames.map((name) => env[name] ?? ""),
134
- ]);
128
+ // Every known key and subject value, scrubbed literally, in dry and in-process runs too.
129
+ const secrets = new RunSecrets([openaiApiKey, e2bApiKey, ...otherValues(env, subjectEnvNames)]);
135
130
  sanitizeParticipantRuns(participantRuns, secrets.scrub);
136
131
  const desktopFiles = await readStudyDesktopFiles(input.cwd, plan, participantRuns, secrets.scrub);
137
132
  if (!desktopFiles.ok)
@@ -342,6 +337,7 @@ function cuaParticipantDeps(plan, input, admitted, run) {
342
337
  prepareDesktop: input.prepareDesktop,
343
338
  }, {
344
339
  createDesktop: input.localVm?.desktop,
340
+ sandboxProvider: plan.runner.desktop === "e2b-desktop" ? plan.runner.provider : undefined,
345
341
  ...callerDriving(input, config),
346
342
  localTreeArchiveBuffer: admitted.localTreeArchiveBuffer,
347
343
  desktopFiles: admitted.desktopFiles.size === 0 ? undefined : admitted.desktopFiles,
@@ -373,3 +369,14 @@ function subjectPhaseReporter(input) {
373
369
  input.emit?.(phaseEvent(event, { kind: "participant", participant }));
374
370
  };
375
371
  }
372
+ /**
373
+ * The Modal token pair's values, which a provider error can quote into run warnings, and the
374
+ * subject env values the study names.
375
+ */
376
+ function otherValues(env, subjectEnv) {
377
+ return [
378
+ env.MODAL_TOKEN_ID?.trim() ?? "",
379
+ env.MODAL_TOKEN_SECRET?.trim() ?? "",
380
+ ...subjectEnv.map((name) => env[name] ?? ""),
381
+ ];
382
+ }
@@ -1,3 +1,4 @@
1
+ import type { SandboxProviderId } from "../../run/sandbox-receipts.js";
1
2
  import type { Verdict } from "../../run/judge.js";
2
3
  import type { HostSuspensionReading } from "../../run/host-suspension.js";
3
4
  import type { LocalCapacity } from "../../substrates/local/capacity.js";
@@ -344,6 +345,8 @@ export interface E2BDesktopDeps {
344
345
  now: () => number;
345
346
  /** Loads the E2B SDK for the participant's desktop. Defaults to loadE2BDesktopModule. */
346
347
  desktopModule?: () => Promise<E2BDesktopModule>;
348
+ /** Who hosts the participant's desktop; E2B when unset. */
349
+ sandboxProvider?: SandboxProviderId;
347
350
  /** The caller's prepareDesktop, called with the participant as its target. */
348
351
  prepareDesktop?: NonNullable<RunStudyHomes["prepareDesktop"]>;
349
352
  /** Clock and sleep for detached provisioning steps. */
@@ -26,6 +26,8 @@ export interface LiveSandboxInputs {
26
26
  verdictNonce: string;
27
27
  maxMinutes: number;
28
28
  e2bApiKey: string;
29
+ /** The run's env; a Modal run reads its Modal credentials from it. */
30
+ env: Readonly<Record<string, string | undefined>>;
29
31
  runPaths: StartedRun["paths"];
30
32
  metadata: ReturnType<typeof buildSandboxMetadata>;
31
33
  /** The run's warnings. Steps append to them. */
@@ -11,6 +11,7 @@ import { extractLocalActorVerdict, normalizeLocalActorTranscript, } from "../../
11
11
  import { NODE_BOOTSTRAP_COMMAND, NODE_BOOTSTRAP_TIMEOUT_MS } from "../../subject/node-bootstrap.js";
12
12
  import { observeDesktopResources, } from "../../substrates/e2b/desktop-resources.js";
13
13
  import { acquireE2BShellSandbox } from "../../substrates/e2b/sandbox.js";
14
+ import { loadModalShellModule } from "../../substrates/modal/sandbox.js";
14
15
  import { e2bAccount } from "../../substrates/e2b/connection.js";
15
16
  import { E2BDesktopStartupError, loadE2BDesktopModule, } from "../../substrates/e2b/sdk.js";
16
17
  import { shellQuote } from "../../substrates/shell.js";
@@ -60,7 +61,10 @@ export class LiveTerminalSandbox {
60
61
  const { requestTimeoutMs, sandboxTimeoutMs } = this;
61
62
  // Declared egress allowlist, or undefined for the historical unrestricted default.
62
63
  const egressAllow = plan.egressAllow;
63
- const sandboxModule = await (deps.desktopModule ?? loadE2BDesktopModule)();
64
+ const provider = plan.sandboxProvider;
65
+ const providerName = provider === "modal" ? "Modal" : "E2B";
66
+ const sandboxModule = await (deps.desktopModule ??
67
+ (provider === "modal" ? () => loadModalShellModule(this.inputs.env) : loadE2BDesktopModule))();
64
68
  await validatePreparedRunArtifactPaths(runPaths);
65
69
  // No sandbox-global env in either mode. In openai-egress, only this host-side SDK request
66
70
  // carries the real runtime key; participant commands receive an inert placeholder. The proxy
@@ -83,6 +87,7 @@ export class LiveTerminalSandbox {
83
87
  this.account = e2bAccount(sandboxModule, options);
84
88
  const acquired = await acquireE2BShellSandbox({
85
89
  module: sandboxModule,
90
+ provider,
86
91
  options,
87
92
  retry: {
88
93
  // A failed first attempt may have allocated a sandbox whose id never reached this run;
@@ -103,8 +108,8 @@ export class LiveTerminalSandbox {
103
108
  this.sandboxId = sandboxId;
104
109
  this.createdAtMs = now();
105
110
  await validatePreparedRunArtifactPaths(runPaths);
106
- recordLifecycle("terminal-lab.sandbox.created", `E2B shell sandbox ${sandboxId} created with allowlisted metadata and kill-on-timeout, and with no sandbox-wide environment variables.`);
107
- const sandboxResources = await observeDesktopResources(sandbox);
111
+ recordLifecycle("terminal-lab.sandbox.created", `${providerName} shell sandbox ${sandboxId} created with allowlisted metadata and kill-on-timeout, and with no sandbox-wide environment variables.`);
112
+ const sandboxResources = await observeDesktopResources(sandbox, undefined, provider === "modal" ? "modal.create" : "e2b.getInfo");
108
113
  this.resources = sandboxResources;
109
114
  if ("reason" in sandboxResources) {
110
115
  warnings.push(`Sandbox resource size unavailable (${sandboxResources.reason}); its compute cost remains unpriced.`);