humanish 0.136.1 → 0.138.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 (99) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/actors/computer-use/actor.d.ts +2 -0
  3. package/dist/actors/computer-use/actor.js +1 -0
  4. package/dist/actors/computer-use/loop/observation.js +2 -0
  5. package/dist/actors/computer-use/loop/session.d.ts +3 -2
  6. package/dist/actors/computer-use/loop/session.js +8 -12
  7. package/dist/actors/computer-use/loop/types.d.ts +14 -1
  8. package/dist/cli/commands/runs.js +2 -2
  9. package/dist/cli/doctor.js +24 -0
  10. package/dist/evidence/browser-network.d.ts +108 -0
  11. package/dist/evidence/browser-network.js +516 -0
  12. package/dist/evidence/trace-page.d.ts +31 -1
  13. package/dist/evidence/trace-page.js +68 -2
  14. package/dist/keys/key-resolution.js +53 -2
  15. package/dist/observer-app.html +2 -2
  16. package/dist/routes/computer-use/bundle.js +1 -0
  17. package/dist/routes/computer-use/e2b-desktop/desktop.js +6 -0
  18. package/dist/routes/computer-use/e2b-desktop/fidelity.d.ts +12 -1
  19. package/dist/routes/computer-use/e2b-desktop/fidelity.js +7 -2
  20. package/dist/routes/computer-use/e2b-desktop/network.d.ts +52 -0
  21. package/dist/routes/computer-use/e2b-desktop/network.js +153 -0
  22. package/dist/routes/computer-use/e2b-desktop/start.js +18 -1
  23. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -0
  24. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  25. package/dist/routes/computer-use/e2b-desktop/teardown.js +6 -3
  26. package/dist/routes/computer-use/fanout-records.js +10 -0
  27. package/dist/routes/computer-use/participant-desktop.d.ts +7 -2
  28. package/dist/routes/computer-use/participant-model.js +2 -1
  29. package/dist/routes/computer-use/plan.js +2 -1
  30. package/dist/routes/computer-use/single-bundle.d.ts +3 -0
  31. package/dist/routes/computer-use/single-bundle.js +4 -0
  32. package/dist/routes/computer-use/types.d.ts +7 -0
  33. package/dist/routes/scripted/plan.js +2 -2
  34. package/dist/routes/shared-world/plan.js +3 -2
  35. package/dist/routes/terminal/live-sandbox.d.ts +2 -0
  36. package/dist/routes/terminal/live-sandbox.js +8 -3
  37. package/dist/routes/terminal/plan.js +51 -8
  38. package/dist/routes/terminal/session.js +8 -1
  39. package/dist/run/bundle.d.ts +2 -1
  40. package/dist/run/cost-summary.js +9 -4
  41. package/dist/run/live-items.d.ts +20 -2
  42. package/dist/run/live-items.js +23 -14
  43. package/dist/run/pricing.d.ts +11 -0
  44. package/dist/run/pricing.js +18 -5
  45. package/dist/run/reclaim-outcomes.d.ts +4 -1
  46. package/dist/run/reclaim-outcomes.js +8 -6
  47. package/dist/run/reclaim.d.ts +4 -1
  48. package/dist/run/reclaim.js +81 -34
  49. package/dist/run/sandbox-creates.d.ts +7 -4
  50. package/dist/run/sandbox-creates.js +17 -9
  51. package/dist/run/sandbox-receipts.d.ts +13 -5
  52. package/dist/run/sandbox-receipts.js +18 -6
  53. package/dist/run/streams.d.ts +7 -0
  54. package/dist/study/composition-rules.js +11 -10
  55. package/dist/study/config.js +4 -1
  56. package/dist/study/doctor.js +1 -1
  57. package/dist/study/keys.d.ts +3 -0
  58. package/dist/study/keys.js +2 -0
  59. package/dist/study/parse/comms.js +1 -20
  60. package/dist/study/parse/execution.js +35 -3
  61. package/dist/study/parse/values.d.ts +5 -0
  62. package/dist/study/parse/values.js +22 -0
  63. package/dist/study/plan-base.js +6 -1
  64. package/dist/study/plan-types.d.ts +5 -2
  65. package/dist/study/requirements.js +7 -2
  66. package/dist/study/routing.d.ts +1 -1
  67. package/dist/study/routing.js +1 -1
  68. package/dist/study/study-fields.d.ts +7 -1
  69. package/dist/study/study-fields.js +10 -0
  70. package/dist/study/types.d.ts +12 -1
  71. package/dist/study/validation.d.ts +8 -0
  72. package/dist/study/validation.js +31 -2
  73. package/dist/substrates/detached.d.ts +5 -0
  74. package/dist/substrates/detached.js +9 -0
  75. package/dist/substrates/e2b/cdp-network-capture.d.ts +23 -0
  76. package/dist/substrates/e2b/cdp-network-capture.js +194 -0
  77. package/dist/substrates/e2b/cdp-probe.d.ts +20 -5
  78. package/dist/substrates/e2b/cdp-probe.js +98 -34
  79. package/dist/substrates/e2b/desktop-cdp.d.ts +32 -2
  80. package/dist/substrates/e2b/desktop-cdp.js +87 -5
  81. package/dist/substrates/e2b/desktop-resources.d.ts +4 -2
  82. package/dist/substrates/e2b/desktop-resources.js +2 -2
  83. package/dist/substrates/e2b/peer-install.js +1 -1
  84. package/dist/substrates/e2b/sandbox.d.ts +3 -0
  85. package/dist/substrates/e2b/sandbox.js +5 -4
  86. package/dist/substrates/modal/image.d.ts +9 -0
  87. package/dist/substrates/modal/image.js +36 -0
  88. package/dist/substrates/modal/sandbox.d.ts +26 -0
  89. package/dist/substrates/modal/sandbox.js +367 -0
  90. package/dist/substrates/modal/sdk.d.ts +112 -0
  91. package/dist/substrates/modal/sdk.js +61 -0
  92. package/dist/verify/network.d.ts +10 -0
  93. package/dist/verify/network.js +162 -0
  94. package/dist/verify/pages.js +1 -16
  95. package/dist/verify/verify.js +8 -2
  96. package/docs/architecture/terminal-product-route.md +1 -1
  97. package/docs/contracts/run-bundle.md +90 -3
  98. package/docs/contracts/schemas.md +6 -5
  99. package/package.json +8 -2
