humanish 0.0.1 → 0.15.0

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