humanish 0.0.1 → 0.15.1

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