humanish 0.114.0 → 0.116.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 (204) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +7 -6
  4. package/TELEMETRY.md +6 -0
  5. package/dist/actors/closing-report-limits.d.ts +11 -0
  6. package/dist/actors/closing-report-limits.js +13 -0
  7. package/dist/actors/closing-report.d.ts +68 -0
  8. package/dist/actors/closing-report.js +55 -0
  9. package/dist/actors/codex/app-server-ui.js +11 -4
  10. package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
  11. package/dist/actors/codex/restricted-participant-policy.js +12 -40
  12. package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
  13. package/dist/actors/computer-use/loop/debrief.js +68 -70
  14. package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
  15. package/dist/actors/computer-use/loop/impressions.js +6 -1
  16. package/dist/actors/computer-use/loop.d.ts +0 -1
  17. package/dist/actors/computer-use/loop.js +3 -3
  18. package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
  19. package/dist/actors/computer-use/openai-conversation.js +3 -29
  20. package/dist/actors/computer-use/openai-provider.js +11 -5
  21. package/dist/actors/contract.d.ts +2 -20
  22. package/dist/actors/contract.js +0 -13
  23. package/dist/actors/scripted-browser/steps.js +6 -5
  24. package/dist/analysis/admission.d.ts +14 -4
  25. package/dist/analysis/admission.js +14 -9
  26. package/dist/analysis/analysis-limits.d.ts +35 -0
  27. package/dist/analysis/analysis-limits.js +41 -0
  28. package/dist/analysis/execute.d.ts +5 -0
  29. package/dist/analysis/execute.js +3 -0
  30. package/dist/analysis/finding-lead.d.ts +27 -0
  31. package/dist/analysis/finding-lead.js +23 -0
  32. package/dist/analysis/service.js +5 -3
  33. package/dist/analysis/validation.js +14 -21
  34. package/dist/cli/commands/analyze.js +2 -1
  35. package/dist/cli/commands/notes.js +5 -3
  36. package/dist/cli/commands/run-signals.js +5 -1
  37. package/dist/cli/commands/runs.js +2 -1
  38. package/dist/cli/commands/setup.js +3 -3
  39. package/dist/cli/commands/study-format.d.ts +5 -2
  40. package/dist/cli/commands/study-format.js +29 -9
  41. package/dist/cli/commands/study-route-computer-use.js +2 -2
  42. package/dist/cli/commands/study-route-scripted.js +1 -1
  43. package/dist/cli/commands/tui.js +59 -17
  44. package/dist/cli/doctor.js +9 -2
  45. package/dist/cli/findings-text.d.ts +5 -0
  46. package/dist/cli/findings-text.js +169 -0
  47. package/dist/cli/findings.d.ts +0 -4
  48. package/dist/cli/findings.js +11 -195
  49. package/dist/cli/io.js +10 -8
  50. package/dist/cli/telemetry.js +10 -22
  51. package/dist/cli/update-check.js +10 -19
  52. package/dist/cli/user-config.d.ts +11 -0
  53. package/dist/cli/user-config.js +25 -0
  54. package/dist/comms/connections.js +4 -3
  55. package/dist/comms/email-catch.js +7 -31
  56. package/dist/comms/setup.js +18 -12
  57. package/dist/evidence/encoded-text.d.ts +12 -11
  58. package/dist/evidence/encoded-text.js +81 -70
  59. package/dist/evidence/redaction.d.ts +11 -0
  60. package/dist/evidence/redaction.js +30 -11
  61. package/dist/evidence/secret-scrub.d.ts +26 -3
  62. package/dist/evidence/secret-scrub.js +172 -38
  63. package/dist/feedback/draft.d.ts +1 -1
  64. package/dist/feedback/draft.js +13 -16
  65. package/dist/feedback/export-bundle.js +21 -17
  66. package/dist/feedback/export.js +32 -46
  67. package/dist/feedback/feedback.js +31 -26
  68. package/dist/guest/desktop-keys.js +59 -5
  69. package/dist/keys/key-resolution.d.ts +9 -0
  70. package/dist/keys/key-resolution.js +62 -55
  71. package/dist/keys/key-status.d.ts +2 -0
  72. package/dist/keys/key-status.js +9 -16
  73. package/dist/observer/artifact.d.ts +1 -1
  74. package/dist/observer/http.d.ts +7 -0
  75. package/dist/observer/http.js +19 -1
  76. package/dist/observer/notes-route.d.ts +0 -1
  77. package/dist/observer/notes-route.js +24 -44
  78. package/dist/observer/pinned-files.js +4 -19
  79. package/dist/observer/render.js +6 -4
  80. package/dist/observer/run-routes.js +13 -28
  81. package/dist/observer/serve.js +2 -1
  82. package/dist/observer-app.html +7 -7
  83. package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
  84. package/dist/routes/computer-use/bundle-parts.js +2 -2
  85. package/dist/routes/computer-use/bundle.d.ts +4 -5
  86. package/dist/routes/computer-use/bundle.js +1 -3
  87. package/dist/routes/computer-use/fanout-records.js +10 -11
  88. package/dist/routes/computer-use/live-phase.d.ts +1 -1
  89. package/dist/routes/computer-use/live-phase.js +0 -1
  90. package/dist/routes/computer-use/participant-execution.d.ts +3 -2
  91. package/dist/routes/computer-use/participant-runs.d.ts +6 -2
  92. package/dist/routes/computer-use/participant-runs.js +9 -5
  93. package/dist/routes/computer-use/plan.d.ts +3 -0
  94. package/dist/routes/computer-use/plan.js +14 -11
  95. package/dist/routes/computer-use/rerun-selection.js +2 -2
  96. package/dist/routes/computer-use/result.js +3 -6
  97. package/dist/routes/computer-use/setup.d.ts +4 -6
  98. package/dist/routes/computer-use/setup.js +2 -6
  99. package/dist/routes/computer-use/single-bundle.d.ts +4 -5
  100. package/dist/routes/computer-use/single-bundle.js +22 -25
  101. package/dist/routes/computer-use/subject-projection.d.ts +4 -8
  102. package/dist/routes/computer-use/subject-projection.js +9 -31
  103. package/dist/routes/computer-use/types.d.ts +4 -41
  104. package/dist/routes/scripted/scenario.js +28 -12
  105. package/dist/routes/scripted/session-result.js +6 -3
  106. package/dist/routes/shared-world/bundle-records.js +2 -8
  107. package/dist/routes/shared-world/participant-specs.d.ts +0 -2
  108. package/dist/routes/shared-world/participant-specs.js +1 -20
  109. package/dist/routes/shared-world/plan.d.ts +3 -0
  110. package/dist/routes/shared-world/plan.js +23 -3
  111. package/dist/routes/shared-world/setup.js +2 -2
  112. package/dist/routes/terminal/ledger.d.ts +9 -9
  113. package/dist/routes/terminal/ledger.js +63 -40
  114. package/dist/routes/terminal/live-finish.js +7 -10
  115. package/dist/routes/terminal/plan.d.ts +3 -0
  116. package/dist/routes/terminal/plan.js +8 -5
  117. package/dist/routes/terminal/token-usage.d.ts +1 -2
  118. package/dist/routes/terminal/token-usage.js +4 -6
  119. package/dist/routes/terminal/types.d.ts +9 -1
  120. package/dist/run/bundle-shape.js +6 -1
  121. package/dist/run/bundle.d.ts +5 -3
  122. package/dist/run/contained-output.d.ts +59 -10
  123. package/dist/run/contained-output.js +160 -68
  124. package/dist/run/detail.d.ts +2 -1
  125. package/dist/run/detail.js +12 -7
  126. package/dist/run/escape-sequences.d.ts +11 -0
  127. package/dist/run/escape-sequences.js +93 -0
  128. package/dist/run/evidence-files.d.ts +8 -1
  129. package/dist/run/evidence-files.js +24 -18
  130. package/dist/run/locate.d.ts +32 -3
  131. package/dist/run/locate.js +45 -40
  132. package/dist/run/note-files.d.ts +26 -45
  133. package/dist/run/note-files.js +86 -58
  134. package/dist/run/note-shape.d.ts +35 -0
  135. package/dist/run/note-shape.js +20 -0
  136. package/dist/run/notes.d.ts +3 -14
  137. package/dist/run/notes.js +23 -75
  138. package/dist/run/outcomes.d.ts +24 -0
  139. package/dist/run/outcomes.js +19 -0
  140. package/dist/run/participant-records.d.ts +2 -0
  141. package/dist/run/participant-records.js +17 -2
  142. package/dist/run/paths.d.ts +2 -0
  143. package/dist/run/paths.js +5 -1
  144. package/dist/run/preflight-receipts.js +13 -8
  145. package/dist/run/project.js +7 -4
  146. package/dist/run/reclaim-outcomes.d.ts +8 -6
  147. package/dist/run/reclaim-outcomes.js +10 -6
  148. package/dist/run/reclaim.d.ts +5 -0
  149. package/dist/run/reclaim.js +43 -16
  150. package/dist/run/run-clock.d.ts +50 -0
  151. package/dist/run/run-clock.js +66 -0
  152. package/dist/run/run.js +5 -2
  153. package/dist/run/sandbox-ids.d.ts +7 -2
  154. package/dist/run/sandbox-ids.js +27 -11
  155. package/dist/run/secrets.d.ts +2 -1
  156. package/dist/run/secrets.js +18 -22
  157. package/dist/run/stored-runs.js +14 -7
  158. package/dist/run/transient-comms-secrets.d.ts +6 -5
  159. package/dist/run/transient-comms-secrets.js +23 -39
  160. package/dist/run-study.js +22 -13
  161. package/dist/study/adapter-scorer-loader.js +6 -4
  162. package/dist/study/discover.js +9 -6
  163. package/dist/study/init-paths.js +7 -5
  164. package/dist/study/persona-resolve.d.ts +2 -2
  165. package/dist/study/persona-resolve.js +9 -5
  166. package/dist/study/plan-types.d.ts +2 -2
  167. package/dist/study/plan.d.ts +5 -3
  168. package/dist/study/plan.js +18 -9
  169. package/dist/study/preflight-probes.js +11 -3
  170. package/dist/study/preflight.js +9 -5
  171. package/dist/study/summary.d.ts +12 -7
  172. package/dist/study/summary.js +24 -24
  173. package/dist/study/warnings.d.ts +19 -12
  174. package/dist/study/warnings.js +39 -17
  175. package/dist/substrates/e2b/connection.d.ts +12 -0
  176. package/dist/substrates/e2b/connection.js +35 -0
  177. package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
  178. package/dist/substrates/e2b/desktop-executor.js +42 -9
  179. package/dist/substrates/e2b/lifetime.d.ts +24 -4
  180. package/dist/substrates/e2b/lifetime.js +41 -4
  181. package/dist/substrates/e2b/sandbox.d.ts +4 -3
  182. package/dist/substrates/e2b/sandbox.js +13 -18
  183. package/dist/substrates/e2b/sdk.d.ts +20 -9
  184. package/dist/substrates/local/capacity.d.ts +18 -2
  185. package/dist/substrates/local/capacity.js +21 -2
  186. package/dist/substrates/local/runtime-host.js +21 -14
  187. package/dist/substrates/local/runtime.js +3 -11
  188. package/dist/tui/contract.d.ts +30 -5
  189. package/dist/tui/contract.js +4 -1
  190. package/dist/tui-app.js +149 -147
  191. package/dist/verify/artifacts.d.ts +9 -2
  192. package/dist/verify/artifacts.js +37 -13
  193. package/dist/verify/liveness.js +19 -5
  194. package/dist/verify/verify.js +54 -27
  195. package/docs/architecture/actor-contract.md +3 -3
  196. package/docs/architecture/guest-desktop.md +7 -2
  197. package/docs/architecture/restricted-codex-analysis.md +1 -1
  198. package/docs/architecture/state-driven-executor.md +5 -3
  199. package/docs/contracts/run-bundle.md +25 -13
  200. package/docs/contracts/schemas.md +38 -29
  201. package/docs/contracts/study-analysis.md +22 -5
  202. package/docs/product/automatic-analysis.md +7 -1
  203. package/package.json +8 -7
  204. package/skills/humanish/SKILL.md +1 -2