@@ -1,14 +1,16 @@
1
1
  // `humanish reclaim`: stop an interrupted run's sandboxes and report what happened to each. It
2
2
  // finds them three ways: the exact ids the run journaled at create time (sandbox-receipts.ndjson),
3
3
  // the ids a create in this process reported before its receipt landed (the signal handler only),
4
- // and E2B's list of sandboxes tagged with the run's owner tags (sandboxOwnerTags), which covers a
5
- // sandbox whose id never reached the run. The list is filtered server-side by the run's tags and
4
+ // and each provider's list of sandboxes tagged with the run's owner tags (sandboxOwnerTags), which
5
+ // covers a sandbox whose id never reached the run. Every sandbox is acted on through the provider
6
+ // its receipt or owner line names: E2B, or Modal (src/substrates/modal/sandbox.ts). The list is filtered server-side by the run's tags and
6
7
  // each result is checked against every tag again, so it can only return this run's sandboxes; an
7
8
  // account-wide operation once destroyed unrelated infrastructure. Kills run concurrently, and
8
9
  // reclaim-receipt.json is written before the first kill and after each one, so an exit at any
9
- // point leaves a record of every sandbox not yet confirmed gone. `--check` asks E2B the same
10
- // questions and kills nothing.
10
+ // point leaves a record of every sandbox not yet confirmed gone. `--check` asks the providers the
11
+ // same questions and kills nothing.
11
12
  import { e2bAccount, e2bConnection } from "../substrates/e2b/connection.js";
13
+ import { modalAccount } from "../substrates/modal/sandbox.js";
12
14
  import { E2B_DEBUG_ENV, e2bDebugMode, loadE2BDesktopModule, } from "../substrates/e2b/sdk.js";
13
15
  import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
14
16
  import { discardPreflightJournal, listPreflightJournals, preflightReclaimDecision, } from "./preflight-receipts.js";
