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
@@ -1,10 +1,11 @@
1
1
  // A run's analysis findings for `review`, `analyze show` and the end of a live `run`: the
2
- // `humanish.analysis-findings.v1` projection and its text. The analysis artifact stays the record;
3
- // this reads the version `analyze show` selects and never starts an analysis.
2
+ // `humanish.analysis-findings.v1` projection, read from the run. findings-text.ts prints it. The
3
+ // analysis artifact stays the record; this reads the version `analyze show` selects and never
4
+ // starts an analysis.
4
5
  import path from "node:path";
5
6
  import { costRefusal } from "../analysis/admission.js";
6
7
  import { DEFAULT_ANALYSIS_MAX_COST_USD } from "../analysis/automatic-config.js";
7
- import { bySeverity, findingLead } from "../analysis/finding-lead.js";
8
+ import { bySeverity } from "../analysis/finding-lead.js";
8
9
  import { loadAnalysis } from "../analysis/load.js";
9
10
  import { projectShareCheckedAnalysis } from "../analysis/sharing.js";
10
11
  import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
@@ -15,6 +16,7 @@ import { routeOf } from "../study/routing.js";
15
16
  import { plural } from "../run/text.js";
16
17
  import { shellQuote } from "../substrates/shell.js";
17
18
  import { analysisOutcomeText, wantsJson } from "./io.js";
19
+ import { formatFindingsSummary } from "./findings-text.js";
18
20
  import { cli } from "./invocation.js";
19
21
  export const ANALYSIS_FINDINGS_SCHEMA = "humanish.analysis-findings.v1";
20
22
  /** ` --cwd <dir>`, or "" when `cwd` is the current directory. */
@@ -287,170 +289,6 @@ export async function readRunFindings(cwd, run, analysisId) {
287
289
  const source = await readRunAnalysis(cwd, run, analysisId);
288
290
  return source ? analysisFindings(source) : missingRunFindings(cwd, run);
289
291
  }
