humanish 0.40.0 → 0.42.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 (56) hide show
  1. package/README.md +36 -8
  2. package/dist/actor-contract.d.ts +9 -0
  3. package/dist/actor-contract.js.map +1 -1
  4. package/dist/adapter-extension.js +1 -0
  5. package/dist/adapter-extension.js.map +1 -1
  6. package/dist/computer-use-actor.d.ts +11 -0
  7. package/dist/computer-use-actor.js +2 -0
  8. package/dist/computer-use-actor.js.map +1 -1
  9. package/dist/computer-use.d.ts +30 -0
  10. package/dist/computer-use.js +65 -3
  11. package/dist/computer-use.js.map +1 -1
  12. package/dist/concurrent-shared-world-lab.js +72 -42
  13. package/dist/concurrent-shared-world-lab.js.map +1 -1
  14. package/dist/cua-actor-lab.d.ts +64 -2
  15. package/dist/cua-actor-lab.js +327 -28
  16. package/dist/cua-actor-lab.js.map +1 -1
  17. package/dist/e2b-desktop-executor.d.ts +1 -1
  18. package/dist/e2b-desktop-executor.js +2 -1
  19. package/dist/e2b-desktop-executor.js.map +1 -1
  20. package/dist/e2b-terminal-lab.js +1 -0
  21. package/dist/e2b-terminal-lab.js.map +1 -1
  22. package/dist/feedback.js +55 -1
  23. package/dist/feedback.js.map +1 -1
  24. package/dist/image-evidence.js +36 -22
  25. package/dist/image-evidence.js.map +1 -1
  26. package/dist/init-templates.js +7 -2
  27. package/dist/init-templates.js.map +1 -1
  28. package/dist/lab-config.d.ts +26 -2
  29. package/dist/lab-config.js +49 -2
  30. package/dist/lab-config.js.map +1 -1
  31. package/dist/observer-assets.js +11 -1
  32. package/dist/observer-assets.js.map +1 -1
  33. package/dist/observer-data.d.ts +16 -0
  34. package/dist/observer-data.js +14 -1
  35. package/dist/observer-data.js.map +1 -1
  36. package/dist/program.js +5 -5
  37. package/dist/program.js.map +1 -1
  38. package/dist/redaction.js +4 -9
  39. package/dist/redaction.js.map +1 -1
  40. package/dist/run.d.ts +88 -2
  41. package/dist/run.js +92 -3
  42. package/dist/run.js.map +1 -1
  43. package/dist/screenshot-image.d.ts +11 -0
  44. package/dist/screenshot-image.js +26 -0
  45. package/dist/screenshot-image.js.map +1 -0
  46. package/dist/scripted-browser-lab.js +4 -2
  47. package/dist/scripted-browser-lab.js.map +1 -1
  48. package/dist/shared-world-lab.js +17 -3
  49. package/dist/shared-world-lab.js.map +1 -1
  50. package/dist/tasks.d.ts +77 -0
  51. package/dist/tasks.js +101 -0
  52. package/dist/tasks.js.map +1 -0
  53. package/docs/contracts/schemas.md +1 -1
  54. package/docs/goals/current.md +16 -3
  55. package/docs/ramp/README.md +1 -1
  56. package/package.json +1 -1