@@ -261,6 +263,45 @@ function unreadableMessage(file, refusal) {
261
263
  const kept = file === SANDBOX_RECEIPTS_ARTIFACT ? "the receipts were kept" : "the receipt was kept";
262
264
  return `${refusalText(file, refusal)}. Nothing was killed and ${kept}; check the file, then run reclaim again.`;
263
265
  }
266
+ /**
267
+ * The account for each provider the run's journal names, or the message that stops reclaim when
268
+ * one it needs cannot be reached. A run with no record and creates in flight is an E2B run unless
269
+ * its owner line says otherwise, as every run was before Modal.
270
+ */
271
+ async function reachProviders(receipts, owners, hooks) {
272
+ const env = hooks.env ?? process.env;
273
+ const recorded = new Set([
274
+ ...receipts.map((receipt) => receipt.provider),
275
+ ...owners.map((owner) => owner.provider),
276
+ ]);
277
+ // A create this process began counts too: its receipt and owner line may not have landed.
278
+ for (const provider of hooks.creates?.providers() ?? [])
279
+ recorded.add(provider);
280
+ const needsE2B = recorded.has("e2b") || (hooks.creates !== undefined && !recorded.has("modal"));
281
+ // E2B's SDK lists by tag as well as killing by id, so it is needed even with no receipt. Without
282
+ // it, an E2B receipt cannot be acted on; with no E2B receipt, only the tag search is lost.
283
+ const accounts = {};
284
+ const unavailable = {};
285
+ try {
286
+ const module = await (hooks.loadModule ?? loadE2BDesktopModule)();
287
+ accounts.e2b = e2bAccount(module, e2bConnection(env));
288
+ }
289
+ catch (error) {
290
+ unavailable.e2b = `Cannot load @e2b/desktop to reach E2B: ${redactText(toErrorMessage(error))}`;
291
+ if (needsE2B)
292
+ return unavailable.e2b;
293
+ }
294
+ // Modal is reached only for a run that recorded a Modal sandbox or owner line.
295
+ if (recorded.has("modal")) {
296
+ try {
297
+ accounts.modal = await (hooks.loadModalAccount ?? modalAccount)(env);
298
+ }
299
+ catch (error) {
300
+ return `Cannot reach Modal: ${redactText(toErrorMessage(error))}`;
301
+ }
302
+ }
303
+ return { accounts, unavailable, recorded, needsE2B };
304
+ }
264
305
  /**
265
306
  * Find and stop (or, for `--check`, look up) every sandbox one directory owns: its receipts, the
266
307
  * creates this process reported, and E2B's tagged list, each once by exact id.
@@ -277,19 +318,10 @@ async function reclaimRoot(root, label, hooks, warnings) {
277
318
  const owners = parseSandboxOwners(journal, label);
278
319
  const check = hooks.check === true;
279
320
  const requestTimeoutMs = hooks.requestTimeoutMs ?? 60_000;
280
- // The SDK lists by tag as well as killing by id, so it is needed even with no receipt. Without
281
- // it, an E2B receipt cannot be acted on; with no E2B receipt, only the tag search is lost.
282
- let e2b;
283
- let moduleError;
284
- try {
285
- const module = await (hooks.loadModule ?? loadE2BDesktopModule)();
286
- e2b = e2bAccount(module, e2bConnection(hooks.env ?? process.env));
287
- }
288
- catch (error) {
289
- moduleError = `Cannot load @e2b/desktop to reach E2B: ${redactText(toErrorMessage(error))}`;
290
- if (receipts.some((receipt) => receipt.provider === "e2b") || hooks.creates !== undefined)
291
- return { kind: "module-unavailable", receiptCount: receipts.length, message: moduleError };
292
- }
321
+ const reached = await reachProviders(receipts, owners, hooks);
322
+ if (typeof reached === "string")
323
+ return { kind: "module-unavailable", receiptCount: receipts.length, message: reached };
324
+ const { accounts, unavailable, recorded, needsE2B } = reached;
293
325
  // An earlier reclaim may have killed a sandbox no receipt names (the signal handler's creates
294
326
  // and tag finds). Its outcome is carried forward, so a later reclaim never erases that record.
295
327
  const earlier = check ? [] : await earlierOutcomes(root, label);
@@ -297,7 +329,7 @@ async function reclaimRoot(root, label, hooks, warnings) {
297
329
  if (!Array.isArray(earlier))
298
330
  return { kind: "unreadable", message: unreadableMessage(RECLAIM_RECEIPT_ARTIFACT, earlier) };
299
331
  const set = targetSet({
300
- e2b,
332
+ accounts,
301
333
  check,
302
334
  requestTimeoutMs,
303
335
  earlier,
@@ -312,38 +344,53 @@ async function reclaimRoot(root, label, hooks, warnings) {
312
344
  if (replaying)
313
345
  replayed.set(sandbox.sandboxId, sandbox);
314
346
  else
315
- set.add("e2b", sandbox.sandboxId, sandbox.participantId, "create", sandbox.released);
347
+ set.add(sandbox.provider, sandbox.sandboxId, sandbox.participantId, "create", sandbox.released);
316
348
  });
317
349
  replaying = false;
318
350
  for (const receipt of receipts) {
319
- const released = receipt.provider === "e2b" && replayed.get(receipt.sandboxId)?.released;
351
+ const create = replayed.get(receipt.sandboxId);
352
+ const released = create?.provider === receipt.provider && create.released;
320
353
  set.add(receipt.provider, receipt.sandboxId, receipt.laneId, "receipt", released === true);
321
354
  }
322
355
  for (const sandbox of replayed.values())
323
- set.add("e2b", sandbox.sandboxId, sandbox.participantId, "create", sandbox.released);
356
+ set.add(sandbox.provider, sandbox.sandboxId, sandbox.participantId, "create", sandbox.released);
324
357
  await set.start();
325
358
  if (hooks.creates !== undefined)
326
359
  await settledWithin(hooks.creates.settled, hooks.createsWaitMs);
327
- // The tags the run recorded before each create, and this directory's own: a copied or moved
328
- // run directory computes other tags than the ones its sandboxes carry.
329
- const search = await searchTagSets(e2b, [...owners, sandboxOwnerTags(root)], {
330
- requestTimeoutMs,
331
- unavailable: moduleError ?? "no E2B SDK",
332
- });
360
+ // Each provider is searched with the tags the run recorded for it before each create, and with
361
+ // this directory's own: a copied or moved run directory computes other tags than the ones its
362
+ // sandboxes carry. E2B is searched unless the journal names only Modal, as before Modal existed.
363
+ const providers = [
364
+ ...(needsE2B || !recorded.has("modal") ? ["e2b"] : []),
365
+ ...(accounts.modal === undefined ? [] : ["modal"]),
366
+ ];
367
+ const searches = await Promise.all(providers.map(async (provider) => {
368
+ const tagSets = [
369
+ ...owners.filter((owner) => owner.provider === provider).map((owner) => owner.tags),
370
+ sandboxOwnerTags(root),
371
+ ];
372
+ const result = await searchTagSets(accounts[provider], tagSets, {
373
+ requestTimeoutMs,
374
+ unavailable: unavailable[provider] ?? `no ${provider} account`,
375
+ });
376
+ return { provider, result };
377
+ }));
333
378
  let found = 0;
334
- for (const listed of search.sandboxes) {
335
- const metadata = listed.metadata ?? {};
336
- const participant = metadata.participantId ?? metadata.kind ?? "unknown";
337
- if (set.add("e2b", listed.sandboxId, participant, "tag"))
338
- found += 1;
339
- }
379
+ for (const { provider, result } of searches)
380
+ for (const listed of result.sandboxes) {
381
+ const metadata = listed.metadata ?? {};
382
+ const participant = metadata.participantId ?? metadata.kind ?? "unknown";
383
+ if (set.add(provider, listed.sandboxId, participant, "tag"))
384
+ found += 1;
385
+ }
386
+ const search = searches.find(({ result }) => result.status !== "done")?.result ?? searches[0].result;
340
387
  const tagSearch = {
341
388
  status: search.status,
342
389
  found,
343
390
  ...(search.detail === undefined ? {} : { detail: search.detail }),
344
391
  };
345
392
  if (search.status !== "done")
346
- warnings.push(`Could not search E2B for sandboxes tagged with ${label}: ${search.detail ?? search.status}. A sandbox whose id never reached a receipt would not be found.`);
393
+ warnings.push(`Could not search for sandboxes tagged with ${label}: ${search.detail ?? search.status}. A sandbox whose id never reached a receipt would not be found.`);
347
394
  // A create that reports its id while the search or a kill is still running is killed too.
348
395
  await set.drain();
349
396
  unwatch?.();
@@ -1,5 +1,5 @@
1
1
  import type { PreparedOutputRoot } from "./contained-output.js";
2
- import type { SandboxOwnerTags } from "./sandbox-receipts.js";
2
+ import type { SandboxOwnerTags, SandboxProviderId } from "./sandbox-receipts.js";
3
3
  /** Refuses a sandbox create for a run directory whose process is stopping. */
