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
@@ -4,48 +4,21 @@ import { COST_CATEGORIES } from "../../run/terminal-contract.js";
4
4
  /**
5
5
  * Build the spend ledger from the captured session. The null discipline:
6
6
  * - The `provider` line is populated from the actor trace's tokenUsage.costUsd when the trace
7
- * carries it (a measured value, incl. a measured 0). When the trace carries no costUsd, the
8
- * provider line is `null` = not measured (never guessed to 0 just because no-spend was intended).
7
+ * carries it (a measured value, incl. a measured 0). Otherwise its `usd` is null = not
8
+ * measured, and when the trace's `estimatedCost` prices the tokens, the line carries that
9
+ * price as `estimatedUsd`: the figure run.json's model-tokens line gives, from the same
10
+ * object. Tokens with no rate, and a run with no token count, say so in the note.
9
11
  * - product/media/payment are `null` by default: core has no signal for those categories, and
10
12
  * only a test can supply one, through `StudyDeps.costProbe`.
11
13
  * `injectedLines` lets a test supply known spend for a category,
12
14
  * exercising the fail-closed cap enforcement deterministically without a real billable run.
13
15
  */
14
16
  export function buildCostLedger(args) {
15
- const providerLine = typeof args.tokenCostUsd === "number"
16
- ? {
17
- usd: args.tokenCostUsd,
18
- source: "provider-token-usage",
19
- note: `Provider spend metered from the actor trace tokenUsage.costUsd (${args.tokenCostUsd} USD).`,
20
- }
21
- : args.tokenUsage
22
- ? {
23
- // Tokens counted, no rate to price them. This stays `usd: null` because a guessed
24
- // dollar figure would be worse than none, but the note carries the measured fact so a
25
- // reader never mistakes "no charge recorded" for "nothing was consumed".
26
- usd: null,
27
- source: "unpriced-token-usage",
28
- note: `Provider spend unpriced: the run consumed ${describeTokenUsage(args.tokenUsage)}, ` +
29
- "but the terminal participant records the model as `codex` and humanish has no rate " +
30
- "for it, so no dollar figure is given. The token count is measured; the price is " +
31
- "not known. Recorded as null, never guessed as 0.",
32
- }
33
- : {
34
- usd: null,
35
- source: "unmeasured",
36
- note: "Provider spend not measured: the actor trace carried no tokenUsage.costUsd in this run, so it is recorded as null and not guessed as 0.",
37
- };
38
- const unmeasured = (category) => ({
39
- usd: null,
40
- count: null,
41
- source: "unmeasured",
42
- note: `${category} spend not measured: humanish has no ${category} spend signal, and a study cannot supply one. Recorded as null, never guessed as 0.`,
43
- });
44
17
  const lines = {
45
18
  product: args.injectedLines?.product ?? unmeasured("product"),
46
19
  media: args.injectedLines?.media ?? unmeasured("media"),
47
20
  payment: args.injectedLines?.payment ?? unmeasured("payment"),
48
- provider: args.injectedLines?.provider ?? providerLine,
21
+ provider: args.injectedLines?.provider ?? providerLine(args.trace),
49
22
  };
50
23
  // knownTotalUsd sums only the non-null lines. A null line contributes nothing and is never
51
24
  // coerced to 0 (that would let an unmeasured category masquerade as a measured zero).
@@ -68,6 +41,51 @@ export function buildCostLedger(args) {
68
41
  fullyMeasured,
69
42
  };
70
43
  }
