humanish 0.115.0 → 0.117.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 (195) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +7 -6
  3. package/TELEMETRY.md +6 -0
  4. package/dist/actors/codex/app-server-ui.js +11 -4
  5. package/dist/actors/codex/restricted-participant-policy.d.ts +0 -2
  6. package/dist/actors/codex/restricted-participant-policy.js +4 -32
  7. package/dist/actors/codex/restricted-participant.d.ts +5 -0
  8. package/dist/actors/codex/restricted-participant.js +3 -2
  9. package/dist/actors/computer-use/actor.d.ts +4 -0
  10. package/dist/actors/computer-use/actor.js +2 -0
  11. package/dist/actors/computer-use/loop/actions.d.ts +4 -4
  12. package/dist/actors/computer-use/loop/actions.js +25 -18
  13. package/dist/actors/computer-use/loop/debrief.js +61 -27
  14. package/dist/actors/computer-use/loop/session.d.ts +3 -0
  15. package/dist/actors/computer-use/loop/session.js +9 -1
  16. package/dist/actors/computer-use/loop/trace.js +1 -0
  17. package/dist/actors/computer-use/loop/types.d.ts +16 -14
  18. package/dist/actors/computer-use/loop.d.ts +1 -1
  19. package/dist/actors/computer-use/loop.js +1 -1
  20. package/dist/actors/computer-use/wait.d.ts +74 -0
  21. package/dist/actors/computer-use/wait.js +100 -0
  22. package/dist/actors/contract.d.ts +14 -0
  23. package/dist/actors/scripted-browser/steps.js +6 -5
  24. package/dist/analysis/analysis-limits.d.ts +35 -0
  25. package/dist/analysis/analysis-limits.js +41 -0
  26. package/dist/analysis/validation.js +14 -21
  27. package/dist/browser-control/protocol.d.ts +86 -6
  28. package/dist/browser-control/protocol.js +13 -6
  29. package/dist/cli/commands/analyze.js +2 -1
  30. package/dist/cli/commands/notes.js +2 -1
  31. package/dist/cli/commands/run-signals.js +5 -1
  32. package/dist/cli/commands/runs.js +2 -1
  33. package/dist/cli/commands/setup.js +3 -3
  34. package/dist/cli/commands/study-format.d.ts +5 -2
  35. package/dist/cli/commands/study-format.js +29 -9
  36. package/dist/cli/commands/study-route-computer-use.js +2 -2
  37. package/dist/cli/commands/study-route-scripted.js +1 -1
  38. package/dist/cli/commands/tui.js +59 -17
  39. package/dist/cli/doctor.js +9 -2
  40. package/dist/cli/findings-text.d.ts +5 -0
  41. package/dist/cli/findings-text.js +169 -0
  42. package/dist/cli/findings.d.ts +0 -4
  43. package/dist/cli/findings.js +5 -167
  44. package/dist/cli/io.js +6 -1
  45. package/dist/cli/update-check.js +4 -1
  46. package/dist/comms/connections.js +4 -3
  47. package/dist/comms/setup.js +18 -12
  48. package/dist/evidence/redaction.js +9 -1
  49. package/dist/evidence/secret-scrub.d.ts +15 -5
  50. package/dist/evidence/secret-scrub.js +67 -9
  51. package/dist/feedback/draft.js +1 -1
  52. package/dist/feedback/export-bundle.js +18 -14
  53. package/dist/feedback/feedback.js +25 -7
  54. package/dist/guest/desktop-executor.js +3 -1
  55. package/dist/guest/desktop-keys.js +59 -5
  56. package/dist/keys/key-resolution.d.ts +9 -0
  57. package/dist/keys/key-resolution.js +60 -48
  58. package/dist/keys/key-status.d.ts +2 -0
  59. package/dist/keys/key-status.js +9 -16
  60. package/dist/observer/pinned-files.js +1 -1
  61. package/dist/observer/render.js +3 -2
  62. package/dist/observer-app.html +5 -5
  63. package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
  64. package/dist/routes/computer-use/bundle-parts.js +2 -2
  65. package/dist/routes/computer-use/bundle.d.ts +4 -5
  66. package/dist/routes/computer-use/bundle.js +1 -3
  67. package/dist/routes/computer-use/e2b-desktop/prepare.js +40 -31
  68. package/dist/routes/computer-use/fanout-records.js +10 -11
  69. package/dist/routes/computer-use/live-phase.d.ts +1 -1
  70. package/dist/routes/computer-use/live-phase.js +0 -1
  71. package/dist/routes/computer-use/local-vm.js +5 -2
  72. package/dist/routes/computer-use/participant-execution.d.ts +3 -2
  73. package/dist/routes/computer-use/participant-model.js +9 -0
  74. package/dist/routes/computer-use/participant-runs.d.ts +6 -2
  75. package/dist/routes/computer-use/participant-runs.js +9 -5
  76. package/dist/routes/computer-use/plan.d.ts +3 -0
  77. package/dist/routes/computer-use/plan.js +18 -13
  78. package/dist/routes/computer-use/rerun-selection.js +2 -2
  79. package/dist/routes/computer-use/result.js +3 -6
  80. package/dist/routes/computer-use/setup.d.ts +4 -6
  81. package/dist/routes/computer-use/setup.js +2 -6
  82. package/dist/routes/computer-use/single-bundle.d.ts +4 -5
  83. package/dist/routes/computer-use/single-bundle.js +22 -25
  84. package/dist/routes/computer-use/subject-projection.d.ts +4 -8
  85. package/dist/routes/computer-use/subject-projection.js +9 -31
  86. package/dist/routes/computer-use/types.d.ts +5 -42
  87. package/dist/routes/scripted/scenario.js +28 -12
  88. package/dist/routes/scripted/session-result.js +6 -3
  89. package/dist/routes/shared-world/bundle-records.js +2 -8
  90. package/dist/routes/shared-world/participant-specs.d.ts +0 -2
  91. package/dist/routes/shared-world/participant-specs.js +1 -20
  92. package/dist/routes/shared-world/plan.d.ts +3 -0
  93. package/dist/routes/shared-world/plan.js +25 -4
  94. package/dist/routes/shared-world/setup.js +2 -2
  95. package/dist/routes/terminal/ledger.d.ts +9 -9
  96. package/dist/routes/terminal/ledger.js +63 -40
  97. package/dist/routes/terminal/live-finish.js +7 -10
  98. package/dist/routes/terminal/live-sandbox.d.ts +1 -1
  99. package/dist/routes/terminal/live-sandbox.js +14 -11
  100. package/dist/routes/terminal/plan.d.ts +3 -0
  101. package/dist/routes/terminal/plan.js +8 -5
  102. package/dist/routes/terminal/sandbox.d.ts +4 -3
  103. package/dist/routes/terminal/sandbox.js +5 -5
  104. package/dist/routes/terminal/token-usage.d.ts +1 -2
  105. package/dist/routes/terminal/token-usage.js +4 -6
  106. package/dist/routes/terminal/types.d.ts +9 -1
  107. package/dist/run/bundle-shape.js +6 -1
  108. package/dist/run/bundle.d.ts +5 -3
  109. package/dist/run/contained-output.d.ts +53 -5
  110. package/dist/run/contained-output.js +141 -42
  111. package/dist/run/detail.d.ts +2 -1
  112. package/dist/run/detail.js +12 -7
  113. package/dist/run/escape-sequences.d.ts +11 -0
  114. package/dist/run/escape-sequences.js +93 -0
  115. package/dist/run/evidence-files.d.ts +8 -8
  116. package/dist/run/evidence-files.js +19 -23
  117. package/dist/run/locate.d.ts +32 -3
  118. package/dist/run/locate.js +45 -40
  119. package/dist/run/notes.d.ts +0 -8
  120. package/dist/run/notes.js +5 -51
  121. package/dist/run/outcomes.d.ts +24 -0
  122. package/dist/run/outcomes.js +19 -0
  123. package/dist/run/paths.d.ts +2 -0
  124. package/dist/run/paths.js +5 -1
  125. package/dist/run/preflight-receipts.js +13 -8
  126. package/dist/run/project.js +7 -4
  127. package/dist/run/reclaim-outcomes.d.ts +8 -6
  128. package/dist/run/reclaim-outcomes.js +10 -6
  129. package/dist/run/reclaim.d.ts +5 -0
  130. package/dist/run/reclaim.js +43 -16
  131. package/dist/run/run-clock.d.ts +50 -0
  132. package/dist/run/run-clock.js +66 -0
  133. package/dist/run/run.js +5 -2
  134. package/dist/run/sandbox-ids.d.ts +7 -2
  135. package/dist/run/sandbox-ids.js +27 -11
  136. package/dist/run/secrets.d.ts +2 -1
  137. package/dist/run/secrets.js +18 -22
  138. package/dist/run/stored-runs.js +14 -7
  139. package/dist/run/transient-comms-secrets.js +6 -17
  140. package/dist/run-study.js +22 -13
  141. package/dist/study/adapter-scorer-loader.js +6 -4
  142. package/dist/study/config.js +4 -1
  143. package/dist/study/discover.js +9 -6
  144. package/dist/study/init-paths.js +7 -5
  145. package/dist/study/keys.d.ts +2 -0
  146. package/dist/study/keys.js +2 -0
  147. package/dist/study/migrate/v2.js +2 -1
  148. package/dist/study/parse/actors.js +8 -0
  149. package/dist/study/persona-resolve.d.ts +2 -2
  150. package/dist/study/persona-resolve.js +9 -5
  151. package/dist/study/plan-participants.d.ts +4 -0
  152. package/dist/study/plan-participants.js +4 -0
  153. package/dist/study/plan-types.d.ts +2 -2
  154. package/dist/study/plan.d.ts +5 -3
  155. package/dist/study/plan.js +18 -9
  156. package/dist/study/preflight-probes.js +11 -3
  157. package/dist/study/preflight.js +9 -5
  158. package/dist/study/summary.d.ts +8 -2
  159. package/dist/study/summary.js +20 -22
  160. package/dist/study/types.d.ts +14 -0
  161. package/dist/study/validation.d.ts +5 -0
  162. package/dist/study/validation.js +8 -0
  163. package/dist/study/warnings.d.ts +19 -12
  164. package/dist/study/warnings.js +50 -17
  165. package/dist/subject/serve.d.ts +3 -0
  166. package/dist/subject/serve.js +4 -1
  167. package/dist/substrates/detached.js +14 -7
  168. package/dist/substrates/e2b/connection.d.ts +12 -0
  169. package/dist/substrates/e2b/connection.js +35 -0
  170. package/dist/substrates/e2b/desktop-executor.d.ts +1 -5
  171. package/dist/substrates/e2b/desktop-executor.js +45 -12
  172. package/dist/substrates/e2b/lifetime.d.ts +24 -4
  173. package/dist/substrates/e2b/lifetime.js +41 -4
  174. package/dist/substrates/e2b/sandbox.d.ts +4 -3
  175. package/dist/substrates/e2b/sandbox.js +13 -18
  176. package/dist/substrates/e2b/sdk.d.ts +20 -9
  177. package/dist/substrates/local/capacity.d.ts +18 -2
  178. package/dist/substrates/local/capacity.js +21 -2
  179. package/dist/substrates/local/runtime-host.js +21 -14
  180. package/dist/substrates/local/runtime.js +3 -11
  181. package/dist/tui/contract.d.ts +30 -5
  182. package/dist/tui/contract.js +4 -1
  183. package/dist/tui-app.js +150 -147
  184. package/dist/verify/artifacts.d.ts +9 -2
  185. package/dist/verify/artifacts.js +37 -13
  186. package/dist/verify/liveness.js +19 -5
  187. package/dist/verify/verify.js +54 -27
  188. package/docs/architecture/browser-control.md +10 -4
  189. package/docs/architecture/guest-desktop.md +7 -2
  190. package/docs/architecture/state-driven-executor.md +4 -2
  191. package/docs/contracts/run-bundle.md +18 -10
  192. package/docs/contracts/schemas.md +46 -29
  193. package/docs/contracts/study-analysis.md +10 -1
  194. package/docs/product/automatic-analysis.md +7 -1
  195. package/package.json +2 -2
