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
@@ -1,7 +1,9 @@
1
1
  import { type PreparedOutputRoot } from "./contained-output.js";
2
2
  export declare const SANDBOX_RECEIPTS_ARTIFACT = "sandbox-receipts.ndjson";
3
3
  /** The providers humanish allocates sandboxes from. Reclaim dispatches on this id. */
4
- export type SandboxProviderId = "e2b";
4
+ export type SandboxProviderId = "e2b" | "modal";
5
+ /** Whether this humanish can act on sandboxes from `provider`. */
6
+ export declare function isSandboxProviderId(provider: string): provider is SandboxProviderId;
5
7
  export interface SandboxReceipt {
6
8
  at: string;
7
9
  /** The participant or subject label the sandbox belongs to (public-safe token). */
@@ -31,18 +33,24 @@ export declare function appendedSandboxIds(root: PreparedOutputRoot): string[];
31
33
  */
32
34
  export declare function appendSandboxReceipt(root: PreparedOutputRoot, receipt: SandboxReceipt): Promise<boolean>;
33
35
  /** Append one owner line; false when the write failed, which never fails the create. */
34
- export declare function appendSandboxOwner(root: PreparedOutputRoot, owner: SandboxOwnerTags): Promise<boolean>;
36
+ export declare function appendSandboxOwner(root: PreparedOutputRoot, owner: SandboxOwnerTags, provider: SandboxProviderId): Promise<boolean>;
35
37
  /** The owner tags every sandbox carries; sandboxOwnerTags in src/run/sandbox-creates.ts. */
36
38
  export type SandboxOwnerTags = {
37
39
  tool: "humanish";
38
40
  runId: string;
39
41
  runKey: string;
40
42
  };
43
+ /** One owner line: the provider searched and the tags searched for. */
44
+ export interface SandboxOwner {
45
+ provider: SandboxProviderId;
46
+ tags: SandboxOwnerTags;
47
+ }
41
48
  /**
42
49
  * The owner tag sets a journal's owner lines record for the run named `runId`. Only a complete
43
- * tuple counts: provider e2b, `tool: humanish`, this run's id and a well-formed key, and nothing
44
- * else. A partial or foreign line would widen reclaim's search past this run, so it is skipped.
50
+ * tuple counts: a provider this version knows, `tool: humanish`, this run's id and a well-formed
51
+ * key, and nothing else. A partial or foreign line would widen reclaim's search past this run, so
52
+ * it is skipped.
45
53
  */
46
- export declare function parseSandboxOwners(text: string, runId: string): SandboxOwnerTags[];
54
+ export declare function parseSandboxOwners(text: string, runId: string): SandboxOwner[];
47
55
  /** Parse a receipts file leniently: a torn final line (crash mid-append) drops, valid lines keep. */
48
56
  export declare function parseSandboxReceipts(text: string): ParsedSandboxReceipt[];
@@ -6,6 +6,11 @@
6
6
  import { appendFile } from "node:fs/promises";
7
7
  import { prepareContainedOutputFile } from "./contained-output.js";
8
8
  export const SANDBOX_RECEIPTS_ARTIFACT = "sandbox-receipts.ndjson";
9
+ const SANDBOX_PROVIDERS = new Set(["e2b", "modal"]);
10
+ /** Whether this humanish can act on sandboxes from `provider`. */
11
+ export function isSandboxProviderId(provider) {
12
+ return SANDBOX_PROVIDERS.has(provider);
13
+ }
9
14
  /**
10
15
  * The ids this process has receipted for each run directory, kept even when the append fails, so
11
16
  * the run's other records can still be scrubbed of them. Keyed by the directory's identity, so a
@@ -51,9 +56,9 @@ export async function appendSandboxReceipt(root, receipt) {
51
56
  }
52
57
  }
53
58
  /** Append one owner line; false when the write failed, which never fails the create. */
54
- export async function appendSandboxOwner(root, owner) {
59
+ export async function appendSandboxOwner(root, owner, provider) {
55
60
  try {
56
- const line = { at: new Date().toISOString(), provider: "e2b", owner };
61
+ const line = { at: new Date().toISOString(), provider, owner };
57
62
  const filePath = await prepareContainedOutputFile(root, SANDBOX_RECEIPTS_ARTIFACT);
58
63
  // The leading newline ends a line an earlier append left torn, so this record parses.
59
64
  await appendFile(filePath, `\n${JSON.stringify(line)}\n`, "utf8");
@@ -68,8 +73,9 @@ export async function appendSandboxOwner(root, owner) {
68
73
  const RUN_KEY = /^[0-9a-f]{16}$/;
69
74
  /**
70
75
  * The owner tag sets a journal's owner lines record for the run named `runId`. Only a complete
71
- * tuple counts: provider e2b, `tool: humanish`, this run's id and a well-formed key, and nothing
72
- * else. A partial or foreign line would widen reclaim's search past this run, so it is skipped.
76
+ * tuple counts: a provider this version knows, `tool: humanish`, this run's id and a well-formed
77
+ * key, and nothing else. A partial or foreign line would widen reclaim's search past this run, so
78
+ * it is skipped.
73
79
  */
74
80
  export function parseSandboxOwners(text, runId) {
75
81
  const owners = new Map();
@@ -80,13 +86,19 @@ export function parseSandboxOwners(text, runId) {
80
86
  try {
81
87
  const parsed = JSON.parse(trimmed);
82
88
  const owner = parsed.owner;
83
- if (parsed.provider !== "e2b" || owner === null || typeof owner !== "object")
89
+ const provider = parsed.provider;
90
+ if (typeof provider !== "string" || !isSandboxProviderId(provider))
91
+ continue;
92
+ if (owner === null || typeof owner !== "object")
84
93
  continue;
85
94
  if (Object.keys(owner).length !== 3 || owner.tool !== "humanish" || owner.runId !== runId)
86
95
  continue;
87
96
  if (typeof owner.runKey !== "string" || !RUN_KEY.test(owner.runKey))
88
97
  continue;
89
- owners.set(owner.runKey, { tool: "humanish", runId, runKey: owner.runKey });
98
+ owners.set(JSON.stringify([provider, owner.runKey]), {
99
+ provider,
100
+ tags: { tool: "humanish", runId, runKey: owner.runKey },
101
+ });
90
102
  }
91
103
  catch {
92
104
  // A torn line: skip it.
@@ -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}".`;
@@ -258,15 +259,15 @@ function desktopCliValidationReason(config) {
258
259
  return null;
259
260
  }
260
261
  // terminal-product route: a real autonomous agent studies a CLI/product from public surfaces inside
261
- // an E2B shell. Fail-closed (claims match mechanism: a field that cannot act on this route is a
262
- // parse error): a registered terminal actor only, execution.target e2b-terminal or absent (absent
263
- // defaults to e2b-terminal, the only target where an in-sandbox agent runs), one participant until
264
- // fan-out lands.
262
+ // a hosted shell. Fail-closed (claims match mechanism: a field that cannot act on this route is a
263
+ // parse error): a registered terminal actor only, execution.target e2b-terminal, modal-terminal or
264
+ // absent (absent defaults to e2b-terminal), one participant until fan-out lands.
265
265
  function terminalValidationReason(config) {
266
266
  if (config.subject.source === "terminal-product") {
267
267
  const type = config.actor?.type ?? "";
268
- if (config.execution?.target !== undefined && config.execution.target !== "e2b-terminal") {
269
- return "A terminal-product subject runs its agent in an E2B shell. Set `execution.target: e2b-terminal` or omit it; `local` and `e2b-desktop` are not supported.";
268
+ const target = config.execution?.target;
269
+ if (target !== undefined && target !== "e2b-terminal" && target !== "modal-terminal") {
270
+ return "A terminal-product subject runs its agent in a hosted shell. Set `execution.target` to e2b-terminal or modal-terminal, or omit it for e2b-terminal; `local` and `e2b-desktop` are not supported.";
270
271
  }
271
272
  if (!actorResolvesToTerminal(type)) {
272
273
  return `actor.type must be a registered terminal actor for terminal-product subjects (one of: ${registeredTerminalActors().join(", ")}). Got "${type}".`;
@@ -275,10 +276,11 @@ function terminalValidationReason(config) {
275
276
  return "Terminal fan-out to more than one participant is not supported yet; set participants to 1.";
276
277
  }
277
278
  }
278
- else if (config.execution?.target === "e2b-terminal") {
279
- // e2b-terminal is the terminal-product substrate only. Any other source declaring it is a
280
- // mis-config: reject, never silently mishandle (mirrors app-url's e2b-desktop pairing rule).
281
- return "`execution.target: e2b-terminal` requires `subject.source: terminal-product` with a registered terminal actor.";
279
+ else if (config.execution?.target === "e2b-terminal" ||
280
+ config.execution?.target === "modal-terminal") {
281
+ // The terminal targets are the terminal-product substrates only. Any other source declaring one
282
+ // is a mis-config: reject, never silently mishandle (mirrors app-url's e2b-desktop pairing rule).
283
+ return `\`execution.target: ${config.execution.target}\` requires \`subject.source: terminal-product\` with a registered terminal actor.`;
282
284
  }
283
285
  else if (actorResolvesToTerminal(config.actor?.type)) {
284
286
  // A registered terminal actor on a non-terminal-product subject: rejected, never ignored (the
@@ -279,7 +279,7 @@ async function realEmailCheck(connection, keys, args) {
279
279
  /** How the participant authenticates: the terminal model key, or a local agent's login. */
280
280
  async function participantChecks(config, route, keys, local, args, checkAccount) {
281
281
  if (route === "terminal") {
282
- const key = keys.find((name) => name !== "E2B_API_KEY");
282
+ const key = keys.find((name) => name === "CODEX_API_KEY" || name === "OPENAI_API_KEY");
283
283
  return [
284
284
  {
285
285
  name: "terminal model authentication",
@@ -14,8 +14,12 @@ export function parseExecution(raw) {
14
14
  const execution = {};
15
15
  if (raw.target !== undefined) {
16
16
  const target = str(raw.target);
17
- if (target !== "local" && target !== "e2b-desktop" && target !== "e2b-terminal") {
18
- return invalid("`execution.target` must be local, e2b-desktop, or e2b-terminal.");
17
+ if (target !== "local" &&
18
+ target !== "e2b-desktop" &&
19
+ target !== "e2b-terminal" &&
20
+ target !== "modal-desktop" &&
21
+ target !== "modal-terminal") {
22
+ return invalid("`execution.target` must be local, e2b-desktop, e2b-terminal, modal-desktop, or modal-terminal.");
19
23
  }
20
24
  execution.target = target;
21
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")
@@ -4,6 +4,7 @@ import type { ReasoningEffort } from "../actors/reasoning-effort.js";
4
4
  import type { BrowserSurface } from "../actors/scripted-browser/types.js";
5
5
  import type { ScriptedRefusal } from "../routes/scripted/plan.js";
6
6
  import type { TerminalRefusal } from "../routes/terminal/plan.js";
7
+ import type { SandboxProviderId } from "../run/sandbox-receipts.js";
7
8
  import type { RunStudyProvenance } from "../run/study-provenance.js";
8
9
  import type { ArrivalPlan } from "./arrivals.js";
9
10
  import type { BundleEstimate } from "./bundle-estimate.js";
@@ -19,7 +20,7 @@ type AtLeastTwo<T> = readonly [T, T, ...T[]];
19
20
  */
20
21
  export type Requirement = {
21
22
  readonly kind: "key";
22
- readonly name: "OPENAI_API_KEY" | "E2B_API_KEY";
23
+ readonly name: "OPENAI_API_KEY" | "E2B_API_KEY" | "MODAL_TOKEN_ID" | "MODAL_TOKEN_SECRET";
23
24
  }
24
25
  /** The terminal runtime key: CODEX_API_KEY, else OPENAI_API_KEY. */
25
26
  | {
@@ -132,6 +133,8 @@ export interface AppUrlSubject {
132
133
  */
133
134
  export type ComputerUseRunner = {
134
135
  readonly desktop: "e2b-desktop";
136
+ /** Who hosts the desktop: E2B, or Modal for `execution.target: modal-desktop`. */
137
+ readonly provider: SandboxProviderId;
135
138
  readonly brain: Brain;
136
139
  readonly participants: NonEmpty<ComputerUseParticipant>;
137
140
  readonly subject: AppUrlSubject | ProvisionedSubject | {
@@ -223,6 +226,8 @@ export interface SharedWorldPlan extends PlanBase {
223
226
  }
224
227
  export type TerminalPlan = PlanBase & {
225
228
  readonly route: "terminal";
229
+ /** Where the shell sandbox runs: `execution.target` modal-terminal, else E2B. */
230
+ readonly sandboxProvider: SandboxProviderId;
226
231
  /** The registered terminal actor that runs. */
227
232
  readonly actor: string;
228
233
  readonly product: StudySubjectProduct & {
@@ -9,9 +9,14 @@ import { localAgentRefusal } from "../actors/local-agent/readiness.js";
9
9
  import { describeMissingKeys } from "../keys/key-resolution.js";
10
10
  import { MODEL_RATES, unpricedCapMessage } from "../run/pricing.js";
11
11
  /** Provider keys in the order the routes name them in a refusal. */
12
- const KEY_ORDER = ["OPENAI_API_KEY", "E2B_API_KEY"];
12
+ const KEY_ORDER = [
13
+ "OPENAI_API_KEY",
14
+ "E2B_API_KEY",
15
+ "MODAL_TOKEN_ID",
16
+ "MODAL_TOKEN_SECRET",
17
+ ];
13
18
  const present = (env, name) => (env[name]?.trim() ?? "") !== "";
14
- /** The `key` requirements whose value `env` lacks, OPENAI_API_KEY before E2B_API_KEY. */
19
+ /** The `key` requirements whose value `env` lacks, in KEY_ORDER. */
15
20
  function missingKeys(requirements, env) {
16
21
  const required = new Set(requirements.flatMap((requirement) => (requirement.kind === "key" ? [requirement.name] : [])));
17
22
  return KEY_ORDER.filter((name) => required.has(name) && !present(env, name));
@@ -69,7 +69,7 @@ export declare function isScriptedBrowserComposition(config: StudyConfig): boole
69
69
  export declare function isProvisionedScriptedBrowserComposition(config: StudyConfig): boolean;
70
70
  /**
71
71
  * True when this config routes to the terminal-product backend: a terminal-product subject whose
72
- * first actor resolves to a registered terminal actor (execution.target e2b-terminal or absent;
72
+ * first actor resolves to a registered terminal actor (execution.target e2b-terminal, modal-terminal or absent;
73
73
  * the parse layer enforces that pairing). Mirror of isComputerUseComposition/isScriptedBrowserComposition;
74
74
  * the single source of truth for routeOf and the warning logic.
75
75
  */
@@ -177,7 +177,7 @@ export function isProvisionedScriptedBrowserComposition(config) {
177
177
  }
178
178
  /**
179
179
  * True when this config routes to the terminal-product backend: a terminal-product subject whose
180
- * first actor resolves to a registered terminal actor (execution.target e2b-terminal or absent;
180
+ * first actor resolves to a registered terminal actor (execution.target e2b-terminal, modal-terminal or absent;
181
181
  * the parse layer enforces that pairing). Mirror of isComputerUseComposition/isScriptedBrowserComposition;
182
182
  * the single source of truth for routeOf and the warning logic.
183
183
  */
@@ -467,7 +467,7 @@ export interface StudyActor {
467
467
  */
468
468
  dwell?: DwellWindow;
469
469
  }
470
- type StudyExecutionTarget = "local" | "e2b-desktop" | "e2b-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
  };
@@ -1,11 +1,13 @@
1
1
  import type { E2BDesktopSandbox } from "./sdk.js";
2
2
  import { type DesktopResources } from "../../run/pricing.js";
3
+ /** Where an allocation's size came from: E2B's report, or the size humanish asked Modal for. */
4
+ export type DesktopResourceSource = "e2b.getInfo" | "modal.create";
3
5
  export type DesktopResourceObservation = {
4
6
  resources: DesktopResources;
5
- source: "e2b.getInfo";
7
+ source: DesktopResourceSource;
6
8
  } | {
7
9
  reason: "metadata_unavailable" | "metadata_invalid" | "metadata_timeout";
8
10
  };
9
11
  /** Called once after the caller owns and journals the handle. Never throws or delays cleanup
10
12
  * beyond the bounded wait. Persist only resource quantities, never the raw info/connection. */
11
- export declare function observeDesktopResources(desktop: Pick<E2BDesktopSandbox, "getInfo">, timeoutMs?: number): Promise<DesktopResourceObservation>;
13
+ export declare function observeDesktopResources(desktop: Pick<E2BDesktopSandbox, "getInfo">, timeoutMs?: number, source?: DesktopResourceSource): Promise<DesktopResourceObservation>;
@@ -1,7 +1,7 @@
1
1
  import { isDesktopResources } from "../../run/pricing.js";
2
2
  /** Called once after the caller owns and journals the handle. Never throws or delays cleanup
3
3
  * beyond the bounded wait. Persist only resource quantities, never the raw info/connection. */
4
- export async function observeDesktopResources(desktop, timeoutMs = 1_000) {
4
+ export async function observeDesktopResources(desktop, timeoutMs = 1_000, source = "e2b.getInfo") {
5
5
  const controller = new AbortController();
6
6
  let timer;
7
7
  try {
@@ -16,7 +16,7 @@ export async function observeDesktopResources(desktop, timeoutMs = 1_000) {
16
16
  });
17
17
  const resources = { cpuCount: info?.cpuCount, memoryMiB: info?.memoryMB };
18
18
  return isDesktopResources(resources)
19
- ? { resources, source: "e2b.getInfo" }
19
+ ? { resources, source }
20
20
  : { reason: "metadata_invalid" };
21
21
  }),
22
22
  new Promise((resolve) => {
@@ -139,7 +139,7 @@ export function desktopPeerAdvice(install, spec = "@e2b/desktop") {
139
139
  return {
140
140
  where: ", and humanish is running from a source checkout",
141
141
  command: "pnpm install",
142
- advice: spec === "@e2b/desktop"
142
+ advice: spec === "@e2b/desktop" || spec === "modal"
143
143
  ? "Run `pnpm install` in the humanish checkout, which installs it as a dev dependency."
144
144
  : `Set ${spec} in the humanish checkout's package.json, then run \`pnpm install\` there.`,
145
145
  };
@@ -1,3 +1,4 @@
1
+ import { type SandboxProviderId } from "../../run/sandbox-receipts.js";
1
2
  import type { PreparedOutputRoot } from "../../run/contained-output.js";
2
3
  import { type DesktopReleaseResult, type OwnedDesktopAllocation } from "../desktop-session.js";
3
4
  import type { StudyConfig } from "../../study/types.js";
@@ -13,6 +14,8 @@ interface E2BSandboxReceiptTarget {
13
14
  }
14
15
  export interface E2BSandboxRequest {
15
16
  module: E2BDesktopModule;
17
+ /** Who `module` creates sandboxes on; receipts and owner lines name it. Defaults to e2b. */
18
+ provider?: SandboxProviderId;
16
19
  options: E2BDesktopCreateOptions;
17
20
  retry?: TransientRetryHooks;
18
21
  /**
@@ -9,7 +9,7 @@
9
9
  // journal at that point, so a startup error that quotes it is scrubbed from every run file.
10
10
  import { redactText, toErrorMessage } from "../../evidence/redaction.js";
11
11
  import { beginSandboxCreate, recordSandboxOwnerOnce, SandboxCreateRefusedError, sandboxOwnerTags, } from "../../run/sandbox-creates.js";
12
- import { appendSandboxOwner, appendSandboxReceipt } from "../../run/sandbox-receipts.js";
12
+ import { appendSandboxOwner, appendSandboxReceipt, } from "../../run/sandbox-receipts.js";
13
13
  import { ownDesktopAllocation, } from "../desktop-session.js";
14
14
  import { e2bAccount } from "./connection.js";
15
15
  import { E2B_DEBUG_KILL_DETAIL, E2BDesktopStartupError, e2bDebugMode, isSandboxNotFoundError, observeDesktopAllocation, } from "./sdk.js";
@@ -23,9 +23,10 @@ export function acquireE2BShellSandbox(request) {
23
23
  }
24
24
  async function acquire(request, template) {
25
25
  const { module } = request;
26
+ const provider = request.provider ?? "e2b";
26
27
  const target = request.receipt;
27
28
  // Refused before any provider call once the run is stopping.
28
- const ticket = target === null ? undefined : beginSandboxCreate(target.root, target.participantId);
29
+ const ticket = target === null ? undefined : beginSandboxCreate(target.root, target.participantId, provider);
29
30
  // The receipts written when the guard reported an id, before desktop startup. A startup error
30
31
  // can quote the id and the create may never return, so the journal holds it first.
31
32
  const reported = new Map();
@@ -35,7 +36,7 @@ async function acquire(request, template) {
35
36
  // journal records them first, so reclaim knows which tags to search for.
36
37
  const owner = target === null ? undefined : sandboxOwnerTags(target.root);
37
38
  if (target !== null && owner !== undefined)
38
- await recordSandboxOwnerOnce(target.root, () => appendSandboxOwner(target.root, owner));
39
+ await recordSandboxOwnerOnce(target.root, provider, () => appendSandboxOwner(target.root, owner, provider));
39
40
  const options = owner === undefined
40
41
  ? request.options
41
42
  : { ...request.options, metadata: { ...request.options.metadata, ...owner } };
@@ -46,7 +47,7 @@ async function acquire(request, template) {
46
47
  return appendSandboxReceipt(root, {
47
48
  at: new Date(now()).toISOString(),
48
49
  laneId: participantId,
49
- provider: "e2b",
50
+ provider,
50
51
  sandboxId,
51
52
  ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
52
53
  });
@@ -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>;
@@ -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 {};