@@ -31,7 +31,7 @@ import { CHROMIUM_EVIDENCE_HYGIENE_FLAGS, chromiumEvidenceProfilePreferencesJson
31
31
  import { DEFAULT_OPENAI_CU_MODEL } from "./openai-responses-cu.js";
32
32
  import { createDesktopSandbox, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
33
33
  import { probeUrl, readDetachedLog, runDetachedStep, startDetachedProcess } from "./e2b-detached.js";
34
- import { DEFAULT_SANDBOX_CATCH_PORT, collectCommsThread, deployCommsCatch, refreshInboxSurface, writeInboxSurface } from "./comms-sandbox-catch.js";
34
+ import { DEFAULT_SANDBOX_CATCH_PORT, collectCommsThread, collectExternalCommsThread, deployCommsCatch, externalCatchHealthy, externalInboxUrl, refreshInboxSurface, writeInboxSurface } from "./comms-sandbox-catch.js";
35
35
  import { FakeInbox } from "./comms-fake-inbox.js";
36
36
  import { buildOriginMap } from "./comms-inbox.js";
37
37
  import { DEFAULT_DEVICE_PRESET, isDevicePresetName, resolveDevicePreset } from "./device-presets.js";
@@ -43,12 +43,13 @@ import { buildObserverData } from "./observer-data.js";
43
43
  import { corepackCommandFor, needsNodeRuntime, nodeBootstrapCommand } from "./subject-runtime.js";
44
44
  import { personaToDirectives, renderPersonaPromptSection } from "./persona.js";
45
45
  import { labPersonaIds, resolveCommittedPersonas } from "./persona-resolve.js";
46
+ import { renderTaskPrompt } from "./tasks.js";
46
47
  import { attachObserverRuntimeStreamUrls, renderObserver } from "./observer.js";
47
48
  import { containsSensitive, digestText, redactedTail, redactText } from "./redaction.js";
48
49
  import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, prepareContainedOutputDirectory, prepareSelectedOutputDirectory, writeContainedOutputFile, writePreparedRunLatestPointer } from "./selected-output-paths.js";
49
50
  import { prepareRunArtifactPaths, validatePreparedRunArtifactPaths } from "./run-paths.js";
50
51
  import { createLocalTreeArchive } from "./source-archive.js";
51
- import { buildRunSource, loadRunBundle, PUBLIC_TARGET_CWD, REVIEW_SCHEMA, RUN_BUNDLE_SCHEMA } from "./run.js";
52
+ import { buildRunSource, loadRunBundle, PUBLIC_TARGET_CWD, REVIEW_SCHEMA, RUN_BUNDLE_SCHEMA, aggregateTaskFunnels, formatParticipantOutcomes, formatStudyTaskFunnel, tallyParticipantOutcomes } from "./run.js";
52
53
  import { estimateActorCost, estimateDesktopCost, MODEL_RATES, round6 } from "./pricing.js";
53
54
  export const CUA_ACTOR_LAB_SCHEMA = "humanish.cua-lab-result.v2";
54
55
  // The only fan-out topology this slice ships: N lanes = N independent E2B desktop sandboxes,
@@ -61,7 +62,28 @@ export const CUA_ACTOR_LAB_PROVIDER_METADATA = {
61
62
  mode: "cua-actor-lab",
62
63
  tool: "humanish"
63
64
  };
64
- const DEFAULT_SESSION_TIMEOUT_MS = 300_000;
65
+ // The DEFAULT session budget, sized so a study can FINISH (docs/principles/three-roles.md: a
66
+ // session ends because the participant is done, not because a timer fired — the time-box is a
67
+ // session-level cap a researcher sets generously; spend protection is the dollar caps' job).
68
+ // The old 300s default ended real signup studies mid-flow: observed studies run 16-40 turns at
69
+ // ~5-6s per turn BEFORE any email wait, so five minutes was the biggest single source of
70
+ // budget_reached endings that read as participant failures.
71
+ //
72
+ // App-url and in-process routes default to 30 minutes. Provisioned routes (clone/local-tree)
73
+ // default to whatever the 1-hour sandbox cap leaves after provisioning, declared state seeding,
74
+ // and the teardown buffer — 20 minutes on a stateless clone — floored at the old five minutes so
75
+ // a state-heavy lab still gets a session at all. An EXPLICIT execution.timeoutMs is never
76
+ // adjusted: when it cannot be provisioned, the plan-time cap refusal shows the arithmetic.
77
+ const DEFAULT_APP_URL_SESSION_TIMEOUT_MS = 30 * 60_000;
78
+ const MIN_DERIVED_SESSION_TIMEOUT_MS = 5 * 60_000;
79
+ function defaultSessionTimeoutMs(config) {
80
+ const provisionedRoute = config.subject.source === "clone" || config.subject.source === "local-tree";
81
+ if (!provisionedRoute)
82
+ return DEFAULT_APP_URL_SESSION_TIMEOUT_MS;
83
+ const stateBudgetMs = (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0);
84
+ const room = MAX_SANDBOX_MS - SUBJECT_PROVISION_BUDGET_MS - stateBudgetMs - SANDBOX_TIMEOUT_BUFFER_MS;
85
+ return Math.max(MIN_DERIVED_SESSION_TIMEOUT_MS, Math.min(DEFAULT_APP_URL_SESSION_TIMEOUT_MS, room));
86
+ }
65
87
  // Settle after opening the browser, before the first screenshot — long enough for a cold
66
88
  // browser + page load to paint (2s captured a blank desktop; the render empirically needs ~6-9s).
67
89
  const BROWSER_SETTLE_MS = 8_000;
@@ -101,6 +123,10 @@ export function composeLaneInstructions(args) {
101
123
  const deviceLine = preset.isMobile
102
124
  ? `You are a mobile user on a ${name} device (${preset.width}x${preset.height} @${preset.deviceScaleFactor}x). Expect a mobile/touch layout.`
103
125
  : `You are a desktop user (${name}, ${preset.width}x${preset.height}).`;
126
+ // The protocol as the PARTICIPANT reads it: numbered goals, nothing else. The success criteria
127
+ // are the researcher's instrument and must never reach this prompt — a persona told how it will
128
+ // be measured optimizes for the measurement instead of using the product (src/tasks.ts).
129
+ const taskLines = renderTaskPrompt(args.tasks ?? []);
104
130
  // A resolved persona contributes its compiled directives (friction tolerance, skill bias,
105
131
  // accessibility behavior, constraints) through the SAME persona.ts compiler the terminal lane
106
132
  // uses, so one persona file means one behavior across every route.
@@ -112,6 +138,7 @@ export function composeLaneInstructions(args) {
112
138
  personaLine,
113
139
  deviceLine,
114
140
  args.mission,
141
+ taskLines,
115
142
  args.instruction ? `Lane focus: ${args.instruction}` : undefined
116
143
  ].filter((part) => Boolean(part));
117
144
  const instructions = parts.join("\n\n");
@@ -212,7 +239,7 @@ export function resolveLaneDevice(config, lane) {
212
239
  * git clone for an upload+extract, but the shared install/build/state/start/probe pipeline
213
240
  * costs the same wall-clock room either way. */
214
241
  function resolvePerLaneSandboxMs(config) {
215
- const timeoutMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
242
+ const timeoutMs = config.execution?.timeoutMs ?? defaultSessionTimeoutMs(config);
216
243
  const provisionedRoute = config.subject.source === "clone" || config.subject.source === "local-tree";
217
244
  const stateBudgetMs = provisionedRoute
218
245
  ? (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0)
@@ -244,6 +271,7 @@ function laneSpecsAndPlan(config, opts = {}) {
244
271
  const env = opts.env ?? {};
245
272
  const actor = config.actors[0];
246
273
  const mission = actor?.mission ?? DEFAULT_MISSION;
274
+ const tasks = actor?.tasks;
247
275
  const roster = actor?.lanes;
248
276
  const laneCount = roster ? roster.length : Math.max(1, opts.countOverride ?? actor?.count ?? 1);
249
277
  const lanes = [];
@@ -257,6 +285,7 @@ function laneSpecsAndPlan(config, opts = {}) {
257
285
  const resolvedPersona = personaId === undefined ? undefined : opts.personas?.get(personaId);
258
286
  const composed = composeLaneInstructions({
259
287
  mission,
288
+ ...(tasks === undefined ? {} : { tasks }),
260
289
  ...(personaId === undefined ? {} : { persona: personaId }),
261
290
  ...(resolvedPersona === undefined ? {} : { resolvedPersona }),
262
291
  ...(((roster ? lane?.instruction : actor?.laneFocus?.instruction)) === undefined ? {} : { instruction: (roster ? lane?.instruction : actor?.laneFocus?.instruction) }),
@@ -274,6 +303,7 @@ function laneSpecsAndPlan(config, opts = {}) {
274
303
  instructions: composed.instructions,
275
304
  ...(lane?.target === undefined ? {} : { targetUrl: lane.target }),
276
305
  ...((lane?.stopWhen ?? actor?.stopWhen) === undefined ? {} : { stopWhen: (lane?.stopWhen ?? actor?.stopWhen) }),
306
+ ...(tasks === undefined ? {} : { tasks }),
277
307
  deviceName: device.name,
278
308
  devicePreset: device.preset,
279
309
  resolution: device.resolution,
@@ -283,7 +313,7 @@ function laneSpecsAndPlan(config, opts = {}) {
283
313
  }
284
314
  const resolved = resolveCuaConcurrency(config, laneCount, env);
285
315
  const concurrency = resolved.bound;
286
- const perLaneSessionBudgetMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
316
+ const perLaneSessionBudgetMs = config.execution?.timeoutMs ?? defaultSessionTimeoutMs(config);
287
317
  const perLaneSandboxMs = resolvePerLaneSandboxMs(config);
288
318
  const plan = {
289
319
  strategy: CUA_FANOUT_STRATEGY,
@@ -474,6 +504,20 @@ function phaseEventIdSuffix(type) {
474
504
  .replace(/\.(started|completed)$/, "")
475
505
  .replace(/\./g, "-");
476
506
  }
507
+ export function makeCuaRunBudget(maxTotalUsd) {
508
+ const laneEstimates = new Map();
509
+ return {
510
+ maxTotalUsd,
511
+ note(laneId, estimateUsd) {
512
+ if (estimateUsd !== null)
513
+ laneEstimates.set(laneId, estimateUsd);
514
+ let total = 0;
515
+ for (const value of laneEstimates.values())
516
+ total += value;
517
+ return total;
518
+ }
519
+ };
520
+ }
477
521
  /** Build a lane's writeScreenshot closure: writes under screenshots/<screenshotDir>/ and records
478
522
  * the relative path the trace references (screenshots/<name> at N=1; screenshots/<laneId>/<name>
479
523
  * at N>1). */
@@ -785,7 +829,16 @@ export function chromeCdpPortResolutionScript(endpoint) {
785
829
  }
786
830
  /** Shared CDP page-selection preamble: pinned target id first, then this lane's target URL,
787
831
  * then a single-page fallback; never an arbitrary page from a multi-page endpoint. */
788
- function chromeCdpPageSelectionScript(endpoint, targetId) {
832
+ function chromeCdpPageSelectionScript(endpoint, targetId,
833
+ /**
834
+ * "pinned" (default): the launch-time target, for measurements about the ORIGINAL window
835
+ * (geometry). "active": the tab the participant is driving NOW — Chrome's /json lists page
836
+ * targets most-recently-focused first. The state observer must follow the participant: a
837
+ * verification link that opens in a NEW tab left the pinned observer reading the old tab
838
+ * forever, so the observed URL never changed again and stopWhen/task criteria went blind
839
+ * (a live run's funnel read reach-dashboard 0/2 under a screenshot OF the dashboard).
840
+ */
841
+ prefer = "pinned") {
789
842
  return [
790
843
  ...chromeCdpPortResolutionScript(endpoint),
791
844
  "const pages = await fetch('http://127.0.0.1:' + cdpPort + '/json').then((r) => r.json()).catch(() => []);",
@@ -793,22 +846,27 @@ function chromeCdpPageSelectionScript(endpoint, targetId) {
793
846
  `const expectedTargetUrl = ${JSON.stringify(endpoint.targetUrl)};`,
794
847
  "const normalizeUrl = (value) => String(value || '').replace(/\\/$/, '');",
795
848
  "const httpPages = Array.isArray(pages) ? pages.filter((entry) => entry && entry.type === 'page' && /^https?:/.test(String(entry.url || ''))) : [];",
796
- "const page = expectedTargetId ? httpPages.find((entry) => entry.id === expectedTargetId) : (httpPages.find((entry) => normalizeUrl(entry.url) === normalizeUrl(expectedTargetUrl)) || (httpPages.length === 1 ? httpPages[0] : undefined));"
849
+ prefer === "active"
850
+ ? "const page = httpPages[0] || (expectedTargetId ? httpPages.find((entry) => entry.id === expectedTargetId) : undefined);"
851
+ : "const page = expectedTargetId ? httpPages.find((entry) => entry.id === expectedTargetId) : (httpPages.find((entry) => normalizeUrl(entry.url) === normalizeUrl(expectedTargetUrl)) || (httpPages.length === 1 ? httpPages[0] : undefined));"
797
852
  ];
798
853
  }
799
854
  export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoint, targetId) {
800
855
  return async () => {
801
856
  const script = [
802
- ...chromeCdpPageSelectionScript(endpoint, targetId),
857
+ // "active": follow the participant to whatever tab they are driving now — never pin the
858
+ // state observer to the launch tab (see chromeCdpPageSelectionScript).
859
+ ...chromeCdpPageSelectionScript(endpoint, targetId, "active"),
803
860
  "if (!page) { console.log('{}'); process.exit(0); }",
804
861
  "let text = '';",
862
+ "let scrollY = undefined;",
805
863
  "let url = String(page.url || '');",
806
864
  "let title = String(page.title || '');",
807
865
  "if (typeof WebSocket === 'function' && page.webSocketDebuggerUrl) {",
808
866
  " const ws = new WebSocket(page.webSocketDebuggerUrl);",
809
867
  " const result = await new Promise((resolve) => {",
810
868
  " const timer = setTimeout(() => resolve(undefined), 1500);",
811
- " 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) })' } }));",
869
+ " 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), scrollY: (window.scrollY || 0) })' } }));",
812
870
  " ws.onmessage = (event) => {",
813
871
  " try {",
814
872
  " const payload = JSON.parse(String(event.data));",
@@ -823,9 +881,10 @@ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoi
823
881
  " url = typeof result.url === 'string' ? result.url : url;",
824
882
  " title = typeof result.title === 'string' ? result.title : title;",
825
883
  " text = typeof result.text === 'string' ? result.text : '';",
884
+ " scrollY = typeof result.scrollY === 'number' ? result.scrollY : undefined;",
826
885
  " }",
827
886
  "}",
828
- "console.log(JSON.stringify({ url, title, text }));"
887
+ "console.log(JSON.stringify({ url, title, text, scrollY }));"
829
888
  ].join("\n");
830
889
  const result = await desktop.commands.run(`node --input-type=module -e ${shellSingleQuote(script)}`, {
831
890
  requestTimeoutMs,
@@ -843,7 +902,8 @@ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoi
843
902
  return {
844
903
  ...(typeof record.url === "string" && record.url.length > 0 ? { url: record.url } : {}),
845
904
  ...(typeof record.title === "string" && record.title.length > 0 ? { title: record.title } : {}),
846
- ...(typeof record.text === "string" && record.text.length > 0 ? { text: record.text } : {})
905
+ ...(typeof record.text === "string" && record.text.length > 0 ? { text: record.text } : {}),
906
+ ...(typeof record.scrollY === "number" && Number.isFinite(record.scrollY) ? { scrollY: record.scrollY } : {})
847
907
  };
848
908
  }
849
909
  catch {
@@ -1380,10 +1440,14 @@ export async function runCuaLane(spec, deps) {
1380
1440
  const maxUsd = config.execution?.caps?.maxUsd;
1381
1441
  const sessionOptions = {
1382
1442
  // Tell the persona where its inbox is — but only when comms is live AND this lane has a declared
1383
- // recipient it can actually receive mail into (else it would stall on an inbox that stays empty).
1443
+ // recipient it can actually receive mail into (else it would stall on an inbox that stays
1444
+ // empty). Two comms planes, mutually exclusive by parse: the in-sandbox catch humanish
1445
+ // deployed, or the adopter-hosted one (#380).
1384
1446
  instructions: commsEmail && commsInboxUrl && deployedComms?.ready && laneHasInboxRecipient(commsEmail, spec.laneId)
1385
1447
  ? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(commsEmail, spec.laneId)?.address).instructions
1386
- : spec.instructions,
1448
+ : deps.externalComms && laneHasInboxRecipient(deps.externalComms.email, spec.laneId)
1449
+ ? withInboxMission(spec, deps.externalComms.inboxUrl, inboxRecipientFor(deps.externalComms.email, spec.laneId)?.address).instructions
1450
+ : spec.instructions,
1387
1451
  persona: spec.persona,
1388
1452
  timeoutMs: deps.timeoutMs,
1389
1453
  openai: {
@@ -1414,6 +1478,24 @@ export async function runCuaLane(spec, deps) {
1414
1478
  ...(spec.idleSteps === undefined ? {} : { idleSteps: spec.idleSteps }),
1415
1479
  ...(spec.noProgressSteps === undefined ? {} : { noProgressSteps: spec.noProgressSteps }),
1416
1480
  ...(spec.stopWhen === undefined ? {} : { stopWhen: spec.stopWhen }),
1481
+ ...(spec.tasks === undefined ? {} : { tasks: spec.tasks }),
1482
+ // The STUDY budget (#299): this lane notes its own running estimate on the shared ledger
1483
+ // and stops when the RUN total crosses the cap — independent of the per-lane maxUsd above.
1484
+ ...(deps.runBudget === undefined
1485
+ ? {}
1486
+ : {
1487
+ overRunBudget: (usage) => {
1488
+ const estimate = estimateActorCost({
1489
+ input: usage.input,
1490
+ output: usage.output,
1491
+ ...(usage.cachedInput > 0 ? { cachedInput: usage.cachedInput } : {})
1492
+ }, capModelId).estimatedCostUsd;
1493
+ const totalUsd = deps.runBudget.note(spec.laneId, estimate);
1494
+ return totalUsd > deps.runBudget.maxTotalUsd
1495
+ ? `study budget reached: the run's estimated model spend $${round6(totalUsd)} crossed execution.caps.maxTotalUsd=$${deps.runBudget.maxTotalUsd}; this lane stops here and sibling lanes stop at their next turn`
1496
+ : null;
1497
+ }
1498
+ }),
1417
1499
  ...(deps.onObservedUrl === undefined ? {} : { onObservedUrl: deps.onObservedUrl }),
1418
1500
  ...(deps.onMessage === undefined ? {} : { onMessage: deps.onMessage }),
1419
1501
  ...(deps.onScreenshot === undefined ? {} : { onScreenshot: deps.onScreenshot })
@@ -1626,7 +1708,8 @@ async function runInProcessLane(spec, deps) {
1626
1708
  redactScreenshots: deps.redactScreenshots,
1627
1709
  scrubText: deps.scrubKnownValues,
1628
1710
  writeScreenshot,
1629
- ...(spec.stopWhen === undefined ? {} : { stopWhen: spec.stopWhen })
1711
+ ...(spec.stopWhen === undefined ? {} : { stopWhen: spec.stopWhen }),
1712
+ ...(spec.tasks === undefined ? {} : { tasks: spec.tasks })
1630
1713
  };
1631
1714
  session = await deps.runSession(sessionOptions);
1632
1715
  }
@@ -1960,6 +2043,15 @@ export async function runCuaActorLab(options) {
1960
2043
  const runSession = hooks.runSession ?? descriptor.runSession;
1961
2044
  const inProcessRoute = hooks.buildExecutor !== undefined;
1962
2045
  const localAppSubject = config.subject.source === "local-app";
2046
+ // Adopter-hosted comms plane on the app-url route (#380): humanish provisions no subject here,
2047
+ // so it cannot host a catch — the OPERATOR runs one, and humanish still does every other part
2048
+ // of the funnel: tells each persona its address and inbox URL, drains the catch over HTTP after
2049
+ // the lanes, and writes the same digest-only evidence. Declaring `external` previously did
2050
+ // nothing on this route (and, per #387, on every other) while its docs said otherwise.
2051
+ const externalCommsConfig = !cloneRoute && !localTreeRoute && !inProcessRoute
2052
+ ? config.comms?.email?.external
2053
+ : undefined;
2054
+ const externalCommsEmail = externalCommsConfig ? config.comms?.email : undefined;
1963
2055
  // Engine re-enforcement of the clone-route structure (library API surface).
1964
2056
  if (cloneRoute && (!serve || !subjectRepo || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(subjectRepo))) {
1965
2057
  return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", !serve
@@ -2017,7 +2109,7 @@ export async function runCuaActorLab(options) {
2017
2109
  const derivedSandboxMs = resolvePerLaneSandboxMs(config);
2018
2110
  if (derivedSandboxMs > MAX_SANDBOX_MS) {
2019
2111
  const provisionedRoute = config.subject.source === "clone" || config.subject.source === "local-tree";
2020
- const sessionMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
2112
+ const sessionMs = config.execution?.timeoutMs ?? defaultSessionTimeoutMs(config);
2021
2113
  const headroomMs = derivedSandboxMs - sessionMs;
2022
2114
  return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", `execution.timeoutMs ${Math.round(sessionMs / 60_000)}m derives a ${Math.round(derivedSandboxMs / 60_000)}m sandbox deadline, and a sandbox may not live longer than ${MAX_SANDBOX_MS / 60_000}m. The deadline is the session budget plus ${Math.round(headroomMs / 60_000)}m of provisioning and teardown headroom${provisionedRoute ? " (this route clones, installs, builds and serves the subject before the actor starts)" : ""}. Lower execution.timeoutMs to at most ${Math.round((MAX_SANDBOX_MS - headroomMs) / 60_000)}m, or set execution.desktop.sandboxTimeoutMs explicitly.`, descriptor.id);
2023
2115
  }
@@ -2099,19 +2191,26 @@ export async function runCuaActorLab(options) {
2099
2191
  // runaway-retry protection. Refuse at PREFLIGHT (before any sandbox/spend) rather than run
2100
2192
  // uncapped: an unenforceable cap is more dangerous than none. The operator adds a rate to
2101
2193
  // src/pricing.ts (the honest place) or removes the cap.
2102
- if (config.execution?.caps?.maxUsd !== undefined) {
2194
+ if (config.execution?.caps?.maxUsd !== undefined || config.execution?.caps?.maxTotalUsd !== undefined) {
2103
2195
  const capModelId = (config.actors[0]?.model ?? DEFAULT_OPENAI_CU_MODEL).trim().toLowerCase();
2104
2196
  if (!MODEL_RATES[capModelId]) {
2105
- return fail("HUMANISH_CUA_LAB_UNPRICED_CAP", `execution.caps.maxUsd is set but src/pricing.ts has no rate for model "${config.actors[0]?.model ?? DEFAULT_OPENAI_CU_MODEL}"; add a rate or remove the cap — an unenforceable cap is refused rather than run uncapped.`, descriptor.id);
2197
+ return fail("HUMANISH_CUA_LAB_UNPRICED_CAP", `execution.caps declares a spend cap (maxUsd/maxTotalUsd) but src/pricing.ts has no rate for model "${config.actors[0]?.model ?? DEFAULT_OPENAI_CU_MODEL}"; add a rate or remove the cap — an unenforceable cap is refused rather than run uncapped.`, descriptor.id);
2106
2198
  }
2107
2199
  }
2200
+ // Adopter-hosted comms catch (#380): fail closed BEFORE any sandbox is created — a comms lab
2201
+ // whose catch is unreachable collects nothing while every lane still spends. The probe asserts
2202
+ // OUR service marker in /health, so an adopter's proxy answering 200 for everything cannot
2203
+ // pass for a catch.
2204
+ if (externalCommsConfig && !(await externalCatchHealthy(externalCommsConfig))) {
2205
+ return fail("HUMANISH_CUA_LAB_COMMS_CATCH_UNREACHABLE", "comms.email.external.catchBaseUrl is not reachable as a humanish comms catch (GET /health must return the humanish-comms-catch service marker). Start it with `humanish comms catch` on that host, or drop comms.email to run without the inbox funnel.", descriptor.id);
2206
+ }
2108
2207
  }
2109
2208
  const runId = options.runId ?? makeCuaRunId();
2110
2209
  const runPaths = await prepareRunArtifactPaths(cwd, runId);
2111
2210
  const artifactRoot = runPaths.absoluteRunRoot;
2112
2211
  const physicalArtifactRoot = runPaths.physicalRunRoot;
2113
2212
  const createdAt = new Date().toISOString();
2114
- const timeoutMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
2213
+ const timeoutMs = config.execution?.timeoutMs ?? defaultSessionTimeoutMs(config);
2115
2214
  const requestTimeoutMs = readPositiveInt(env.HUMANISH_E2B_REQUEST_TIMEOUT_MS, 60_000);
2116
2215
  const redactScreenshots = config.policies?.redactScreenshots === true;
2117
2216
  await prepareContainedOutputDirectory(runPaths, "screenshots");
@@ -2169,6 +2268,14 @@ export async function runCuaActorLab(options) {
2169
2268
  redactScreenshots,
2170
2269
  scrubKnownValues,
2171
2270
  runSession,
2271
+ // The study-level ledger exists once per RUN, shared by every lane (#299). Dry runs never
2272
+ // spend, so they carry none.
2273
+ ...(dryRun || config.execution?.caps?.maxTotalUsd === undefined
2274
+ ? {}
2275
+ : { runBudget: makeCuaRunBudget(config.execution.caps.maxTotalUsd) }),
2276
+ ...(externalCommsConfig === undefined || externalCommsEmail === undefined
2277
+ ? {}
2278
+ : { externalComms: { email: externalCommsEmail, inboxUrl: externalInboxUrl(externalCommsConfig) } }),
2172
2279
  now: hooks.now ?? Date.now,
2173
2280
  hooks: liveHooks
2174
2281
  };
@@ -2245,6 +2352,47 @@ export async function runCuaActorLab(options) {
2245
2352
  failFastReason = ran.failFastReason;
2246
2353
  }
2247
2354
  }
2355
+ const externalCommsWarnings = [];
2356
+ // Adopter-hosted drain (#380): once per RUN, after every lane finished — the catch is one
2357
+ // shared external endpoint, not a per-sandbox file. Same routing and digest-only artifact as
2358
+ // the in-sandbox drain; the artifact is registered on every lane that declared a recipient
2359
+ // address, since the thread carries each inbox's mail. A drain failure never fails the run.
2360
+ if (!dryRun && externalCommsConfig && externalCommsEmail && outcomes !== undefined) {
2361
+ try {
2362
+ const commsChannel = new FakeInbox();
2363
+ const commsInboxes = [];
2364
+ for (const recipient of externalCommsEmail.recipients ?? []) {
2365
+ if (recipient.address !== undefined) {
2366
+ commsInboxes.push(await commsChannel.provisionAddress(recipient.lane, recipient.address));
2367
+ }
2368
+ }
2369
+ const authToken = externalCommsConfig.authTokenEnv === undefined ? undefined : env[externalCommsConfig.authTokenEnv];
2370
+ const collected = await collectExternalCommsThread({
2371
+ external: { ...externalCommsConfig, ...(authToken === undefined ? {} : { authToken }) },
2372
+ channel: commsChannel,
2373
+ inboxes: commsInboxes
2374
+ });
2375
+ if (collected.artifact) {
2376
+ const commsPath = "comms/thread.json";
2377
+ await writeContainedOutputFile(runPaths, commsPath, `${JSON.stringify(collected.artifact, null, 2)}\n`, "utf8");
2378
+ for (const [index, outcome] of outcomes.entries()) {
2379
+ const laneId = laneSpecs[index]?.laneId;
2380
+ if (laneId !== undefined && outcome.commsArtifactPath === undefined && laneHasInboxRecipient(externalCommsEmail, laneId)) {
2381
+ outcome.commsArtifactPath = commsPath;
2382
+ }
2383
+ }
2384
+ }
2385
+ else if (collected.captured > 0) {
2386
+ externalCommsWarnings.push(`Comms catch captured ${collected.captured} email send(s) but none matched a declared recipient inbox — no comms evidence written. Declare comms.email.recipients[].address to match the address the app sends to.`);
2387
+ }
2388
+ else {
2389
+ externalCommsWarnings.push(`Comms catch captured ZERO email sends — your app never delivered mail through the catch at ${externalCommsConfig.catchBaseUrl}. Verify the app's email-API base URL points at it and that the flow reached an email step.`);
2390
+ }
2391
+ }
2392
+ catch (error) {
2393
+ externalCommsWarnings.push(`Comms evidence collection failed against the adopter-hosted catch (run continues): ${redactText(scrubKnownValues(toErrorMessage(error)))}`);
2394
+ }
2395
+ }
2248
2396
  // Per-lane subject projections (invariant 5).
2249
2397
  const laneSubjects = laneSpecs.map((_spec, index) => {
2250
2398
  const outcome = outcomes?.[index];
@@ -2268,14 +2416,14 @@ export async function runCuaActorLab(options) {
2268
2416
  // lane's projection already carries the identical archiveSha256/commit/dirty: the
2269
2417
  // `first.source !== "clone"` branch below returns it directly, with no unanimity math needed
2270
2418
  // (there is nothing that could diverge).
2271
- const aggregateWarnings = [];
2419
+ const aggregateWarnings = [...externalCommsWarnings];
2272
2420
  // execution.caps.maxUsd is a PER-LANE cap: it is enforced INSIDE each lane's loop independently,
2273
2421
  // so an N-lane fan-out can spend up to N × maxUsd before any lane aborts, while the run cost
2274
- // summary reports the (larger) aggregate. Warn at run level so the operator sees the true ceiling.
2275
- // A shared, run-level budget is future work there is no cross-lane counter today.
2422
+ // summary reports the (larger) aggregate. Warn at run level so the operator sees the true
2423
+ // ceiling unless the study declared the shared budget (#299), which caps the run as a whole.
2276
2424
  const perLaneCapUsd = config.execution?.caps?.maxUsd;
2277
- if (perLaneCapUsd !== undefined && laneCount > 1) {
2278
- aggregateWarnings.push(`execution.caps.maxUsd ($${perLaneCapUsd}) is a PER-LANE cap; ${laneCount} lanes may spend up to ${laneCount} × $${perLaneCapUsd} (~$${round6(perLaneCapUsd * laneCount)} total) before any lane aborts. A shared run-level budget is future work.`);
2425
+ if (perLaneCapUsd !== undefined && laneCount > 1 && config.execution?.caps?.maxTotalUsd === undefined) {
2426
+ aggregateWarnings.push(`execution.caps.maxUsd ($${perLaneCapUsd}) is a PER-LANE cap; ${laneCount} lanes may spend up to ${laneCount} × $${perLaneCapUsd} (~$${round6(perLaneCapUsd * laneCount)} total) before any lane aborts. Set execution.caps.maxTotalUsd for a shared study budget.`);
2279
2427
  }
2280
2428
  const aggregateSubject = (() => {
2281
2429
  const first = laneSubjects[0];
@@ -3006,6 +3154,80 @@ export function buildCuaCostSummary(args) {
3006
3154
  function desktopSpanToMinutes(desktopDurationMs) {
3007
3155
  return desktopDurationMs === undefined ? undefined : desktopDurationMs / 60_000;
3008
3156
  }
3157
+ /**
3158
+ * Feedback candidates derived from what LIVE participants actually reported (#392).
3159
+ *
3160
+ * A live run's feedback draft used to fall through to a dry-run template, because no browser route
3161
+ * ever built a candidate. The candidate worth filing is the one the study produced: a participant
3162
+ * who reported friction on the way (the most valuable thing a run captures), or one who stopped
3163
+ * trying. A clean pass files nothing here — feedback exists to carry findings, and a run without
3164
+ * any falls back to an honest live summary in the draft layer instead of a template.
3165
+ *
3166
+ * Everything quoted is already scrub+redacted — `session.reason` passes through redactNarration in
3167
+ * the loop before it ever lands on a trace — and passes redactText again here as defense-in-depth.
3168
+ */
3169
+ export function participantFeedbackCandidates(args) {
3170
+ const candidates = [];
3171
+ for (const lane of args.lanes) {
3172
+ const session = lane.session;
3173
+ if (session === undefined)
3174
+ continue;
3175
+ const friction = resolveSelfReportedBlocker(session);
3176
+ const abandoned = session.status === "abandoned";
3177
+ if (friction === undefined && !abandoned)
3178
+ continue;
3179
+ const summary = friction !== undefined
3180
+ ? `Participant ${lane.personaId} (${lane.laneId}) reported friction on the way through the study goal`
3181
+ : `Participant ${lane.personaId} (${lane.laneId}) stopped before completing the study goal`;
3182
+ const lastScreenshot = lane.screenshots[lane.screenshots.length - 1];
3183
+ candidates.push({
3184
+ schema: "humanish.feedback-candidate.v1",
3185
+ id: `participant-report-${lane.laneId}`,
3186
+ run_id: args.runId,
3187
+ stream_id: lane.streamId,
3188
+ adapter_id: args.adapterId,
3189
+ scenario_id: args.scenarioId,
3190
+ persona_id: lane.personaId,
3191
+ actor: "computer-use",
3192
+ substrate: args.substrate,
3193
+ // The participant is reporting on the PRODUCT: friction and abandonment are target-app
3194
+ // findings by the three-roles rule. A harness failure never reaches this builder — it is
3195
+ // not a participant report.
3196
+ failure_owner: "target-app",
3197
+ summary,
3198
+ expected: args.goal,
3199
+ actual: redactText(friction ?? session.reason),
3200
+ evidence: [
3201
+ ...(lane.traceArtifactPath === undefined ? [] : [{
3202
+ path: lane.traceArtifactPath,
3203
+ kind: "trace",
3204
+ note: "Full actor trace: turns, actions, and the participant's own report."
3205
+ }]),
3206
+ ...(lastScreenshot === undefined ? [] : [{
3207
+ path: lastScreenshot,
3208
+ kind: "screenshot",
3209
+ note: "Final screenshot at the moment the session ended."
3210
+ }]),
3211
+ ...(lane.commsArtifactPath === undefined ? [] : [{
3212
+ path: lane.commsArtifactPath,
3213
+ kind: "log",
3214
+ note: "Digest-only comms thread captured in-sandbox."
3215
+ }])
3216
+ ],
3217
+ redaction: {
3218
+ status: "passed",
3219
+ notes: "Quoted participant text passed the loop's known-value scrub and pattern redaction before persisting, and redactText again here."
3220
+ },
3221
+ idempotency_key: `humanish:${args.runId}:${lane.laneId}:participant-report`,
3222
+ proposed_next_state: "study-quality-review",
3223
+ acceptance_proof: [
3224
+ `pnpm humanish -- verify --run ${args.runId} --json`,
3225
+ `pnpm humanish -- watch --run ${args.runId} --no-open`
3226
+ ]
3227
+ });
3228
+ }
3229
+ return candidates;
3230
+ }
3009
3231
  export function buildCuaBundle(args) {
3010
3232
  const publicAppUrl = publicSafeAppUrlLabel(args.appUrl);
3011
3233
  // Run-level cost ESTIMATE (advisory; omitted when nothing was priced and no sandbox ran).
@@ -3212,6 +3434,11 @@ export function buildCuaBundle(args) {
3212
3434
  streamId: "stream-001"
3213
3435
  });
3214
3436
  }
3437
+ // A funnel with a denominator of one is still the funnel — and its absence stays honest: no
3438
+ // declared protocol (or a dry run) means no `tasks` field, never an empty one.
3439
+ const singleStudyTasks = args.inProgress !== true && args.session?.trace.taskFunnel !== undefined
3440
+ ? aggregateTaskFunnels([args.session.trace.taskFunnel])
3441
+ : undefined;
3215
3442
  const review = {
3216
3443
  schema: REVIEW_SCHEMA,
3217
3444
  verdict: args.inProgress === true
@@ -3221,6 +3448,12 @@ export function buildCuaBundle(args) {
3221
3448
  : args.sessionError
3222
3449
  ? "fail"
3223
3450
  : "contract_proof_only",
3451
+ // One lane is still a study with a denominator of one, and saying so keeps a single-lane
3452
+ // result from being read as though it generalized.
3453
+ ...(args.session && args.inProgress !== true
3454
+ ? { participants: tallyParticipantOutcomes([args.session.status]) }
3455
+ : {}),
3456
+ ...(singleStudyTasks === undefined ? {} : { tasks: singleStudyTasks }),
3224
3457
  summary: reason,
3225
3458
  gaps: args.session || args.sessionError
3226
3459
  ? []
@@ -3246,7 +3479,7 @@ export function buildCuaBundle(args) {
3246
3479
  scenario: {
3247
3480
  id: `cua-${args.labId}`,
3248
3481
  title: args.labTitle ?? `Computer-use lab: ${args.labId}`,
3249
- goal: args.mission,
3482
+ goal: redactText(args.mission),
3250
3483
  source: `lab:${args.labId}`,
3251
3484
  sourceDigest: args.persona.promptDigest
3252
3485
  },
@@ -3278,7 +3511,26 @@ export function buildCuaBundle(args) {
3278
3511
  events: "events.ndjson"
3279
3512
  },
3280
3513
  review,
3281
- feedbackCandidates: [],
3514
+ // What the participant reported, when it reported anything (#392). Dry-run and in-progress
3515
+ // bundles carry none — there is no participant yet to quote.
3516
+ feedbackCandidates: args.dryRun || args.inProgress === true
3517
+ ? []
3518
+ : participantFeedbackCandidates({
3519
+ runId: args.runId,
3520
+ scenarioId: `cua-${args.labId}`,
3521
+ adapterId: args.labId,
3522
+ goal: redactText(args.mission),
3523
+ substrate: args.desktopRoute === false ? "local-filesystem" : "e2b-desktop",
3524
+ lanes: [{
3525
+ laneId: args.laneId ?? "lane-01",
3526
+ streamId: "stream-001",
3527
+ personaId: args.persona.id,
3528
+ ...(args.session === undefined ? {} : { session: args.session }),
3529
+ ...(args.traceArtifactPath === undefined ? {} : { traceArtifactPath: args.traceArtifactPath }),
3530
+ screenshots: args.screenshots,
3531
+ ...(args.commsArtifactPath === undefined ? {} : { commsArtifactPath: args.commsArtifactPath })
3532
+ }]
3533
+ }),
3282
3534
  // Custom desktop image provenance (omitted on the stock-template default → byte-stable).
3283
3535
  ...(args.desktopTemplate === undefined ? {} : { desktopTemplate: args.desktopTemplate }),
3284
3536
  ...(args.desktopBrowser === undefined ? {} : { desktopBrowser: args.desktopBrowser }),
@@ -3614,14 +3866,31 @@ export function buildCuaFanoutBundle(args) {
3614
3866
  && outcome.sessionError === undefined
3615
3867
  && !outcome.noEngagement
3616
3868
  && !outcome.selfReportedBlocker).length;
3869
+ // What happened to the PARTICIPANTS, with the denominator attached. The verdict above has to
3870
+ // collapse the run to one word; this does not (docs/principles/three-roles.md).
3871
+ const terminalOutcomes = (outcomes ?? []).filter((outcome) => outcome?.session?.status !== undefined);
3872
+ const participants = terminalOutcomes.length > 0
3873
+ ? tallyParticipantOutcomes(terminalOutcomes.map((outcome) => outcome.session.status),
3874
+ // A participant who reached the goal AND told you the road there was broken is the most
3875
+ // useful result a study produces; reporting only the outcome would bury it.
3876
+ terminalOutcomes.map((outcome) => outcome.selfReportedBlocker === true))
3877
+ : undefined;
3878
+ // The study funnel: per-task completion rates across every session that measured one. This is
3879
+ // "where did people get stuck" as data, next to WHO got stuck (participants) above.
3880
+ const participantFunnels = (outcomes ?? [])
3881
+ .map((outcome) => outcome?.session?.trace.taskFunnel)
3882
+ .filter((funnel) => funnel !== undefined);
3883
+ const studyTasks = args.inProgress === true ? undefined : aggregateTaskFunnels(participantFunnels);
3617
3884
  const review = {
3618
3885
  schema: REVIEW_SCHEMA,
3619
3886
  verdict,
3887
+ ...(participants === undefined ? {} : { participants }),
3888
+ ...(studyTasks === undefined ? {} : { tasks: studyTasks }),
3620
3889
  summary: args.inProgress === true
3621
3890
  ? `Live computer-use fan-out is running (${specs.length} per-lane worlds); terminal lane evidence has not been written yet.`
3622
3891
  : args.dryRun
3623
3892
  ? `${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.`
3624
- : `${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.`,
3893
+ : `${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${participants ? ` — ${formatParticipantOutcomes(participants)}` : ""}${studyTasks ? `; tasks: ${formatStudyTaskFunnel(studyTasks)}` : ""}.`,
3625
3894
  gaps: args.inProgress === true
3626
3895
  ? ["Live fan-out session is still running."]
3627
3896
  : args.dryRun
@@ -3682,7 +3951,15 @@ export function buildCuaFanoutBundle(args) {
3682
3951
  scenario: {
3683
3952
  id: `cua-${config.id}`,
3684
3953
  title: config.title ?? `Computer-use fan-out: ${config.id}`,
3685
- goal: specs[0].instructions,
3954
+ // Redacted at WRITE time, like every other raw-text surface in the bundle. Lane records are
3955
+ // digest-only by design, but scenario.goal keeps one lane's composed instructions verbatim —
3956
+ // and an adopter whose authored lane text must name a runtime world URL (an inbox on a route
3957
+ // where the harness does not inject one) put an *.e2b.app address in it. That landed raw here
3958
+ // and in observer-data.json, the sensitive-text scanner matched it, and verify failed a bundle
3959
+ // this writer produced. The only adopter-side workaround was scanner evasion (#412).
3960
+ //
3961
+ // The instructions the model actually receives are untouched; only the persisted copy changes.
3962
+ goal: redactText(specs[0].instructions),
3686
3963
  source: `lab:${config.id}`,
3687
3964
  sourceDigest: specs[0].persona.promptDigest
3688
3965
  },
@@ -3713,7 +3990,29 @@ export function buildCuaFanoutBundle(args) {
3713
3990
  events: "events.ndjson"
3714
3991
  },
3715
3992
  review,
3716
- feedbackCandidates: [],
3993
+ // What the participants reported, when any reported anything (#392). Dry-run and in-progress
3994
+ // bundles carry none — there is no participant yet to quote.
3995
+ feedbackCandidates: args.dryRun || args.inProgress === true
3996
+ ? []
3997
+ : participantFeedbackCandidates({
3998
+ runId: args.runId,
3999
+ scenarioId: `cua-${config.id}`,
4000
+ adapterId: config.id,
4001
+ goal: redactText(specs[0].instructions),
4002
+ substrate: "e2b-desktop",
4003
+ lanes: specs.map((spec, index) => {
4004
+ const outcome = outcomes?.[index];
4005
+ return {
4006
+ laneId: spec.laneId,
4007
+ streamId: spec.streamId,
4008
+ personaId: spec.persona.id,
4009
+ ...(outcome?.session === undefined ? {} : { session: outcome.session }),
4010
+ ...(outcome?.session === undefined ? {} : { traceArtifactPath: spec.traceArtifactPath }),
4011
+ screenshots: outcome?.screenshots ?? [],
4012
+ ...(outcome?.commsArtifactPath === undefined ? {} : { commsArtifactPath: outcome.commsArtifactPath })
4013
+ };
4014
+ })
4015
+ }),
3717
4016
  // Custom desktop image provenance (every lane launched on it); omitted on the stock default.
3718
4017
  ...(config.execution?.desktop?.template === undefined ? {} : { desktopTemplate: config.execution.desktop.template }),
3719
4018
  ...(configuredBrowser === undefined