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,788 @@
1
+ // The scripted-browser lab backend: either an app-url subject (a loopback app the operator
2
+ // already runs) or one provisioned synthetic clone subject (served in E2B and exposed through
3
+ // getHost) driven by the REGISTRY-RESOLVED scripted-browser actor.
4
+ // Mirrors cua-actor-lab.ts: the descriptor returned by the registry runs the session; this
5
+ // backend consumes `scenario.ref` (resolves the committed scenario whose browser steps ARE the
6
+ // actor's behavior — no built-in fallback on the lab route, unlike `run --app-url`), composes
7
+ // the per-surface sessions, persists the evidence bundle, and renders the Observer.
8
+ //
9
+ // Spend posture: no model/provider-token spend BY MECHANISM — nothing on this code path can
10
+ // construct a provider client, and every projected trace records tokenUsage zeros. Local
11
+ // app-url runs also spend no sandbox minutes; live provisioned clone runs can spend E2B
12
+ // sandbox minutes to clone/serve the synthetic subject. `scenario.mode: live` is still
13
+ // required because the gate's justification here is ACTUATION: a live scripted run drives a
14
+ // real browser against a real running app (fills forms, clicks buttons — state-mutating
15
+ // effects), which deserves the same affirmative declaration as spend. Dry-run (the default)
16
+ // parses and digest-pins the scenario and emits the contract bundle without touching anything.
17
+ //
18
+ // Subject provenance (invariant 5): local app-url runs declare that the lab did NOT provision
19
+ // the subject, so build/commit provenance is UNPINNED and the evidence binds to the scenario
20
+ // digest instead. Provisioned clone runs persist structured commit/env-name/state provenance
21
+ // plus a host digest while never writing the raw getHost URL or secret values into artifacts.
22
+ import { randomBytes } from "node:crypto";
23
+ import { realpath } from "node:fs/promises";
24
+ import path from "node:path";
25
+ import { parse as parseYaml } from "yaml";
26
+ import { actorRegistry, isScriptedBrowserActorDescriptor } from "./actor-registry.js";
27
+ import { toErrorMessage } from "./command-failure.js";
28
+ import { commandDigestOf, provisionCloneSubject, resolveSubjectState } from "./cua-actor-lab.js";
29
+ import { createDesktopSandbox, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
30
+ import { renderObserver } from "./observer.js";
31
+ import { digestText, redactText } from "./redaction.js";
32
+ import { prepareRunArtifactPaths, validatePreparedRunArtifactPaths } from "./run-paths.js";
33
+ import { buildRunSource, PUBLIC_TARGET_CWD, REVIEW_SCHEMA, RUN_BUNDLE_SCHEMA } from "./run.js";
34
+ import { browserSurfaces, normalizeLocalAppUrl, parseBrowserPersonaJourneyFromScenario, resolveBrowserCommand, runScriptedBrowserSessionInPreparedRoot } from "./scripted-browser-actor.js";
35
+ import { prepareSelectedOutputDirectory, readContainedRegularFile, writeContainedOutputFile, writePreparedRunLatestPointer } from "./selected-output-paths.js";
36
+ export const SCRIPTED_BROWSER_LAB_SCHEMA = "humanish.scripted-lab-result.v1";
37
+ // Journey wall-clock budget per surface — same default as `run --app-url`.
38
+ const DEFAULT_SESSION_TIMEOUT_MS = 60_000;
39
+ const SANDBOX_TIMEOUT_BUFFER_MS = 10 * 60_000;
40
+ const SUBJECT_PROVISION_BUDGET_MS = 30 * 60_000;
41
+ const DEFAULT_STATE_STEP_TIMEOUT_MS = 5 * 60_000;
42
+ // Default surface roster is 1 (desktop only): the defaults-table single-lane row governs;
43
+ // `count: 2` is the declared override that adds the mobile surface.
44
+ const DEFAULT_SURFACE_COUNT = 1;
45
+ // Same public-safe token shape the lab id uses; an id-style scenario.ref must match it before
46
+ // it is interpolated into a repo path.
47
+ const SCENARIO_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
48
+ class UnsafeScriptedSessionResultError extends Error {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = "UnsafeScriptedSessionResultError";
52
+ }
53
+ }
54
+ export async function runScriptedBrowserLab(options) {
55
+ const { config, dryRun } = options;
56
+ const cwd = path.resolve(options.cwd);
57
+ const physicalCwd = await realpath(cwd);
58
+ const projectRoot = await prepareSelectedOutputDirectory(path.dirname(physicalCwd), physicalCwd);
59
+ const hooks = options.hooks ?? {};
60
+ const render = hooks.renderObserverFn ?? renderObserver;
61
+ const warnings = [];
62
+ const actorType = config.actors[0]?.type ?? "";
63
+ const failed = (code, message, extras) => ({
64
+ schema: SCRIPTED_BROWSER_LAB_SCHEMA,
65
+ ok: false,
66
+ cwd,
67
+ labId: config.id,
68
+ actor: extras?.actor ?? actorType,
69
+ appUrl: extras?.appUrl ?? config.subject.appUrl ?? "",
70
+ dryRun,
71
+ runId: options.runId ?? "not-created",
72
+ sessions: [],
73
+ warnings,
74
+ error: { code, message }
75
+ });
76
+ // Resolve the actor through the registry — the parse layer already validated this, but the
77
+ // engine fails closed rather than trusting a config that arrived through another door
78
+ // (runScriptedBrowserLab is itself exported npm surface).
79
+ const descriptor = actorRegistry[actorType];
80
+ if (!descriptor || !isScriptedBrowserActorDescriptor(descriptor)) {
81
+ return failed("HUMANISH_SCRIPTED_LAB_ACTOR_UNSUPPORTED", `actors[0].type "${actorType}" is not a registered scripted-browser actor.`);
82
+ }
83
+ const runSession = hooks.runSession;
84
+ const provisionedRoute = config.subject.source === "clone";
85
+ const evidenceAppUrl = provisionedRoute ? "[provisioned-subject]" : normalizeLocalAppUrl(config.subject.appUrl ?? "") ?? "";
86
+ const urlPolicy = provisionedRoute
87
+ ? { kind: "provisioned-subject", evidenceOrigin: evidenceAppUrl }
88
+ : { kind: "loopback" };
89
+ const serve = config.subject.serve;
90
+ const subjectRepo = provisionedRoute ? config.subject.repos?.[0] ?? "" : undefined;
91
+ const subjectEnvNames = provisionedRoute ? config.subject.env ?? [] : [];
92
+ const env = hooks.env ?? process.env;
93
+ const e2bApiKey = env.E2B_API_KEY?.trim() ?? "";
94
+ const hasGithubToken = subjectEnvNames.includes("GITHUB_TOKEN");
95
+ const redactRepoLabel = config.policies?.redactRepos ?? hasGithubToken;
96
+ const publicRepo = provisionedRoute && subjectRepo ? (redactRepoLabel ? "repo-01" : subjectRepo) : undefined;
97
+ const scrubSourceValues = [
98
+ ...(subjectRepo ? [subjectRepo] : []),
99
+ ...subjectEnvNames.map((name) => env[name] ?? "")
100
+ ].filter(Boolean);
101
+ const scrubKnownValues = (text) => scrubSourceValues.reduce((acc, value) => acc.split(value).join("[redacted]"), text);
102
+ // Re-enforce the local loopback entry boundary at the engine. The provisioned clone route
103
+ // mints its own getHost URL later and persists only evidenceAppUrl.
104
+ let appUrl = provisionedRoute ? serve?.url ?? "" : evidenceAppUrl;
105
+ if (!provisionedRoute && !appUrl) {
106
+ return failed("HUMANISH_SCRIPTED_LAB_SUBJECT_UNSAFE", "subject.appUrl must be a loopback http(s) URL (127.0.0.1 or localhost) on the scripted-browser route.", { actor: descriptor.id });
107
+ }
108
+ if (provisionedRoute && (!serve || !subjectRepo || !publicRepo)) {
109
+ return failed("HUMANISH_SCRIPTED_LAB_SUBJECT_UNSAFE", "clone scripted-browser labs require one subject repo plus subject.serve; parseLabConfig should have rejected this config.", { actor: descriptor.id, appUrl: evidenceAppUrl });
110
+ }
111
+ // Consume scenario.ref (fail-closed: invariant 6 — the steps ARE the actor; there is no
112
+ // built-in journey fallback on the lab route).
113
+ const scenario = await resolveScriptedScenario(projectRoot, config.scenario?.ref);
114
+ if (!scenario.ok) {
115
+ return failed("HUMANISH_SCRIPTED_LAB_SCENARIO_INVALID", scenario.message, { actor: descriptor.id, appUrl: evidenceAppUrl });
116
+ }
117
+ const journey = scenario.journey;
118
+ if (!dryRun && provisionedRoute) {
119
+ if (!e2bApiKey) {
120
+ return failed("HUMANISH_SCRIPTED_LAB_KEYS_MISSING", "Live clone scripted-browser labs require E2B_API_KEY (dry-run remains $0 and does not provision a subject).", { actor: descriptor.id, appUrl: evidenceAppUrl });
121
+ }
122
+ const missingSubjectEnv = subjectEnvNames.filter((name) => !env[name]?.trim());
123
+ if (missingSubjectEnv.length > 0) {
124
+ return failed("HUMANISH_SCRIPTED_LAB_SUBJECT_ENV_MISSING", `Subject env values missing for live clone scripted-browser lab: ${missingSubjectEnv.join(", ")}.`, { actor: descriptor.id, appUrl: evidenceAppUrl });
125
+ }
126
+ }
127
+ const surfaces = browserSurfaces.slice(0, config.actors[0]?.count ?? DEFAULT_SURFACE_COUNT);
128
+ const timeoutMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
129
+ const persona = {
130
+ id: config.actors[0]?.persona ?? "scripted-journey",
131
+ traitsApplied: [],
132
+ // The step manifest IS the "prompt" on this lane; the digest binds the trace to the
133
+ // committed scenario text.
134
+ promptDigest: journey.sourceDigest.slice(0, 16)
135
+ };
136
+ // Live runs need a browser BEFORE any actuation (unless one is injected).
137
+ let browserCommand = hooks.browserCommand;
138
+ if (!dryRun && !hooks.launchBrowser && !browserCommand) {
139
+ const resolved = await resolveBrowserCommand();
140
+ if (!resolved) {
141
+ return failed("HUMANISH_SCRIPTED_LAB_BROWSER_MISSING", "No Chrome/Chromium browser command was found for the scripted-browser actor. Set HUMANISH_BROWSER_COMMAND to a browser binary playwright-core can launch.", { actor: descriptor.id, appUrl: evidenceAppUrl });
142
+ }
143
+ browserCommand = resolved;
144
+ }
145
+ const runId = options.runId ?? makeScriptedRunId();
146
+ const runPaths = await prepareRunArtifactPaths(physicalCwd, runId);
147
+ const artifactRoot = runPaths.physicalRunRoot;
148
+ const createdAt = new Date().toISOString();
149
+ const source = await buildRunSource({
150
+ capturedAt: createdAt,
151
+ cwd: physicalCwd,
152
+ humanishSource: "present",
153
+ packageName: "humanish"
154
+ });
155
+ let sessionResults = [];
156
+ let sessionError;
157
+ const stateStepRecords = [];
158
+ let subjectCommit;
159
+ let subjectSandboxId;
160
+ let subjectKilled = false;
161
+ let hostDigest;
162
+ if (!dryRun) {
163
+ let subjectModule;
164
+ let subjectDesktop;
165
+ try {
166
+ if (provisionedRoute) {
167
+ const requestTimeoutMs = readPositiveInt(env.HUMANISH_E2B_REQUEST_TIMEOUT_MS, 60_000);
168
+ const timers = hooks.detachedTimers ?? {};
169
+ subjectModule = await (hooks.loadDesktopModule ?? loadE2BDesktopModule)();
170
+ await validatePreparedRunArtifactPaths(runPaths);
171
+ subjectDesktop = await createDesktopSandbox(subjectModule, {
172
+ apiKey: e2bApiKey,
173
+ requestTimeoutMs,
174
+ timeoutMs: timeoutMs + SUBJECT_PROVISION_BUDGET_MS
175
+ + (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0)
176
+ + SANDBOX_TIMEOUT_BUFFER_MS,
177
+ metadata: {
178
+ mode: "scripted-browser-lab",
179
+ tool: "humanish",
180
+ labId: config.id,
181
+ role: "subject",
182
+ actor: descriptor.id
183
+ },
184
+ ...(subjectEnvNames.length > 0
185
+ ? { envs: Object.fromEntries(subjectEnvNames.map((name) => [name, env[name]])) }
186
+ : {}),
187
+ dpi: 96,
188
+ lifecycle: { onTimeout: "kill" }
189
+ }, config.execution?.desktop?.template);
190
+ subjectSandboxId = subjectDesktop.sandboxId;
191
+ if (hooks.prepareDesktop) {
192
+ await hooks.prepareDesktop(subjectDesktop);
193
+ await validatePreparedRunArtifactPaths(runPaths);
194
+ }
195
+ subjectCommit = await provisionCloneSubject(subjectDesktop, {
196
+ repo: subjectRepo,
197
+ depth: config.subject.clone?.depth ?? 1,
198
+ serve: serve,
199
+ ...(config.subject.state === undefined ? {} : { state: config.subject.state }),
200
+ hasGithubToken,
201
+ requestTimeoutMs,
202
+ scrub: scrubKnownValues,
203
+ onCommit: (commit) => {
204
+ subjectCommit = commit;
205
+ },
206
+ onStateStep: (record) => {
207
+ stateStepRecords.push(record);
208
+ },
209
+ ...timers
210
+ });
211
+ if (typeof subjectDesktop.getHost !== "function") {
212
+ throw new Error("the installed @e2b/desktop SDK does not expose getHost(port); clone scripted-browser labs require it to reach the provisioned subject");
213
+ }
214
+ const rawHost = subjectDesktop.getHost(servePort(serve.url));
215
+ const hostUrl = /^https?:\/\//i.test(rawHost) ? rawHost : `https://${rawHost}`;
216
+ if (!isTokenlessHost(hostUrl)) {
217
+ throw new Error("getHost returned a non-tokenless URL; refusing to persist or drive a host URL that may carry a credential");
218
+ }
219
+ appUrl = hostUrl;
220
+ hostDigest = hostOriginDigest(hostUrl);
221
+ }
222
+ // One session per surface, in parallel — parity with `run --app-url`.
223
+ sessionResults = await Promise.all(surfaces.map((surface) => {
224
+ const sessionOptions = {
225
+ appUrl,
226
+ evidenceAppUrl,
227
+ urlPolicy,
228
+ journey,
229
+ surface,
230
+ persona,
231
+ timeoutMs,
232
+ artifactRoot,
233
+ ...(browserCommand === undefined ? {} : { browserCommand }),
234
+ ...(hooks.launchBrowser === undefined ? {} : { launchBrowser: hooks.launchBrowser }),
235
+ ...(hooks.now === undefined ? {} : { now: hooks.now })
236
+ };
237
+ return runSession
238
+ ? runSession(sessionOptions).then(async (result) => {
239
+ await validatePreparedRunArtifactPaths(runPaths);
240
+ validateScriptedSessionResult(surface, result);
241
+ return result;
242
+ })
243
+ : runScriptedBrowserSessionInPreparedRoot(sessionOptions, runPaths);
244
+ }));
245
+ }
246
+ catch (error) {
247
+ if (error instanceof UnsafeScriptedSessionResultError) {
248
+ throw error;
249
+ }
250
+ // The session itself maps launch failures to harness_error; reaching here means the
251
+ // harness around it failed. Redacted at this boundary before persisting anywhere.
252
+ sessionError = redactText(scrubKnownValues(toErrorMessage(error)));
253
+ }
254
+ finally {
255
+ if (subjectDesktop && subjectModule) {
256
+ if (typeof subjectModule.Sandbox.kill === "function") {
257
+ try {
258
+ await subjectModule.Sandbox.kill(subjectDesktop.sandboxId, { requestTimeoutMs: 60_000 });
259
+ subjectKilled = true;
260
+ }
261
+ catch (error) {
262
+ warnings.push(`Subject sandbox teardown failed (server-side kill-on-timeout will reclaim it): ${redactText(scrubKnownValues(toErrorMessage(error)))}`);
263
+ }
264
+ }
265
+ else {
266
+ warnings.push("Installed @e2b/desktop SDK does not expose Sandbox.kill; server-side kill-on-timeout will reclaim the subject sandbox.");
267
+ }
268
+ }
269
+ }
270
+ for (const result of sessionResults) {
271
+ // The backend writes the provider-neutral projection next to the session's native
272
+ // traces/<surface>.json (cua's actor.json convention, pluralized per surface).
273
+ await writeContainedOutputFile(runPaths, `actor-${result.capture.surface.id}.json`, `${JSON.stringify(result.trace, null, 2)}\n`, "utf8");
274
+ }
275
+ }
276
+ const screenshotsBySurface = new Map();
277
+ for (const result of sessionResults) {
278
+ screenshotsBySurface.set(result.capture.surface.id, await existingScreenshots(runPaths, result));
279
+ }
280
+ const subject = provisionedRoute
281
+ ? {
282
+ source: "clone",
283
+ repo: publicRepo,
284
+ ...(subjectCommit === undefined ? {} : { commit: subjectCommit }),
285
+ envNames: subjectEnvNames,
286
+ state: resolveSubjectState({ declared: config.subject.state, dryRun, executed: stateStepRecords })
287
+ }
288
+ : undefined;
289
+ const bundle = buildScriptedLabBundle({
290
+ actorId: descriptor.id,
291
+ appUrl: evidenceAppUrl,
292
+ createdAt,
293
+ dryRun,
294
+ journey,
295
+ labId: config.id,
296
+ ...(config.title ? { labTitle: config.title } : {}),
297
+ persona,
298
+ runId,
299
+ scenarioSource: scenario.source,
300
+ scenarioSourceDigest: scenario.sourceDigest,
301
+ screenshotsBySurface,
302
+ sessionResults,
303
+ ...(sessionError === undefined ? {} : { sessionError }),
304
+ source,
305
+ surfaces,
306
+ ...(subject === undefined ? {} : { subject }),
307
+ ...(config.execution?.desktop?.template === undefined ? {} : { desktopTemplate: config.execution.desktop.template }),
308
+ ...(hostDigest === undefined ? {} : { hostDigest })
309
+ });
310
+ await writeContainedOutputFile(runPaths, "run.json", `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
311
+ await writeContainedOutputFile(runPaths, "review.json", `${JSON.stringify(bundle.review, null, 2)}\n`, "utf8");
312
+ await writeContainedOutputFile(runPaths, "review.md", renderScriptedReviewMarkdown(bundle), "utf8");
313
+ await writeContainedOutputFile(runPaths, "events.ndjson", `${bundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8");
314
+ // Keep `verify --run latest` honest: point it at THIS run (mirrors run.ts's RunPointer).
315
+ await writePreparedRunLatestPointer(runPaths, `${JSON.stringify({
316
+ schema: "humanish.latest-run.v1",
317
+ runId,
318
+ path: runPaths.relativeRunRoot,
319
+ updatedAt: createdAt
320
+ }, null, 2)}\n`, "utf8");
321
+ // Surface the local-fidelity posture so the operator knows the bundle is not publish-safe as-is.
322
+ if (sessionResults.some((result) => result.trace.redaction.screenshots === "raw")) {
323
+ 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. policies.redactScreenshots is not yet supported on the scripted route.");
324
+ }
325
+ const observer = await render(physicalCwd, runId, { open: options.open === true });
326
+ await validatePreparedRunArtifactPaths(runPaths);
327
+ const harnessError = sessionResults.some((result) => result.completionReason === "harness_error");
328
+ const ok = observer.ok
329
+ && sessionError === undefined
330
+ && (dryRun || (sessionResults.length === surfaces.length && !harnessError));
331
+ return {
332
+ schema: SCRIPTED_BROWSER_LAB_SCHEMA,
333
+ ok,
334
+ cwd,
335
+ labId: config.id,
336
+ actor: descriptor.id,
337
+ appUrl: evidenceAppUrl,
338
+ dryRun,
339
+ runId,
340
+ ...(subject === undefined ? {} : { subject }),
341
+ ...(subjectSandboxId === undefined ? {} : { subjectSandbox: { sandboxId: subjectSandboxId, killed: subjectKilled } }),
342
+ ...(hostDigest === undefined ? {} : { hostDigest }),
343
+ scenario: {
344
+ id: journey.scenarioId,
345
+ source: scenario.source,
346
+ sourceDigest: scenario.sourceDigest,
347
+ steps: journey.steps.length
348
+ },
349
+ sessions: sessionResults.map((result) => ({
350
+ surface: result.capture.surface.id,
351
+ status: result.status,
352
+ completionReason: result.completionReason,
353
+ reason: result.reason,
354
+ screenshots: screenshotsBySurface.get(result.capture.surface.id)?.length ?? 0
355
+ })),
356
+ observer,
357
+ warnings: [...warnings, ...observer.warnings],
358
+ ...(ok
359
+ ? {}
360
+ : {
361
+ error: {
362
+ code: "HUMANISH_SCRIPTED_LAB_FAILED",
363
+ message: sessionError
364
+ ?? (observer.ok
365
+ ? harnessError
366
+ ? `Scripted session ended with a harness error: ${sessionResults.find((result) => result.completionReason === "harness_error")?.reason ?? "unknown"}`
367
+ : "Scripted lab did not produce terminal sessions for every surface."
368
+ : observer.error?.message ?? "Observer failed for the scripted lab run.")
369
+ }
370
+ })
371
+ };
372
+ }
373
+ /**
374
+ * Resolve and consume `scenario.ref`. Path-style refs (contain a separator or end .yaml/.yml)
375
+ * resolve against cwd and are CLAMPED inside it — a ../../ escape is rejected, never recorded
376
+ * as repo-relative provenance. Id-style refs must be public-safe tokens and resolve to
377
+ * humanish/scenarios/<ref>.yaml (then .yml). Every failure mode is fail-closed.
378
+ */
379
+ async function resolveScriptedScenario(projectRoot, ref) {
380
+ if (!ref || !ref.trim()) {
381
+ return {
382
+ ok: false,
383
+ message: "scripted-browser labs require `scenario.ref` — the committed scenario's browser steps are what this actor executes."
384
+ };
385
+ }
386
+ const trimmed = ref.trim();
387
+ let absolutePath;
388
+ let source;
389
+ if (scenarioRefLooksLikePath(trimmed)) {
390
+ absolutePath = path.resolve(projectRoot.physicalPath, trimmed);
391
+ const relative = path.relative(projectRoot.physicalPath, absolutePath);
392
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
393
+ return {
394
+ ok: false,
395
+ message: `scenario.ref path must stay inside the target cwd (got "${trimmed}") — provenance is recorded repo-relative and an escaping path cannot be.`
396
+ };
397
+ }
398
+ source = relative.split(path.sep).join("/");
399
+ }
400
+ else {
401
+ if (!SCENARIO_REF_ID_PATTERN.test(trimmed)) {
402
+ return {
403
+ ok: false,
404
+ message: `scenario.ref must be a public-safe scenario id or a .yaml path inside the repo (got "${trimmed}").`
405
+ };
406
+ }
407
+ const candidates = [
408
+ path.posix.join("humanish", "scenarios", `${trimmed}.yaml`),
409
+ path.posix.join("humanish", "scenarios", `${trimmed}.yml`)
410
+ ];
411
+ const found = await firstExistingFile(projectRoot, candidates);
412
+ if (!found) {
413
+ return {
414
+ ok: false,
415
+ message: `scenario.ref "${trimmed}" was not found (looked for ${candidates.join(", ")}).`
416
+ };
417
+ }
418
+ source = found;
419
+ absolutePath = path.join(projectRoot.physicalPath, found);
420
+ }
421
+ const relativeScenarioPath = path.relative(projectRoot.physicalPath, absolutePath);
422
+ const scenarioBytes = await readContainedRegularFile(projectRoot, relativeScenarioPath);
423
+ if (!scenarioBytes) {
424
+ return { ok: false, message: `scenario.ref "${trimmed}" could not be read (${source}).` };
425
+ }
426
+ const text = scenarioBytes.toString("utf8");
427
+ let raw;
428
+ try {
429
+ raw = parseYaml(text);
430
+ }
431
+ catch {
432
+ return { ok: false, message: `${source} could not be parsed as YAML; the scripted scenario failed closed.` };
433
+ }
434
+ const sourceDigest = digestText(text);
435
+ const parsed = parseBrowserPersonaJourneyFromScenario({ raw, relativePath: source, sourceDigest });
436
+ if (parsed.failure) {
437
+ return { ok: false, message: parsed.failure };
438
+ }
439
+ if (!parsed.journey) {
440
+ return {
441
+ ok: false,
442
+ message: `${source} declares no executable browser steps — the scripted-browser actor needs a scenario with browser.steps (there is no built-in fallback on the lab route).`
443
+ };
444
+ }
445
+ return { ok: true, journey: parsed.journey, source, sourceDigest };
446
+ }
447
+ function scenarioRefLooksLikePath(ref) {
448
+ return ref.endsWith(".yaml")
449
+ || ref.endsWith(".yml")
450
+ || ref.includes("/")
451
+ || ref.includes("\\")
452
+ || ref.startsWith(".");
453
+ }
454
+ async function firstExistingFile(projectRoot, candidates) {
455
+ for (const candidate of candidates) {
456
+ if (await readContainedRegularFile(projectRoot, candidate) !== null) {
457
+ return candidate;
458
+ }
459
+ }
460
+ return null;
461
+ }
462
+ async function existingScreenshots(runPaths, result) {
463
+ const existing = [];
464
+ for (const step of result.capture.steps) {
465
+ // Blocked steps whose evidence is the failure itself recorded no screenshot path.
466
+ if (!step.screenshotPath) {
467
+ continue;
468
+ }
469
+ const screenshot = await readContainedRegularFile(runPaths, step.screenshotPath);
470
+ if (screenshot && screenshot.byteLength > 0) {
471
+ existing.push(step.screenshotPath);
472
+ }
473
+ }
474
+ return existing;
475
+ }
476
+ function validateScriptedSessionResult(expectedSurface, result) {
477
+ if (result.capture.surface.id !== expectedSurface.id || !isSafeOutputSegment(result.capture.surface.id)) {
478
+ throw new UnsafeScriptedSessionResultError("Scripted session returned an unexpected or unsafe surface id.");
479
+ }
480
+ const paths = [
481
+ result.capture.tracePath,
482
+ ...(result.capture.screenshotPath ? [result.capture.screenshotPath] : []),
483
+ ...result.capture.steps.flatMap((step) => step.screenshotPath ? [step.screenshotPath] : [])
484
+ ];
485
+ if (!paths.every(isSafeRelativeArtifactPath)) {
486
+ throw new UnsafeScriptedSessionResultError("Scripted session returned an unsafe artifact path.");
487
+ }
488
+ }
489
+ function isSafeOutputSegment(value) {
490
+ return value.length > 0
491
+ && value !== "."
492
+ && value !== ".."
493
+ && !value.includes("/")
494
+ && !value.includes("\\")
495
+ && !value.includes("\0");
496
+ }
497
+ function isSafeRelativeArtifactPath(value) {
498
+ if (!value || value.includes("\0") || path.isAbsolute(value) || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) {
499
+ return false;
500
+ }
501
+ return !value.replace(/\\/g, "/").split("/").some((part) => !part || part === "." || part === "..");
502
+ }
503
+ /**
504
+ * Project the scripted lab run into a humanish.run-bundle.v1 (no schema change — a new
505
+ * producer only). The load-bearing line is `stream.actor = result.trace`: the provider-neutral
506
+ * ActorTrace seam the Observer renders and verifyRun's engagement check reads. Exported for
507
+ * the bundle-builder tests.
508
+ */
509
+ export function buildScriptedLabBundle(args) {
510
+ const resultBySurface = new Map(args.sessionResults.map((result) => [result.capture.surface.id, result]));
511
+ const simulations = [];
512
+ const streams = [];
513
+ args.surfaces.forEach((surface, index) => {
514
+ const simId = `scripted-${surface.id}`;
515
+ const streamId = `${simId}-stream`;
516
+ const result = resultBySurface.get(surface.id);
517
+ const screenshots = args.screenshotsBySurface.get(surface.id) ?? [];
518
+ const lastScreenshot = screenshots.at(-1);
519
+ const status = result
520
+ ? result.status
521
+ : args.sessionError
522
+ ? "failed"
523
+ : "contract_proof_only";
524
+ const reason = result?.reason
525
+ ?? args.sessionError
526
+ ?? "Contract bundle only: dry-run pinned the scenario contract without launching a browser or touching the subject app.";
527
+ simulations.push({
528
+ id: simId,
529
+ index: index + 1,
530
+ personaId: args.persona.id,
531
+ scenarioId: args.journey.scenarioId,
532
+ status,
533
+ streamKind: "browser",
534
+ mode: "browser-sim",
535
+ progress: 100,
536
+ currentStep: reason,
537
+ summary: result
538
+ ? `Scripted-browser actor (${args.actorId}) replayed ${args.journey.scenarioId} on the ${surface.id} surface; ${result.completionReason}.`
539
+ : args.sessionError
540
+ ? `Scripted lab failed before a terminal session verdict: ${args.sessionError}`
541
+ : `Contract lane for the scripted-browser actor (${args.actorId}) against ${args.appUrl}.`,
542
+ streamIds: [streamId],
543
+ startedAt: args.createdAt,
544
+ updatedAt: result?.capture.capturedAt ?? args.createdAt
545
+ });
546
+ streams.push({
547
+ id: streamId,
548
+ simId,
549
+ kind: "browser",
550
+ label: `${surface.label} — ${args.labId}`,
551
+ status,
552
+ transport: "snapshot",
553
+ updatedAt: result?.capture.capturedAt ?? args.createdAt,
554
+ embed: lastScreenshot
555
+ ? { kind: "screenshot", url: `../${lastScreenshot}`, title: `${surface.label} (raw)` }
556
+ : { kind: "placeholder", title: surface.label },
557
+ // REAL emulated viewport: isMobile/deviceScaleFactor genuinely render on this route
558
+ // (playwright emulation), unlike the e2b-desktop route's prompt-signal-only fidelity.
559
+ viewport: surface.viewport,
560
+ ui: {
561
+ route: args.appUrl,
562
+ intent: args.journey.goal,
563
+ state: reason,
564
+ ...(result ? { actorStatus: result.status } : {}),
565
+ ...(lastScreenshot ? { screenshotUrl: `../${lastScreenshot}` } : {})
566
+ },
567
+ // The seam this registration exists to fill: the provider-neutral actor evidence.
568
+ ...(result ? { actor: result.trace } : {}),
569
+ artifacts: [
570
+ { label: "run bundle", path: "run.json", kind: "bundle" },
571
+ { label: "review", path: "review.md", kind: "review" },
572
+ { label: "events", path: "events.ndjson", kind: "events" },
573
+ ...(result
574
+ ? [
575
+ { label: `${surface.id} browser trace`, path: result.capture.tracePath, kind: "trace" },
576
+ { label: `${surface.id} actor trace`, path: `actor-${surface.id}.json`, kind: "trace" }
577
+ ]
578
+ : []),
579
+ ...screenshots.map((screenshot, screenshotIndex) => ({
580
+ label: `${surface.id} screenshot ${String(screenshotIndex + 1).padStart(2, "0")} (raw)`,
581
+ path: screenshot,
582
+ kind: "screenshot"
583
+ }))
584
+ ]
585
+ });
586
+ });
587
+ const events = [
588
+ {
589
+ id: "event-000-created",
590
+ at: args.createdAt,
591
+ level: "info",
592
+ type: "scripted-lab.run.created",
593
+ message: `Created scripted-browser lab run for ${args.labId} (actor ${args.actorId}, ${args.surfaces.length} surface${args.surfaces.length === 1 ? "" : "s"}).`
594
+ },
595
+ {
596
+ id: "event-001-subject",
597
+ at: args.createdAt,
598
+ level: "info",
599
+ type: "scripted-lab.subject.declared",
600
+ // Invariant 5: provenance recorded or its absence DECLARED. The lab did not provision
601
+ // this subject on app-url routes; clone routes carry structured subject provenance below.
602
+ message: args.subject
603
+ ? `Provisioned synthetic subject: clone of ${args.subject.repo}${args.subject.commit ? `@${args.subject.commit}` : ""}, served + getHost-exposed in-sandbox; env names: ${args.subject.envNames?.join(", ") || "none"} (values never persisted); state provenance: ${args.subject.state.provenance}; evidence host digest: ${args.hostDigest ?? "dry-run"}.`
604
+ : `Subject app declared at ${args.appUrl}; the lab did not provision it — subject build/commit provenance is UNPINNED; evidence binds to the scenario digest ${args.scenarioSourceDigest}.`
605
+ },
606
+ {
607
+ id: "event-002-spend",
608
+ at: args.createdAt,
609
+ level: "info",
610
+ type: "scripted-lab.spend",
611
+ message: args.subject
612
+ ? "No model spend by construction; live provisioned scripted runs may spend E2B sandbox minutes to clone/serve the synthetic subject, then drive deterministic browser steps."
613
+ : "$0 provider spend by construction (no model and no sandbox in the loop); scenario.mode: live gates real browser actuation against the declared app, not cost."
614
+ }
615
+ ];
616
+ if (args.sessionResults.length > 0) {
617
+ for (const result of args.sessionResults) {
618
+ events.push({
619
+ id: `event-${String(events.length).padStart(3, "0")}-session-${result.capture.surface.id}`,
620
+ at: result.capture.capturedAt,
621
+ level: result.status === "passed" ? "info" : "warn",
622
+ type: `scripted-lab.session.${result.completionReason}`,
623
+ message: `${result.capture.surface.id}: ${result.status} — ${result.reason}`,
624
+ simId: `scripted-${result.capture.surface.id}`,
625
+ streamId: `scripted-${result.capture.surface.id}-stream`
626
+ });
627
+ }
628
+ }
629
+ else if (args.sessionError) {
630
+ events.push({
631
+ id: "event-003-session-error",
632
+ at: args.createdAt,
633
+ level: "error",
634
+ type: "scripted-lab.session.error",
635
+ message: args.sessionError
636
+ });
637
+ }
638
+ else {
639
+ events.push({
640
+ id: "event-003-contract",
641
+ at: args.createdAt,
642
+ level: "info",
643
+ type: "scripted-lab.contract.ready",
644
+ message: `Dry-run contract bundle ready: scenario ${args.journey.scenarioId} @ ${args.scenarioSourceDigest} (${args.scenarioSource}, ${args.journey.steps.length} step${args.journey.steps.length === 1 ? "" : "s"}) parsed and digest-pinned; switch scenario.mode to live to actuate a real browser.`
645
+ });
646
+ }
647
+ const review = buildScriptedReview(args);
648
+ const ranLive = args.sessionResults.length > 0 || args.sessionError !== undefined;
649
+ return {
650
+ schema: RUN_BUNDLE_SCHEMA,
651
+ runId: args.runId,
652
+ mode: args.dryRun ? "dry-run" : "live",
653
+ simCount: args.surfaces.length,
654
+ createdAt: args.createdAt,
655
+ cwd: PUBLIC_TARGET_CWD,
656
+ artifactRoot: path.join(".humanish", "runs", args.runId),
657
+ source: args.source,
658
+ persona: {
659
+ id: args.persona.id,
660
+ name: `Scripted journey persona (${args.persona.id})`,
661
+ source: `lab:${args.labId}`,
662
+ sourceDigest: args.persona.promptDigest
663
+ },
664
+ scenario: {
665
+ id: args.journey.scenarioId,
666
+ title: args.journey.scenarioTitle,
667
+ goal: args.journey.goal,
668
+ source: args.scenarioSource,
669
+ sourceDigest: args.scenarioSourceDigest
670
+ },
671
+ lifecycle: [
672
+ {
673
+ at: args.createdAt,
674
+ event: "scripted-lab.run.created",
675
+ message: `Created scripted-browser lab run with ${args.surfaces.length} surface lane${args.surfaces.length === 1 ? "" : "s"} (actor ${args.actorId}).`
676
+ }
677
+ ],
678
+ simulations,
679
+ streams,
680
+ events,
681
+ redaction: {
682
+ status: "passed",
683
+ notes: ranLive
684
+ ? "Scripted step URLs are sanitized to loopback origin+path (query/hash redacted) and step text passes text redaction. Screenshots are FULL-FIDELITY (raw), retained for local use in gitignored .humanish — NOT redacted for publishing; policies.redactScreenshots is not yet supported on this route."
685
+ : "Dry-run contract bundle: no browser ran and no screenshots were captured. The scenario contract is digest-pinned; live step text passes text redaction when a session runs."
686
+ },
687
+ artifacts: {
688
+ run: "run.json",
689
+ reviewJson: "review.json",
690
+ reviewMarkdown: "review.md",
691
+ observerData: "observer/observer-data.json",
692
+ events: "events.ndjson"
693
+ },
694
+ review,
695
+ feedbackCandidates: [],
696
+ ...(args.subject === undefined ? {} : { subject: args.subject }),
697
+ ...(args.desktopTemplate === undefined ? {} : { desktopTemplate: args.desktopTemplate })
698
+ };
699
+ }
700
+ function buildScriptedReview(args) {
701
+ if (args.sessionError) {
702
+ return {
703
+ schema: REVIEW_SCHEMA,
704
+ verdict: "fail",
705
+ summary: `Scripted lab failed before a terminal session verdict: ${args.sessionError}`,
706
+ gaps: []
707
+ };
708
+ }
709
+ if (args.sessionResults.length === 0) {
710
+ return {
711
+ schema: REVIEW_SCHEMA,
712
+ verdict: "contract_proof_only",
713
+ summary: `Dry-run contract for scenario ${args.journey.scenarioId} (${args.scenarioSource}, ${args.journey.steps.length} steps) against ${args.appUrl}: composition and scenario contract proven at $0; no browser ran.`,
714
+ gaps: ["Live scripted session not yet run (dry-run contract only)."]
715
+ };
716
+ }
717
+ // Worst-of across surfaces: harness/step failures outrank a timeout outranks a pass.
718
+ const reasons = args.sessionResults.map((result) => result.completionReason);
719
+ const verdict = reasons.some((reason) => reason === "harness_error" || reason === "step_failed")
720
+ ? "fail"
721
+ : reasons.some((reason) => reason === "timed_out")
722
+ ? "timed_out"
723
+ : "pass";
724
+ const passed = args.sessionResults.filter((result) => result.status === "passed").length;
725
+ return {
726
+ schema: REVIEW_SCHEMA,
727
+ verdict,
728
+ summary: `Scripted-browser actor replayed ${args.journey.scenarioId} on ${args.sessionResults.length} surface${args.sessionResults.length === 1 ? "" : "s"} against ${args.appUrl}: ${passed}/${args.sessionResults.length} satisfied the scenario predicate.`,
729
+ gaps: args.sessionResults
730
+ .filter((result) => result.status !== "passed")
731
+ .map((result) => `${result.capture.surface.id}: ${result.reason}`)
732
+ };
733
+ }
734
+ function renderScriptedReviewMarkdown(bundle) {
735
+ const subject = bundle.events.find((event) => event.type === "scripted-lab.subject.declared");
736
+ const spend = bundle.events.find((event) => event.type === "scripted-lab.spend");
737
+ const traces = bundle.streams
738
+ .map((stream) => ({ stream, trace: stream.actor }))
739
+ .filter((entry) => entry.trace !== undefined);
740
+ return [
741
+ `# ${bundle.scenario.title}`,
742
+ "",
743
+ `- run: ${bundle.runId}`,
744
+ `- mode: ${bundle.mode}`,
745
+ `- verdict: ${bundle.review.verdict}`,
746
+ `- summary: ${bundle.review.summary}`,
747
+ `- scenario: ${bundle.scenario.id} @ ${bundle.scenario.sourceDigest} (${bundle.scenario.source})`,
748
+ ...(subject ? [`- subject: ${subject.message}`] : []),
749
+ ...(spend ? [`- spend: ${spend.message}`] : []),
750
+ ...traces.map(({ stream, trace }) => `- ${stream.simId}: ${trace.provider} (${trace.lane}/${trace.protocol}) ${trace.status} (${trace.completionReason}); ${trace.counts.actions ?? 0} step action(s), ${trace.counts.screenshots ?? 0} raw screenshot(s)`),
751
+ ...(bundle.review.gaps.length > 0 ? ["", "## Gaps", ...bundle.review.gaps.map((gap) => `- ${gap}`)] : []),
752
+ ""
753
+ ].join("\n");
754
+ }
755
+ function makeScriptedRunId() {
756
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
757
+ return `scripted-${stamp}-${randomBytes(4).toString("hex")}`;
758
+ }
759
+ function readPositiveInt(value, fallback) {
760
+ if (value === undefined)
761
+ return fallback;
762
+ const parsed = Number.parseInt(value, 10);
763
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
764
+ }
765
+ function servePort(serveUrl) {
766
+ const url = new URL(serveUrl);
767
+ if (url.port)
768
+ return Number(url.port);
769
+ return url.protocol === "https:" ? 443 : 80;
770
+ }
771
+ function isTokenlessHost(value) {
772
+ try {
773
+ const url = new URL(value);
774
+ return url.username === "" && url.password === "" && url.search === "";
775
+ }
776
+ catch {
777
+ return false;
778
+ }
779
+ }
780
+ function hostOriginDigest(url) {
781
+ try {
782
+ return commandDigestOf(new URL(url).origin);
783
+ }
784
+ catch {
785
+ return commandDigestOf(url);
786
+ }
787
+ }
788
+ //# sourceMappingURL=scripted-browser-lab.js.map