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
@@ -0,0 +1,106 @@
1
+ // The end of a live run's output: what its automatic analysis left, read from the run once and
2
+ // written both ways. The human output gets the analysis line, then the top findings or the command
3
+ // that gets them; `--json` gets the same message and command on `automaticAnalysis`, and a bounded
4
+ // `findings` summary when the analysis left findings.
5
+ import { cwdFlag, readRunFindings } from "./findings.js";
6
+ import { formatFindingsSummary } from "./findings-text.js";
7
+ import { cli } from "./invocation.js";
8
+ import { analysisOutcomeText, wantsJson, writeResult } from "./io.js";
9
+ /** At most this many findings go in `run --json`; `review --json` has every one. */
10
+ const TOP_FINDINGS = 5;
11
+ function findingsSummary(view, review, observerPath) {
12
+ return {
13
+ analysisId: view.analysisId,
14
+ status: view.status,
15
+ count: view.findings.length,
16
+ designFindingCount: view.designFindings === null ? null : view.designFindings.length,
17
+ top: view.findings.slice(0, TOP_FINDINGS).map((finding) => ({
18
+ id: finding.id,
19
+ title: finding.title,
20
+ headline: finding.headline,
21
+ impact: finding.impact,
22
+ confidence: finding.confidence,
23
+ participants: finding.affected.map((participant) => participant.streamId),
24
+ })),
25
+ review,
26
+ observer: observerPath === undefined ? null : `${observerPath}#/report`,
27
+ path: view.path,
28
+ };
29
+ }
30
+ /** What the run's analysis left: the message, the command, the human lines and any findings. */
31
+ async function readRunReport(cwd, result, deps) {
32
+ const outcome = result.automaticAnalysis;
33
+ if (outcome === undefined || result.runId === undefined || result.runId === "not-created")
34
+ return undefined;
35
+ const outcomeLine = `analysis: ${analysisOutcomeText(outcome)}`;
36
+ const plain = {
37
+ message: `The automatic analysis was ${analysisOutcomeText(outcome)}.`,
38
+ nextCommand: null,
39
+ lines: [outcomeLine],
40
+ };
41
+ // A dry run left no evidence, and its own output names the live run.
42
+ if (outcome.reason === "AUTOMATIC_ANALYSIS_DRY_RUN")
43
+ return plain;
44
+ const analysisId = outcome.result?.analysisId;
45
+ const finished = outcome.state === "complete" || outcome.state === "partial";
46
+ let view;
47
+ try {
48
+ view = await readRunFindings(cwd, result.runId, finished ? analysisId : undefined, deps);
49
+ }
50
+ catch {
51
+ return plain;
52
+ }
53
+ const rejected = outcome.result?.rejectedOutputPath;
54
+ const rejectedLine = rejected
55
+ ? [
56
+ `analysis: ${outcome.result?.error?.code ?? "rejected"}; the rejected output is kept locally at ${rejected}`,
57
+ ]
58
+ : [];
59
+ const review = cli(`review --run ${view.runId}${cwdFlag(cwd)}`);
60
+ if (finished && (view.state === "ready" || view.state === "stale"))
61
+ return {
62
+ message: view.message,
63
+ nextCommand: review,
64
+ lines: [outcomeLine, ...rejectedLine, ...formatFindingsSummary(view, review)],
65
+ findings: findingsSummary(view, review, result.observer?.observerPath),
66
+ };
67
+ // The line reads "analysis: skipped because ...", as it does without the view.
68
+ const said = view.message.replace(/^The automatic analysis (?:was )?/, "");
69
+ return {
70
+ message: view.message,
71
+ nextCommand: view.next,
72
+ lines: [
73
+ `analysis: ${said}`,
74
+ ...rejectedLine,
75
+ ...(view.next === null ? [] : [`next: ${view.next}`]),
76
+ ],
77
+ };
78
+ }
79
+ /**
80
+ * Writes a finished run's result. After the route's own output it says what the automatic
81
+ * analysis did and the command a person runs next; when the analysis left findings, the top ones.
82
+ * `--json` carries the same message and command on `automaticAnalysis` and the findings summary
83
+ * as `findings`. A run with no analysis record is written as writeResult writes it.
84
+ */
85
+ export async function writeRunResult(command, io, cwd, result, formatHuman, deps = {}) {
86
+ const report = await readRunReport(cwd, result, deps);
87
+ if (report === undefined || result.automaticAnalysis === undefined) {
88
+ writeResult(command, io, result, formatHuman);
89
+ return;
90
+ }
91
+ if (!wantsJson(command)) {
92
+ writeResult(command, io, result, formatHuman, report.lines);
93
+ return;
94
+ }
95
+ writeResult(command, io, {
96
+ ...result,
97
+ automaticAnalysis: {
98
+ ...result.automaticAnalysis,
99
+ message: report.message,
100
+ nextCommand: report.nextCommand,
101
+ },
102
+ ...(report.findings === undefined ? {} : { findings: report.findings }),
103
+ },
104
+ // JSON output never calls the human formatter.
105
+ () => "");
106
+ }
@@ -3,6 +3,7 @@ import type { Duplex } from "node:stream";
3
3
  import { type BrowserControlIdentity } from "../browser-control/protocol.js";
4
4
  import { createBrowserControlClient } from "../browser-control/client.js";
5
5
  import { type GuestMediaConfig } from "./media-config.js";
6
+ import { type GuestDisplayConfig } from "./display-config.js";
6
7
  export declare const GUEST_BOOTSTRAP_VERSION = 1;
7
8
  export declare const GUEST_BOOTSTRAP_LIMITS: Readonly<{
8
9
  bytes: number;
@@ -18,7 +19,7 @@ export declare const GUEST_BOOTSTRAP_LIMITS: Readonly<{
18
19
  /** Same entry authority as the local desktop's opaque app-port forward. */
19
20
  export declare function validateGuestInitialUrl(value: string): string;
20
21
  export declare function guestReadyTimeoutMs(initialUrl?: string): number;
21
- export declare function encodeGuestBootstrap(identity: BrowserControlIdentity, ready?: boolean, initialUrl?: string, media?: GuestMediaConfig, recording?: DesktopRecordingConfig): Buffer;
22
+ export declare function encodeGuestBootstrap(identity: BrowserControlIdentity, ready?: boolean, initialUrl?: string, media?: GuestMediaConfig, recording?: DesktopRecordingConfig, display?: GuestDisplayConfig): Buffer;
22
23
  export declare function parseGuestBootstrap(bytes: Buffer, revision: string, ready?: boolean): BrowserControlIdentity;
23
24
  /** Own the first frame and continue refusing early bytes until synchronous handoff. */
24
25
  export declare class GuestBootstrapReader {
@@ -31,6 +32,7 @@ export declare class GuestBootstrapReader {
31
32
  initialUrl: string | undefined;
32
33
  media: GuestMediaConfig | undefined;
33
34
  recording: DesktopRecordingConfig | undefined;
35
+ display: GuestDisplayConfig | undefined;
34
36
  private resolve;
35
37
  private reject;
36
38
  private readonly header;
@@ -52,4 +54,4 @@ export declare class GuestBootstrapReader {
52
54
  close(): void;
53
55
  }
54
56
  /** Fixed preface on an already acquired Firecracker stream; no path discovery/retry. */
55
- export declare function connectGuestBootstrap(stream: Duplex, identity: BrowserControlIdentity, signal: AbortSignal, initialUrl?: string, media?: GuestMediaConfig, recording?: DesktopRecordingConfig): Promise<ReturnType<typeof createBrowserControlClient>>;
57
+ export declare function connectGuestBootstrap(stream: Duplex, identity: BrowserControlIdentity, signal: AbortSignal, initialUrl?: string, media?: GuestMediaConfig, recording?: DesktopRecordingConfig, display?: GuestDisplayConfig): Promise<ReturnType<typeof createBrowserControlClient>>;
@@ -3,9 +3,10 @@ import { validateBrowserControlIdentity, sameBrowserControlIdentity, } from "../
3
3
  import { ComputerUseExecutorError } from "../actors/computer-use/executor-error.js";
4
4
  import { createBrowserControlClient } from "../browser-control/client.js";
5
5
  import { guestMediaConfigSchema } from "./media-config.js";
6
+ import { guestDisplayConfigSchema } from "./display-config.js";
6
7
  export const GUEST_BOOTSTRAP_VERSION = 1;
7
8
  export const GUEST_BOOTSTRAP_LIMITS = Object.freeze({
8
- bytes: 65_536 + 1024,
9
+ bytes: 65_536 + 2048,
9
10
  readyBytes: 1024,
10
11
  initialUrlBytes: 65_536,
11
12
  admissionMs: 5000,
@@ -42,7 +43,7 @@ export function guestReadyTimeoutMs(initialUrl) {
42
43
  ? 0
43
44
  : GUEST_BOOTSTRAP_LIMITS.navigationMs + GUEST_BOOTSTRAP_LIMITS.paintMs));
44
45
  }
45
- function canonical(identity, ready, initialUrl, media, recording) {
46
+ function canonical(identity, ready, initialUrl, media, recording, display) {
46
47
  const { generation, challenge, runtimeRevision } = validateBrowserControlIdentity(identity);
47
48
  const fixed = { generation, challenge, runtimeRevision };
48
49
  if (initialUrl !== undefined && (ready || validateGuestInitialUrl(initialUrl) !== initialUrl))
@@ -52,6 +53,8 @@ function canonical(identity, ready, initialUrl, media, recording) {
52
53
  if (recording !== undefined &&
53
54
  (ready || !desktopRecordingConfigSchema.safeParse(recording).success))
54
55
  throw refused();
56
+ if (display !== undefined && (ready || !guestDisplayConfigSchema.safeParse(display).success))
57
+ throw refused();
55
58
  return JSON.stringify(ready
56
59
  ? { version: 1, ready: true, identity: fixed }
57
60
  : {
@@ -62,10 +65,11 @@ function canonical(identity, ready, initialUrl, media, recording) {
62
65
  ...(recording === undefined
63
66
  ? {}
64
67
  : { recording: desktopRecordingConfigSchema.parse(recording) }),
68
+ ...(display === undefined ? {} : { display: guestDisplayConfigSchema.parse(display) }),
65
69
  });
66
70
  }
67
- export function encodeGuestBootstrap(identity, ready = false, initialUrl, media, recording) {
68
- const bytes = Buffer.from(canonical(identity, ready, initialUrl, media, recording));
71
+ export function encodeGuestBootstrap(identity, ready = false, initialUrl, media, recording, display) {
72
+ const bytes = Buffer.from(canonical(identity, ready, initialUrl, media, recording, display));
69
73
  if (bytes.length > (ready ? GUEST_BOOTSTRAP_LIMITS.readyBytes : GUEST_BOOTSTRAP_LIMITS.bytes))
70
74
  throw refused();
71
75
  const frame = Buffer.alloc(4 + bytes.length);
@@ -89,16 +93,18 @@ function parseBootstrap(bytes, revision, ready) {
89
93
  const initialUrl = "initialUrl" in value ? value.initialUrl : undefined;
90
94
  const media = "media" in value ? guestMediaConfigSchema.parse(value.media) : undefined;
91
95
  const recording = "recording" in value ? desktopRecordingConfigSchema.parse(value.recording) : undefined;
96
+ const display = "display" in value ? guestDisplayConfigSchema.parse(value.display) : undefined;
92
97
  if (initialUrl !== undefined && typeof initialUrl !== "string")
93
98
  throw refused();
94
99
  if (identity.runtimeRevision !== revision ||
95
- canonical(identity, ready, initialUrl, media, recording) !== bytes.toString("ascii"))
100
+ canonical(identity, ready, initialUrl, media, recording, display) !== bytes.toString("ascii"))
96
101
  throw refused();
97
102
  return {
98
103
  identity,
99
104
  ...(initialUrl === undefined ? {} : { initialUrl }),
100
105
  ...(media === undefined ? {} : { media }),
101
106
  ...(recording === undefined ? {} : { recording }),
107
+ ...(display === undefined ? {} : { display }),
102
108
  };
103
109
  }
104
110
  catch {
@@ -116,6 +122,7 @@ export class GuestBootstrapReader {
116
122
  initialUrl;
117
123
  media;
118
124
  recording;
125
+ display;
119
126
  resolve;
120
127
  reject;
121
128
  header = Buffer.alloc(4);
@@ -190,6 +197,7 @@ export class GuestBootstrapReader {
190
197
  this.initialUrl = parsed.initialUrl;
191
198
  this.media = parsed.media;
192
199
  this.recording = parsed.recording;
200
+ this.display = parsed.display;
193
201
  this.admitted = true;
194
202
  clearTimeout(this.timer);
195
203
  this.resolve(parsed.identity);
@@ -244,9 +252,9 @@ export class GuestBootstrapReader {
244
252
  }
245
253
  }
246
254
  /** Fixed preface on an already acquired Firecracker stream; no path discovery/retry. */
247
- export async function connectGuestBootstrap(stream, identity, signal, initialUrl, media, recording) {
255
+ export async function connectGuestBootstrap(stream, identity, signal, initialUrl, media, recording, display) {
248
256
  validateBrowserControlIdentity(identity);
249
- const request = encodeGuestBootstrap(identity, false, initialUrl, media, recording);
257
+ const request = encodeGuestBootstrap(identity, false, initialUrl, media, recording, display);
250
258
  await new Promise((resolve, reject) => {
251
259
  let bytes = Buffer.alloc(0), done = false;
252
260
  const timer = setTimeout(() => finish(new ComputerUseExecutorError("deadline_exceeded", "not_dispatched")), GUEST_BOOTSTRAP_LIMITS.connectMs);
@@ -0,0 +1,31 @@
1
+ import { z } from "zod";
2
+ /** The screen a guest starts when the bootstrap names none. */
3
+ export declare const DEFAULT_GUEST_SCREEN: Readonly<{
4
+ width: 960;
5
+ height: 720;
6
+ }>;
7
+ /**
8
+ * The screens a guest admits. The narrowest is the narrowest window Chromium renders; the widest
9
+ * is the widest device preset. At 1920×1200 an uncompressed 24-bit frame is 6.9 MB, so every
10
+ * capture fits the 8 MiB browser-control PNG limit. Below 480 pixels high the page area under
11
+ * Chromium's toolbar is too short to use.
12
+ */
13
+ export declare const GUEST_SCREEN_LIMITS: Readonly<{
14
+ minWidth: 500;
15
+ maxWidth: 1920;
16
+ minHeight: 480;
17
+ maxHeight: 1200;
18
+ }>;
19
+ /** One participant's Xvfb screen, and the mobile emulation its pages get, if any. */
20
+ export declare const guestDisplayConfigSchema: z.ZodObject<{
21
+ width: z.ZodNumber;
22
+ height: z.ZodNumber;
23
+ mobileEmulation: z.ZodOptional<z.ZodObject<{
24
+ width: z.ZodNumber;
25
+ height: z.ZodNumber;
26
+ deviceScaleFactor: z.ZodNumber;
27
+ touch: z.ZodBoolean;
28
+ userAgent: z.ZodString;
29
+ }, z.core.$strict>>;
30
+ }, z.core.$strict>;
31
+ export type GuestDisplayConfig = z.infer<typeof guestDisplayConfigSchema>;
@@ -0,0 +1,35 @@
1
+ import { z } from "zod";
2
+ import { MIN_DESKTOP_RENDER_WIDTH } from "../study/device-presets.js";
3
+ /** The screen a guest starts when the bootstrap names none. */
4
+ export const DEFAULT_GUEST_SCREEN = Object.freeze({ width: 960, height: 720 });
5
+ /**
6
+ * The screens a guest admits. The narrowest is the narrowest window Chromium renders; the widest
7
+ * is the widest device preset. At 1920×1200 an uncompressed 24-bit frame is 6.9 MB, so every
8
+ * capture fits the 8 MiB browser-control PNG limit. Below 480 pixels high the page area under
9
+ * Chromium's toolbar is too short to use.
10
+ */
11
+ export const GUEST_SCREEN_LIMITS = Object.freeze({
12
+ minWidth: MIN_DESKTOP_RENDER_WIDTH,
13
+ maxWidth: 1920,
14
+ minHeight: 480,
15
+ maxHeight: 1200,
16
+ });
17
+ const side = (max) => z.number().int().min(1).max(max);
18
+ /** One participant's Xvfb screen, and the mobile emulation its pages get, if any. */
19
+ export const guestDisplayConfigSchema = z
20
+ .object({
21
+ width: z.number().int().min(GUEST_SCREEN_LIMITS.minWidth).max(GUEST_SCREEN_LIMITS.maxWidth),
22
+ height: z.number().int().min(GUEST_SCREEN_LIMITS.minHeight).max(GUEST_SCREEN_LIMITS.maxHeight),
23
+ mobileEmulation: z
24
+ .object({
25
+ width: side(GUEST_SCREEN_LIMITS.maxWidth),
26
+ height: side(GUEST_SCREEN_LIMITS.maxHeight),
27
+ deviceScaleFactor: z.number().positive().max(4),
28
+ touch: z.boolean(),
29
+ // The bootstrap frame is ASCII; a launch flag carries it too.
30
+ userAgent: z.string().regex(/^[\x20-\x7e]{1,512}$/),
31
+ })
32
+ .strict()
33
+ .optional(),
34
+ })
35
+ .strict();
@@ -1,5 +1,6 @@
1
1
  import { type Stats } from "node:fs";
2
2
  import { type BrowserContext, type Page } from "playwright-core";
3
+ import { type GuestDisplayConfig } from "./display-config.js";
3
4
  import type { GuestRuntimeDesktop } from "./runtime.js";
4
5
  import type { GuestMediaConfig } from "./media-config.js";
5
6
  import { type GuestDesktopMediaOptions } from "./desktop-media.js";
@@ -10,7 +11,7 @@ export declare const GUEST_RUNTIME_PATHS: Readonly<{
10
11
  run: "/run/humanish";
11
12
  home: "/home/humanish";
12
13
  }>;
13
- export type GuestRuntimePhase = "layout" | "xauthority" | "display" | "window_manager" | "media" | "recording" | "browser" | "sandbox" | "focus" | "fixture" | "navigation";
14
+ export type GuestRuntimePhase = "layout" | "xauthority" | "display" | "window_manager" | "media" | "recording" | "browser" | "sandbox" | "emulation" | "focus" | "fixture" | "navigation";
14
15
  /** Initial document only: do not wait for app data, subresources or network idle. */
15
16
  export declare function navigateGuestInitialPage(page: Pick<Page, "goto" | "waitForFunction">, initialUrl: string, signal: AbortSignal): Promise<void>;
16
17
  /** /run/humanish and /home/humanish: real directories private to the guest user. */
@@ -19,7 +20,11 @@ export declare function isPrivateGuestDirectory(stat: Pick<Stats, "isDirectory"
19
20
  export declare function isPinnedOpenboxConfig(stat: Pick<Stats, "isFile" | "uid" | "gid" | "nlink" | "size" | "mode">): boolean;
20
21
  /** chrome://sandbox must report every namespace and seccomp feature and the overall verdict. */
21
22
  export declare function isAdequatelySandboxed(report: string): boolean;
22
- export declare function chromiumLaunchArgs(media: GuestMediaConfig | undefined): string[];
23
+ /**
24
+ * The window leaves 20 pixels above and below it on the participant's screen. A mobile participant's
25
+ * user agent and touch flags reach every page and worker; CDP emulation sets the viewport.
26
+ */
27
+ export declare function chromiumLaunchArgs(media: GuestMediaConfig | undefined, display?: GuestDisplayConfig): string[];
23
28
  /** The devices to start; the permission policy only changes Chromium's flags. */
24
29
  export declare function desktopMediaRequest(media: GuestMediaConfig): GuestDesktopMediaOptions["media"];
25
30
  /** Audio needs Pulse, which only the microphone path starts. */
@@ -27,7 +32,7 @@ export declare function recorderAudio(recording: DesktopRecordingConfig, media:
27
32
  audioSources: DesktopRecordingAudioSource[];
28
33
  pulseReady: boolean;
29
34
  };
30
- /** Fixed guest layout with an optional admitted initial loopback app URL. */
35
+ /** Fixed guest layout with an optional admitted initial loopback app URL and participant screen. */
31
36
  export declare function createGuestRuntimeDesktop(options: {
32
37
  signal: AbortSignal;
33
38
  onTerminal(): void;
@@ -35,6 +40,7 @@ export declare function createGuestRuntimeDesktop(options: {
35
40
  initialUrl?: string;
36
41
  media?: GuestMediaConfig;
37
42
  recording?: DesktopRecordingConfig;
43
+ display?: GuestDisplayConfig;
38
44
  }): Promise<GuestRuntimeDesktop & {
39
45
  readonly owner: {
40
46
  context: BrowserContext;
@@ -3,6 +3,8 @@ import { constants } from "node:fs";
3
3
  import { lstat, mkdir, open, rm } from "node:fs/promises";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
5
  import { chromium } from "playwright-core";
6
+ import { DEFAULT_GUEST_SCREEN } from "./display-config.js";
7
+ import { mobileEmulationCommands, mobileEmulationLaunchFlags, } from "../study/mobile-emulation.js";
6
8
  import { createGuestDesktopNativeTools } from "./desktop-native.js";
7
9
  import { createGuestChromiumText } from "./chromium-text.js";
8
10
  import { createGuestBrowserTools } from "./browser-tools.js";
@@ -33,16 +35,18 @@ const GUEST_RUNTIME_ENV = Object.freeze({
33
35
  TMPDIR: "/tmp",
34
36
  });
35
37
  const CONFIG_SHA = "4ae1c52eab748a3624b3948ce792647be258caba70c8c72038b9a43be6459552";
36
- const XVFB_ARGS = Object.freeze([
37
- ":0",
38
- "-screen",
39
- "0",
40
- "960x720x24",
41
- "-nolisten",
42
- "tcp",
43
- "-auth",
44
- GUEST_RUNTIME_ENV.XAUTHORITY,
45
- ]);
38
+ function xvfbArgs(screen) {
39
+ return [
40
+ ":0",
41
+ "-screen",
42
+ "0",
43
+ `${screen.width}x${screen.height}x24`,
44
+ "-nolisten",
45
+ "tcp",
46
+ "-auth",
47
+ GUEST_RUNTIME_ENV.XAUTHORITY,
48
+ ];
49
+ }
46
50
  const SANDBOX_FEATURES = ["PID namespaces", "Network namespaces", "Seccomp-BPF sandbox"];
47
51
  const RECORDING_PATH = `${GUEST_RUNTIME_PATHS.home}/desktop-recording.mp4`;
48
52
  /** Initial document only: do not wait for app data, subresources or network idle. */
@@ -86,10 +90,16 @@ export function isAdequatelySandboxed(report) {
86
90
  return (SANDBOX_FEATURES.every((label) => new RegExp(label + "\\s+Yes").test(report)) &&
87
91
  report.includes("You are adequately sandboxed"));
88
92
  }
89
- export function chromiumLaunchArgs(media) {
93
+ /**
94
+ * The window leaves 20 pixels above and below it on the participant's screen. A mobile participant's
95
+ * user agent and touch flags reach every page and worker; CDP emulation sets the viewport.
96
+ */
97
+ export function chromiumLaunchArgs(media, display) {
98
+ const screen = display ?? DEFAULT_GUEST_SCREEN;
90
99
  return [
91
- "--window-size=960,680",
100
+ `--window-size=${screen.width},${screen.height - 40}`,
92
101
  "--window-position=0,20",
102
+ ...mobileEmulationLaunchFlags(display?.mobileEmulation),
93
103
  "--disable-background-networking",
94
104
  "--disable-component-update",
95
105
  "--no-first-run",
@@ -162,12 +172,12 @@ async function waitForDisplay(processes, signal, check) {
162
172
  throw setupFailed();
163
173
  }
164
174
  /** A recorder that fails to start leaves the desktop usable; finishRecording reports it. */
165
- async function startGuestRecorder(recording, media, env, signal) {
175
+ async function startGuestRecorder(recording, media, screen, env, signal) {
166
176
  try {
167
177
  return await startDesktopRecorder({
168
178
  display: ":0",
169
- width: 960,
170
- height: 720,
179
+ width: screen.width,
180
+ height: screen.height,
171
181
  outputPath: RECORDING_PATH,
172
182
  env,
173
183
  signal,
@@ -178,7 +188,7 @@ async function startGuestRecorder(recording, media, env, signal) {
178
188
  return undefined;
179
189
  }
180
190
  }
181
- async function launchGuestBrowser(state, media, check) {
191
+ async function launchGuestBrowser(state, media, display, check) {
182
192
  state.pendingContext = chromium.launchPersistentContext(`${GUEST_RUNTIME_PATHS.home}/browser`, {
183
193
  executablePath: "/usr/bin/chromium",
184
194
  headless: false,
@@ -186,7 +196,7 @@ async function launchGuestBrowser(state, media, check) {
186
196
  viewport: null,
187
197
  env: state.recorder?.env ?? state.media?.env ?? GUEST_RUNTIME_ENV,
188
198
  timeout: 25_000,
189
- args: chromiumLaunchArgs(media),
199
+ args: chromiumLaunchArgs(media, display),
190
200
  });
191
201
  // A browser that finishes launching after the stop is closed here; teardown may not see it.
192
202
  void state.pendingContext.then((browser) => {
@@ -216,6 +226,25 @@ async function verifyBrowserSandbox(context, page, check) {
216
226
  check();
217
227
  return report;
218
228
  }
229
+ /** Sends the emulation commands over a page session that stays attached, so they hold. */
230
+ async function emulateOn(context, page, request) {
231
+ const session = await context.newCDPSession(page);
232
+ for (const { method, params } of mobileEmulationCommands(request))
233
+ // Playwright types send by protocol method; these names come from the fixed list.
234
+ await session.send(method, params);
235
+ }
236
+ /**
237
+ * Emulate a phone on the launch page before it navigates, so the first document loads with the
238
+ * phone's viewport, touch and user agent. Tabs opened later get the same overrides when they open;
239
+ * a tab that closes before that is left alone.
240
+ */
241
+ async function emulateMobile(browser, request, check) {
242
+ await emulateOn(browser.context, browser.page, request);
243
+ check();
244
+ browser.context.on("page", (page) => {
245
+ void emulateOn(browser.context, page, request).catch(() => { });
246
+ });
247
+ }
219
248
  /** Text actions run only while the browser window that setup saw still has focus. */
220
249
  function focusGuard(native, window) {
221
250
  return async (actionSignal) => {
@@ -223,7 +252,7 @@ function focusGuard(native, window) {
223
252
  throw new ComputerUseExecutorError("action_rejected", "not_dispatched");
224
253
  };
225
254
  }
226
- async function createGuestExecutorFor(state, browser, signal, onTerminal, check) {
255
+ async function createGuestExecutorFor(state, browser, screen, signal, onTerminal, check) {
227
256
  const native = createGuestDesktopNativeTools({
228
257
  display: ":0",
229
258
  temporaryDirectory: `${GUEST_RUNTIME_PATHS.run}/capture`,
@@ -236,8 +265,8 @@ async function createGuestExecutorFor(state, browser, signal, onTerminal, check)
236
265
  assertFocusedWindow: focusGuard(native, window),
237
266
  });
238
267
  return createGuestDesktopExecutor({
239
- width: 960,
240
- height: 720,
268
+ width: screen.width,
269
+ height: screen.height,
241
270
  tools: createGuestBrowserTools(native, state.content),
242
271
  authoritySignal: signal,
243
272
  onTerminal,
@@ -329,10 +358,11 @@ function closeGuestRuntimeDesktop(state, processes, controller, detach) {
329
358
  controller.abort();
330
359
  return state.closing;
331
360
  }
332
- /** Fixed guest layout with an optional admitted initial loopback app URL. */
361
+ /** Fixed guest layout with an optional admitted initial loopback app URL and participant screen. */
333
362
  export async function createGuestRuntimeDesktop(options) {
334
363
  if (options.initialUrl !== undefined)
335
364
  validateGuestInitialUrl(options.initialUrl);
365
+ const screen = options.display ?? DEFAULT_GUEST_SCREEN;
336
366
  const controller = new AbortController();
337
367
  const signal = AbortSignal.any([options.signal, controller.signal]);
338
368
  const state = { stopping: false };
@@ -363,7 +393,7 @@ export async function createGuestRuntimeDesktop(options) {
363
393
  progress("xauthority");
364
394
  await authorizeDisplay(processes, check);
365
395
  progress("display");
366
- processes.spawn("/usr/bin/Xvfb", XVFB_ARGS, undefined, true);
396
+ processes.spawn("/usr/bin/Xvfb", xvfbArgs(screen), undefined, true);
367
397
  await waitForDisplay(processes, signal, check);
368
398
  progress("window_manager");
369
399
  processes.spawn("/usr/bin/openbox", ["--config-file", `${GUEST_RUNTIME_PATHS.root}/openbox.xml`], undefined, true);
@@ -381,15 +411,19 @@ export async function createGuestRuntimeDesktop(options) {
381
411
  if (options.recording !== undefined) {
382
412
  progress("recording");
383
413
  const env = state.media?.env ?? GUEST_RUNTIME_ENV;
384
- state.recorder = await startGuestRecorder(options.recording, options.media, env, signal);
414
+ state.recorder = await startGuestRecorder(options.recording, options.media, screen, env, signal);
385
415
  check();
386
416
  }
387
417
  progress("browser");
388
- const browser = await launchGuestBrowser(state, options.media, check);
418
+ const browser = await launchGuestBrowser(state, options.media, options.display, check);
389
419
  progress("sandbox");
390
420
  const sandboxReport = await verifyBrowserSandbox(browser.context, browser.page, check);
421
+ if (options.display?.mobileEmulation !== undefined) {
422
+ progress("emulation");
423
+ await emulateMobile(browser, options.display.mobileEmulation, check);
424
+ }
391
425
  progress("focus");
392
- const executor = await createGuestExecutorFor(state, browser, signal, options.onTerminal, check);
426
+ const executor = await createGuestExecutorFor(state, browser, screen, signal, options.onTerminal, check);
393
427
  if (options.initialUrl !== undefined) {
394
428
  progress("navigation");
395
429
  await navigateGuestInitialPage(browser.page, options.initialUrl, signal);
@@ -1,6 +1,7 @@
1
1
  import type { Duplex, Readable } from "node:stream";
2
2
  import type { CuaExecutor } from "../actors/computer-use/loop.js";
3
3
  import type { GuestMediaConfig } from "./media-config.js";
4
+ import type { GuestDisplayConfig } from "./display-config.js";
4
5
  import type { DesktopRecordingConfig, DesktopRecordingMetadata } from "../evidence/desktop-recording-types.js";
5
6
  export interface GuestRuntimeDesktop {
6
7
  executor: CuaExecutor;
@@ -18,7 +19,7 @@ export interface GuestRuntimeOptions {
18
19
  signal: AbortSignal;
19
20
  /** Fixed private supervision channel, not browser-control output. */
20
21
  marker(value: "A" | "R"): void;
21
- createDesktop(signal: AbortSignal, onTerminal: () => void, initialUrl?: string, media?: GuestMediaConfig, recording?: DesktopRecordingConfig): Promise<GuestRuntimeDesktop>;
22
+ createDesktop(signal: AbortSignal, onTerminal: () => void, initialUrl?: string, media?: GuestMediaConfig, recording?: DesktopRecordingConfig, display?: GuestDisplayConfig): Promise<GuestRuntimeDesktop>;
22
23
  }
23
24
  /** One admitted desktop and one dispatcher. The owner retains physical teardown. */
24
25
  export declare function runGuestRuntime(options: GuestRuntimeOptions): Promise<{
@@ -83,7 +83,7 @@ export async function runGuestRuntime(options) {
83
83
  void close();
84
84
  }, guestReadyTimeoutMs(reader.initialUrl));
85
85
  options.marker("A");
86
- preparing = options.createDesktop(authority, terminal, reader.initialUrl, reader.media, reader.recording);
86
+ preparing = options.createDesktop(authority, terminal, reader.initialUrl, reader.media, reader.recording, reader.display);
87
87
  desktop = await Promise.race([
88
88
  preparing,
89
89
  new Promise((_, reject) => {
@@ -27,12 +27,13 @@ try {
27
27
  if (writeSync(Number(markerFd), value) !== 1)
28
28
  throw new Error();
29
29
  },
30
- createDesktop: (signal, onTerminal, initialUrl, media, recording) => createGuestRuntimeDesktop({
30
+ createDesktop: (signal, onTerminal, initialUrl, media, recording, display) => createGuestRuntimeDesktop({
31
31
  signal,
32
32
  onTerminal,
33
33
  ...(initialUrl === undefined ? {} : { initialUrl }),
34
34
  ...(media === undefined ? {} : { media }),
35
35
  ...(recording === undefined ? {} : { recording }),
36
+ ...(display === undefined ? {} : { display }),
36
37
  }),
37
38
  });
38
39
  const result = await runtime.closed;
@@ -2,18 +2,17 @@
2
2
  // browser-state observer that watches it for drift, and the final browser geometry.
3
3
  import { redactText, toErrorMessage } from "../../../evidence/redaction.js";
4
4
  import { readDetachedLog } from "../../../substrates/detached.js";
5
- import { applyMobileEmulation, DEFAULT_MOBILE_USER_AGENT, makeChromeBrowserStateObserver, } from "../../../substrates/e2b/desktop-cdp.js";
5
+ import { applyMobileEmulation, makeChromeBrowserStateObserver, } from "../../../substrates/e2b/desktop-cdp.js";
6
+ import { mobileEmulationLaunchFlags, participantMobileEmulation, } from "../../../study/mobile-emulation.js";
6
7
  import { captureDesktopBrowserGeometry } from "../../../substrates/e2b/desktop-geometry.js";
7
8
  import { e2bShell } from "../../../substrates/e2b/shell.js";
9
+ /** The participant's emulation request; see participantMobileEmulation for who gets one. */
10
+ function emulationFor(residual, spec) {
11
+ return participantMobileEmulation(residual.execution?.desktop?.fidelity, spec.planned.device.preset);
12
+ }
8
13
  /** Only participants on a mobile preset are emulated; the flags go on the browser command line. */
9
14
  export function mobileLaunchFlags(residual, spec) {
10
- const requested = residual.execution?.desktop?.fidelity;
11
- if (!requested?.mobileEmulation || !spec.planned.device.preset.isMobile)
12
- return [];
13
- return [
14
- `--user-agent=${requested.userAgent ?? DEFAULT_MOBILE_USER_AGENT}`,
15
- ...(requested.touch === false ? [] : ["--touch-events=enabled"]),
16
- ];
15
+ return mobileEmulationLaunchFlags(emulationFor(residual, spec));
17
16
  }
18
17
  /** The launched browser's DevTools address, as far as the launch recorded it. */
19
18
  export function cdpEndpoint(identity, targetUrl) {
@@ -33,24 +32,18 @@ export function cdpEndpoint(identity, targetUrl) {
33
32
  */
34
33
  export async function applyParticipantMobileFidelity(args) {
35
34
  const { spec, deps } = args;
36
- const request = deps.residual.execution?.desktop?.fidelity;
35
+ const request = emulationFor(deps.residual, spec);
37
36
  const none = {
38
37
  applied: undefined,
39
38
  emulatedTargetId: undefined,
40
39
  holderName: undefined,
41
40
  };
42
- if (!request?.mobileEmulation || !spec.planned.device.preset.isMobile)
41
+ if (request === undefined)
43
42
  return none;
44
43
  if (args.browserFamily !== "chromium") {
45
44
  throw new Error(`execution.desktop.fidelity.mobileEmulation needs Chrome or Chromium for participant ${spec.planned.id}; the launched browser family is ${args.browserFamily}. Set execution.desktop.browser: chrome.`);
46
45
  }
47
- const applied = await applyMobileEmulation(args.desktop, deps.requestTimeoutMs, cdpEndpoint(args.launchIdentity, args.targetUrl), {
48
- width: spec.planned.device.preset.width,
49
- height: spec.planned.device.preset.height,
50
- deviceScaleFactor: request.deviceScaleFactor ?? spec.planned.device.preset.deviceScaleFactor,
51
- touch: request.touch ?? true,
52
- userAgent: request.userAgent ?? DEFAULT_MOBILE_USER_AGENT,
53
- }, {
46
+ const applied = await applyMobileEmulation(args.desktop, deps.requestTimeoutMs, cdpEndpoint(args.launchIdentity, args.targetUrl), request, {
54
47
  targetId: args.targetId,
55
48
  ...(args.network === undefined ? {} : { network: args.network.request }),
56
49
  });