humanish 0.0.1 → 0.15.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 (196) hide show
  1. package/AGENTS.md +77 -0
  2. package/CONTRIBUTING.md +39 -0
  3. package/LICENSE +21 -0
  4. package/README.md +435 -8
  5. package/SECURITY.md +34 -0
  6. package/dist/actor-contract.d.ts +107 -0
  7. package/dist/actor-contract.js +286 -0
  8. package/dist/actor-contract.js.map +1 -0
  9. package/dist/actor-registry.d.ts +73 -0
  10. package/dist/actor-registry.js +93 -0
  11. package/dist/actor-registry.js.map +1 -0
  12. package/dist/adapter-extension.d.ts +54 -0
  13. package/dist/adapter-extension.js +214 -0
  14. package/dist/adapter-extension.js.map +1 -0
  15. package/dist/argv.d.ts +1 -0
  16. package/dist/argv.js +8 -0
  17. package/dist/argv.js.map +1 -0
  18. package/dist/artifact-reference.d.ts +22 -0
  19. package/dist/artifact-reference.js +47 -0
  20. package/dist/artifact-reference.js.map +1 -0
  21. package/dist/browser-evidence-hygiene.d.ts +7 -0
  22. package/dist/browser-evidence-hygiene.js +40 -0
  23. package/dist/browser-evidence-hygiene.js.map +1 -0
  24. package/dist/claude-agent-sdk.d.ts +94 -0
  25. package/dist/claude-agent-sdk.js +356 -0
  26. package/dist/claude-agent-sdk.js.map +1 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +5 -0
  29. package/dist/cli.js.map +1 -0
  30. package/dist/codex-app-server-ui.d.ts +39 -0
  31. package/dist/codex-app-server-ui.js +327 -0
  32. package/dist/codex-app-server-ui.js.map +1 -0
  33. package/dist/codex-app-server.d.ts +141 -0
  34. package/dist/codex-app-server.js +747 -0
  35. package/dist/codex-app-server.js.map +1 -0
  36. package/dist/command-failure.d.ts +29 -0
  37. package/dist/command-failure.js +54 -0
  38. package/dist/command-failure.js.map +1 -0
  39. package/dist/computer-use-actor.d.ts +52 -0
  40. package/dist/computer-use-actor.js +48 -0
  41. package/dist/computer-use-actor.js.map +1 -0
  42. package/dist/computer-use.d.ts +232 -0
  43. package/dist/computer-use.js +615 -0
  44. package/dist/computer-use.js.map +1 -0
  45. package/dist/concurrency.d.ts +1 -0
  46. package/dist/concurrency.js +27 -0
  47. package/dist/concurrency.js.map +1 -0
  48. package/dist/concurrent-shared-world-lab.d.ts +115 -0
  49. package/dist/concurrent-shared-world-lab.js +1092 -0
  50. package/dist/concurrent-shared-world-lab.js.map +1 -0
  51. package/dist/core/git-state.d.ts +31 -0
  52. package/dist/core/git-state.js +142 -0
  53. package/dist/core/git-state.js.map +1 -0
  54. package/dist/core/index.d.ts +4 -0
  55. package/dist/core/index.js +3 -0
  56. package/dist/core/index.js.map +1 -0
  57. package/dist/core/run-primitives.d.ts +66 -0
  58. package/dist/core/run-primitives.js +120 -0
  59. package/dist/core/run-primitives.js.map +1 -0
  60. package/dist/cua-actor-lab.d.ts +620 -0
  61. package/dist/cua-actor-lab.js +2834 -0
  62. package/dist/cua-actor-lab.js.map +1 -0
  63. package/dist/device-presets.d.ts +67 -0
  64. package/dist/device-presets.js +50 -0
  65. package/dist/device-presets.js.map +1 -0
  66. package/dist/e2b-desktop-executor.d.ts +101 -0
  67. package/dist/e2b-desktop-executor.js +309 -0
  68. package/dist/e2b-desktop-executor.js.map +1 -0
  69. package/dist/e2b-desktop-launch.d.ts +144 -0
  70. package/dist/e2b-desktop-launch.js +59 -0
  71. package/dist/e2b-desktop-launch.js.map +1 -0
  72. package/dist/e2b-detached.d.ts +53 -0
  73. package/dist/e2b-detached.js +130 -0
  74. package/dist/e2b-detached.js.map +1 -0
  75. package/dist/e2b-terminal-lab.d.ts +345 -0
  76. package/dist/e2b-terminal-lab.js +1467 -0
  77. package/dist/e2b-terminal-lab.js.map +1 -0
  78. package/dist/env-file.d.ts +14 -0
  79. package/dist/env-file.js +108 -0
  80. package/dist/env-file.js.map +1 -0
  81. package/dist/feedback.d.ts +51 -0
  82. package/dist/feedback.js +313 -0
  83. package/dist/feedback.js.map +1 -0
  84. package/dist/image-evidence.d.ts +2 -0
  85. package/dist/image-evidence.js +33 -0
  86. package/dist/image-evidence.js.map +1 -0
  87. package/dist/index.d.ts +63 -0
  88. package/dist/index.js +33 -0
  89. package/dist/index.js.map +1 -0
  90. package/dist/init-templates.d.ts +12 -0
  91. package/dist/init-templates.js +369 -0
  92. package/dist/init-templates.js.map +1 -0
  93. package/dist/init.d.ts +26 -0
  94. package/dist/init.js +343 -0
  95. package/dist/init.js.map +1 -0
  96. package/dist/lab-app-runner.d.ts +78 -0
  97. package/dist/lab-app-runner.js +403 -0
  98. package/dist/lab-app-runner.js.map +1 -0
  99. package/dist/lab-config.d.ts +578 -0
  100. package/dist/lab-config.js +1751 -0
  101. package/dist/lab-config.js.map +1 -0
  102. package/dist/lab-engine.d.ts +77 -0
  103. package/dist/lab-engine.js +216 -0
  104. package/dist/lab-engine.js.map +1 -0
  105. package/dist/lab-preflight.d.ts +67 -0
  106. package/dist/lab-preflight.js +385 -0
  107. package/dist/lab-preflight.js.map +1 -0
  108. package/dist/labs.d.ts +53 -0
  109. package/dist/labs.js +176 -0
  110. package/dist/labs.js.map +1 -0
  111. package/dist/observer-assets.d.ts +2 -0
  112. package/dist/observer-assets.js +2630 -0
  113. package/dist/observer-assets.js.map +1 -0
  114. package/dist/observer-data.d.ts +63 -0
  115. package/dist/observer-data.js +145 -0
  116. package/dist/observer-data.js.map +1 -0
  117. package/dist/observer-static.d.ts +39 -0
  118. package/dist/observer-static.js +192 -0
  119. package/dist/observer-static.js.map +1 -0
  120. package/dist/observer.d.ts +46 -0
  121. package/dist/observer.js +353 -0
  122. package/dist/observer.js.map +1 -0
  123. package/dist/openai-responses-cu.d.ts +113 -0
  124. package/dist/openai-responses-cu.js +531 -0
  125. package/dist/openai-responses-cu.js.map +1 -0
  126. package/dist/oss-lab.d.ts +52 -0
  127. package/dist/oss-lab.js +299 -0
  128. package/dist/oss-lab.js.map +1 -0
  129. package/dist/oss-meta-lab-scoring.d.ts +15 -0
  130. package/dist/oss-meta-lab-scoring.js +145 -0
  131. package/dist/oss-meta-lab-scoring.js.map +1 -0
  132. package/dist/oss-meta-lab.d.ts +279 -0
  133. package/dist/oss-meta-lab.js +4889 -0
  134. package/dist/oss-meta-lab.js.map +1 -0
  135. package/dist/oss-remote-telemetry.d.ts +77 -0
  136. package/dist/oss-remote-telemetry.js +403 -0
  137. package/dist/oss-remote-telemetry.js.map +1 -0
  138. package/dist/persona.d.ts +35 -0
  139. package/dist/persona.js +133 -0
  140. package/dist/persona.js.map +1 -0
  141. package/dist/pi-agent-core.d.ts +77 -0
  142. package/dist/pi-agent-core.js +203 -0
  143. package/dist/pi-agent-core.js.map +1 -0
  144. package/dist/program.d.ts +54 -0
  145. package/dist/program.js +2347 -0
  146. package/dist/program.js.map +1 -0
  147. package/dist/redaction.d.ts +90 -0
  148. package/dist/redaction.js +292 -0
  149. package/dist/redaction.js.map +1 -0
  150. package/dist/run.d.ts +784 -0
  151. package/dist/run.js +4555 -0
  152. package/dist/run.js.map +1 -0
  153. package/dist/scripted-browser-actor.d.ts +294 -0
  154. package/dist/scripted-browser-actor.js +1286 -0
  155. package/dist/scripted-browser-actor.js.map +1 -0
  156. package/dist/scripted-browser-lab.d.ts +107 -0
  157. package/dist/scripted-browser-lab.js +738 -0
  158. package/dist/scripted-browser-lab.js.map +1 -0
  159. package/dist/shared-world-lab.d.ts +208 -0
  160. package/dist/shared-world-lab.js +1048 -0
  161. package/dist/shared-world-lab.js.map +1 -0
  162. package/dist/source-archive.d.ts +82 -0
  163. package/dist/source-archive.js +365 -0
  164. package/dist/source-archive.js.map +1 -0
  165. package/dist/stop-conditions.d.ts +26 -0
  166. package/dist/stop-conditions.js +66 -0
  167. package/dist/stop-conditions.js.map +1 -0
  168. package/dist/terminal-agent-actor.d.ts +36 -0
  169. package/dist/terminal-agent-actor.js +23 -0
  170. package/dist/terminal-agent-actor.js.map +1 -0
  171. package/docs/architecture/actor-contract.md +431 -0
  172. package/docs/architecture/github-feedback-loop.md +189 -0
  173. package/docs/architecture/local-codex-tui-actor.md +213 -0
  174. package/docs/architecture/observer.md +118 -0
  175. package/docs/architecture/oss-lab-poc.md +242 -0
  176. package/docs/architecture/project-layout.md +166 -0
  177. package/docs/architecture/state-driven-executor.md +203 -0
  178. package/docs/architecture/terminal-product-lane.md +148 -0
  179. package/docs/contracts/adapter-fixtures.md +80 -0
  180. package/docs/contracts/core.md +71 -0
  181. package/docs/contracts/feedback.md +145 -0
  182. package/docs/contracts/policy.md +305 -0
  183. package/docs/contracts/run-bundle.md +358 -0
  184. package/docs/contracts/schemas.md +984 -0
  185. package/docs/goals/current.md +409 -0
  186. package/docs/principles/invariants-and-defaults.md +135 -0
  187. package/docs/principles/self-driving-harness.md +129 -0
  188. package/docs/product/open-source-install-experience.md +229 -0
  189. package/docs/ramp/README.md +200 -0
  190. package/docs/release/open-source-readiness.md +208 -0
  191. package/docs/release/public-readiness-standard.md +205 -0
  192. package/docs/roadmap/world-class-open-source-v0.md +311 -0
  193. package/package.json +102 -8
  194. package/skills/humanish/SKILL.md +244 -0
  195. package/skills/humanish/agents/openai.yaml +7 -0
  196. package/index.js +0 -1
