humanish 0.110.3 → 0.110.4

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 (77) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/actors/scripted-browser/actor.js +17 -8
  3. package/dist/actors/scripted-browser/launch-env.d.ts +12 -0
  4. package/dist/actors/scripted-browser/launch-env.js +50 -0
  5. package/dist/analysis/automatic-completion.d.ts +2 -1
  6. package/dist/analysis/automatic-completion.js +7 -2
  7. package/dist/cli/commands/migrate.js +1 -1
  8. package/dist/cli/commands/runs.js +5 -2
  9. package/dist/cli/commands/study-route-computer-use.js +3 -2
  10. package/dist/cli/commands/study-route-preview.js +2 -1
  11. package/dist/cli/commands/study-route-shared-world.js +3 -3
  12. package/dist/cli/commands/study.js +4 -1
  13. package/dist/routes/computer-use/live-phase.js +8 -1
  14. package/dist/routes/computer-use/local-vm.js +2 -1
  15. package/dist/routes/computer-use/plan.js +6 -5
  16. package/dist/routes/computer-use/route.js +2 -1
  17. package/dist/routes/computer-use/setup.js +2 -1
  18. package/dist/routes/scripted/plan.js +5 -4
  19. package/dist/routes/scripted/route.js +5 -1
  20. package/dist/routes/scripted/surface-sessions.d.ts +2 -0
  21. package/dist/routes/scripted/surface-sessions.js +2 -1
  22. package/dist/routes/shared-world/participant-specs.js +5 -1
  23. package/dist/routes/shared-world/plan.js +7 -6
  24. package/dist/routes/shared-world/route.js +3 -3
  25. package/dist/routes/terminal/plan.js +4 -3
  26. package/dist/routes/terminal/route.js +2 -1
  27. package/dist/routes/terminal/session.js +1 -0
  28. package/dist/run/display.d.ts +10 -0
  29. package/dist/run/display.js +22 -0
  30. package/dist/run/reclaim.d.ts +4 -2
  31. package/dist/run/reclaim.js +61 -18
  32. package/dist/run/run.d.ts +11 -1
  33. package/dist/run/run.js +24 -5
  34. package/dist/run/status.d.ts +6 -0
  35. package/dist/run/status.js +1 -0
  36. package/dist/run/stored-runs.js +26 -11
  37. package/dist/study/composition-rules.js +32 -32
  38. package/dist/study/config.d.ts +9 -2
  39. package/dist/study/config.js +14 -5
  40. package/dist/study/doctor.js +5 -4
  41. package/dist/study/{convert.d.ts → migrate/convert.d.ts} +1 -1
  42. package/dist/study/{convert.js → migrate/convert.js} +29 -31
  43. package/dist/study/{migrate.d.ts → migrate/migrate.d.ts} +1 -1
  44. package/dist/study/{migrate.js → migrate/migrate.js} +2 -2
  45. package/dist/study/{owned-files.js → migrate/owned-files.js} +1 -1
  46. package/dist/study/migrate/v2.d.ts +68 -0
  47. package/dist/study/migrate/v2.js +480 -0
  48. package/dist/study/parse/actors.d.ts +46 -1
  49. package/dist/study/parse/actors.js +116 -69
  50. package/dist/study/parse/execution.js +2 -2
  51. package/dist/study/parse/study-v3.d.ts +26 -0
  52. package/dist/study/parse/study-v3.js +53 -35
  53. package/dist/study/parse/study.d.ts +38 -0
  54. package/dist/study/parse/study.js +225 -0
  55. package/dist/study/persona-resolve.d.ts +2 -4
  56. package/dist/study/persona-resolve.js +2 -1
  57. package/dist/study/plan-base.d.ts +1 -1
  58. package/dist/study/plan-base.js +5 -4
  59. package/dist/study/plan-participants.js +12 -11
  60. package/dist/study/plan.js +4 -3
  61. package/dist/study/preflight-probes.js +2 -2
  62. package/dist/study/preflight.js +2 -2
  63. package/dist/study/routing.js +12 -15
  64. package/dist/study/run-study-options.js +3 -3
  65. package/dist/study/study-fields.d.ts +28 -0
  66. package/dist/study/study-fields.js +51 -0
  67. package/dist/study/summary.js +13 -17
  68. package/dist/study/types.d.ts +40 -0
  69. package/dist/study/url-credentials.js +2 -1
  70. package/dist/study/validation.d.ts +2 -0
  71. package/dist/study/validation.js +46 -41
  72. package/dist/study/warnings.js +5 -4
  73. package/dist/substrates/local/runtime-config.js +9 -9
  74. package/dist/tui-app.js +3 -3
  75. package/docs/contracts/schemas.md +13 -4
  76. package/package.json +1 -1
  77. /package/dist/study/{owned-files.d.ts → migrate/owned-files.d.ts} +0 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,20 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.110.4: Scripted runs reclaim clean, review says interrupted, Chrome under a long TMPDIR (2026-10-05)
12
+
13
+ humanish 0.110.4 fixes four findings of the 0.110.3 live smoke. `humanish reclaim --check` reports
14
+ a live scripted run against an `app-url` subject `clean`, with reason `no-sandbox`, and exits 0.
15
+ `humanish review` on a computer-use run stopped by Ctrl-C after its desktop started says the run
16
+ was interrupted. A run that failed before any participant started no longer prints "Participants
17
+ finished; preparing analysis…". The scripted route launches Chrome when `TMPDIR` is too long for
18
+ Chrome's socket path. Planner refusals for a library config built without `parseStudy` name v3
19
+ keys, and so does `humanish migrate` when one of those checks refuses a v2 file. Inside, a v3
20
+ parser runs beside `parseStudy`, src reads the study through accessors, and `humanish migrate`
21
+ reads v2 files through its own front end; the library API is unchanged.
22
+
23
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.110.4)
24
+
11
25
  ## 0.110.3: Ctrl-C at the watch prompt, review says analysis is off, E2B docs links kept (2026-10-05)