44
+ function unmeasured(category) {
45
+ return {
46
+ usd: null,
47
+ count: null,
48
+ source: "unmeasured",
49
+ note: `${category} spend not measured: humanish has no ${category} spend signal, and a study cannot supply one. Recorded as null, never guessed as 0.`,
50
+ };
51
+ }
52
+ /** The participant's model tokens: a measured charge, an estimate, unpriced, or not counted. */
53
+ function providerLine(trace) {
54
+ const { tokenUsage, estimatedCost } = trace;
55
+ if (typeof tokenUsage?.costUsd === "number")
56
+ return {
57
+ usd: tokenUsage.costUsd,
58
+ source: "provider-token-usage",
59
+ note: `Provider spend metered from the actor trace tokenUsage.costUsd (${tokenUsage.costUsd} USD).`,
60
+ };
61
+ if (tokenUsage === undefined)
62
+ return {
63
+ usd: null,
64
+ source: "unmeasured",
65
+ note: "Provider spend not measured: the actor trace carried no tokenUsage.costUsd in this run, so it is recorded as null and not guessed as 0.",
66
+ };
67
+ if (estimatedCost !== undefined && estimatedCost.estimatedCostUsd !== null)
68
+ return {
69
+ usd: null,
70
+ estimatedUsd: estimatedCost.estimatedCostUsd,
71
+ source: "estimated-token-usage",
72
+ note: `Provider spend estimated at ${estimatedCost.estimatedCostUsd} USD: the run consumed ` +
73
+ `${describeTokenUsage(tokenUsage)}, priced at ${estimatedCost.modelId ?? "the participant model's"} ` +
74
+ `rates as of ${estimatedCost.ratesAsOf}, as run.json's cost summary prices them. No provider ` +
75
+ "charge was measured, so usd stays null and caps.maxUsd does not count the estimate.",
76
+ };
77
+ // Tokens counted, no rate to price them. A guessed dollar figure would be worse than none, but
78
+ // the note carries the measured fact so a reader never mistakes "no charge recorded" for
79
+ // "nothing was consumed".
80
+ return {
81
+ usd: null,
82
+ source: "unpriced-token-usage",
83
+ note: `Provider spend unpriced: the run consumed ${describeTokenUsage(tokenUsage)}, but humanish ` +
84
+ `has no rate for ${estimatedCost?.modelId === undefined ? "the participant's model" : `the model ${estimatedCost.modelId}`}, ` +
85
+ "so no dollar figure is given. The token count is measured; the price is not known. " +
86
+ "Recorded as null, never guessed as 0.",
87
+ };
88
+ }
71
89
  /** True when no cost line carries a dollar value, so a no-spend proof has nothing to stand on. */
