humanish 0.115.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 (154) hide show
  1. package/CHANGELOG.md +22 -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/computer-use/loop/debrief.js +61 -27
  6. package/dist/actors/scripted-browser/steps.js +6 -5
  7. package/dist/analysis/analysis-limits.d.ts +35 -0
  8. package/dist/analysis/analysis-limits.js +41 -0
  9. package/dist/analysis/validation.js +14 -21
  10. package/dist/cli/commands/analyze.js +2 -1
  11. package/dist/cli/commands/notes.js +2 -1
  12. package/dist/cli/commands/run-signals.js +5 -1
  13. package/dist/cli/commands/runs.js +2 -1
  14. package/dist/cli/commands/setup.js +3 -3
  15. package/dist/cli/commands/study-format.d.ts +5 -2
  16. package/dist/cli/commands/study-format.js +29 -9
  17. package/dist/cli/commands/study-route-computer-use.js +2 -2
  18. package/dist/cli/commands/study-route-scripted.js +1 -1
  19. package/dist/cli/commands/tui.js +59 -17
  20. package/dist/cli/doctor.js +9 -2
  21. package/dist/cli/findings-text.d.ts +5 -0
  22. package/dist/cli/findings-text.js +169 -0
  23. package/dist/cli/findings.d.ts +0 -4
  24. package/dist/cli/findings.js +5 -167
  25. package/dist/cli/io.js +6 -1
  26. package/dist/cli/update-check.js +4 -1
  27. package/dist/comms/connections.js +4 -3
  28. package/dist/comms/setup.js +18 -12
  29. package/dist/evidence/redaction.js +9 -1
  30. package/dist/evidence/secret-scrub.d.ts +15 -5
  31. package/dist/evidence/secret-scrub.js +67 -9
  32. package/dist/feedback/draft.js +1 -1
  33. package/dist/feedback/export-bundle.js +18 -14
  34. package/dist/feedback/feedback.js +25 -7
  35. package/dist/guest/desktop-keys.js +59 -5
  36. package/dist/keys/key-resolution.d.ts +9 -0
  37. package/dist/keys/key-resolution.js +60 -48
  38. package/dist/keys/key-status.d.ts +2 -0
  39. package/dist/keys/key-status.js +9 -16
  40. package/dist/observer/pinned-files.js +1 -1
  41. package/dist/observer/render.js +3 -2
  42. package/dist/observer-app.html +7 -7
  43. package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
  44. package/dist/routes/computer-use/bundle-parts.js +2 -2
  45. package/dist/routes/computer-use/bundle.d.ts +4 -5
  46. package/dist/routes/computer-use/bundle.js +1 -3
  47. package/dist/routes/computer-use/fanout-records.js +10 -11
  48. package/dist/routes/computer-use/live-phase.d.ts +1 -1
  49. package/dist/routes/computer-use/live-phase.js +0 -1
  50. package/dist/routes/computer-use/participant-execution.d.ts +3 -2
  51. package/dist/routes/computer-use/participant-runs.d.ts +6 -2
  52. package/dist/routes/computer-use/participant-runs.js +9 -5
  53. package/dist/routes/computer-use/plan.d.ts +3 -0
  54. package/dist/routes/computer-use/plan.js +14 -11
  55. package/dist/routes/computer-use/rerun-selection.js +2 -2
  56. package/dist/routes/computer-use/result.js +3 -6
  57. package/dist/routes/computer-use/setup.d.ts +4 -6
  58. package/dist/routes/computer-use/setup.js +2 -6
  59. package/dist/routes/computer-use/single-bundle.d.ts +4 -5
  60. package/dist/routes/computer-use/single-bundle.js +22 -25
  61. package/dist/routes/computer-use/subject-projection.d.ts +4 -8
  62. package/dist/routes/computer-use/subject-projection.js +9 -31
  63. package/dist/routes/computer-use/types.d.ts +4 -41
  64. package/dist/routes/scripted/scenario.js +28 -12
  65. package/dist/routes/scripted/session-result.js +6 -3
  66. package/dist/routes/shared-world/bundle-records.js +2 -8
  67. package/dist/routes/shared-world/participant-specs.d.ts +0 -2
  68. package/dist/routes/shared-world/participant-specs.js +1 -20
  69. package/dist/routes/shared-world/plan.d.ts +3 -0
  70. package/dist/routes/shared-world/plan.js +23 -3
  71. package/dist/routes/shared-world/setup.js +2 -2
  72. package/dist/routes/terminal/ledger.d.ts +9 -9
  73. package/dist/routes/terminal/ledger.js +63 -40
  74. package/dist/routes/terminal/live-finish.js +7 -10
  75. package/dist/routes/terminal/plan.d.ts +3 -0
  76. package/dist/routes/terminal/plan.js +8 -5
  77. package/dist/routes/terminal/token-usage.d.ts +1 -2
  78. package/dist/routes/terminal/token-usage.js +4 -6
  79. package/dist/routes/terminal/types.d.ts +9 -1
  80. package/dist/run/bundle-shape.js +6 -1
  81. package/dist/run/bundle.d.ts +5 -3
  82. package/dist/run/contained-output.d.ts +53 -5
  83. package/dist/run/contained-output.js +141 -42
  84. package/dist/run/detail.d.ts +2 -1
  85. package/dist/run/detail.js +12 -7
  86. package/dist/run/escape-sequences.d.ts +11 -0
  87. package/dist/run/escape-sequences.js +93 -0
  88. package/dist/run/evidence-files.d.ts +8 -8
  89. package/dist/run/evidence-files.js +19 -23
  90. package/dist/run/locate.d.ts +32 -3
  91. package/dist/run/locate.js +45 -40
  92. package/dist/run/notes.d.ts +0 -8
  93. package/dist/run/notes.js +5 -51
  94. package/dist/run/outcomes.d.ts +24 -0
  95. package/dist/run/outcomes.js +19 -0
  96. package/dist/run/paths.d.ts +2 -0
  97. package/dist/run/paths.js +5 -1
  98. package/dist/run/preflight-receipts.js +13 -8
  99. package/dist/run/project.js +7 -4
  100. package/dist/run/reclaim-outcomes.d.ts +8 -6
  101. package/dist/run/reclaim-outcomes.js +10 -6
  102. package/dist/run/reclaim.d.ts +5 -0
  103. package/dist/run/reclaim.js +43 -16
  104. package/dist/run/run-clock.d.ts +50 -0
  105. package/dist/run/run-clock.js +66 -0
  106. package/dist/run/run.js +5 -2
  107. package/dist/run/sandbox-ids.d.ts +7 -2
  108. package/dist/run/sandbox-ids.js +27 -11
  109. package/dist/run/secrets.d.ts +2 -1
  110. package/dist/run/secrets.js +18 -22
  111. package/dist/run/stored-runs.js +14 -7
  112. package/dist/run/transient-comms-secrets.js +6 -17
  113. package/dist/run-study.js +22 -13
  114. package/dist/study/adapter-scorer-loader.js +6 -4
  115. package/dist/study/discover.js +9 -6
  116. package/dist/study/init-paths.js +7 -5
  117. package/dist/study/persona-resolve.d.ts +2 -2
  118. package/dist/study/persona-resolve.js +9 -5
  119. package/dist/study/plan-types.d.ts +2 -2
  120. package/dist/study/plan.d.ts +5 -3
  121. package/dist/study/plan.js +18 -9
  122. package/dist/study/preflight-probes.js +11 -3
  123. package/dist/study/preflight.js +9 -5
  124. package/dist/study/summary.d.ts +8 -2
  125. package/dist/study/summary.js +20 -22
  126. package/dist/study/warnings.d.ts +19 -12
  127. package/dist/study/warnings.js +38 -17
  128. package/dist/substrates/e2b/connection.d.ts +12 -0
  129. package/dist/substrates/e2b/connection.js +35 -0
  130. package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
  131. package/dist/substrates/e2b/desktop-executor.js +42 -9
  132. package/dist/substrates/e2b/lifetime.d.ts +24 -4
  133. package/dist/substrates/e2b/lifetime.js +41 -4
  134. package/dist/substrates/e2b/sandbox.d.ts +4 -3
  135. package/dist/substrates/e2b/sandbox.js +13 -18
  136. package/dist/substrates/e2b/sdk.d.ts +20 -9
  137. package/dist/substrates/local/capacity.d.ts +18 -2
  138. package/dist/substrates/local/capacity.js +21 -2
  139. package/dist/substrates/local/runtime-host.js +21 -14
  140. package/dist/substrates/local/runtime.js +3 -11
  141. package/dist/tui/contract.d.ts +30 -5
  142. package/dist/tui/contract.js +4 -1
  143. package/dist/tui-app.js +149 -147
  144. package/dist/verify/artifacts.d.ts +9 -2
  145. package/dist/verify/artifacts.js +37 -13
  146. package/dist/verify/liveness.js +19 -5
  147. package/dist/verify/verify.js +54 -27
  148. package/docs/architecture/guest-desktop.md +7 -2
  149. package/docs/architecture/state-driven-executor.md +4 -2
  150. package/docs/contracts/run-bundle.md +18 -10
  151. package/docs/contracts/schemas.md +27 -18
  152. package/docs/contracts/study-analysis.md +10 -1
  153. package/docs/product/automatic-analysis.md +7 -1
  154. package/package.json +2 -2
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.
@@ -38,9 +38,15 @@ export interface StudySummary {
38
38
  * default. A study variable you cannot see is one nobody chose.
39
39
  */
40
40
  reasoningEffort?: string;
41
- caps: StudyCaps;
41
+ /** Absent on routes whose dollar caps the summary does not show; `{}` when none is declared. */
42
+ caps?: StudyCaps;
42
43
  /**
43
- * 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
44
50
  * presence, not local CLI authentication or provider validity. Undefined when not checked.
45
51
  */
46
52
  keysReady?: boolean;
@@ -7,10 +7,9 @@ import { localRuntimeStatus } from "../substrates/local/runtime.js";
7
7
  // what a stakeholder reads on the screen where they decide whether to press Start.
8
8
  //
9
9
  // Resolved analysis defaults are shown independently of declared participant caps. A cap that is not
10
- // declared is not "unlimited" and not "$0"; it is a line the screen does not draw.
11
- import { resolveStudyDryRun } from "./plan.js";
12
- import { routeOf } from "./plan.js";
13
- import { localCodexParticipantCheck, planCliRun } from "./doctor.js";
10
+ // declared is not "unlimited" and not "$0": the screen says that no cap is set.
11
+ import { planStudy, resolveStudyDryRun, routeOf } from "./plan.js";
12
+ import { localCodexParticipantCheck } from "./doctor.js";
14
13
  import { requiredKeys, requiredSubjectEnv } from "./requirements.js";
15
14
  import { automaticAnalysisBudget, formatAutomaticAnalysisBudget, } from "../analysis/automatic-config.js";
16
15
  import { DEFAULT_OPENAI_CU_MODEL, DEFAULT_OPENAI_CU_REASONING_EFFORT, } from "../actors/computer-use/openai-provider.js";
@@ -70,7 +69,7 @@ function participantsOf(config) {
70
69
  /** The computer-use caps. Other routes draw none. */
71
70
  function summaryCapsOf(config) {
72
71
  if (!isComputerUseComposition(config))
73
- return {};
72
+ return undefined;
74
73
  const caps = config.caps;
75
74
  return {
76
75
  ...(typeof caps?.maxUsd === "number" ? { laneUsd: caps.maxUsd } : {}),
@@ -89,18 +88,17 @@ export async function readStudySummary(cwd, study, options = {}) {
89
88
  const route = routeOf(inspected.config);
90
89
  let keysReady;
91
90
  let missingKeys;
92
- const dryRun = resolveStudyDryRun(inspected.config, undefined, true) === true;
93
- // A live key check reads the plan's requirements. A study the planner refuses has none to check,
94
- // so the summary reports the refusal in place of its keys.
95
- const planned = options.checkKeys === true && !dryRun ? await planCliRun(inspected.config, cwd) : undefined;
91
+ const mode = resolveStudyDryRun(inspected.config, undefined, true) === true ? "dry-run" : "live";
92
+ // The keys are the live plan's, whatever the file's mode: the screen offers a live run of every
93
+ // study, and a dry run needs no keys to report. A study the planner refuses live has none to
94
+ // check, so the summary reports the refusal in place of its keys.
95
+ const planned = options.checkKeys === true ? planStudy(inspected.config, { cwd, dryRun: false }) : undefined;
96
96
  const planRefusal = planned?.ok === false ? planned.refusal.message : undefined;
97
- if (options.checkKeys === true && planned?.ok !== false) {
98
- const requirements = planned?.planned.plan.requirements ?? [];
97
+ if (planned?.ok === true) {
98
+ const requirements = planned.planned.plan.requirements;
99
99
  const subjectKeys = requiredSubjectEnv(requirements);
100
100
  const email = inspected.config.comms?.email;
101
- const receivingKey = !dryRun && email?.kind === "real"
102
- ? await receivingRequiredKey(cwd, email.connection)
103
- : undefined;
101
+ const receivingKey = email?.kind === "real" ? await receivingRequiredKey(cwd, email.connection) : undefined;
104
102
  const candidates = [
105
103
  "OPENAI_API_KEY",
106
104
  "CODEX_API_KEY",
@@ -108,13 +106,11 @@ export async function readStudySummary(cwd, study, options = {}) {
108
106
  ...subjectKeys,
109
107
  ...(receivingKey ? [receivingKey] : []),
110
108
  ];
111
- const probes = dryRun
112
- ? []
113
- : await probeKeySources(candidates, {
114
- cwd,
115
- env: options.env ?? process.env,
116
- ...(options.keyDeps === undefined ? {} : { deps: options.keyDeps }),
117
- }).catch(() => []);
109
+ const probes = await probeKeySources(candidates, {
110
+ cwd,
111
+ env: options.env ?? process.env,
112
+ ...(options.keyDeps === undefined ? {} : { deps: options.keyDeps }),
113
+ }).catch(() => []);
118
114
  const present = new Set(probes.filter((probe) => probe.source !== null).map((probe) => probe.name));
119
115
  const required = requiredKeys(requirements, (name) => present.has(name));
120
116
  const missing = [
@@ -130,6 +126,7 @@ export async function readStudySummary(cwd, study, options = {}) {
130
126
  const analysis = automaticAnalysisBudget(inspected.config.review?.analysis, route, computerUseParticipantCount(inspected.config));
131
127
  const subject = subjectOf(config);
132
128
  const participants = participantsOf(inspected.config);
129
+ const caps = summaryCapsOf(inspected.config);
133
130
  const runtime = options.checkKeys === true && isLocalBrowserStudy(inspected.config)
134
131
  ? await localRuntimeStatus({
135
132
  ...(options.env ? { env: options.env } : {}),
@@ -170,7 +167,8 @@ export async function readStudySummary(cwd, study, options = {}) {
170
167
  ...(participants === undefined ? {} : { participants }),
171
168
  model: inspected.config.actor?.model ?? DEFAULT_OPENAI_CU_MODEL,
172
169
  reasoningEffort: reasoningEffortOf(inspected.config),
173
- caps: summaryCapsOf(inspected.config),
170
+ ...(caps === undefined ? {} : { caps }),
171
+ mode,
174
172
  ...(keysReady === undefined ? {} : { keysReady }),
175
173
  ...(missingKeys === undefined ? {} : { missingKeys }),
176
174
  ...(planRefusal === undefined ? {} : { planRefusal }),
@@ -1,5 +1,6 @@
1
1
  import type { ResolvedPersona } from "./persona.js";
2
2
  import type { StudyConfig } from "./types.js";
3
+ import { type Participant } from "./plan-participants.js";
3
4
  /**
4
5
  * The fields a config sets that its route does not read, each as `field` or `field (reason)`.
5
6
  * parseStudy refuses a study for any of them.
@@ -10,17 +11,23 @@ export declare function inertFieldLabels(config: StudyConfig): string[];
10
11
  * `participants[].entry` or `execution.timeoutMs`. `humanish migrate` drops each one.
11
12
  */
12
13
  export declare function inertFieldPaths(config: StudyConfig): string[];
14
+ export declare function forwardDeclaredWarnings(config: StudyConfig): string[];
13
15
  /**
14
- * The warning for `execution.egressAllow` on a route that ignores it, or undefined. Only the
15
- * terminal route creates its sandbox with the list as an outbound allowlist; on any other route the
16
- * study author would trust a list that blocks nothing. `humanish study check` and every run print
17
- * it, and the run records it in its bundle.
18
- * Removed in the first minor release on or after 2026-11-04: deprecated on 2026-10-05. That release refuses the field off the terminal
19
- * route.
16
+ * The warnings about a study's own fields that a run records in its bundle as `study.warning`
17
+ * events: `execution.egressAllow` off the terminal route, and participant text that reads like a
18
+ * script. parseStudy reports them too, after its other warnings, so `study check` and every run
19
+ * print them.
20
20
  */
21
- export declare function egressAllowIgnoredWarning(config: StudyConfig): string | undefined;
22
- export declare function forwardDeclaredWarnings(config: StudyConfig): string[];
23
- /** Authored participant text is checked before runtime instructions or researcher criteria join it. */
24
- export declare function scriptedMissionWarnings(config: StudyConfig): string[];
25
- /** Backgrounds give participants a reason to act beyond the assigned task. */
26
- export declare function personaBackgroundWarnings(config: StudyConfig, personas: ReadonlyMap<string, ResolvedPersona>): string[];
21
+ export declare function recordedStudyWarnings(config: StudyConfig): string[];
22
+ /**
23
+ * A warning for each participant whose persona has no background, which gives a participant a
24
+ * reason to act beyond the assigned task. `participants` are the records the planner builds: a
25
+ * computer-use or shared-world plan's participants, or `plannedParticipants` before a plan exists.
26
+ */
27
+ export declare function personaBackgroundWarnings(studyId: string, participants: readonly Pick<Participant, "id" | "personaId">[], personas: ReadonlyMap<string, ResolvedPersona>): string[];
28
+ /**
29
+ * The participant records planStudy builds for a study's declared participants, for `study check`
30
+ * and `study show`, which run no plan. Only computer-use and shared-world studies have them; a run's
31
+ * `--count` is not applied.
32
+ */
33
+ export declare function plannedParticipants(config: StudyConfig): readonly Participant[];