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
@@ -11,6 +11,7 @@ import { extractLocalActorVerdict, normalizeLocalActorTranscript, } from "../../
11
11
  import { NODE_BOOTSTRAP_COMMAND, NODE_BOOTSTRAP_TIMEOUT_MS } from "../../subject/node-bootstrap.js";
12
12
  import { observeDesktopResources, } from "../../substrates/e2b/desktop-resources.js";
13
13
  import { acquireE2BShellSandbox } from "../../substrates/e2b/sandbox.js";
14
+ import { loadModalShellModule } from "../../substrates/modal/sandbox.js";
14
15
  import { e2bAccount } from "../../substrates/e2b/connection.js";
15
16
  import { E2BDesktopStartupError, loadE2BDesktopModule, } from "../../substrates/e2b/sdk.js";
16
17
  import { shellQuote } from "../../substrates/shell.js";
@@ -60,7 +61,10 @@ export class LiveTerminalSandbox {
60
61
  const { requestTimeoutMs, sandboxTimeoutMs } = this;
61
62
  // Declared egress allowlist, or undefined for the historical unrestricted default.
62
63
  const egressAllow = plan.egressAllow;
63
- const sandboxModule = await (deps.desktopModule ?? loadE2BDesktopModule)();
64
+ const provider = plan.sandboxProvider;
65
+ const providerName = provider === "modal" ? "Modal" : "E2B";
66
+ const sandboxModule = await (deps.desktopModule ??
67
+ (provider === "modal" ? () => loadModalShellModule(this.inputs.env) : loadE2BDesktopModule))();
64
68
  await validatePreparedRunArtifactPaths(runPaths);
65
69
  // No sandbox-global env in either mode. In openai-egress, only this host-side SDK request
66
70
  // carries the real runtime key; participant commands receive an inert placeholder. The proxy
@@ -83,6 +87,7 @@ export class LiveTerminalSandbox {
83
87
  this.account = e2bAccount(sandboxModule, options);
84
88
  const acquired = await acquireE2BShellSandbox({
85
89
  module: sandboxModule,
90
+ provider,
86
91
  options,
87
92
  retry: {
88
93
  // A failed first attempt may have allocated a sandbox whose id never reached this run;
@@ -103,8 +108,8 @@ export class LiveTerminalSandbox {
103
108
  this.sandboxId = sandboxId;
104
109
  this.createdAtMs = now();
105
110
  await validatePreparedRunArtifactPaths(runPaths);
106
- recordLifecycle("terminal-lab.sandbox.created", `E2B shell sandbox ${sandboxId} created with allowlisted metadata and kill-on-timeout, and with no sandbox-wide environment variables.`);
107
- const sandboxResources = await observeDesktopResources(sandbox);
111
+ recordLifecycle("terminal-lab.sandbox.created", `${providerName} shell sandbox ${sandboxId} created with allowlisted metadata and kill-on-timeout, and with no sandbox-wide environment variables.`);
112
+ const sandboxResources = await observeDesktopResources(sandbox, undefined, provider === "modal" ? "modal.create" : "e2b.getInfo");
108
113
  this.resources = sandboxResources;
109
114
  if ("reason" in sandboxResources) {
110
115
  warnings.push(`Sandbox resource size unavailable (${sandboxResources.reason}); its compute cost remains unpriced.`);
@@ -7,8 +7,9 @@ import { isReasoningEffort } from "../../actors/reasoning-effort.js";
7
7
  import { actorRegistry, isTerminalActorDescriptor } from "../../actors/registry.js";
8
8
  import { isNonEmpty, planBase } from "../../study/plan-base.js";
9
9
  import { studyUrlCredentialReason } from "../../study/url-credentials.js";
10
- import { desktopMediaValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
10
+ import { desktopMediaValidationReason, networkCaptureValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
11
11
  import { ceilingAdvice } from "../../substrates/e2b/lifetime.js";
12
+ import { MODAL_MAX_SANDBOX_MS, MODAL_TOKEN_ID_ENV, MODAL_TOKEN_SECRET_ENV, } from "../../substrates/modal/sdk.js";
12
13
  import { NODE_BOOTSTRAP_TIMEOUT_MS } from "../../subject/node-bootstrap.js";
13
14
  import { terminalSandboxTimeoutMs } from "./lifetime.js";
14
15
  import { isExactRuntimeVersion, TERMINAL_RUNTIME_VERSION_TIMEOUT_MS } from "./runtime.js";
@@ -24,7 +25,7 @@ export function planTerminalStudy(config, input) {
24
25
  });
25
26
  if (String(config.comms?.email?.kind) === "real")
26
27
  return refuse("HUMANISH_TERMINAL_SUBJECT_INVALID", "Real email receiving is unsupported on the terminal route. Use a supported hosted computer-use browser study.");
27
- const mediaReason = desktopMediaValidationReason(config);
28
+ const mediaReason = desktopMediaValidationReason(config) ?? networkCaptureValidationReason(config, "terminal");
28
29
  if (mediaReason)
29
30
  return refuse("HUMANISH_TERMINAL_SUBJECT_INVALID", mediaReason);
30
31
  // A library caller's config skips the parser, which refuses these first.
@@ -34,6 +35,10 @@ export function planTerminalStudy(config, input) {
34
35
  const runtimeAuth = config.execution?.runtimeAuth;
35
36
  if (runtimeAuth !== undefined && runtimeAuth !== "openai-egress" && runtimeAuth !== "openai-env")
36
37
  return refuse("HUMANISH_TERMINAL_CREDENTIAL_DENIED", "`execution.runtimeAuth` must be openai-egress or openai-env. Leave it out for openai-egress, which keeps the OpenAI key outside the sandbox.");
38
+ const sandboxProvider = config.execution?.target === "modal-terminal" ? "modal" : "e2b";
39
+ const modal = sandboxProvider === "modal" ? modalRefusal(config) : undefined;
40
+ if (modal !== undefined)
41
+ return refuse(modal.code, modal.message);
37
42
  const analysis = resolveAutomaticAnalysis(config.review?.analysis);
38
43
  if (!analysis.ok)
39
44
  return refuse("HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message);
@@ -64,6 +69,7 @@ export function planTerminalStudy(config, input) {
64
69
  const shared = {
65
70
  ...base,
66
71
  route: "terminal",
72
+ sandboxProvider,
67
73
  actor: descriptor.id,
68
74
  product: { ...product, publicSurfaces: surfaces },
69
75
  ...(actor?.persona === undefined ? {} : { personaId: actor.persona }),
@@ -107,14 +113,16 @@ export function planTerminalStudy(config, input) {
107
113
  // The sandbox's timeout covers the steps before the codex command, maxMinutes and the teardown
108
114
  // buffer, and E2B refuses a sandbox past the plan's ceiling. Refuse here, with the arithmetic,
109
115
  // rather than after the plan prints, from a provider 400 that names neither knob.
110
- const ceiling = input.sandboxCeiling;
116
+ const ceiling = sandboxProvider === "modal"
117
+ ? { ok: true, ms: MODAL_MAX_SANDBOX_MS, source: "default" }
118
+ : input.sandboxCeiling;
111
119
  if (!ceiling.ok)
112
120
  return refuse("HUMANISH_TERMINAL_CAPS_INVALID", ceiling.message, descriptor.id);
113
121
  const productInstall = shared.product.install !== undefined;
114
122
  const sandboxTimeoutMs = terminalSandboxTimeoutMs({ maxMinutes, productInstall });
115
123
  if (sandboxTimeoutMs > ceiling.ms) {
116
124
  const headroomMinutes = (sandboxTimeoutMs - maxMinutes * 60_000) / 60_000;
117
- return refuse("HUMANISH_TERMINAL_CAPS_INVALID", `caps.maxMinutes ${maxMinutes} derives a ${sandboxTimeoutMs / 60_000}m sandbox deadline, and a sandbox may not live longer than ${ceiling.ms / 60_000}m. The deadline is maxMinutes plus ${headroomMinutes}m: the Node bootstrap (${NODE_BOOTSTRAP_TIMEOUT_MS / 60_000}m), the runtime version check (${TERMINAL_RUNTIME_VERSION_TIMEOUT_MS / 60_000}m)${productInstall ? `, the product setup (${PRODUCT_SETUP_TIMEOUT_MS / 60_000}m)` : ""} and the teardown buffer (${TERMINAL_SANDBOX_TIMEOUT_BUFFER_MS / 60_000}m). Lower caps.maxMinutes to at most ${ceiling.ms / 60_000 - headroomMinutes}.${ceilingAdvice(sandboxTimeoutMs)} No sandbox was created and the runtime key was not used.`, descriptor.id);
125
+ return refuse("HUMANISH_TERMINAL_CAPS_INVALID", `caps.maxMinutes ${maxMinutes} derives a ${sandboxTimeoutMs / 60_000}m sandbox deadline, and a sandbox may not live longer than ${ceiling.ms / 60_000}m. The deadline is maxMinutes plus ${headroomMinutes}m: the Node bootstrap (${NODE_BOOTSTRAP_TIMEOUT_MS / 60_000}m), the runtime version check (${TERMINAL_RUNTIME_VERSION_TIMEOUT_MS / 60_000}m)${productInstall ? `, the product setup (${PRODUCT_SETUP_TIMEOUT_MS / 60_000}m)` : ""} and the teardown buffer (${TERMINAL_SANDBOX_TIMEOUT_BUFFER_MS / 60_000}m). Lower caps.maxMinutes to at most ${ceiling.ms / 60_000 - headroomMinutes}.${sandboxProvider === "modal" ? " Modal ends every sandbox after 24 hours." : ceilingAdvice(sandboxTimeoutMs)} No sandbox was created and the runtime key was not used.`, descriptor.id);
118
126
  }
119
127
  return {
120
128
  ok: true,
@@ -122,10 +130,45 @@ export function planTerminalStudy(config, input) {
122
130
  ...shared,
123
131
  dryRun: false,
124
132
  caps: { ...caps, maxUsd, maxMinutes },
125
- requirements: [
126
- { kind: "key", name: "E2B_API_KEY" },
127
- { kind: "key-one-of", names: ["CODEX_API_KEY", "OPENAI_API_KEY"] },
128
- ],
133
+ requirements: liveRequirements(sandboxProvider),
129
134
  },
130
135
  };
131
136
  }
137
+ /** The sandbox provider's keys, then the runtime key the agent's command gets. */
138
+ function liveRequirements(provider) {
139
+ return [
140
+ ...(provider === "modal"
141
+ ? [
142
+ { kind: "key", name: MODAL_TOKEN_ID_ENV },
143
+ { kind: "key", name: MODAL_TOKEN_SECRET_ENV },
144
+ ]
145
+ : [{ kind: "key", name: "E2B_API_KEY" }]),
146
+ { kind: "key-one-of", names: ["CODEX_API_KEY", "OPENAI_API_KEY"] },
147
+ ];
148
+ }
149
+ /** What a terminal study on Modal cannot have that one on E2B can. */
150
+ function modalRefusal(config) {
151
+ // E2B adds the key to requests at its egress proxy for each sandbox. Modal does that only from a
152
+ // Secret stored in the operator's workspace, so humanish does not place the key there itself.
153
+ if (config.execution?.runtimeAuth !== "openai-env")
154
+ return {
155
+ code: "HUMANISH_TERMINAL_CREDENTIAL_DENIED",
156
+ message: "On Modal, humanish cannot keep the OpenAI key outside the sandbox: Modal adds a key to outbound requests only from a Secret stored in your Modal workspace. Set `execution.runtimeAuth: openai-env`, which passes the key to the codex command only, or use `execution.target: e2b-terminal`.",
157
+ };
158
+ const egress = modalEgressReason(config.execution?.egressAllow);
159
+ return egress === undefined
160
+ ? undefined
161
+ : { code: "HUMANISH_TERMINAL_SUBJECT_INVALID", message: egress };
162
+ }
163
+ const CIDR = /^[0-9a-f:.]+\/\d{1,3}$/i;
164
+ /**
165
+ * Why a declared egress allowlist cannot run on Modal, or undefined when it can. Modal limits
166
+ * egress to listed domains or CIDR ranges; an entry that is neither, such as a bare IP address,
167
+ * has no Modal form.
168
+ */
169
+ function modalEgressReason(egressAllow) {
170
+ const unsupported = (egressAllow ?? []).filter((entry) => !CIDR.test(entry) && !/^(\*\.)?([a-z0-9-]+\.)+[a-z0-9-]*[a-z][a-z0-9-]*$/i.test(entry));
171
+ return unsupported.length === 0
172
+ ? undefined
173
+ : `Modal limits egress to domain names and CIDR ranges, and \`execution.egressAllow\` lists ${unsupported.map((entry) => `"${entry}"`).join(", ")}. Write a bare address as a /32 range, or use \`execution.target: e2b-terminal\`.`;
174
+ }
@@ -95,6 +95,7 @@ export async function runLiveTerminalSession(args) {
95
95
  verdictNonce,
96
96
  maxMinutes,
97
97
  e2bApiKey,
98
+ env,
98
99
  runPaths,
99
100
  metadata: buildSandboxMetadata({ studyId: plan.studyId, recordId: "sim-001", runId }),
100
101
  warnings,
@@ -160,7 +161,13 @@ async function prepareLivePrompt(args) {
160
161
  // anything persists (a key has no detectable "shape" if it is an arbitrary token); redactText is
161
162
  // the second pass for secret-shaped content. Applied pre-truncation so a cut can never split a
162
163
  // value past the scrubber.
163
- const secrets = new RunSecrets([runtimeEnv.keyValue, env.E2B_API_KEY?.trim() ?? ""]);
164
+ // The sandbox provider's credentials too: a provider error that quotes one reaches the run.
165
+ const secrets = new RunSecrets([
166
+ runtimeEnv.keyValue,
167
+ env.E2B_API_KEY?.trim() ?? "",
168
+ env.MODAL_TOKEN_ID?.trim() ?? "",
169
+ env.MODAL_TOKEN_SECRET?.trim() ?? "",
170
+ ]);
164
171
  const sanitize = (text) => redactText(secrets.scrub(text));
165
172
  const persona = terminalPersonaRef(terminalPersona, promptDigest, secrets.scrub);
166
173
  return {
@@ -441,7 +441,8 @@ export interface RunCostLine {
441
441
  cpuCount: number;
442
442
  memoryMiB: number;
443
443
  };
444
- resourceSource?: "e2b.getInfo";
444
+ /** E2B's report of the allocation, or the size humanish asked Modal for. */
445
+ resourceSource?: "e2b.getInfo" | "modal.create";
445
446
  resourceUnavailableReason?: "metadata_unavailable" | "metadata_invalid" | "metadata_timeout";
446
447
  usdPerSecond?: number;
447
448
  };
@@ -1,4 +1,4 @@
1
- import { estimateActorCostForExecution, estimateAllocatedDesktopCost, estimateDesktopCost, round6, } from "./pricing.js";
1
+ import { estimateActorCostForExecution, estimateAllocatedDesktopCost, estimateDesktopCost, MODAL_SANDBOX_RESOURCE_RATE, round6, } from "./pricing.js";
2
2
  import {} from "./bundle.js";
3
3
  export function buildRunCostSummary(args) {
4
4
  const breakdown = [...modelCostLines(args.participants), ...desktopCostLines(args)];
@@ -76,8 +76,11 @@ function desktopCostLines(args) {
76
76
  const breakdown = [];
77
77
  for (const usage of args.desktops ?? []) {
78
78
  const observation = usage.observation;
79
- const resources = observation && "resources" in observation ? observation.resources : undefined;
80
- const estimate = estimateAllocatedDesktopCost(usage.minutes, resources);
79
+ const observed = observation && "resources" in observation ? observation : undefined;
80
+ const resources = observed?.resources;
81
+ const estimate = observed?.source === "modal.create"
82
+ ? estimateAllocatedDesktopCost(usage.minutes, resources, MODAL_SANDBOX_RESOURCE_RATE)
83
+ : estimateAllocatedDesktopCost(usage.minutes, resources);
81
84
  breakdown.push({
82
85
  kind: "desktop-minutes",
83
86
  ...(usage.participantId === undefined ? {} : { laneId: usage.participantId }),
@@ -88,7 +91,9 @@ function desktopCostLines(args) {
88
91
  desktop: {
89
92
  minutes: estimate.minutes,
90
93
  durationBasis: "host-acquired-to-cleanup",
91
- ...(resources === undefined ? {} : { resources, resourceSource: "e2b.getInfo" }),
94
+ ...(observed === undefined
95
+ ? {}
96
+ : { resources: observed.resources, resourceSource: observed.source }),
92
97
  ...(observation && "reason" in observation
93
98
  ? { resourceUnavailableReason: observation.reason }
94
99
  : {}),
@@ -13,11 +13,29 @@ export interface LiveItemsCursor {
13
13
  items: number;
14
14
  bytes: number;
15
15
  }
16
+ /** How run.json binds a file of JSON lines: its byte count, its sha256 and its number of lines. */
17
+ export interface JsonLinesIndex {
18
+ path: string;
19
+ bytes: number;
20
+ sha256: string;
21
+ lines: number;
22
+ }
23
+ /** What a refusal calls one line of a file and several: `an item` and `items` for a live file. */
24
+ export interface LineNoun {
25
+ one: string;
26
+ many: string;
27
+ }
16
28
  /**
17
- * The items the indexed prefix of a live file holds, or why the bytes do not match the index:
29
+ * The JSON object on each line of a file run.json binds, or why the bytes do not match the index:
18
30
  * `bytes` must be exactly the indexed byte count, hash to the indexed sha256, and hold the indexed
19
- * number of lines, each a JSON object.
31
+ * number of lines, each a JSON object. A refusal names the file and counts, never what it holds.
20
32
  */
33
+ export declare function boundJsonLines(bytes: Buffer, index: JsonLinesIndex, noun: LineNoun): {
34
+ lines: Record<string, unknown>[];
35
+ } | {
36
+ refused: string;
37
+ };
38
+ /** The items the indexed prefix of a live file holds, or why the bytes do not match the index. */
21
39
  export declare function liveItemsFromBytes(bytes: Buffer, index: LiveItemsIndex): {
22
40
  items: ActorTraceItem[];
23
41
  } | {
@@ -29,19 +29,24 @@ export function isLiveItemsIndex(value) {
29
29
  typeof value.sha256 === "string" &&
30
30
  /^[0-9a-f]{64}$/.test(value.sha256));
31
31
  }
32
+ const ITEM = { one: "an item", many: "items" };
32
33
  /**
33
- * The items the indexed prefix of a live file holds, or why the bytes do not match the index:
34
+ * The JSON object on each line of a file run.json binds, or why the bytes do not match the index:
34
35
  * `bytes` must be exactly the indexed byte count, hash to the indexed sha256, and hold the indexed
35
- * number of lines, each a JSON object.
36
+ * number of lines, each a JSON object. A refusal names the file and counts, never what it holds.
36
37
  */
37
- export function liveItemsFromBytes(bytes, index) {
38
+ export function boundJsonLines(bytes, index, noun) {
38
39
  if (bytes.length !== index.bytes)
39
40
  return {
40
41
  refused: `${index.path} holds ${bytes.length} bytes and run.json records ${index.bytes}`,
41
42
  };
42
43
  if (createHash("sha256").update(bytes).digest("hex") !== index.sha256)
43
44
  return { refused: `${index.path} does not match the sha256 run.json records for it` };
44
- return itemLinesOf(bytes, index.items, index.path);
45
+ return objectLinesOf(bytes, index.lines, index.path, noun);
46
+ }
47
+ /** The items the indexed prefix of a live file holds, or why the bytes do not match the index. */
48
+ export function liveItemsFromBytes(bytes, index) {
49
+ return asItems(boundJsonLines(bytes, { ...index, lines: index.items }, ITEM));
45
50
  }
46
51
  /**
47
52
  * The items past `after` in the indexed prefix of a live file, from the file's bytes between
@@ -59,10 +64,14 @@ export function liveItemsAfter(bytes, index, after) {
59
64
  };
60
65
  if (bytes[0] !== 0x0a)
61
66
  return { refused: `${index.path} has no line end before byte ${after.bytes}` };
62
- return itemLinesOf(bytes.subarray(1), index.items - after.items, index.path);
67
+ return asItems(objectLinesOf(bytes.subarray(1), index.items - after.items, index.path, ITEM));
68
+ }
69
+ /** A live file's lines as trace items. */
70
+ function asItems(read) {
71
+ return "refused" in read ? read : { items: read.lines };
63
72
  }
64
73
  /** The JSON object on each of `count` lines in `bytes`, which must be UTF-8 and end a line. */
65
- function itemLinesOf(bytes, count, file) {
74
+ function objectLinesOf(bytes, count, file, noun) {
66
75
  let text;
67
76
  try {
68
77
  text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
@@ -74,21 +83,21 @@ function itemLinesOf(bytes, count, file) {
74
83
  if (lines.length > 0 && lines.pop() !== "")
75
84
  return { refused: `${file} does not end its indexed bytes at a line end` };
76
85
  if (lines.length !== count)
77
- return { refused: `${file} holds ${lines.length} items and run.json records ${count}` };
78
- const items = [];
86
+ return { refused: `${file} holds ${lines.length} ${noun.many} and run.json records ${count}` };
87
+ const objects = [];
79
88
  for (const line of lines) {
80
- let item;
89
+ let value;
81
90
  try {
82
- item = JSON.parse(line);
91
+ value = JSON.parse(line);
83
92
  }
84
93
  catch {
85
94
  return { refused: `${file} holds a line that is not JSON` };
86
95
  }
87
- if (!isRecord(item))
88
- return { refused: `${file} holds a line that is not an item` };
89
- items.push(item);
96
+ if (!isRecord(value))
97
+ return { refused: `${file} holds a line that is not ${noun.one}` };
98
+ objects.push(value);
90
99
  }
91
- return { items };
100
+ return { lines: objects };
92
101
  }
93
102
  /** The warning for a participant whose live items were written in full on two writes in a row. */
94
103
  function rewrittenEvent(stream, position) {
@@ -50,6 +50,12 @@ export interface DesktopResourceRate {
50
50
  usdPerGiBSecond: number;
51
51
  asOf: string;
52
52
  source: string;
53
+ /** The sizes the public sheet prices; a size outside them stays unpriced. Absent: any size. */
54
+ sheet?: {
55
+ cpuCounts: readonly number[];
56
+ minMemoryMiB: number;
57
+ maxMemoryMiB: number;
58
+ };
53
59
  }
54
60
  /**
55
61
  * The token-derived cost estimate for one actor trace. `estimatedCostUsd: null` = declared absent
@@ -104,6 +110,11 @@ export interface DesktopCostEstimate {
104
110
  usdPerSecond?: number;
105
111
  }
106
112
  export declare const MODEL_RATES: Record<string, ModelRate>;
113
+ /**
114
+ * Modal's Sandbox rate, per vCPU: Modal prices a physical core, $0.00003942 a second, and counts
115
+ * it as two vCPU. Modal prices every size the same way.
116
+ */
117
+ export declare const MODAL_SANDBOX_RESOURCE_RATE: DesktopResourceRate;
107
118
  /**
108
119
  * The refusal for a spend cap on a model this table cannot price. It names the priced models,
109
120
  * since an npm install cannot edit this file.
@@ -91,6 +91,19 @@ const DESKTOP_RESOURCE_RATE = {
91
91
  asOf: "2026-09-05",
92
92
  // A source label rather than an executable URL; runtime URL redaction protects E2B streams.
93
93
  source: "e2b.dev/pricing",
94
+ // The public Hobby/Pro sheet stops at 8 CPU / 8 GiB; larger allocations may have negotiated
95
+ // prices, so their resource evidence is kept without extrapolating a standard rate to them.
96
+ sheet: { cpuCounts: [1, 2, 4, 6, 8], minMemoryMiB: 512, maxMemoryMiB: 8192 },
97
+ };
98
+ /**
99
+ * Modal's Sandbox rate, per vCPU: Modal prices a physical core, $0.00003942 a second, and counts
100
+ * it as two vCPU. Modal prices every size the same way.
101
+ */
102
+ export const MODAL_SANDBOX_RESOURCE_RATE = {
103
+ usdPerCpuSecond: 0.00003942 / 2,
104
+ usdPerGiBSecond: 0.00000667,
105
+ asOf: "2026-10-19",
106
+ source: "modal.com/pricing",
94
107
  };
95
108
  // Planning assumption behind the public estimateDesktopCost helper: stock desktops observed on
96
109
  // 2026-09-05 had 8 vCPU / 8 GiB. This is the largest CPU/RAM combination on that public sheet, not
@@ -131,11 +144,11 @@ export function estimateAllocatedDesktopCost(minutes, resources, rate = DESKTOP_
131
144
  minutes: round6(minutes),
132
145
  };
133
146
  }
134
- // The public Hobby/Pro sheet stops at 8 CPU / 8 GiB; larger allocations may have negotiated
135
- // prices. Retain the resource evidence without extrapolating a standard rate to them.
136
- if (![1, 2, 4, 6, 8].includes(resources.cpuCount) ||
137
- resources.memoryMiB < 512 ||
138
- resources.memoryMiB > 8192 ||
147
+ const { sheet } = rate;
148
+ if ((sheet !== undefined &&
149
+ (!sheet.cpuCounts.includes(resources.cpuCount) ||
150
+ resources.memoryMiB < sheet.minMemoryMiB ||
151
+ resources.memoryMiB > sheet.maxMemoryMiB)) ||
139
152
  !Number.isFinite(rate.usdPerCpuSecond) ||
140
153
  rate.usdPerCpuSecond < 0 ||
141
154
  !Number.isFinite(rate.usdPerGiBSecond) ||
@@ -1,5 +1,8 @@
1
1
  import { type E2BTagSearch } from "../substrates/e2b/sandbox.js";
2
2
  import type { E2BAccount } from "../substrates/e2b/connection.js";
3
+ import { type SandboxProviderId } from "./sandbox-receipts.js";
4
+ /** The account reclaim reaches each provider through; absent when it could not be set up. */
5
+ export type ProviderAccounts = Partial<Record<SandboxProviderId, E2BAccount>>;
3
6
  import { type ContainedRefusal, type PreparedOutputRoot } from "./contained-output.js";
4
7
  export declare const RECLAIM_RESULT_SCHEMA = "humanish.reclaim-result.v1";
5
8
  export declare const RECLAIM_RECEIPT_ARTIFACT = "reclaim-receipt.json";
@@ -50,7 +53,7 @@ export interface Target {
50
53
  * kills leaves a record of each one not yet confirmed gone.
51
54
  */
52
55
  export declare function targetSet(options: {
53
- e2b: E2BAccount | undefined;
56
+ accounts: ProviderAccounts;
54
57
  check: boolean;
55
58
  requestTimeoutMs: number;
56
59
  earlier: ReclaimOutcome[];
@@ -4,6 +4,7 @@
4
4
  // receipt writer. src/run/reclaim.ts decides what to find; this module records what happened.
5
5
  import { REDACTED_SANDBOX_ID, redactText, sandboxIdDigest, toErrorMessage, } from "../evidence/redaction.js";
6
6
  import { destroyE2BSandbox, findE2BSandboxesByTags, inspectE2BSandbox, } from "../substrates/e2b/sandbox.js";
7
+ import { isSandboxProviderId } from "./sandbox-receipts.js";
7
8
  import { readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "./contained-output.js";
8
9
  import { scrubSandboxIds } from "./sandbox-ids.js";
9
10
  import { isRecord } from "./type-guards.js";
@@ -15,7 +16,7 @@ export const RECLAIM_RECEIPT_ARTIFACT = "reclaim-receipt.json";
15
16
  * kills leaves a record of each one not yet confirmed gone.
16
17
  */
17
18
  export function targetSet(options) {
18
- const { e2b, check, requestTimeoutMs, earlier, persist } = options;
19
+ const { accounts, check, requestTimeoutMs, earlier, persist } = options;
19
20
  const targets = new Map();
20
21
  const work = [];
21
22
  const queued = [];
@@ -25,7 +26,7 @@ export function targetSet(options) {
25
26
  const seen = new Set(current.map((outcome) => outcome.sandboxIdDigest));
26
27
  return [...current, ...carried.filter((outcome) => !seen.has(outcome.sandboxIdDigest))];
27
28
  };
28
- const act = (target) => actOn(e2b, target, { check, requestTimeoutMs }).then((settled) => {
29
+ const act = (target) => actOn(accounts, target, { check, requestTimeoutMs }).then((settled) => {
29
30
  target.outcome = settled;
30
31
  void persist.write(snapshot(), "unknown");
31
32
  });
@@ -95,17 +96,18 @@ export async function searchTagSets(e2b, tagSets, options) {
95
96
  };
96
97
  }
97
98
  /** Kill (or look up) one sandbox, and say what happened in reclaim's vocabulary. */
98
- async function actOn(e2b, target, options) {
99
+ async function actOn(accounts, target, options) {
99
100
  const { outcome, provider, rawId } = target;
100
- if (provider !== "e2b" || e2b === undefined)
101
+ const account = isSandboxProviderId(provider) ? accounts[provider] : undefined;
102
+ if (account === undefined)
101
103
  return {
102
104
  ...outcome,
103
105
  state: "unsupported-provider",
104
106
  detail: `provider ${JSON.stringify(provider)} is not supported by this humanish version; nothing was attempted, and that provider's own timeout is the backstop`,
105
107
  };
106
108
  const attempt = options.check
107
- ? await inspectE2BSandbox(e2b, rawId, options)
108
- : await destroyE2BSandbox(e2b, rawId, options);
109
+ ? await inspectE2BSandbox(account, rawId, options)
110
+ : await destroyE2BSandbox(account, rawId, options);
109
111
  const { detail: _previous, ...rest } = outcome;
110
112
  // A provider error that quotes the id is scrubbed; the raw id stays in sandbox-receipts.ndjson.
111
113
  return "detail" in attempt
@@ -1,3 +1,4 @@
1
+ import { type E2BAccount } from "../substrates/e2b/connection.js";
1
2
  import { type E2BDesktopModule } from "../substrates/e2b/sdk.js";
2
3
  import { type StoppedSandboxCreates } from "./sandbox-creates.js";
3
4
  import { RECLAIM_RECEIPT_ARTIFACT, RECLAIM_RESULT_SCHEMA, type ReclaimOutcome, type ReclaimState, type ReclaimTagSearch } from "./reclaim-outcomes.js";
@@ -33,13 +34,15 @@ export interface ReclaimResult {
33
34
  export interface ReclaimHooks {
34
35
  /** Tests inject a fake @e2b/desktop module; default loads the real one. */
35
36
  loadModule?: () => Promise<E2BDesktopModule>;
37
+ /** Tests inject a fake Modal account; default reads the Modal credentials from `env`. */
38
+ loadModalAccount?: (env: Readonly<Record<string, string | undefined>>) => Promise<E2BAccount>;
36
39
  /**
37
40
  * The env whose E2B_API_KEY and E2B_DOMAIN every E2B call carries. Defaults to process.env, where
38
41
  * the CLI put the run's key before the run started.
39
42
  */
40
43
  env?: Readonly<Record<string, string | undefined>>;
41
44
  requestTimeoutMs?: number;
42
- /** Ask E2B whether each sandbox still exists, and kill nothing. */
45
+ /** Ask the provider whether each sandbox still exists, and kill nothing. */
43
46
  check?: boolean;
44
47
  /** The signal handler's view of this run's creates: the ids they reported, and those still
45
48
  * in flight, whose sandboxes are killed as their ids arrive. */