4
4
  export declare class SandboxCreateRefusedError extends Error {
5
5
  constructor();
@@ -7,6 +7,7 @@ export declare class SandboxCreateRefusedError extends Error {
7
7
  /** One sandbox a create in this process reported, and what its route did with it. */
8
8
  export interface CreatedSandbox {
9
9
  sandboxId: string;
10
+ provider: SandboxProviderId;
10
11
  /** The participant or subject label the receipt also records. */
11
12
  participantId: string;
12
13
  /** True once the route's own release confirmed the sandbox gone. */
@@ -32,14 +33,14 @@ export interface SandboxCreateTicket {
32
33
  settled(): void;
33
34
  }
34
35
  /** Register a create for `root`. Throws SandboxCreateRefusedError once the directory is stopping. */
35
- export declare function beginSandboxCreate(root: PreparedOutputRoot, participantId: string): SandboxCreateTicket;
36
+ export declare function beginSandboxCreate(root: PreparedOutputRoot, participantId: string, provider: SandboxProviderId): SandboxCreateTicket;
36
37
  /**
37
- * Run `write` once per directory and return its promise to every caller. Creates that start
38
+ * Run `write` once per directory and provider and return its promise to every caller. Creates that start
38
39
  * together then resume in the order they called, so the order they reach the provider does not
39
40
  * depend on which file append finishes first. A write that reports failure is forgotten, so the
40
41
  * next create tries again.
41
42
  */
42
- export declare function recordSandboxOwnerOnce(root: PreparedOutputRoot, write: () => Promise<boolean>): Promise<void>;
43
+ export declare function recordSandboxOwnerOnce(root: PreparedOutputRoot, provider: SandboxProviderId, write: () => Promise<boolean>): Promise<void>;
43
44
  /** What the signal handler holds after stopping a directory's creates. */
44
45
  export interface StoppedSandboxCreates {
45
46
  /**
@@ -51,6 +52,8 @@ export interface StoppedSandboxCreates {
51
52
  settled: Promise<void>;
52
53
  /** How many of those creates have not settled yet. */
53
54
  inFlight(): number;
55
+ /** The providers creates began on, including those whose id has not arrived. */
56
+ providers(): ReadonlySet<SandboxProviderId>;
54
57
  }
55
58
  /**
56
59
  * Stop `root`'s creates: any create that starts from now on is refused. Called synchronously when
@@ -36,6 +36,8 @@ function directoryFor(root) {
36
36
  return existing;
37
37
  const created = {
38
38
  stopping: false,
39
+ ownerLines: new Map(),
40
+ providers: new Set(),
39
41
  created: new Map(),
40
42
  inFlight: new Set(),
41
43
  listeners: new Set(),
@@ -73,10 +75,11 @@ export function sandboxOwnerTags(root) {
73
75
  return { tool: "humanish", runId: path.basename(where), runKey };
74
76
  }
75
77
  /** Register a create for `root`. Throws SandboxCreateRefusedError once the directory is stopping. */
76
- export function beginSandboxCreate(root, participantId) {
78
+ export function beginSandboxCreate(root, participantId, provider) {
77
79
  const directory = directoryFor(root);
78
80
  if (directory.stopping)
79
81
  throw new SandboxCreateRefusedError();
82
+ directory.providers.add(provider);
80
83
  let settle;
81
84
  const done = new Promise((resolve) => {
82
85
  settle = resolve;
@@ -87,7 +90,7 @@ export function beginSandboxCreate(root, participantId) {
87
90
  created(sandboxId) {
88
91
  if (directory.created.has(sandboxId))
89
92
  return;
90
- const sandbox = { sandboxId, participantId, released: false };
93
+ const sandbox = { sandboxId, provider, participantId, released: false };
91
94
  directory.created.set(sandboxId, sandbox);
92
95
  for (const listener of directory.listeners)
93
96
  listener(sandbox);
@@ -104,17 +107,21 @@ export function beginSandboxCreate(root, participantId) {
104
107
  };
105
108
  }
106
109
  /**
107
- * Run `write` once per directory and return its promise to every caller. Creates that start
110
+ * Run `write` once per directory and provider and return its promise to every caller. Creates that start
108
111
  * together then resume in the order they called, so the order they reach the provider does not
109
112
  * depend on which file append finishes first. A write that reports failure is forgotten, so the
110
113
  * next create tries again.
111
114
  */
112
- export function recordSandboxOwnerOnce(root, write) {
113
- const directory = directoryFor(root);
114
- const pending = (directory.ownerLine ??= write().then((written) => {
115
- if (!written && directory.ownerLine === pending)
116
- delete directory.ownerLine;
117
- }));
115
+ export function recordSandboxOwnerOnce(root, provider, write) {
116
+ const { ownerLines } = directoryFor(root);
117
+ const existing = ownerLines.get(provider);
118
+ if (existing !== undefined)
119
+ return existing;
120
+ const pending = write().then((written) => {
121
+ if (!written && ownerLines.get(provider) === pending)
122
+ ownerLines.delete(provider);
123
+ });
124
+ ownerLines.set(provider, pending);
118
125
  return pending;
119
126
  }
120
127
  /**
@@ -136,5 +143,6 @@ export function stopSandboxCreates(root) {
136
143
  },
137
144
  settled: Promise.all(pending).then(() => undefined),
138
145
  inFlight: () => pending.filter((create) => directory.inFlight.has(create)).length,
146
+ providers: () => directory.providers,
139
147
  };
140
148
  }
@@ -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.
@@ -1,5 +1,6 @@
1
1
  import type { CodexAppServerTrace } from "../actors/codex/app-server-trace.js";
2
2
  import type { ActorStatus, ActorTrace, ActorTraceItem } from "../actors/contract.js";
3
+ import type { BrowserNetworkIndex } from "../evidence/browser-network.js";
3
4
  import type { RunDesktopRecording } from "../evidence/desktop-recording-types.js";
4
5
  export type RunStreamKind = "ui" | "browser" | "terminal" | "tui" | "codex-ui" | "artifact" | "summary";
5
6
  export type RunSimulationStatus = "queued" | "preparing" | "running" | "passed" | "abandoned" | "incomplete" | "complete" | "blocked" | "timed_out" | "failed" | "contract_proof_only";
@@ -310,6 +311,12 @@ export interface RunStream {
310
311
  };
311
312
  completion?: RunStreamCompletion;
312
313
  recording?: RunDesktopRecording;
314
+ /**
315
+ * The participant's browser network log, when its study set `execution.network`: where the
316
+ * file is, what binds it, and why records are missing. The shape guard does not read it; verify
317
+ * checks it with the file (src/verify/network.ts).
318
+ */
319
+ network?: BrowserNetworkIndex;
313
320
  artifacts: Array<{
314
321
  label: string;
315
322
  path: string;
@@ -258,15 +258,15 @@ function desktopCliValidationReason(config) {
258
258
  return null;
259
259
  }
260
260
  // 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.
261
+ // a hosted 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, modal-terminal or
263
+ // absent (absent defaults to e2b-terminal), one participant until fan-out lands.
265
264
  function terminalValidationReason(config) {
266
265
  if (config.subject.source === "terminal-product") {
267
266
  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.";
267
+ const target = config.execution?.target;
268
+ if (target !== undefined && target !== "e2b-terminal" && target !== "modal-terminal") {
269
+ 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
270
  }
271
271
  if (!actorResolvesToTerminal(type)) {
272
272
  return `actor.type must be a registered terminal actor for terminal-product subjects (one of: ${registeredTerminalActors().join(", ")}). Got "${type}".`;
@@ -275,10 +275,11 @@ function terminalValidationReason(config) {
275
275
  return "Terminal fan-out to more than one participant is not supported yet; set participants to 1.";
276
276
  }
277
277
  }
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.";
278
+ else if (config.execution?.target === "e2b-terminal" ||
279
+ config.execution?.target === "modal-terminal") {
280
+ // The terminal targets are the terminal-product substrates only. Any other source declaring one
281
+ // is a mis-config: reject, never silently mishandle (mirrors app-url's e2b-desktop pairing rule).
282
+ return `\`execution.target: ${config.execution.target}\` requires \`subject.source: terminal-product\` with a registered terminal actor.`;
282
283
  }
283
284
  else if (actorResolvesToTerminal(config.actor?.type)) {
284
285
  // A registered terminal actor on a non-terminal-product subject: rejected, never ignored (the
@@ -39,7 +39,7 @@ import { invalid, optionalStr, str } from "./parse/values.js";
39
39
  import { isComputerUseComposition, routeOf } from "./routing.js";
40
40
  import { declaredParticipantIds } from "./plan-participants.js";
41
41
  import { ID_PATTERN, STUDY_SCHEMA, } from "./types.js";
42
- import { automaticAnalysisRouteReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, smtpValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "./validation.js";
42
+ import { automaticAnalysisRouteReason, desktopMediaValidationReason, networkCaptureValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, smtpValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "./validation.js";
43
43
  import { forwardDeclaredWarnings, inertFieldLabels, inertFieldPaths } from "./warnings.js";
44
44
  import { plural } from "../run/text.js";
45
45
  /**
@@ -228,6 +228,9 @@ function checkStudyConfig(config) {
228
228
  const mediaReason = desktopMediaValidationReason(config);
229
229
  if (mediaReason)
230
230
  return invalid(mediaReason);
231
+ const networkReason = networkCaptureValidationReason(config);
232
+ if (networkReason)
233
+ return invalid(networkReason);
231
234
  const outputLimitReason = outputTokenLimitValidationReason(config);
232
235
  if (outputLimitReason)
233
236
  return invalid(outputLimitReason);
@@ -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",
@@ -187,6 +187,9 @@ export declare const EXECUTION: {
187
187
  transport: true;
188
188
  doNotTrack: true;
189
189
  };
190
+ network: {
191
+ origins: true;
192
+ };
190
193
  desktop: {
191
194
  template: true;
192
195
  browser: true;
@@ -119,6 +119,8 @@ export const EXECUTION = {
119
119
  completionTimeoutMs: true,
120
120
  egressAllow: true,
121
121
  terminal: { stdin: true, transport: true, doNotTrack: true },
122
+ // true or false, or a mapping whose keys are checked here.
123
+ network: { origins: true },
122
124
  desktop: {
123
125
  template: true,
124
126
  browser: true,
@@ -1,4 +1,4 @@
1
- import { invalid, posInt, str } from "./values.js";
1
+ import { exactOrigin, invalid, posInt, str } from "./values.js";
2
2
  import { isRecord } from "../../run/type-guards.js";
3
3
  // Fail-loud (never silently swallow a comms setting): a malformed `comms` block returns a parse
4
4
  // failure rather than being dropped.
@@ -104,25 +104,6 @@ function parseEmailConnection(raw) {
104
104
  }
105
105
  return { ok: true, value };
106
106
  }
107
- /** The origin of an http(s) URL with no credentials, path, query or fragment, else undefined. */
108
- function exactOrigin(input) {
109
- if (typeof input !== "string")
110
- return undefined;
111
- try {
112
- const url = new URL(input);
113
- return ["http:", "https:"].includes(url.protocol) &&
114
- !url.username &&
115
- !url.password &&
116
- url.pathname === "/" &&
117
- !url.search &&
118
- !url.hash
119
- ? url.origin
120
- : undefined;
121
- }
122
- catch {
123
- return undefined;
124
- }
125
- }
126
107
  /** `comms.email.external`: the catch the adopter runs, its inbox and the env var holding its token. */
127
108
  function parseExternalCatch(raw) {
128
109
  if (raw === undefined)
@@ -1,7 +1,7 @@
1
1
  import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
2
2
  import { DEVICE_PRESET_NAMES, isDevicePresetName } from "../device-presets.js";
3
3
  import { isExactRuntimeVersion } from "../../routes/terminal/runtime.js";
4
- import { invalid, nonNegNumber, posInt, str } from "./values.js";
4
+ import { exactOrigin, invalid, nonNegNumber, posInt, str } from "./values.js";
5
5
  import { isRecord } from "../../run/type-guards.js";
6
6
  import { scanEncodedText } from "../../evidence/encoded-text.js";
7
7
  export function parseExecution(raw) {
@@ -14,8 +14,11 @@ 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-terminal") {
21
+ return invalid("`execution.target` must be local, e2b-desktop, e2b-terminal, or modal-terminal.");
19
22
  }
20
23
  execution.target = target;
21
24
  }
@@ -34,6 +37,11 @@ export function parseExecution(raw) {
34
37
  }
35
38
  if (desktopResult.value)
36
39
  execution.desktop = desktopResult.value;
40
+ const network = parseNetwork(raw.network);
41
+ if (!network.ok)
42
+ return network;
43
+ if (network.value !== undefined)
44
+ execution.network = network.value;
37
45
  const terminalResult = parseTerminal(raw.terminal);
38
46
  if (!terminalResult.ok) {
39
47
  return terminalResult;
@@ -71,6 +79,30 @@ export function parseExecution(raw) {
71
79
  }
72
80
  return { ok: true, value: Object.keys(execution).length > 0 ? execution : undefined };
73
81
  }
82
+ /** `execution.network`: true, false, or `{ origins }`. False reads as absent. */
83
+ function parseNetwork(raw) {
84
+ if (raw === undefined || raw === false)
85
+ return { ok: true, value: undefined };
86
+ if (raw === true)
87
+ return { ok: true, value: true };
88
+ if (!isRecord(raw))
89
+ return invalid("`execution.network` must be true, false, or a mapping with `origins`: the origins whose requests are kept beside the target's.");
90
+ if (raw.origins === undefined)
91
+ return { ok: true, value: {} };
92
+ if (!Array.isArray(raw.origins) || raw.origins.length > 16)
93
+ return invalid("`execution.network.origins` must list at most 16 exact http(s) origins, such as https://api.example.com.");
94
+ const origins = [];
95
+ for (const entry of raw.origins) {
96
+ const origin = exactOrigin(entry);
97
+ if (origin === undefined)
98
+ return invalid("`execution.network.origins` accepts exact http(s) origins without credentials, path, query or fragment, such as https://api.example.com.");
99
+ // The network log's index records each origin, so one verify would flag is refused here.
100
+ if (scanEncodedText(origin).sensitive)
101
+ return invalid("`execution.network.origins` has an origin that looks like a secret or a local path, and the run records each origin in its network log index. List the origin of the API or asset host only.");
102
+ origins.push(origin);
103
+ }
104
+ return { ok: true, value: { origins: [...new Set(origins)] } };
105
+ }
74
106
  function parseTerminal(raw) {
75
107
  if (raw === undefined) {
76
108
  return { ok: true, value: undefined };
@@ -14,3 +14,8 @@ export declare function posInt(value: unknown): number | undefined;
14
14
  /** A non-negative finite number (0 allowed; caps default to 0 = no-spend). Accepts a numeric
15
15
  * string too, since YAML scalars can arrive as strings. */
16
16
  export declare function nonNegNumber(value: unknown): number | undefined;
17
+ /**
18
+ * The origin of an http(s) URL with no credentials, path, query or fragment, else undefined. The
19
+ * origin lists `comms.email.allowedOrigins` and `execution.network.origins` take this rule.
20
+ */
21
+ export declare function exactOrigin(input: unknown): string | undefined;
@@ -60,3 +60,25 @@ export function nonNegNumber(value) {
60
60
  }
61
61
  return undefined;
62
62
  }
63
+ /**
64
+ * The origin of an http(s) URL with no credentials, path, query or fragment, else undefined. The
65
+ * origin lists `comms.email.allowedOrigins` and `execution.network.origins` take this rule.
66
+ */
67
+ export function exactOrigin(input) {
68
+ if (typeof input !== "string")
69
+ return undefined;
70
+ try {
71
+ const url = new URL(input);
72
+ return ["http:", "https:"].includes(url.protocol) &&
73
+ !url.username &&
74
+ !url.password &&
75
+ url.pathname === "/" &&
76
+ !url.search &&
77
+ !url.hash
78
+ ? url.origin
79
+ : undefined;
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }