humanish 0.139.1 → 0.140.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/CHANGELOG.md +11 -0
  2. package/README.md +5 -4
  3. package/dist/analysis/automatic-completion.js +24 -0
  4. package/dist/analysis/automatic-config.d.ts +15 -6
  5. package/dist/analysis/automatic-config.js +25 -9
  6. package/dist/cli/commands/study-route-computer-use.js +2 -3
  7. package/dist/cli/commands/study-route-scripted.js +3 -4
  8. package/dist/cli/commands/study-route-shared-world.js +2 -3
  9. package/dist/cli/commands/study-route-terminal.js +3 -4
  10. package/dist/cli/commands/study-run.js +1 -1
  11. package/dist/cli/doctor.d.ts +2 -0
  12. package/dist/cli/doctor.js +3 -0
  13. package/dist/cli/findings.d.ts +17 -14
  14. package/dist/cli/findings.js +37 -31
  15. package/dist/cli/io.d.ts +5 -1
  16. package/dist/cli/io.js +11 -2
  17. package/dist/cli/run-findings.d.ts +19 -0
  18. package/dist/cli/run-findings.js +106 -0
  19. package/dist/guest/bootstrap.d.ts +4 -2
  20. package/dist/guest/bootstrap.js +15 -7
  21. package/dist/guest/display-config.d.ts +31 -0
  22. package/dist/guest/display-config.js +35 -0
  23. package/dist/guest/runtime-desktop.d.ts +9 -3
  24. package/dist/guest/runtime-desktop.js +59 -25
  25. package/dist/guest/runtime.d.ts +2 -1
  26. package/dist/guest/runtime.js +1 -1
  27. package/dist/guest-runtime-main.js +2 -1
  28. package/dist/routes/computer-use/e2b-desktop/fidelity.js +10 -17
  29. package/dist/routes/computer-use/local-vm.js +34 -1
  30. package/dist/routes/computer-use/plan.js +1 -1
  31. package/dist/routes/scripted/plan.js +1 -1
  32. package/dist/routes/shared-world/plan.js +1 -1
  33. package/dist/routes/terminal/plan.js +1 -1
  34. package/dist/run/route-shell.js +1 -1
  35. package/dist/study/doctor.js +11 -6
  36. package/dist/study/init-templates.js +9 -3
  37. package/dist/study/mobile-emulation.d.ts +27 -0
  38. package/dist/study/mobile-emulation.js +60 -0
  39. package/dist/study/plan.js +1 -1
  40. package/dist/study/preflight-result.js +1 -1
  41. package/dist/study/summary.js +1 -1
  42. package/dist/study/warnings.js +5 -2
  43. package/dist/substrates/e2b/cdp-probe.d.ts +2 -6
  44. package/dist/substrates/e2b/desktop-cdp.d.ts +0 -2
  45. package/dist/substrates/e2b/desktop-cdp.js +0 -2
  46. package/dist/substrates/local/firecracker-desktop.d.ts +5 -0
  47. package/dist/substrates/local/firecracker-desktop.js +7 -1
  48. package/dist/substrates/local/runtime-config.d.ts +12 -0
  49. package/dist/substrates/local/runtime-config.js +55 -13
  50. package/dist/substrates/local/runtime-release.js +8 -8
  51. package/dist/substrates/local/runtime.js +1 -0
  52. package/docs/contracts/study-analysis.md +14 -8
  53. package/docs/principles/invariants-and-defaults.md +1 -1
  54. package/docs/product/automatic-analysis.md +46 -11
  55. package/package.json +1 -1
  56. package/skills/humanish/SKILL.md +45 -26
@@ -3,7 +3,9 @@ import path from "node:path";
3
3
  import { participantList } from "../../study/study-fields.js";
4
4
  import { inboxRecipientFor, } from "./participant-desktop.js";
5
5
  import { createLocalFirecrackerDesktop, } from "../../substrates/local/firecracker-desktop.js";
6
- import { localBrowserDefaults, localBrowserUnsupportedReason, } from "../../substrates/local/runtime-config.js";
6
+ import { localBrowserDefaults, localBrowserUnsupportedReason, localGuestDisplay, } from "../../substrates/local/runtime-config.js";
7
+ import { mobileEmulationCommands } from "../../study/mobile-emulation.js";
8
+ import { declaredScreenForRender } from "../../substrates/e2b/desktop-geometry.js";
7
9
  import { localRuntimeCapacity, prepareLocalRuntime } from "../../substrates/local/runtime.js";
8
10
  import { dockerCommandLine, usesLima } from "../../substrates/local/runtime-host.js";
9
11
  import { outOfMemoryMessage } from "../../substrates/local/capacity.js";
@@ -52,6 +54,30 @@ function mediaEvidence(media) {
52
54
  },
53
55
  };
54
56
  }