12
26
 
13
27
  humanish 0.110.3 runs the `watch` and `observe` shutdown, and prints `watch stopped` or `observe
@@ -23,17 +23,26 @@ import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, pre
23
23
  import { ACTOR_TRACE_SCHEMA, SCRIPTED_BROWSER_CAPABILITIES, } from "../contract.js";
24
24
  import { buildBlockedBrowserPersonaSteps, buildBrowserTrace, captureBlockedStepScreenshot, compactBrowserError, executeBrowserPersonaStep, probeAppUrl, sanitizeBrowserEvidenceUrl, screenshotPathForBrowserStep, surfaceScreenshotPath, tracePathForBrowserSurface, } from "./steps.js";
25
25
  import { LOOPBACK_EVIDENCE_URL_POLICY, } from "./types.js";
26
+ import { chromeLaunchEnv, chromeLaunchError } from "./launch-env.js";
26
27
  /** Production default: lazy playwright-core import + chromium.launch, exactly as the driver
27
- * always did. Kept in one place so the optional peer is touched by exactly one code path. */
28
+ * always did. Kept in one place so the optional peer is touched by exactly one code path. A
29
+ * `TMPDIR` too long for Chrome's singleton socket is replaced for Chrome (chromeLaunchEnv). */
28
30
  async function launchPlaywrightChromium(args) {
29
31
  const { chromium } = await import("playwright-core");
30
- const browser = await chromium.launch({
31
- executablePath: args.browserCommand,
32
- headless: true,
33
- args: [...CHROMIUM_EVIDENCE_HYGIENE_FLAGS, "--disable-gpu", "--disable-dev-shm-usage"],
34
- timeout: args.timeoutMs,
35
- });
36
- return browser;
32
+ const env = await chromeLaunchEnv();
33
+ try {
34
+ const browser = await chromium.launch({
35
+ executablePath: args.browserCommand,
36
+ headless: true,
37
+ args: [...CHROMIUM_EVIDENCE_HYGIENE_FLAGS, "--disable-gpu", "--disable-dev-shm-usage"],
38
+ timeout: args.timeoutMs,
39
+ ...(env === undefined ? {} : { env }),
40
+ });
41
+ return browser;
42
+ }
43
+ catch (error) {
44
+ throw chromeLaunchError(error, env);
45
+ }
37
46
  }
38
47
  function assertScriptedSessionPathIds(options) {
39
48
  assertSafeOutputPathSegment(options.surface.id, "Browser surface id");
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The environment to launch Chrome with: `env` with `TMPDIR` set to /tmp when its own `TMPDIR` is
3
+ * too long for the singleton socket and /tmp is a writable directory. Undefined when Chrome can
4
+ * launch with `env` as it is, or when /tmp cannot be used; chromeLaunchError then names the cause.
5
+ */
6
+ export declare function chromeLaunchEnv(env?: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv | undefined>;
7
+ /**
8
+ * The launch error to report: one that names the cause and the fix when Chrome aborted because its
9
+ * singleton socket path was too long, and `error` itself otherwise. `env` is the environment
10
+ * Chrome was launched with.
11
+ */
12
+ export declare function chromeLaunchError(error: unknown, env?: NodeJS.ProcessEnv): unknown;
@@ -0,0 +1,50 @@
1
+ // Chrome's process singleton listens on a Unix socket at `$TMPDIR/<product>.XXXXXX/SingletonSocket`,
2
+ // where the product is com.google.Chrome, or org.chromium.Chromium for Chromium and Chrome for
3
+ // Testing. A Unix socket path holds at most 107 bytes on Linux and 103 on macOS, so under a longer
4
+ // `TMPDIR`, such as a sandboxed agent's private temp directory, Chrome aborts at launch with
5
+ // "Socket path too long", and Playwright reports only that the browser closed. The scripted
6
+ // browser gives Chrome /tmp in that case; the browser profile stays where Playwright puts it.
7
+ import { constants } from "node:fs";
8
+ import { access } from "node:fs/promises";
9
+ /** `/org.chromium.Chromium.XXXXXX/SingletonSocket`: the longest path Chrome adds to `TMPDIR`. */
10
+ const SOCKET_SUFFIX_BYTES = 45;
11
+ /** The longest socket path, without its NUL: `sun_path` holds 104 bytes on macOS, 108 elsewhere. */
12
+ const SOCKET_PATH_MAX_BYTES = process.platform === "darwin" ? 103 : 107;
13
+ /** Where Chrome makes its socket directory when `TMPDIR` is unset. */
14
+ const DEFAULT_TMPDIR = "/tmp";
15
+ /** The byte length of `TMPDIR` when Chrome's singleton socket path under it would be too long. */
16
+ function overlongTmpdirBytes(env) {
17
+ const tmpdir = env.TMPDIR?.replace(/\/+$/, "");
18
+ if (tmpdir === undefined || tmpdir === "")
19
+ return undefined;
20
+ const bytes = Buffer.byteLength(tmpdir);
21
+ return bytes + SOCKET_SUFFIX_BYTES > SOCKET_PATH_MAX_BYTES ? bytes : undefined;
22
+ }
23
+ /**
24
+ * The environment to launch Chrome with: `env` with `TMPDIR` set to /tmp when its own `TMPDIR` is
25
+ * too long for the singleton socket and /tmp is a writable directory. Undefined when Chrome can
26
+ * launch with `env` as it is, or when /tmp cannot be used; chromeLaunchError then names the cause.
27
+ */
28
+ export async function chromeLaunchEnv(env = process.env) {
29
+ if (process.platform === "win32" || overlongTmpdirBytes(env) === undefined)
30
+ return undefined;
31
+ try {
32
+ await access(DEFAULT_TMPDIR, constants.W_OK | constants.X_OK);
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ return { ...env, TMPDIR: DEFAULT_TMPDIR };
38
+ }
39
+ /**
40
+ * The launch error to report: one that names the cause and the fix when Chrome aborted because its
41
+ * singleton socket path was too long, and `error` itself otherwise. `env` is the environment
42
+ * Chrome was launched with.
43
+ */
44
+ export function chromeLaunchError(error, env = process.env) {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ if (!message.includes("Socket path too long"))
47
+ return error;
48
+ const bytes = overlongTmpdirBytes(env);
49
+ return new Error(`Chrome could not start: its singleton socket path under \`TMPDIR\`${bytes === undefined ? "" : ` (${bytes} bytes)`} is longer than the ${SOCKET_PATH_MAX_BYTES} bytes a Unix socket path allows. Set \`TMPDIR\` to a directory of at most ${SOCKET_PATH_MAX_BYTES - SOCKET_SUFFIX_BYTES} bytes, such as /tmp, and run again.`);
50
+ }
@@ -9,7 +9,8 @@ export interface AnalysisInput {
9
9
  readonly deps?: Pick<StudyDeps, "analysis">;
10
10
  /** Cancels the analysis only. */
11
11
  readonly analysisSignal?: AbortSignal;
12
- /** Receives analysis-started and analysis-finished. */
12
+ /** Receives analysis-started and analysis-finished, around the analysis of a run where a
13
+ * participant's session started. */
13
14
  readonly emit?: (event: StudyEvent) => void;
14
15
  }