@@ -0,0 +1,35 @@
1
+ /** The connection an env names: E2B_API_KEY and E2B_DOMAIN, trimmed, each only when set. */
2
+ export function e2bConnection(env) {
3
+ return named({ apiKey: env.E2B_API_KEY?.trim(), domain: env.E2B_DOMAIN?.trim() });
4
+ }
5
+ /**
6
+ * The kill, getInfo and list of `module`, each carrying `connection`'s key and domain over the
7
+ * call's own options. `connection` may be a create's options, so the release of a sandbox reaches
8
+ * the account that created it. Each method is bound now: a hook that later replaces one on the
9
+ * shared module does not change this account. A method the installed SDK lacks stays absent.
10
+ */
11
+ export function e2bAccount(module, connection) {
12
+ const sdk = module.Sandbox;
13
+ const reached = named(connection);
14
+ const account = {};
15
+ if (typeof sdk.kill === "function") {
16
+ const kill = sdk.kill.bind(sdk);
17
+ account.kill = (sandboxId, options) => kill(sandboxId, { ...options, ...reached });
18
+ }
19
+ if (typeof sdk.getInfo === "function") {
20
+ const getInfo = sdk.getInfo.bind(sdk);
21
+ account.getInfo = (sandboxId, options) => getInfo(sandboxId, { ...options, ...reached });
22
+ }
23
+ if (typeof sdk.list === "function") {
24
+ const list = sdk.list.bind(sdk);
25
+ account.list = (options) => list({ ...options, ...reached });
26
+ }
27
+ return account;
28
+ }
29
+ /** Only the key and the domain, and only when non-empty: the SDK reads an empty one as unset. */
30
+ function named(values) {
31
+ return {
32
+ ...(values.apiKey ? { apiKey: values.apiKey } : {}),
33
+ ...(values.domain ? { domain: values.domain } : {}),
34
+ };
35
+ }
@@ -6,7 +6,7 @@ import type { CuaExecutor, CuaObservation } from "../../actors/computer-use/loop
6
6
  * executor awaits every call, which is correct for both sync and async returns.