@@ -2,12 +2,12 @@ import { lstat, readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { redactText } from "../evidence/redaction.js";
4
4
  import { bindExistingRunArtifactPaths, RUNS_RELATIVE_ROOT, isSafeRunIdSegment, resolveRunsRoot, } from "./paths.js";
5
- import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, } from "./contained-output.js";
5
+ import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, refusalText, } from "./contained-output.js";
6
6
  import { RUN_BUNDLE_FILE } from "./bundle.js";
7
7
  import { isReviewSummary, isRunOutcome } from "./bundle-shape.js";
8
8
  import { bundleDisplayFacts, displayedReview, runDisplay, } from "./display.js";
9
9
  import { RUN_STATUS_FILE } from "./status.js";
10
- import { readLatest, readRunJsonIfExists, resolveRunPath } from "./locate.js";
10
+ import { readLatest, readRunJsonIfExists, resolveRunPath, runJsonValue } from "./locate.js";
11
11
  import { withCuaReviewProvenance } from "./outcomes.js";
12
12
  import { isNodeError, isRecord } from "./type-guards.js";
13
13
  import { invalidRunStorageVerifyResult, verifyResolvedRun, } from "../verify/verify.js";
@@ -70,7 +70,8 @@ export async function listRuns(cwdInput) {
70
70
  if (runsRoot && entryRunPaths.physicalRunsRoot !== runsRoot.physicalPath) {
71
71
  return runsUnavailableResult(cwd, new Error("humanish runs root changed physical destination."));
72
72
  }
73
- const bundle = await readRunJsonIfExists(entryRunPaths, RUN_BUNDLE_FILE);
73
+ // A listing shows what it can read: a run.json it refuses lists like a missing one.
74
+ const bundle = runJsonValue(await readRunJsonIfExists(entryRunPaths, RUN_BUNDLE_FILE));
74
75
  runs.push({
75
76
  runId: entryName,
76
77
  createdAt: isRecord(bundle) && typeof bundle.createdAt === "string" ? bundle.createdAt : null,
@@ -105,7 +106,7 @@ async function listedRunDisplay(runPaths, bundle) {
105
106
  if (displayed === undefined)
106
107
  return {};
107
108
  const status = displayed.outcome === undefined
108
- ? await readRunJsonIfExists(runPaths, RUN_STATUS_FILE)
109
+ ? runJsonValue(await readRunJsonIfExists(runPaths, RUN_STATUS_FILE))
109
110
  : undefined;
110
111
  return { display: runDisplay(bundleDisplayFacts(displayed, status)) };
111
112
  }
@@ -155,18 +156,24 @@ export async function readReview(cwdInput, runInput) {
155
156
  if (!verified.ok || !verified.bundlePath) {
156
157
  return verified;
157
158
  }
158
- const review = runPaths ? await readRunJsonIfExists(runPaths, "review.json") : null;
159
+ const reviewRead = runPaths ? await readRunJsonIfExists(runPaths, "review.json") : undefined;
160
+ const review = reviewRead === undefined ? null : runJsonValue(reviewRead);
159
161
  if (!isReviewSummary(review)) {
160
162
  return {
161
163
  ...verified,
162
164
  ok: false,
163
165
  error: {
164
166
  code: "HUMANISH_INVALID_RUN_BUNDLE",
165
- message: "review.json is missing or invalid.",
167
+ message: reviewRead?.status === "refused"
168
+ ? `${refusalText("review.json", reviewRead)}.`
169
+ : "review.json is missing or invalid.",
166
170
  },
167
171
  };
168
172
  }
169
- const bundle = runPaths ? await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE) : null;
173
+ // The display reads what it can: a run.json refused here shows as a missing one does.
174
+ const bundle = runPaths
175
+ ? runJsonValue(await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE))
176
+ : null;
170
177
  const projected = isRecord(bundle) && Array.isArray(bundle.streams)
171
178
  ? withCuaReviewProvenance(review, bundle.streams.filter(isRecord))
172
179
  : review;
@@ -6,10 +6,11 @@ export declare function scrubTransientCommsText(text: string): string;
6
6
  /**
7
7
  * A scrub for text a model writes: each scope value as written and in its encoded forms
8
8
  * (percent-encoded, JSON-escaped, base64, base64url, hex), and where escapes split one. Text that
9
- * holds an encoded value is returned decoded with each value replaced. Text without one keeps its
10
- * original spelling, so an exact quote that holds an escape still matches its evidence. The literal
11
- * scrub runs first, before decoding can rewrite a value that itself holds an escape, and last,
12
- * because the encoded scrub leaves text inside marker-shaped spans alone. Outside a scope it
13
- * changes nothing.
9
+ * holds a value is returned decoded with each value replaced. Text without one keeps its original
10
+ * spelling, so an exact quote that holds an escape still matches its evidence. The literal scrub
11
+ * runs first, before decoding can rewrite a value that itself holds an escape, and last. The
12
+ * result is checked last of all: if it still holds a value as written or decoded, as a value
13
+ * holding part of a marker can after the literal scrub, the whole text is replaced. Outside a
14
+ * scope it changes nothing.
14
15
  */
15
16
  export declare function transientCommsKnownValueScrub(): (text: string) => string;
@@ -1,7 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { decodeEscapes } from "../evidence/encoded-text.js";
3
- import { scrubSecretValues } from "../evidence/secret-scrub.js";
4
- import { escapeRegExp } from "./text.js";
2
+ import { REDACTION_MARKERS } from "../evidence/redaction.js";
3
+ import { holdsSecretValue, scrubSecretValues, scrubValuesAsWritten, } from "../evidence/secret-scrub.js";
5
4
  const scopes = new AsyncLocalStorage();
6
5
  const MAX_VALUES = 8192;
7
6
  const MAX_BYTES = 1024 * 1024;
@@ -15,7 +14,7 @@ function usable(scope) {
15
14
  function fail(scope) {
16
15
  scope.failed = true;
17
16
  scope.values.clear();
18
- delete scope.pattern;
17
+ delete scope.literal;
19
18
  throw new Error("TRANSIENT_NARRATION_SECRET_LIMIT");
20
19
  }
21
20
  /** Enclose both participant execution and its automatic analysis in one scope. Nested runs isolate too. */
@@ -30,7 +29,7 @@ export async function withTransientCommsSecrets(work) {
30
29
  scope.closed = true;
31
30
  scope.values.clear();
32
31
  scope.bytes = 0;
33
- delete scope.pattern;
32
+ delete scope.literal;
34
33
  }
35
34
  }
36
35
  export function registerTransientCommsSecrets(values) {
@@ -50,7 +49,7 @@ export function registerTransientCommsSecrets(values) {
50
49
  fail(scope);
51
50
  scope.values.add(value);
52
51
  scope.bytes += bytes;
53
- delete scope.pattern;
52
+ delete scope.literal;
54
53
  }
55
54
  }
56
55
  /** Literal, longest-first replacement in one pass; replacement text is never matched recursively. */
@@ -59,46 +58,31 @@ export function scrubTransientCommsText(text) {
59
58
  if (!scope)
60
59
  return text;
61
60
  usable(scope);
62
- if (!scope.values.size)
63
- return text;
64
- try {
65
- scope.pattern ??= new RegExp([...scope.values]
66
- .sort((a, b) => b.length - a.length)
67
- .map(escapeRegExp)
68
- .join("|"), "g");
69
- return text.replace(scope.pattern, "[REDACTED_SECRET]");
70
- }
71
- catch {
72
- return fail(scope);
73
- }
61
+ scope.literal ??= scrubValuesAsWritten([...scope.values]);
62
+ return scope.literal(text);
74
63
  }
75
- /**
76
- * The scope's values as a scrubSecretValues scrub, which also finds them percent-encoded, escaped or
77
- * base64-encoded and returns decoded text. Outside a scope it changes nothing.
78
- */
79
- function transientCommsEncodedScrub() {
80
- const scope = scopes.getStore();
81
- if (!scope)
82
- return (text) => text;
83
- usable(scope);
84
- return scrubSecretValues([...scope.values]);
85
- }
86
- const REDACTED = "[REDACTED_SECRET]";
87
- const markers = (text) => text.split(REDACTED).length - 1;
64
+ const REDACTED = REDACTION_MARKERS.secret;
88
65
  /**
89
66
  * A scrub for text a model writes: each scope value as written and in its encoded forms
90
67
  * (percent-encoded, JSON-escaped, base64, base64url, hex), and where escapes split one. Text that
91
- * holds an encoded value is returned decoded with each value replaced. Text without one keeps its
92
- * original spelling, so an exact quote that holds an escape still matches its evidence. The literal
93
- * scrub runs first, before decoding can rewrite a value that itself holds an escape, and last,
94
- * because the encoded scrub leaves text inside marker-shaped spans alone. Outside a scope it
95
- * changes nothing.
68
+ * holds a value is returned decoded with each value replaced. Text without one keeps its original
69
+ * spelling, so an exact quote that holds an escape still matches its evidence. The literal scrub
70
+ * runs first, before decoding can rewrite a value that itself holds an escape, and last. The
71
+ * result is checked last of all: if it still holds a value as written or decoded, as a value
72
+ * holding part of a marker can after the literal scrub, the whole text is replaced. Outside a
73
+ * scope it changes nothing.
96
74
  */
97
75
  export function transientCommsKnownValueScrub() {
98
- const encoded = transientCommsEncodedScrub();
76
+ const scope = scopes.getStore();
77
+ if (!scope)
78
+ return scrubTransientCommsText;
79
+ usable(scope);
80
+ const values = [...scope.values];
81
+ const encoded = scrubSecretValues(values);
82
+ const holds = holdsSecretValue(values);
99
83
  return (text) => {
100
84
  const literal = scrubTransientCommsText(text);
101
- const found = encoded(literal);
102
- return scrubTransientCommsText(markers(found) > markers(decodeEscapes(literal)) ? found : literal);
85
+ const result = scrubTransientCommsText(holds(literal) ? encoded(literal) : literal);
86
+ return holds(result) ? REDACTED : result;
103
87
  };
104
88
  }
package/dist/run-study.js CHANGED
@@ -4,7 +4,6 @@
4
4
  // which runs the route's local checks that need no scorer (keys, subject env, the local agent) and
5
5
  // returns the route's run. prepareStudy is the same path in two steps, so the CLI can present either
6
6
  // refusal before it loads a declared review scorer.
7
- import { participantList } from "./study/study-fields.js";
8
7
  import { personaBackgroundWarnings } from "./study/warnings.js";
9
8
  import { resolveCommittedPersonasForCwd, studyPersonaIds } from "./study/persona-resolve.js";
10
9
  import { computerUseInput, scriptedInput, sharedWorldInput, terminalInput, } from "./study/route-inputs.js";
@@ -73,8 +72,12 @@ export async function prepareStudy(config, options, deps = {}) {
73
72
  outcome.result.warnings.push(...normalized.warnings);
74
73
  return { ok: false, outcome };
75
74
  }
76
- const runWarnings = [...(planned.planned.plan.warnings ?? [])];
77
- const plan = { ...planned.planned.plan, warnings: runWarnings };
75
+ // The run records the background warnings in its bundle with the plan's own.
76
+ const backgroundWarnings = await planBackgroundWarnings(planned.planned.plan, study, options.cwd);
77
+ const plan = {
78
+ ...planned.planned.plan,
79
+ warnings: [...(planned.planned.plan.warnings ?? []), ...backgroundWarnings],
80
+ };
78
81
  const admitted = await admitPlan(study, planning, plan, deps, normalized.emit);
79
82
  if (!admitted.ok) {
80
83
  await vm?.close();
@@ -97,16 +100,7 @@ export async function prepareStudy(config, options, deps = {}) {
97
100
  return outcome;
98
101
  }
99
102
  }
100
- // Admission keeps its refusal order; these warnings join the plan before its run scope opens.
101
- const personas = route === "computer-use" || route === "shared-world"
102
- ? (await resolveCommittedPersonasForCwd(options.cwd, studyPersonaIds(study))).personas
103
- : new Map();
104
- const backgroundWarnings = personaBackgroundWarnings(route === "computer-use" &&
105
- options.count !== undefined &&
106
- participantList(study) === undefined
107
- ? { ...study, participants: options.count }
108
- : study, personas);
109
- runWarnings.splice(0, runWarnings.length, ...(planned.planned.plan.warnings ?? []), ...backgroundWarnings);
103
+ // A run's result lists the background warnings; a refusal before the run does not.
110
104
  normalized.warnings.push(...backgroundWarnings);
111
105
  // The route layers the scorer over the inputs it admitted, and the local study is not
112
106
  // rebuilt for it. The admitted runSession, provider and participant desktop belong to this
@@ -125,6 +119,21 @@ export async function prepareStudy(config, options, deps = {}) {
125
119
  },
126
120
  };
127
121
  }