15
16
  export interface AutomaticAnalysisResult {
@@ -38,8 +38,13 @@ export async function completeAutomaticAnalysis(result, finished, config, input,
38
38
  const signal = input?.analysisSignal;
39
39
  let started = false;
40
40
  try {
41
- input?.emit?.({ type: "analysis-started" });
42
- started = true;
41
+ // The CLI prints "Participants finished" on analysis-started. A run where no participant's
42
+ // session started (it failed before, such as at E2B login) still records its analysis
43
+ // outcome, but without the two events.
44
+ if (finished.participantsRan) {
45
+ input?.emit?.({ type: "analysis-started" });
46
+ started = true;
47
+ }
43
48
  try {
44
49
  await validatePreparedRunRootIdentity(prepared);
45
50
  }
@@ -1,4 +1,4 @@
1
- import { migrateStudies } from "../../study/migrate.js";
1
+ import { migrateStudies, } from "../../study/migrate/migrate.js";
2
2
  import { CWD_OPTION_DESCRIPTION, JSON_OPTION_DESCRIPTION, wantsJson, writeResult, } from "../io.js";
3
3
  function formatValue(value) {
4
4
  const text = JSON.stringify(value);
@@ -217,9 +217,12 @@ function formatReclaimHuman(result, cwd, preflight) {
217
217
  const search = result.tagSearch.status === "done"
218
218
  ? `E2B lists ${plural(result.tagSearch.found, "more sandbox", "more sandboxes")} tagged with it`
219
219
  : `E2B tag search ${result.tagSearch.status}`;
220
+ const head = `${check ? "Reclaim check" : "Reclaim"} ${result.runId}: ${result.state}.`;
220
221
  lines.push(result.reason === "dry-run"
221
- ? `${check ? "Reclaim check" : "Reclaim"} ${result.runId}: ${result.state}. It was a dry run, which creates no sandboxes, so E2B was not contacted.`
222
- : `${check ? "Reclaim check" : "Reclaim"} ${result.runId}: ${result.state}. ${plural(result.receiptCount, "sandbox receipt")}; ${search}.`);
222
+ ? `${head} It was a dry run, which creates no sandboxes, so E2B was not contacted.`
223
+ : result.reason === "no-sandbox"
224
+ ? `${head} Its status.json records that its route created no sandbox, so E2B was not contacted.`
225
+ : `${head} ${plural(result.receiptCount, "sandbox receipt")}; ${search}.`);
223
226
  for (const outcome of result.outcomes) {
224
227
  lines.push(` sandbox ${outcome.sandboxIdDigest} (${outcome.laneId}, from ${outcome.source}): ${outcome.state}${outcome.detail ? `: ${outcome.detail}` : ""}`);
225
228
  }
@@ -13,6 +13,7 @@ import { writeRunFindings } from "../findings.js";
13
13
  import { formatCuaStudyHuman } from "./study-format.js";
14
14
  import { resolveRouteShouldOpen } from "./study-route-open.js";
15
15
  import { studyResultIdentity } from "../../run/study-result.js";
16
+ import { actorOf, declaredParticipantCount } from "../../study/study-fields.js";
16
17
  /**
17
18
  * The computer-use route's CLI setup: its settings, the watch and exposure plan, the runStudyWith options
18
19
  * with the live Observer hook, and how it presents the outcome or a run error. Undefined when
@@ -54,7 +55,7 @@ function refuseCua(args, dryRun, code, message) {
54
55
  ...studyResultIdentity("computer-use", args.config.id),
55
56
  ok: false,
56
57
  cwd: args.options.cwd,
57
- actor: args.config.actors[0]?.type ?? "",
58
+ actor: actorOf(args.config)?.type ?? "",
58
59
  appUrl: "",
59
60
  dryRun,
60
61
  runId: args.options.runId ?? "not-created",
@@ -79,7 +80,7 @@ function resolveCuaSettings(args) {
79
80
  args.io.setExitCode(2);
80
81
  return undefined;
81
82
  }
82
- const count = parseStudyCount(args.options.count, args.config.actors[0]?.count ?? 1);
83
+ const count = parseStudyCount(args.options.count, declaredParticipantCount(args.config) ?? 1);
83
84
  if (count === null) {
84
85
  args.io.writeErr("error: --count must be a positive integer.\n");
85
86
  args.io.setExitCode(2);
@@ -2,12 +2,13 @@ import { Command } from "commander";
2
2
  import { studyResultIdentity } from "../../run/study-result.js";
3
3
  import { formatRunHuman, parseStudyCount, writeResult, } from "../io.js";
4
4
  import { planObserver, showObserver, staticObserverOpen } from "../observer-follow.js";
5
+ import { declaredParticipantCount } from "../../study/study-fields.js";
5
6
  /**
6
7
  * The preview route's CLI setup: the participant count and Observer plan, its runStudyWith options, and how it
7
8
  * presents the outcome. Undefined when setup has already written its own result.
8
9
  */
9
10
  export function previewRouteRun(args) {
10
- const participantCount = parseStudyCount(args.options.count, args.config.actors[0]?.count ?? 4);
11
+ const participantCount = parseStudyCount(args.options.count, declaredParticipantCount(args.config) ?? 4);
11
12
  if (participantCount === null) {
12
13
  const result = {
13
14
  ...studyResultIdentity("preview", args.config.id),
@@ -9,8 +9,8 @@ import { followDecision, followObserver, showObserver, staticObserverOpen, withO
9
9
  import { writeRunFindings } from "../findings.js";
10
10
  import { formatConcurrentSharedWorldStudyHuman } from "./study-format.js";
11
11
  import { resolveRouteShouldOpen, watchFinishedPlan } from "./study-route-open.js";
12
- import { rosterOf } from "../../study/parse/actors.js";
13
12
  import { studyResultIdentity } from "../../run/study-result.js";
13
+ import { actorOf, participantList } from "../../study/study-fields.js";
14
14
  /**
15
15
  * The shared-world route's CLI setup: dry run, open and follow semantics, the live Observer server
16
16
  * the run attaches, and how it presents the outcome. Undefined when setup has already written its
@@ -34,10 +34,10 @@ export function sharedWorldRouteRun(args) {
34
34
  ...studyResultIdentity("shared-world", args.config.id),
35
35
  ok: false,
36
36
  cwd: args.options.cwd,
37
- actor: args.config.actors[0]?.type ?? "",
37
+ actor: actorOf(args.config)?.type ?? "",
38
38
  topology: "shared-world",
39
39
  topologyMode: "concurrent",
40
- roleCount: rosterOf(args.config.actors[0])?.length ?? 0,
40
+ roleCount: participantList(args.config)?.length ?? 0,
41
41
  concurrency: args.config.execution?.concurrency ?? 1,
42
42
  dryRun,
43
43
  runId: runId ?? args.options.runId ?? "not-created",
@@ -4,6 +4,7 @@ import { Command, Option } from "commander";
4
4
  import { inspectStudyManifest, listStudyManifests } from "../../study/discover.js";
5
5
  import { runStudyPreflight, } from "../../study/preflight.js";
6
6
  import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION, dotenvPathOf, envFileAliasOption, JSON_OPTION_DESCRIPTION, parsePositiveInteger, writeResult, humanError, } from "../io.js";
7
+ import { actorsOf } from "../../study/study-fields.js";
7
8
  export function registerStudyCommands(parent, io) {
8
9
  const study = parent
9
10
  .command("study")
@@ -114,7 +115,9 @@ function formatStudyShowHuman(result) {
114
115
  `id: ${config.id}`,
115
116
  `subject: ${config.subject.source}`,
116
117
  ...(config.execution?.target ? [`execution: ${config.execution.target}`] : []),
117
- `actors: ${config.actors.map((actor) => actor.type).join(", ")}`,
118
+ `actors: ${actorsOf(config)
119
+ .map((actor) => actor.type)
120
+ .join(", ")}`,
118
121
  ...(config.title ? [`title: ${config.title}`] : []),
119
122
  ...(config.description ? [`description: ${config.description}`] : []),
120
123
  ...(result.path ? [`path: ${result.path}`] : []),
@@ -102,7 +102,14 @@ export async function runStudyParticipants(setup, participants) {
102
102
  let failFastReason;
103
103
  try {
104
104
  if (!dryRun)
105
- ({ outcomes, failFastReason } = await runAllCuaParticipants(participantRuns, deps, participantPlan, inProcess));
105
+ ({ outcomes, failFastReason } = await runAllCuaParticipants(participantRuns, {
106
+ ...deps,
107
+ // A session that throws returns no trace; the run still records that it started.
108
+ runSession: (options) => {
109
+ run.participantStarted();
110
+ return deps.runSession(options);
111
+ },
112
+ }, participantPlan, inProcess));
106
113
  }
107
114
  finally {
108
115
  try {
@@ -10,6 +10,7 @@ import { createRestrictedCodexParticipant } from "../../actors/codex/restricted-
10
10
  import { withCloseReport } from "./participant-model.js";
11
11
  import { guestMediaConfigSchema } from "../../guest/media-config.js";
12
12
  import { startLocalCapturedInbox } from "../../substrates/local/captured-inbox.js";
13
+ import { actorOf } from "../../study/study-fields.js";
13
14
  function guestMedia(config) {
14
15
  const declaredMedia = config.execution?.desktop?.media;
15
16
  return declaredMedia === undefined
@@ -164,7 +165,7 @@ export function prepareLocalVmRun(options) {
164
165
  const media = guestMedia(config);
165
166
  // A caller-supplied provider replaces the Codex account participant, so the account is
166
167
  // neither checked nor used.
167
- const account = config.actors[0]?.type === "local-agent" && options.createProvider === undefined;
168
+ const account = actorOf(config)?.type === "local-agent" && options.createProvider === undefined;
168
169
  const state = { sessions: [], participants: [], cleanupUnconfirmed: false };
169
170
  const context = {
170
171
  config,
@@ -7,7 +7,7 @@ import { actorRegistry, isCuaActorDescriptor, } from "../../actors/registry.js";
7
7
  import { isHttpUrl, isLoopbackUrl } from "../../study/parse/subject.js";
8
8
  import { studyUrlCredentialReason } from "../../study/url-credentials.js";
9
9
  import { subjectStateInvalidReason } from "../../study/parse/subject-state.js";
10
- import { brainOf, callerBrainOf, capsOf, desktopRequirements, planBase, provisionedSubject, } from "../../study/plan-base.js";
10
+ import { brainOf, callerBrainOf, planCaps, desktopRequirements, planBase, provisionedSubject, } from "../../study/plan-base.js";
11
11
  import { computerUseParticipants, declaredTargets } from "../../study/plan-participants.js";
12
12
  import { MAX_COMPUTER_USE_PARTICIPANTS } from "../../study/routing.js";
13
13
  import { cloneTargetValidationReason, computerUseValidationReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, scenarioCapsValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
@@ -16,6 +16,7 @@ import { isLocalBrowserStudy } from "../../substrates/local/runtime-config.js";
16
16
  import { boundedConcurrency, defaultSessionTimeoutMs, resolveParticipantSandboxMs, } from "./participant-runs.js";
17
17
  import { MAX_SANDBOX_MS } from "../../substrates/e2b/lifetime.js";
18
18
  import {} from "./types.js";
19
+ import { actorOf, declaresSharedWorld } from "../../study/study-fields.js";
19
20
  /** The registered descriptor for a planned actor id; planComputerUseStudy checked the registry. */
20
21
  export function cuaDescriptorOf(actor) {
21
22
  const descriptor = actorRegistry[actor];
@@ -88,7 +89,7 @@ function unsupportedDeclarationReason(config, hasRunSession, driving, { inProces
88
89
  scenarioCapsValidationReason(config);
89
90
  if (reason)
90
91
  return invalid(reason);
91
- if (config.actors[0]?.maxOutputTokens !== undefined &&
92
+ if (actorOf(config)?.maxOutputTokens !== undefined &&
92
93
  (hasRunSession || driving.createProvider || driving.inProcess))
93
94
  return invalid("maxOutputTokens cannot be enforced by a custom runSession/provider/executor route.");
94
95
  const receivingReason = receivingEmailValidationReason(config);
@@ -220,7 +221,7 @@ export function planComputerUseStudy(config, input) {
220
221
  if (tasksReason)
221
222
  return refuse("before-scope", "HUMANISH_STUDY_TASKS_UNSUPPORTED", tasksReason);
222
223
  // The parser checks the actor too; a library caller skips the parser.
223
- const actorType = config.actors[0]?.type ?? "";
224
+ const actorType = actorOf(config)?.type ?? "";
224
225
  const descriptor = actorRegistry[actorType];
225
226
  if (!descriptor || !isCuaActorDescriptor(descriptor))
226
227
  return refuse("in-scope", "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED", `actor.type "${actorType}" is not a registered computer-use actor.`);
@@ -231,7 +232,7 @@ export function planComputerUseStudy(config, input) {
231
232
  // A shared world runs every participant against one app; this route would run them as separate
232
233
  // participants. It comes after the rules above, so a shared-world config that breaks one of them,
233
234
  // which runStudyWith sends here, still gets that rule's message.
234
- if (config.subject.topology === "shared-world")
235
+ if (declaresSharedWorld(config))
235
236
  return refuse("in-scope", "HUMANISH_COMPUTER_USE_SUBJECT_INVALID", "route: shared-world studies run every participant against one shared app on the shared-world route; this route runs independent participants. The shared-world route takes subject.source clone or local-tree, or app-url with policies.allowPublicTargets: true, on execution.target e2b-desktop.", actor);
236
237
  // The in-process route drives subject.appUrl on this machine and creates no desktop, so it would
237
238
  // skip the subject a clone, local-tree or desktop-cli study declares.
@@ -292,7 +293,7 @@ export function planComputerUseStudy(config, input) {
292
293
  concurrency: boundedConcurrency(declared, n),
293
294
  sessionBudgetMs: config.execution?.timeoutMs ?? defaultSessionTimeoutMs(config),
294
295
  sandboxMs: resolveParticipantSandboxMs(config),
295
- caps: capsOf(config),
296
+ caps: planCaps(config, "computer-use"),
296
297
  ...(input.rerun === undefined ? {} : { rerun: rerunPlan(input.rerun) }),
297
298
  requirements: base.dryRun || runner.desktop === "in-process"
298
299
  ? []
@@ -32,6 +32,7 @@ import { runStudyParticipants } from "./live-phase.js";
32
32
  import { admitCuaRun, refuseCuaStudy, startCuaRun } from "./setup.js";
33
33
  import {} from "./types.js";
34
34
  import { studyResultIdentity } from "../../run/study-result.js";
35
+ import { actorOf } from "../../study/study-fields.js";
35
36
  /**
36
37
  * A refused computer-use study's result, at the refusal's stage: a before-scope refusal has its own
37
38
  * envelope and no analysis record; the others come after the cwd checks, and the participant cap after
@@ -44,7 +45,7 @@ export async function computerUseStudyRefusal(options, refusal) {
44
45
  ...studyResultIdentity("computer-use", config.id),
45
46
  ok: false,
46
47
  cwd: path.resolve(options.cwd),
47
- actor: config.actors[0]?.type ?? "",
48
+ actor: actorOf(config)?.type ?? "",
48
49
  dryRun,
49
50
  runId: options.runId ?? "not-created",
50
51
  appUrl: config.subject.appUrl ?? config.subject.serve?.url ?? "",
@@ -25,6 +25,7 @@ import { projectParticipantSubjects, subjectProvenanceArg } from "./subject-proj
25
25
  import { participantSubjectEnv, } from "./types.js";
26
26
  import { studyPersonaIds } from "../../study/persona-resolve.js";
27
27
  import { studyResultIdentity } from "../../run/study-result.js";
28
+ import { actorOf } from "../../study/study-fields.js";
28
29
  /** The physical project, bound before any caller hook runs. */
29
30
  async function bindProject(cwd) {
30
31
  // Capture the physical project before reading or invoking any caller hook. A supported
@@ -47,7 +48,7 @@ export async function refuseCuaStudy(options, refusal) {
47
48
  ...studyResultIdentity("computer-use", config.id),
48
49
  ok: false,
49
50
  cwd: projectRoot.physicalPath,
50
- actor: refusal.actor ?? config.actors[0]?.type ?? "",
51
+ actor: refusal.actor ?? actorOf(config)?.type ?? "",
51
52
  appUrl: declaredAppUrl(config),
52
53
  dryRun,
53
54
  runId: options.runId ?? "not-created",
@@ -7,6 +7,7 @@ import { browserSurfaces } from "../../actors/scripted-browser/types.js";
7
7
  import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
8
8
  import { isNonEmpty, planBase, provisionedSubject } from "../../study/plan-base.js";
9
9
  import { cloneTargetValidationReason, desktopMediaValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
10
+ import { actorOf, surfaceCount, scenarioRefOf } from "../../study/study-fields.js";
10
11
  // Default surface roster is 1 (desktop only): the defaults-table single-participant row governs;
11
12
  // `count: 2` is the declared override that adds the mobile surface.
12
13
  const DEFAULT_SURFACE_COUNT = 1;
@@ -42,7 +43,7 @@ export function planScriptedStudy(config, input) {
42
43
  const cloneTargetReason = cloneTargetValidationReason(config);
43
44
  if (cloneTargetReason)
44
45
  return refuse("HUMANISH_SCRIPTED_SUBJECT_UNSAFE", cloneTargetReason);
45
- const actorType = config.actors[0]?.type ?? "";
46
+ const actorType = actorOf(config)?.type ?? "";
46
47
  const descriptor = actorRegistry[actorType];
47
48
  if (!descriptor || !isScriptedBrowserActorDescriptor(descriptor))
48
49
  return refuse("HUMANISH_SCRIPTED_ACTOR_UNSUPPORTED", `actor.type "${actorType}" is not a registered scripted-browser actor.`);
@@ -63,7 +64,7 @@ export function planScriptedStudy(config, input) {
63
64
  subject = { kind: "loopback", appUrl };
64
65
  }
65
66
  // The steps are the actor, so there is no built-in journey to fall back on.
66
- const scenarioRef = config.scenario?.ref;
67
+ const scenarioRef = scenarioRefOf(config);
67
68
  if (!scenarioRef?.trim())
68
69
  return refuse("HUMANISH_SCRIPTED_SCENARIO_INVALID", "A scripted-browser study needs `scenario`: the actor runs the browser steps in that scenario file.", { actor, appUrl: evidenceAppUrlOf(subject) });
69
70
  const requirements = [];
@@ -75,7 +76,7 @@ export function planScriptedStudy(config, input) {
75
76
  // The browser runs on this machine on both subjects; a clone's is pointed at its getHost URL.
76
77
  if (!input.dryRun && input.injectedBrowser !== true)
77
78
  requirements.push({ kind: "host-browser" });
78
- const persona = config.actors[0]?.persona;
79
+ const persona = actorOf(config)?.persona;
79
80
  const timeoutMs = config.execution?.timeoutMs;
80
81
  return {
81
82
  ok: true,
@@ -88,7 +89,7 @@ export function planScriptedStudy(config, input) {
88
89
  actor,
89
90
  subject,
90
91
  scenarioRef,
91
- surfaces: browserSurfaces.slice(0, config.actors[0]?.count ?? DEFAULT_SURFACE_COUNT),
92
+ surfaces: browserSurfaces.slice(0, surfaceCount(config) ?? DEFAULT_SURFACE_COUNT),
92
93
  ...(persona === undefined ? {} : { personaId: persona }),
93
94
  ...(timeoutMs === undefined ? {} : { sessionTimeoutMs: timeoutMs }),
94
95
  requirements,
@@ -31,6 +31,7 @@ import { ScriptedSubject } from "./subject.js";
31
31
  import { runScriptedSessions, writeSurfaceTraces } from "./surface-sessions.js";
32
32
  import {} from "./types.js";
33
33
  import { studyResultIdentity } from "../../run/study-result.js";
34
+ import { actorOf } from "../../study/study-fields.js";
34
35
  /**
35
36
  * A refused scripted study's result, at the refusal's stage: a before-scope refusal has its own field
36
37
  * order and no analysis record; the others come after the output directory checks.
@@ -38,7 +39,7 @@ import { studyResultIdentity } from "../../run/study-result.js";
38
39
  export async function scriptedStudyRefusal(options, refusal) {
39
40
  const { config, dryRun } = options;
40
41
  const cwd = path.resolve(options.cwd);
41
- const actorType = config.actors[0]?.type ?? "";
42
+ const actorType = actorOf(config)?.type ?? "";
42
43
  if (refusal.beforeScope)
43
44
  return {
44
45
  ...studyResultIdentity("scripted", config.id),
@@ -94,6 +95,8 @@ async function runScriptedPlanInScope(plan, input, scope) {
94
95
  mintRunId: makeScriptedRunId,
95
96
  mode: dryRun ? "dry-run" : "live",
96
97
  study: plan.study,
98
+ // Only a clone subject is served from a sandbox; an app-url run drives a local browser.
99
+ sandboxes: setup.clone ? undefined : "none",
97
100
  renderReview: renderScriptedReviewMarkdown,
98
101
  observer: { open: input.open === true, render: setup.deps.renderObserver },
99
102
  });
@@ -144,6 +147,7 @@ async function runScriptedPlanInScope(plan, input, scope) {
144
147
  deps: setup.deps,
145
148
  browserCommand: setup.browserCommand,
146
149
  runPaths,
150
+ onSessionStart: () => run.participantStarted(),
147
151
  });
148
152
  }
149
153
  catch (error) {
@@ -8,6 +8,8 @@ export declare function runScriptedSessions(session: Required<Pick<ScriptedBrows
8
8
  deps: StudyDeps;
9
9
  browserCommand: string | undefined;
10
10
  runPaths: PreparedRunArtifactPaths;
11
+ /** Called as each surface's session starts, before it can throw or return. */
12
+ onSessionStart: () => void;
11
13
  }): Promise<ScriptedBrowserSessionResult[]>;
12
14
  /** Writes each surface's actor-<surface>.json, as the computer-use route writes actor.json. */
13
15
  export declare function writeSurfaceTraces(runPaths: PreparedRunArtifactPaths, results: readonly ScriptedBrowserSessionResult[]): Promise<void>;
@@ -7,9 +7,10 @@ import { validateScriptedSessionResult } from "./session-result.js";
7
7
  /** One session per surface, in parallel. */
8
8
  export function runScriptedSessions(session, run) {
9
9
  const { appUrl, evidenceAppUrl, urlPolicy, journey, persona, timeoutMs, artifactRoot } = session;
10
- const { surfaces, deps, browserCommand, runPaths } = run;
10
+ const { surfaces, deps, browserCommand, runPaths, onSessionStart } = run;
11
11
  const runSession = deps.runScriptedSession;
12
12
  return Promise.all(surfaces.map((surface) => {
13
+ onSessionStart();
13
14
  const sessionOptions = {
14
15
  appUrl,
15
16
  evidenceAppUrl,
@@ -167,7 +167,11 @@ export function participantRunDeps(ctx, live, scrubKnownValues) {
167
167
  studyCwd: ctx.cwd,
168
168
  redactScreenshots: ctx.redactScreenshots,
169
169
  scrubKnownValues,
170
- runSession: ctx.runSession,
170
+ // A session that throws returns no trace; the run still records that it started.
171
+ runSession: (options) => {
172
+ ctx.run.participantStarted();
173
+ return ctx.runSession(options);
174
+ },
171
175
  // A local-agent brain runs each participant on the operator's signed-in agent, as on computer
172
176
  // use.
173
177
  brain: ctx.plan.brain,
@@ -6,12 +6,13 @@ import { DEFAULT_OPENAI_CU_MODEL } from "../../actors/computer-use/openai-provid
6
6
  import { studyUrlCredentialReason } from "../../study/url-credentials.js";
7
7
  import { actorRegistry, isCuaActorDescriptor, } from "../../actors/registry.js";
8
8
  import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
9
- import { brainOf, capsOf, desktopRequirements, isNonEmpty, planBase, } from "../../study/plan-base.js";
9
+ import { brainOf, planCaps, desktopRequirements, isNonEmpty, planBase, } from "../../study/plan-base.js";
10
10
  import { sharedWorldParticipants } from "../../study/plan-participants.js";
11
11
  import { PUBLIC_TARGET_OWNER_PATTERN } from "../../study/parse/subject.js";
12
12
  import { REPO_SLUG_PATTERN } from "../../study/parse/values.js";
13
13
  import { concurrentSharedWorldValidationReason, desktopMediaValidationReason, externalPublicSharedWorldValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, scenarioCapsValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
14
14
  import { MODEL_RATES, unpricedCapMessage } from "../../run/pricing.js";
15
+ import { actorOf, capsOf } from "../../study/study-fields.js";
15
16
  /** The registered descriptor for a planned actor; planSharedWorldStudy checked the registry. */
16
17
  export function sharedWorldDescriptorOf(actorType) {
17
18
  const descriptor = actorRegistry[actorType];
@@ -38,7 +39,7 @@ export function planSharedWorldStudy(config, input) {
38
39
  const tasksReason = taskProtocolValidationReason(config, false);
39
40
  if (tasksReason)
40
41
  return refuse("HUMANISH_STUDY_TASKS_UNSUPPORTED", tasksReason);
41
- const actorType = config.actors[0]?.type ?? "";
42
+ const actorType = actorOf(config)?.type ?? "";
42
43
  const descriptor = actorRegistry[actorType];
43
44
  if (!descriptor || !isCuaActorDescriptor(descriptor))
44
45
  return refuse("HUMANISH_SHARED_WORLD_ACTOR_UNSUPPORTED", `actor.type "${actorType}" is not a registered computer-use actor.`);
@@ -59,11 +60,11 @@ export function planSharedWorldStudy(config, input) {
59
60
  : concurrentSharedWorldValidationReason(config));
60
61
  if (invalidReason)
61
62
  return refuse(invalid, invalidReason, actor);
62
- if (config.actors[0]?.maxOutputTokens !== undefined && input.hasRunSession === true)
63
+ if (actorOf(config)?.maxOutputTokens !== undefined && input.hasRunSession === true)
63
64
  return refuse(invalid, "maxOutputTokens cannot be enforced by a custom runSession.", actor);
64
- const caps = config.execution?.caps;
65
+ const caps = capsOf(config, "shared-world");
65
66
  if (!input.dryRun && (caps?.maxUsd !== undefined || caps?.maxTotalUsd !== undefined)) {
66
- const model = (config.actors[0]?.model ?? DEFAULT_OPENAI_CU_MODEL).trim().toLowerCase();
67
+ const model = (actorOf(config)?.model ?? DEFAULT_OPENAI_CU_MODEL).trim().toLowerCase();
67
68
  if (!MODEL_RATES[model])
68
69
  return refuse("HUMANISH_SHARED_WORLD_UNPRICED_CAP", unpricedCapMessage(model), actor);
69
70
  }
@@ -106,7 +107,7 @@ export function planSharedWorldStudy(config, input) {
106
107
  ? {}
107
108
  : { sessionTimeoutMs: config.execution.timeoutMs }),
108
109
  brain,
109
- caps: capsOf(config),
110
+ caps: planCaps(config, "shared-world"),
110
111
  requirements: base.dryRun
111
112
  ? []
112
113
  : [
@@ -48,7 +48,7 @@ import { prepareConcurrentRun } from "./setup.js";
48
48
  import { browserRouteScorer } from "../../study/adapter-scorer-loader.js";
49
49
  import { withLateScorer } from "../../study/route-inputs.js";
50
50
  import {} from "./types.js";
51
- import { rosterOf } from "../../study/parse/actors.js";
51
+ import { actorOf, participantList } from "../../study/study-fields.js";
52
52
  /** The shared-world code for each local-agent refusal, kind for kind with computer-use. */
53
53
  const LOCAL_AGENT_REFUSAL_CODES = {
54
54
  "agent-missing": "HUMANISH_SHARED_WORLD_AGENT_MISSING",
@@ -59,11 +59,11 @@ const LOCAL_AGENT_REFUSAL_CODES = {
59
59
  /** A refused shared-world study's result: the route's envelope, and a refusal's analysis record. */
60
60
  export function sharedWorldStudyRefusal(options, refusal) {
61
61
  const { config, dryRun } = options;
62
- const declared = rosterOf(config.actors[0]) ?? [];
62
+ const declared = participantList(config) ?? [];
63
63
  const fail = concurrentStudyFailure({
64
64
  cwd: path.resolve(options.cwd),
65
65
  studyId: config.id,
66
- actor: config.actors[0]?.type ?? "",
66
+ actor: actorOf(config)?.type ?? "",
67
67
  participantCount: declared.length,
68
68
  // The parser fills concurrency for multi-participant studies, so this fallback serves only
69
69
  // library callers: every declared participant runs at once unless the author declared a cap.
@@ -13,6 +13,7 @@ import { NODE_BOOTSTRAP_TIMEOUT_MS } from "../../subject/node-bootstrap.js";
13
13
  import { terminalSandboxTimeoutMs } from "./lifetime.js";
14
14
  import { isExactRuntimeVersion, TERMINAL_RUNTIME_VERSION_TIMEOUT_MS } from "./runtime.js";
15
15
  import { PRODUCT_SETUP_TIMEOUT_MS, TERMINAL_SANDBOX_TIMEOUT_BUFFER_MS, } from "./types.js";
16
+ import { actorOf, capsOf } from "../../study/study-fields.js";
16
17
  /**
17
18
  * Plan a terminal-product study. It is called for any config handed to the terminal runner, not
18
19
  * only one routeOf sends here, so a config for another route gets this route's refusal.
@@ -40,12 +41,12 @@ export function planTerminalStudy(config, input) {
40
41
  const tasksReason = taskProtocolValidationReason(config, false);
41
42
  if (tasksReason)
42
43
  return refuse("HUMANISH_STUDY_TASKS_UNSUPPORTED", tasksReason);
43
- const actorType = config.actors[0]?.type ?? "";
44
+ const actorType = actorOf(config)?.type ?? "";
44
45
  const descriptor = actorRegistry[actorType];
45
46
  if (!descriptor || !isTerminalActorDescriptor(descriptor))
46
47
  return refuse("HUMANISH_TERMINAL_ACTOR_UNSUPPORTED", `actor.type "${actorType}" is not a registered terminal actor.`);
47
48
  const runtimeVersion = config.execution?.runtime?.version;
48
- const actor = config.actors[0];
49
+ const actor = actorOf(config);
49
50
  if ((config.execution?.runtime !== undefined && !isExactRuntimeVersion(runtimeVersion)) ||
50
51
  (actor?.model !== undefined &&
51
52
  (typeof actor.model !== "string" || actor.model.trim().length === 0)) ||
@@ -83,7 +84,7 @@ export function planTerminalStudy(config, input) {
83
84
  ...(egressAllow === undefined ? {} : { egressAllow }),
84
85
  ...(stdin === undefined ? {} : { stdin }),
85
86
  };
86
- const caps = config.scenario?.caps;
87
+ const caps = capsOf(config, "terminal");
87
88
  if (input.dryRun)
88
89
  return { ok: true, plan: { ...shared, dryRun: true, ...(caps ? { caps } : {}) } };
89
90
  // The live route injects the runtime key into one command only, and only a registered actor
@@ -54,6 +54,7 @@ import { terminalRouteScorer } from "../../study/adapter-scorer-loader.js";
54
54
  import { withLateScorer } from "../../study/route-inputs.js";
55
55
  import {} from "./types.js";
56
56
  import { studyResultIdentity } from "../../run/study-result.js";
57
+ import { actorOf } from "../../study/study-fields.js";
57
58
  /** A refused terminal study's result: the route's envelope, and the analysis record a refusal gets. */
58
59
  export function terminalStudyRefusal(options, refusal) {
59
60
  const { config, dryRun } = options;
@@ -61,7 +62,7 @@ export function terminalStudyRefusal(options, refusal) {
61
62
  ...studyResultIdentity("terminal", config.id),
62
63
  ok: false,
63
64
  cwd: path.resolve(options.cwd),
64
- actor: refusal.actor ?? config.actors[0]?.type ?? "",
65
+ actor: refusal.actor ?? actorOf(config)?.type ?? "",
65
66
  product: config.subject.product?.name ?? "",
66
67
  dryRun,
67
68
  runId: options.runId ?? "not-created",