290
- const IMPACT_TEXT = {
291
- blocked_task: "blocked task",
292
- friction: "friction",
293
- recovery: "recovery",
294
- uncertain: "uncertain",
295
- };
296
- const RECOVERY_TEXT = {
297
- recovered: "recovered",
298
- not_observed: "no recovery observed",
299
- unknown: "recovery unknown",
300
- };
301
- /** `+1:05` from milliseconds since the first retained capture. */
302
- function elapsedText(ms) {
303
- const seconds = Math.floor(ms / 1000);
304
- const minutes = Math.floor(seconds / 60);
305
- const hours = Math.floor(minutes / 60);
306
- const ss = String(seconds % 60).padStart(2, "0");
307
- return hours > 0
308
- ? `+${hours}:${String(minutes % 60).padStart(2, "0")}:${ss}`
309
- : `+${minutes}:${ss}`;
310
- }
311
- /** One participant's cited moments: each frame once, at its earliest cited time. */
312
- function momentsText(evidence) {
313
- const frames = new Map();
314
- let unframed = 0;
315
- for (const entry of evidence) {
316
- if (entry.frame === null) {
317
- unframed++;
318
- continue;
319
- }
320
- const earlier = frames.get(entry.frame);
321
- if (earlier === undefined ||
322
- (entry.elapsedMs !== null && (earlier ?? Infinity) > entry.elapsedMs))
323
- frames.set(entry.frame, entry.elapsedMs);
324
- }
325
- const parts = [...frames]
326
- .sort(([a], [b]) => a - b)
327
- .map(([frame, ms]) => `frame ${frame}${ms === null ? "" : ` at ${elapsedText(ms)}`}`);
328
- if (unframed > 0)
329
- parts.push(`${plural(unframed, "item")} with no frame`);
330
- return parts.join(", ");
331
- }
332
- /** The capture files, relative to the run directory, in frame order. */
333
- function captureFiles(evidence, runRoot) {
334
- return [
335
- ...new Set(evidence
336
- .filter((entry) => entry.capture !== null)
337
- .sort((a, b) => (a.frame ?? 0) - (b.frame ?? 0))
338
- .map((entry) => (runRoot ? path.relative(runRoot, entry.capture) : entry.capture))),
339
- ];
340
- }
341
- function findingLines(finding, runRoot) {
342
- const cited = [...new Set(finding.evidence.map((entry) => entry.streamId))];
343
- const labels = new Map(finding.affected.map((p) => [p.streamId, p.label]));
344
- const affectedIds = finding.affected.map((p) => p.streamId);
345
- const streams = [...affectedIds, ...cited.filter((id) => !labels.has(id))];
346
- const captures = captureFiles(finding.evidence, runRoot);
347
- const correction = finding.correction;
348
- const lead = findingLead(finding, correction);
349
- return [
350
- ...(lead === null
351
- ? [`${finding.id} ${finding.title}`]
352
- : [
353
- `${finding.id} ${lead.headline}`,
354
- ...(lead.account === null ? [] : [` ${lead.account}`]),
355
- ` evidence: ${finding.title}`,
356
- ]),
357
- ` impact: ${IMPACT_TEXT[finding.impact]} · confidence: ${finding.confidence} · recovery: ${RECOVERY_TEXT[finding.recovery]}`,
358
- ` ${finding.summary}`,
359
- ` affected: ${finding.affected.length} of ${plural(finding.exposedCount, "exposed participant")}`,
360
- ...streams.map((streamId) => {
361
- const moments = momentsText(finding.evidence.filter((entry) => entry.streamId === streamId));
362
- const label = `${labels.get(streamId) ?? streamId}${labels.has(streamId) ? "" : " (cited, not affected)"}`;
363
- return ` - ${label}${moments ? `: ${moments}` : ""}`;
364
- }),
365
- ...(captures.length === 0 ? [] : [` captures: ${captures.join(", ")}`]),
366
- ` next step: ${finding.nextStep}`,
367
- ...(correction === null
368
- ? []
369
- : [
370
- ` human review: ${correction.status}, ${correction.reason}${correction.replacementClaim ? ` Amended claim: ${correction.replacementClaim}` : ""}`,
371
- ]),
372
- ];
373
- }
374
- function designFindingLines(finding, runRoot) {
375
- const captures = captureFiles(finding.evidence, runRoot);
376
- return [
377
- `${finding.id} ${finding.severity}: ${finding.headline}`,
378
- ` screen: ${finding.screen}`,
379
- ` notice: ${finding.notice}`,
380
- ` why it matters: ${finding.whyItMatters}`,
381
- ` suggestion: ${finding.suggestion}`,
382
- ` seen by: ${finding.seenBy.map((p) => p.label).join(", ")} · confidence: ${finding.confidence}`,
383
- ...(captures.length === 0 ? [] : [` captures: ${captures.join(", ")}`]),
384
- ];
385
- }
386
- /** Nothing for an analysis written before design findings. */
387
- function designSection(view) {
388
- const design = view.designFindings;
389
- if (design === null)
390
- return [];
391
- if (design.length === 0)
392
- return ["", "design findings: none in the reviewed captures"];
393
- return [
394
- "",
395
- `design findings: ${design.length}, most severe first`,
396
- ...design.flatMap((finding, index) => [
397
- ...(index === 0 ? [] : [""]),
398
- ...designFindingLines(finding, view.runPath ?? ""),
399
- ]),
400
- ];
401
- }
402
- /** The full findings block: the analysis, every finding with its evidence, and its limitations. */
403
- export function formatFindings(view) {
404
- if (view.state !== "ready" && view.state !== "stale")
405
- return [`findings: none. ${view.message}`, ...(view.next ? [`next: ${view.next}`] : [])];
406
- const model = [view.provider, view.model].filter(Boolean).join(" ");
407
- const cost = view.estimatedCostUsd === null ? "" : `, estimated $${view.estimatedCostUsd.toFixed(2)}`;
408
- return [
409
- `findings: ${view.findings.length} from analysis ${view.analysisId} (${view.status}, ${model}${cost})`,
410
- ...(view.state === "stale" ? [`warning: ${view.message}`, `next: ${view.next}`] : []),
411
- ...(view.summary ? [view.summary] : []),
412
- ...view.findings.flatMap((finding) => ["", ...findingLines(finding, view.runPath ?? "")]),
413
- ...designSection(view),
414
- ...(view.limitations.length === 0
415
- ? []
416
- : ["", "limitations:", ...view.limitations.map((limitation) => `- ${limitation}`)]),
417
- ...view.warnings.map((warning) => `warning: ${warning}`),
418
- "",
419
- `analysis: ${view.path}`,
420
- ];
421
- }
422
- /** At most `limit` findings, one line each, and the command that prints all of them. */
423
- export function formatFindingsSummary(view, fullCommand, limit = 3) {
424
- if (view.state !== "ready" && view.state !== "stale")
425
- return [`findings: ${view.message}`, ...(view.next ? [`next: ${view.next}`] : [])];
426
- const shown = view.findings.slice(0, limit);
427
- const design = view.designFindings;
428
- return [
429
- `findings: ${view.findings.length === 0 ? "none" : view.findings.length}${view.state === "stale" ? " (stale)" : ""}`,
430
- ...shown.map((finding) => {
431
- const qualities = `${IMPACT_TEXT[finding.impact]}, ${finding.confidence} confidence, ${RECOVERY_TEXT[finding.recovery]}`;
432
- const lead = findingLead(finding, finding.correction);
433
- if (lead === null)
434
- return `- ${finding.id} ${qualities}: ${finding.title}`;
435
- return `- ${finding.id} ${lead.headline} (${lead.corrected ? "corrected in human review" : qualities})`;
436
- }),
437
- ...(view.findings.length > limit
438
- ? [`- and ${plural(view.findings.length - limit, "more finding")}`]
439
- : []),
440
- ...(design === null
441
- ? []
442
- : [
443
- `design findings: ${design.length === 0 ? "none" : design.length}`,
444
- ...design
445
- .slice(0, limit)
446
- .map((finding) => `- ${finding.id} ${finding.severity}: ${finding.headline}`),
447
- ...(design.length > limit
448
- ? [`- and ${plural(design.length - limit, "more design finding")}`]
449
- : []),
450
- ]),
451
- `all findings: ${fullCommand}`,
452
- ];
453
- }
454
292
  /**
455
293
  * The end of a live run's human output, after its `analysis:` line: the findings of the analysis
456
294
  * the run just completed. Prints nothing for --json, or when the run's analysis did not complete,
package/dist/cli/io.js CHANGED
@@ -186,7 +186,12 @@ export function writeResult(command, io, result, formatHuman) {
186
186
  }
187
187
  else {
188
188
  writeHuman(command, io, formatHuman(output));
189
- if (output !== null && typeof output === "object" && "automaticAnalysis" in output) {
189
+ // A run refused before it was created has no analysis to report, so its output ends with the
190
+ // refusal. Its JSON keeps the skip record.
191
+ if (output !== null &&
192
+ typeof output === "object" &&
193
+ "automaticAnalysis" in output &&
194
+ output.runId !== "not-created") {
190
195
  const analysis = output.automaticAnalysis;
191
196
  const cost = analysis?.result?.admission && refusedCost(analysis.result.admission);
192
197
  const refusal = cost && costRefusal(cost, `--run ${analysis.result?.run ?? "latest"}`, cli);
@@ -61,7 +61,9 @@ function dayPassed(timestamp, now) {
61
61
  * Only a person at a terminal is told, and only when nothing has switched the check off. A pipe,
62
62
  * CI and a JSON document have a program reading them. A study participant runs the version the
63
63
  * study chose, and a notice would send it to another. The telemetry switches turn this off too:
64
- * someone who opted out of telemetry expects no unprompted requests.
64
+ * someone who opted out of telemetry expects no unprompted requests. NO_UPDATE_NOTIFIER is the
65
+ * switch the update-notifier package reads, and it is read the same way, so any value, even `0`,
66
+ * turns the check off for someone who set it for the CLIs built on that package.
65
67
  */