122
+ /**
123
+ * A warning for each of the plan's participants whose persona has no background. Only
124
+ * computer-use and shared-world plans have participants; the committed personas are read for them.
125
+ */
126
+ async function planBackgroundWarnings(plan, study, cwd) {
127
+ const participants = plan.route === "computer-use"
128
+ ? plan.runner.participants
129
+ : plan.route === "shared-world"
130
+ ? plan.plane.participants
131
+ : [];
132
+ if (participants.length === 0)
133
+ return [];
134
+ const { personas } = await resolveCommittedPersonasForCwd(cwd, studyPersonaIds(study));
135
+ return personaBackgroundWarnings(plan.studyId, participants, personas);
136
+ }
128
137
  /** The route's admit function for a plan, with the route's input from the run's options. */
129
138
  async function admitPlan(config, options, plan, deps, emit) {
130
139
  switch (plan.route) {
@@ -18,7 +18,7 @@ import { realpath } from "node:fs/promises";
18
18
  import path from "node:path";
19
19
  import { pathToFileURL } from "node:url";
20
20
  import { digestText, redactText } from "../evidence/redaction.js";
21
- import { prepareSelectedOutputDirectory, readContainedRegularFile, } from "../run/contained-output.js";
21
+ import { prepareSelectedOutputDirectory, readContainedRegularFile, refusalText, PROJECT_FILE_MAX_BYTES, } from "../run/contained-output.js";
22
22
  /**
23
23
  * A scorer written for computer use and shared world, as `RunStudyOptions.scorer` takes it. Those
24
24
  * routes pass the browser context; another route that calls it passes a context it was not written
@@ -96,11 +96,13 @@ export async function loadAdapterScorer(args) {
96
96
  const relPosix = relative.split(path.sep).join("/");
97
97
  // Fail-closed containment gate on the entry file only: rejects symlink, nlink>1, realpath-escape,
98
98
  // TOCTOU. Its returned bytes are the digest input.
99
- const bytes = await readContainedRegularFile(root, relPosix);
100
- if (!bytes) {
99
+ const read = await readContainedRegularFile(root, relPosix, PROJECT_FILE_MAX_BYTES);
100
+ if (read.status === "refused" && read.reason === "too-large")
101
+ return fail("HUMANISH_STUDY_SCORER_NOT_FOUND", `review.scorer.ref "${trimmed}" could not be read: ${refusalText(relPosix, read)}.`);
102
+ if (read.status !== "read") {
101
103
  return fail("HUMANISH_STUDY_SCORER_NOT_FOUND", `review.scorer.ref "${trimmed}" could not be read as a regular file inside the project (${relPosix}). Use a plain file there: not a symlink or a hard link, and not a path that resolves outside the project.`);
102
104
  }
103
- const digest = digestText(bytes.toString("utf8"));
105
+ const digest = digestText(read.bytes.toString("utf8"));
104
106
  // Import the entry module in a broad try/catch (ERR_MODULE_NOT_FOUND / SyntaxError / ERR_REQUIRE_ESM
105
107
  // / top-level throw). import() executes the transitive graph, and the cwd clamp guards only the entry
106
108
  // file; out-of-tree/transitive code is acceptable only because of the trust boundary above.
@@ -1,4 +1,4 @@
1
- import { personaBackgroundWarnings } from "./warnings.js";
1
+ import { personaBackgroundWarnings, plannedParticipants } from "./warnings.js";
2
2
  import { studyPersonaIds, resolveCommittedPersonasForCwd } from "./persona-resolve.js";
3
3
  import { personaBrief, PersonaConfigError } from "./persona.js";
4
4
  import { constants } from "node:fs";
@@ -7,7 +7,7 @@ import path from "node:path";
7
7
  import { parse } from "yaml";
8
8
  import { parseStudy } from "./config.js";
9
9
  import { V2_SCHEMA } from "./migrate/v2.js";
10
- import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, prepareSelectedOutputDirectory, readContainedRegularFile, } from "../run/contained-output.js";
10
+ import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, prepareSelectedOutputDirectory, readContainedRegularFile, refusalText, PROJECT_FILE_MAX_BYTES, } from "../run/contained-output.js";
11
11
  import { isNodeError } from "../run/type-guards.js";
12
12
  import { studyFileCandidates, STUDY_DIRECTORIES } from "./files.js";
13
13
  const STUDY_LIST_SCHEMA = "humanish.study-list.v1";
@@ -197,7 +197,7 @@ export async function inspectStudyManifest(cwd, study) {
197
197
  warnings: [
198
198
  ...resolved.warnings,
199
199
  ...personaResolution.warnings,
200
- ...personaBackgroundWarnings(resolved.config, personaResolution.personas),
200
+ ...personaBackgroundWarnings(resolved.config.id, plannedParticipants(resolved.config), personaResolution.personas),
201
201
  ],
202
202
  };
203
203
  }
@@ -279,14 +279,17 @@ async function readManagedManifest(projectRoot, relativePath) {
279
279
  if (inspected.status !== "ok") {
280
280
  return inspected;
281
281
  }
282
- const contents = await readContainedRegularFile(projectRoot, relativePath.replace(/\\/g, "/"));
283
- if (!contents) {
282
+ const contents = await readContainedRegularFile(projectRoot, relativePath.replace(/\\/g, "/"), PROJECT_FILE_MAX_BYTES);
283
+ if (contents.status === "refused" && contents.reason === "too-large")
284
+ return { status: "unsafe", message: `${refusalText("The study file", contents)}.` };
285
+ // Gone or swapped since it was inspected above.
286
+ if (contents.status !== "read") {
284
287
  return {
285
288
  status: "unsafe",
286
289
  message: "The study file changed or failed containment validation.",
287
290
  };
288
291
  }
289
- return { status: "ok", contents: contents.toString("utf8") };
292
+ return { status: "ok", contents: contents.bytes.toString("utf8") };
290
293
  }
291
294
  async function readExplicitManifest(projectRoot, requestedPath) {
292
295
  try {
@@ -4,7 +4,7 @@
4
4
  import { lstat } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { runtimeDirectories, starterFiles } from "./init-templates.js";
7
- import { assertPreparedSelectedOutputDirectory, readContainedRegularFile, } from "../run/contained-output.js";
7
+ import { assertPreparedSelectedOutputDirectory, ContainedReadRefusedError, readContainedRegularFile, PROJECT_FILE_MAX_BYTES, } from "../run/contained-output.js";
8
8
  import { isPathInside } from "../run/paths.js";
9
9
  import { isNodeError } from "../run/type-guards.js";
10
10
  export async function validateInitProjectPaths(cwd) {
@@ -54,10 +54,12 @@ function unsafeProjectPath(relativePath) {
54
54
  };
55
55
  }
56
56
  export async function readTextIfExists(projectRoot, relativePath) {
57
- const bytes = await readContainedRegularFile(projectRoot, relativePath);
58
- if (bytes !== null) {
59
- return bytes.toString("utf8");
60
- }
57
+ const read = await readContainedRegularFile(projectRoot, relativePath, PROJECT_FILE_MAX_BYTES);
58
+ if (read.status === "read")
59
+ return read.bytes.toString("utf8");
60
+ // A file over the limit is a size problem, not an unsafe path.
61
+ if (read.status === "refused" && read.reason === "too-large")
62
+ throw new ContainedReadRefusedError(relativePath, read);
61
63
  const target = path.join(projectRoot.physicalPath, relativePath);
62
64
  try {
63
65
  await lstat(target);
@@ -5,8 +5,8 @@ import type { StudyConfig } from "./types.js";
5
5
  export declare function personaTitleFromId(personaId: string): string;
6
6
  /**
7
7
  * Resolve one persona from `humanish/personas/`, then the ignored `.humanish/local/personas/`.
8
- * Returns `null` with a warning when the id is unsafe or no file exists. Invalid rich backgrounds
9
- * reject the study before execution.
8
+ * Returns `null` with a warning when the id is unsafe or no file exists. Invalid rich backgrounds,
9
+ * and a persona file that is there and cannot be read, reject the study before execution.
10
10
  */
11
11
  export declare function resolveCommittedPersona(projectRoot: PreparedSelectedOutputDirectory, personaId: string): Promise<{
12
12
  persona: ResolvedPersona | null;
@@ -2,7 +2,7 @@
2
2
  import { parse as parseYaml } from "yaml";
3
3
  import path from "node:path";
4
4
  import { parseResolvedPersona, PersonaConfigError } from "./persona.js";
5
- import { prepareSelectedOutputDirectory, readContainedRegularFile, } from "../run/contained-output.js";
5
+ import { prepareSelectedOutputDirectory, readContainedRegularFile, PROJECT_FILE_MAX_BYTES, refusalText, } from "../run/contained-output.js";
6
6
  import { digestText, redactText } from "../evidence/redaction.js";
7
7
  import { realpath } from "node:fs/promises";
8
8
  import { participantList } from "./study-fields.js";
@@ -23,8 +23,8 @@ const PERSONA_DIRECTORIES = [
23
23
  ];
24
24
  /**
25
25
  * Resolve one persona from `humanish/personas/`, then the ignored `.humanish/local/personas/`.
26
- * Returns `null` with a warning when the id is unsafe or no file exists. Invalid rich backgrounds
27
- * reject the study before execution.
26
+ * Returns `null` with a warning when the id is unsafe or no file exists. Invalid rich backgrounds,
27
+ * and a persona file that is there and cannot be read, reject the study before execution.
28
28
  */
29
29
  export async function resolveCommittedPersona(projectRoot, personaId) {
30
30
  if (!PERSONA_ID_PATTERN.test(personaId)) {
@@ -37,9 +37,13 @@ export async function resolveCommittedPersona(projectRoot, personaId) {
37
37
  path.posix.join(directory, `${personaId}.yaml`),
38
38
  path.posix.join(directory, `${personaId}.yml`),
39
39
  ])) {
40
- const bytes = await readContainedRegularFile(projectRoot, candidate);
41
- if (!bytes)
40
+ const read = await readContainedRegularFile(projectRoot, candidate, PROJECT_FILE_MAX_BYTES);
41
+ if (read.status === "missing")
42
42
  continue;
43
+ // A candidate that is there and refused stops resolution: the next file is another persona.
44
+ if (read.status === "refused")
45
+ throw new PersonaConfigError(`Persona ${redactText(personaId)}: ${refusalText(candidate, read)}. Fix or remove that file; a lower-priority persona file is not used in its place.`);
46
+ const bytes = read.bytes;
43
47
  let raw;
44
48
  try {
45
49
  raw = parseYaml(bytes.toString("utf8"));
@@ -196,8 +196,8 @@ export interface SharedWorldPlan extends PlanBase {
196
196
  readonly plane: SharedWorldPlane;
197
197
  /** At least 2. */
198
198
  readonly concurrency: number;
199
- /** The declared participant session timeout; the route supplies its default. */
200
- readonly sessionTimeoutMs?: number;
199
+ /** Each participant's session budget: execution.timeoutMs, else the plane's derived default. */
200
+ readonly sessionTimeoutMs: number;
201
201
  readonly brain: Extract<Brain, {
202
202
  kind: "openai" | "local-agent";
203
203
  }>;
@@ -6,8 +6,10 @@ export { routeOf, type StudyRoute } from "./routing.js";
6
6
  /** Resolve dry-run: explicit override wins, else the scenario mode, else the given fallback. */
7
7
  export declare function resolveStudyDryRun(config: StudyConfig, override: boolean | undefined, fallback: boolean | undefined): boolean | undefined;
8
8
  /**
9
- * The plan a study runs under, built without reading files, env or the network. Each route's planner
10
- * makes every refusal that route makes, in the route's order and with its codes and messages; the
11
- * route's exported runner calls the same planner.
9
+ * The plan a study runs under, built without reading files or the network. From the environment
10
+ * (options.env, else process.env, as the routes read it) it reads one value, the E2B sandbox
11
+ * ceiling, which no study file can know. Each route's planner makes every refusal that route
12
+ * makes, in the route's order and with its codes and messages; the route's exported runner calls
13
+ * the same planner.
12
14
  */
13
15
  export declare function planStudy(config: StudyConfig, options: InternalRunStudyOptions, deps?: StudyDeps): PlanResult;
@@ -6,13 +6,14 @@ import { callerDrivingOf, planComputerUseStudy } from "../routes/computer-use/pl
6
6
  import { injectedBrowser, planScriptedStudy } from "../routes/scripted/plan.js";
7
7
  import { planSharedWorldStudy } from "../routes/shared-world/plan.js";
8
8
  import { planTerminalStudy } from "../routes/terminal/plan.js";
9
+ import { sandboxCeiling } from "../substrates/e2b/lifetime.js";
9
10
  import { localBrowserDefaults } from "../substrates/local/runtime-config.js";
10
11
  import { THIS_REPO_DRY_RUN_ONLY } from "./composition-rules.js";
11
12
  import { planBase } from "./plan-base.js";
12
13
  import { routeOf } from "./routing.js";
13
14
  import { automaticAnalysisRouteReason, taskProtocolValidationReason } from "./validation.js";
14
15
  import { declaredParticipantCount } from "./study-fields.js";
15
- import { egressAllowIgnoredWarning, scriptedMissionWarnings } from "./warnings.js";
16
+ import { recordedStudyWarnings } from "./warnings.js";
16
17
  export { routeOf } from "./routing.js";
17
18
  /** Resolve dry-run: explicit override wins, else the scenario mode, else the given fallback. */
18
19
  export function resolveStudyDryRun(config, override, fallback) {
@@ -63,20 +64,22 @@ function planPreview(config, options, input) {
63
64
  };
64
65
  }
65
66
  /**
66
- * The plan a study runs under, built without reading files, env or the network. Each route's planner
67
- * makes every refusal that route makes, in the route's order and with its codes and messages; the
68
- * route's exported runner calls the same planner.
67
+ * The plan a study runs under, built without reading files or the network. From the environment
68
+ * (options.env, else process.env, as the routes read it) it reads one value, the E2B sandbox
69
+ * ceiling, which no study file can know. Each route's planner makes every refusal that route
70
+ * makes, in the route's order and with its codes and messages; the route's exported runner calls
71
+ * the same planner.
69
72
  */
70
73
  export function planStudy(config, options, deps = {}) {
71
74
  const study = localBrowserDefaults(config);
72
75
  const input = { dryRun: resolveStudyDryRun(study, options.dryRun, true) ?? true };
73
- const result = planRoute(routeOf(config), study, options, input, deps);
76
+ const ceiling = sandboxCeiling(options.env ?? process.env);
77
+ const result = planRoute(routeOf(config), study, options, input, deps, ceiling);
74
78
  if (!result.ok)
75
79
  return result;
76
80
  // The manifest the CLI resolved enters the plan here and nowhere else; the routes read
77
81
  // plan.study for the run's status record and bundle, and plan.warnings for its bundle events.
78
- const warning = egressAllowIgnoredWarning(study);
79
- const warnings = [...(warning === undefined ? [] : [warning]), ...scriptedMissionWarnings(study)];
82
+ const warnings = recordedStudyWarnings(study);
80
83
  const plan = {
81
84
  ...result.plan,
82
85
  ...(options.study === undefined ? {} : { study: options.study }),
@@ -84,7 +87,7 @@ export function planStudy(config, options, deps = {}) {
84
87
  };
85
88
  return { ok: true, planned: { plan } };
86
89
  }
87
- function planRoute(route, study, options, input, deps) {
90
+ function planRoute(route, study, options, input, deps, sandboxCeiling) {
88
91
  switch (route) {
89
92
  case "preview":
90
93
  return planPreview(study, options, input);
@@ -95,14 +98,20 @@ function planRoute(route, study, options, input, deps) {
95
98
  driving: callerDrivingOf(options),
96
99
  ...(options.count === undefined ? {} : { countOverride: options.count }),
97
100
  ...(options.rerun === undefined ? {} : { rerun: options.rerun }),
101
+ sandboxCeiling,
98
102
  });
99
103
  case "shared-world":
100
104
  return planSharedWorldStudy(study, {
101
105
  ...input,
102
106
  hasRunSession: deps.runSession !== undefined,
107
+ sandboxCeiling,
103
108
  });
104
109
  case "terminal":
105
- return planTerminalStudy(study, { ...input, hasCostProbe: deps.costProbe !== undefined });
110
+ return planTerminalStudy(study, {
111
+ ...input,
112
+ hasCostProbe: deps.costProbe !== undefined,
113
+ sandboxCeiling,
114
+ });
106
115
  case "scripted":
107
116
  return planScriptedStudy(study, { ...input, injectedBrowser: injectedBrowser(deps) });
108
117
  }
@@ -4,7 +4,7 @@
4
4
  // the study and dispatches here.
5
5
  import { cloneProvisioningBudgetMs, provisionCloneSubject } from "../subject/clone.js";
6
6
  import { CUA_ACTOR_STUDY_PROVIDER_METADATA } from "../routes/computer-use/e2b-desktop/prepare.js";
7
- import { MAX_SANDBOX_MS } from "../substrates/e2b/lifetime.js";
7
+ import { sandboxCeiling } from "../substrates/e2b/lifetime.js";
8
8
  import { abandonPreflightJournal, discardPreflightJournal, openPreflightJournal, } from "../run/preflight-receipts.js";
9
9
  import { probeUrl } from "../substrates/detached.js";
10
10
  import { loadE2BDesktopModule } from "../substrates/e2b/sdk.js";
@@ -147,8 +147,16 @@ async function withPreflightSandbox(ctx, args, callback) {
147
147
  let allocation;
148
148
  let failureMessage;
149
149
  // The lease is sized to the probe's work, never longer than a declared sandbox timeout (the run
150
- // gets no more than that either) or E2B's maximum.
151
- const timeoutMs = Math.min(args.leaseMs, ctx.config.execution?.desktop?.sandboxTimeoutMs ?? MAX_SANDBOX_MS);
150
+ // gets no more than that either) or the sandbox ceiling.
151
+ const ceiling = sandboxCeiling(ctx.env);
152
+ if (!ceiling.ok)
153
+ return {
154
+ ok: false,
155
+ result: fail(ctx, "HUMANISH_STUDY_PREFLIGHT_PROVISION_FAILED", ceiling.message, [
156
+ { name: "sandbox preflight", ok: false, message: ceiling.message },
157
+ ]),
158
+ };
159
+ const timeoutMs = Math.min(args.leaseMs, ctx.config.execution?.desktop?.sandboxTimeoutMs ?? ceiling.ms);
152
160
  // The receipt goes to a journal under .humanish/preflight, so `humanish reclaim --preflight`
153
161
  // can kill the probe if this process dies before the finally block does.
154
162
  let journal;
@@ -1,4 +1,4 @@
1
- import { personaBackgroundWarnings } from "./warnings.js";
1
+ import { personaBackgroundWarnings, plannedParticipants } from "./warnings.js";
2
2
  import { resolveCommittedPersonasForCwd, studyPersonaIds } from "./persona-resolve.js";
3
3
  import path from "node:path";
4
4
  import { isLoopbackUrl } from "./parse/subject.js";
@@ -37,9 +37,10 @@ export async function runStudyPreflight(options) {
37
37
  error: resolved.error,
38
38
  };
39
39
  }
40
- const personas = resolved.config.route === "computer-use" || resolved.config.route === "shared-world"
41
- ? (await resolveCommittedPersonasForCwd(cwd, studyPersonaIds(resolved.config))).personas
42
- : new Map();
40
+ const participants = plannedParticipants(resolved.config);
41
+ const personas = participants.length === 0
42
+ ? new Map()
43
+ : (await resolveCommittedPersonasForCwd(cwd, studyPersonaIds(resolved.config))).personas;
43
44
  const route = routeOf(resolved.config);
44
45
  const ctx = {
45
46
  cwd,
@@ -59,7 +60,10 @@ export async function runStudyPreflight(options) {
59
60
  ],
60
61
  targets: collectTargets(resolved.config),
61
62
  sandbox: { created: false },
62
- warnings: [...resolved.warnings, ...personaBackgroundWarnings(resolved.config, personas)],
63
+ warnings: [
64
+ ...resolved.warnings,
65
+ ...personaBackgroundWarnings(resolved.config.id, participants, personas),
66
+ ],
63
67
  };
64
68
  const machine = machineCheck(ctx.config);
65
69
  // A local study's app and desktops run on this machine, so the hosted probes do not apply.
@@ -1,4 +1,5 @@
1
1
  import { type LocalRuntimeStatus } from "../substrates/local/runtime.js";
2
+ import { type AutomaticAnalysisBudget } from "../analysis/automatic-config.js";
2
3
  import { type KeyResolutionDeps } from "../keys/key-resolution.js";
3
4
  export declare const STUDY_SUMMARY_SCHEMA = "humanish.study-summary.v1";
4
5
  export interface StudyCaps {
@@ -14,11 +15,9 @@ export interface StudySummary {
14
15
  message: string;
15
16
  };
16
17
  communications?: string;
17
- analysis?: {
18
- provider?: "openai" | "codex";
19
- billing?: "api-estimate" | "account-unknown";
20
- model: string;
21
- maxCostUsd: number | null;
18
+ analysis?: AutomaticAnalysisBudget & {
19
+ /** The budget line `run` and `study check` print, from formatAutomaticAnalysisBudget. */
20
+ line: string;
22
21
  };
23
22
  schema: typeof STUDY_SUMMARY_SCHEMA;
24
23
  /** The study's `id`. */
@@ -39,9 +38,15 @@ export interface StudySummary {
39
38
  * default. A study variable you cannot see is one nobody chose.
40
39
  */
41
40
  reasoningEffort?: string;
42
- caps: StudyCaps;
41
+ /** Absent on routes whose dollar caps the summary does not show; `{}` when none is declared. */
42
+ caps?: StudyCaps;
43
43
  /**
44
- * Whether the configured route's required keys resolve. Dry runs require none. This checks key
44
+ * What `humanish run <study>` runs this file as: its `mode`, a dry run when it sets none. The TUI
45
+ * cannot start a live run of a dry-run file, because no `run` flag overrides the file to live.
46
+ */
47
+ mode: "live" | "dry-run";
48
+ /**
49
+ * Whether the keys a live run of this study needs resolve, whatever its mode. This checks key
45
50
  * presence, not local CLI authentication or provider validity. Undefined when not checked.
46
51
  */
47
52
  keysReady?: boolean;