72
90
  export function noSpendLineMeasured(proof) {
73
91
  return proof.knownZeroLines.length + proof.knownNonZeroLines.length === 0;
@@ -80,19 +98,24 @@ export function noSpendNotEstablished(maxUsd) {
80
98
  return `No-spend proof not established for maxUsd=${maxUsd}: no spend line was measured.`;
81
99
  }
82
100
  /**
83
- * What the ledger measured, in words: the dollar lines it knows, provider tokens it counted but
84
- * could not price, and the lines it has no signal for. A reader of a maxUsd=0 run must not take
85
- * an all-null ledger for a proven $0.
101
+ * What the ledger measured, in words: the dollar lines it knows, the provider tokens it counted
102
+ * with their estimate or as unpriced, and the lines it has no signal for. A reader of a maxUsd=0
103
+ * run must not take an all-null ledger for a proven $0.
86
104
  */
87
105
  export function describeMeasuredSpend(ledger, tokenUsage) {
88
106
  const measured = COST_CATEGORIES.filter((c) => ledger.lines[c].usd !== null).map((c) => `${c} ${ledger.lines[c].usd} USD`);
89
- const unpricedProvider = ledger.lines.provider.source === "unpriced-token-usage";
90
- const unmeasured = COST_CATEGORIES.filter((c) => ledger.lines[c].usd === null && !(c === "provider" && unpricedProvider));
107
+ const provider = ledger.lines.provider;
108
+ const countedProvider = provider.usd === null &&
109
+ (provider.source === "unpriced-token-usage" || provider.source === "estimated-token-usage");
110
+ const unmeasured = COST_CATEGORIES.filter((c) => ledger.lines[c].usd === null && !(c === "provider" && countedProvider));
111
+ const counts = tokenUsage === undefined ? "" : ` (${describeTokenUsage(tokenUsage)})`;
91
112
  return [
92
113
  measured.length > 0 ? `Measured: ${measured.join(", ")}.` : "",
93
- unpricedProvider
94
- ? `Provider tokens were consumed${tokenUsage === undefined ? "" : ` (${describeTokenUsage(tokenUsage)})`} and are unpriced, not zero.`
95
- : "",
114
+ !countedProvider
115
+ ? ""
116
+ : provider.estimatedUsd === undefined
117
+ ? `Provider tokens were consumed${counts} and are unpriced, not zero.`
118
+ : `Provider tokens${counts} are estimated at ${provider.estimatedUsd} USD, the participant's model cost, which caps.maxUsd does not count.`,
96
119
  unmeasured.length > 0 ? `Not measured (null, not claimed zero): ${unmeasured.join(", ")}.` : "",
97
120
  ]
98
121
  .filter((part) => part.length > 0)
@@ -81,17 +81,13 @@ async function settleLiveLedgers(inputs, trace, normalizedTranscript) {
81
81
  const { recordLifecycle, lifecycle, commandLog, interventions, terminalEvents } = inputs.recorder;
82
82
  // --- Spend ledger + no-spend proof + full caps enforcement (fail-closed). ---
83
83
  // The cost ledger is derived, with the null discipline: provider spend from the trace's
84
- // tokenUsage.costUsd when present (else null = not measured), product/media/payment null by
85
- // default (core has no signal). The costProbe hook lets tests inject known
86
- // spend to exercise the fail-closed cap without a real billable run.
84
+ // tokenUsage.costUsd when present, else null with the trace's token estimate beside it, and
85
+ // product/media/payment null by default (core has no signal). The costProbe hook lets tests
86
+ // inject known spend to exercise the fail-closed cap without a real billable run.
87
87
  const injectedLines = costProbe?.(trace.tokenUsage?.costUsd === undefined ? {} : { tokenCostUsd: trace.tokenUsage.costUsd });
88
88
  if (costProbe)
89
89
  await validatePreparedRunArtifactPaths(runPaths);
90
- const cost = buildCostLedger({
91
- ...(trace.tokenUsage?.costUsd === undefined ? {} : { tokenCostUsd: trace.tokenUsage.costUsd }),
92
- ...(trace.tokenUsage === undefined ? {} : { tokenUsage: trace.tokenUsage }),
93
- ...(injectedLines ? { injectedLines } : {}),
94
- });
90
+ const cost = buildCostLedger({ trace, ...(injectedLines ? { injectedLines } : {}) });
95
91
  const noSpendProof = buildNoSpendProof(cost, maxUsd ?? null, trace.tokenUsage);
96
92
  const proofVerdict = !noSpendProof.satisfied
97
93
  ? `No-spend proof not satisfied for maxUsd=${maxUsd ?? "null"}.`
@@ -138,8 +134,9 @@ export async function finishLiveTerminalSession(inputs) {
138
134
  const participant = terminalParticipantFacts(trace, session.error);
139
135
  const judgment = judgeTerminal({ dryRun: false, participant });
140
136
  // The run cost summary, as the computer-use route records it: the sandbox's compute time from
141
- // its span and observed size, and the participant's tokens (unpriced for Codex). It is not part
142
- // of the cap ledger above, whose lines sum against caps.maxUsd.
137
+ // its span and observed size, and the participant's tokens at trace.estimatedCost, the price the
138
+ // ledger's provider line also carries. It is not part of the cap ledger above, whose lines sum
139
+ // against caps.maxUsd.
143
140
  const desktops = session.runCostDesktops();
144
141
  const runCost = buildRunCostSummary({
145
142
  participants: [{ trace }],
@@ -45,7 +45,7 @@ export declare class LiveTerminalSandbox {
45
45
  private readonly wallClockMs;
46
46
  private readonly sandboxTimeoutMs;
47
47
  private sandbox;
48
- private module;
48
+ private account;
49
49
  private allocation;
50
50
  private startupCleanup;
51
51
  private startupCleanupDetail;
@@ -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 { e2bAccount } from "../../substrates/e2b/connection.js";
14
15
  import { E2BDesktopStartupError, loadE2BDesktopModule, } from "../../substrates/e2b/sdk.js";
15
16
  import { shellQuote } from "../../substrates/shell.js";
16
17
  import { buildOpenAiEgressNetwork } from "./runtime-auth.js";
@@ -36,7 +37,7 @@ export class LiveTerminalSandbox {
36
37
  wallClockMs;
37
38
  sandboxTimeoutMs;
38
39
  sandbox;
39
- module;
40
+ account;
40
41
  allocation;
41
42
  startupCleanup;
42
43
  startupCleanupDetail;
@@ -60,7 +61,6 @@ export class LiveTerminalSandbox {
60
61
  // Declared egress allowlist, or undefined for the historical unrestricted default.
61
62
  const egressAllow = plan.egressAllow;
62
63
  const sandboxModule = await (deps.desktopModule ?? loadE2BDesktopModule)();
63
- this.module = sandboxModule;
64
64
  await validatePreparedRunArtifactPaths(runPaths);
65
65
  // No sandbox-global env in either mode. In openai-egress, only this host-side SDK request
66
66
  // carries the real runtime key; participant commands receive an inert placeholder. The proxy
@@ -71,16 +71,19 @@ export class LiveTerminalSandbox {
71
71
  const network = runtimeEnv.mode === "openai-egress"
72
72
  ? buildOpenAiEgressNetwork(runtimeEnv.keyValue, routing)
73
73
  : routing;
74
+ const options = {
75
+ apiKey: e2bApiKey,
76
+ requestTimeoutMs,
77
+ timeoutMs: sandboxTimeoutMs,
78
+ metadata,
79
+ ...(network === undefined ? {} : { network }),
80
+ lifecycle: { onTimeout: "kill" },
81
+ };
82
+ // Teardown's check by id reaches the account the create used, whatever process.env names.
83
+ this.account = e2bAccount(sandboxModule, options);
74
84
  const acquired = await acquireE2BShellSandbox({
75
85
  module: sandboxModule,
76
- options: {
77
- apiKey: e2bApiKey,
78
- requestTimeoutMs,
79
- timeoutMs: sandboxTimeoutMs,
80
- metadata,
81
- ...(network === undefined ? {} : { network }),
82
- lifecycle: { onTimeout: "kill" },
83
- },
86
+ options,
84
87
  retry: {
85
88
  // A failed first attempt may have allocated a sandbox whose id never reached this run;
86
89
  // its own kill-on-timeout reclaims it.
@@ -418,7 +421,7 @@ export class LiveTerminalSandbox {
418
421
  // --- Proven cleanup, by exact id, never Sandbox.list. ---
419
422
  this.cleanup = await teardownSandbox({
420
423
  allocation: this.allocation,
421
- sandboxModule: this.module,
424
+ account: this.account,
422
425
  ...(this.startupCleanup === undefined ? {} : { startupCleanup: this.startupCleanup }),
423
426
  startupCleanupDetail: this.startupCleanupDetail,
424
427
  requestTimeoutMs,
@@ -1,5 +1,6 @@
1
1
  import type { RoutePlanResult, RouteRefusal, TerminalPlan } from "../../study/plan-types.js";
2
2
  import type { StudyConfig } from "../../study/types.js";
3
+ import { type SandboxCeiling } from "../../substrates/e2b/lifetime.js";
3
4
  import { type TerminalProductStudyResult } from "./types.js";
4
5
  /** The error a terminal study returns before a run starts. `actor` names the registered actor. */
5
6
  export interface TerminalRefusal extends RouteRefusal<"terminal", NonNullable<TerminalProductStudyResult["error"]>["code"]> {
@@ -14,4 +15,6 @@ export declare function planTerminalStudy(config: StudyConfig, input: {
14
15
  readonly dryRun: boolean;
15
16
  /** A test's costProbe can measure spend lines, so a positive maxUsd can trip. */
16
17
  readonly hasCostProbe?: boolean;
18
+ /** The longest sandbox lifetime the operator's E2B plan allows (sandboxCeiling). */
19
+ readonly sandboxCeiling: SandboxCeiling;
17
20
  }): TerminalPlanResult;
@@ -8,7 +8,7 @@ import { actorRegistry, isTerminalActorDescriptor } from "../../actors/registry.
8
8
  import { isNonEmpty, planBase } from "../../study/plan-base.js";
9
9
  import { studyUrlCredentialReason } from "../../study/url-credentials.js";
10
10
  import { desktopMediaValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
11
- import { MAX_SANDBOX_MS } from "../../substrates/e2b/lifetime.js";
11
+ import { ceilingAdvice } from "../../substrates/e2b/lifetime.js";
12
12
  import { NODE_BOOTSTRAP_TIMEOUT_MS } from "../../subject/node-bootstrap.js";
13
13
  import { terminalSandboxTimeoutMs } from "./lifetime.js";
14
14
  import { isExactRuntimeVersion, TERMINAL_RUNTIME_VERSION_TIMEOUT_MS } from "./runtime.js";
@@ -105,13 +105,16 @@ export function planTerminalStudy(config, input) {
105
105
  if (maxUsd > 0 && input.hasCostProbe !== true)
106
106
  return refuse("HUMANISH_TERMINAL_UNPRICED_CAP", `caps.maxUsd=${maxUsd} cannot be enforced: Codex reports no provider cost for the participant (its token cost is only estimated after the run) and no product, media or payment spend is measured, so a positive dollar cap can never trip. Set caps.maxUsd to 0 and bound the run with caps.maxMinutes, the codex command's wall-clock kill. No sandbox was created and the runtime key was not used.`, descriptor.id);
107
107
  // The sandbox's timeout covers the steps before the codex command, maxMinutes and the teardown
108
- // buffer, and E2B refuses a sandbox over an hour. Refuse here, with the arithmetic, rather than
109
- // after the plan prints, from a provider 400 that names neither knob.
108
+ // buffer, and E2B refuses a sandbox past the plan's ceiling. Refuse here, with the arithmetic,
109
+ // rather than after the plan prints, from a provider 400 that names neither knob.
110
+ const ceiling = input.sandboxCeiling;
111
+ if (!ceiling.ok)
112
+ return refuse("HUMANISH_TERMINAL_CAPS_INVALID", ceiling.message, descriptor.id);
110
113
  const productInstall = shared.product.install !== undefined;
111
114
  const sandboxTimeoutMs = terminalSandboxTimeoutMs({ maxMinutes, productInstall });
112
- if (sandboxTimeoutMs > MAX_SANDBOX_MS) {
115
+ if (sandboxTimeoutMs > ceiling.ms) {
113
116
  const headroomMinutes = (sandboxTimeoutMs - maxMinutes * 60_000) / 60_000;
114
- 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 ${MAX_SANDBOX_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 ${MAX_SANDBOX_MS / 60_000 - headroomMinutes}. No sandbox was created and the runtime key was not used.`, descriptor.id);
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);
115
118
  }
116
119
  return {
117
120
  ok: true,
@@ -1,5 +1,6 @@
1
1
  import type { OwnedDesktopAllocation } from "../../substrates/desktop-session.js";
2
- import { E2BDesktopStartupError, type E2BDesktopModule } from "../../substrates/e2b/sdk.js";
2
+ import type { E2BAccount } from "../../substrates/e2b/connection.js";
3
+ import { E2BDesktopStartupError } from "../../substrates/e2b/sdk.js";
3
4
  import type { TerminalLedgers } from "./types.js";
4
5
  /**
5
6
  * Tear the sandbox down and prove it by exact id, never by Sandbox.list (humanish must never
@@ -14,8 +15,8 @@ import type { TerminalLedgers } from "./types.js";
14
15
  export declare function teardownSandbox(args: {
15
16
  /** The allocation from acquisition; its release kills the exact id it captured. */
16
17
  allocation: OwnedDesktopAllocation | undefined;
17
- /** For the Sandbox.getInfo(id) re-check only. */
18
- sandboxModule: E2BDesktopModule | undefined;
18
+ /** The account the create used, for the Sandbox.getInfo(id) re-check only. */
19
+ account: E2BAccount | undefined;
19
20
  startupCleanup?: E2BDesktopStartupError["cleanup"];
20
21
  /** Why the startup guard's cleanup is unconfirmed, when it knows. */
21
22
  startupCleanupDetail?: string | undefined;
@@ -1,5 +1,5 @@
1
1
  import { toErrorMessage } from "../../evidence/redaction.js";
2
- import { E2BDesktopStartupError, isSandboxNotFoundError, } from "../../substrates/e2b/sdk.js";
2
+ import { E2BDesktopStartupError, isSandboxNotFoundError } from "../../substrates/e2b/sdk.js";
3
3
  import { releaseUnavailableDetail } from "../../substrates/e2b/sandbox.js";
4
4
  /**
5
5
  * Tear the sandbox down and prove it by exact id, never by Sandbox.list (humanish must never
@@ -12,8 +12,8 @@ import { releaseUnavailableDetail } from "../../substrates/e2b/sandbox.js";
12
12
  * fails closed on an unproven teardown.
13
13
  */
14
14
  export async function teardownSandbox(args) {
15
- const { allocation, sandboxModule, startupCleanup, startupCleanupDetail, requestTimeoutMs, sanitize, recordLifecycle, warnings, } = args;
16
- if (allocation === undefined || !sandboxModule) {
15
+ const { allocation, account, startupCleanup, startupCleanupDetail, requestTimeoutMs, sanitize, recordLifecycle, warnings, } = args;
16
+ if (allocation === undefined || account === undefined) {
17
17
  // create() can reject after its constructor acquired a handle. The default loader retains
18
18
  // that authority and reclaims it before rejecting; the route itself never receives its ID.
19
19
  if (startupCleanup === "killed" || startupCleanup === "already_gone") {
@@ -58,7 +58,7 @@ export async function teardownSandbox(args) {
58
58
  const killNote = released.reason === "terminated"
59
59
  ? "kill(id) returned true (found and killed)"
60
60
  : "kill(id) found the exact sandbox already gone (404)";
61
- if (typeof sandboxModule.Sandbox.getInfo !== "function") {
61
+ if (account.getInfo === undefined) {
62
62
  recordLifecycle("terminal-lab.cleanup.killed", `Sandbox ${sandboxId} reclaimed: ${killNote}; the installed SDK has no getInfo(id) to re-verify, so kill(id)'s own result is the proof.`);
63
63
  return {
64
64
  killed: true,
@@ -67,7 +67,7 @@ export async function teardownSandbox(args) {
67
67
  };
68
68
  }
69
69
  try {
70
- const info = await sandboxModule.Sandbox.getInfo(sandboxId, { requestTimeoutMs });
70
+ const info = await account.getInfo(sandboxId, { requestTimeoutMs });
71
71
  const state = info.state ?? "unknown";
72
72
  recordLifecycle("terminal-lab.cleanup.unconfirmed", `Sandbox ${sandboxId} ${killNote}, but getInfo(id) still reports state=${state} (not confirmed reclaimed by id).`);
73
73
  return {
@@ -13,6 +13,5 @@ export declare function terminalUsageRecords(lines: string): string[];
13
13
  */
14
14
  export declare function parseTerminalTokenUsage(transcript: string): ActorTokenUsage | undefined;
15
15
  /** Human-readable token statement for the cost ledger and the no-spend proof. Reports what was
16
- * counted and says plainly that it is unpriced, so a reader never reads "no charge recorded" as
17
- * "nothing was consumed". */
16
+ * counted, so a reader never reads "no charge recorded" as "nothing was consumed". */
18
17
  export declare function describeTokenUsage(usage: ActorTokenUsage): string;
@@ -9,10 +9,9 @@
9
9
  // {"type":"turn.completed","usage":{"input_tokens":201536,"cached_input_tokens":170558,
10
10
  // "cache_write_input_tokens":30951,"output_tokens":2283,"reasoning_output_tokens":902}}
11
11
  //
12
- // So the route can record a measured token fact even when it cannot record a priced one. Rates stay
13
- // out of this module deliberately: pricing lives in src/run/pricing.ts, and the terminal route has no
14
- // real model id to price against (it records `model: "codex"`), so the output is
15
- // tokens-known-rate-unknown rather than a guessed dollar figure.
12
+ // So the route records a measured token fact whether or not a rate prices it. Rates stay out of this
13
+ // module: the route prices these counts once, from the model it passed to Codex, with
14
+ // src/run/pricing.ts (live-finish.ts), and the ledger and run.json both read that estimate.
16
15
  // Bounded to a single line on purpose. `[^}]*` would run past a truncated record's missing brace
17
16
  // and swallow the next, valid record's body, silently dropping a real turn from the count.
18
17
  const USAGE_RE = /"type"\s*:\s*"turn\.completed"\s*,\s*"usage"\s*:\s*(\{[^}\n]*\})/g;
@@ -81,8 +80,7 @@ export function parseTerminalTokenUsage(transcript) {
81
80
  };
82
81
  }
83
82
  /** Human-readable token statement for the cost ledger and the no-spend proof. Reports what was
84
- * counted and says plainly that it is unpriced, so a reader never reads "no charge recorded" as
85
- * "nothing was consumed". */
83
+ * counted, so a reader never reads "no charge recorded" as "nothing was consumed". */
86
84
  export function describeTokenUsage(usage) {
87
85
  const parts = [];
88
86
  if (usage.input !== undefined)
@@ -232,6 +232,11 @@ export interface InterventionRecord {
232
232
  export interface CostLine {
233
233
  /** known zero (0) | not measured (null). The key is always present when the line is applicable. */
234
234
  usd: number | null;
235
+ /** The provider line's price for its counted tokens, from the trace's `estimatedCost` (the
236
+ * figure run.json's model-tokens line gives), when no charge was measured. An estimate from a
237
+ * rate table: `usd` stays null, and neither `knownTotalUsd` nor caps.maxUsd counts it. Present
238
+ * only with source `estimated-token-usage`. */
239
+ estimatedUsd?: number;
235
240
  /** Optional billable-unit count, same discipline: a known count, or null = not measured. */
236
241
  count?: number | null;
237
242
  /** How this line's value was established (provenance for the verifier + the human reviewer). */
@@ -239,7 +244,10 @@ export interface CostLine {
239
244
  /** Tokens were counted but no rate could price them, so `usd` stays null while the note
240
245
  * carries the measured token totals. Distinct from "unmeasured", which means no
241
246
  * signal at all. */
242
- | "unpriced-token-usage";
247
+ | "unpriced-token-usage"
248
+ /** Tokens were counted and priced from a rate table: `estimatedUsd` holds the estimate and
249
+ * `usd` stays null, since no charge was measured. */
250
+ | "estimated-token-usage";
243
251
  /** A short, public-safe note (never a secret value). */
244
252
  note: string;
245
253
  }
@@ -213,10 +213,15 @@ export const ARCHIVE_SHA256_PATTERN = /^[a-f0-9]{64}$/;
213
213
  function isRunSubjectProvenance(value) {
214
214
  if (!isRecord(value))
215
215
  return false;
216
- if (value.source !== "clone" && value.source !== "app-url" && value.source !== "local-tree")
216
+ if (value.source !== "clone" &&
217
+ value.source !== "app-url" &&
218
+ value.source !== "local-tree" &&
219
+ value.source !== "desktop-cli")
217
220
  return false;
218
221
  if (value.repo !== undefined && typeof value.repo !== "string")
219
222
  return false;
223
+ if (value.product !== undefined && typeof value.product !== "string")
224
+ return false;
220
225
  if (value.commit !== undefined && typeof value.commit !== "string")
221
226
  return false;
222
227
  if (value.archiveSha256 !== undefined &&
@@ -158,9 +158,11 @@ export interface RunSubjectStateStepRecord {
158
158
  * pre-existing bundles).
159
159
  */
160
160
  export interface RunSubjectProvenance {
161
- source: "clone" | "app-url" | "local-tree";
161
+ source: "clone" | "app-url" | "local-tree" | "desktop-cli";
162
162
  /** Clone-route only. Honors policies.redactRepos exactly as the provenance event does. */
163
163
  repo?: string;
164
+ /** Desktop-cli only: the product the study declares in `subject.product.name`. */
165
+ product?: string;
164
166
  /** Clone-route: the cloned commit SHA. Local-tree route: the host-side HEAD at pack time,
165
167
  * when the packed root was a git work tree. */
166
168
  commit?: string;
@@ -184,8 +186,8 @@ export interface RunSubjectProvenance {
184
186
  * an external DB is still unpinned overall).
185
187
  * declared-not-run: steps declared but not (all) executed ok: dry-run contract bundles
186
188
  * and failed live provisioning.
187
- * undeclared: no subject.state block (stateless apps, app-url subjects), the explicit
188
- * "absence declared" marker that provenance requires when there is none.
189
+ * undeclared: no subject.state block (stateless apps, app-url and desktop-cli subjects),
190
+ * the explicit "absence declared" marker that provenance requires when there is none.
189
191
  * external-public: an operator-declared, operator-owned public deployment used
190
192
  * directly as the shared plane; humanish neither provisioned nor seeded it (no getHost, no
191
193
  * clone, no in-sandbox filesystem). It is not "seeded" (nothing was seeded) and not "unpinned" (this is
@@ -46,13 +46,61 @@ export declare function writeContainedOutputFile(rootInput: PreparedOutputRoot,
46
46
  * writeContainedOutputFile renames a finished file into place instead, replacing what is there.
47
47
  */
48
48
  export declare function createContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string): Promise<void>;
49
- /** Read one regular file only when both lexical and physical paths stay in root. */
50
- export declare function readContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<Buffer | null>;
51
49
  /**
52
- * Whether a contained path is confirmed absent. A present entry that readContainedRegularFile
53
- * refuses or fails to read is not absent.
50
+ * A file in a run directory or a preflight journal. verify reads every run file whole to scan it,
51
+ * and a frame whole to check it, so this covers the largest screenshot src/evidence/image.ts
52
+ * accepts (32 MiB). Every other reader of a run file uses the same limit, so no command reads a
53
+ * run file that verify could not scan.
54
54
  */
55
- export declare function containedPathAbsent(rootInput: PreparedOutputRoot, relativePath: string): Promise<boolean>;
55
+ export declare const RUN_ARTIFACT_MAX_BYTES: number;
56
+ /** `.humanish/runs/latest.json`: a run id and a path. */
57
+ export declare const LATEST_POINTER_MAX_BYTES: number;
58
+ /** A file a person writes in the project: study, persona and scenario YAML, package.json, agent instructions. */
59
+ export declare const PROJECT_FILE_MAX_BYTES: number;
60
+ /**
61
+ * Why a contained read did not read a file that is there. `too-large`: it holds more than the
62
+ * limit. `changed`: it, its folder or the root changed while it was opened or read, which
63
+ * includes growing past the limit. `directory`: a folder is at the path. `not-regular`: a link, a
64
+ * hard link or a special file. `unsafe-path`: the path leaves the root or passes through a link.
65
+ * `unreadable`: the system refused the open or the read.
66
+ */
67
+ export type ContainedRefusalReason = "too-large" | "changed" | "directory" | "not-regular" | "unsafe-path" | "unreadable";
68
+ /** A file that is there and was not read: why, and the most the read would have taken. */
69
+ export interface ContainedRefusal {
70
+ readonly status: "refused";
71
+ readonly reason: ContainedRefusalReason;
72
+ readonly limit: number;
73
+ }
74
+ /** What a contained read found: the bytes, nothing at the path, or a file it refused. */
75
+ export type ContainedRead = {
76
+ readonly status: "read";
77
+ readonly bytes: Buffer;
78
+ } | {
79
+ readonly status: "missing";
80
+ } | ContainedRefusal;
81
+ /** `relativePath` and why it was refused, as a clause: "x.yaml is larger than 4194304 bytes, ...". */
82
+ export declare function refusalText(relativePath: string, refusal: ContainedRefusal): string;
83
+ /** A refused contained read, for a caller that stops on one. The message names the file. */
84
+ export declare class ContainedReadRefusedError extends Error {
85
+ readonly relativePath: string;
86
+ readonly refusal: ContainedRefusal;
87
+ constructor(relativePath: string, refusal: ContainedRefusal);
88
+ }
89
+ /**
90
+ * Read one regular file only when both lexical and physical paths stay in root and it holds at
91
+ * most `maxBytes`. A larger file is refused without being read, and one that grows past
92
+ * `maxBytes` while it is read is refused after at most `maxBytes + 1` bytes. `missing` means
93
+ * nothing is at the path, or a folder on it is missing.
94
+ */
95
+ export declare function readContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string, maxBytes: number): Promise<ContainedRead>;
96
+ /**
97
+ * At most `maxBytes` from the start of an opened file, or null when it holds more. It reads by
98
+ * position, in chunks of up to 64 KiB, and stops one byte past the limit, so a file that grew
99
+ * after it was opened is never read whole.
100
+ */
101
+ export declare function readOpenedAtMost(handle: FileHandle, maxBytes: number): Promise<Buffer | null>;
56
102
  /** The caller owns this checked descriptor and must close it after reading/streaming. */
57
103
  export declare function openContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<FileHandle | null>;
58
104
  export declare function assertSafeOutputPathSegment(value: string, label?: string): void;
105
+ /** A non-empty relative path with no empty, `.` or `..` segment: the names a contained read accepts. */
106
+ export declare function isSafeRelativeFilePath(value: string): boolean;