humanish 0.74.0 → 0.76.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.
@@ -46,6 +46,7 @@ import { appendSandboxReceipt } from "./sandbox-receipts.js";
46
46
  import { assertScreenshotEvidence } from "./image-evidence.js";
47
47
  import { buildObserverData } from "./observer-data.js";
48
48
  import { corepackCommandFor, needsNodeRuntime, nodeBootstrapCommand } from "./subject-runtime.js";
49
+ import { chromeCdpProbeCommand, parseChromeCdpProbeOutput } from "./chrome-cdp-probe.js";
49
50
  import { personaToDirectives, renderPersonaPromptSection } from "./persona.js";
50
51
  import { labPersonaIds, resolveCommittedPersonas } from "./persona-resolve.js";
51
52
  import { renderTaskPrompt } from "./tasks.js";
@@ -505,6 +506,37 @@ function isoNow(now) {
505
506
  return new Date(now()).toISOString();
506
507
  }
507
508
  /** Emit a phase-started event (no ok/durationMs: those belong to the matching completed event). */
509
+ /**
510
+ * Run a provisioning step and, when it fails with an EXIT CODE, run it once more (#602). A cold
511
+ * install of 0.74.0 lost its whole first live study to one transient TLS error inside the
512
+ * sandbox's `npm install`; the parallel install twenty seconds later passed, as had the ten
513
+ * before it. One retry clears that class. A TIMEOUT is not retried: its budget is already spent,
514
+ * and a second wait would double it. The retry runs under its own step name so both logs stay.
515
+ */
516
+ async function runProvisioningStepWithOneRetry(desktop, args) {
517
+ const first = await runDetachedStep(desktop, {
518
+ name: args.name,
519
+ command: args.command,
520
+ cwd: args.cwd,
521
+ timeoutMs: args.timeoutMs,
522
+ requestTimeoutMs: args.requestTimeoutMs,
523
+ ...args.timers
524
+ });
525
+ if (first.ok || first.timedOut)
526
+ return { ...first, attempts: 1 };
527
+ const retryStartedAt = args.now();
528
+ emitPhaseStarted(args.onPhase, args.now, args.retryPhase, `${args.retryMessage} (first attempt exited ${first.exitCode ?? "null"}; retrying once)`);
529
+ const second = await runDetachedStep(desktop, {
530
+ name: `${args.name}-retry`,
531
+ command: args.command,
532
+ cwd: args.cwd,
533
+ timeoutMs: args.timeoutMs,
534
+ requestTimeoutMs: args.requestTimeoutMs,
535
+ ...args.timers
536
+ });
537
+ emitPhaseCompleted(args.onPhase, args.now, retryStartedAt, args.retryPhase, second.ok, second.ok ? `${args.retryMessage}: succeeded on the second attempt` : `${args.retryMessage}: failed twice`);
538
+ return { ...second, attempts: 2, ...(first.exitCode === undefined ? {} : { firstExitCode: first.exitCode }) };
539
+ }
508
540
  function emitPhaseStarted(onPhase, now, phase, message) {
509
541
  onPhase?.({ at: isoNow(now), type: `cua-lab.subject.${phase}.started`, message });
510
542
  }
@@ -704,10 +736,13 @@ async function fillDesktopBrowserWindow(desktop, windowId, resolution, requestTi
704
736
  })
705
737
  .catch(() => undefined);
706
738
  }
