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
package/CHANGELOG.md CHANGED
@@ -8,6 +8,17 @@ 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
+
11
22
  ## 0.138.0: A terminal study can run on Modal (2026-10-19)
12
23
 
13
24
  humanish 0.138.0 can run a terminal study's shell on Modal as well as E2B. Set
@@ -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. */
@@ -93,7 +93,7 @@ export function isRunOutcome(value) {
93
93
  function isRunProviderResource(value) {
94
94
  return (isRecord(value) &&
95
95
  value.schema === "humanish.provider-resource.v1" &&
96
- value.provider === "e2b-desktop" &&
96
+ (value.provider === "e2b-desktop" || value.provider === "modal-desktop") &&
97
97
  value.kind === "sandbox" &&
98
98
  typeof value.id === "string" &&
99
99
  value.id.trim().length > 0 &&
@@ -53,7 +53,7 @@ export interface RunFeedbackCandidate {
53
53
  scenario_id: string;
54
54
  persona_id: string;
55
55
  actor: "codex-tui" | "codex-exec" | "codex-app-server" | "computer-use" | "synthetic-dry-run" | "unknown";
56
- substrate: "e2b-desktop" | "local-desktop" | "e2b-terminal" | "local-filesystem" | "codex-app-server" | "unknown";
56
+ substrate: "e2b-desktop" | "modal-desktop" | "local-desktop" | "e2b-terminal" | "local-filesystem" | "codex-app-server" | "unknown";
57
57
  failure_owner: "harness" | "target-app" | "actor" | "environment" | "unknown";
58
58
  summary: string;
59
59
  expected: string;
@@ -479,7 +479,8 @@ export interface RunCostSummary {
479
479
  }
480
480
  export interface RunProviderResource {
481
481
  schema: "humanish.provider-resource.v1";
482
- provider: "e2b-desktop";
482
+ /** The hosted desktop's provider: E2B, or Modal for `execution.target: modal-desktop`. */
483
+ provider: "e2b-desktop" | "modal-desktop";
483
484
  kind: "sandbox";
484
485
  /**
485
486
  * "[redacted-sandbox-id]" in runs from 0.110 on; earlier runs recorded the raw id here. The raw id
@@ -66,6 +66,7 @@ function isFeedbackActor(value) {
66
66
  }
67
67
  function isFeedbackSubstrate(value) {
68
68
  return (value === "e2b-desktop" ||
69
+ value === "modal-desktop" ||
69
70
  value === "local-desktop" ||
70
71
  value === "e2b-terminal" ||
71
72
  value === "local-filesystem" ||
@@ -111,8 +111,9 @@ function appUrlValidationReason(config) {
111
111
  }
112
112
  }
113
113
  else {
114
- if (config.execution?.target !== "e2b-desktop" && config.execution?.target !== "local") {
115
- return "app-url computer-use subjects require `execution.target: local` or `e2b-desktop`.";
114
+ const target = config.execution?.target;
115
+ if (target !== "e2b-desktop" && target !== "modal-desktop" && target !== "local") {
116
+ return "app-url computer-use subjects require `execution.target: local`, `e2b-desktop` or `modal-desktop`.";
116
117
  }
117
118
  if (!actorResolvesToComputerUse(type)) {
118
119
  return `actor.type must be a registered computer-use actor for app-url × e2b-desktop studies (one of: ${registeredComputerUseActors().join(", ")}); for local scripted execution use a registered scripted-browser actor (${registeredScriptedBrowserActors().join(", ")}). Got "${type}".`;
@@ -17,8 +17,9 @@ export function parseExecution(raw) {
17
17
  if (target !== "local" &&
18
18
  target !== "e2b-desktop" &&
19
19
  target !== "e2b-terminal" &&
20
+ target !== "modal-desktop" &&
20
21
  target !== "modal-terminal") {
21
- return invalid("`execution.target` must be local, e2b-desktop, e2b-terminal, or modal-terminal.");
22
+ return invalid("`execution.target` must be local, e2b-desktop, e2b-terminal, modal-desktop, or modal-terminal.");
22
23
  }
23
24
  execution.target = target;
24
25
  }
@@ -7,6 +7,7 @@ export declare function provisionedSubject(config: StudyConfig): ProvisionedSubj
7
7
  /** Keys, env and local tools a live run checks right before it acquires anything. */
8
8
  export declare function desktopRequirements(config: StudyConfig, args: {
9
9
  e2b: boolean;
10
+ modal?: boolean;
10
11
  brain: Brain;
11
12
  localVm: boolean;
12
13
  externalCatch: boolean;
@@ -73,6 +73,8 @@ export function desktopRequirements(config, args) {
73
73
  const requirements = [];
74
74
  if (args.e2b)
75
75
  requirements.push({ kind: "key", name: "E2B_API_KEY" });
76
+ if (args.modal === true)
77
+ requirements.push({ kind: "key", name: "MODAL_TOKEN_ID" }, { kind: "key", name: "MODAL_TOKEN_SECRET" });
76
78
  if (args.localVm)
77
79
  requirements.push({ kind: "local-vm" });
78
80
  if (args.brain.kind === "openai")
@@ -133,6 +133,8 @@ export interface AppUrlSubject {
133
133
  */
134
134
  export type ComputerUseRunner = {
135
135
  readonly desktop: "e2b-desktop";
136
+ /** Who hosts the desktop: E2B, or Modal for `execution.target: modal-desktop`. */
137
+ readonly provider: SandboxProviderId;
136
138
  readonly brain: Brain;
137
139
  readonly participants: NonEmpty<ComputerUseParticipant>;
138
140
  readonly subject: AppUrlSubject | ProvisionedSubject | {
@@ -467,7 +467,7 @@ export interface StudyActor {
467
467
  */
468
468
  dwell?: DwellWindow;
469
469
  }
470
- type StudyExecutionTarget = "local" | "e2b-desktop" | "e2b-terminal" | "modal-terminal";
470
+ type StudyExecutionTarget = "local" | "e2b-desktop" | "e2b-terminal" | "modal-desktop" | "modal-terminal";
471
471
  /** Terminal transport: the captured non-interactive exec stream (stdin disabled). It is not an
472
472
  * interactive duplex PTY; labeling captured exec output "pty" would be a claim/mechanism
473
473
  * mismatch, so this route uses "exec-stream". */
@@ -262,7 +262,9 @@ function routesOf(config) {
262
262
  terminal: isTerminalProductComposition(config),
263
263
  shared,
264
264
  externalPublic: shared && config.subject.source === "app-url",
265
- hostedCuaBrowser: config.execution?.target === "e2b-desktop" && cua,
265
+ hostedCuaBrowser: (config.execution?.target === "e2b-desktop" ||
266
+ config.execution?.target === "modal-desktop") &&
267
+ cua,
266
268
  createsE2BDesktop: (cua || (scripted && config.subject.source === "clone")) &&
267
269
  config.execution?.target === "e2b-desktop",
268
270
  };
@@ -211,6 +211,11 @@ export declare const DESKTOP_CREATE_CLEANUP_TIMEOUT_MS = 10000;
211
211
  * the id.
212
212
  */
213
213
  export declare function observeDesktopAllocation(options: E2BDesktopCreateOptions, observer: (sandboxId: string) => void): void;
214
+ /**
215
+ * Tell the observer registered for `options` that its sandbox exists, for a module that starts its
216
+ * own desktop after create, as the Modal one does. An observer that throws does not fail the create.
217
+ */
218
+ export declare function reportDesktopAllocation(options: E2BDesktopCreateOptions, sandboxId: string): void;
214
219
  type DesktopCreateCleanup = "killed" | "already_gone" | "unconfirmed";
215
220
  /** Startup failed after this call acquired a handle. No credentials/options are included here. */
216
221
  export declare class E2BDesktopStartupError extends Error {
@@ -31,6 +31,18 @@ const allocationObservers = new WeakMap();
31
31
  export function observeDesktopAllocation(options, observer) {
32
32
  allocationObservers.set(options, observer);
33
33
  }
34
+ /**
35
+ * Tell the observer registered for `options` that its sandbox exists, for a module that starts its
36
+ * own desktop after create, as the Modal one does. An observer that throws does not fail the create.
37
+ */
38
+ export function reportDesktopAllocation(options, sandboxId) {
39
+ try {
40
+ allocationObservers.get(options)?.(sandboxId);
41
+ }
42
+ catch {
43
+ // The resolved handle reports the id as well.
44
+ }
45
+ }
34
46
  /** Startup failed after this call acquired a handle. No credentials/options are included here. */
35
47
  export class E2BDesktopStartupError extends Error {
36
48
  cleanup;
@@ -0,0 +1,16 @@
1
+ import { ReadableStream } from "node:stream/web";
2
+ import type { E2BCommandResult, E2BCommandRunOptions } from "../e2b/sdk.js";
3
+ import type { ModalSandbox } from "./sdk.js";
4
+ /** Modal takes sandbox and command timeouts in whole seconds; a shorter one is rounded up. */
5
+ export declare const wholeSeconds: (ms: number) => number;
6
+ /** Run `command` as the sandbox user, to completion or, with `background`, returning a handle. */
7
+ export declare function runCommand(sandbox: ModalSandbox, command: string, options?: E2BCommandRunOptions): Promise<E2BCommandResult>;
8
+ /** Relative paths are under the sandbox user's home, as on E2B. */
9
+ export declare function sandboxPath(path: string): string;
10
+ export declare function writeFile(sandbox: ModalSandbox, path: string, data: string | ArrayBuffer): Promise<{
11
+ path: string;
12
+ }>;
13
+ /** A file's bytes as a stream, read as the sandbox user. */
14
+ export declare function readFile(sandbox: ModalSandbox, path: string): Promise<ReadableStream<Uint8Array>>;
15
+ /** A file's bytes, whole. */
16
+ export declare function readBytes(sandbox: ModalSandbox, path: string): Promise<Uint8Array>;