7
7
  */
8
8
  export interface E2BDesktopLike {
9
- /** Optional command surface used only for best-effort substrate fallbacks. */
9
+ /** The sandbox's command surface: keypresses, held modifiers and typed text run xdotool here. */
10
10
  commands?: {
11
11
  run(command: string, options?: {
12
12
  requestTimeoutMs?: number;
@@ -48,16 +48,12 @@ export interface E2BDesktopLike {
48
48
  scroll(direction?: "up" | "down", amount?: number): Promise<void> | void;
49
49
  /** Write text at the current cursor position (the SDK's typing method). */
50
50
  write(text: string): Promise<void> | void;
51
- /** Press a key or chord (the SDK's key method); accepts the keys array. */
52
- press(key: string | string[]): Promise<void> | void;
53
51
  /** Drag from one coordinate tuple to another. */
54
52
  drag(from: [number, number], to: [number, number]): Promise<void> | void;
55
53
  /** Wait for the given number of milliseconds. */
56
54
  wait(ms: number): Promise<void> | void;
57
55
  }
58
56
  export interface E2BDesktopExecutorOptions {
59
- /** Fallback wait when a wait action carries no ms. Default 500. */
60
- defaultWaitMs?: number;
61
57
  /**
62
58
  * Pixels of CuaAction scroll dy per one SDK scroll tick. The executor maps
63
59
  * abs(dy) / scrollAmountPerTick to the SDK's integer `amount` (floored at 1 for
@@ -2,11 +2,11 @@ import { shellQuote } from "../shell.js";
2
2
  import { perceptualSignature } from "../../evidence/frame-signature.js";
3
3
  import { commandFailureInfo } from "../command-failure.js";
4
4
  import { ComputerUseExecutorError } from "../../actors/computer-use/executor-error.js";
5
- import { xdotoolHeldModifiers } from "../../guest/desktop-keys.js";
6
- const DEFAULT_WAIT_MS = 500;
5
+ import { CUA_WAIT_LIMITS } from "../../actors/computer-use/wait.js";
6
+ import { xdotoolChord, xdotoolHeldModifiers } from "../../guest/desktop-keys.js";
7
7
  const DEFAULT_SCROLL_AMOUNT_PER_TICK = 100;
8
8
  const TYPE_COMMAND_TIMEOUT_MS = 15_000;
9
- const HELD_KEYS_TIMEOUT_MS = 15_000;
9
+ const KEY_COMMAND_TIMEOUT_MS = 15_000;
10
10
  const CURSOR_READ_TIMEOUT_MS = 500;
11
11
  /**
12
12
  * The stock image's xdotool waits ~15s on a synchronized move to its current position.
@@ -145,7 +145,6 @@ async function typeText(desktop, text) {
145
145
  * screenshot or action is ever logged.
146
146
  */
147
147
  export function createE2BDesktopExecutor(desktop, options = {}) {
148
- const defaultWaitMs = options.defaultWaitMs ?? DEFAULT_WAIT_MS;
149
148
  const scrollAmountPerTick = options.scrollAmountPerTick ?? DEFAULT_SCROLL_AMOUNT_PER_TICK;
150
149
  return {
151
150
  async observe() {
@@ -234,11 +233,15 @@ export function createE2BDesktopExecutor(desktop, options = {}) {
234
233
  }
235
234
  return;
236
235
  }
237
- case "keypress":
238
- // The SDK press() accepts a string[] directly; pass the keys through so
239
- // a chord (e.g. ["Control", "a"]) is pressed together, not in sequence.
240
- await desktop.press(action.keys);
236
+ case "keypress": {
237
+ // One chord, so ctrl and a are pressed together. A name the table cannot map is
238
+ // refused here, before anything reaches the desktop.
239
+ const chord = xdotoolChord(action.keys);
240
+ if (!desktop.commands)
241
+ throw new ComputerUseExecutorError("action_rejected", "not_dispatched");
242
+ await runKeyCommand(desktop.commands, `key --clearmodifiers ${chord}`);
241
243
  return;
244
+ }
242
245
  case "drag": {
243
246
  // The SDK drag takes two endpoints, not an N-point path: drag from the
244
247
  // first to the last point. 0 points is a safe no-op; 1 point has no
@@ -254,7 +257,8 @@ export function createE2BDesktopExecutor(desktop, options = {}) {
254
257
  return;
255
258
  }
256
259
  case "wait":
257
- await desktop.wait(action.ms ?? defaultWaitMs);
260
+ // The loop sends every wait with its length; planWait owns the lengths.
261
+ await desktop.wait(action.ms ?? CUA_WAIT_LIMITS.settleMs);
258
262
  return;
259
263
  case "screenshot":
260
264
  // No-op: the loop calls observe() separately to capture each frame, so
@@ -279,11 +283,10 @@ async function withHeldModifiers(desktop, chord, run) {
279
283
  const commands = desktop.commands;
280
284
  if (!commands)
281
285
  throw new ComputerUseExecutorError("action_rejected", "not_dispatched");
282
- const options = { requestTimeoutMs: HELD_KEYS_TIMEOUT_MS, timeoutMs: HELD_KEYS_TIMEOUT_MS };
283
286
  let failed = false;
284
287
  let failure;
285
288
  try {
286
- await commands.run(`xdotool keydown ${chord}`, options);
289
+ await runKeyCommand(commands, `keydown ${chord}`);
287
290
  await run();
288
291
  }
289
292
  catch (error) {
@@ -292,7 +295,7 @@ async function withHeldModifiers(desktop, chord, run) {
292
295
  }
293
296
  // A keydown that failed may still have pressed some keys, so the release always runs.
294
297
  try {
295
- await commands.run(`xdotool keyup ${chord}`, options);
298
+ await runKeyCommand(commands, `keyup ${chord}`);
296
299
  }
297
300
  catch {
298
301
  throw new ComputerUseExecutorError("execution_failed", "outcome_uncertain");
@@ -300,3 +303,33 @@ async function withHeldModifiers(desktop, chord, run) {
300
303
  if (failed)
301
304
  throw failure;
302
305
  }
306
+ /**
307
+ * A key command xdotool did not complete. The loop reads `exitCode` and `stderr` the way it reads
308
+ * the SDK's CommandExitError, so it records the action as a failed desktop command and skips it.
309
+ */
310
+ class ComputerUseKeyError extends Error {
311
+ exitCode;
312
+ stderr;
313
+ constructor(exitCode, stderr) {
314
+ super(`key command failed (exit ${exitCode})`);
315
+ this.name = "ComputerUseKeyError";
316
+ this.exitCode = exitCode;
317
+ this.stderr = stderr;
318
+ }
319
+ }
320
+ /**
321
+ * Run an xdotool key command: `key`, `keydown` or `keyup` with a chord from the key table, whose
322
+ * names are plain shell words, so the command and its stderr hold no participant text. The SDK
323
+ * throws its own error on a non-zero exit. xdotool skips a name it cannot resolve with a "No such
324
+ * key name" warning and still exits 0, so that warning fails the command too.
325
+ */
326
+ async function runKeyCommand(commands, command) {
327
+ const result = await commands.run(`xdotool ${command}`, {
328
+ requestTimeoutMs: KEY_COMMAND_TIMEOUT_MS,
329
+ timeoutMs: KEY_COMMAND_TIMEOUT_MS,
330
+ });
331
+ const exitCode = result.exitCode ?? 0;
332
+ const stderr = result.stderr ?? "";
333
+ if (exitCode !== 0 || stderr.includes("No such key name"))
334
+ throw new ComputerUseKeyError(exitCode, stderr);
335
+ }
@@ -1,9 +1,29 @@
1
1
  /**
2
- * E2B refuses a sandbox lifetime over one hour ("400: Timeout cannot be greater than 1 hours").
3
- * A derived deadline has to stay under it, and saying so at plan time beats discovering it from a
4
- * raw provider 400 after a plan has already printed.
2
+ * The longest lifetime humanish asks E2B for, in milliseconds, and whether it is the default or
3
+ * the setting; or why the setting cannot be read.
5
4
  */
6
- export declare const MAX_SANDBOX_MS: number;
5
+ export type SandboxCeiling = {
6
+ readonly ok: true;
7
+ readonly ms: number;
8
+ readonly source: "default" | "setting";
9
+ } | {
10
+ readonly ok: false;
11
+ readonly message: string;
12
+ };
13
+ /**
14
+ * The sandbox ceiling: HUMANISH_E2B_MAX_SANDBOX_MINUTES when set, else 60 minutes. E2B refuses a
15
+ * longer sandbox with a 400 ("Timeout cannot be greater than 1 hours" on Hobby), and neither its
16
+ * API nor its SDK says which plan a key is on, so the operator states it. A derived deadline has
17
+ * to stay under the ceiling, and saying so at plan time beats a provider 400 after the plan printed.
18
+ */
19
+ export declare function sandboxCeiling(env: Readonly<Record<string, string | undefined>>): SandboxCeiling;
20
+ /** The ceiling in use and where it came from, or why the setting cannot be read (doctor's row). */
21
+ export declare function describeSandboxCeiling(ceiling: SandboxCeiling): string;
22
+ /**
23
+ * The sentence a plan refusal adds when a `deadlineMs` sandbox deadline passes the ceiling: the
24
+ * setting value that would admit it, when an E2B plan allows that long.
25
+ */
26
+ export declare function ceilingAdvice(deadlineMs: number): string;
7
27
  /** Server-side reclamation buffer past a participant's own wall-clock stop. */
8
28
  export declare const SANDBOX_TIMEOUT_BUFFER_MS: number;
9
29
  /** Room a provisioned subject adds to the sandbox deadline for clone, install, build, start and probe. */
@@ -2,12 +2,49 @@
2
2
  // deadline when they set a sandbox's server-side timeout. A dead host process can never orphan a
3
3
  // sandbox past that timeout, so every route derives it from these values.
4
4
  import { readPositiveInt } from "../../study/parse/values.js";
5
+ /** The setting that tells humanish how long the operator's E2B plan lets a sandbox live. */
6
+ const SANDBOX_CEILING_SETTING = "HUMANISH_E2B_MAX_SANDBOX_MINUTES";
7
+ /** E2B's Hobby plan ends a sandbox after 1 hour, so a key on any plan can use this ceiling. */
8
+ const DEFAULT_CEILING_MINUTES = 60;
9
+ /** E2B's Pro plan allows 24 hours, the longest lifetime E2B documents. */
10
+ const LARGEST_CEILING_MINUTES = 24 * 60;
5
11
  /**
6
- * E2B refuses a sandbox lifetime over one hour ("400: Timeout cannot be greater than 1 hours").
7
- * A derived deadline has to stay under it, and saying so at plan time beats discovering it from a
8
- * raw provider 400 after a plan has already printed.
12
+ * The sandbox ceiling: HUMANISH_E2B_MAX_SANDBOX_MINUTES when set, else 60 minutes. E2B refuses a
13
+ * longer sandbox with a 400 ("Timeout cannot be greater than 1 hours" on Hobby), and neither its
14
+ * API nor its SDK says which plan a key is on, so the operator states it. A derived deadline has
15
+ * to stay under the ceiling, and saying so at plan time beats a provider 400 after the plan printed.
9
16
  */
10
- export const MAX_SANDBOX_MS = 60 * 60_000;
17
+ export function sandboxCeiling(env) {
18
+ const declared = env[SANDBOX_CEILING_SETTING]?.trim() ?? "";
19
+ if (declared === "")
20
+ return { ok: true, ms: DEFAULT_CEILING_MINUTES * 60_000, source: "default" };
21
+ const minutes = /^\d+$/.test(declared) ? Number.parseInt(declared, 10) : Number.NaN;
22
+ if (!(minutes >= 1 && minutes <= LARGEST_CEILING_MINUTES))
23
+ return {
24
+ ok: false,
25
+ message: `${SANDBOX_CEILING_SETTING} must be a whole number of minutes from 1 to ${LARGEST_CEILING_MINUTES} (got "${declared.slice(0, 40)}"). Set it to the sandbox lifetime your E2B plan allows: ${DEFAULT_CEILING_MINUTES} on Hobby, ${LARGEST_CEILING_MINUTES} on Pro. Unset, it is ${DEFAULT_CEILING_MINUTES}.`,
26
+ };
27
+ return { ok: true, ms: minutes * 60_000, source: "setting" };
28
+ }
29
+ /** The ceiling in use and where it came from, or why the setting cannot be read (doctor's row). */
30
+ export function describeSandboxCeiling(ceiling) {
31
+ if (!ceiling.ok)
32
+ return ceiling.message;
33
+ const minutes = ceiling.ms / 60_000;
34
+ return ceiling.source === "setting"
35
+ ? `${minutes} minutes, from ${SANDBOX_CEILING_SETTING}. A study whose sandbox deadline is longer is refused before any sandbox is created, and E2B refuses a lifetime past the plan's own limit.`
36
+ : `${minutes} minutes, E2B's Hobby limit. On a plan with longer sandboxes (Pro allows ${LARGEST_CEILING_MINUTES} minutes), set ${SANDBOX_CEILING_SETTING} to that plan's limit.`;
37
+ }
38
+ /**
39
+ * The sentence a plan refusal adds when a `deadlineMs` sandbox deadline passes the ceiling: the
40
+ * setting value that would admit it, when an E2B plan allows that long.
41
+ */
42
+ export function ceilingAdvice(deadlineMs) {
43
+ const needed = Math.ceil(deadlineMs / 60_000);
44
+ return needed <= LARGEST_CEILING_MINUTES
45
+ ? ` If your E2B plan allows longer sandboxes (Pro allows ${LARGEST_CEILING_MINUTES} minutes), set ${SANDBOX_CEILING_SETTING} to ${needed} or more.`
46
+ : ` No E2B plan humanish knows allows a sandbox past ${LARGEST_CEILING_MINUTES} minutes.`;
47
+ }
11
48
  /** Server-side reclamation buffer past a participant's own wall-clock stop. */
12
49
  export const SANDBOX_TIMEOUT_BUFFER_MS = 10 * 60_000;
13
50
  /** Room a provisioned subject adds to the sandbox deadline for clone, install, build, start and probe. */
@@ -1,6 +1,7 @@
1
1
  import type { PreparedOutputRoot } from "../../run/contained-output.js";
2
2
  import { type DesktopReleaseResult, type OwnedDesktopAllocation } from "../desktop-session.js";
3
3
  import type { StudyConfig } from "../../study/types.js";
4
+ import { type E2BAccount } from "./connection.js";
4
5
  import { type E2BDesktopCreateOptions, type E2BDesktopModule, type E2BDesktopSandbox, type E2BListedSandbox } from "./sdk.js";
5
6
  /** Where an allocation's receipt goes: the run's prepared root, under a public-safe participant
6
7
  * label. The receipt line saves it as `laneId`. */
@@ -59,7 +60,7 @@ export type E2BSandboxDestroyOutcome = {
59
60
  };
60
61
  /** Kill one sandbox by its exact id, for reclaim, and map the shared release result onto
61
62
  * reclaim's persisted outcome. */
62
- export declare function destroyE2BSandbox(module: E2BDesktopModule, sandboxId: string, options: {
63
+ export declare function destroyE2BSandbox(account: E2BAccount, sandboxId: string, options: {
63
64
  requestTimeoutMs: number;
64
65
  }): Promise<E2BSandboxDestroyOutcome>;
65
66
  /** What `reclaim --check` learned about one sandbox by its exact id. */
@@ -73,7 +74,7 @@ export type E2BSandboxPresence = {
73
74
  * Ask E2B whether one sandbox still exists, by exact id. A SandboxNotFoundError, recognized by
74
75
  * type, means gone; any other error leaves the answer open.
75
76
  */
76
- export declare function inspectE2BSandbox(module: E2BDesktopModule, sandboxId: string, options: {
77
+ export declare function inspectE2BSandbox(account: E2BAccount, sandboxId: string, options: {
77
78
  requestTimeoutMs: number;
78
79
  }): Promise<E2BSandboxPresence>;
79
80
  /** What E2B listed for a run's owner tags. `sandboxes` holds exact matches only. */
@@ -87,7 +88,7 @@ export interface E2BTagSearch {
87
88
  * checked against every tag again, so a server that ignored the filter still cannot hand back
88
89
  * another run's sandbox, and the search stops after a few pages instead of walking the account.
89
90
  */
90
- export declare function findE2BSandboxesByTags(module: E2BDesktopModule, tags: Record<string, string>, options: {
91
+ export declare function findE2BSandboxesByTags(account: E2BAccount, tags: Record<string, string>, options: {
91
92
  requestTimeoutMs: number;
92
93
  }): Promise<E2BTagSearch>;
93
94
  /** Versioned public template built by runtime/browser-media/e2b-template.mjs. */
@@ -11,6 +11,7 @@ import { redactText, toErrorMessage } from "../../evidence/redaction.js";
11
11
  import { beginSandboxCreate, recordSandboxOwnerOnce, SandboxCreateRefusedError, sandboxOwnerTags, } from "../../run/sandbox-creates.js";
12
12
  import { appendSandboxOwner, appendSandboxReceipt } from "../../run/sandbox-receipts.js";
13
13
  import { ownDesktopAllocation, } from "../desktop-session.js";
14
+ import { e2bAccount } from "./connection.js";
14
15
  import { E2B_DEBUG_KILL_DETAIL, E2BDesktopStartupError, e2bDebugMode, isSandboxNotFoundError, observeDesktopAllocation, } from "./sdk.js";
15
16
  /** A desktop sandbox, on the stock `desktop` template or a named one. */
16
17
  export function acquireE2BDesktopSandbox(request) {
@@ -67,7 +68,8 @@ async function acquire(request, template) {
67
68
  : module.Sandbox.create(template, options);
68
69
  }, { ...request.retry, canRetry: () => ticket?.stopping() !== true });
69
70
  ticket?.created(sandbox.sandboxId);
70
- const allocation = ownE2BSandbox(module, sandbox.sandboxId, ticket);
71
+ // The release reaches the account the create used, whatever process.env names.
72
+ const allocation = ownE2BSandbox(e2bAccount(module, options), sandbox.sandboxId, ticket);
71
73
  try {
72
74
  // Best effort by contract: a failed write leaves the owner tags and the TTL as the
73
75
  // backstops and never fails the participant. One line per sandbox: a receipt the guard's
@@ -90,12 +92,6 @@ async function acquire(request, template) {
90
92
  ticket?.settled();
91
93
  }
92
94
  }
93
- /** The provider's kill method bound to its class, or undefined when the SDK has none. */
94
- function boundKill(module) {
95
- return typeof module.Sandbox.kill === "function"
96
- ? module.Sandbox.kill.bind(module.Sandbox)
97
- : undefined;
98
- }
99
95
  /**
100
96
  * Kill one sandbox by exact id and read the answer in the release vocabulary both callers share.
101
97
  * The SDK resolves false for an id it no longer knows (a 404), and a SandboxNotFoundError,
@@ -129,9 +125,8 @@ async function killById(kill, sandboxId, options) {
129
125
  return { status: "unconfirmed", reason: "release_failed", error };
130
126
  }
131
127
  }
132
- function ownE2BSandbox(module, resourceId, ticket) {
133
- // Bind the provider method now, before any hook can replace it on the shared module.
134
- const kill = boundKill(module);
128
+ function ownE2BSandbox(account, resourceId, ticket) {
129
+ const { kill } = account;
135
130
  return ownDesktopAllocation({
136
131
  resourceId,
137
132
  release: async () => {
@@ -189,8 +184,8 @@ export function releaseUnavailableDetail(detail) {
189
184
  }
190
185
  /** Kill one sandbox by its exact id, for reclaim, and map the shared release result onto
191
186
  * reclaim's persisted outcome. */
192
- export async function destroyE2BSandbox(module, sandboxId, options) {
193
- const released = await killById(boundKill(module), sandboxId, options);
187
+ export async function destroyE2BSandbox(account, sandboxId, options) {
188
+ const released = await killById(account.kill, sandboxId, options);
194
189
  if (released.status === "released")
195
190
  return { state: released.reason === "terminated" ? "killed" : "already-gone" };
196
191
  switch (released.reason) {
@@ -209,14 +204,14 @@ export async function destroyE2BSandbox(module, sandboxId, options) {
209
204
  * Ask E2B whether one sandbox still exists, by exact id. A SandboxNotFoundError, recognized by
210
205
  * type, means gone; any other error leaves the answer open.
211
206
  */
212
- export async function inspectE2BSandbox(module, sandboxId, options) {
213
- if (typeof module.Sandbox.getInfo !== "function")
207
+ export async function inspectE2BSandbox(account, sandboxId, options) {
208
+ if (account.getInfo === undefined)
214
209
  return {
215
210
  state: "check-failed",
216
211
  detail: "the installed @e2b/desktop SDK has no Sandbox.getInfo",
217
212
  };
218
213
  try {
219
- await module.Sandbox.getInfo(sandboxId, options);
214
+ await account.getInfo(sandboxId, options);
220
215
  return { state: "running" };
221
216
  }
222
217
  catch (error) {
@@ -234,8 +229,8 @@ const TAG_SEARCH_MAX_PAGES = 3;
234
229
  * checked against every tag again, so a server that ignored the filter still cannot hand back
235
230
  * another run's sandbox, and the search stops after a few pages instead of walking the account.
236
231
  */
237
- export async function findE2BSandboxesByTags(module, tags, options) {
238
- if (typeof module.Sandbox.list !== "function")
232
+ export async function findE2BSandboxesByTags(account, tags, options) {
233
+ if (account.list === undefined)
239
234
  return {
240
235
  status: "unavailable",
241
236
  sandboxes: [],
@@ -243,7 +238,7 @@ export async function findE2BSandboxesByTags(module, tags, options) {
243
238
  };
244
239
  const sandboxes = [];
245
240
  try {
246
- const pages = module.Sandbox.list({
241
+ const pages = account.list({
247
242
  query: { metadata: tags },
248
243
  limit: TAG_SEARCH_PAGE_SIZE,
249
244
  requestTimeoutMs: options.requestTimeoutMs,
@@ -15,18 +15,14 @@ export interface E2BDesktopModule {
15
15
  * killed, false otherwise (the SDK's own doc comment). This boolean is the primary by-id
16
16
  * cleanup proof: a caller never needs to re-list to confirm reclamation.
17
17
  */
18
- kill?(sandboxId: string, options?: {
19
- requestTimeoutMs?: number;
20
- }): Promise<boolean>;
18
+ kill?(sandboxId: string, options?: E2BRequestOptions): Promise<boolean>;
21
19
  /**
22
20
  * Fetch one sandbox by its exact id (never account-wide). Throws a SandboxNotFoundError-
23
21
  * shaped error (see isSandboxNotFoundError below) when the id no longer exists; that thrown
24
22
  * error is the by-id confirmation that a killed sandbox is gone. Optional: older SDKs may
25
23
  * lack it, so callers fall back to kill()'s own boolean rather than ever calling Sandbox.list.
26
24
  */
27
- getInfo?(sandboxId: string, options?: {
28
- requestTimeoutMs?: number;
29
- }): Promise<E2BSandboxInfo>;
25
+ getInfo?(sandboxId: string, options?: E2BRequestOptions): Promise<E2BSandboxInfo>;
30
26
  /**
31
27
  * List sandboxes whose metadata matches every `query.metadata` pair, filtered server-side.
32
28
  * Reclaim calls it only with a run's owner tags (sandboxOwnerTags in src/run/sandbox-creates.ts)
@@ -36,12 +32,23 @@ export interface E2BDesktopModule {
36
32
  list?(options?: E2BSandboxListOptions): E2BSandboxPaginator;
37
33
  };
38
34
  }
39
- interface E2BSandboxListOptions {
35
+ /**
36
+ * Which E2B account a call reaches. The SDK reads E2B_API_KEY and E2B_DOMAIN from process.env for
37
+ * a call that names neither, and an empty value counts as none.
38
+ */
39
+ export interface E2BConnection {
40
+ apiKey?: string;
41
+ domain?: string;
42
+ }
43
+ /** The options of a call on a sandbox that exists: its account, and how long the request may take. */
44
+ interface E2BRequestOptions extends E2BConnection {
45
+ requestTimeoutMs?: number;
46
+ }
47
+ interface E2BSandboxListOptions extends E2BRequestOptions {
40
48
  query?: {
41
49
  metadata?: Record<string, string>;
42
50
  };
43
51
  limit?: number;
44
- requestTimeoutMs?: number;
45
52
  }
46
53
  interface E2BSandboxPaginator {
47
54
  readonly hasNext: boolean;
@@ -78,7 +85,11 @@ export interface E2BNetworkOptions {
78
85
  };
79
86
  }[]>;
80
87
  }
81
- export interface E2BDesktopCreateOptions {
88
+ /**
89
+ * A create names its account. e2bAccount (connection.ts) gives every later call on the sandbox the
90
+ * same one.
91
+ */
92
+ export interface E2BDesktopCreateOptions extends E2BConnection {
82
93
  apiKey: string;
83
94
  dpi?: number;
84
95
  envs?: Record<string, string>;
@@ -53,8 +53,23 @@ export declare function localCapacity(host: LocalCapacity["host"], size: Machine
53
53
  }): LocalCapacity;
54
54
  /** This machine's memory and CPUs, or the size a test passes in. */
55
55
  export declare function machineSize(machine?: MachineSize): MachineSize;
56
- /** A new VM's size: room for four desktops, capped at half the Mac's memory and CPUs. */
57
- export declare function defaultVmSize(mac: MachineSize): VmSize;
56
+ /** The humanish Lima VM as `limactl list` shows it: whether it exists, and its size when listed. */
57
+ interface LimaVmListing {
58
+ readonly exists: boolean;
59
+ readonly size?: MachineSize;
60
+ }
61
+ /**
62
+ * The humanish Lima VM's memory and CPUs: the size Lima lists for it, or for a VM that does not
63
+ * exist yet, the size setup creates (room for four desktops, capped at half the Mac's memory and
64
+ * CPUs). Undefined for an existing VM that lists no size: Lima omits the memory it cannot parse,
65
+ * and a default in its place would be a guess.
66
+ */
67
+ export declare function limaVmSize(vm: LimaVmListing, mac: MachineSize): VmSize | undefined;
68
+ /**
69
+ * Desktops the humanish Lima VM holds, or will hold once setup creates it. Undefined when its size
70
+ * is unknown.
71
+ */
72
+ export declare function limaCapacity(vm: LimaVmListing, mac: MachineSize): LocalCapacity | undefined;
58
73
  /**
59
74
  * Why a VM size the user asked for cannot work, or undefined: it must hold at least one desktop
60
75
  * and fit within the Mac.
@@ -73,3 +88,4 @@ export declare function capacityShortfall(capacity: LocalCapacity, needed: numbe
73
88
  } | undefined;
74
89
  /** What a participant whose desktop Docker killed for memory is told, with what to change. */
75
90
  export declare function outOfMemoryMessage(host: LocalCapacity["host"]): string;
91
+ export {};
@@ -50,14 +50,33 @@ function vmSizeFor(desktops) {
50
50
  export function machineSize(machine) {
51
51
  return machine ?? { memoryBytes: totalmem(), cpus: availableParallelism() };
52
52
  }
53
- /** A new VM's size: room for four desktops, capped at half the Mac's memory and CPUs. */
54
- export function defaultVmSize(mac) {
53
+ /**
54
+ * The humanish Lima VM's memory and CPUs: the size Lima lists for it, or for a VM that does not
55
+ * exist yet, the size setup creates (room for four desktops, capped at half the Mac's memory and
56
+ * CPUs). Undefined for an existing VM that lists no size: Lima omits the memory it cannot parse,
57
+ * and a default in its place would be a guess.
58
+ */
59
+ export function limaVmSize(vm, mac) {
60
+ if (vm.size !== undefined)
61
+ return { memoryGiB: vm.size.memoryBytes / GiB, cpus: vm.size.cpus };
62
+ if (vm.exists)
63
+ return undefined;
55
64
  const wanted = vmSizeFor(DEFAULT_VM_DESKTOPS);
56
65
  return {
57
66
  memoryGiB: Math.min(wanted.memoryGiB, Math.floor(mac.memoryBytes / GiB / 2)),
58
67
  cpus: Math.min(wanted.cpus, Math.floor(mac.cpus / 2)),
59
68
  };
60
69
  }
70
+ /**
71
+ * Desktops the humanish Lima VM holds, or will hold once setup creates it. Undefined when its size
72
+ * is unknown.
73
+ */
74
+ export function limaCapacity(vm, mac) {
75
+ const size = limaVmSize(vm, mac);
76
+ if (size === undefined)
77
+ return undefined;
78
+ return localCapacity("lima-vm", { memoryBytes: size.memoryGiB * GiB, cpus: size.cpus }, { planned: !vm.exists, machine: mac });
79
+ }
61
80
  /**
62
81
  * Why a VM size the user asked for cannot work, or undefined: it must hold at least one desktop
63
82
  * and fit within the Mac.
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { cli } from "../../cli/invocation.js";
7
- import { defaultVmSize, machineSize, vmSizeProblem, } from "./capacity.js";
7
+ import { limaVmSize, machineSize, vmSizeProblem, } from "./capacity.js";
8
8
  import { plural } from "../../run/text.js";
9
9
  const exec = promisify(execFile);
10
10
  export const LIMA_INSTANCE = "humanish-runtime";
@@ -124,22 +124,18 @@ export async function limaStatus(options = {}) {
124
124
  export async function prepareLima(options = {}, progress) {
125
125
  const status = await limaStatus(options);
126
126
  const mac = machineSize(options.machine);
127
- const current = status.size === undefined
128
- ? defaultVmSize(mac)
129
- : { memoryGiB: status.size.memoryBytes / 1024 ** 3, cpus: status.size.cpus };
130
- const size = { ...current, ...options.size };
127
+ const current = limaVmSize(status, mac);
131
128
  if (options.size !== undefined) {
129
+ const size = sizeToApply(current, options.size);
132
130
  const problem = vmSizeProblem(size, mac);
133
131
  if (problem !== undefined)
134
132
  throw new Error(problem);
135
- }
136
- // An existing VM whose size Lima did not list gets the asked-for size without a comparison.
137
- const differs = status.size === undefined
138
- ? options.size !== undefined
139
- : size.cpus !== current.cpus || size.memoryGiB !== current.memoryGiB;
140
- if (status.exists && differs) {
141
- await resizeLima(size, status.ready, options, progress);
142
- return;
133
+ // An existing VM whose size Lima did not list gets the asked-for size without a comparison.
134
+ const differs = current === undefined || size.cpus !== current.cpus || size.memoryGiB !== current.memoryGiB;
135
+ if (status.exists && differs) {
136
+ await resizeLima(size, status.ready, options, progress);
137
+ return;
138
+ }
143
139
  }
144
140
  if (status.ready)
145
141
  return;
@@ -153,7 +149,7 @@ export async function prepareLima(options = {}, progress) {
153
149
  const work = await mkdtemp(path.join(tmpdir(), "humanish-lima-"));
154
150
  try {
155
151
  const file = path.join(work, "host.yaml");
156
- await writeFile(file, limaTemplate(size), { mode: 0o600 });
152
+ await writeFile(file, limaTemplate(sizeToApply(current, options.size)), { mode: 0o600 });
157
153
  await hostExec("limactl", ["start", "--tty=false", "--name", LIMA_INSTANCE, file], options, 15 * 60_000);
158
154
  }
159
155
  finally {
@@ -161,6 +157,17 @@ export async function prepareLima(options = {}, progress) {
161
157
  }
162
158
  }
163
159
  }
160
+ /**
161
+ * The size to create or resize the VM at: the asked-for values over the VM's current size. Only an
162
+ * existing VM that lists no size has no current size, so there a value not asked for is unknown.
163
+ */
164
+ function sizeToApply(current, asked = {}) {
165
+ const memoryGiB = asked.memoryGiB ?? current?.memoryGiB;
166
+ const cpus = asked.cpus ?? current?.cpus;
167
+ if (memoryGiB === undefined || cpus === undefined)
168
+ throw new Error(`limactl lists no size for the humanish Lima VM, so humanish cannot tell which ${memoryGiB === undefined ? "--memory" : "--cpus"} to keep. Run ${cli("runtime setup --memory <GiB> --cpus <n>")} with both values.`);
169
+ return { memoryGiB, cpus };
170
+ }
164
171
  /** Lima changes the memory and CPUs of a stopped instance only. Running desktops keep it running. */
165
172
  async function resizeLima(size, running, options, progress) {
166
173
  if (running) {
@@ -6,24 +6,16 @@ import path from "node:path";
6
6
  import { Readable, Transform } from "node:stream";
7
7
  import { pipeline } from "node:stream/promises";
8
8
  import { LOCAL_MEDIA_RUNTIME_RELEASES, LOCAL_RUNTIME_RELEASES } from "./runtime-release.js";
9
- import { defaultVmSize, localCapacity, machineSize, } from "./capacity.js";
9
+ import { limaCapacity, localCapacity, machineSize, } from "./capacity.js";
10
10
  import { limaStatus, loadRuntimeArchive, prepareLima, runtimeArchitecture, runtimeDocker, runtimeExec, usesLima, } from "./runtime-host.js";
11
11
  import { cli } from "../../cli/invocation.js";
12
12
  const docker = async (args, options) => (await runtimeDocker(args, options)).stdout.trim();
13
13
  const runtimeRelease = (options) => options.release ??
14
14
  (options.media ? LOCAL_MEDIA_RUNTIME_RELEASES : LOCAL_RUNTIME_RELEASES)[runtimeArchitecture(options) ?? "amd64"];
15
- /** Desktops the existing Lima VM holds, the VM setup would create, or this Linux machine. */
15
+ /** Desktops the Lima VM holds (or setup would create), or this Linux machine. */
16
16
  function runtimeCapacity(options, lima) {
17
- if (lima === undefined)
18
- return localCapacity("linux-host", machineSize(options.machine));
19
17
  const machine = machineSize(options.machine);
20
- if (lima.size !== undefined)
21
- return localCapacity("lima-vm", lima.size, { machine });
22
- // An existing VM whose size Lima did not list has an unknown size, which no default replaces.
23
- if (lima.exists)
24
- return undefined;
25
- const planned = defaultVmSize(machine);
26
- return localCapacity("lima-vm", { memoryBytes: planned.memoryGiB * 1024 ** 3, cpus: planned.cpus }, { planned: true, machine });
18
+ return lima === undefined ? localCapacity("linux-host", machine) : limaCapacity(lima, machine);
27
19
  }
28
20
  /**
29
21
  * How many desktops the local runtime holds, read without starting anything. Undefined when the