66
68
  function checkAllowed(context) {
67
69
  const env = context.env;
@@ -70,6 +72,7 @@ function checkAllowed(context) {
70
72
  !context.ownCheckout &&
71
73
  !envFlag(env.CI) &&
72
74
  !envFlag(env.HUMANISH_NO_UPDATE_CHECK) &&
75
+ env.NO_UPDATE_NOTIFIER === undefined &&
73
76
  !envFlag(env.HUMANISH_STUDY_PARTICIPANT) &&
74
77
  !disabledByEnvironment(env));
75
78
  }
@@ -54,12 +54,13 @@ export async function readCommsConnections(cwd) {
54
54
  return empty();
55
55
  if ((await lstat(file)).size > 65_536)
56
56
  throw new Error("Connection configuration is too large.");
57
- const bytes = await readContainedRegularFile(root, "comms.yaml");
58
- if (!bytes || bytes.length > 65_536)
57
+ const read = await readContainedRegularFile(root, "comms.yaml", 65_536);
58
+ // Gone since the check above, or refused: either way it cannot be read safely.
59
+ if (read.status !== "read")
59
60
  throw new Error("Cannot safely read connection configuration.");
60
61
  // YAML diagnostics may echo the offending value; do not let them reach a status or frame.
61
62
  try {
62
- return parseConnections(parse(bytes.toString("utf8"), { maxAliasCount: 0 }));
63
+ return parseConnections(parse(read.bytes.toString("utf8"), { maxAliasCount: 0 }));
63
64
  }
64
65
  catch {
65
66
  throw new Error("Invalid connection configuration.");
@@ -13,6 +13,14 @@ import { resolveStudyManifest } from "../study/discover.js";
13
13
  import { assertPreparedSelectedOutputDirectory, prepareManagedHumanishOutputDirectory, prepareSelectedOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../run/contained-output.js";
14
14
  import { otherStudyFiles } from "../study/files.js";
15
15
  import { isNodeError } from "../run/type-guards.js";
16
+ /** The most of a study file the receiving copy reads, for its source and its destination. */
17
+ const MAX_STUDY_COPY_BYTES = 1024 * 1024;
18
+ /** The read found exactly `expected`, or found nothing where `expected` is null. */
19
+ function readsAs(read, expected) {
20
+ return expected === null
21
+ ? read.status === "missing"
22
+ : read.status === "read" && read.bytes.equals(expected);
23
+ }
16
24
  export async function checkCommsConnection(args) {
17
25
  const base = {
18
26
  schema: "humanish.comms-check.v1",
@@ -128,9 +136,10 @@ export async function configureCommsStudy(args) {
128
136
  const rel = path
129
137
  .relative(root.requestedPath, path.resolve(args.cwd, source.path))
130
138
  .replace(/\\/g, "/");
131
- const bytes = await readContainedRegularFile(root, rel);
132
- if (!bytes || bytes.length > 1024 * 1024)
139
+ const sourceRead = await readContainedRegularFile(root, rel, MAX_STUDY_COPY_BYTES);
140
+ if (sourceRead.status !== "read")
133
141
  return { ...base, message: "The study file is missing or too large." };
142
+ const bytes = sourceRead.bytes;
134
143
  const text = bytes.toString("utf8");
135
144
  const raw = parse(text);
136
145
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -182,13 +191,12 @@ export async function configureCommsStudy(args) {
182
191
  ...base,
183
192
  message: `${others.join(", ")} already uses the name ${stem}. Rename or remove it before configuring email, so \`run ${stem}\` reads one file.`,
184
193
  };
185
- let prior;
186
- try {
187
- prior = await readContainedRegularFile(root, destination);
188
- }
189
- catch {
194
+ // An existing destination is replaced only when its bytes are in the plan token, so one that
195
+ // cannot be read whole is refused instead of being taken as absent.
196
+ const destinationRead = await readContainedRegularFile(root, destination, MAX_STUDY_COPY_BYTES);
197
+ if (destinationRead.status === "refused")
190
198
  return { ...base, message: "The local destination could not be read safely." };
191
- }
199
+ const prior = destinationRead.status === "read" ? destinationRead.bytes : null;
192
200
  const content = String(study);
193
201
  const token = createHash("sha256")
194
202
  .update(bytes)
@@ -219,10 +227,8 @@ export async function configureCommsStudy(args) {
219
227
  const lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
220
228
  const identity = await lock.stat();
221
229
  try {
222
- const currentSource = await readContainedRegularFile(root, rel);
223
- const currentDestination = await readContainedRegularFile(root, destination);
224
- if (!currentSource?.equals(bytes) ||
225
- (prior === null ? currentDestination !== null : !currentDestination?.equals(prior)))
230
+ const reread = (file) => readContainedRegularFile(root, file, MAX_STUDY_COPY_BYTES);
231
+ if (!readsAs(await reread(rel), bytes) || !readsAs(await reread(destination), prior))
226
232
  return { ...base, message: "Files changed while saving. Preview again." };
227
233
  await writeContainedOutputFile(directory, filename, content, "utf8");
228
234
  }
@@ -182,7 +182,15 @@ export function containsSensitive(text) {
182
182
  // suffix so redaction cannot turn a string's contents into JSON delimiters.
183
183
  // This also works when the closing quote arrives in a later stream callback.
184
184
  function redactLocalPaths(text, label) {
185
- return LOCAL_PATH_PATTERNS.reduce((current, [pattern, replacement]) => current.replace(pattern, (match) => (label ?? replacement) + (match.match(/\\+$/)?.[0] ?? "")), text);
185
+ return LOCAL_PATH_PATTERNS.reduce((current, [pattern, replacement]) => current.replace(pattern, (match) => (label ?? replacement) + trailingBackslashes(match)), text);
186
+ }
187
+ // Read from the end: `/\\+$/` retries a run of backslashes from each of its characters when
188
+ // something follows the run, which took 1.4 s on a path holding 40,000 of them.
189
+ function trailingBackslashes(text) {
190
+ let start = text.length;
191
+ while (start > 0 && text.charCodeAt(start - 1) === 0x5c)
192
+ start -= 1;
193
+ return text.slice(start);
186
194
  }
187
195
  /**
188
196
  * What a shared copy of a run says in place of a provider sandbox id. scripts/public-surface-scan.mjs
@@ -1,12 +1,22 @@
1
1
  /**
2
2
  * A value as written, and percent-encoded, JSON-escaped once (also with non-ASCII characters as
3
- * `\u` escapes) and twice, its UTF-8 bytes read one per character and the reverse, base64 at each
4
- * byte offset, base64url and hex. Twice, because a JSON event can carry a command's JSON output as
5
- * a string. An
6
- * escaped form is searched for at any length, as the value is: it holds a backslash or a `%`, so it
7
- * is not ordinary text.
3
+ * `\u` escapes) and twice, its UTF-8 bytes read one per character and the reverse, base64 padded,
4
+ * unpadded and at each byte offset, base64url and hex. Twice, because a JSON event can carry a
5
+ * command's JSON output as a string. An escaped form is searched for at any length, as the value
6
+ * is: it holds a backslash or a `%`, so it is not ordinary text. A base64 or hex form is searched
7
+ * for from MIN_ENCODED_FORM characters, so a four-byte value is found in base64 written whole and
8
+ * not inside a longer run, where four or five of its characters do not depend on its neighbours.
8
9
  */
9
10
  export declare function encodedForms(value: string): string[];
11
+ /**
12
+ * A scrub that replaces each non-empty value as written with `[REDACTED_SECRET]`, markers
13
+ * included. From the start of the text, the longest value that starts at a position is replaced
14
+ * and the search goes on after it, so the marker it writes is never searched. One global regex of
15
+ * the values, longest first, does that fastest. V8 compiles it at its first use and refuses it
16
+ * once a value has 32,768 characters; the scrub then finds each value itself, with the same
17
+ * output, in time linear in the text for each value.
18
+ */
19
+ export declare function scrubValuesAsWritten(values: readonly string[]): (text: string) => string;
10
20
  /**
11
21
  * A check that the text holds one of the values' forms outside the markers in any of its
12
22
  * readingsOf. A scrub's output must pass it.
@@ -10,9 +10,12 @@ const REDACTED = REDACTION_MARKERS.secret;
10
10
  // of the marker's name. Other bracketed text, such as `[REDACTED_373433393231]`, is searched like
11
11
  // any text.
12
12
  const MARKER = new RegExp(Object.values(REDACTION_MARKERS).map(escapeRegExp).join("|"), "g");
13
- // The shortest encoded form searched for. Shorter base64 or hex runs are ordinary text often enough
14
- // that matching them would redact words. A value itself is searched for at any length.
15
- const MIN_ENCODED_FORM = 8;
13
+ // The shortest encoded form searched for: the unpadded base64 of a four-byte value, such as a
14
+ // four-digit code. A given form of n characters turns up by chance in random base64 about once in
15
+ // 64^n characters, once in 69 billion at 6 and once in 17 million at 4. In the text of 9 real runs
16
+ // (2.5 million characters) no 6- or 7-character form of any 4-, 5- or 6-digit code occurred. A
17
+ // value itself is searched for at any length.
18
+ const MIN_ENCODED_FORM = 6;
16
19
  /**
17
20
  * The characters of a value's base64 encoding that do not depend on its neighbours, with 0, 1 or
18
21
  * 2 bytes before it. A prefix byte shares the next character with the value, and so does the byte
@@ -31,11 +34,12 @@ function base64Middles(bytes, encoding) {
31
34
  }
32
35
  /**
33
36
  * A value as written, and percent-encoded, JSON-escaped once (also with non-ASCII characters as
34
- * `\u` escapes) and twice, its UTF-8 bytes read one per character and the reverse, base64 at each
35
- * byte offset, base64url and hex. Twice, because a JSON event can carry a command's JSON output as
36
- * a string. An
37
- * escaped form is searched for at any length, as the value is: it holds a backslash or a `%`, so it
38
- * is not ordinary text.
37
+ * `\u` escapes) and twice, its UTF-8 bytes read one per character and the reverse, base64 padded,
38
+ * unpadded and at each byte offset, base64url and hex. Twice, because a JSON event can carry a
39
+ * command's JSON output as a string. An escaped form is searched for at any length, as the value
40
+ * is: it holds a backslash or a `%`, so it is not ordinary text. A base64 or hex form is searched
41
+ * for from MIN_ENCODED_FORM characters, so a four-byte value is found in base64 written whole and
42
+ * not inside a longer run, where four or five of its characters do not depend on its neighbours.
39
43
  */
40
44
  export function encodedForms(value) {
41
45
  const bytes = Buffer.from(value, "utf8");
@@ -51,8 +55,10 @@ export function encodedForms(value) {
51
55
  catch {
52
56
  // no percent-encoded form
53
57
  }
58
+ const base64 = bytes.toString("base64");
54
59
  const binary = [
55
- bytes.toString("base64"),
60
+ base64,
61
+ base64.replace(/=+$/, ""),
56
62
  bytes.toString("base64url"),
57
63
  bytes.toString("hex"),
58
64
  ...base64Middles(bytes, "base64"),
@@ -127,6 +133,58 @@ function eachOccurrence(text, form, found) {
127
133
  at = matched > 0 ? -1 : text.indexOf(form, next);
128
134
  }
129
135
  }
136
+ /**
137
+ * Each value's occurrences, the longest value that starts at a position replaced from the start of
138
+ * the text on, in time linear in the text for each value.
139
+ */
140
+ function replaceLongestFirst(text, values) {
141
+ // The length of the longest value that starts at each position, allocated at the first find.
142
+ let longest;
143
+ for (const value of values)
144
+ eachOccurrence(text, value, (at) => {
145
+ longest ??= new Uint32Array(text.length);
146
+ longest[at] = Math.max(longest[at], value.length);
147
+ });
148
+ if (longest === undefined)
149
+ return text;
150
+ let result = "";
151
+ let cursor = 0;
152
+ for (let at = 0; at < text.length; at += 1) {
153
+ if (longest[at] === 0)
154
+ continue;
155
+ result += text.slice(cursor, at) + REDACTED;
156
+ cursor = at + longest[at];
157
+ at = cursor - 1;
158
+ }
159
+ return result + text.slice(cursor);
160
+ }
161
+ /**
162
+ * A scrub that replaces each non-empty value as written with `[REDACTED_SECRET]`, markers
163
+ * included. From the start of the text, the longest value that starts at a position is replaced
164
+ * and the search goes on after it, so the marker it writes is never searched. One global regex of
165
+ * the values, longest first, does that fastest. V8 compiles it at its first use and refuses it
166
+ * once a value has 32,768 characters; the scrub then finds each value itself, with the same
167
+ * output, in time linear in the text for each value.
168
+ */
169
+ export function scrubValuesAsWritten(values) {
170
+ const written = [...new Set(values)].filter((value) => value.length > 0);
171
+ if (written.length === 0)
172
+ return (text) => text;
173
+ let pattern = new RegExp([...written]
174
+ .sort((left, right) => right.length - left.length)
175
+ .map(escapeRegExp)
176
+ .join("|"), "g");
177
+ return (text) => {
178
+ if (pattern !== undefined)
179
+ try {
180
+ return text.replace(pattern, REDACTED);
181
+ }
182
+ catch {
183
+ pattern = undefined;
184
+ }
185
+ return replaceLongestFirst(text, written);
186
+ };
187
+ }
130
188
  /** Every occurrence of every form not wholly inside a marker, in no order. */
131
189
  function occurrences(text, forms) {
132
190
  const markers = [];
@@ -11,7 +11,7 @@ import { loadRunBundlePrepared } from "../run/locate.js";
11
11
  import { isFeedbackIdempotencyKey } from "../run/feedback-shape.js";
12
12
  import { runNoteFile } from "../run/note-files.js";
13
13
  import { byRunTime } from "../run/note-shape.js";
14
- import { formatRunTime } from "../run/notes.js";
14
+ import { formatRunTime } from "../run/run-clock.js";
15
15
  import { runParticipantCaptions } from "../run/participant-records.js";
16
16
  import { isRecord } from "../run/type-guards.js";
17
17
  import { feedbackProofCommands, projectFeedbackAcceptanceProof } from "./proof.js";
@@ -12,16 +12,16 @@ import { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js"
12
12
  import { containsSensitive, redactSandboxIds, redactScreenshot, redactText, } from "../evidence/redaction.js";
13
13
  import { decodeEscapes, readingsOf } from "../evidence/encoded-text.js";
14
14
  import { readPlainText } from "../evidence/plain-text.js";
15
- import { streamScreenshotPaths } from "../verify/artifacts.js";
15
+ import { MAX_RUN_ENTRIES, streamScreenshotPaths } from "../verify/artifacts.js";
16
16
  import { verifyRunPrepared } from "../verify/verify.js";
17
17
  import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
18
18
  import {} from "../run/bundle.js";
19
19
  import { runIdOf, isPathInside, prepareRunArtifactPaths, validatePreparedRunArtifactPaths, } from "../run/paths.js";
20
20
  import { isAnalysisRecordPath } from "../analysis/sharing.js";
21
21
  import { LOCAL_ONLY_RUN_FILES } from "../run/local-only-files.js";
22
- import { assertPreparedSelectedOutputDirectory, prepareManagedHumanishOutputDirectory, prepareSelectedOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../run/contained-output.js";
22
+ import { readUnchangedFile } from "../run/evidence-files.js";
23
+ import { assertPreparedSelectedOutputDirectory, prepareManagedHumanishOutputDirectory, prepareSelectedOutputDirectory, writeContainedOutputFile, } from "../run/contained-output.js";
23
24
  const DERIVATION_SCHEMA = "humanish.redacted-derivation.v1";
24
- const MAX_FILES = 10_000;
25
25
  const TEXT_EXTENSIONS = new Set([
26
26
  ".json",
27
27
  ".ndjson",
@@ -69,8 +69,8 @@ async function inventory(root, maxBytes) {
69
69
  if (!before.isDirectory() || before.isSymbolicLink())
70
70
  throw new Error("Source contains an unsafe directory.");
71
71
  for (const name of (await readdir(directory)).sort()) {
72
- if (++entriesSeen > MAX_FILES)
73
- throw new Error(`Source exceeds ${MAX_FILES} inventory entries.`);
72
+ if (++entriesSeen > MAX_RUN_ENTRIES)
73
+ throw new Error(`Source exceeds ${MAX_RUN_ENTRIES} inventory entries.`);
74
74
  if (name.includes("\\") || name.includes("\0"))
75
75
  throw new Error("Source contains an unsafe path segment.");
76
76
  const rel = relative ? `${relative}/${name}` : name;
@@ -86,15 +86,17 @@ async function inventory(root, maxBytes) {
86
86
  else {
87
87
  if (path.extname(rel).toLowerCase() === ".mp4")
88
88
  throw new Error("Continuous video/audio cannot be redacted by bundle export. Use --local-only HTML export for a snapshot-only copy.");
89
- if (info.size > BigInt(maxBytes - total))
90
- throw new Error("Source inventory exceeds --max-bytes.");
91
- const bytes = await readContainedRegularFile(root, rel);
92
- if (bytes === null)
89
+ // At most what is left of --max-bytes, so a file that grows after this lstat is refused
90
+ // unread. With nothing left, an empty file still reads and any other is refused.
91
+ const read = await readUnchangedFile(root, rel, Math.max(1, maxBytes - total));
92
+ if (read.state === "unavailable")
93
93
  throw new Error("Source artifact could not be read through its bound identity.");
94
- total += bytes.length;
94
+ if (read.state === "limit")
95
+ throw new Error("Source inventory exceeds --max-bytes.");
96
+ total += read.bytes.length;
95
97
  if (total > maxBytes)
96
98
  throw new Error("Source inventory exceeds --max-bytes.");
97
- files.push({ path: rel, bytes, sha256: hash(bytes) });
99
+ files.push({ path: rel, bytes: read.bytes, sha256: hash(read.bytes) });
98
100
  }
99
101
  }
100
102
  const after = await lstat(directory, { bigint: true });
@@ -541,10 +543,12 @@ async function buildDerivative(stage, source, runId, bundle, maxBytes) {
541
543
  throw new Error("Derivative Observer could not be rebuilt.");
542
544
  const generated = [];
543
545
  for (const relative of ["observer/index.html", "observer/observer-data.json"]) {
544
- const bytes = await readContainedRegularFile(stagePaths, relative);
545
- if (bytes === null)
546
+ const read = await readUnchangedFile(stagePaths, relative, maxBytes);
547
+ if (read.state === "limit")
548
+ throw new Error("The regenerated Observer exceeds --max-bytes.");
549
+ if (read.state === "unavailable")
546
550
  throw new Error("Regenerated Observer artifact could not be read safely.");
547
- generated.push({ path: relative, sha256: hash(bytes) });
551
+ generated.push({ path: relative, sha256: hash(read.bytes) });
548
552
  }
549
553
  await writeContainedOutputFile(stagePaths, "derivation.json", jsonBytes({ ...receipt, generated }));
550
554
  const derivativeVerify = await verifyRunPrepared(stage.physicalPath, runId, stagePaths);
@@ -4,7 +4,7 @@ import { realpath } from "node:fs/promises";
4
4
  import { runNotFoundMessage } from "../run/run-not-found.js";
5
5
  import path from "node:path";
6
6
  import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
7
- import { bindExistingManagedHumanishOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../run/contained-output.js";
7
+ import { bindExistingManagedHumanishOutputDirectory, ContainedReadRefusedError, LATEST_POINTER_MAX_BYTES, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "../run/contained-output.js";
8
8
  import { loadRunBundlePrepared } from "../run/locate.js";
9
9
  import { readNotesForSharing } from "../run/note-files.js";
10
10
  import { cli } from "../cli/invocation.js";
@@ -208,7 +208,9 @@ async function isSafeFeedbackEvidenceFile(context, evidencePath) {
208
208
  path.isAbsolute(relative)) {
209
209
  return false;
210
210
  }
211
- return (await readContainedRegularFile(context.preparedRunPaths, relative)) !== null;
211
+ // Evidence a draft cites must be readable: a refused file is listed with the missing ones.
212
+ const read = await readContainedRegularFile(context.preparedRunPaths, relative, RUN_ARTIFACT_MAX_BYTES);
213
+ return read.status === "read";
212
214
  }
213
215
  export async function renderIssueMarkdown(cwdInput, runInput, repo, options = {}) {
214
216
  const verified = await verifyFeedbackBound(cwdInput, runInput, options);
@@ -253,9 +255,22 @@ export async function listFeedback(cwdInput, runInput) {
253
255
  },
254
256
  };
255
257
  }
256
- const draftBytes = await readContainedRegularFile(context.preparedRunPaths, path.join("feedback", "draft.json"));
257
- const draft = draftBytes === null ? undefined : JSON.parse(draftBytes.toString("utf8"));
258
+ const draftRead = await readContainedRegularFile(context.preparedRunPaths, path.join("feedback", "draft.json"), RUN_ARTIFACT_MAX_BYTES);
258
259
  const draftPath = path.join(context.preparedRunPaths.relativeRunRoot, "feedback", "draft.json");
260
+ if (draftRead.status === "refused")
261
+ return {
262
+ schema: FEEDBACK_RESULT_SCHEMA,
263
+ ok: false,
264
+ cwd,
265
+ run: runInput,
266
+ error: {
267
+ code: "HUMANISH_INVALID_FEEDBACK_DRAFT",
268
+ message: `The feedback draft could not be read: ${refusalText(draftPath, draftRead)}.`,
269
+ },
270
+ };
271
+ const draft = draftRead.status === "missing"
272
+ ? undefined
273
+ : JSON.parse(draftRead.bytes.toString("utf8"));
259
274
  return {
260
275
  schema: FEEDBACK_RESULT_SCHEMA,
261
276
  ok: true,
@@ -284,10 +299,13 @@ async function resolveFeedbackRunContext(cwd, runInput) {
284
299
  const runsRoot = await bindExistingManagedHumanishOutputDirectory(physicalCwd, "runs");
285
300
  if (!runsRoot)
286
301
  return null;
287
- const pointerBytes = await readContainedRegularFile(runsRoot, "latest.json");
288
- if (!pointerBytes)
302
+ const pointerRead = await readContainedRegularFile(runsRoot, "latest.json", LATEST_POINTER_MAX_BYTES);
303
+ if (pointerRead.status === "missing")
289
304
  return null;
290
- const pointer = JSON.parse(pointerBytes.toString("utf8"));
305
+ // A pointer that is there and refused names no run, and saying so beats "run not found".
306
+ if (pointerRead.status === "refused")
307
+ throw new ContainedReadRefusedError(path.join(".humanish", "runs", "latest.json"), pointerRead);
308
+ const pointer = JSON.parse(pointerRead.bytes.toString("utf8"));
291
309
  if (typeof pointer.runId !== "string" ||
292
310
  typeof pointer.path !== "string" ||
293
311
  !resolveLatestRunDirectory(physicalCwd, { path: pointer.path, runId: pointer.runId })) {
@@ -1,5 +1,6 @@
1
1
  import { perceptualSignature } from "../evidence/frame-signature.js";
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
+ import { CUA_WAIT_LIMITS } from "../actors/computer-use/wait.js";
3
4
  import { BROWSER_CONTROL_LIMITS, validateBrowserControlAction, validateBrowserControlPng, } from "../browser-control/protocol.js";
4
5
  import { ComputerUseExecutorError, isComputerUseExecutorError, } from "../actors/computer-use/executor-error.js";
5
6
  import { xdotoolChord, xdotoolHeldModifiers } from "./desktop-keys.js";
@@ -104,8 +105,9 @@ async function executeAction(context, state, value, callerSignal) {
104
105
  throw error;
105
106
  }
106
107
  }
108
+ // The loop sends every wait with its length; planWait owns the lengths.
107
109
  if (action.kind === "wait")
108
- await delay(action.ms ?? 250, undefined, { signal });
110
+ await delay(action.ms ?? CUA_WAIT_LIMITS.settleMs, undefined, { signal });
109
111
  for (const command of commands) {
110
112
  assertOpen(state, signal, progress.dispatched);
111
113
  // No await between authority check and the native port invocation.