57
+ /**
58
+ * The geometry a local participant records when its guest emulates a phone: the requested screen
59
+ * and the emulation the guest applied before the first page loaded. The guest does not read back
60
+ * what the page reports, so the block has no `resolved` part and says so.
61
+ */
62
+ function emulatedGeometry(run, display) {
63
+ const { device } = run.planned;
64
+ const declared = declaredScreenForRender(device.preset, device.name, device.resolution);
65
+ const request = display.mobileEmulation;
66
+ return {
67
+ screen: {
68
+ requested: { width: display.width, height: display.height },
69
+ ...(declared === undefined ? {} : { declared }),
70
+ },
71
+ fidelity: {
72
+ tier: "mobile-emulated",
73
+ requested: request,
74
+ applied: mobileEmulationCommands(request).map((command) => command.method),
75
+ },
76
+ warnings: [
77
+ `Mobile emulation was applied for participant ${run.planned.id} in the local guest, which does not read back the page's own report; desktopGeometry.fidelity carries the request without a resolved block.`,
78
+ ],
79
+ };
80
+ }
55
81
  /** One participant's Firecracker desktop and optional captured inbox, released in finalize. */
56
82
  function createLocalParticipantDesktop(context, run, warnings, artifactRoot) {
57
83
  const { config, media, recording, state } = context;
@@ -71,11 +97,13 @@ function createLocalParticipantDesktop(context, run, warnings, artifactRoot) {
71
97
  const runtime = await context.assets();
72
98
  if (address && email?.kind === "fake" && email.external)
73
99
  inbox = await startLocalCapturedInbox(email.external, address);
100
+ const display = localGuestDisplay(config, run.planned.device);
74
101
  session = await createLocalFirecrackerDesktop({
75
102
  assets: runtime,
76
103
  ...(inbox ? { inboxUrl: inbox.url } : {}),
77
104
  ...(media === undefined ? {} : { media }),
78
105
  ...(recording === undefined ? {} : { recording }),
106
+ ...(display === undefined ? {} : { display }),
79
107
  appUrl: run.planned.targetUrl ?? config.subject.appUrl,
80
108
  outputRoot: path.join(context.cwd, ".humanish", "local-runtime"),
81
109
  ...(context.signal === undefined ? {} : { signal: context.signal }),
@@ -83,6 +111,11 @@ function createLocalParticipantDesktop(context, run, warnings, artifactRoot) {
83
111
  state.sessions.push(session);
84
112
  if (media !== undefined)
85
113
  evidence.desktopBrowser = mediaEvidence(media);
114
+ if (display?.mobileEmulation !== undefined)
115
+ evidence.desktopGeometry = emulatedGeometry(run, {
116
+ ...display,
117
+ mobileEmulation: display.mobileEmulation,
118
+ });
86
119
  },
87
120
  async openSession() {
88
121
  if (!session)
@@ -343,7 +343,7 @@ export function planComputerUseStudy(config, input) {
343
343
  ...(actor === undefined ? {} : { actor }),
344
344
  },
345
345
  });
346
- const analysis = resolveAutomaticAnalysis(config.review?.analysis);
346
+ const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
347
347
  if (!analysis.ok)
348
348
  return refuse("before-scope", "HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message);
349
349
  const tasksReason = taskProtocolValidationReason(config, true);
@@ -34,7 +34,7 @@ export function planScriptedStudy(config, input) {
34
34
  const beforeScope = { beforeScope: true };
35
35
  if (String(config.comms?.email?.kind) === "real")
36
36
  return refuse("HUMANISH_SCRIPTED_SCENARIO_INVALID", "Real email receiving is unsupported on the scripted route. Use a supported hosted computer-use browser study.", beforeScope);
37
- const analysis = resolveAutomaticAnalysis(config.review?.analysis);
37
+ const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
38
38
  if (!analysis.ok)
39
39
  return refuse("HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message, beforeScope);
40
40
  const tasksReason = taskProtocolValidationReason(config, false);
@@ -38,7 +38,7 @@ export function planSharedWorldStudy(config, input) {
38
38
  networkCaptureValidationReason(config, "shared-world");
39
39
  if (mediaReason)
40
40
  return refuse(invalid, mediaReason);
41
- const analysis = resolveAutomaticAnalysis(config.review?.analysis);
41
+ const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
42
42
  if (!analysis.ok)
43
43
  return refuse("HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message);
44
44
  const tasksReason = taskProtocolValidationReason(config, false);
@@ -39,7 +39,7 @@ export function planTerminalStudy(config, input) {
39
39
  const modal = sandboxProvider === "modal" ? modalRefusal(config) : undefined;
40
40
  if (modal !== undefined)
41
41
  return refuse(modal.code, modal.message);
42
- const analysis = resolveAutomaticAnalysis(config.review?.analysis);
42
+ const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
43
43
  if (!analysis.ok)
44
44
  return refuse("HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message);
45
45
  const tasksReason = taskProtocolValidationReason(config, false);
@@ -42,7 +42,7 @@ export async function admitRoute(shell) {
42
42
  * or default analysis is recorded as skipped.
43
43
  */
44
44
  export function completeRefusalAnalysis(refused, config, input) {
45
- const analysis = resolveAutomaticAnalysis(config.review?.analysis);
45
+ const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
46
46
  return completeAutomaticAnalysis(refused, undefined, analysis.ok ? analysis.config : undefined, input, { trigger: config.review?.analysis === undefined ? "default" : "explicit" });
47
47
  }
48
48
  /** A result as its route's outcome. StudyOutcome pairs each route with its result type. */
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
2
+ import { isLocalBrowserStudy, localStudySetsScreens } from "../substrates/local/runtime-config.js";
3
3
  import { localRuntimeStatus } from "../substrates/local/runtime.js";
4
4
  import { capacityShortfall, describeCapacity, } from "../substrates/local/capacity.js";
5
5
  import { keyNamesOf, requiredKeys, requiredSubjectEnv } from "./requirements.js";
@@ -204,7 +204,7 @@ export async function studySetupChecks(args) {
204
204
  if (route === "scripted")
205
205
  checks.push(await scriptedBrowserCheck());
206
206
  checks.push(...subjectEnvChecks(requiredSubjectEnv(requirements), args));
207
- const analysis = automaticAnalysisBudget(config.review?.analysis, route);
207
+ const analysis = automaticAnalysisBudget(config.review?.analysis, route, undefined, config.actor);
208
208
  if (analysis)
209
209
  checks.push(await analysisCheck(analysis, args, checkAccount));
210
210
  checks.push(checkScope(analysis));
@@ -231,10 +231,13 @@ async function localBrowserChecks(config, args, desktopsAtOnce) {
231
231
  media: config.execution?.desktop?.media !== undefined ||
232
232
  config.execution?.desktop?.recording !== undefined,
233
233
  })))();
234
+ const screens = runtime.installed && runtime.assets?.displaySizes !== true && localStudySetsScreens(config)
235
+ ? "The installed local runtime starts every participant at 960×720, and this study sets other screen sizes. Omit device and resolution, use a hosted desktop, or set HUMANISH_LOCAL_RUNTIME_IMAGE to a runtime built from current source."
236
+ : undefined;
234
237
  checks.push({
235
238
  name: "local browser runtime",
236
- ok: runtime.ok,
237
- message: runtime.message,
239
+ ok: runtime.ok && screens === undefined,
240
+ message: screens ?? runtime.message,
238
241
  ...(runtime.ok && !runtime.installed ? { status: "note" } : {}),
239
242
  });
240
243
  if (runtime.capacity !== undefined && desktopsAtOnce !== undefined)
@@ -252,7 +255,9 @@ async function localBrowserChecks(config, args, desktopsAtOnce) {
252
255
  }
253
256
  return {
254
257
  checks,
255
- ...(runtime.ok && runtime.installed ? {} : { next: runtime.message }),
258
+ ...(runtime.ok && runtime.installed && screens === undefined
259
+ ? {}
260
+ : { next: screens ?? runtime.message }),
256
261
  };
257
262
  }
258
263
  /** Whether the desktops the study runs at once fit the VM or this machine, and why. */
@@ -384,7 +389,7 @@ async function analysisCheck(analysis, args, checkAccount) {
384
389
  ...(keyed ? {} : { status: "note" }),
385
390
  message: keyed
386
391
  ? `OPENAI_API_KEY is present for the separate automatic analysis request; model access and quota are not tested. The analysis is refused before it starts if ${admissionRule(`$${analysis.maxCostUsd}`)}; this is not a billing cap. Participant readiness is independent.`
387
- : "Will be skipped: OPENAI_API_KEY is missing. The participant may run, but there will be no automatic findings report. Add an OpenAI API key or set review.analysis: false deliberately.",
392
+ : "Will be skipped: OPENAI_API_KEY is missing. The participant may run, but there will be no automatic findings report. Add an OpenAI API key, or set review.analysis.provider: codex to have a signed-in Codex account analyze the run instead, or set review.analysis: false deliberately.",
388
393
  };
389
394
  }
390
395
  /** What doctor checked and what it did not. */
@@ -62,6 +62,7 @@ Studies:
62
62
  - private or machine-local studies live in ignored .humanish/studies/*.yaml or
63
63
  .humanish/local/studies/*.yaml;
64
64
  - run a study with \`${command} run <study>\`, or \`${command} watch <study>\` to stay attached;
65
+ - a live run ends with findings from a separate analyst; \`${command} review --run latest\` shows them;
65
66
  - files under humanish/labs/ move there with \`${command} migrate\`.
66
67
 
67
68
  Format standard:
@@ -79,12 +80,17 @@ Format standard:
79
80
  */
80
81
  function tryLiveStudy(actor, localAgent = "codex") {
81
82
  const account = actor === "local-agent";
83
+ const analyst = localAgent === "codex"
84
+ ? `The analysis after the run uses the same Codex account. Set review.analysis: false to skip
85
+ it.`
86
+ : `When OPENAI_API_KEY is set, the analysis after the run bills it: humanish refuses that request
87
+ before it starts if its estimate is over $${DEFAULT_ANALYSIS_MAX_COST_USD}. With Codex also signed
88
+ in, review.analysis.provider: codex has its account analyze the run instead. Set
89
+ review.analysis: false to skip it.`;
82
90
  const needs = account
83
91
  ? ` Needs E2B_API_KEY and the coding agent signed in on this machine. Cost: one small task, a few
84
92
  E2B desktop minutes and the agent's account usage. humanish cannot put a dollar cap on account
85
- usage, so the ten-minute session limit below bounds the run. When OPENAI_API_KEY is set, the
86
- analysis after the run bills it: humanish refuses that request before it starts if its estimate
87
- is over $${DEFAULT_ANALYSIS_MAX_COST_USD}. Set review.analysis: false to skip it.`
93
+ usage, so the ten-minute session limit below bounds the run. ${analyst}`
88
94
  : ` Needs E2B_API_KEY and OPENAI_API_KEY. Cost: one small task with a $2 cap on estimated model
89
95
  spend. The run stops before its next model request once the estimate passes $2, so the last
90
96
  request can go slightly over, and hosted desktop time is billed separately. The analysis after
@@ -0,0 +1,27 @@
1
+ import type { DevicePreset } from "./device-presets.js";
2
+ import type { StudyDesktopFidelity } from "./types.js";
3
+ /** The CDP Emulation overrides one participant's Chromium pages get. */
4
+ export interface MobileEmulationRequest {
5
+ width: number;
6
+ height: number;
7
+ deviceScaleFactor: number;
8
+ touch: boolean;
9
+ userAgent: string;
10
+ }
11
+ /**
12
+ * The emulation a participant gets, or undefined. Only a participant on a mobile preset is
13
+ * emulated: a run-wide `mobileEmulation` must not hand a desktop or tablet participant beside a
14
+ * phone participant an iPhone user agent. Hosted and local desktops both read this.
15
+ */
16
+ export declare function participantMobileEmulation(fidelity: StudyDesktopFidelity | undefined, preset: DevicePreset): MobileEmulationRequest | undefined;
17
+ /** Chromium flags that carry the user agent and touch support to every page and worker. */
18
+ export declare function mobileEmulationLaunchFlags(request: MobileEmulationRequest | undefined): string[];
19
+ /**
20
+ * The CDP commands that apply a request to one page, in order. The local guest sends these over
21
+ * its own page session; the hosted desktop's in-sandbox probe sends the same Emulation domain.
22
+ * Touch adds mouse-to-touch conversion, since participants drive a desktop pointer.
23
+ */
24
+ export declare function mobileEmulationCommands(request: MobileEmulationRequest): {
25
+ method: string;
26
+ params: Record<string, unknown>;
27
+ }[];
@@ -0,0 +1,60 @@
1
+ /** The user agent a mobile-emulated participant presents unless the study sets its own. */
2
+ 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";
3
+ /**
4
+ * The emulation a participant gets, or undefined. Only a participant on a mobile preset is
5
+ * emulated: a run-wide `mobileEmulation` must not hand a desktop or tablet participant beside a
6
+ * phone participant an iPhone user agent. Hosted and local desktops both read this.
7
+ */
8
+ export function participantMobileEmulation(fidelity, preset) {
9
+ if (!fidelity?.mobileEmulation || !preset.isMobile)
10
+ return undefined;
11
+ return {
12
+ width: preset.width,
13
+ height: preset.height,
14
+ deviceScaleFactor: fidelity.deviceScaleFactor ?? preset.deviceScaleFactor,
15
+ touch: fidelity.touch ?? true,
16
+ userAgent: fidelity.userAgent ?? DEFAULT_MOBILE_USER_AGENT,
17
+ };
18
+ }
19
+ /** Chromium flags that carry the user agent and touch support to every page and worker. */
20
+ export function mobileEmulationLaunchFlags(request) {
21
+ if (request === undefined)
22
+ return [];
23
+ return [
24
+ `--user-agent=${request.userAgent}`,
25
+ ...(request.touch ? ["--touch-events=enabled"] : []),
26
+ ];
27
+ }
28
+ /**
29
+ * The CDP commands that apply a request to one page, in order. The local guest sends these over
30
+ * its own page session; the hosted desktop's in-sandbox probe sends the same Emulation domain.
31
+ * Touch adds mouse-to-touch conversion, since participants drive a desktop pointer.
32
+ */
33
+ export function mobileEmulationCommands(request) {
34
+ return [
35
+ {
36
+ method: "Emulation.setDeviceMetricsOverride",
37
+ params: {
38
+ width: request.width,
39
+ height: request.height,
40
+ deviceScaleFactor: request.deviceScaleFactor,
41
+ mobile: true,
42
+ screenWidth: request.width,
43
+ screenHeight: request.height,
44
+ },
45
+ },
46
+ ...(request.touch
47
+ ? [
48
+ {
49
+ method: "Emulation.setTouchEmulationEnabled",
50
+ params: { enabled: true, maxTouchPoints: 5 },
51
+ },
52
+ {
53
+ method: "Emulation.setEmitTouchEventsForMouse",
54
+ params: { enabled: true, configuration: "mobile" },
55
+ },
56
+ ]
57
+ : []),
58
+ { method: "Emulation.setUserAgentOverride", params: { userAgent: request.userAgent } },
59
+ ];
60
+ }
@@ -41,7 +41,7 @@ function planPreview(config, options, input) {
41
41
  });
42
42
  if (String(config.comms?.email?.kind) === "real")
43
43
  return refuse("HUMANISH_STUDY_COMMS_UNSUPPORTED", "Real email receiving is unsupported on the preview route. Use a supported hosted computer-use study.");
44
- const analysis = resolveAutomaticAnalysis(config.review?.analysis);
44
+ const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
45
45
  if (!analysis.ok)
46
46
  return refuse("HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message);
47
47
  const unsupported = automaticAnalysisRouteReason(config);
@@ -7,7 +7,7 @@ import { computerUseParticipantCount } from "./routing.js";
7
7
  export const STUDY_CHECK_SCHEMA = "humanish.study-check.v1";
8
8
  export function finalize(ctx, args) {
9
9
  const checks = [...ctx.checks, ...(args?.check ? [args.check] : []), ...(args?.checks ?? [])];
10
- const analysis = automaticAnalysisBudget(ctx.config.review?.analysis, ctx.route, computerUseParticipantCount(ctx.config));
10
+ const analysis = automaticAnalysisBudget(ctx.config.review?.analysis, ctx.route, computerUseParticipantCount(ctx.config), ctx.config.actor);
11
11
  return {
12
12
  schema: STUDY_CHECK_SCHEMA,
13
13
  ...(analysis ? { analysis } : {}),
@@ -123,7 +123,7 @@ export async function readStudySummary(cwd, study, options = {}) {
123
123
  missingKeys = missing;
124
124
  }
125
125
  // Computed once: a test-then-use pair reads as though the two calls could differ.
126
- const analysis = automaticAnalysisBudget(inspected.config.review?.analysis, route, computerUseParticipantCount(inspected.config));
126
+ const analysis = automaticAnalysisBudget(inspected.config.review?.analysis, route, computerUseParticipantCount(inspected.config), inspected.config.actor);
127
127
  const subject = subjectOf(config);
128
128
  const participants = participantsOf(inspected.config);
129
129
  const caps = summaryCapsOf(inspected.config);
@@ -1,6 +1,7 @@
1
1
  import { cli } from "../cli/invocation.js";
2
2
  import { isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
3
3
  import { DESKTOP_FILES_ROUTE_REASON } from "./desktop-files.js";
4
+ import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
4
5
  import { computerUseParticipants, declaredParticipantIds, sharedWorldParticipants, } from "./plan-participants.js";
5
6
  import { addressedRecipients } from "./parse/comms.js";
6
7
  import { participantList, participantInstruction, declaredParticipantCount, } from "./study-fields.js";
@@ -198,8 +199,10 @@ const CONFIG_ROWS = [
198
199
  },
199
200
  {
200
201
  field: "execution.desktop.fidelity",
201
- reason: "mobile emulation is applied only to computer-use participants in hosted Chromium on execution.target: e2b-desktop",
202
- applies: (config, routes) => !routes.hostedCuaBrowser && config.execution?.desktop?.fidelity !== undefined,
202
+ reason: "mobile emulation is applied only to computer-use participants in Chromium on a hosted desktop or execution.target: local",
203
+ applies: (config, routes) => !routes.hostedCuaBrowser &&
204
+ !isLocalBrowserStudy(config) &&
205
+ config.execution?.desktop?.fidelity !== undefined,
203
206
  },
204
207
  {
205
208
  field: "execution.desktop.sandboxTimeoutMs",
@@ -1,4 +1,5 @@
1
1
  import { type ChromeNetworkCaptureRequest } from "./cdp-network-capture.js";
2
+ import type { MobileEmulationRequest } from "../../study/mobile-emulation.js";
2
3
  /** Which target to attribute when the endpoint lists several pages. */
3
4
  export type ChromeCdpPagePreference = "pinned" | "active";
4
5
  export interface ChromeCdpProbeArgs {
@@ -37,12 +38,7 @@ export interface ChromeCdpProbeArgs {
37
38
  network?: ChromeNetworkCaptureRequest;
38
39
  }
39
40
  /** Mobile emulation request: the CDP Emulation domain applied to one page target. */
40
- export interface ChromeMobileEmulationRequest {
41
- width: number;
42
- height: number;
43
- deviceScaleFactor: number;
44
- touch: boolean;
45
- userAgent: string;
41
+ export interface ChromeMobileEmulationRequest extends MobileEmulationRequest {
46
42
  platform?: string;
47
43
  }
48
44
  /** What the page reports after emulation: the proof, never copied from the request. */
@@ -66,8 +66,6 @@ export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbo
66
66
  text?: string;
67
67
  scrollY?: number;
68
68
  }>;
69
- /** The user agent a mobile-emulated participant presents unless the study sets its own. */
70
- export declare 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";
71
69
  /**
72
70
  * Apply mobile emulation to the participant's launch page and read back what the page reports.
73
71
  * Fails closed: a request that cannot be applied throws, because a desktop run labelled mobile is
@@ -97,8 +97,6 @@ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoi
97
97
  };
98
98
  };
99
99
  }
100
- /** The user agent a mobile-emulated participant presents unless the study sets its own. */
101
- 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";
102
100
  /**
103
101
  * Apply mobile emulation to the participant's launch page and read back what the page reports.
104
102
  * Fails closed: a request that cannot be applied throws, because a desktop run labelled mobile is
@@ -2,11 +2,14 @@ import type { Writable } from "node:stream";
2
2
  import { type DesktopRecordingConfig, type DesktopRecordingMetadata } from "../../evidence/desktop-recording-types.js";
3
3
  import { type DesktopSession } from "../desktop-session.js";
4
4
  import { type GuestMediaConfig } from "../../guest/media-config.js";
5
+ import { type GuestDisplayConfig } from "../../guest/display-config.js";
5
6
  /** The installed runtime image; `humanish runtime setup` chooses it, never a participant. */
6
7
  export interface LocalFirecrackerAssets {
7
8
  image: string;
8
9
  runtimeRevision: string;
9
10
  media?: boolean;
11
+ /** The guest starts the screen and emulation a bootstrap names; older images are 960×720 only. */
12
+ displaySizes?: boolean;
10
13
  }
11
14
  export interface LocalFirecrackerDesktop extends DesktopSession {
12
15
  finishRecording(destination: Writable): Promise<DesktopRecordingMetadata>;
@@ -26,6 +29,8 @@ interface LocalFirecrackerOptions {
26
29
  signal?: AbortSignal;
27
30
  media?: GuestMediaConfig;
28
31
  recording?: DesktopRecordingConfig;
32
+ /** The participant's screen and mobile emulation; absent is the guest's 960×720 default. */
33
+ display?: GuestDisplayConfig;
29
34
  inboxUrl?: string;
30
35
  }
31
36
  /** One VM with opaque TCP forwards to the selected app and optional scoped inbox. */
@@ -9,6 +9,7 @@ import { ownDesktopAllocation, } from "../desktop-session.js";
9
9
  import { runtimeDocker, runtimeExec, usesLima } from "./runtime-host.js";
10
10
  import { openLimaTunnel } from "./runtime-ssh.js";
11
11
  import { guestMediaConfigSchema } from "../../guest/media-config.js";
12
+ import { guestDisplayConfigSchema } from "../../guest/display-config.js";
12
13
  import { cli } from "../../cli/invocation.js";
13
14
  import { DESKTOP_RESERVATION } from "./capacity.js";
14
15
  const docker = async (args) => (await runtimeDocker(args, {}, 60_000)).stdout.trim();
@@ -55,6 +56,11 @@ function checkDesktopOptions(options) {
55
56
  if (options.assets.media !== true)
56
57
  throw new Error(`This runtime does not include the recorder. Run ${cli("runtime setup --media")}.`);
57
58
  }
59
+ if (options.display !== undefined) {
60
+ const { width, height } = guestDisplayConfigSchema.parse(options.display);
61
+ if (options.assets.displaySizes !== true)
62
+ throw new Error(`This local runtime starts every participant at 960×720, so it cannot start the ${width}×${height} screen this participant needs. Omit device and resolution, use a hosted desktop, or set HUMANISH_LOCAL_RUNTIME_IMAGE to a runtime built from current source.`);
63
+ }
58
64
  let url;
59
65
  try {
60
66
  url = new URL(validateGuestInitialUrl(options.appUrl));
@@ -261,7 +267,7 @@ async function bootstrapGuest(stream, options, url, signal) {
261
267
  runtimeRevision: options.assets.runtimeRevision,
262
268
  };
263
269
  try {
264
- return await connectGuestBootstrap(stream, identity, signal, url.href, options.media, options.recording);
270
+ return await connectGuestBootstrap(stream, identity, signal, url.href, options.media, options.recording, options.display);
265
271
  }
266
272
  catch (error) {
267
273
  signal.throwIfAborted();
@@ -1,6 +1,18 @@
1
1
  import type { StudyConfig } from "../../study/types.js";
2
+ import { type DevicePreset } from "../../study/device-presets.js";
3
+ import { type GuestDisplayConfig } from "../../guest/display-config.js";
2
4
  export declare const LOCAL_BROWSER_LIFETIME_MS: number;
3
5
  export declare function isLocalBrowserStudy(config: StudyConfig): boolean;
4
6
  /** Defaults for the explicitly selected local substrate; hosted configurations are untouched. */
5
7
  export declare function localBrowserDefaults(config: StudyConfig): StudyConfig;
6
8
  export declare function localBrowserUnsupportedReason(config: StudyConfig): string | undefined;
9
+ /**
10
+ * The screen a local participant's guest starts, with mobile emulation where hosted desktops apply
11
+ * it. Undefined is the guest's 960×720 default, which every installed runtime supports.
12
+ */
13
+ export declare function localGuestDisplay(config: StudyConfig, device: {
14
+ preset: DevicePreset;
15
+ resolution: readonly [number, number];
16
+ }): GuestDisplayConfig | undefined;
17
+ /** Whether any participant of a local study needs a screen other than the guest's default. */
18
+ export declare function localStudySetsScreens(config: StudyConfig): boolean;
@@ -1,4 +1,7 @@
1
1
  import { participantList } from "../../study/study-fields.js";
2
+ import { DEVICE_PRESET_NAMES, resolveParticipantDevice, } from "../../study/device-presets.js";
3
+ import { participantMobileEmulation } from "../../study/mobile-emulation.js";
4
+ import { DEFAULT_GUEST_SCREEN, GUEST_SCREEN_LIMITS, } from "../../guest/display-config.js";
2
5
  // The runtime image enforces a 30-minute lifetime; reserve setup/teardown room.
3
6
  export const LOCAL_BROWSER_LIFETIME_MS = 30 * 60_000;
4
7
  const MAX_SESSION_MS = 20 * 60_000;
@@ -24,12 +27,18 @@ export function localBrowserDefaults(config) {
24
27
  timeoutMs: config.execution?.timeoutMs ?? MAX_SESSION_MS,
25
28
  desktop: {
26
29
  ...config.execution?.desktop,
27
- resolution: config.execution?.desktop?.resolution ?? [960, 720],
30
+ // A named device governs the screen when the study sets one; resolution would override it.
31
+ ...(config.execution?.desktop?.device !== undefined ||
32
+ participantList(config)?.some((entry) => entry.device !== undefined)
33
+ ? {}
34
+ : {
35
+ resolution: config.execution?.desktop?.resolution ?? [
36
+ DEFAULT_GUEST_SCREEN.width,
37
+ DEFAULT_GUEST_SCREEN.height,
38
+ ],
39
+ }),
28
40
  },
29
41
  },
30
- ...(config.review?.analysis === undefined && actor.type === "local-agent"
31
- ? { review: { ...config.review, analysis: { provider: "codex" } } }
32
- : {}),
33
42
  };
34
43
  }
35
44
  export function localBrowserUnsupportedReason(config) {
@@ -42,20 +51,19 @@ export function localBrowserUnsupportedReason(config) {
42
51
  (actor.type === "local-agent" && actor.localAgent !== undefined && actor.localAgent !== "codex")) {
43
52
  return "Local browser studies support only Codex local-agent or openai-computer-use participants.";
44
53
  }
45
- if (desktop?.resolution?.[0] !== 960 ||
46
- desktop.resolution[1] !== 720 ||
47
- desktop.device !== undefined ||
48
- (desktop.browser !== undefined && !["default", "chromium"].includes(desktop.browser)) ||
49
- participantList(config)?.some((entry) => entry.device !== undefined) ||
50
- desktop.template !== undefined ||
51
- desktop.sandboxTimeoutMs !== undefined) {
52
- return "The local browser runtime currently uses a 960×720 Chromium desktop. Omit hosted templates, device presets and sandboxTimeoutMs.";
54
+ if ((desktop?.browser !== undefined && !["default", "chromium"].includes(desktop.browser)) ||
55
+ desktop?.template !== undefined ||
56
+ desktop?.sandboxTimeoutMs !== undefined) {
57
+ return "The local browser runtime runs Chromium and has no hosted templates or sandboxTimeoutMs. Remove execution.desktop.browser, template and sandboxTimeoutMs, or use a hosted desktop.";
53
58
  }
59
+ const screenReason = localScreenReason(config);
60
+ if (screenReason)
61
+ return screenReason;
54
62
  if (config.comms !== undefined &&
55
63
  (config.comms.email?.kind !== "fake" || !config.comms.email.external)) {
56
64
  return "Local browser inboxes require captured email with comms.email.external.catchBaseUrl. Run humanish comms catch and point your app's email sends at it. Real receiving requires a supported hosted route.";
57
65
  }
58
- if (desktop.media?.camera !== undefined && desktop.media.camera.source !== "synthetic") {
66
+ if (desktop?.media?.camera !== undefined && desktop.media.camera.source !== "synthetic") {
59
67
  return "Local cameras currently use source: synthetic. Camera files remain supported on hosted desktops.";
60
68
  }
61
69
  if (actor.type === "local-agent" &&
@@ -87,3 +95,37 @@ export function localBrowserUnsupportedReason(config) {
87
95
  }
88
96
  return undefined;
89
97
  }
98
+ /** Why a participant's screen does not fit the local guest, or undefined. */
99
+ function localScreenReason(config) {
100
+ const limits = GUEST_SCREEN_LIMITS;
101
+ for (const entry of participantList(config) ?? [{}]) {
102
+ const [width, height] = resolveParticipantDevice(config, entry.device).resolution;
103
+ if (width < limits.minWidth ||
104
+ width > limits.maxWidth ||
105
+ height < limits.minHeight ||
106
+ height > limits.maxHeight)
107
+ return `Local browser desktops support screens up to ${limits.maxWidth}×${limits.maxHeight} and at least ${limits.minHeight} pixels high; this study asks for ${width}×${height}. Use a device preset (${DEVICE_PRESET_NAMES.join(", ")}), a resolution within those bounds, or a hosted desktop.`;
108
+ }
109
+ const userAgent = config.execution?.desktop?.fidelity?.userAgent;
110
+ if (userAgent !== undefined && !/^[\x20-\x7e]{1,512}$/.test(userAgent))
111
+ return "Local browser desktops take an execution.desktop.fidelity.userAgent of at most 512 printable ASCII characters. Shorten it or remove non-ASCII characters.";
112
+ return undefined;
113
+ }
114
+ /**
115
+ * The screen a local participant's guest starts, with mobile emulation where hosted desktops apply
116
+ * it. Undefined is the guest's 960×720 default, which every installed runtime supports.
117
+ */
118
+ export function localGuestDisplay(config, device) {
119
+ const mobileEmulation = participantMobileEmulation(config.execution?.desktop?.fidelity, device.preset);
120
+ const [width, height] = device.resolution;
121
+ if (mobileEmulation === undefined &&
122
+ width === DEFAULT_GUEST_SCREEN.width &&
123
+ height === DEFAULT_GUEST_SCREEN.height)
124
+ return undefined;
125
+ return { width, height, ...(mobileEmulation === undefined ? {} : { mobileEmulation }) };
126
+ }
127
+ /** Whether any participant of a local study needs a screen other than the guest's default. */
128
+ export function localStudySetsScreens(config) {
129
+ const study = localBrowserDefaults(config);
130
+ return (participantList(study) ?? [{}]).some((entry) => localGuestDisplay(study, resolveParticipantDevice(study, entry.device)) !== undefined);
131
+ }
@@ -1,18 +1,18 @@
1
1
  /** Updated with the verified, public runtime artifact before release. */
2
2
  const LOCAL_RUNTIME_RELEASE = {
3
- url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.09.28.1/runtime-linux-amd64.tar.gz",
4
- sha256: "224be52467d6a808c79f156c6537d8a34c2dfb7b71daa836d34c8611fbbb6647",
5
- bytes: 596142100,
6
- image: "sha256:89cec77ea8097d32f3ef14bf2bdb0a93ffd25c3975913f8c5ffc6a2f360482a2",
3
+ url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.10.21.1/runtime-linux-amd64.tar.gz",
4
+ sha256: "1de53db000f661e91ff6ea9a2eb8198b08b850621cf63d8ce7c1b046f5035faf",
5
+ bytes: 597029770,
6
+ image: "sha256:c805c310b7b086276efa74ea5735e8b559457c7edba85c5c9e3ac7934e8760f2",
7
7
  };
8
8
  /** Architecture-specific artifacts; each retains matching sources and notices. */
9
9
  export const LOCAL_RUNTIME_RELEASES = {
10
10
  amd64: LOCAL_RUNTIME_RELEASE,
11
11
  arm64: {
12
- url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.09.28.2/runtime-linux-arm64.tar.gz",
13
- sha256: "d03e1b6335bea76a22bf88ca9aa8dc1f4fe0af16fba02c654a333766e27cd2ea",
14
- bytes: 582661785,
15
- image: "sha256:c3d584d9586d2e86833ee8d050c43774d456c6c4f8f3c9a53194bd90910fa938",
12
+ url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.10.21.2/runtime-linux-arm64.tar.gz",
13
+ sha256: "ea7a42e12eae3b4d9140fff41e4e3512930202f9ccbb688497b146634d1cef10",
14
+ bytes: 583555960,
15
+ image: "sha256:e21c33b4745afdb3bac6a69c76606814348032ee661228c9bfdd9c7d06591a28",
16
16
  },
17
17
  };
18
18
  /** Media assets are separate so ordinary browser studies keep the smaller download. */
@@ -164,6 +164,7 @@ async function engineStatus(options, architecture, lima) {
164
164
  image: image.Id,
165
165
  runtimeRevision: revision,
166
166
  ...(labels["to.humanish.runtime.media"] === "1" ? { media: true } : {}),
167
+ ...(labels["to.humanish.runtime.display-sizes"] === "1" ? { displaySizes: true } : {}),
167
168
  },
168
169
  };
169
170
  }