707
- async function openDesktopBrowserTarget(desktop, targetUrl, requestTimeoutMs, browserPreference) {
739
+ async function openDesktopBrowserTarget(desktop, targetUrl, requestTimeoutMs, browserPreference,
740
+ /** Launch-time flags that make mobile fidelity (#221) hold across every tab: the user agent and
741
+ * touch events are browser-wide here, where the CDP holder covers only the launch page. */
742
+ extraChromiumFlags = []) {
708
743
  const requestedBrowser = browserPreference ?? "default";
709
744
  if (isHttpUrl(targetUrl)) {
710
- const chromiumFlags = CHROMIUM_EVIDENCE_HYGIENE_FLAGS.map(shellSingleQuote).join(" ");
745
+ const chromiumFlags = [...CHROMIUM_EVIDENCE_HYGIENE_FLAGS, ...extraChromiumFlags].map(shellSingleQuote).join(" ");
711
746
  const browserLaunchCommand = [
712
747
  "set -euo pipefail",
713
748
  `target_url=${shellSingleQuote(targetUrl)}`,
@@ -839,164 +874,145 @@ export function desktopBrowserFamily(value) {
839
874
  return "unknown";
840
875
  }
841
876
  /**
842
- * Observe-time CDP port resolution lines (pure; exported for contract tests): cached
843
- * launch-time port first, then a re-read of the profile's DevToolsActivePort marker, then the
844
- * legacy fixed 9222. The re-read is a local best-effort file read inside the already
845
- * time-bounded observer command, so a missing/garbled marker degrades to the fallback,
846
- * never a hang.
847
- */
848
- export function chromeCdpPortResolutionScript(endpoint) {
849
- return [
850
- `let cdpPort = ${endpoint.cdpPort === undefined ? "undefined" : JSON.stringify(endpoint.cdpPort)};`,
851
- `const cdpProfileDir = ${JSON.stringify(endpoint.profileDir ?? "")};`,
852
- "if (cdpPort === undefined && cdpProfileDir) {",
853
- " try {",
854
- " const { readFileSync } = await import('node:fs');",
855
- " const marker = readFileSync(cdpProfileDir + '/DevToolsActivePort', 'utf8');",
856
- " const parsed = Number.parseInt(String(marker.split('\\n')[0] ?? ''), 10);",
857
- " if (Number.isInteger(parsed) && parsed > 0) cdpPort = parsed;",
858
- " } catch {}",
859
- "}",
860
- "if (cdpPort === undefined) cdpPort = 9222;"
861
- ];
862
- }
863
- /** Shared CDP page-selection preamble: pinned target id first, then this lane's target URL,
864
- * then a single-page fallback; never an arbitrary page from a multi-page endpoint. */
865
- function chromeCdpPageSelectionScript(endpoint, targetId,
866
- /**
867
- * "pinned" (default): the launch-time target, for measurements about the ORIGINAL window
868
- * (geometry). "active": the tab the participant is driving NOW — Chrome's /json lists page
869
- * targets most-recently-focused first. The state observer must follow the participant: a
870
- * verification link that opens in a NEW tab left the pinned observer reading the old tab
871
- * forever, so the observed URL never changed again and stopWhen/task criteria went blind
872
- * (a live run's funnel read reach-dashboard 0/2 under a screenshot OF the dashboard).
877
+ * The URL / title / page-text / scroll observer behind stopWhen and task criteria. One probe per
878
+ * observation, run on the sandbox's python3 (see chrome-cdp-probe.ts for why not node: #514).
879
+ *
880
+ * "active": follow the participant to whatever tab they are driving now — never pin the state
881
+ * observer to the launch tab (a verification link that opened in a NEW tab left a pinned observer
882
+ * reading the old tab forever).
883
+ *
884
+ * `onUnavailable` fires ONCE, on the first probe that could not read the page, with the reason.
885
+ * The observer still degrades to `{}` for the loop; the callback is how a lane says out loud that
886
+ * url/text criteria are not being measured, instead of letting the funnel report 0/N (#514).
873
887
  */
874
- prefer = "pinned") {
875
- return [
876
- ...chromeCdpPortResolutionScript(endpoint),
877
- "const pages = await fetch('http://127.0.0.1:' + cdpPort + '/json').then((r) => r.json()).catch(() => []);",
878
- `const expectedTargetId = ${JSON.stringify(targetId ?? "")};`,
879
- `const expectedTargetUrl = ${JSON.stringify(endpoint.targetUrl)};`,
880
- "const normalizeUrl = (value) => String(value || '').replace(/\\/$/, '');",
881
- "const httpPages = Array.isArray(pages) ? pages.filter((entry) => entry && entry.type === 'page' && /^https?:/.test(String(entry.url || ''))) : [];",
882
- prefer === "active"
883
- ? "const page = httpPages[0] || (expectedTargetId ? httpPages.find((entry) => entry.id === expectedTargetId) : undefined);"
884
- : "const page = expectedTargetId ? httpPages.find((entry) => entry.id === expectedTargetId) : (httpPages.find((entry) => normalizeUrl(entry.url) === normalizeUrl(expectedTargetUrl)) || (httpPages.length === 1 ? httpPages[0] : undefined));"
885
- ];
886
- }
887
- export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoint, targetId) {
888
+ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoint, targetId, onUnavailable) {
889
+ let reported = false;
890
+ const unavailable = (reason) => {
891
+ if (!reported) {
892
+ reported = true;
893
+ onUnavailable?.(reason);
894
+ }
895
+ return {};
896
+ };
888
897
  return async () => {
889
- const script = [
890
- // "active": follow the participant to whatever tab they are driving now — never pin the
891
- // state observer to the launch tab (see chromeCdpPageSelectionScript).
892
- ...chromeCdpPageSelectionScript(endpoint, targetId, "active"),
893
- "if (!page) { console.log('{}'); process.exit(0); }",
894
- "let text = '';",
895
- "let scrollY = undefined;",
896
- "let url = String(page.url || '');",
897
- "let title = String(page.title || '');",
898
- "if (typeof WebSocket === 'function' && page.webSocketDebuggerUrl) {",
899
- " const ws = new WebSocket(page.webSocketDebuggerUrl);",
900
- " const result = await new Promise((resolve) => {",
901
- " const timer = setTimeout(() => resolve(undefined), 1500);",
902
- " 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) })' } }));",
903
- " ws.onmessage = (event) => {",
904
- " try {",
905
- " const payload = JSON.parse(String(event.data));",
906
- " if (payload.id !== 1) return;",
907
- " clearTimeout(timer);",
908
- " resolve(payload.result && payload.result.result && payload.result.result.value);",
909
- " } catch { clearTimeout(timer); resolve(undefined); }",
910
- " };",
911
- " ws.onerror = () => { clearTimeout(timer); resolve(undefined); };",
912
- " }).finally(() => { try { ws.close(); } catch {} });",
913
- " if (result && typeof result === 'object') {",
914
- " url = typeof result.url === 'string' ? result.url : url;",
915
- " title = typeof result.title === 'string' ? result.title : title;",
916
- " text = typeof result.text === 'string' ? result.text : '';",
917
- " scrollY = typeof result.scrollY === 'number' ? result.scrollY : undefined;",
918
- " }",
919
- "}",
920
- "console.log(JSON.stringify({ url, title, text, scrollY }));"
921
- ].join("\n");
922
- const result = await desktop.commands.run(`node --input-type=module -e ${shellSingleQuote(script)}`, {
923
- requestTimeoutMs,
924
- timeoutMs: 5_000
925
- });
898
+ const result = await desktop.commands.run(chromeCdpProbeCommand({ ...endpoint, ...(targetId === undefined ? {} : { targetId }), prefer: "active", mode: "state" }), { requestTimeoutMs, timeoutMs: 5_000 });
926
899
  if (result.exitCode !== undefined && result.exitCode !== 0) {
927
- return {};
928
- }
929
- try {
930
- const parsed = JSON.parse((result.stdout ?? "{}").trim() || "{}");
931
- if (!parsed || typeof parsed !== "object") {
932
- return {};
933
- }
934
- const record = parsed;
935
- return {
936
- ...(typeof record.url === "string" && record.url.length > 0 ? { url: record.url } : {}),
937
- ...(typeof record.title === "string" && record.title.length > 0 ? { title: record.title } : {}),
938
- ...(typeof record.text === "string" && record.text.length > 0 ? { text: record.text } : {}),
939
- ...(typeof record.scrollY === "number" && Number.isFinite(record.scrollY) ? { scrollY: record.scrollY } : {})
940
- };
941
- }
942
- catch {
943
- return {};
900
+ return unavailable(`probe exited ${result.exitCode}: ${tailOf(result.stderr ?? result.stdout ?? "")}`);
944
901
  }
902
+ const parsed = parseChromeCdpProbeOutput(result.stdout);
903
+ if (parsed.unavailable !== undefined)
904
+ return unavailable(parsed.unavailable);
905
+ return {
906
+ ...(parsed.url === undefined ? {} : { url: parsed.url }),
907
+ ...(parsed.title === undefined ? {} : { title: parsed.title }),
908
+ ...(parsed.text === undefined ? {} : { text: parsed.text }),
909
+ ...(parsed.scrollY === undefined ? {} : { scrollY: parsed.scrollY })
910
+ };
945
911
  };
946
912
  }
947
913
  /**
948
914
  * Read the running browser's actual outer-window bounds and CSS layout viewport through the
949
915
  * already-enabled local Chrome DevTools endpoint. The returned values come from `window.*` in
950
916
  * the target page; requested E2B resolution is deliberately not an input to this function.
917
+ * `undefined` carries the reason the measurement is missing via `onUnavailable`, so the geometry
918
+ * warning can name the cause (a dead CDP endpoint, no python3) instead of only the symptom.
951
919
  */
952
- export function makeChromeDesktopGeometryObserver(desktop, requestTimeoutMs, endpoint, targetId) {
920
+ export function makeChromeDesktopGeometryObserver(desktop, requestTimeoutMs, endpoint, targetId, onUnavailable) {
953
921
  return async () => {
954
- const script = [
955
- ...chromeCdpPageSelectionScript(endpoint, targetId),
956
- "if (!page || typeof WebSocket !== 'function' || !page.webSocketDebuggerUrl) { console.log('{}'); process.exit(0); }",
957
- "const ws = new WebSocket(page.webSocketDebuggerUrl);",
958
- "const result = await new Promise((resolve) => {",
959
- " const timer = setTimeout(() => resolve(undefined), 1500);",
960
- " ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { returnByValue: true, expression: '({ browserWindow: { x: window.screenX, y: window.screenY, width: window.outerWidth, height: window.outerHeight }, viewport: { width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio } })' } }));",
961
- " ws.onmessage = (event) => {",
962
- " try {",
963
- " const payload = JSON.parse(String(event.data));",
964
- " if (payload.id !== 1) return;",
965
- " clearTimeout(timer);",
966
- " resolve(payload.result && payload.result.result && payload.result.result.value);",
967
- " } catch { clearTimeout(timer); resolve(undefined); }",
968
- " };",
969
- " ws.onerror = () => { clearTimeout(timer); resolve(undefined); };",
970
- "}).finally(() => { try { ws.close(); } catch {} });",
971
- "console.log(JSON.stringify(result ? { ...result, targetId: String(page.id || '') } : {}));"
972
- ].join("\n");
973
- const result = await desktop.commands.run(`node --input-type=module -e ${shellSingleQuote(script)}`, {
974
- requestTimeoutMs,
975
- timeoutMs: 5_000
976
- });
922
+ const result = await desktop.commands.run(chromeCdpProbeCommand({ ...endpoint, ...(targetId === undefined ? {} : { targetId }), prefer: "pinned", mode: "geometry" }), { requestTimeoutMs, timeoutMs: 5_000 });
977
923
  if (result.exitCode !== undefined && result.exitCode !== 0) {
924
+ onUnavailable?.(`probe exited ${result.exitCode}: ${tailOf(result.stderr ?? result.stdout ?? "")}`);
978
925
  return undefined;
979
926
  }
980
- try {
981
- const parsed = JSON.parse((result.stdout ?? "{}").trim() || "{}");
982
- if (!parsed || typeof parsed !== "object")
983
- return undefined;
984
- const record = parsed;
985
- const rawWindow = record.browserWindow;
986
- const rawViewport = record.viewport;
987
- if (!isMeasuredRect(rawWindow) || !isMeasuredViewport(rawViewport))
988
- return undefined;
989
- return {
990
- browserWindow: { ...rawWindow, source: "cdp" },
991
- viewport: { ...rawViewport, source: "cdp" },
992
- ...(typeof record.targetId === "string" && record.targetId.length > 0 ? { targetId: record.targetId } : {})
993
- };
927
+ const parsed = parseChromeCdpProbeOutput(result.stdout);
928
+ if (parsed.unavailable !== undefined) {
929
+ onUnavailable?.(parsed.unavailable);
930
+ return undefined;
994
931
  }
995
- catch {
932
+ if (!isMeasuredRect(parsed.browserWindow) || !isMeasuredViewport(parsed.viewport)) {
933
+ onUnavailable?.("the page reported no usable window or viewport dimensions");
996
934
  return undefined;
997
935
  }
936
+ return {
937
+ browserWindow: { ...parsed.browserWindow, source: "cdp" },
938
+ viewport: { ...parsed.viewport, source: "cdp" },
939
+ ...(parsed.targetId === undefined ? {} : { targetId: parsed.targetId })
940
+ };
998
941
  };
999
942
  }
943
+ /** The user agent a mobile-emulated lane presents unless the lab sets its own. */
944
+ export const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
945
+ /**
946
+ * Apply mobile emulation (#221) to the lane's launch page and read back what the page reports.
947
+ * Fails CLOSED: a request that cannot be applied throws, because a desktop run labelled mobile is
948
+ * the over-trust this feature exists to prevent. A read-back that cannot be taken is a warning
949
+ * (the emulation was applied; only the proof is missing).
950
+ */
951
+ export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint, targetId, request) {
952
+ const command = (mode) => chromeCdpProbeCommand({ ...endpoint, ...(targetId === undefined ? {} : { targetId }), prefer: "pinned", mode, emulation: request });
953
+ const read = async () => {
954
+ const result = await desktop.commands.run(command("fidelity"), { requestTimeoutMs, timeoutMs: 15_000 });
955
+ if (result.exitCode !== undefined && result.exitCode !== 0) {
956
+ return { unavailable: `probe exited ${result.exitCode}: ${tailOf(result.stderr ?? result.stdout ?? "")}` };
957
+ }
958
+ return parseChromeCdpProbeOutput(result.stdout);
959
+ };
960
+ // The UA / touch / DPR overrides are bound to the DevTools session that set them and lapse the
961
+ // moment its socket closes (measured: only the viewport width survived a one-shot apply). So the
962
+ // applier stays attached for the lane's whole life as a detached process; the sandbox teardown
963
+ // ends it. Its first stdout line says what was applied.
964
+ const holderName = `mobile-emulation-${Date.now().toString(36)}`;
965
+ await startDetachedProcess(desktop, { name: holderName, command: command("hold"), requestTimeoutMs });
966
+ let announced;
967
+ for (let attempt = 0; attempt < 30 && announced === undefined; attempt += 1) {
968
+ await new Promise((resolve) => setTimeout(resolve, 500));
969
+ const log = await readDetachedLog(desktop, holderName, requestTimeoutMs).catch(() => "");
970
+ const line = log.split("\n").find((candidate) => candidate.trim().startsWith("{"));
971
+ if (line !== undefined)
972
+ announced = parseChromeCdpProbeOutput(line);
973
+ }
974
+ if (announced === undefined) {
975
+ throw new Error("mobile emulation could not be applied: the in-sandbox applier printed nothing within 15 s");
976
+ }
977
+ if (announced.unavailable !== undefined) {
978
+ throw new Error(`mobile emulation could not be applied (${announced.unavailable}); applied before failing: ${(announced.applied ?? []).join(", ") || "nothing"}`);
979
+ }
980
+ const applied = announced;
981
+ const warnings = [];
982
+ // The reload inside the applier takes a moment; the read-back is retried until the page reports
983
+ // the requested viewport and user agent, so a slow page does not read as "no proof".
984
+ let readBack = await read();
985
+ for (let attempt = 0; attempt < 20 && (readBack.fidelity === undefined || readBack.fidelity.innerWidth !== request.width || !readBack.fidelity.userAgent.includes(request.userAgent.slice(0, 24))); attempt += 1) {
986
+ await new Promise((resolve) => setTimeout(resolve, 500));
987
+ readBack = await read();
988
+ }
989
+ const fidelityRead = readBack;
990
+ const requested = {
991
+ width: request.width,
992
+ height: request.height,
993
+ deviceScaleFactor: request.deviceScaleFactor,
994
+ touch: request.touch,
995
+ userAgent: request.userAgent
996
+ };
997
+ if (fidelityRead.fidelity === undefined) {
998
+ warnings.push(`Mobile emulation was applied but the page's own report could not be read (${fidelityRead.unavailable ?? "no fidelity read"}); desktopGeometry.fidelity carries the request without a resolved block.`);
999
+ return { fidelity: { tier: "mobile-emulated", requested, applied: applied.applied ?? [] }, warnings };
1000
+ }
1001
+ const resolved = { ...fidelityRead.fidelity, source: "cdp" };
1002
+ if (resolved.innerWidth !== request.width) {
1003
+ warnings.push(`Mobile emulation requested a ${request.width} px viewport; the page reports ${resolved.innerWidth} px.`);
1004
+ }
1005
+ if (resolved.devicePixelRatio !== request.deviceScaleFactor) {
1006
+ warnings.push(`Mobile emulation requested devicePixelRatio ${request.deviceScaleFactor}; the page reports ${resolved.devicePixelRatio}.`);
1007
+ }
1008
+ if (request.touch && resolved.maxTouchPoints === 0) {
1009
+ warnings.push("Mobile emulation requested touch; the page reports navigator.maxTouchPoints 0.");
1010
+ }
1011
+ if (!resolved.userAgent.includes("Mobile") && !resolved.userAgent.includes("Android") && !resolved.userAgent.includes("iPhone")) {
1012
+ warnings.push("Mobile emulation requested a mobile user agent; the page reports a desktop one.");
1013
+ }
1014
+ return { fidelity: { tier: "mobile-emulated", requested, applied: applied.applied ?? [], resolved }, warnings };
1015
+ }
1000
1016
  function isMeasuredRect(value) {
1001
1017
  if (!value || typeof value !== "object")
1002
1018
  return false;
@@ -1063,25 +1079,37 @@ export async function captureDesktopBrowserGeometry(args) {
1063
1079
  else {
1064
1080
  warnings.push(`Browser window bounds could not be measured for lane ${args.laneId}; the live stream will use the full desktop.`);
1065
1081
  }
1082
+ let cdpUnavailable;
1066
1083
  const chromeGeometry = args.browserFamily === "chromium"
1067
1084
  ? await makeChromeDesktopGeometryObserver(args.desktop, args.requestTimeoutMs, {
1068
1085
  ...(args.launchIdentity?.cdpPort === undefined ? {} : { cdpPort: args.launchIdentity.cdpPort }),
1069
1086
  ...(args.launchIdentity?.profileDir === undefined ? {} : { profileDir: args.launchIdentity.profileDir }),
1070
1087
  targetUrl: args.targetUrl
1071
- }, args.browserTargetId)().catch(() => undefined)
1088
+ }, args.browserTargetId, (reason) => {
1089
+ cdpUnavailable = reason;
1090
+ })().catch((error) => {
1091
+ cdpUnavailable = toErrorMessage(error);
1092
+ return undefined;
1093
+ })
1072
1094
  : undefined;
1073
1095
  const browserWindow = chromeGeometry?.browserWindow ?? xdotoolWindow;
1074
1096
  const viewport = chromeGeometry?.viewport;
1097
+ // The fill check reads the X window when it was measured: under mobile emulation (#221) the
1098
+ // page's window.outerWidth reports the EMULATED screen (414), which is not a fill failure.
1099
+ const fillBounds = xdotoolWindow ?? browserWindow;
1075
1100
  if (!browserWindow) {
1076
1101
  warnings.push(`Browser outer bounds could not be measured for lane ${args.laneId}.`);
1077
1102
  }
1078
- else if (browserWindow.width !== args.requestedScreen[0] || browserWindow.height !== args.requestedScreen[1]) {
1079
- warnings.push(`Browser window fill did not reach the requested ${args.requestedScreen[0]}x${args.requestedScreen[1]} screen for lane ${args.laneId}; measured outer bounds are ${browserWindow.width}x${browserWindow.height}.`);
1103
+ else if (fillBounds !== undefined && (fillBounds.width !== args.requestedScreen[0] || fillBounds.height !== args.requestedScreen[1])) {
1104
+ warnings.push(`Browser window fill did not reach the requested ${args.requestedScreen[0]}x${args.requestedScreen[1]} screen for lane ${args.laneId}; measured outer bounds are ${fillBounds.width}x${fillBounds.height}.`);
1080
1105
  }
1081
1106
  if (!viewport) {
1107
+ // Name the cause, not only the symptom: the same dead DevTools channel that loses the viewport
1108
+ // loses every url/text observation, and a reader of the bundle should learn that here (#514).
1109
+ const cause = cdpUnavailable === undefined ? "" : ` DevTools probe: ${redactText(cdpUnavailable)}.`;
1082
1110
  warnings.push(args.browserFamily === "firefox"
1083
1111
  ? `Browser CSS viewport measurement is unavailable for Firefox on lane ${args.laneId}; stream.viewport is omitted instead of reading a different browser's CDP endpoint.`
1084
- : `Browser CSS viewport could not be measured for lane ${args.laneId}; stream.viewport is omitted instead of copying the requested screen resolution.`);
1112
+ : `Browser CSS viewport could not be measured for lane ${args.laneId}; stream.viewport is omitted instead of copying the requested screen resolution.${cause}`);
1085
1113
  }
1086
1114
  return {
1087
1115
  ...(browserWindowId === undefined ? {} : { browserWindowId }),
@@ -1216,8 +1244,17 @@ function hasBlockerLanguage(text) {
1216
1244
  // study completed without a participant-reported finding". Friction is the INCLUSIVE scan; a
1217
1245
  // false positive here adds a candidate a person then reads, which is the cheap direction.
1218
1246
  const REPORTED_DEFECT_LANGUAGE = /\b(defects?|bugs?|accessibilit(y|ies)|inaccessible|not (keyboard|screen.?reader)[- ]?accessible|confus(ed|ing)|hesitat(ed|ion)|unexpected(ly)?|unclear|hard to (find|tell|see|read|reach)|no (visible )?focus|overlap(ped|ping|s)?|truncat(ed|es|ion)|cut off|did nothing|nothing happened|no effect)\b/;
1247
+ // The friction scan's own negations (#614). "Nothing was confusing", "no defects", "not unclear"
1248
+ // are what a participant writes when it has NOTHING to report, and until 2026-09-03 each of them
1249
+ // counted as reported friction and became a feedback candidate whose "actual" was a sentence
1250
+ // reporting no problem. Only the report-shaped adjectives are negatable here: "no visible focus",
1251
+ // "not keyboard-accessible" and "did nothing" are defects and stay.
1252
+ const NEGATED_REPORT_LANGUAGE = /\b(?:nothing|no|not|never|without|none)\s+(?:was\s+|were\s+|felt\s+|seemed\s+|really\s+|particularly\s+|especially\s+|major\s+|real\s+|obvious\s+|noticeable\s+)*(?:confus(?:ed|ing|ion)|unclear|unexpected(?:ly)?|hesitat(?:ed|ion|ions)|surpris(?:ed|ing|es)|defects?|bugs?|overlap(?:ped|ping|s)?|truncat(?:ed|es|ion)|hard to (?:find|tell|see|read|reach))\b/g;
1253
+ function stripNegatedReportLanguage(text) {
1254
+ return text.replace(NEGATED_REPORT_LANGUAGE, " ");
1255
+ }
1219
1256
  function completionReasonContradictsGoal(reason) {
1220
- const text = stripQuotedSpans(stripNegatedNonBlockerPhrases(reason.toLowerCase()));
1257
+ const text = stripQuotedSpans(stripNegatedReportLanguage(stripNegatedNonBlockerPhrases(reason.toLowerCase())));
1221
1258
  return hasBlockerLanguage(text) || REPORTED_DEFECT_LANGUAGE.test(text);
1222
1259
  }
1223
1260
  /** The verdict scan (strict): like the friction scan, but resolved-arc segments are stripped
@@ -1422,6 +1459,7 @@ export async function runCuaLane(spec, deps) {
1422
1459
  let browserLaunchIdentity;
1423
1460
  let browserLaunched = false;
1424
1461
  let initialBrowserGeometry;
1462
+ let appliedFidelity;
1425
1463
  let browserWindowId;
1426
1464
  let browserTargetId;
1427
1465
  const declaredScreen = declaredScreenForRender(spec.devicePreset, spec.deviceName, spec.resolution);
@@ -1613,12 +1651,44 @@ export async function runCuaLane(spec, deps) {
1613
1651
  });
1614
1652
  }
1615
1653
  if (!desktopCliRoute) {
1616
- const browserLaunch = await openDesktopBrowserTarget(desktop, targetUrl, deps.requestTimeoutMs, config.execution?.desktop?.browser);
1654
+ const requestedFidelity = config.execution?.desktop?.fidelity;
1655
+ const browserLaunch = await openDesktopBrowserTarget(desktop, targetUrl, deps.requestTimeoutMs, config.execution?.desktop?.browser, requestedFidelity?.mobileEmulation && spec.devicePreset.isMobile
1656
+ ? [
1657
+ `--user-agent=${requestedFidelity.userAgent ?? DEFAULT_MOBILE_USER_AGENT}`,
1658
+ ...(requestedFidelity.touch === false ? [] : ["--touch-events=enabled"])
1659
+ ]
1660
+ : []);
1617
1661
  desktopBrowser = browserLaunch.evidence;
1618
1662
  launchedBrowserFamily = browserLaunch.family;
1619
1663
  browserLaunchIdentity = browserLaunch.identity;
1620
1664
  browserLaunched = true;
1621
1665
  await desktop.wait(BROWSER_SETTLE_MS).catch(() => undefined);
1666
+ // Mobile fidelity beyond viewport size (#221): applied to the launch page before the
1667
+ // geometry capture and the participant's first observation, OUTSIDE the stream/geometry
1668
+ // try below (whose catch degrades to a warning): a request that cannot be applied fails
1669
+ // the lane closed with the reason.
1670
+ // Only lanes on a mobile preset are emulated: a run-wide flag must not hand a desktop or
1671
+ // tablet lane an iPhone user agent (the first live proof did exactly that to the desktop
1672
+ // newcomer beside the phone lane). Those lanes carry no fidelity block, which is honest.
1673
+ const fidelityRequest = config.execution?.desktop?.fidelity;
1674
+ if (fidelityRequest?.mobileEmulation && spec.devicePreset.isMobile) {
1675
+ if (launchedBrowserFamily !== "chromium") {
1676
+ throw new Error(`execution.desktop.fidelity.mobileEmulation needs Chrome or Chromium on lane ${spec.laneId}; the launched browser family is ${launchedBrowserFamily}. Set execution.desktop.browser: chrome.`);
1677
+ }
1678
+ const applied = await applyMobileEmulation(desktop, deps.requestTimeoutMs, {
1679
+ ...(browserLaunchIdentity?.cdpPort === undefined ? {} : { cdpPort: browserLaunchIdentity.cdpPort }),
1680
+ ...(browserLaunchIdentity?.profileDir === undefined ? {} : { profileDir: browserLaunchIdentity.profileDir }),
1681
+ targetUrl
1682
+ }, browserTargetId, {
1683
+ width: spec.devicePreset.width,
1684
+ height: spec.devicePreset.height,
1685
+ deviceScaleFactor: fidelityRequest.deviceScaleFactor ?? spec.devicePreset.deviceScaleFactor,
1686
+ touch: fidelityRequest.touch ?? true,
1687
+ userAgent: fidelityRequest.userAgent ?? DEFAULT_MOBILE_USER_AGENT
1688
+ });
1689
+ appliedFidelity = applied.fidelity;
1690
+ warnings.push(...applied.warnings);
1691
+ }
1622
1692
  }
1623
1693
  else {
1624
1694
  // A terminal window, opened the way the browser is opened on every other route: the
@@ -1752,7 +1822,13 @@ export async function runCuaLane(spec, deps) {
1752
1822
  ...(browserLaunchIdentity?.cdpPort === undefined ? {} : { cdpPort: browserLaunchIdentity.cdpPort }),
1753
1823
  ...(browserLaunchIdentity?.profileDir === undefined ? {} : { profileDir: browserLaunchIdentity.profileDir }),
1754
1824
  targetUrl
1755
- }, browserTargetId)
1825
+ }, browserTargetId,
1826
+ // Once per lane: a dark observation channel is a gap in the instrument, and the
1827
+ // funnel's NEVER MEASURED count needs this line to explain itself (#514).
1828
+ (reason) => {
1829
+ warnings.push(`Browser-state observer unavailable for lane ${spec.laneId} (${redactText(deps.scrubKnownValues(reason))}); ` +
1830
+ "urlIncludes/urlPathEquals/textIncludes stop conditions and task criteria are NOT being measured this session.");
1831
+ })
1756
1832
  }
1757
1833
  }
1758
1834
  : {}),
@@ -1837,6 +1913,7 @@ export async function runCuaLane(spec, deps) {
1837
1913
  screen: desktopGeometry.screen,
1838
1914
  ...(chosenGeometry.browserWindow === undefined ? {} : { browserWindow: chosenGeometry.browserWindow }),
1839
1915
  ...(chosenGeometry.viewport === undefined ? {} : { viewport: chosenGeometry.viewport }),
1916
+ ...(appliedFidelity === undefined ? {} : { fidelity: appliedFidelity }),
1840
1917
  ...((desktopGeometry.warnings?.length ?? 0) + geometryWarnings.length === 0
1841
1918
  ? {}
1842
1919
  : { warnings: [...(desktopGeometry.warnings ?? []), ...geometryWarnings] })
@@ -3288,13 +3365,17 @@ async function runSubjectServePipeline(desktop, args) {
3288
3365
  if (needsNodeRuntime(serveCommands)) {
3289
3366
  const runtimeStartedAt = now();
3290
3367
  emitPhaseStarted(args.onPhase, now, "runtime", "providing the Node runtime the serve pipeline needs");
3291
- const bootstrap = await runDetachedStep(desktop, {
3368
+ const bootstrap = await runProvisioningStepWithOneRetry(desktop, {
3292
3369
  name: "subject-runtime-node",
3293
3370
  command: nodeBootstrapCommand(),
3294
3371
  cwd: SUBJECT_DIR,
3295
3372
  timeoutMs: args.serve.installTimeoutMs ?? INSTALL_TIMEOUT_MS,
3296
3373
  requestTimeoutMs: args.requestTimeoutMs,
3297
- ...timers
3374
+ timers,
3375
+ retryPhase: "runtime-retry",
3376
+ retryMessage: "Node runtime bootstrap",
3377
+ onPhase: args.onPhase,
3378
+ now
3298
3379
  });
3299
3380
  let ok = bootstrap.ok;
3300
3381
  const corepack = ok ? corepackCommandFor(serveCommands) : undefined;
@@ -3311,23 +3392,39 @@ async function runSubjectServePipeline(desktop, args) {
3311
3392
  }
3312
3393
  emitPhaseCompleted(args.onPhase, now, runtimeStartedAt, "runtime", ok, ok ? "Node runtime ready" : "could not provide a Node runtime");
3313
3394
  if (!ok) {
3314
- throw new Error(`the subject's serve pipeline needs a Node runtime and this desktop template has none, and bootstrapping one failed: ${tailOf(args.scrub(bootstrap.logTail))}. Use execution.desktop.template with an image that ships Node, or change serve.install to a runtime the template provides.`);
3395
+ throw new Error(`the subject's serve pipeline needs a Node runtime and this desktop template has none, and bootstrapping one failed${bootstrap.attempts === 2 ? " twice" : ""}: ${tailOf(args.scrub(bootstrap.logTail))}. Use execution.desktop.template with an image that ships Node, or change serve.install to a runtime the template provides.`);
3315
3396
  }
3316
3397
  }
3317
3398
  if (args.serve.install) {
3318
3399
  const installStartedAt = now();
3319
3400
  emitPhaseStarted(args.onPhase, now, "install", "installing subject dependencies");
3320
- const install = await runDetachedStep(desktop, {
3401
+ const install = await runProvisioningStepWithOneRetry(desktop, {
3321
3402
  name: "subject-install",
3322
3403
  command: args.serve.install,
3323
3404
  cwd: SUBJECT_DIR,
3324
3405
  timeoutMs: args.serve.installTimeoutMs ?? INSTALL_TIMEOUT_MS,
3325
3406
  requestTimeoutMs: args.requestTimeoutMs,
3326
- ...timers
3407
+ timers,
3408
+ retryPhase: "install-retry",
3409
+ retryMessage: "subject install",
3410
+ onPhase: args.onPhase,
3411
+ now
3327
3412
  });
3328
- emitPhaseCompleted(args.onPhase, now, installStartedAt, "install", install.ok, install.ok ? "subject dependencies installed" : "subject install failed");
3413
+ emitPhaseCompleted(args.onPhase, now, installStartedAt, "install", install.ok, install.ok
3414
+ ? install.attempts === 2
3415
+ ? "subject dependencies installed (on the second attempt)"
3416
+ : "subject dependencies installed"
3417
+ : install.attempts === 2
3418
+ ? "subject install failed twice"
3419
+ : "subject install failed");
3329
3420
  if (!install.ok) {
3330
- throw new Error(`subject install ${install.timedOut ? "timed out" : `failed (exit ${install.exitCode})`}: ${tailOf(args.scrub(install.logTail))}`);
3421
+ // Lead with the line a person can act on; npm's own trace follows it (#602).
3422
+ const headline = install.timedOut
3423
+ ? `subject install timed out after ${args.serve.installTimeoutMs ?? INSTALL_TIMEOUT_MS}ms`
3424
+ : install.attempts === 2
3425
+ ? `subject install failed twice (exit ${install.firstExitCode ?? "null"}, then exit ${install.exitCode ?? "null"}); the sandbox could not complete serve.install`
3426
+ : `subject install failed (exit ${install.exitCode ?? "null"})`;
3427
+ throw new Error(`${headline}: ${tailOf(args.scrub(install.logTail))}`);
3331
3428
  }
3332
3429
  await refresh();
3333
3430
  }