@@ -0,0 +1,2834 @@
1
+ // The computer-use lab backend: a subject (an app-url the caller provisioned, or a repo the
2
+ // lab clones AND serves in-sandbox) driven by a REGISTRY-RESOLVED computer-use actor inside a
3
+ // hosted E2B desktop. This is the path that makes `actors[].type` load-bearing — the
4
+ // descriptor returned by the registry runs the session; the lab provisions the desktop and
5
+ // subject, composes the prompt from config, persists the evidence bundle, and tears down.
6
+ //
7
+ // Substrate notes:
8
+ // - The desktop is created via the shared loader in e2b-desktop-launch.ts with kill-on-timeout
9
+ // lifecycle, so a dead host process can never orphan a sandbox past its server-side deadline.
10
+ // - Env placement follows the doctrine (docs/principles/invariants-and-defaults.md): the
11
+ // ACTOR's key never enters the sandbox (the model drives from outside via the provider API);
12
+ // the SUBJECT's declared env NAMES are provisioned in on the clone route — values come from
13
+ // the caller's environment and are never logged or persisted.
14
+ // - The live stream URL is runtime-only (carries an auth key) and is never persisted into run
15
+ // artifacts — only its presence is recorded, mirroring the meta lab's convention.
16
+ // - Evidence redaction is mode-aware (docs/principles/invariants-and-defaults.md, the
17
+ // capture-vs-publish rule): screenshots persist RAW (full fidelity) by default into gitignored
18
+ // .humanish/; `policies.redactScreenshots: true` opts into blur-at-capture for a share-as-is
19
+ // bundle. Length-only typed text and text redaction of reasoning/messages are UNCONDITIONAL;
20
+ // harness errors are redacted at THIS boundary; the bundle's `stream.actor` carries the
21
+ // conformant humanish.actor-trace.v1 projection, whose `redaction.screenshots` records the
22
+ // run's actual mode ("raw" | "blurred" | "n/a") — every label downstream derives from it.
23
+ import { randomBytes } from "node:crypto";
24
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
25
+ import path from "node:path";
26
+ import { runDesktopCommandOrThrow, toErrorMessage } from "./command-failure.js";
27
+ import { pathToFileURL } from "node:url";
28
+ import { adapterScoreFailureMessage, applyBrowserAdapterHooks } from "./adapter-extension.js";
29
+ import { actorRegistry, isCuaActorDescriptor } from "./actor-registry.js";
30
+ import { CHROMIUM_EVIDENCE_HYGIENE_FLAGS, chromiumEvidenceProfilePreferencesJson } from "./browser-evidence-hygiene.js";
31
+ import { createDesktopSandbox, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
32
+ import { probeUrl, readDetachedLog, runDetachedStep, startDetachedProcess } from "./e2b-detached.js";
33
+ import { DEFAULT_DEVICE_PRESET, isDevicePresetName, resolveDevicePreset } from "./device-presets.js";
34
+ import { cuaLaneValidationReason, isHttpUrl, isLoopbackUrl, MAX_CUA_LANES, subjectStateInvalidReason } from "./lab-config.js";
35
+ import { mapWithConcurrency } from "./concurrency.js";
36
+ import { assertScreenshotEvidence } from "./image-evidence.js";
37
+ import { buildObserverData } from "./observer-data.js";
38
+ import { attachObserverRuntimeStreamUrls, renderObserver } from "./observer.js";
39
+ import { containsSensitive, digestText, redactedTail, redactText } from "./redaction.js";
40
+ import { createLocalTreeArchive } from "./source-archive.js";
41
+ import { buildRunSource, loadRunBundle, PUBLIC_TARGET_CWD, REVIEW_SCHEMA, RUN_BUNDLE_SCHEMA } from "./run.js";
42
+ export const CUA_ACTOR_LAB_SCHEMA = "humanish.cua-lab-result.v2";
43
+ // The only fan-out topology this slice ships: N lanes = N independent E2B desktop sandboxes,
44
+ // each its own world (clone/serve + subject.state per lane). Shared-world is layer 7 (#164).
45
+ export const CUA_FANOUT_STRATEGY = "per-lane-worlds";
46
+ // Default in-flight lane bound when the config does not declare execution.concurrency.
47
+ const DEFAULT_CUA_CONCURRENCY = 3;
48
+ // Env override that may only LOWER the effective concurrency (never raise concurrent paid
49
+ // desktops — invariant 3). Read names-only into a local; the value never persists.
50
+ const CUA_MAX_CONCURRENCY_ENV = "HUMANISH_CUA_MAX_CONCURRENCY";
51
+ export const CUA_ACTOR_LAB_PROVIDER_METADATA = {
52
+ mode: "cua-actor-lab",
53
+ tool: "humanish"
54
+ };
55
+ const DEFAULT_SESSION_TIMEOUT_MS = 300_000;
56
+ // Settle after opening the browser, before the first screenshot — long enough for a cold
57
+ // browser + page load to paint (2s captured a blank desktop; the render empirically needs ~6-9s).
58
+ const BROWSER_SETTLE_MS = 8_000;
59
+ // Device/viewport comes from the named-preset registry (device-presets.ts), selectable per run
60
+ // via execution.desktop.device (default `desktop`=1440x950). NOTE: this is run-wide for now; a
61
+ // per-PERSONA device dimension (N personas × devices, as the bespoke sims author) lands with
62
+ // fan-out. On this E2B-desktop route only width/height physically render — isMobile/DSF are
63
+ // honest metadata + a prompt signal, not rendered (device-presets.ts FIDELITY NOTE).
64
+ // Server-side reclamation buffer past the loop's own wall-clock stop.
65
+ const SANDBOX_TIMEOUT_BUFFER_MS = 10 * 60_000;
66
+ // Room the clone route adds to the sandbox deadline for clone/install/build/start/probe.
67
+ const SUBJECT_PROVISION_BUDGET_MS = 30 * 60_000;
68
+ export const SUBJECT_DIR = "/home/user/subject";
69
+ // Remote path for the once-per-run packed local-tree archive; removed by the extract step
70
+ // after it unpacks into SUBJECT_DIR.
71
+ const LOCAL_TREE_REMOTE_ARCHIVE_PATH = "/home/user/.humanish-source.tar.gz";
72
+ const CLONE_TIMEOUT_MS = 5 * 60_000;
73
+ const INSTALL_TIMEOUT_MS = 10 * 60_000;
74
+ const BUILD_TIMEOUT_MS = 10 * 60_000;
75
+ const DEFAULT_READY_TIMEOUT_MS = 180_000;
76
+ // Per-step budget for subject.state seed steps; each step's declared (or default) budget is
77
+ // also summed into the default sandbox deadline so seeding never eats the session's room.
78
+ const DEFAULT_STATE_STEP_TIMEOUT_MS = 5 * 60_000;
79
+ // How much of a failing step's log tail rides the (redacted) error message.
80
+ const ERROR_TAIL_CHARS = 2000;
81
+ const DEFAULT_MISSION = "You are testing a web application. The browser is already open at the subject URL. Explore it, accomplish what the scenario asks, and stop when done.";
82
+ /** Compose one lane's actor prompt: persona line + device line + mission + per-lane steer.
83
+ * At N=1 (homogeneous, no roster) this reproduces the prior composeInstructions byte-for-byte. */
84
+ export function composeLaneInstructions(args) {
85
+ const { name, preset } = args.device;
86
+ const deviceLine = preset.isMobile
87
+ ? `You are a mobile user on a ${name} device (${preset.width}x${preset.height} @${preset.deviceScaleFactor}x). Expect a mobile/touch layout.`
88
+ : `You are a desktop user (${name}, ${preset.width}x${preset.height}).`;
89
+ const parts = [
90
+ args.persona ? `Persona: ${args.persona}.` : undefined,
91
+ deviceLine,
92
+ args.mission,
93
+ args.instruction ? `Lane focus: ${args.instruction}` : undefined
94
+ ].filter((part) => Boolean(part));
95
+ const instructions = parts.join("\n\n");
96
+ return {
97
+ instructions,
98
+ persona: {
99
+ id: args.persona ?? "cua-operator",
100
+ traitsApplied: [],
101
+ promptDigest: digestText(instructions, 16)
102
+ }
103
+ };
104
+ }
105
+ /**
106
+ * Resolve a lane's device + rendered resolution (most-specific wins, exactly as the single-lane
107
+ * path always has): a raw execution.desktop.resolution escape hatch (only legal when no lane
108
+ * sets a device — XOR enforced at parse) → the lane's named device → the run-wide
109
+ * execution.desktop.device → the default preset. A raw resolution is an unnamed custom desktop
110
+ * (non-mobile, DSF 1): we never claim a named preset's mobile/DPR for hand-set geometry.
111
+ */
112
+ export function resolveLaneDevice(config, lane) {
113
+ const rawResolution = config.execution?.desktop?.resolution;
114
+ if (lane?.device === undefined && rawResolution) {
115
+ const preset = { width: rawResolution[0], height: rawResolution[1], isMobile: false, deviceScaleFactor: 1 };
116
+ return { name: "custom", preset, resolution: [rawResolution[0], rawResolution[1]] };
117
+ }
118
+ const candidate = lane?.device ?? config.execution?.desktop?.device;
119
+ const presetName = isDevicePresetName(candidate) ? candidate : DEFAULT_DEVICE_PRESET;
120
+ const preset = resolveDevicePreset(presetName);
121
+ return { name: presetName, preset, resolution: [preset.width, preset.height] };
122
+ }
123
+ /** Per-lane sandbox deadline (each lane owns its own desktop). Mirrors the single-lane formula
124
+ * verbatim so N=1 stays byte-stable: explicit sandboxTimeoutMs, else session budget + (clone
125
+ * or local-tree: provision budget + Σ state-step budgets) + the server-side
126
+ * reclamation buffer. Local-tree shares the clone route's provisioning budget: it swaps a
127
+ * git clone for an upload+extract, but the shared install/build/state/start/probe pipeline
128
+ * costs the same wall-clock room either way. */
129
+ function resolvePerLaneSandboxMs(config) {
130
+ const timeoutMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
131
+ const provisionedRoute = config.subject.source === "clone" || config.subject.source === "local-tree";
132
+ const stateBudgetMs = provisionedRoute
133
+ ? (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0)
134
+ : 0;
135
+ return config.execution?.desktop?.sandboxTimeoutMs
136
+ ?? timeoutMs + (provisionedRoute ? SUBJECT_PROVISION_BUDGET_MS + stateBudgetMs : 0) + SANDBOX_TIMEOUT_BUFFER_MS;
137
+ }
138
+ /**
139
+ * Effective in-flight lane bound. Default min(laneCount, 3); a declared execution.concurrency
140
+ * clamps to [1, laneCount]; the env override may only LOWER it (never raise concurrent paid
141
+ * desktops — invariant 3). Pure given (config, laneCount, env).
142
+ */
143
+ function resolveCuaConcurrency(config, laneCount, env) {
144
+ const declared = config.execution?.concurrency;
145
+ const base = declared !== undefined
146
+ ? Math.min(Math.max(1, declared), laneCount)
147
+ : Math.min(laneCount, DEFAULT_CUA_CONCURRENCY);
148
+ const envLower = readPositiveInt(env[CUA_MAX_CONCURRENCY_ENV], 0);
149
+ if (envLower > 0) {
150
+ return Math.max(1, Math.min(base, envLower, laneCount));
151
+ }
152
+ return Math.max(1, base);
153
+ }
154
+ /** Build the lane specs AND the public plan from a config (pure). countOverride is the CLI
155
+ * --count for homogeneous fan-out (ignored when a `lanes` roster is declared). */
156
+ function laneSpecsAndPlan(config, opts = {}) {
157
+ const env = opts.env ?? {};
158
+ const actor = config.actors[0];
159
+ const mission = actor?.mission ?? DEFAULT_MISSION;
160
+ const roster = actor?.lanes;
161
+ const laneCount = roster ? roster.length : Math.max(1, opts.countOverride ?? actor?.count ?? 1);
162
+ const lanes = [];
163
+ for (let i = 0; i < laneCount; i += 1) {
164
+ const lane = roster?.[i];
165
+ const laneId = lane?.id ?? `lane-${String(i + 1).padStart(2, "0")}`;
166
+ const simId = `sim-${String(i + 1).padStart(3, "0")}`;
167
+ const streamId = `stream-${String(i + 1).padStart(3, "0")}`;
168
+ const device = resolveLaneDevice(config, lane);
169
+ const composed = composeLaneInstructions({
170
+ mission,
171
+ ...(((roster ? lane?.persona : actor?.persona)) === undefined ? {} : { persona: (roster ? lane?.persona : actor?.persona) }),
172
+ ...(((roster ? lane?.instruction : actor?.laneFocus?.instruction)) === undefined ? {} : { instruction: (roster ? lane?.instruction : actor?.laneFocus?.instruction) }),
173
+ device: { name: device.name, preset: device.preset }
174
+ });
175
+ lanes.push({
176
+ laneId,
177
+ ...(lane?.actorType === undefined ? {} : { actorType: lane.actorType }),
178
+ ...(lane?.surface === undefined ? {} : { surface: lane.surface }),
179
+ ...(lane?.caseGroup === undefined ? {} : { caseGroup: lane.caseGroup }),
180
+ laneIndex: i,
181
+ simId,
182
+ streamId,
183
+ persona: composed.persona,
184
+ instructions: composed.instructions,
185
+ ...(lane?.target === undefined ? {} : { targetUrl: lane.target }),
186
+ ...((lane?.stopWhen ?? actor?.stopWhen) === undefined ? {} : { stopWhen: (lane?.stopWhen ?? actor?.stopWhen) }),
187
+ deviceName: device.name,
188
+ devicePreset: device.preset,
189
+ resolution: device.resolution,
190
+ screenshotDir: laneCount === 1 ? "" : laneId,
191
+ traceArtifactPath: laneCount === 1 ? "actor.json" : `actors/${streamId}.json`
192
+ });
193
+ }
194
+ const concurrency = resolveCuaConcurrency(config, laneCount, env);
195
+ const perLaneSessionBudgetMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
196
+ const perLaneSandboxMs = resolvePerLaneSandboxMs(config);
197
+ const plan = {
198
+ strategy: CUA_FANOUT_STRATEGY,
199
+ laneCount,
200
+ concurrency,
201
+ waves: Math.ceil(laneCount / concurrency),
202
+ perLaneSessionBudgetMs,
203
+ worstCaseSandboxMinutes: Math.round((laneCount * perLaneSandboxMs) / 60_000),
204
+ dryRun: opts.dryRun === true,
205
+ lanes: lanes.map((spec) => ({
206
+ id: spec.laneId,
207
+ ...(spec.actorType === undefined ? {} : { actorType: spec.actorType }),
208
+ ...(spec.surface === undefined ? {} : { surface: spec.surface }),
209
+ ...(spec.caseGroup === undefined ? {} : { caseGroup: spec.caseGroup }),
210
+ index: spec.laneIndex + 1,
211
+ persona: spec.persona.id,
212
+ device: spec.deviceName,
213
+ resolution: spec.resolution,
214
+ instructionDigest: spec.persona.promptDigest,
215
+ ...(spec.targetUrl === undefined ? {} : { targetDigest: digestUrl(spec.targetUrl) })
216
+ }))
217
+ };
218
+ return { lanes, plan };
219
+ }
220
+ async function resolveCuaRerunSelection(args) {
221
+ const source = await loadRunBundle(args.cwd, args.sourceRunId);
222
+ if (!source) {
223
+ return { ok: false, message: `source run not found or invalid: ${args.sourceRunId}` };
224
+ }
225
+ const bundle = source.bundle;
226
+ if (bundle.mode !== "live") {
227
+ return { ok: false, message: `source run ${bundle.runId} is ${bundle.mode}; rerun selection only applies to live CUA fan-out evidence.` };
228
+ }
229
+ const fanoutEvent = bundle.events.some((event) => event.type === "cua-lab.fanout.plan");
230
+ if (!fanoutEvent || bundle.streams.length < 2) {
231
+ return { ok: false, message: `source run ${bundle.runId} is not a CUA fan-out run.` };
232
+ }
233
+ const prior = bundle.streams
234
+ .map(snapshotPriorCuaLane)
235
+ .filter((lane) => lane !== null);
236
+ const priorById = new Map(prior.map((lane) => [lane.laneId, lane]));
237
+ if (priorById.size < 2) {
238
+ return { ok: false, message: `source run ${bundle.runId} does not expose multiple lane ids.` };
239
+ }
240
+ const explicitLaneIds = uniqueLaneIds(args.laneIds ?? []);
241
+ const selectedLaneIds = explicitLaneIds.length > 0
242
+ ? explicitLaneIds
243
+ : prior.filter((lane) => lane.rerunnable).map((lane) => lane.laneId);
244
+ if (selectedLaneIds.length === 0) {
245
+ return { ok: false, message: `source run ${bundle.runId} has no failed, blocked, timed-out, or hollow lanes to rerun.` };
246
+ }
247
+ const missingPrior = selectedLaneIds.filter((laneId) => !priorById.has(laneId));
248
+ if (missingPrior.length > 0) {
249
+ return { ok: false, message: `selected lane id(s) were not present in source run ${bundle.runId}: ${missingPrior.join(", ")}` };
250
+ }
251
+ const specsById = new Map(args.laneSpecs.map((spec) => [spec.laneId, spec]));
252
+ const missingCurrent = selectedLaneIds.filter((laneId) => !specsById.has(laneId));
253
+ if (missingCurrent.length > 0) {
254
+ return { ok: false, message: `selected lane id(s) are not present in the current lab config ${args.config.id}: ${missingCurrent.join(", ")}` };
255
+ }
256
+ const selectedSpecs = selectedLaneIds.map((laneId) => specsById.get(laneId));
257
+ const selectedPlanLaneIds = new Set(selectedLaneIds);
258
+ const selectedPlanEntries = args.plan.lanes.filter((lane) => selectedPlanLaneIds.has(lane.id));
259
+ const concurrency = Math.max(1, Math.min(args.plan.concurrency, selectedSpecs.length));
260
+ const plan = {
261
+ ...args.plan,
262
+ laneCount: selectedSpecs.length,
263
+ concurrency,
264
+ waves: Math.ceil(selectedSpecs.length / concurrency),
265
+ worstCaseSandboxMinutes: Math.round((selectedSpecs.length * resolvePerLaneSandboxMs(args.config)) / 60_000),
266
+ lanes: selectedPlanEntries
267
+ };
268
+ const previous = selectedLaneIds.map((laneId) => priorById.get(laneId).previous);
269
+ return {
270
+ ok: true,
271
+ laneSpecs: selectedSpecs,
272
+ plan,
273
+ rerun: {
274
+ sourceRunId: bundle.runId,
275
+ selectedLaneIds,
276
+ previous
277
+ }
278
+ };
279
+ }
280
+ function uniqueLaneIds(values) {
281
+ const seen = new Set();
282
+ const result = [];
283
+ for (const value of values) {
284
+ const laneId = value.trim();
285
+ if (!laneId || seen.has(laneId))
286
+ continue;
287
+ seen.add(laneId);
288
+ result.push(laneId);
289
+ }
290
+ return result;
291
+ }
292
+ function snapshotPriorCuaLane(stream) {
293
+ if (stream.kind !== "browser" || typeof stream.laneId !== "string" || !stream.laneId.trim()) {
294
+ return null;
295
+ }
296
+ const actorStatus = stream.actor?.status;
297
+ const completionReason = stream.actor?.completionReason;
298
+ const reason = stream.ui?.state ?? stream.actor?.reason;
299
+ const actions = stream.actor?.counts.actions ?? 0;
300
+ const messages = stream.actor?.counts.messages ?? 0;
301
+ const hollow = completionReason === "goal_satisfied" && actions === 0 && messages === 0;
302
+ const rerunnable = stream.status !== "passed"
303
+ || actorStatus === "failed"
304
+ || actorStatus === "blocked"
305
+ || actorStatus === "timed_out"
306
+ || completionReason === "harness_error"
307
+ || hollow;
308
+ return {
309
+ laneId: stream.laneId,
310
+ previous: {
311
+ laneId: stream.laneId,
312
+ streamId: stream.id,
313
+ status: stream.status,
314
+ ...(reason === undefined ? {} : { reason }),
315
+ ...(actorStatus === undefined ? {} : { actorStatus }),
316
+ ...(completionReason === undefined ? {} : { completionReason })
317
+ },
318
+ rerunnable
319
+ };
320
+ }
321
+ /**
322
+ * Pure pre-flight plan resolver (runs in dry-run AND live). Returns the lane table, the
323
+ * effective concurrency, the wave count, the per-lane session budget, and the worst-case total
324
+ * sandbox-minutes — BEFORE any sandbox or provider call. The same plan appears in dry-run,
325
+ * marked $0 (dryRun: true).
326
+ */
327
+ export function resolveCuaLanePlan(config, opts = {}) {
328
+ return laneSpecsAndPlan(config, opts).plan;
329
+ }
330
+ /** Print the lane plan to stderr BEFORE any sandbox/provider call (public-safe: ids, devices,
331
+ * digests, and budgets only — no prompt text, no secrets). */
332
+ function emitPreflightPlan(plan, labId) {
333
+ const lines = [];
334
+ lines.push(`humanish cua fan-out plan (${labId}): ${plan.laneCount} lane(s), strategy ${plan.strategy}, concurrency ${plan.concurrency}, ${plan.waves} wave(s).`);
335
+ lines.push(` per-lane session budget ${Math.round(plan.perLaneSessionBudgetMs / 1000)}s; worst-case ~${plan.worstCaseSandboxMinutes} sandbox-minutes total${plan.dryRun ? " (dry-run: $0)" : ""}.`);
336
+ for (const lane of plan.lanes) {
337
+ lines.push(` - ${formatLanePlanEntry(lane)}`);
338
+ }
339
+ process.stderr.write(`${lines.join("\n")}\n`);
340
+ }
341
+ function formatLanePlanEntry(lane) {
342
+ const taxonomy = [
343
+ lane.actorType ? `type=${lane.actorType}` : undefined,
344
+ lane.surface ? `surface=${lane.surface}` : undefined,
345
+ lane.caseGroup ? `case=${lane.caseGroup}` : undefined
346
+ ].filter((part) => part !== undefined);
347
+ return `${lane.id}: persona=${lane.persona}${taxonomy.length > 0 ? ` ${taxonomy.join(" ")}` : ""} device=${lane.device} ${lane.resolution[0]}x${lane.resolution[1]} prompt#${lane.instructionDigest}${lane.targetDigest ? ` target#${lane.targetDigest}` : ""}`;
348
+ }
349
+ /** ISO timestamp from an injectable clock (tests freeze `now` for deterministic durationMs). */
350
+ function isoNow(now) {
351
+ return new Date(now()).toISOString();
352
+ }
353
+ /** Emit a phase-started event (no ok/durationMs: those belong to the matching completed event). */
354
+ function emitPhaseStarted(onPhase, now, phase, message) {
355
+ onPhase?.({ at: isoNow(now), type: `cua-lab.subject.${phase}.started`, message });
356
+ }
357
+ /** Emit the matching phase-completed event: always carries ok and durationMs (>= 0). */
358
+ function emitPhaseCompleted(onPhase, now, startedAt, phase, ok, message) {
359
+ onPhase?.({
360
+ at: isoNow(now),
361
+ type: `cua-lab.subject.${phase}.completed`,
362
+ ok,
363
+ durationMs: Math.max(0, now() - startedAt),
364
+ message
365
+ });
366
+ }
367
+ /** Default phase-boundary sink (stderr): one line per event, prefixed with the lane id ONLY
368
+ * when laneCount > 1. Single-lane emission is unconditional: total single-lane silence for the
369
+ * whole clone/install/build/ready boot is the bug this event stream exists to close.
370
+ * Overridable via CuaActorLabHooks.onPhase so deterministic tests capture instead of writing to
371
+ * the real stderr. */
372
+ function defaultSubjectPhaseSink(event, ctx) {
373
+ const durationSuffix = event.durationMs === undefined ? "" : ` (${event.durationMs}ms)`;
374
+ const prefix = ctx.laneCount > 1 ? `humanish cua [${ctx.laneId}]` : "humanish cua";
375
+ process.stderr.write(`${prefix}: ${event.message}${durationSuffix}\n`);
376
+ }
377
+ /** Short id-safe suffix for a subject-phase RunEvent: drops the shared prefix/suffix so each
378
+ * phase gets a distinct bundle event id (e.g. "clone", "state-before-build"). */
379
+ function phaseEventIdSuffix(type) {
380
+ return type
381
+ .replace(/^cua-lab\.subject\./, "")
382
+ .replace(/\.(started|completed)$/, "")
383
+ .replace(/\./g, "-");
384
+ }
385
+ /** Build a lane's writeScreenshot closure: writes under screenshots/<screenshotDir>/ and records
386
+ * the relative path the trace references (screenshots/<name> at N=1; screenshots/<laneId>/<name>
387
+ * at N>1). */
388
+ export function makeLaneWriteScreenshot(artifactRoot, spec, screenshots) {
389
+ const dirParts = spec.screenshotDir ? ["screenshots", spec.screenshotDir] : ["screenshots"];
390
+ const relPrefix = spec.screenshotDir ? path.posix.join("screenshots", spec.screenshotDir) : "screenshots";
391
+ return async (name, bytes) => {
392
+ const rel = path.posix.join(relPrefix, name);
393
+ assertScreenshotEvidence(rel, bytes);
394
+ await mkdir(path.join(artifactRoot, ...dirParts), { recursive: true });
395
+ await writeFile(path.join(artifactRoot, ...dirParts, name), bytes);
396
+ screenshots.push(rel);
397
+ return rel;
398
+ };
399
+ }
400
+ /**
401
+ * Verify the desktop geometry IN-SANDBOX (the per-lane device claim is checked, never assumed).
402
+ * Returns a fail-closed DEVICE_GEOMETRY message on a PARSEABLE mismatch. When xdpyinfo is
403
+ * unavailable or unparseable (only in fakes — a real E2B desktop always answers), it cannot be
404
+ * verified, so the lane proceeds with the requested geometry (no false failure).
405
+ */
406
+ async function checkLaneGeometry(desktop, spec, requestTimeoutMs) {
407
+ let out = "";
408
+ try {
409
+ const result = await desktop.commands.run("xdpyinfo 2>/dev/null | grep -i dimensions || true", { requestTimeoutMs });
410
+ out = (result.stdout ?? "").trim();
411
+ }
412
+ catch {
413
+ return undefined;
414
+ }
415
+ const match = out.match(/(\d+)\s*x\s*(\d+)\s*pixels/i);
416
+ if (!match) {
417
+ return undefined;
418
+ }
419
+ const width = Number(match[1]);
420
+ const height = Number(match[2]);
421
+ const [expectedWidth, expectedHeight] = spec.resolution;
422
+ if (width === expectedWidth && height === expectedHeight) {
423
+ return undefined;
424
+ }
425
+ return `HUMANISH_CUA_LAB_DEVICE_GEOMETRY: lane ${spec.laneId} requested a ${expectedWidth}x${expectedHeight} desktop but xdpyinfo reports ${width}x${height} in-sandbox; the per-lane device geometry is unverified (fail-closed).`;
426
+ }
427
+ /** A blocked lane outcome (pipeline gate / fail-fast skipped it before it ran). */
428
+ function blockedLaneOutcome(spec, reason) {
429
+ return {
430
+ spec,
431
+ killed: false,
432
+ streamUrlPresent: false,
433
+ screenshots: [],
434
+ stateStepRecords: [],
435
+ phaseRecords: [],
436
+ warnings: [],
437
+ skippedReason: reason,
438
+ noEngagement: false,
439
+ selfReportedBlocker: false,
440
+ harnessError: false
441
+ };
442
+ }
443
+ async function findVisibleBrowserWindowId(desktop, requestTimeoutMs) {
444
+ const result = await desktop.commands.run([
445
+ "set -euo pipefail",
446
+ "export DISPLAY=\"${DISPLAY:-:0}\"",
447
+ "find_chrome_window() {",
448
+ " timeout 2s xdotool search --onlyvisible --class 'google-chrome|Google-chrome|chromium|Chromium|chrome|Chrome' 2>/dev/null | tail -n 1 || true",
449
+ "}",
450
+ "find_firefox_window() {",
451
+ " timeout 2s xdotool search --onlyvisible --class 'firefox|Firefox' 2>/dev/null | tail -n 1 || true",
452
+ "}",
453
+ "find_named_window() {",
454
+ " timeout 2s xdotool search --onlyvisible --name 'Google Chrome|Chromium|Mozilla Firefox|127[.]0[.]0[.]1|localhost|e2b[.]app|e2b[.]dev' 2>/dev/null | tail -n 1 || true",
455
+ "}",
456
+ "window_id=",
457
+ "for _ in $(seq 1 10); do",
458
+ " window_id=\"$(find_chrome_window)\"",
459
+ " if [ -z \"$window_id\" ]; then window_id=\"$(find_firefox_window)\"; fi",
460
+ " if [ -z \"$window_id\" ]; then window_id=\"$(find_named_window)\"; fi",
461
+ " if [ -n \"$window_id\" ]; then break; fi",
462
+ " sleep 0.5",
463
+ "done",
464
+ "if [ -n \"$window_id\" ]; then printf 'WINDOW_ID=%s\\n' \"$window_id\"; fi"
465
+ ].join("\n"), {
466
+ requestTimeoutMs,
467
+ timeoutMs: 15_000
468
+ });
469
+ return (result.stdout ?? "").match(/^WINDOW_ID=(\S+)$/m)?.[1];
470
+ }
471
+ /**
472
+ * Build the xdotool command that makes a browser window fill the desktop.
473
+ * Exported (pure) for contract tests. A window manager can ignore Chrome's
474
+ * --window-size, so xdotool is the robust path: move the window to the origin,
475
+ * then size it to the exact desktop resolution so Observer screenshots carry no
476
+ * dead margin around the browser.
477
+ */
478
+ export function buildFillDesktopWindowCommand(windowId, width, height) {
479
+ return [
480
+ "set -euo pipefail",
481
+ `win=${shellSingleQuote(windowId)}`,
482
+ `xdotool windowactivate "$win" >/dev/null 2>&1 || true`,
483
+ `xdotool windowmove "$win" 0 0 >/dev/null 2>&1 || true`,
484
+ `xdotool windowsize "$win" ${width} ${height} >/dev/null 2>&1 || true`,
485
+ ].join("\n");
486
+ }
487
+ /**
488
+ * Best-effort: resize the detected browser window to fill the lane's desktop
489
+ * resolution. A failure must never fail the lane (the actor can still run on a
490
+ * smaller window), so all errors are swallowed.
491
+ */
492
+ async function fillDesktopBrowserWindow(desktop, windowId, resolution, requestTimeoutMs) {
493
+ const [width, height] = resolution;
494
+ await desktop.commands
495
+ .run(buildFillDesktopWindowCommand(windowId, width, height), {
496
+ requestTimeoutMs,
497
+ timeoutMs: 10_000,
498
+ })
499
+ .catch(() => undefined);
500
+ }
501
+ async function openDesktopBrowserTarget(desktop, targetUrl, requestTimeoutMs, browserPreference) {
502
+ const requestedBrowser = browserPreference ?? "default";
503
+ if (isHttpUrl(targetUrl)) {
504
+ const chromiumFlags = CHROMIUM_EVIDENCE_HYGIENE_FLAGS.map(shellSingleQuote).join(" ");
505
+ const browserLaunchCommand = [
506
+ "set -euo pipefail",
507
+ `target_url=${shellSingleQuote(targetUrl)}`,
508
+ `browser_preference=${shellSingleQuote(requestedBrowser)}`,
509
+ "chrome_profile_dir=/tmp/humanish-chrome-profile",
510
+ `chrome_preferences_json=${shellSingleQuote(chromiumEvidenceProfilePreferencesJson())}`,
511
+ "prepare_chrome_profile() {",
512
+ " mkdir -p \"$chrome_profile_dir/Default\"",
513
+ " printf '%s\\n' \"$chrome_preferences_json\" > \"$chrome_profile_dir/Default/Preferences\"",
514
+ "}",
515
+ "launch_browser() {",
516
+ " local label=\"$1\"",
517
+ " local binary=\"$2\"",
518
+ " shift 2",
519
+ " if command -v \"$binary\" >/dev/null 2>&1; then",
520
+ " nohup \"$binary\" \"$@\" \"$target_url\" >/tmp/humanish-browser-open.log 2>&1 &",
521
+ " printf 'HUMANISH_BROWSER_RESOLVED=%s\\n' \"$label\"",
522
+ " return 0",
523
+ " fi",
524
+ " return 1",
525
+ "}",
526
+ `chrome_debug_flags=(--remote-debugging-address=127.0.0.1 --remote-debugging-port=9222 "--user-data-dir=$chrome_profile_dir" ${chromiumFlags})`,
527
+ "prepare_chrome_profile",
528
+ "open_target() {",
529
+ " case \"$browser_preference\" in",
530
+ " chrome)",
531
+ " launch_browser google-chrome google-chrome --new-window \"${chrome_debug_flags[@]}\" && return 0",
532
+ " launch_browser google-chrome-stable google-chrome-stable --new-window \"${chrome_debug_flags[@]}\" && return 0",
533
+ " echo 'requested browser chrome was not found' >&2",
534
+ " return 127",
535
+ " ;;",
536
+ " chromium)",
537
+ " launch_browser chromium chromium --new-window \"${chrome_debug_flags[@]}\" && return 0",
538
+ " launch_browser chromium-browser chromium-browser --new-window \"${chrome_debug_flags[@]}\" && return 0",
539
+ " echo 'requested browser chromium was not found' >&2",
540
+ " return 127",
541
+ " ;;",
542
+ " firefox)",
543
+ " launch_browser firefox firefox --new-window && return 0",
544
+ " echo 'requested browser firefox was not found' >&2",
545
+ " return 127",
546
+ " ;;",
547
+ " default)",
548
+ " launch_browser google-chrome google-chrome --new-window \"${chrome_debug_flags[@]}\" && return 0",
549
+ " launch_browser google-chrome-stable google-chrome-stable --new-window \"${chrome_debug_flags[@]}\" && return 0",
550
+ " launch_browser chromium chromium --new-window \"${chrome_debug_flags[@]}\" && return 0",
551
+ " launch_browser chromium-browser chromium-browser --new-window \"${chrome_debug_flags[@]}\" && return 0",
552
+ " launch_browser firefox firefox --new-window && return 0",
553
+ " launch_browser xdg-open xdg-open && return 0",
554
+ " echo 'no browser opener found' >&2",
555
+ " return 127",
556
+ " ;;",
557
+ " esac",
558
+ "}",
559
+ "open_target"
560
+ ].join("\n");
561
+ const result = await runDesktopCommandOrThrow(() => desktop.commands.run(browserLaunchCommand, {
562
+ requestTimeoutMs,
563
+ timeoutMs: 15_000,
564
+ }), ({ exitCode, stderrTail }) => new Error(`browser launch failed${exitCode === undefined ? "" : ` with exit ${exitCode}`}: ${stderrTail}`));
565
+ if (result.exitCode !== undefined && result.exitCode !== 0) {
566
+ throw new Error(`browser launch failed with exit ${result.exitCode}: ${tailOf(result.stderr ?? result.stdout ?? "")}`);
567
+ }
568
+ const resolved = (result.stdout ?? "").match(/^HUMANISH_BROWSER_RESOLVED=(\S+)$/m)?.[1];
569
+ return browserPreference === undefined ? undefined : {
570
+ requested: requestedBrowser,
571
+ ...(resolved === undefined ? {} : { resolved })
572
+ };
573
+ }
574
+ if (browserPreference === undefined || browserPreference === "default") {
575
+ if (desktop.open) {
576
+ await desktop.open(targetUrl);
577
+ }
578
+ else {
579
+ await desktop.launch("google-chrome", targetUrl);
580
+ }
581
+ return browserPreference === undefined ? undefined : { requested: requestedBrowser };
582
+ }
583
+ const launchTarget = requestedBrowser === "chrome" ? "google-chrome"
584
+ : requestedBrowser === "chromium" ? "chromium"
585
+ : requestedBrowser === "firefox" ? "firefox"
586
+ : "google-chrome";
587
+ await desktop.launch(launchTarget, targetUrl);
588
+ return { requested: requestedBrowser, resolved: launchTarget };
589
+ }
590
+ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs) {
591
+ return async () => {
592
+ const script = [
593
+ "const pages = await fetch('http://127.0.0.1:9222/json').then((r) => r.json()).catch(() => []);",
594
+ "const page = Array.isArray(pages) ? pages.find((entry) => entry && entry.type === 'page' && /^https?:/.test(String(entry.url || ''))) : undefined;",
595
+ "if (!page) { console.log('{}'); process.exit(0); }",
596
+ "let text = '';",
597
+ "let url = String(page.url || '');",
598
+ "let title = String(page.title || '');",
599
+ "if (typeof WebSocket === 'function' && page.webSocketDebuggerUrl) {",
600
+ " const ws = new WebSocket(page.webSocketDebuggerUrl);",
601
+ " const result = await new Promise((resolve) => {",
602
+ " const timer = setTimeout(() => resolve(undefined), 1500);",
603
+ " ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { returnByValue: true, expression: '({ url: location.href, title: document.title, text: (document.body && document.body.innerText || \"\").slice(0, 20000) })' } }));",
604
+ " ws.onmessage = (event) => {",
605
+ " try {",
606
+ " const payload = JSON.parse(String(event.data));",
607
+ " if (payload.id !== 1) return;",
608
+ " clearTimeout(timer);",
609
+ " resolve(payload.result && payload.result.result && payload.result.result.value);",
610
+ " } catch { clearTimeout(timer); resolve(undefined); }",
611
+ " };",
612
+ " ws.onerror = () => { clearTimeout(timer); resolve(undefined); };",
613
+ " }).finally(() => { try { ws.close(); } catch {} });",
614
+ " if (result && typeof result === 'object') {",
615
+ " url = typeof result.url === 'string' ? result.url : url;",
616
+ " title = typeof result.title === 'string' ? result.title : title;",
617
+ " text = typeof result.text === 'string' ? result.text : '';",
618
+ " }",
619
+ "}",
620
+ "console.log(JSON.stringify({ url, title, text }));"
621
+ ].join("\n");
622
+ const result = await desktop.commands.run(`node --input-type=module -e ${shellSingleQuote(script)}`, {
623
+ requestTimeoutMs,
624
+ timeoutMs: 5_000
625
+ });
626
+ if (result.exitCode !== undefined && result.exitCode !== 0) {
627
+ return {};
628
+ }
629
+ try {
630
+ const parsed = JSON.parse((result.stdout ?? "{}").trim() || "{}");
631
+ if (!parsed || typeof parsed !== "object") {
632
+ return {};
633
+ }
634
+ const record = parsed;
635
+ return {
636
+ ...(typeof record.url === "string" && record.url.length > 0 ? { url: record.url } : {}),
637
+ ...(typeof record.title === "string" && record.title.length > 0 ? { title: record.title } : {}),
638
+ ...(typeof record.text === "string" && record.text.length > 0 ? { text: record.text } : {})
639
+ };
640
+ }
641
+ catch {
642
+ return {};
643
+ }
644
+ };
645
+ }
646
+ function shellSingleQuote(value) {
647
+ return `'${value.replace(/'/g, "'\\''")}'`;
648
+ }
649
+ async function startDesktopStream(desktop, browserWindowId) {
650
+ if (!browserWindowId) {
651
+ await desktop.stream.start({ requireAuth: true });
652
+ return;
653
+ }
654
+ try {
655
+ await desktop.stream.start({ requireAuth: true, windowId: browserWindowId });
656
+ }
657
+ catch {
658
+ await desktop.stream.start({ requireAuth: true });
659
+ }
660
+ }
661
+ function completionReasonContradictsGoal(reason) {
662
+ const text = stripNegatedNonBlockerPhrases(reason.toLowerCase());
663
+ return /\b(can'?t|cannot|could not|unable|blocked|blocker|failed|invalid|not set)\b/.test(text)
664
+ || /\b(shows|showing|hit|encountered|returned|got)\b.{0,80}\berror\b/.test(text)
665
+ || /\berror[:.]/.test(text)
666
+ || /what would you like me to do|please tell me|need (the )?(task|credentials|instructions)/.test(text);
667
+ }
668
+ function stripNegatedNonBlockerPhrases(text) {
669
+ return text
670
+ .replace(/\bno\s+(?:real\s+|remaining\s+|actual\s+)?(?:blocker|blockers|blocking issue|blocking issues|error|errors|failure|failures)\s+(?:was\s+|were\s+)?(?:encountered|observed|found|hit|seen|reported|detected)\b/g, "")
671
+ .replace(/\bwithout\s+(?:a\s+|any\s+)?(?:real\s+|remaining\s+|actual\s+)?(?:blocker|blockers|blocking issue|blocking issues|error|errors|failure|failures)\b/g, "")
672
+ .replace(/\bnot\s+(?:blocked|a blocker|an error|failed)\b/g, "");
673
+ }
674
+ function traceHasStopWhenMatch(session) {
675
+ return session.trace.items.some((item) => item.kind === "notice"
676
+ && item.status === "matched"
677
+ && item.title.startsWith("stopWhen matched"));
678
+ }
679
+ /**
680
+ * Run ONE E2B desktop lane end-to-end: create the sandbox (per-lane metadata + the lane's device
681
+ * resolution), prepareDesktop, verify geometry, (clone+serve+seed the subject per lane), open the
682
+ * browser, run the session, and ALWAYS tear down THIS lane's sandbox BY ID in a finally. Never
683
+ * enumerates sandboxes. Extracted from the former single-lane block; at N=1 it writes the exact
684
+ * same artifacts (actor.json, screenshots/<name>) the bundle has always referenced.
685
+ */
686
+ export async function runCuaLane(spec, deps) {
687
+ const { config, appUrl, cloneRoute, localTreeRoute, serve, subjectRepo, subjectEnvNames } = deps;
688
+ const targetUrl = spec.targetUrl ?? appUrl;
689
+ const env = deps.env;
690
+ const warnings = [];
691
+ const screenshots = [];
692
+ const writeScreenshot = makeLaneWriteScreenshot(deps.artifactRoot, spec, screenshots);
693
+ const stateStepRecords = [];
694
+ // Completed-only trail (durationMs/ok are set on completed events, never on started ones):
695
+ // this is what survives into bundle.events. The default/injected sink below sees EVERY event,
696
+ // started and completed alike, so an operator watching stderr sees both halves of each phase.
697
+ const phaseRecords = [];
698
+ const onSubjectPhase = (event) => {
699
+ if (event.ok !== undefined) {
700
+ phaseRecords.push(event);
701
+ }
702
+ (deps.hooks.onPhase ?? defaultSubjectPhaseSink)(event, { laneId: spec.laneId, laneCount: deps.laneCount });
703
+ };
704
+ let session;
705
+ let sessionError;
706
+ let failureCode;
707
+ let sandboxId;
708
+ let killed = false;
709
+ let streamUrl;
710
+ let subjectCommit;
711
+ let desktopBrowser;
712
+ let provisioned = false;
713
+ let signaled = false;
714
+ const signal = (ok) => {
715
+ if (!signaled && deps.signalProvisioned) {
716
+ signaled = true;
717
+ deps.signalProvisioned(ok);
718
+ }
719
+ };
720
+ let desktopModule;
721
+ let desktop;
722
+ try {
723
+ desktopModule = await (deps.hooks.loadDesktopModule ?? loadE2BDesktopModule)();
724
+ // Optional custom desktop template (image): present → Sandbox.create(template, opts); absent →
725
+ // the byte-stable Sandbox.create(opts) default (stock `desktop` template).
726
+ desktop = await createDesktopSandbox(desktopModule, {
727
+ apiKey: deps.e2bApiKey,
728
+ requestTimeoutMs: deps.requestTimeoutMs,
729
+ timeoutMs: deps.perLaneSandboxMs,
730
+ metadata: {
731
+ ...CUA_ACTOR_LAB_PROVIDER_METADATA,
732
+ labId: config.id,
733
+ simId: spec.simId,
734
+ laneId: spec.laneId,
735
+ laneIndex: String(spec.laneIndex),
736
+ laneCount: String(deps.laneCount)
737
+ },
738
+ // Env placement per the doctrine: the ACTOR's key never enters the sandbox (the model drives
739
+ // from outside). The SUBJECT's declared env NAMES are provisioned here on the clone route.
740
+ ...(subjectEnvNames.length > 0
741
+ ? { envs: Object.fromEntries(subjectEnvNames.map((name) => [name, env[name]])) }
742
+ : {}),
743
+ resolution: spec.resolution,
744
+ dpi: 96,
745
+ lifecycle: { onTimeout: "kill" }
746
+ }, config.execution?.desktop?.template);
747
+ sandboxId = desktop.sandboxId;
748
+ if (deps.hooks.prepareDesktop) {
749
+ await deps.hooks.prepareDesktop(desktop, { laneId: spec.laneId, laneIndex: spec.laneIndex, laneCount: deps.laneCount });
750
+ }
751
+ // Per-lane geometry assertion (fail-closed) — the device claim is verified in-sandbox.
752
+ const geometryError = await checkLaneGeometry(desktop, spec, deps.requestTimeoutMs);
753
+ if (geometryError) {
754
+ sessionError = geometryError;
755
+ failureCode = "HUMANISH_CUA_LAB_DEVICE_GEOMETRY";
756
+ }
757
+ else {
758
+ if (cloneRoute && serve && subjectRepo) {
759
+ subjectCommit = await provisionCloneSubject(desktop, {
760
+ repo: subjectRepo,
761
+ depth: config.subject.clone?.depth ?? 1,
762
+ serve,
763
+ ...(config.subject.state === undefined ? {} : { state: config.subject.state }),
764
+ hasGithubToken: deps.hasGithubToken,
765
+ requestTimeoutMs: deps.requestTimeoutMs,
766
+ scrub: deps.scrubKnownValues,
767
+ onCommit: (commit) => {
768
+ subjectCommit = commit;
769
+ },
770
+ onStateStep: (record) => {
771
+ stateStepRecords.push(record);
772
+ },
773
+ onPhase: onSubjectPhase,
774
+ ...(deps.hooks.detachedTimers ?? {})
775
+ });
776
+ }
777
+ else if (localTreeRoute && serve && deps.localTreeArchiveBuffer) {
778
+ await provisionLocalTreeSubject(desktop, {
779
+ archiveBuffer: deps.localTreeArchiveBuffer,
780
+ serve,
781
+ ...(config.subject.state === undefined ? {} : { state: config.subject.state }),
782
+ requestTimeoutMs: deps.requestTimeoutMs,
783
+ scrub: deps.scrubKnownValues,
784
+ onStateStep: (record) => {
785
+ stateStepRecords.push(record);
786
+ },
787
+ onPhase: onSubjectPhase,
788
+ ...(deps.hooks.detachedTimers ?? {})
789
+ });
790
+ }
791
+ desktopBrowser = await openDesktopBrowserTarget(desktop, targetUrl, deps.requestTimeoutMs, config.execution?.desktop?.browser);
792
+ await desktop.wait(BROWSER_SETTLE_MS).catch(() => undefined);
793
+ // World is ready: release the pipeline gate so the remaining lanes may start.
794
+ provisioned = true;
795
+ signal(true);
796
+ try {
797
+ const browserWindowId = await findVisibleBrowserWindowId(desktop, deps.requestTimeoutMs)
798
+ .catch((error) => {
799
+ warnings.push(`Browser window lookup failed before live stream start (falling back to desktop stream): ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}`);
800
+ return undefined;
801
+ });
802
+ if (browserWindowId !== undefined) {
803
+ await fillDesktopBrowserWindow(desktop, browserWindowId, spec.resolution, deps.requestTimeoutMs);
804
+ }
805
+ await startDesktopStream(desktop, browserWindowId);
806
+ const candidateStreamUrl = desktop.stream.getUrl({
807
+ authKey: desktop.stream.getAuthKey(),
808
+ autoConnect: true,
809
+ viewOnly: true,
810
+ resize: "scale"
811
+ });
812
+ if (typeof candidateStreamUrl === "string" && candidateStreamUrl.trim().length > 0) {
813
+ streamUrl = candidateStreamUrl;
814
+ await deps.hooks.onRuntimeStreamReady?.({
815
+ laneId: spec.laneId,
816
+ sandboxId: desktop.sandboxId,
817
+ simId: spec.simId,
818
+ streamId: spec.streamId,
819
+ url: streamUrl
820
+ });
821
+ }
822
+ else {
823
+ warnings.push("Live desktop stream started but did not return a usable watch URL; Observer will fall back to screenshots.");
824
+ }
825
+ }
826
+ catch (error) {
827
+ warnings.push(`Live desktop stream unavailable (run continues; evidence still captured): ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}`);
828
+ }
829
+ const sessionOptions = {
830
+ instructions: spec.instructions,
831
+ persona: spec.persona,
832
+ timeoutMs: deps.timeoutMs,
833
+ openai: {
834
+ apiKey: deps.openaiApiKey,
835
+ ...(config.actors[0]?.model ? { model: config.actors[0].model } : {})
836
+ },
837
+ desktop: desktop,
838
+ executorOptions: {
839
+ observeBrowserState: makeChromeBrowserStateObserver(desktop, deps.requestTimeoutMs)
840
+ },
841
+ redactScreenshots: deps.redactScreenshots,
842
+ scrubText: deps.scrubKnownValues,
843
+ writeScreenshot,
844
+ ...(spec.stopWhen === undefined ? {} : { stopWhen: spec.stopWhen })
845
+ };
846
+ session = await deps.runSession(sessionOptions);
847
+ }
848
+ }
849
+ catch (error) {
850
+ sessionError = redactText(deps.scrubKnownValues(toErrorMessage(error)));
851
+ }
852
+ finally {
853
+ if (!provisioned) {
854
+ signal(false);
855
+ }
856
+ if (desktop && desktopModule) {
857
+ const failed = sessionError !== undefined || session === undefined;
858
+ // Each route's own keep flag gates its own lane only: a clone.keep can never leak into
859
+ // a local-tree lane's teardown decision, and vice versa.
860
+ const keepReason = cloneRoute && config.subject.clone?.keep === true
861
+ ? "subject.clone.keep"
862
+ : localTreeRoute && config.subject.localTree?.keep === true
863
+ ? "subject.localTree.keep"
864
+ : undefined;
865
+ const keepForDebug = keepReason !== undefined && failed;
866
+ if (keepForDebug) {
867
+ warnings.push(`Sandbox ${desktop.sandboxId} kept for debugging (${keepReason} on failure); reclaim it via E2B or it will be killed on its server-side timeout.`);
868
+ }
869
+ else if (typeof desktopModule.Sandbox.kill === "function") {
870
+ try {
871
+ await desktopModule.Sandbox.kill(desktop.sandboxId, { requestTimeoutMs: 60_000 });
872
+ killed = true;
873
+ }
874
+ catch (error) {
875
+ warnings.push(`Sandbox teardown failed (server-side kill-on-timeout will reclaim it): ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}`);
876
+ }
877
+ }
878
+ else {
879
+ warnings.push("Installed @e2b/desktop SDK does not expose Sandbox.kill; server-side kill-on-timeout will reclaim the sandbox.");
880
+ }
881
+ }
882
+ }
883
+ if (session) {
884
+ await mkdir(path.dirname(path.join(deps.artifactRoot, spec.traceArtifactPath)), { recursive: true });
885
+ await writeFile(path.join(deps.artifactRoot, spec.traceArtifactPath), `${JSON.stringify(session.trace, null, 2)}\n`, "utf8");
886
+ if (session.trace.redaction.screenshots === "raw") {
887
+ warnings.push("Screenshots are full-fidelity (raw) for local use — the bundle stays in gitignored .humanish and nothing scans these pixels; review them before sharing anywhere. Set policies.redactScreenshots: true to blur a share-as-is bundle.");
888
+ }
889
+ }
890
+ const noEngagement = session !== undefined
891
+ && session.completionReason === "goal_satisfied"
892
+ && (session.trace.counts.actions ?? 0) === 0
893
+ && (session.trace.counts.messages ?? 0) === 0
894
+ && !traceHasStopWhenMatch(session);
895
+ if (noEngagement) {
896
+ warnings.push("Actor returned goal_satisfied with ZERO actions and ZERO messages — it likely saw a blank or still-loading screen and stopped without engaging. NOT counted as a pass. Check the screenshot; raise execution.timeoutMs or confirm the subject painted before the first turn.");
897
+ }
898
+ const blockerReason = session?.completionReason === "goal_satisfied" && completionReasonContradictsGoal(session.reason)
899
+ ? session.reason
900
+ : undefined;
901
+ const selfReportedBlocker = blockerReason !== undefined;
902
+ if (selfReportedBlocker) {
903
+ warnings.push(`Actor returned goal_satisfied while its final message describes a blocker or asks for missing instructions — NOT counted as a pass: ${redactText(deps.scrubKnownValues(blockerReason))}`);
904
+ }
905
+ const harnessError = sessionError !== undefined || session?.completionReason === "harness_error";
906
+ return {
907
+ spec,
908
+ ...(session ? { session } : {}),
909
+ ...(sessionError === undefined ? {} : { sessionError }),
910
+ ...(sandboxId === undefined ? {} : { sandboxId }),
911
+ killed,
912
+ streamUrlPresent: streamUrl !== undefined,
913
+ screenshots,
914
+ ...(subjectCommit === undefined ? {} : { subjectCommit }),
915
+ ...(desktopBrowser === undefined ? {} : { desktopBrowser }),
916
+ stateStepRecords,
917
+ phaseRecords,
918
+ warnings,
919
+ noEngagement,
920
+ selfReportedBlocker,
921
+ harnessError,
922
+ ...(failureCode === undefined ? {} : { failureCode })
923
+ };
924
+ }
925
+ /** Run the single IN-PROCESS lane (a custom executor + provider; NO E2B). Always one lane. */
926
+ async function runInProcessLane(spec, deps) {
927
+ const warnings = [];
928
+ const screenshots = [];
929
+ const writeScreenshot = makeLaneWriteScreenshot(deps.artifactRoot, spec, screenshots);
930
+ let session;
931
+ let sessionError;
932
+ try {
933
+ const executor = await deps.hooks.buildExecutor({ config: deps.config, actor: deps.descriptor, appUrl: deps.appUrl });
934
+ const provider = await deps.hooks.buildProvider({ config: deps.config, actor: deps.descriptor });
935
+ const sessionOptions = {
936
+ instructions: spec.instructions,
937
+ persona: spec.persona,
938
+ timeoutMs: deps.timeoutMs,
939
+ provider,
940
+ executor,
941
+ redactScreenshots: deps.redactScreenshots,
942
+ scrubText: deps.scrubKnownValues,
943
+ writeScreenshot,
944
+ ...(spec.stopWhen === undefined ? {} : { stopWhen: spec.stopWhen })
945
+ };
946
+ session = await deps.runSession(sessionOptions);
947
+ }
948
+ catch (error) {
949
+ sessionError = redactText(deps.scrubKnownValues(toErrorMessage(error)));
950
+ }
951
+ if (session) {
952
+ await mkdir(path.dirname(path.join(deps.artifactRoot, spec.traceArtifactPath)), { recursive: true });
953
+ await writeFile(path.join(deps.artifactRoot, spec.traceArtifactPath), `${JSON.stringify(session.trace, null, 2)}\n`, "utf8");
954
+ if (session.trace.redaction.screenshots === "raw") {
955
+ warnings.push("Screenshots are full-fidelity (raw) for local use — the bundle stays in gitignored .humanish and nothing scans these pixels; review them before sharing anywhere. Set policies.redactScreenshots: true to blur a share-as-is bundle.");
956
+ }
957
+ }
958
+ const noEngagement = session !== undefined
959
+ && session.completionReason === "goal_satisfied"
960
+ && (session.trace.counts.actions ?? 0) === 0
961
+ && (session.trace.counts.messages ?? 0) === 0
962
+ && !traceHasStopWhenMatch(session);
963
+ if (noEngagement) {
964
+ warnings.push("Actor returned goal_satisfied with ZERO actions and ZERO messages — it likely saw a blank or still-loading screen and stopped without engaging. NOT counted as a pass. Check the screenshot; raise execution.timeoutMs or confirm the subject painted before the first turn.");
965
+ }
966
+ const blockerReason = session?.completionReason === "goal_satisfied" && completionReasonContradictsGoal(session.reason)
967
+ ? session.reason
968
+ : undefined;
969
+ const selfReportedBlocker = blockerReason !== undefined;
970
+ if (selfReportedBlocker) {
971
+ warnings.push(`Actor returned goal_satisfied while its final message describes a blocker or asks for missing instructions — NOT counted as a pass: ${blockerReason}`);
972
+ }
973
+ return {
974
+ spec,
975
+ ...(session ? { session } : {}),
976
+ ...(sessionError === undefined ? {} : { sessionError }),
977
+ killed: false,
978
+ streamUrlPresent: false,
979
+ screenshots,
980
+ stateStepRecords: [],
981
+ phaseRecords: [],
982
+ warnings,
983
+ noEngagement,
984
+ selfReportedBlocker,
985
+ harnessError: sessionError !== undefined || session?.completionReason === "harness_error",
986
+ entryKind: "local-app"
987
+ };
988
+ }
989
+ /**
990
+ * Run N>1 E2B lanes with bounded concurrency, a pipeline gate (lane 1 provisions before the rest
991
+ * start), and session fail-fast on HARNESS errors only (queued lanes become `blocked` with a
992
+ * pinned reason + a fail-fast event; mission verdicts never trip it). Each lane tears down ITS
993
+ * OWN sandbox by id; nothing here ever enumerates.
994
+ */
995
+ async function runCuaLanes(laneSpecs, deps, concurrency) {
996
+ const failFast = { tripped: false, reason: "" };
997
+ let resolveGate;
998
+ let rejectGate;
999
+ const gate = new Promise((resolve, reject) => {
1000
+ resolveGate = resolve;
1001
+ rejectGate = () => reject(new Error("gate"));
1002
+ });
1003
+ // The gate is rejected on lane-0 provisioning failure; swallow the unhandled rejection if no
1004
+ // later lane ever awaits it (concurrency could let lane 0 finish alone).
1005
+ gate.catch(() => undefined);
1006
+ const outcomes = await mapWithConcurrency(laneSpecs, concurrency, async (spec, index) => {
1007
+ if (index > 0) {
1008
+ try {
1009
+ await gate;
1010
+ }
1011
+ catch {
1012
+ return blockedLaneOutcome(spec, `skipped: lane ${laneSpecs[0]?.laneId ?? "lane-01"} failed to provision its world (pipeline gate)`);
1013
+ }
1014
+ }
1015
+ if (failFast.tripped) {
1016
+ return blockedLaneOutcome(spec, `skipped: ${failFast.reason}`);
1017
+ }
1018
+ const outcome = await runCuaLane(spec, {
1019
+ ...deps,
1020
+ ...(index === 0
1021
+ ? {
1022
+ signalProvisioned: (ok) => {
1023
+ if (ok) {
1024
+ resolveGate?.();
1025
+ }
1026
+ else {
1027
+ rejectGate?.();
1028
+ }
1029
+ }
1030
+ }
1031
+ : {})
1032
+ });
1033
+ if (outcome.harnessError && !failFast.tripped) {
1034
+ failFast.tripped = true;
1035
+ failFast.reason = `a prior lane (${outcome.spec.laneId}) ended in a harness error (fail-fast)`;
1036
+ }
1037
+ return outcome;
1038
+ });
1039
+ return { outcomes, ...(failFast.tripped ? { failFastReason: failFast.reason } : {}) };
1040
+ }
1041
+ /** Project one lane outcome (or a dry-run contract spec) into the public CuaLaneResult. */
1042
+ function toLaneResult(spec, outcome, subject, dryRun) {
1043
+ const base = {
1044
+ id: spec.laneId,
1045
+ ...(spec.actorType === undefined ? {} : { actorType: spec.actorType }),
1046
+ ...(spec.surface === undefined ? {} : { surface: spec.surface }),
1047
+ ...(spec.caseGroup === undefined ? {} : { caseGroup: spec.caseGroup }),
1048
+ index: spec.laneIndex + 1,
1049
+ persona: spec.persona.id,
1050
+ device: spec.deviceName,
1051
+ resolution: spec.resolution,
1052
+ subject
1053
+ };
1054
+ if (!outcome || dryRun) {
1055
+ return { ...base, status: "contract_proof_only", ok: dryRun };
1056
+ }
1057
+ if (outcome.skippedReason !== undefined) {
1058
+ return {
1059
+ ...base,
1060
+ status: "blocked",
1061
+ ok: false,
1062
+ skippedReason: outcome.skippedReason,
1063
+ error: { code: "HUMANISH_CUA_LAB_FAILED", message: outcome.skippedReason }
1064
+ };
1065
+ }
1066
+ const session = outcome.session;
1067
+ const laneOk = laneOutcomeOk(outcome, dryRun);
1068
+ const status = session ? session.status : "failed";
1069
+ return {
1070
+ ...base,
1071
+ status,
1072
+ ok: laneOk,
1073
+ ...(session
1074
+ ? {
1075
+ session: {
1076
+ status: session.status,
1077
+ completionReason: session.completionReason,
1078
+ reason: session.reason,
1079
+ screenshots: outcome.screenshots.length
1080
+ }
1081
+ }
1082
+ : {}),
1083
+ ...(outcome.sandboxId === undefined
1084
+ ? {}
1085
+ : { sandbox: { sandboxId: outcome.sandboxId, killed: outcome.killed, streamUrlPresent: outcome.streamUrlPresent } }),
1086
+ ...(laneOk
1087
+ ? {}
1088
+ : {
1089
+ error: {
1090
+ code: outcome.failureCode ?? "HUMANISH_CUA_LAB_FAILED",
1091
+ message: outcome.sessionError
1092
+ ?? (outcome.noEngagement
1093
+ ? "Actor took no actions and produced no message (likely a blank/still-loading screen); not a credible goal_satisfied."
1094
+ : outcome.selfReportedBlocker
1095
+ ? "Actor reported goal_satisfied while its final message described a blocker or asked for missing instructions; not a credible pass."
1096
+ : session?.completionReason === "harness_error"
1097
+ ? `Computer-use session ended with a harness error: ${session.reason}`
1098
+ : session?.status !== "passed"
1099
+ ? `Computer-use session ended with ${session?.status ?? "unknown"}: ${session?.reason ?? "no terminal reason"}`
1100
+ : "Computer-use lab did not produce a terminal session.")
1101
+ }
1102
+ })
1103
+ };
1104
+ }
1105
+ function laneOutcomeOk(outcome, dryRun) {
1106
+ if (dryRun)
1107
+ return true;
1108
+ if (!outcome || outcome.skippedReason !== undefined)
1109
+ return false;
1110
+ return outcome.session !== undefined
1111
+ && outcome.session.status === "passed"
1112
+ && outcome.session.completionReason !== "harness_error"
1113
+ && outcome.sessionError === undefined
1114
+ && !outcome.noEngagement
1115
+ && !outcome.selfReportedBlocker;
1116
+ }
1117
+ function fanoutReviewVerdict(args) {
1118
+ if (args.dryRun)
1119
+ return "contract_proof_only";
1120
+ const outcomes = args.outcomes ?? [];
1121
+ if (outcomes.length !== args.expectedLaneCount)
1122
+ return "fail";
1123
+ if (outcomes.some((outcome) => !laneOutcomeOk(outcome, false))) {
1124
+ return outcomes.some((outcome) => outcome.session?.status === "timed_out")
1125
+ ? "timed_out"
1126
+ : "fail";
1127
+ }
1128
+ return "pass";
1129
+ }
1130
+ /** Build the per-lane subject projection (invariant 5). Local-tree lanes all share ONE
1131
+ * host-packed archive, so every lane's projection carries the identical archiveSha256/
1132
+ * commit/dirty (no per-lane divergence is possible, unlike the clone route's per-lane
1133
+ * in-sandbox commit). */
1134
+ function laneSubjectProjection(args) {
1135
+ if (args.cloneRoute && args.publicRepo) {
1136
+ return {
1137
+ source: "clone",
1138
+ repo: args.publicRepo,
1139
+ ...(args.subjectCommit === undefined ? {} : { commit: args.subjectCommit }),
1140
+ envNames: args.subjectEnvNames,
1141
+ state: args.subjectState
1142
+ };
1143
+ }
1144
+ if (args.localTreeRoute) {
1145
+ const archive = args.localTreeArchive;
1146
+ return {
1147
+ source: "local-tree",
1148
+ ...(archive === undefined ? {} : { archiveSha256: archive.archiveSha256 }),
1149
+ ...(archive?.git === undefined ? {} : { commit: archive.git.commit, dirty: archive.git.dirty }),
1150
+ envNames: args.subjectEnvNames,
1151
+ state: args.subjectState
1152
+ };
1153
+ }
1154
+ return { source: "app-url", state: args.subjectState };
1155
+ }
1156
+ /** Narrow a resolved CuaSubjectProjection into the shape buildCuaBundle/buildSingleLaneBundle's
1157
+ * subjectProvenance param wants (provisioned-route sources only; app-url stays undeclared, the
1158
+ * default branch buildCuaBundle already handles). */
1159
+ function subjectProvenanceArg(subject, publicRepo, subjectEnvNames) {
1160
+ if (subject.source === "clone" && publicRepo) {
1161
+ return {
1162
+ source: "clone",
1163
+ repo: publicRepo,
1164
+ ...(subject.commit === undefined ? {} : { commit: subject.commit }),
1165
+ envNames: subjectEnvNames,
1166
+ state: subject.state
1167
+ };
1168
+ }
1169
+ if (subject.source === "local-tree") {
1170
+ return {
1171
+ source: "local-tree",
1172
+ ...(subject.archiveSha256 === undefined ? {} : { archiveSha256: subject.archiveSha256 }),
1173
+ ...(subject.commit === undefined ? {} : { commit: subject.commit }),
1174
+ ...(subject.dirty === undefined ? {} : { dirty: subject.dirty }),
1175
+ envNames: subjectEnvNames,
1176
+ state: subject.state
1177
+ };
1178
+ }
1179
+ return undefined;
1180
+ }
1181
+ export async function runCuaActorLab(options) {
1182
+ const { config, dryRun } = options;
1183
+ const cwd = path.resolve(options.cwd);
1184
+ const hooks = options.hooks ?? {};
1185
+ let liveObserver;
1186
+ const runtimeStreamUrls = [];
1187
+ const liveHooks = {
1188
+ ...hooks,
1189
+ onRuntimeStreamReady: async (stream) => {
1190
+ await hooks.onRuntimeStreamReady?.(stream);
1191
+ runtimeStreamUrls.push({ streamId: stream.streamId, url: stream.url });
1192
+ if (liveObserver) {
1193
+ attachObserverRuntimeStreamUrls(liveObserver, runtimeStreamUrls);
1194
+ }
1195
+ }
1196
+ };
1197
+ const env = hooks.env ?? process.env;
1198
+ const render = hooks.renderObserverFn ?? renderObserver;
1199
+ const cloneRoute = config.subject.source === "clone";
1200
+ const localTreeRoute = config.subject.source === "local-tree";
1201
+ // Both routes provision the subject in-sandbox (clone via git, local-tree via pack+upload)
1202
+ // and then share the identical install/build/state/start/probe pipeline, so every seam that
1203
+ // gates on "does this route provision a subject" is keyed on this union, not on cloneRoute
1204
+ // alone.
1205
+ const provisionedRoute = cloneRoute || localTreeRoute;
1206
+ const serve = config.subject.serve;
1207
+ const appUrl = (provisionedRoute ? serve?.url : config.subject.appUrl) ?? "";
1208
+ const subjectRepo = cloneRoute ? config.subject.repos?.[0] ?? "" : undefined;
1209
+ const subjectEnvNames = provisionedRoute ? config.subject.env ?? [] : [];
1210
+ const actor = config.actors[0];
1211
+ const actorType = actor?.type ?? "";
1212
+ const fail = (code, message, actorLabel) => ({
1213
+ schema: CUA_ACTOR_LAB_SCHEMA,
1214
+ ok: false,
1215
+ cwd,
1216
+ labId: config.id,
1217
+ actor: actorLabel ?? actorType,
1218
+ appUrl,
1219
+ dryRun,
1220
+ runId: options.runId ?? "not-created",
1221
+ lanes: [],
1222
+ warnings: [],
1223
+ error: { code, message }
1224
+ });
1225
+ // Resolve the actor through the registry — the parse layer validated this, but the engine fails
1226
+ // closed rather than trusting a config that arrived through another door.
1227
+ const descriptor = actorRegistry[actorType];
1228
+ if (!descriptor || !isCuaActorDescriptor(descriptor)) {
1229
+ return fail("HUMANISH_CUA_LAB_ACTOR_UNSUPPORTED", `actors[0].type "${actorType}" is not a registered computer-use actor.`);
1230
+ }
1231
+ const runSession = hooks.runSession ?? descriptor.runSession;
1232
+ const inProcessRoute = hooks.buildExecutor !== undefined;
1233
+ const localAppSubject = config.subject.source === "local-app";
1234
+ // Engine re-enforcement of the clone-route structure (library API surface).
1235
+ if (cloneRoute && (!serve || !subjectRepo || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(subjectRepo))) {
1236
+ return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", !serve
1237
+ ? "clone subjects on the computer-use route require `subject.serve` (start + url) — the lab serves the app in-sandbox."
1238
+ : `subject.repos[0] must be an owner/repo slug (got "${subjectRepo ?? ""}").`, descriptor.id);
1239
+ }
1240
+ // Engine re-enforcement of the local-tree-route structure (library API surface): a caller
1241
+ // driving this function directly (bypassing parseLabConfig) still gets the same fail-closed
1242
+ // shape the parser enforces, naming which requirement is missing.
1243
+ if (localTreeRoute && (!serve || config.execution?.target !== "e2b-desktop")) {
1244
+ return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", !serve
1245
+ ? "local-tree subjects on the computer-use route require `subject.serve` (start + url): the lab packs and serves the working tree in-sandbox."
1246
+ : "local-tree subjects require `execution.target: e2b-desktop`: the packed working tree is provisioned and served inside a hosted desktop sandbox.", descriptor.id);
1247
+ }
1248
+ // Engine re-enforcement of the state declaration (library API surface).
1249
+ if (config.subject.state) {
1250
+ const stateReason = !provisionedRoute
1251
+ ? "`subject.state` applies only to clone subjects or local-tree subjects (the lab seeds the state it serves)."
1252
+ : subjectStateInvalidReason(config.subject.state, config.subject.env);
1253
+ if (stateReason) {
1254
+ return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", stateReason, descriptor.id);
1255
+ }
1256
+ }
1257
+ // Re-enforce the entry-target boundary (library API surface).
1258
+ const allowPublicTargets = config.policies?.allowPublicTargets === true;
1259
+ const declaredTargets = [appUrl, ...(actor?.lanes ?? []).map((lane) => lane.target).filter((target) => target !== undefined)];
1260
+ const entryTargetSafe = declaredTargets.every((target) => provisionedRoute || localAppSubject
1261
+ ? isLoopbackUrl(target)
1262
+ : allowPublicTargets
1263
+ ? isHttpUrl(target)
1264
+ : isLoopbackUrl(target));
1265
+ if (!entryTargetSafe) {
1266
+ return fail("HUMANISH_CUA_LAB_SUBJECT_UNSAFE", provisionedRoute || localAppSubject || !allowPublicTargets
1267
+ ? "subject.appUrl and any actors[0].lanes[].target entries must be loopback (127.0.0.1 or localhost) unless policies.allowPublicTargets is set for an app-url subject."
1268
+ : "subject.appUrl and actors[0].lanes[].target entries must be valid http(s) URLs.", descriptor.id);
1269
+ }
1270
+ // In-process route pairing guard (boot-time, BEFORE key-gating): a custom executor needs a
1271
+ // custom provider too (the default OpenAI provider is vision-based and would fail closed).
1272
+ if (hooks.buildExecutor !== undefined && hooks.buildProvider === undefined) {
1273
+ return fail("HUMANISH_CUA_LAB_EXECUTOR_NO_PROVIDER", "cuaHooks.buildExecutor requires cuaHooks.buildProvider — a state-driven executor returns no screenshot, so it must be paired with a NON-vision provider (the default OpenAI computer-use provider is vision-based and would fail closed).", descriptor.id);
1274
+ }
1275
+ // local-app fail-closed (BEFORE key-gating): there is no built-in in-process driver.
1276
+ if (localAppSubject && !inProcessRoute) {
1277
+ return fail("HUMANISH_CUA_LAB_LOCAL_APP_NO_EXECUTOR", "subject.source: local-app requires a library caller to supply cuaHooks.buildExecutor + buildProvider; there is no built-in driver for an in-process JS contract. (Drive the app via runLab(..., { cuaHooks: { buildExecutor, buildProvider } }).)", descriptor.id);
1278
+ }
1279
+ // Re-enforce the fan-out cross-validation (library API surface): lanes XOR count/laneFocus,
1280
+ // device XOR raw resolution, cap, unique ids, allowPublicTargets+N>1, clone.fanout.
1281
+ const fanoutReason = cuaLaneValidationReason(config);
1282
+ if (fanoutReason) {
1283
+ return fail("HUMANISH_CUA_LAB_FANOUT_INVALID", fanoutReason, descriptor.id);
1284
+ }
1285
+ // Resolve the lane plan (pure) — the SAME table for dry-run and live.
1286
+ let { lanes: laneSpecs, plan } = laneSpecsAndPlan(config, {
1287
+ ...(options.countOverride === undefined ? {} : { countOverride: options.countOverride }),
1288
+ env,
1289
+ dryRun
1290
+ });
1291
+ let laneCount = laneSpecs.length;
1292
+ if (laneCount > MAX_CUA_LANES) {
1293
+ return fail("HUMANISH_CUA_LAB_FANOUT_INVALID", `Computer-use fan-out is capped at ${MAX_CUA_LANES} lanes (resolved ${laneCount}); N concurrent paid desktops is real spend.`, descriptor.id);
1294
+ }
1295
+ if (inProcessRoute && laneCount > 1) {
1296
+ return fail("HUMANISH_CUA_LAB_FANOUT_INVALID", "Multi-lane fan-out is not supported on the in-process route (cuaHooks.buildExecutor) — fan-out provisions one independent E2B desktop per lane, which the in-process route deliberately skips. Run a single in-process lane, or fan out on the E2B route.", descriptor.id);
1297
+ }
1298
+ let rerunLineage;
1299
+ if (options.rerun) {
1300
+ const selected = await resolveCuaRerunSelection({
1301
+ cwd,
1302
+ config,
1303
+ sourceRunId: options.rerun.sourceRunId,
1304
+ ...(options.rerun.laneIds === undefined ? {} : { laneIds: options.rerun.laneIds }),
1305
+ laneSpecs,
1306
+ plan
1307
+ });
1308
+ if (!selected.ok) {
1309
+ return fail("HUMANISH_CUA_LAB_RERUN_INVALID", selected.message, descriptor.id);
1310
+ }
1311
+ laneSpecs = selected.laneSpecs;
1312
+ plan = selected.plan;
1313
+ laneCount = laneSpecs.length;
1314
+ rerunLineage = selected.rerun;
1315
+ }
1316
+ // Pre-flight plan: BEFORE any sandbox or provider call (dry-run AND live). The hook fires for
1317
+ // every N (observable + testable); the stderr table prints for fan-out (N>1) so single-lane
1318
+ // runs stay as quiet as they always were.
1319
+ if (laneCount > 1) {
1320
+ emitPreflightPlan(plan, config.id);
1321
+ }
1322
+ hooks.onPreflight?.(plan);
1323
+ // Read keys once into locals (names only; values never logged or persisted).
1324
+ const openaiApiKey = env.OPENAI_API_KEY?.trim() ?? "";
1325
+ const e2bApiKey = env.E2B_API_KEY?.trim() ?? "";
1326
+ // Literal scrubber for every known provisioned value (no secret "shape" to pattern-match).
1327
+ const knownSecretValues = [
1328
+ openaiApiKey,
1329
+ e2bApiKey,
1330
+ ...subjectEnvNames.map((name) => env[name] ?? "")
1331
+ ].filter((value) => value.length >= 4);
1332
+ const scrubKnownValues = (text) => knownSecretValues.reduce((current, value) => current.split(value).join("[REDACTED_SECRET]"), text);
1333
+ const redactRepoLabel = config.policies?.redactRepos ?? subjectEnvNames.includes("GITHUB_TOKEN");
1334
+ const publicRepo = cloneRoute && subjectRepo ? (redactRepoLabel ? "repo-01" : subjectRepo) : undefined;
1335
+ const hasGithubToken = subjectEnvNames.includes("GITHUB_TOKEN");
1336
+ // Key-gating is route-aware: the in-process route uses the caller's OWN model + executor.
1337
+ if (!dryRun && !inProcessRoute) {
1338
+ const missingKeys = [
1339
+ ...(openaiApiKey ? [] : ["OPENAI_API_KEY"]),
1340
+ ...(e2bApiKey ? [] : ["E2B_API_KEY"])
1341
+ ];
1342
+ if (missingKeys.length > 0) {
1343
+ return fail("HUMANISH_CUA_LAB_KEYS_MISSING", `Live computer-use labs need ${missingKeys.join(" and ")} in the environment (pass them via --env-file; values are never persisted).`, descriptor.id);
1344
+ }
1345
+ const missingSubjectEnv = subjectEnvNames.filter((name) => !env[name]?.trim());
1346
+ if (missingSubjectEnv.length > 0) {
1347
+ return fail("HUMANISH_CUA_LAB_SUBJECT_ENV_MISSING", `subject.env declares ${missingSubjectEnv.join(", ")} but the environment does not provide ${missingSubjectEnv.length === 1 ? "it" : "them"} (pass via --env-file; values are never persisted).`, descriptor.id);
1348
+ }
1349
+ }
1350
+ const runId = options.runId ?? makeCuaRunId();
1351
+ const artifactRoot = path.join(cwd, ".humanish", "runs", runId);
1352
+ const createdAt = new Date().toISOString();
1353
+ const timeoutMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
1354
+ const requestTimeoutMs = readPositiveInt(env.HUMANISH_E2B_REQUEST_TIMEOUT_MS, 60_000);
1355
+ const redactScreenshots = config.policies?.redactScreenshots === true;
1356
+ await mkdir(path.join(artifactRoot, "screenshots"), { recursive: true });
1357
+ const source = await buildRunSource({
1358
+ capturedAt: createdAt,
1359
+ cwd,
1360
+ humanishSource: "present",
1361
+ packageName: "humanish"
1362
+ });
1363
+ // Pack the working tree ONCE per run, on the host, BEFORE any sandbox or provider call: every
1364
+ // fan-out lane below uploads this SAME archive, so one archiveSha256 describes every lane's
1365
+ // digest. Dry-run packs nothing (no fs side effects; the contract bundle carries no
1366
+ // archiveSha256). A packing failure fails the run closed here, before createDesktopSandbox is
1367
+ // ever reached.
1368
+ let localTreeArchive;
1369
+ let localTreeArchiveBuffer;
1370
+ if (localTreeRoute && !dryRun) {
1371
+ const packLocalTree = hooks.packLocalTree ?? defaultPackLocalTree;
1372
+ try {
1373
+ const packed = await packLocalTree({
1374
+ root: cwd,
1375
+ ...(config.subject.localTree?.exclude === undefined ? {} : { extraExclude: config.subject.localTree.exclude }),
1376
+ ...(config.subject.localTree?.maxArchiveBytes === undefined ? {} : { maxArchiveBytes: config.subject.localTree.maxArchiveBytes })
1377
+ });
1378
+ localTreeArchive = packed.archive;
1379
+ localTreeArchiveBuffer = packed.buffer;
1380
+ // One operator-facing line (stderr, same channel as emitPreflightPlan): what left the
1381
+ // host, by counts and digest only, never paths or file names.
1382
+ process.stderr.write(`humanish local-tree: packed ${packed.archive.fileCount} entries, ${packed.archive.totalBytes} bytes, archiveSha256 ${packed.archive.archiveSha256}`
1383
+ + `${packed.archive.git ? ` (commit ${packed.archive.git.commit.slice(0, 12)}, ${packed.archive.git.dirty ? "dirty" : "clean"} working tree)` : " (not a git work tree)"}\n`);
1384
+ }
1385
+ catch (error) {
1386
+ return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", `local-tree packing failed: ${redactText(scrubKnownValues(toErrorMessage(error)))}`, descriptor.id);
1387
+ }
1388
+ }
1389
+ const deps = {
1390
+ config,
1391
+ descriptor,
1392
+ appUrl,
1393
+ cloneRoute,
1394
+ localTreeRoute,
1395
+ ...(serve === undefined ? {} : { serve }),
1396
+ ...(subjectRepo === undefined ? {} : { subjectRepo }),
1397
+ subjectEnvNames,
1398
+ hasGithubToken,
1399
+ ...(localTreeArchiveBuffer === undefined ? {} : { localTreeArchiveBuffer }),
1400
+ env,
1401
+ openaiApiKey,
1402
+ e2bApiKey,
1403
+ requestTimeoutMs,
1404
+ perLaneSandboxMs: resolvePerLaneSandboxMs(config),
1405
+ timeoutMs,
1406
+ laneCount,
1407
+ artifactRoot,
1408
+ redactScreenshots,
1409
+ scrubKnownValues,
1410
+ runSession,
1411
+ hooks: liveHooks
1412
+ };
1413
+ const inProgressLaneSubjects = laneSpecs.map(() => laneSubjectProjection({
1414
+ cloneRoute,
1415
+ localTreeRoute,
1416
+ ...(publicRepo === undefined ? {} : { publicRepo }),
1417
+ subjectEnvNames,
1418
+ ...(localTreeArchive === undefined ? {} : { localTreeArchive }),
1419
+ subjectState: resolveSubjectState({
1420
+ declared: provisionedRoute ? config.subject.state : undefined,
1421
+ dryRun: false,
1422
+ executed: []
1423
+ })
1424
+ }));
1425
+ const inProgressAggregateSubject = inProgressLaneSubjects[0];
1426
+ const inProgressProvenance = subjectProvenanceArg(inProgressAggregateSubject, publicRepo, subjectEnvNames);
1427
+ if (!dryRun && options.onObserverReady) {
1428
+ const inProgressBundle = laneCount === 1 && rerunLineage === undefined
1429
+ ? buildSingleLaneBundle({
1430
+ spec: laneSpecs[0],
1431
+ outcome: undefined,
1432
+ descriptor,
1433
+ appUrl: laneSpecs[0].targetUrl ?? appUrl,
1434
+ createdAt,
1435
+ dryRun: false,
1436
+ config,
1437
+ runId,
1438
+ source,
1439
+ redactScreenshots,
1440
+ inProgress: true,
1441
+ ...(inProgressProvenance === undefined ? {} : { subjectProvenance: inProgressProvenance }),
1442
+ inProcessRoute,
1443
+ localAppSubject
1444
+ })
1445
+ : buildCuaFanoutBundle({
1446
+ specs: laneSpecs,
1447
+ laneSubjects: inProgressLaneSubjects,
1448
+ aggregateSubject: inProgressAggregateSubject,
1449
+ descriptor,
1450
+ appUrl,
1451
+ createdAt,
1452
+ dryRun: false,
1453
+ config,
1454
+ runId,
1455
+ source,
1456
+ plan,
1457
+ ...(rerunLineage === undefined ? {} : { rerun: rerunLineage }),
1458
+ cloneRoute,
1459
+ localTreeRoute,
1460
+ ...(publicRepo === undefined ? {} : { publicRepo }),
1461
+ subjectEnvNames,
1462
+ inProgress: true
1463
+ });
1464
+ await writeCuaRunArtifacts(cwd, artifactRoot, inProgressBundle, createdAt);
1465
+ liveObserver = observerResultForCuaArtifacts(cwd, runId, artifactRoot, [
1466
+ "Live CUA Observer is attached before final verification; stream auth URLs are runtime-only and are not persisted."
1467
+ ]);
1468
+ await options.onObserverReady(liveObserver);
1469
+ }
1470
+ // Run lanes (dry-run runs none). In-process is always one lane.
1471
+ let outcomes;
1472
+ let failFastReason;
1473
+ if (!dryRun) {
1474
+ if (inProcessRoute) {
1475
+ outcomes = [await runInProcessLane(laneSpecs[0], deps)];
1476
+ }
1477
+ else if (laneCount === 1) {
1478
+ outcomes = [await runCuaLane(laneSpecs[0], deps)];
1479
+ }
1480
+ else {
1481
+ const ran = await runCuaLanes(laneSpecs, deps, plan.concurrency);
1482
+ outcomes = ran.outcomes;
1483
+ failFastReason = ran.failFastReason;
1484
+ }
1485
+ }
1486
+ // Per-lane subject projections (invariant 5).
1487
+ const laneSubjects = laneSpecs.map((_spec, index) => {
1488
+ const outcome = outcomes?.[index];
1489
+ const subjectState = resolveSubjectState({
1490
+ declared: provisionedRoute ? config.subject.state : undefined,
1491
+ dryRun,
1492
+ executed: outcome?.stateStepRecords ?? []
1493
+ });
1494
+ return laneSubjectProjection({
1495
+ cloneRoute,
1496
+ localTreeRoute,
1497
+ ...(publicRepo === undefined ? {} : { publicRepo }),
1498
+ subjectEnvNames,
1499
+ ...(outcome?.subjectCommit === undefined ? {} : { subjectCommit: outcome.subjectCommit }),
1500
+ ...(localTreeArchive === undefined ? {} : { localTreeArchive }),
1501
+ subjectState
1502
+ });
1503
+ });
1504
+ // Aggregate subject (top-level + bundle): unanimity-gated commit (+ divergence warning) on
1505
+ // the clone route. Local-tree lanes all pack from the SAME once-per-run archive, so every
1506
+ // lane's projection already carries the identical archiveSha256/commit/dirty: the
1507
+ // `first.source !== "clone"` branch below returns it directly, with no unanimity math needed
1508
+ // (there is nothing that could diverge).
1509
+ const aggregateWarnings = [];
1510
+ const aggregateSubject = (() => {
1511
+ const first = laneSubjects[0];
1512
+ if (first.source !== "clone") {
1513
+ return first;
1514
+ }
1515
+ const commits = (outcomes ?? []).map((outcome) => outcome.subjectCommit).filter((commit) => commit !== undefined);
1516
+ const unanimous = !dryRun && commits.length === laneCount && new Set(commits).size === 1;
1517
+ if (!dryRun && laneCount > 1 && new Set(commits).size > 1) {
1518
+ aggregateWarnings.push("Fan-out lanes resolved DIVERGENT subject commits — the top-level subject.commit is omitted; see per-lane provenance in result.lanes for each lane's pinned commit.");
1519
+ }
1520
+ // Build without commit, then add it only when unanimous (avoids an explicit commit:undefined
1521
+ // under exactOptionalPropertyTypes).
1522
+ return {
1523
+ source: "clone",
1524
+ ...(first.repo === undefined ? {} : { repo: first.repo }),
1525
+ ...(first.envNames === undefined ? {} : { envNames: first.envNames }),
1526
+ state: first.state,
1527
+ ...(unanimous && commits[0] !== undefined ? { commit: commits[0] } : {})
1528
+ };
1529
+ })();
1530
+ const finalProvenance = subjectProvenanceArg(aggregateSubject, publicRepo, subjectEnvNames);
1531
+ const bundle = laneCount === 1 && rerunLineage === undefined
1532
+ ? buildSingleLaneBundle({
1533
+ spec: laneSpecs[0],
1534
+ outcome: outcomes?.[0],
1535
+ descriptor,
1536
+ appUrl: laneSpecs[0].targetUrl ?? appUrl,
1537
+ createdAt,
1538
+ dryRun,
1539
+ config,
1540
+ runId,
1541
+ source,
1542
+ redactScreenshots,
1543
+ ...(finalProvenance === undefined ? {} : { subjectProvenance: finalProvenance }),
1544
+ inProcessRoute,
1545
+ localAppSubject
1546
+ })
1547
+ : buildCuaFanoutBundle({
1548
+ specs: laneSpecs,
1549
+ ...(outcomes === undefined ? {} : { outcomes }),
1550
+ laneSubjects,
1551
+ aggregateSubject,
1552
+ descriptor,
1553
+ appUrl,
1554
+ createdAt,
1555
+ dryRun,
1556
+ config,
1557
+ runId,
1558
+ source,
1559
+ plan,
1560
+ ...(rerunLineage === undefined ? {} : { rerun: rerunLineage }),
1561
+ ...(failFastReason === undefined ? {} : { failFastReason }),
1562
+ cloneRoute,
1563
+ localTreeRoute,
1564
+ ...(publicRepo === undefined ? {} : { publicRepo }),
1565
+ subjectEnvNames
1566
+ });
1567
+ const adapterWarnings = [];
1568
+ await applyBrowserAdapterHooks({
1569
+ hooks,
1570
+ bundle,
1571
+ context: {
1572
+ bundle,
1573
+ runDir: artifactRoot,
1574
+ labId: config.id,
1575
+ runId,
1576
+ actor: descriptor.id,
1577
+ backend: "cua",
1578
+ dryRun,
1579
+ laneCount
1580
+ },
1581
+ sanitize: (text) => redactText(scrubKnownValues(text)),
1582
+ warnings: adapterWarnings,
1583
+ hookLabel: "cuaHooks"
1584
+ });
1585
+ await writeCuaRunArtifacts(cwd, artifactRoot, bundle, createdAt);
1586
+ const observer = await render(cwd, runId, { open: options.open === true });
1587
+ if (observer.ok && runtimeStreamUrls.length > 0) {
1588
+ attachObserverRuntimeStreamUrls(observer, runtimeStreamUrls);
1589
+ }
1590
+ // Lane-level pass: dry-run lanes are contract-ok; live lanes need a passed, engaged session.
1591
+ const laneOk = (outcome) => laneOutcomeOk(outcome, dryRun);
1592
+ const allLanesOk = laneSpecs.every((_, index) => laneOk(outcomes?.[index]));
1593
+ const adapterFailure = adapterScoreFailureMessage(bundle);
1594
+ const ok = observer.ok && allLanesOk && adapterFailure === undefined;
1595
+ const laneWarnings = (outcomes ?? []).flatMap((outcome) => outcome.warnings);
1596
+ const warnings = [...laneWarnings, ...aggregateWarnings, ...adapterWarnings, ...observer.warnings];
1597
+ const laneResults = laneSpecs.map((spec, index) => toLaneResult(spec, outcomes?.[index], laneSubjects[index], dryRun));
1598
+ const laneSummary = buildLaneSummary(outcomes, laneCount, plan, dryRun);
1599
+ const firstOutcome = outcomes?.[0];
1600
+ const errorResult = (() => {
1601
+ if (ok)
1602
+ return undefined;
1603
+ if (adapterFailure !== undefined) {
1604
+ return {
1605
+ code: "HUMANISH_CUA_LAB_FAILED",
1606
+ message: adapterFailure
1607
+ };
1608
+ }
1609
+ if (laneCount === 1) {
1610
+ const outcome = firstOutcome;
1611
+ return {
1612
+ code: outcome?.failureCode ?? "HUMANISH_CUA_LAB_FAILED",
1613
+ message: outcome?.sessionError
1614
+ ?? (outcome?.noEngagement
1615
+ ? "Actor took no actions and produced no message (likely a blank/still-loading screen); not a credible goal_satisfied."
1616
+ : observer.ok
1617
+ ? outcome?.session?.completionReason === "harness_error"
1618
+ ? `Computer-use session ended with a harness error: ${outcome.session.reason}`
1619
+ : outcome?.session?.status !== "passed"
1620
+ ? `Computer-use session ended with ${outcome?.session?.status ?? "unknown"}: ${outcome?.session?.reason ?? "no terminal reason"}`
1621
+ : "Computer-use lab did not produce a terminal session."
1622
+ : observer.error?.message ?? "Observer failed for the computer-use lab run.")
1623
+ };
1624
+ }
1625
+ const failingLane = (outcomes ?? []).find((outcome) => !laneOk(outcome));
1626
+ const geometryLane = (outcomes ?? []).find((outcome) => outcome.failureCode === "HUMANISH_CUA_LAB_DEVICE_GEOMETRY");
1627
+ const code = geometryLane?.failureCode ?? "HUMANISH_CUA_LAB_FAILED";
1628
+ return {
1629
+ code,
1630
+ message: observer.ok
1631
+ ? `Fan-out run failed: ${laneSummary.passed}/${laneCount} lane(s) passed (${laneSummary.skipped} skipped, ${laneSummary.harnessErrors} harness error(s), ${laneSummary.hollow} hollow)${failingLane?.sessionError ? `; first failure: ${failingLane.sessionError}` : ""}.`
1632
+ : observer.error?.message ?? "Observer failed for the computer-use fan-out run."
1633
+ };
1634
+ })();
1635
+ return {
1636
+ schema: CUA_ACTOR_LAB_SCHEMA,
1637
+ ok,
1638
+ cwd,
1639
+ labId: config.id,
1640
+ actor: descriptor.id,
1641
+ appUrl,
1642
+ dryRun,
1643
+ runId,
1644
+ ...(firstOutcome?.session
1645
+ ? {
1646
+ session: {
1647
+ status: firstOutcome.session.status,
1648
+ completionReason: firstOutcome.session.completionReason,
1649
+ reason: firstOutcome.session.reason,
1650
+ screenshots: firstOutcome.screenshots.length
1651
+ }
1652
+ }
1653
+ : {}),
1654
+ ...(firstOutcome?.sandboxId
1655
+ ? { sandbox: { sandboxId: firstOutcome.sandboxId, killed: firstOutcome.killed, streamUrlPresent: firstOutcome.streamUrlPresent } }
1656
+ : {}),
1657
+ subject: aggregateSubject,
1658
+ plan,
1659
+ lanes: laneResults,
1660
+ laneSummary,
1661
+ ...(rerunLineage === undefined ? {} : { rerun: rerunLineage }),
1662
+ observer,
1663
+ warnings,
1664
+ ...(errorResult === undefined ? {} : { error: errorResult })
1665
+ };
1666
+ }
1667
+ /** Aggregate lane counts for the result projection. */
1668
+ function buildLaneSummary(outcomes, laneCount, plan, dryRun) {
1669
+ if (dryRun || !outcomes) {
1670
+ return {
1671
+ strategy: CUA_FANOUT_STRATEGY,
1672
+ total: laneCount,
1673
+ passed: 0,
1674
+ skipped: 0,
1675
+ harnessErrors: 0,
1676
+ hollow: 0,
1677
+ concurrency: plan.concurrency,
1678
+ waves: plan.waves
1679
+ };
1680
+ }
1681
+ let passed = 0;
1682
+ let skipped = 0;
1683
+ let harnessErrors = 0;
1684
+ let hollow = 0;
1685
+ for (const outcome of outcomes) {
1686
+ if (outcome.skippedReason !== undefined) {
1687
+ skipped += 1;
1688
+ continue;
1689
+ }
1690
+ if (outcome.harnessError)
1691
+ harnessErrors += 1;
1692
+ if (outcome.noEngagement)
1693
+ hollow += 1;
1694
+ if (laneOutcomeOk(outcome, dryRun))
1695
+ passed += 1;
1696
+ }
1697
+ return {
1698
+ strategy: CUA_FANOUT_STRATEGY,
1699
+ total: laneCount,
1700
+ passed,
1701
+ skipped,
1702
+ harnessErrors,
1703
+ hollow,
1704
+ concurrency: plan.concurrency,
1705
+ waves: plan.waves
1706
+ };
1707
+ }
1708
+ async function writeCuaRunArtifacts(cwd, artifactRoot, bundle, updatedAt) {
1709
+ const publicBundle = {
1710
+ ...bundle,
1711
+ cwd: PUBLIC_TARGET_CWD
1712
+ };
1713
+ await writeFile(path.join(artifactRoot, "run.json"), `${JSON.stringify(publicBundle, null, 2)}\n`, "utf8");
1714
+ await writeFile(path.join(artifactRoot, "review.json"), `${JSON.stringify(publicBundle.review, null, 2)}\n`, "utf8");
1715
+ await writeFile(path.join(artifactRoot, "review.md"), renderCuaReviewMarkdown(publicBundle), "utf8");
1716
+ await writeFile(path.join(artifactRoot, "events.ndjson"), `${publicBundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8");
1717
+ await mkdir(path.join(artifactRoot, "observer"), { recursive: true });
1718
+ await writeFile(path.join(artifactRoot, "observer", "observer-data.json"), `${JSON.stringify(buildObserverData(publicBundle), null, 2)}\n`, "utf8");
1719
+ await writeFile(path.join(cwd, ".humanish", "runs", "latest.json"), `${JSON.stringify({
1720
+ schema: "humanish.latest-run.v1",
1721
+ runId: publicBundle.runId,
1722
+ path: path.join(".humanish", "runs", publicBundle.runId),
1723
+ updatedAt
1724
+ }, null, 2)}\n`, "utf8");
1725
+ }
1726
+ function observerResultForCuaArtifacts(cwd, runId, artifactRoot, warnings = []) {
1727
+ const observerPath = path.join(artifactRoot, "observer", "index.html");
1728
+ const observerDataPath = path.join(artifactRoot, "observer", "observer-data.json");
1729
+ const eventsPath = path.join(artifactRoot, "events.ndjson");
1730
+ return {
1731
+ schema: "humanish.observer-result.v1",
1732
+ ok: true,
1733
+ cwd,
1734
+ run: runId,
1735
+ observerPath: path.relative(cwd, observerPath),
1736
+ observerDataPath: path.relative(cwd, observerDataPath),
1737
+ eventsPath: path.relative(cwd, eventsPath),
1738
+ observerUrl: pathToFileURL(observerPath).href,
1739
+ bundlePath: path.join(artifactRoot, "run.json"),
1740
+ opened: false,
1741
+ warnings
1742
+ };
1743
+ }
1744
+ /** Build the N=1 bundle via the unchanged buildCuaBundle (byte-stable). */
1745
+ function buildSingleLaneBundle(args) {
1746
+ const { spec, outcome, config } = args;
1747
+ return buildCuaBundle({
1748
+ actorId: args.descriptor.id,
1749
+ appUrl: args.appUrl,
1750
+ laneId: spec.laneId,
1751
+ ...(spec.actorType === undefined ? {} : { actorType: spec.actorType }),
1752
+ ...(spec.surface === undefined ? {} : { surface: spec.surface }),
1753
+ ...(spec.caseGroup === undefined ? {} : { caseGroup: spec.caseGroup }),
1754
+ createdAt: args.createdAt,
1755
+ dryRun: args.dryRun,
1756
+ labId: config.id,
1757
+ ...(config.title ? { labTitle: config.title } : {}),
1758
+ mission: spec.instructions,
1759
+ persona: spec.persona,
1760
+ resolution: spec.resolution,
1761
+ deviceScaleFactor: spec.devicePreset.deviceScaleFactor,
1762
+ isMobile: spec.devicePreset.isMobile,
1763
+ runId: args.runId,
1764
+ screenshots: outcome?.screenshots ?? [],
1765
+ captureRedaction: args.redactScreenshots ? "blurred" : "raw",
1766
+ ...(outcome?.session ? { session: outcome.session } : {}),
1767
+ ...(outcome?.sessionError ? { sessionError: outcome.sessionError } : {}),
1768
+ source: args.source,
1769
+ ...(args.inProgress === undefined ? {} : { inProgress: args.inProgress }),
1770
+ ...(args.subjectProvenance === undefined ? {} : { subjectProvenance: args.subjectProvenance }),
1771
+ ...(config.execution?.desktop?.template === undefined ? {} : { desktopTemplate: config.execution.desktop.template }),
1772
+ ...(outcome?.desktopBrowser === undefined ? {} : { desktopBrowser: outcome.desktopBrowser }),
1773
+ providerResources: providerResourcesForOutcome({
1774
+ outcome,
1775
+ createdAt: args.createdAt,
1776
+ simId: spec.simId,
1777
+ streamId: spec.streamId,
1778
+ laneId: spec.laneId
1779
+ }),
1780
+ ...(args.localAppSubject || args.inProcessRoute ? { entryKind: "local-app" } : {}),
1781
+ ...(outcome?.session ? { traceArtifactPath: spec.traceArtifactPath } : {}),
1782
+ phaseEvents: outcome?.phaseRecords ?? []
1783
+ });
1784
+ }
1785
+ /**
1786
+ * Shared post-populate provisioning pipeline (clone AND local-tree routes): (install) ->
1787
+ * state(before-build) -> (build) -> state(before-start) -> detached start -> readiness probe ->
1788
+ * state(after-ready). Both provisioning routes populate SUBJECT_DIR by different means (git
1789
+ * clone vs. upload+extract) and then run this identical pipeline unchanged.
1790
+ *
1791
+ * State steps run through the same detached primitive as serve steps (author-trusted, the
1792
+ * "serve commands are author-trusted" corollary) under the reserved `subject-state-<name>`
1793
+ * label prefix, so a step name can never collide with subject-clone/subject-extract/install/
1794
+ * build/start. after-ready steps complete BEFORE the caller opens the browser: the actor never
1795
+ * drives a half-seeded subject and seeding never eats the session budget.
1796
+ */
1797
+ async function runSubjectServePipeline(desktop, args) {
1798
+ const timers = {
1799
+ ...(args.now === undefined ? {} : { now: args.now }),
1800
+ ...(args.sleep === undefined ? {} : { sleep: args.sleep })
1801
+ };
1802
+ const now = args.now ?? Date.now;
1803
+ const refresh = args.onPhaseComplete ?? (() => Promise.resolve());
1804
+ const stateSteps = args.state?.seed ?? [];
1805
+ const runStateSteps = async (when) => {
1806
+ const steps = stateSteps.filter((step) => (step.when ?? "before-start") === when);
1807
+ if (steps.length === 0) {
1808
+ // No declared steps for this group: no boundary to report (avoids empty-group noise on
1809
+ // every run, since before-build/before-start/after-ready are always called).
1810
+ return;
1811
+ }
1812
+ const groupStartedAt = now();
1813
+ emitPhaseStarted(args.onPhase, now, `state.${when}`, `running subject state seed steps (${when})`);
1814
+ for (const step of steps) {
1815
+ const stepTimeoutMs = step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS;
1816
+ const startedAt = now();
1817
+ const result = await runDetachedStep(desktop, {
1818
+ name: `subject-state-${step.name}`,
1819
+ command: step.command,
1820
+ cwd: SUBJECT_DIR,
1821
+ timeoutMs: stepTimeoutMs,
1822
+ requestTimeoutMs: args.requestTimeoutMs,
1823
+ ...timers
1824
+ });
1825
+ args.onStateStep?.({
1826
+ name: step.name,
1827
+ when,
1828
+ // Digest only (sha256-16): the command text never persists: the lab YAML in the
1829
+ // consumer's repo is the plaintext source of truth.
1830
+ commandDigest: commandDigestOf(step.command),
1831
+ ok: result.ok,
1832
+ ...(result.exitCode === undefined ? {} : { exitCode: result.exitCode }),
1833
+ ...(result.timedOut ? { timedOut: true } : {}),
1834
+ durationMs: Math.max(0, now() - startedAt)
1835
+ });
1836
+ if (!result.ok) {
1837
+ emitPhaseCompleted(args.onPhase, now, groupStartedAt, `state.${when}`, false, `subject state seed steps failed (${when})`);
1838
+ // Fail closed with the existing scrub-before-truncate tail chain: literal scrub of
1839
+ // every provisioned value PRE-truncation, then pattern redaction + cap in tailOf.
1840
+ throw new Error(`subject state step "${step.name}" ${result.timedOut ? `timed out after ${stepTimeoutMs}ms` : `failed (exit ${result.exitCode})`}: ${tailOf(args.scrub(result.logTail))}`);
1841
+ }
1842
+ }
1843
+ emitPhaseCompleted(args.onPhase, now, groupStartedAt, `state.${when}`, true, `subject state seed steps complete (${when})`);
1844
+ };
1845
+ if (args.serve.install) {
1846
+ const installStartedAt = now();
1847
+ emitPhaseStarted(args.onPhase, now, "install", "installing subject dependencies");
1848
+ const install = await runDetachedStep(desktop, {
1849
+ name: "subject-install",
1850
+ command: args.serve.install,
1851
+ cwd: SUBJECT_DIR,
1852
+ timeoutMs: args.serve.installTimeoutMs ?? INSTALL_TIMEOUT_MS,
1853
+ requestTimeoutMs: args.requestTimeoutMs,
1854
+ ...timers
1855
+ });
1856
+ emitPhaseCompleted(args.onPhase, now, installStartedAt, "install", install.ok, install.ok ? "subject dependencies installed" : "subject install failed");
1857
+ if (!install.ok) {
1858
+ throw new Error(`subject install ${install.timedOut ? "timed out" : `failed (exit ${install.exitCode})`}: ${tailOf(args.scrub(install.logTail))}`);
1859
+ }
1860
+ await refresh();
1861
+ }
1862
+ // before-build: after install, before build (builds that read seeded state, e.g. SSG).
1863
+ // When no build is declared this simply precedes start: equivalent to before-start.
1864
+ await runStateSteps("before-build");
1865
+ await refresh();
1866
+ if (args.serve.build) {
1867
+ const buildStartedAt = now();
1868
+ emitPhaseStarted(args.onPhase, now, "build", "building subject");
1869
+ const build = await runDetachedStep(desktop, {
1870
+ name: "subject-build",
1871
+ command: args.serve.build,
1872
+ cwd: SUBJECT_DIR,
1873
+ timeoutMs: args.serve.buildTimeoutMs ?? BUILD_TIMEOUT_MS,
1874
+ requestTimeoutMs: args.requestTimeoutMs,
1875
+ ...timers
1876
+ });
1877
+ emitPhaseCompleted(args.onPhase, now, buildStartedAt, "build", build.ok, build.ok ? "subject build complete" : "subject build failed");
1878
+ if (!build.ok) {
1879
+ throw new Error(`subject build ${build.timedOut ? "timed out" : `failed (exit ${build.exitCode})`}: ${tailOf(args.scrub(build.logTail))}`);
1880
+ }
1881
+ await refresh();
1882
+ }
1883
+ // before-start (the default phase): migrations, SQL/file fixtures, an in-sandbox DB server
1884
+ // (`sudo service postgresql start && pg_isready` is a bounded step; the daemon it forks is
1885
+ // reclaimed by the sandbox lifecycle like everything else).
1886
+ await runStateSteps("before-start");
1887
+ await refresh();
1888
+ await startDetachedProcess(desktop, {
1889
+ name: "subject-start",
1890
+ command: args.serve.start,
1891
+ cwd: SUBJECT_DIR,
1892
+ requestTimeoutMs: args.requestTimeoutMs
1893
+ });
1894
+ // Fire-and-forget: startDetachedProcess never waits for the long-lived server to exit, so
1895
+ // there is no matching completed event here (no ok/durationMs to report yet); readiness is
1896
+ // the next boundary.
1897
+ args.onPhase?.({ at: isoNow(now), type: "cua-lab.subject.serve.started", message: "subject server launched (detached)" });
1898
+ const readyStartedAt = now();
1899
+ emitPhaseStarted(args.onPhase, now, "ready", "waiting for subject to become ready");
1900
+ const ready = await probeUrl(desktop, args.serve.url, {
1901
+ timeoutMs: args.serve.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
1902
+ requestTimeoutMs: args.requestTimeoutMs,
1903
+ ...timers
1904
+ });
1905
+ emitPhaseCompleted(args.onPhase, now, readyStartedAt, "ready", ready, ready ? "subject is ready" : "subject did not become ready in time");
1906
+ if (!ready) {
1907
+ const startLog = await readDetachedLog(desktop, "subject-start", args.requestTimeoutMs).catch(() => "");
1908
+ throw new Error(`subject did not answer at ${args.serve.url} within ${args.serve.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms; server log tail: ${tailOf(args.scrub(startLog))}`);
1909
+ }
1910
+ // after-ready: fixture loading through the RUNNING app (loopback curl from in-sandbox:
1911
+ // steps are author-trusted provisioning, not actors, so no new URL policy surface). These
1912
+ // complete before the caller opens the browser and the session timer starts.
1913
+ await runStateSteps("after-ready");
1914
+ await refresh();
1915
+ }
1916
+ /**
1917
+ * Provision a clone subject inside the sandbox: clone → the shared serve pipeline
1918
+ * (install → state(before-build) → build → state(before-start) → start → readiness
1919
+ * probe → state(after-ready)). Returns the latest subject HEAD after successful
1920
+ * provisioning. Throws (with a capped log tail for the caller to redact) on any failing step:
1921
+ * the lab persists that as a failed-evidence bundle.
1922
+ *
1923
+ * Auth: when GITHUB_TOKEN is among the declared subject env names, the clone authenticates
1924
+ * via an Authorization header computed IN-SANDBOX from the provisioned env: the token never
1925
+ * appears in the script text, the process argv beyond the transient git call, the clone URL,
1926
+ * or .git/config.
1927
+ */
1928
+ export async function provisionCloneSubject(desktop, args) {
1929
+ const timers = {
1930
+ ...(args.now === undefined ? {} : { now: args.now }),
1931
+ ...(args.sleep === undefined ? {} : { sleep: args.sleep })
1932
+ };
1933
+ const now = args.now ?? Date.now;
1934
+ let latestCommit;
1935
+ const refreshCommit = async () => {
1936
+ const head = await desktop.commands.run(`git -C ${SUBJECT_DIR} rev-parse HEAD 2>/dev/null || true`, { requestTimeoutMs: args.requestTimeoutMs });
1937
+ const commit = (head.stdout ?? "").trim() || undefined;
1938
+ if (commit) {
1939
+ latestCommit = commit;
1940
+ args.onCommit?.(commit);
1941
+ }
1942
+ };
1943
+ const cloneCommand = args.hasGithubToken
1944
+ ? `auth=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0) && git -c http.extraHeader="Authorization: Basic $auth" clone --depth ${args.depth} https://github.com/${args.repo}.git ${SUBJECT_DIR}`
1945
+ : `git clone --depth ${args.depth} https://github.com/${args.repo}.git ${SUBJECT_DIR}`;
1946
+ const cloneStartedAt = now();
1947
+ emitPhaseStarted(args.onPhase, now, "clone", "cloning subject repository");
1948
+ const clone = await runDetachedStep(desktop, {
1949
+ name: "subject-clone",
1950
+ command: cloneCommand,
1951
+ timeoutMs: CLONE_TIMEOUT_MS,
1952
+ requestTimeoutMs: args.requestTimeoutMs,
1953
+ ...timers
1954
+ });
1955
+ emitPhaseCompleted(args.onPhase, now, cloneStartedAt, "clone", clone.ok, clone.ok ? "subject repository cloned" : "subject clone failed");
1956
+ if (!clone.ok) {
1957
+ throw new Error(`subject clone ${clone.timedOut ? "timed out" : `failed (exit ${clone.exitCode})`}: ${tailOf(args.scrub(clone.logTail))}`);
1958
+ }
1959
+ await refreshCommit();
1960
+ await runSubjectServePipeline(desktop, {
1961
+ serve: args.serve,
1962
+ ...(args.state === undefined ? {} : { state: args.state }),
1963
+ requestTimeoutMs: args.requestTimeoutMs,
1964
+ scrub: args.scrub,
1965
+ ...(args.onStateStep === undefined ? {} : { onStateStep: args.onStateStep }),
1966
+ ...(args.onPhase === undefined ? {} : { onPhase: args.onPhase }),
1967
+ onPhaseComplete: refreshCommit,
1968
+ ...timers
1969
+ });
1970
+ return latestCommit;
1971
+ }
1972
+ /**
1973
+ * Provision a local-tree subject inside the sandbox: upload the once-per-run packed archive
1974
+ * (identical bytes across every fan-out lane) → extract it into SUBJECT_DIR → the
1975
+ * same shared serve pipeline provisionCloneSubject uses. Unlike the clone route there is no
1976
+ * in-sandbox git refresh: the archive excludes .git entirely (see source-archive.ts), so
1977
+ * subject identity is the host-side LocalTreeArchive captured at pack time, never anything
1978
+ * resolved in-sandbox.
1979
+ */
1980
+ export async function provisionLocalTreeSubject(desktop, args) {
1981
+ const timers = {
1982
+ ...(args.now === undefined ? {} : { now: args.now }),
1983
+ ...(args.sleep === undefined ? {} : { sleep: args.sleep })
1984
+ };
1985
+ const now = args.now ?? Date.now;
1986
+ const uploadStartedAt = now();
1987
+ emitPhaseStarted(args.onPhase, now, "upload", "uploading packed local-tree archive");
1988
+ try {
1989
+ await desktop.files.write(LOCAL_TREE_REMOTE_ARCHIVE_PATH, args.archiveBuffer, {
1990
+ requestTimeoutMs: args.requestTimeoutMs,
1991
+ useOctetStream: true
1992
+ });
1993
+ }
1994
+ catch (error) {
1995
+ emitPhaseCompleted(args.onPhase, now, uploadStartedAt, "upload", false, "local-tree archive upload failed");
1996
+ throw new Error(`subject-upload failed: ${tailOf(args.scrub(toErrorMessage(error)))}`);
1997
+ }
1998
+ emitPhaseCompleted(args.onPhase, now, uploadStartedAt, "upload", true, "local-tree archive uploaded");
1999
+ const extractCommand = `rm -rf ${SUBJECT_DIR} && mkdir -p ${SUBJECT_DIR} && tar -xzf ${LOCAL_TREE_REMOTE_ARCHIVE_PATH} -C ${SUBJECT_DIR} && rm -f ${LOCAL_TREE_REMOTE_ARCHIVE_PATH}`;
2000
+ const extractStartedAt = now();
2001
+ emitPhaseStarted(args.onPhase, now, "extract", "extracting local-tree archive");
2002
+ const extract = await runDetachedStep(desktop, {
2003
+ name: "subject-extract",
2004
+ command: extractCommand,
2005
+ timeoutMs: CLONE_TIMEOUT_MS,
2006
+ requestTimeoutMs: args.requestTimeoutMs,
2007
+ ...timers
2008
+ });
2009
+ emitPhaseCompleted(args.onPhase, now, extractStartedAt, "extract", extract.ok, extract.ok ? "local-tree archive extracted" : "local-tree archive extraction failed");
2010
+ if (!extract.ok) {
2011
+ throw new Error(`subject extract ${extract.timedOut ? "timed out" : `failed (exit ${extract.exitCode})`}: ${tailOf(args.scrub(extract.logTail))}`);
2012
+ }
2013
+ await runSubjectServePipeline(desktop, {
2014
+ serve: args.serve,
2015
+ ...(args.state === undefined ? {} : { state: args.state }),
2016
+ requestTimeoutMs: args.requestTimeoutMs,
2017
+ scrub: args.scrub,
2018
+ ...(args.onStateStep === undefined ? {} : { onStateStep: args.onStateStep }),
2019
+ ...(args.onPhase === undefined ? {} : { onPhase: args.onPhase }),
2020
+ ...timers
2021
+ });
2022
+ }
2023
+ /**
2024
+ * Default local-tree packing implementation: createLocalTreeArchive(root, opts) on the host,
2025
+ * then a single read of the produced archive file into an ArrayBuffer for upload. The DI seam
2026
+ * (CuaActorLabHooks.packLocalTree) overrides this in deterministic tests so they never require
2027
+ * tar/git.
2028
+ */
2029
+ export async function defaultPackLocalTree(args) {
2030
+ const archive = createLocalTreeArchive(args.root, {
2031
+ ...(args.extraExclude === undefined ? {} : { extraExclude: args.extraExclude }),
2032
+ ...(args.maxArchiveBytes === undefined ? {} : { maxArchiveBytes: args.maxArchiveBytes })
2033
+ });
2034
+ const bytes = await readFile(archive.archivePath);
2035
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
2036
+ // The archive was written to a fresh mkdtemp dir (no outputPath passed above); once the
2037
+ // bytes are buffered the on-disk copy is pure residue, and a packed working tree left in
2038
+ // the host tmpdir is itself a small leak surface. Best-effort removal.
2039
+ await rm(path.dirname(archive.archivePath), { recursive: true, force: true }).catch(() => undefined);
2040
+ return { archive, buffer };
2041
+ }
2042
+ /** sha256 hex of the exact command string, first 16 chars (the promptDigest convention). */
2043
+ export function commandDigestOf(command) {
2044
+ return digestText(command, 16);
2045
+ }
2046
+ /**
2047
+ * Resolve the bundle's state marker from the declaration and what actually ran.
2048
+ * Precedence: external declared → "unpinned" (seed records, if any, stay attached — a
2049
+ * migrated external DB is still unpinned overall); else seed declared → "seeded" only when
2050
+ * every declared step executed ok on a live run, otherwise "declared-not-run" (dry-run
2051
+ * contract bundles and failed live provisioning); no declaration → "undeclared".
2052
+ */
2053
+ export function resolveSubjectState(args) {
2054
+ const declared = args.declared;
2055
+ if (!declared) {
2056
+ return { provenance: "undeclared" };
2057
+ }
2058
+ const declaredSeed = declared.seed ?? [];
2059
+ const external = declared.external ?? [];
2060
+ // Dry-run: nothing executes (no sandbox) — record the DECLARED recipe: name, phase, and
2061
+ // command digest only, with NO execution fields.
2062
+ const seed = args.dryRun
2063
+ ? declaredSeed.map((step) => ({
2064
+ name: step.name,
2065
+ when: step.when ?? "before-start",
2066
+ commandDigest: commandDigestOf(step.command)
2067
+ }))
2068
+ : args.executed;
2069
+ const allRanOk = !args.dryRun
2070
+ && declaredSeed.length > 0
2071
+ && seed.length === declaredSeed.length
2072
+ && seed.every((record) => record.ok === true);
2073
+ const provenance = external.length > 0
2074
+ ? "unpinned"
2075
+ : declaredSeed.length === 0
2076
+ ? "undeclared"
2077
+ : allRanOk
2078
+ ? "seeded"
2079
+ : "declared-not-run";
2080
+ return {
2081
+ provenance,
2082
+ ...(seed.length > 0 ? { seed } : {}),
2083
+ ...(external.length > 0 ? { externalEnvNames: external } : {})
2084
+ };
2085
+ }
2086
+ /** The human-readable state story appended to the provenance event (and review.md via it). */
2087
+ function describeSubjectState(state, dryRun) {
2088
+ switch (state.provenance) {
2089
+ case "seeded":
2090
+ return `seeded (${state.seed?.length ?? 0} step(s): ${(state.seed ?? []).map((record) => record.name).join(", ")})`;
2091
+ case "unpinned":
2092
+ return `UNPINNED (external: ${(state.externalEnvNames ?? []).join(", ")})`;
2093
+ case "declared-not-run":
2094
+ return `declared, not run (${dryRun ? "dry-run contract" : "provisioning did not complete"})`;
2095
+ case "undeclared":
2096
+ return "undeclared";
2097
+ }
2098
+ }
2099
+ // The in-sandbox `tail -c` upstream is a fundamental log-tail limit we cannot redact past.
2100
+ function tailOf(log) {
2101
+ return redactedTail(log, ERROR_TAIL_CHARS);
2102
+ }
2103
+ /**
2104
+ * Project a computer-use session into a humanish.run-bundle.v1. The load-bearing line is
2105
+ * `stream.actor = session.trace` — the provider-neutral ActorTrace seam the Observer renders.
2106
+ * Exported for the bundle-builder tests.
2107
+ */
2108
+ export function buildCuaBundle(args) {
2109
+ const publicAppUrl = publicSafeAppUrlLabel(args.appUrl);
2110
+ const status = args.inProgress === true
2111
+ ? "running"
2112
+ : args.session
2113
+ ? args.session.status
2114
+ : args.sessionError
2115
+ ? "failed"
2116
+ : "contract_proof_only";
2117
+ const reason = args.inProgress === true
2118
+ ? "Live computer-use session is running; stream auth URL is available only through the attached Observer server."
2119
+ : args.session?.reason
2120
+ ?? args.sessionError
2121
+ ?? "Contract bundle only: dry-run produced the evidence shape without launching a desktop or spending provider tokens.";
2122
+ const lastScreenshot = args.screenshots[args.screenshots.length - 1];
2123
+ // Honest labels (invariant 6: claims match mechanism): every screenshot label names the
2124
+ // run's ACTUAL mode. The session trace is the evidence-of-record; the capture policy covers
2125
+ // frames written before a mid-session failure produced a trace.
2126
+ const traceScreenshotMode = args.session?.trace.redaction.screenshots;
2127
+ const screenshotMode = traceScreenshotMode === "raw" || traceScreenshotMode === "blurred"
2128
+ ? traceScreenshotMode
2129
+ : args.captureRedaction ?? "raw";
2130
+ const simulation = {
2131
+ id: "sim-001",
2132
+ index: 1,
2133
+ personaId: args.persona.id,
2134
+ scenarioId: `cua-${args.labId}`,
2135
+ status,
2136
+ streamKind: "browser",
2137
+ mode: "browser-sim",
2138
+ progress: args.inProgress === true ? 20 : 100,
2139
+ currentStep: reason,
2140
+ summary: args.session
2141
+ ? `Computer-use actor (${args.actorId}) drove the subject app in a hosted desktop browser; ${args.session.completionReason}.`
2142
+ : args.inProgress === true
2143
+ ? `Computer-use actor (${args.actorId}) is driving the subject app in a hosted desktop browser.`
2144
+ : args.sessionError
2145
+ ? `Computer-use lab failed before a terminal session verdict: ${args.sessionError}`
2146
+ : `Contract lane for the computer-use actor (${args.actorId}) against ${publicAppUrl}.`,
2147
+ streamIds: ["stream-001"],
2148
+ startedAt: args.createdAt,
2149
+ updatedAt: args.createdAt
2150
+ };
2151
+ const stream = {
2152
+ id: "stream-001",
2153
+ simId: "sim-001",
2154
+ laneId: args.laneId ?? "lane-01",
2155
+ ...(args.actorType === undefined ? {} : { actorType: args.actorType }),
2156
+ ...(args.surface === undefined ? {} : { surface: args.surface }),
2157
+ ...(args.caseGroup === undefined ? {} : { caseGroup: args.caseGroup }),
2158
+ kind: "browser",
2159
+ label: `CUA browser — ${args.labId}`,
2160
+ status,
2161
+ transport: "snapshot",
2162
+ updatedAt: args.createdAt,
2163
+ embed: lastScreenshot
2164
+ ? { kind: "screenshot", url: lastScreenshot, title: `CUA desktop (${screenshotMode})` }
2165
+ : { kind: "placeholder", title: "CUA desktop" },
2166
+ viewport: {
2167
+ width: args.resolution[0],
2168
+ height: args.resolution[1],
2169
+ deviceScaleFactor: args.deviceScaleFactor ?? 1,
2170
+ ...(args.isMobile === undefined ? {} : { isMobile: args.isMobile })
2171
+ },
2172
+ ui: {
2173
+ route: publicAppUrl,
2174
+ intent: "Watch the computer-use actor drive the subject app in a hosted desktop browser.",
2175
+ state: reason,
2176
+ ...(args.session ? { actorStatus: args.session.status } : {}),
2177
+ ...(lastScreenshot ? { screenshotUrl: lastScreenshot } : {})
2178
+ },
2179
+ // The seam this lab exists to fill: the provider-neutral actor evidence projection.
2180
+ ...(args.session ? { actor: args.session.trace } : {}),
2181
+ artifacts: [
2182
+ { label: "run bundle", path: "run.json", kind: "bundle" },
2183
+ { label: "review", path: "review.md", kind: "review" },
2184
+ { label: "events", path: "events.ndjson", kind: "events" },
2185
+ ...(args.traceArtifactPath
2186
+ ? [{ label: "actor trace", path: args.traceArtifactPath, kind: "trace" }]
2187
+ : []),
2188
+ ...args.screenshots.map((screenshot, index) => ({
2189
+ label: `screenshot ${String(index + 1).padStart(2, "0")} (${screenshotMode})`,
2190
+ path: screenshot,
2191
+ kind: "screenshot"
2192
+ }))
2193
+ ]
2194
+ };
2195
+ const events = [
2196
+ {
2197
+ id: "event-000-created",
2198
+ at: args.createdAt,
2199
+ level: "info",
2200
+ type: "cua-lab.run.created",
2201
+ message: `Created computer-use lab run for ${args.labId} (actor ${args.actorId}).`
2202
+ },
2203
+ args.subjectProvenance
2204
+ ? {
2205
+ id: "event-001-subject",
2206
+ at: args.createdAt,
2207
+ level: "info",
2208
+ type: "cua-lab.subject.provenance",
2209
+ // HONEST WORDING: claim "cloned/packed and served" only when it actually happened.
2210
+ message: `${subjectProvenanceMessage(args.subjectProvenance, publicAppUrl, args.dryRun, args.session !== undefined)} (subject env names: ${args.subjectProvenance.envNames.length > 0 ? args.subjectProvenance.envNames.join(", ") : "none"}; values never persisted); state: ${describeSubjectState(args.subjectProvenance.state, args.dryRun)}.`,
2211
+ simId: "sim-001",
2212
+ streamId: "stream-001"
2213
+ }
2214
+ : {
2215
+ id: "event-001-subject",
2216
+ at: args.createdAt,
2217
+ level: "info",
2218
+ type: "cua-lab.subject.declared",
2219
+ // Invariant 5: declare what the subject WAS, including the ABSENCE of a pin. A
2220
+ // local-app / in-process subject is an already-running LOCAL dev server the caller
2221
+ // provisioned; it cannot be commit-pinned, so its provenance is honestly UNPINNED and
2222
+ // no E2B desktop was created. A plain app-url entry runs inside the desktop sandbox.
2223
+ message: args.entryKind === "local-app"
2224
+ ? `Subject app declared at ${publicAppUrl} (already-running LOCAL dev server driven in-process; NO clone, NO E2B desktop). Provenance: caller-provisioned and UNPINNED — a running dev server cannot be commit-pinned.`
2225
+ : `Subject app declared at ${publicAppUrl} (loopback inside the desktop sandbox).`,
2226
+ simId: "sim-001",
2227
+ streamId: "stream-001"
2228
+ },
2229
+ args.session
2230
+ ? {
2231
+ id: "event-002-session",
2232
+ at: args.createdAt,
2233
+ level: args.session.status === "passed" ? "info" : "warn",
2234
+ type: `cua-lab.session.${args.session.completionReason}`,
2235
+ message: `${args.session.status}: ${args.session.reason}`,
2236
+ simId: "sim-001",
2237
+ streamId: "stream-001"
2238
+ }
2239
+ : args.inProgress === true
2240
+ ? {
2241
+ id: "event-002-running",
2242
+ at: args.createdAt,
2243
+ level: "info",
2244
+ type: "cua-lab.session.running",
2245
+ message: "Live computer-use session is running; terminal evidence has not been written yet.",
2246
+ simId: "sim-001",
2247
+ streamId: "stream-001"
2248
+ }
2249
+ : args.sessionError
2250
+ ? {
2251
+ id: "event-002-session",
2252
+ at: args.createdAt,
2253
+ level: "error",
2254
+ type: "cua-lab.session.error",
2255
+ message: args.sessionError,
2256
+ simId: "sim-001",
2257
+ streamId: "stream-001"
2258
+ }
2259
+ : {
2260
+ id: "event-002-contract",
2261
+ at: args.createdAt,
2262
+ level: "info",
2263
+ type: "cua-lab.contract.ready",
2264
+ message: "Dry-run contract bundle ready; switch scenario.mode to live for a real desktop session.",
2265
+ simId: "sim-001",
2266
+ streamId: "stream-001"
2267
+ }
2268
+ ];
2269
+ // Persisted phase trail (real boot timing, not just a coarse provenance sentence): one
2270
+ // RunEvent per COMPLETED phase boundary (started events never persist here; they carry no
2271
+ // durationMs). ok:false phases warn rather than error, since the failing phase's own thrown
2272
+ // error already becomes the terminal cua-lab.session.error event above.
2273
+ let phaseEventSeq = 3;
2274
+ for (const phase of args.phaseEvents ?? []) {
2275
+ events.push({
2276
+ id: `event-${String(phaseEventSeq++).padStart(3, "0")}-phase-${phaseEventIdSuffix(phase.type)}`,
2277
+ at: phase.at,
2278
+ level: phase.ok === false ? "warn" : "info",
2279
+ type: phase.type,
2280
+ message: phase.durationMs === undefined ? phase.message : `${phase.message} (${phase.durationMs}ms)`,
2281
+ simId: "sim-001",
2282
+ streamId: "stream-001"
2283
+ });
2284
+ }
2285
+ const review = {
2286
+ schema: REVIEW_SCHEMA,
2287
+ verdict: args.inProgress === true
2288
+ ? "contract_proof_only"
2289
+ : args.session
2290
+ ? verdictForStatus(args.session.status)
2291
+ : args.sessionError
2292
+ ? "fail"
2293
+ : "contract_proof_only",
2294
+ summary: reason,
2295
+ gaps: args.session || args.sessionError
2296
+ ? []
2297
+ : args.inProgress === true
2298
+ ? ["Live desktop session is still running."]
2299
+ : ["Live desktop session not yet run (dry-run contract only)."]
2300
+ };
2301
+ return {
2302
+ schema: RUN_BUNDLE_SCHEMA,
2303
+ runId: args.runId,
2304
+ mode: args.dryRun ? "dry-run" : "live",
2305
+ simCount: 1,
2306
+ createdAt: args.createdAt,
2307
+ cwd: PUBLIC_TARGET_CWD,
2308
+ artifactRoot: path.join(".humanish", "runs", args.runId),
2309
+ source: args.source,
2310
+ persona: {
2311
+ id: args.persona.id,
2312
+ name: `Computer-use operator (${args.persona.id})`,
2313
+ source: `lab:${args.labId}`,
2314
+ sourceDigest: args.persona.promptDigest
2315
+ },
2316
+ scenario: {
2317
+ id: `cua-${args.labId}`,
2318
+ title: args.labTitle ?? `Computer-use lab: ${args.labId}`,
2319
+ goal: args.mission,
2320
+ source: `lab:${args.labId}`,
2321
+ sourceDigest: args.persona.promptDigest
2322
+ },
2323
+ lifecycle: [
2324
+ {
2325
+ at: args.createdAt,
2326
+ event: "cua-lab.run.created",
2327
+ message: `Created computer-use lab run with one desktop browser lane (actor ${args.actorId}).`
2328
+ }
2329
+ ],
2330
+ simulations: [simulation],
2331
+ streams: [stream],
2332
+ events,
2333
+ redaction: {
2334
+ status: "passed",
2335
+ notes: traceScreenshotMode === "raw"
2336
+ ? "Typed text recorded as length only and reasoning/messages pass through text redaction. Screenshots are FULL-FIDELITY (raw), retained for local use — NOT redacted for publishing; set policies.redactScreenshots: true to blur a share-as-is bundle."
2337
+ : traceScreenshotMode === "blurred"
2338
+ ? "Typed text recorded as length only and reasoning/messages pass through text redaction. Screenshots are blurred at capture (policies.redactScreenshots: true) for a share-as-is bundle."
2339
+ : args.screenshots.length > 0
2340
+ ? `Session ended before a trace was recorded; ${args.screenshots.length} already-written frame(s) follow the capture policy (${screenshotMode}). Typed text is recorded as length only and reasoning/messages pass through text redaction.`
2341
+ : "No screenshots captured. Typed text is recorded as length only and reasoning/messages pass through text redaction whenever a session runs."
2342
+ },
2343
+ artifacts: {
2344
+ run: "run.json",
2345
+ reviewJson: "review.json",
2346
+ reviewMarkdown: "review.md",
2347
+ observerData: "observer/observer-data.json",
2348
+ events: "events.ndjson"
2349
+ },
2350
+ review,
2351
+ feedbackCandidates: [],
2352
+ // Custom desktop image provenance (omitted on the stock-template default → byte-stable).
2353
+ ...(args.desktopTemplate === undefined ? {} : { desktopTemplate: args.desktopTemplate }),
2354
+ ...(args.desktopBrowser === undefined ? {} : { desktopBrowser: args.desktopBrowser }),
2355
+ ...(args.providerResources === undefined || args.providerResources.length === 0 ? {} : { providerResources: args.providerResources }),
2356
+ // Structured subject provenance (invariant 5): code pin + state story. Uniform and
2357
+ // honest on app-url bundles too — the caller minted the URL, its state is the caller's.
2358
+ // CuaSubjectProvenanceArg's two variants (clone, local-tree) are already RunSubjectProvenance-
2359
+ // shaped, so no reconstruction is needed beyond the app-url fallback.
2360
+ subject: args.subjectProvenance ?? { source: "app-url", state: { provenance: "undeclared" } }
2361
+ };
2362
+ }
2363
+ /** Human-readable provenance line for the single-lane subject.provenance event (invariant 5):
2364
+ * claims "cloned/packed and served" ONLY when it actually happened. */
2365
+ function subjectProvenanceMessage(provenance, publicAppUrl, dryRun, hasSession) {
2366
+ if (provenance.source === "clone") {
2367
+ if (dryRun) {
2368
+ return `Subject declared: clone of ${provenance.repo}, to be served at ${publicAppUrl} in-sandbox (dry-run contract; nothing cloned)`;
2369
+ }
2370
+ if (provenance.commit) {
2371
+ return hasSession
2372
+ ? `Subject cloned from ${provenance.repo}@${provenance.commit} and served at ${publicAppUrl} in-sandbox`
2373
+ : `Subject cloned from ${provenance.repo}@${provenance.commit}; serving at ${publicAppUrl} did not complete (see session error)`;
2374
+ }
2375
+ return `Subject clone attempted from ${provenance.repo}; commit unresolved (provisioning failed before resolution)`;
2376
+ }
2377
+ if (dryRun) {
2378
+ return `Subject declared: local working tree, to be packed and served at ${publicAppUrl} in-sandbox (dry-run contract; nothing packed)`;
2379
+ }
2380
+ if (provenance.archiveSha256) {
2381
+ const dirtyLabel = provenance.dirty === true ? ", dirty working tree" : provenance.dirty === false ? ", clean working tree" : "";
2382
+ return hasSession
2383
+ ? `Subject packed (archiveSha256 ${provenance.archiveSha256}${dirtyLabel}) and served at ${publicAppUrl} in-sandbox`
2384
+ : `Subject packed (archiveSha256 ${provenance.archiveSha256}${dirtyLabel}); serving at ${publicAppUrl} did not complete (see session error)`;
2385
+ }
2386
+ return "Subject local-tree packing attempted; archive digest unresolved (provisioning failed before resolution)";
2387
+ }
2388
+ /**
2389
+ * Project N>1 fan-out lanes into a humanish.run-bundle.v1 (the evidence schema is unchanged; this
2390
+ * is a new producer for the multi-stream shape). One sim + one stream per lane; per-lane
2391
+ * provenance/session events; a recorded `cua-lab.fanout.plan` event (and a `cua-lab.fanout.fail-fast`
2392
+ * event when a harness error skipped queued lanes). N-ary verify/Observer already handle multiple
2393
+ * streams. The N=1 path NEVER reaches here (buildCuaBundle owns it, byte-stable).
2394
+ */
2395
+ export function buildCuaFanoutBundle(args) {
2396
+ const { specs, outcomes, config } = args;
2397
+ const simulations = [];
2398
+ const streams = [];
2399
+ const events = [];
2400
+ events.push({
2401
+ id: "event-000-created",
2402
+ at: args.createdAt,
2403
+ level: "info",
2404
+ type: "cua-lab.run.created",
2405
+ message: `Created computer-use fan-out run for ${config.id} (actor ${args.descriptor.id}, ${specs.length} lanes, per-lane worlds).`
2406
+ });
2407
+ events.push({
2408
+ id: "event-001-fanout-plan",
2409
+ at: args.createdAt,
2410
+ level: "info",
2411
+ type: "cua-lab.fanout.plan",
2412
+ message: `Fan-out plan: ${args.plan.laneCount} lane(s) (${args.plan.strategy}), concurrency ${args.plan.concurrency}, ${args.plan.waves} wave(s); per-lane session budget ${Math.round(args.plan.perLaneSessionBudgetMs / 1000)}s; worst-case ~${args.plan.worstCaseSandboxMinutes} sandbox-minutes${args.dryRun ? " (dry-run: $0)" : ""}. Lanes: ${args.plan.lanes.map(formatLanePlanEntry).join(", ")}.`
2413
+ });
2414
+ let eventSeq = 2;
2415
+ const nextEventId = (suffix) => `event-${String(eventSeq++).padStart(3, "0")}-${suffix}`;
2416
+ if (args.rerun) {
2417
+ events.push({
2418
+ id: nextEventId("fanout-rerun"),
2419
+ at: args.createdAt,
2420
+ level: "info",
2421
+ type: "cua-lab.fanout.rerun",
2422
+ message: `Rerun selected ${args.rerun.selectedLaneIds.length} lane(s) from ${args.rerun.sourceRunId}: ${args.rerun.previous.map((lane) => `${lane.laneId} was ${lane.status}${lane.completionReason ? `/${lane.completionReason}` : ""}`).join(", ")}. This is a new linked run; the source run verdict is unchanged.`
2423
+ });
2424
+ }
2425
+ specs.forEach((spec, index) => {
2426
+ const outcome = outcomes?.[index];
2427
+ const laneAppUrl = spec.targetUrl ?? args.appUrl;
2428
+ const publicLaneAppUrl = publicSafeAppUrlLabel(laneAppUrl);
2429
+ const subject = args.laneSubjects[index];
2430
+ const session = outcome?.session;
2431
+ const screenshots = outcome?.screenshots ?? [];
2432
+ const lastScreenshot = screenshots[screenshots.length - 1];
2433
+ const status = args.inProgress === true && outcome === undefined
2434
+ ? "running"
2435
+ : outcome?.skippedReason !== undefined
2436
+ ? "blocked"
2437
+ : session
2438
+ ? session.status
2439
+ : outcome?.sessionError
2440
+ ? "failed"
2441
+ : "contract_proof_only";
2442
+ const reason = args.inProgress === true && outcome === undefined
2443
+ ? "Live computer-use lane is running; stream auth URL is available only through the attached Observer server."
2444
+ : outcome?.skippedReason
2445
+ ?? session?.reason
2446
+ ?? outcome?.sessionError
2447
+ ?? "Contract bundle only: dry-run produced the evidence shape without launching a desktop or spending provider tokens.";
2448
+ const traceScreenshotMode = session?.trace.redaction.screenshots;
2449
+ const screenshotMode = traceScreenshotMode === "raw" || traceScreenshotMode === "blurred"
2450
+ ? traceScreenshotMode
2451
+ : config.policies?.redactScreenshots === true ? "blurred" : "raw";
2452
+ simulations.push({
2453
+ id: spec.simId,
2454
+ index: index + 1,
2455
+ personaId: spec.persona.id,
2456
+ scenarioId: `cua-${config.id}`,
2457
+ status,
2458
+ streamKind: "browser",
2459
+ mode: "browser-sim",
2460
+ progress: args.inProgress === true && outcome === undefined ? 20 : 100,
2461
+ currentStep: reason,
2462
+ summary: session
2463
+ ? `Lane ${spec.laneId} (${spec.persona.id}/${spec.deviceName}): computer-use actor (${args.descriptor.id}) drove the subject app; ${session.completionReason}.`
2464
+ : args.inProgress === true && outcome === undefined
2465
+ ? `Lane ${spec.laneId} (${spec.persona.id}/${spec.deviceName}): computer-use actor (${args.descriptor.id}) is driving the subject app.`
2466
+ : outcome?.skippedReason !== undefined
2467
+ ? `Lane ${spec.laneId} ${outcome.skippedReason}.`
2468
+ : outcome?.sessionError
2469
+ ? `Lane ${spec.laneId} failed before a terminal session verdict: ${outcome.sessionError}`
2470
+ : `Contract lane ${spec.laneId} (${spec.persona.id}/${spec.deviceName}) for ${args.descriptor.id} against ${publicLaneAppUrl}.`,
2471
+ streamIds: [spec.streamId],
2472
+ startedAt: args.createdAt,
2473
+ updatedAt: args.createdAt
2474
+ });
2475
+ streams.push({
2476
+ id: spec.streamId,
2477
+ simId: spec.simId,
2478
+ laneId: spec.laneId,
2479
+ ...(spec.actorType === undefined ? {} : { actorType: spec.actorType }),
2480
+ ...(spec.surface === undefined ? {} : { surface: spec.surface }),
2481
+ ...(spec.caseGroup === undefined ? {} : { caseGroup: spec.caseGroup }),
2482
+ kind: "browser",
2483
+ label: `CUA lane ${spec.laneId} — ${config.id}`,
2484
+ status,
2485
+ transport: "snapshot",
2486
+ updatedAt: args.createdAt,
2487
+ embed: lastScreenshot
2488
+ ? { kind: "screenshot", url: lastScreenshot, title: `CUA desktop ${spec.laneId} (${screenshotMode})` }
2489
+ : { kind: "placeholder", title: `CUA desktop ${spec.laneId}` },
2490
+ viewport: {
2491
+ width: spec.resolution[0],
2492
+ height: spec.resolution[1],
2493
+ deviceScaleFactor: spec.devicePreset.deviceScaleFactor,
2494
+ isMobile: spec.devicePreset.isMobile
2495
+ },
2496
+ ui: {
2497
+ route: publicLaneAppUrl,
2498
+ intent: `Watch lane ${spec.laneId} (${spec.persona.id}/${spec.deviceName}) drive the subject app in its own hosted desktop.`,
2499
+ state: reason,
2500
+ ...(session ? { actorStatus: session.status } : {}),
2501
+ ...(lastScreenshot ? { screenshotUrl: lastScreenshot } : {})
2502
+ },
2503
+ ...(session ? { actor: session.trace } : {}),
2504
+ artifacts: [
2505
+ { label: "run bundle", path: "run.json", kind: "bundle" },
2506
+ { label: "review", path: "review.md", kind: "review" },
2507
+ { label: "events", path: "events.ndjson", kind: "events" },
2508
+ ...(session
2509
+ ? [{ label: `lane ${spec.laneId} actor trace`, path: spec.traceArtifactPath, kind: "trace" }]
2510
+ : []),
2511
+ ...screenshots.map((screenshot, screenshotIndex) => ({
2512
+ label: `lane ${spec.laneId} screenshot ${String(screenshotIndex + 1).padStart(2, "0")} (${screenshotMode})`,
2513
+ path: screenshot,
2514
+ kind: "screenshot"
2515
+ }))
2516
+ ]
2517
+ });
2518
+ // Per-lane subject provenance (invariant 5).
2519
+ if (args.cloneRoute && args.publicRepo) {
2520
+ events.push({
2521
+ id: nextEventId(`subject-${spec.laneId}`),
2522
+ at: args.createdAt,
2523
+ level: "info",
2524
+ type: "cua-lab.subject.provenance",
2525
+ message: `Lane ${spec.laneId}: ${args.dryRun
2526
+ ? `subject declared — clone of ${args.publicRepo}, served at ${publicLaneAppUrl} in-sandbox (dry-run contract; nothing cloned)`
2527
+ : subject.commit
2528
+ ? session
2529
+ ? `subject cloned from ${args.publicRepo}@${subject.commit} and served at ${publicLaneAppUrl} in-sandbox`
2530
+ : `subject cloned from ${args.publicRepo}@${subject.commit}; serving did not complete (see session error)`
2531
+ : `subject clone attempted from ${args.publicRepo}; commit unresolved`} (subject env names: ${args.subjectEnvNames.length > 0 ? args.subjectEnvNames.join(", ") : "none"}; values never persisted); state: ${describeSubjectState(subject.state, args.dryRun)}.`,
2532
+ simId: spec.simId,
2533
+ streamId: spec.streamId
2534
+ });
2535
+ }
2536
+ else if (subject.source === "local-tree") {
2537
+ events.push({
2538
+ id: nextEventId(`subject-${spec.laneId}`),
2539
+ at: args.createdAt,
2540
+ level: "info",
2541
+ type: "cua-lab.subject.provenance",
2542
+ message: `Lane ${spec.laneId}: ${args.dryRun
2543
+ ? `subject declared: local working tree, to be packed and served at ${publicLaneAppUrl} in-sandbox (dry-run contract; nothing packed)`
2544
+ : subject.archiveSha256
2545
+ ? session
2546
+ ? `subject packed (archiveSha256 ${subject.archiveSha256}${subject.dirty === true ? ", dirty working tree" : subject.dirty === false ? ", clean working tree" : ""}) and served at ${publicLaneAppUrl} in-sandbox`
2547
+ : `subject packed (archiveSha256 ${subject.archiveSha256}); serving did not complete (see session error)`
2548
+ : "subject local-tree packing attempted; archive digest unresolved"} (subject env names: ${args.subjectEnvNames.length > 0 ? args.subjectEnvNames.join(", ") : "none"}; values never persisted); state: ${describeSubjectState(subject.state, args.dryRun)}.`,
2549
+ simId: spec.simId,
2550
+ streamId: spec.streamId
2551
+ });
2552
+ }
2553
+ else {
2554
+ events.push({
2555
+ id: nextEventId(`subject-${spec.laneId}`),
2556
+ at: args.createdAt,
2557
+ level: "info",
2558
+ type: "cua-lab.subject.declared",
2559
+ message: `Lane ${spec.laneId}: subject app declared at ${publicLaneAppUrl} (loopback inside the lane's own desktop sandbox).`,
2560
+ simId: spec.simId,
2561
+ streamId: spec.streamId
2562
+ });
2563
+ }
2564
+ // Per-lane session event.
2565
+ if (session) {
2566
+ events.push({
2567
+ id: nextEventId(`session-${spec.laneId}`),
2568
+ at: args.createdAt,
2569
+ level: session.status === "passed" ? "info" : "warn",
2570
+ type: `cua-lab.session.${session.completionReason}`,
2571
+ message: `Lane ${spec.laneId}: ${session.status} — ${session.reason}`,
2572
+ simId: spec.simId,
2573
+ streamId: spec.streamId
2574
+ });
2575
+ }
2576
+ else if (args.inProgress === true && outcome === undefined) {
2577
+ events.push({
2578
+ id: nextEventId(`running-${spec.laneId}`),
2579
+ at: args.createdAt,
2580
+ level: "info",
2581
+ type: "cua-lab.session.running",
2582
+ message: `Lane ${spec.laneId}: live computer-use session is running; terminal evidence has not been written yet.`,
2583
+ simId: spec.simId,
2584
+ streamId: spec.streamId
2585
+ });
2586
+ }
2587
+ else if (outcome?.skippedReason !== undefined) {
2588
+ events.push({
2589
+ id: nextEventId(`blocked-${spec.laneId}`),
2590
+ at: args.createdAt,
2591
+ level: "warn",
2592
+ type: "cua-lab.session.blocked",
2593
+ message: `Lane ${spec.laneId} ${outcome.skippedReason}.`,
2594
+ simId: spec.simId,
2595
+ streamId: spec.streamId
2596
+ });
2597
+ }
2598
+ else if (outcome?.sessionError) {
2599
+ events.push({
2600
+ id: nextEventId(`session-error-${spec.laneId}`),
2601
+ at: args.createdAt,
2602
+ level: "error",
2603
+ type: "cua-lab.session.error",
2604
+ message: `Lane ${spec.laneId}: ${outcome.sessionError}`,
2605
+ simId: spec.simId,
2606
+ streamId: spec.streamId
2607
+ });
2608
+ }
2609
+ else {
2610
+ events.push({
2611
+ id: nextEventId(`contract-${spec.laneId}`),
2612
+ at: args.createdAt,
2613
+ level: "info",
2614
+ type: "cua-lab.contract.ready",
2615
+ message: `Lane ${spec.laneId}: dry-run contract lane ready; switch scenario.mode to live for a real desktop session.`,
2616
+ simId: spec.simId,
2617
+ streamId: spec.streamId
2618
+ });
2619
+ }
2620
+ // Persisted per-lane phase trail (real boot timing): one RunEvent per COMPLETED phase
2621
+ // boundary this lane recorded (started events never persist here; they carry no durationMs).
2622
+ for (const phase of outcome?.phaseRecords ?? []) {
2623
+ events.push({
2624
+ id: nextEventId(`phase-${spec.laneId}-${phaseEventIdSuffix(phase.type)}`),
2625
+ at: phase.at,
2626
+ level: phase.ok === false ? "warn" : "info",
2627
+ type: phase.type,
2628
+ message: phase.durationMs === undefined
2629
+ ? `Lane ${spec.laneId}: ${phase.message}`
2630
+ : `Lane ${spec.laneId}: ${phase.message} (${phase.durationMs}ms)`,
2631
+ simId: spec.simId,
2632
+ streamId: spec.streamId
2633
+ });
2634
+ }
2635
+ });
2636
+ if (args.failFastReason) {
2637
+ events.push({
2638
+ id: nextEventId("fanout-fail-fast"),
2639
+ at: args.createdAt,
2640
+ level: "warn",
2641
+ type: "cua-lab.fanout.fail-fast",
2642
+ message: `Fan-out fail-fast: ${args.failFastReason}. In-flight lanes finished; queued lanes were skipped (blocked) — completed evidence is retained.`
2643
+ });
2644
+ }
2645
+ // Worst-of review verdict across lanes; live fan-out must prove every lane.
2646
+ const verdict = args.inProgress === true
2647
+ ? "contract_proof_only"
2648
+ : fanoutReviewVerdict({
2649
+ dryRun: args.dryRun,
2650
+ expectedLaneCount: specs.length,
2651
+ outcomes
2652
+ });
2653
+ const passedLanes = (outcomes ?? []).filter((outcome) => outcome.skippedReason === undefined
2654
+ && outcome.session !== undefined
2655
+ && outcome.session.status === "passed"
2656
+ && outcome.session.completionReason !== "harness_error"
2657
+ && outcome.sessionError === undefined
2658
+ && !outcome.noEngagement
2659
+ && !outcome.selfReportedBlocker).length;
2660
+ const review = {
2661
+ schema: REVIEW_SCHEMA,
2662
+ verdict,
2663
+ summary: args.inProgress === true
2664
+ ? `Live computer-use fan-out is running (${specs.length} per-lane worlds); terminal lane evidence has not been written yet.`
2665
+ : args.dryRun
2666
+ ? `${args.rerun ? `Rerun contract from ${args.rerun.sourceRunId}: ` : ""}Dry-run fan-out contract: ${specs.length} per-lane-world lanes composed for ${args.descriptor.id} against ${args.appUrl}; no desktops launched, $0 spend.`
2667
+ : `${args.rerun ? `Rerun from ${args.rerun.sourceRunId}: ` : ""}Computer-use fan-out (${specs.length} per-lane worlds): ${passedLanes}/${specs.length} lane(s) reached a terminal, engaged verdict.`,
2668
+ gaps: args.inProgress === true
2669
+ ? ["Live fan-out session is still running."]
2670
+ : args.dryRun
2671
+ ? ["Live fan-out session not yet run (dry-run contract only)."]
2672
+ : specs
2673
+ .map((spec, index) => ({ spec, outcome: outcomes?.[index] }))
2674
+ .filter(({ outcome }) => outcome === undefined
2675
+ || outcome.skippedReason !== undefined
2676
+ || outcome.sessionError !== undefined
2677
+ || outcome.noEngagement
2678
+ || outcome.selfReportedBlocker
2679
+ || outcome.session === undefined
2680
+ || outcome.session.status !== "passed")
2681
+ .map(({ spec, outcome }) => `${spec.laneId}: ${outcome?.skippedReason ?? outcome?.sessionError ?? outcome?.session?.reason ?? "did not pass"}`)
2682
+ };
2683
+ const anyRaw = (outcomes ?? []).some((outcome) => outcome.session?.trace.redaction.screenshots === "raw");
2684
+ const ranLive = (outcomes ?? []).some((outcome) => outcome.session !== undefined || outcome.sessionError !== undefined);
2685
+ const configuredBrowser = config.execution?.desktop?.browser;
2686
+ const resolvedBrowsers = (outcomes ?? [])
2687
+ .map((outcome) => outcome.desktopBrowser?.resolved)
2688
+ .filter((value) => value !== undefined);
2689
+ const unanimousResolvedBrowser = resolvedBrowsers.length > 0 && new Set(resolvedBrowsers).size === 1
2690
+ ? resolvedBrowsers[0]
2691
+ : undefined;
2692
+ const providerResources = (outcomes ?? []).flatMap((outcome) => providerResourcesForOutcome({
2693
+ outcome,
2694
+ createdAt: args.createdAt,
2695
+ simId: outcome.spec.simId,
2696
+ streamId: outcome.spec.streamId,
2697
+ laneId: outcome.spec.laneId
2698
+ }));
2699
+ return {
2700
+ schema: RUN_BUNDLE_SCHEMA,
2701
+ runId: args.runId,
2702
+ mode: args.dryRun ? "dry-run" : "live",
2703
+ simCount: specs.length,
2704
+ createdAt: args.createdAt,
2705
+ cwd: PUBLIC_TARGET_CWD,
2706
+ artifactRoot: path.join(".humanish", "runs", args.runId),
2707
+ source: args.source,
2708
+ persona: {
2709
+ id: specs[0].persona.id,
2710
+ name: `Computer-use fan-out (${specs.length} lanes)`,
2711
+ source: `lab:${config.id}`,
2712
+ sourceDigest: specs[0].persona.promptDigest
2713
+ },
2714
+ scenario: {
2715
+ id: `cua-${config.id}`,
2716
+ title: config.title ?? `Computer-use fan-out: ${config.id}`,
2717
+ goal: specs[0].instructions,
2718
+ source: `lab:${config.id}`,
2719
+ sourceDigest: specs[0].persona.promptDigest
2720
+ },
2721
+ lifecycle: [
2722
+ {
2723
+ at: args.createdAt,
2724
+ event: "cua-lab.run.created",
2725
+ message: `Created computer-use fan-out run with ${specs.length} per-lane desktop browser lanes (actor ${args.descriptor.id}).`
2726
+ }
2727
+ ],
2728
+ simulations,
2729
+ streams,
2730
+ events,
2731
+ ...(args.rerun === undefined ? {} : { rerun: args.rerun }),
2732
+ redaction: {
2733
+ status: "passed",
2734
+ notes: ranLive
2735
+ ? anyRaw
2736
+ ? "Typed text recorded as length only and reasoning/messages pass through text redaction. Some lanes captured FULL-FIDELITY (raw) screenshots, retained for local use — NOT redacted for publishing; set policies.redactScreenshots: true to blur a share-as-is bundle."
2737
+ : "Typed text recorded as length only and reasoning/messages pass through text redaction. Screenshots are blurred at capture (policies.redactScreenshots: true) for a share-as-is bundle."
2738
+ : "Dry-run fan-out contract bundle: no desktops launched and no screenshots captured. Typed text is recorded as length only and reasoning/messages pass through text redaction whenever a session runs."
2739
+ },
2740
+ artifacts: {
2741
+ run: "run.json",
2742
+ reviewJson: "review.json",
2743
+ reviewMarkdown: "review.md",
2744
+ observerData: "observer/observer-data.json",
2745
+ events: "events.ndjson"
2746
+ },
2747
+ review,
2748
+ feedbackCandidates: [],
2749
+ // Custom desktop image provenance (every lane launched on it); omitted on the stock default.
2750
+ ...(config.execution?.desktop?.template === undefined ? {} : { desktopTemplate: config.execution.desktop.template }),
2751
+ ...(configuredBrowser === undefined
2752
+ ? {}
2753
+ : { desktopBrowser: { requested: configuredBrowser, ...(unanimousResolvedBrowser === undefined ? {} : { resolved: unanimousResolvedBrowser }) } }),
2754
+ ...(providerResources.length === 0 ? {} : { providerResources }),
2755
+ subject: args.aggregateSubject
2756
+ };
2757
+ }
2758
+ function providerResourcesForOutcome(args) {
2759
+ if (args.outcome?.sandboxId === undefined) {
2760
+ return [];
2761
+ }
2762
+ return [{
2763
+ schema: "humanish.provider-resource.v1",
2764
+ provider: "e2b-desktop",
2765
+ kind: "sandbox",
2766
+ id: args.outcome.sandboxId,
2767
+ owner: "humanish",
2768
+ status: args.outcome.killed ? "killed" : "running",
2769
+ simId: args.simId,
2770
+ streamId: args.streamId,
2771
+ laneId: args.laneId,
2772
+ createdAt: args.createdAt,
2773
+ cleanup: {
2774
+ killed: args.outcome.killed,
2775
+ reason: args.outcome.killed
2776
+ ? "killed during normal lane teardown"
2777
+ : "not killed during normal lane teardown; cleanup may reclaim by exact recorded id"
2778
+ }
2779
+ }];
2780
+ }
2781
+ function verdictForStatus(status) {
2782
+ switch (status) {
2783
+ case "passed":
2784
+ return "pass";
2785
+ case "failed":
2786
+ return "fail";
2787
+ case "blocked":
2788
+ return "blocked";
2789
+ case "timed_out":
2790
+ return "timed_out";
2791
+ }
2792
+ }
2793
+ function renderCuaReviewMarkdown(bundle) {
2794
+ const trace = bundle.streams[0]?.actor;
2795
+ const provenance = bundle.events.find((event) => event.type === "cua-lab.subject.provenance");
2796
+ return [
2797
+ `# ${bundle.scenario.title}`,
2798
+ "",
2799
+ `- run: ${bundle.runId}`,
2800
+ `- mode: ${bundle.mode}`,
2801
+ `- verdict: ${bundle.review.verdict}`,
2802
+ `- summary: ${bundle.review.summary}`,
2803
+ ...(provenance ? [`- subject: ${provenance.message}`] : []),
2804
+ ...(trace
2805
+ ? [
2806
+ `- actor: ${trace.provider} (${trace.lane}/${trace.protocol})`,
2807
+ // Honest count: name the trace's actual screenshot mode ("raw" | "blurred"); say
2808
+ // nothing when no frames exist ("n/a") rather than claim a redaction that never ran.
2809
+ `- evidence: ${trace.items.length} trace item(s), ${trace.counts.screenshots ?? 0} ${trace.redaction.screenshots === "raw" || trace.redaction.screenshots === "blurred"
2810
+ ? `${trace.redaction.screenshots} screenshot(s)`
2811
+ : "screenshot(s)"}`
2812
+ ]
2813
+ : []),
2814
+ ...(bundle.review.gaps.length > 0 ? ["", "## Gaps", ...bundle.review.gaps.map((gap) => `- ${gap}`)] : []),
2815
+ ""
2816
+ ].join("\n");
2817
+ }
2818
+ function makeCuaRunId() {
2819
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
2820
+ return `cua-${stamp}-${randomBytes(4).toString("hex")}`;
2821
+ }
2822
+ function publicSafeAppUrlLabel(url) {
2823
+ return containsSensitive(url) ? `[target-url:${digestUrl(url)}]` : url;
2824
+ }
2825
+ function digestUrl(url) {
2826
+ return digestText(url, 16);
2827
+ }
2828
+ function readPositiveInt(value, fallback) {
2829
+ if (value === undefined)
2830
+ return fallback;
2831
+ const parsed = Number.parseInt(value, 10);
2832
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
2833
+ }
2834
+ //# sourceMappingURL=cua-actor-lab.js.map