humanish 0.139.1 → 0.141.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 (120) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +5 -4
  3. package/dist/actors/codex/restricted-participant.js +1 -1
  4. package/dist/actors/computer-use/external-handoff.d.ts +22 -0
  5. package/dist/actors/computer-use/external-handoff.js +83 -0
  6. package/dist/actors/computer-use/loop/observation.d.ts +7 -0
  7. package/dist/actors/computer-use/loop/observation.js +17 -1
  8. package/dist/actors/computer-use/loop/types.d.ts +6 -0
  9. package/dist/analysis/analysis-limits.d.ts +6 -0
  10. package/dist/analysis/analysis-limits.js +6 -0
  11. package/dist/analysis/analysis-lock.d.ts +8 -0
  12. package/dist/analysis/analysis-lock.js +129 -0
  13. package/dist/analysis/automatic-completion.js +31 -0
  14. package/dist/analysis/automatic-config.d.ts +15 -6
  15. package/dist/analysis/automatic-config.js +25 -9
  16. package/dist/analysis/execute.js +1 -1
  17. package/dist/analysis/job.d.ts +1 -0
  18. package/dist/analysis/job.js +6 -2
  19. package/dist/analysis/recording-ended.d.ts +16 -0
  20. package/dist/analysis/recording-ended.js +29 -0
  21. package/dist/analysis/service.d.ts +1 -3
  22. package/dist/analysis/service.js +23 -50
  23. package/dist/analysis/source.d.ts +1 -1
  24. package/dist/browser-control/protocol.d.ts +8 -0
  25. package/dist/browser-control/protocol.js +21 -0
  26. package/dist/cli/commands/analysis-signals.d.ts +10 -4
  27. package/dist/cli/commands/analysis-signals.js +28 -6
  28. package/dist/cli/commands/runs.js +8 -2
  29. package/dist/cli/commands/study-route-computer-use.js +4 -5
  30. package/dist/cli/commands/study-route-scripted.js +5 -6
  31. package/dist/cli/commands/study-route-shared-world.js +4 -5
  32. package/dist/cli/commands/study-route-terminal.js +5 -6
  33. package/dist/cli/commands/study-run.js +1 -1
  34. package/dist/cli/doctor.d.ts +2 -0
  35. package/dist/cli/doctor.js +3 -0
  36. package/dist/cli/findings.d.ts +40 -14
  37. package/dist/cli/findings.js +117 -35
  38. package/dist/cli/io.d.ts +5 -1
  39. package/dist/cli/io.js +12 -2
  40. package/dist/cli/run-findings.d.ts +19 -0
  41. package/dist/cli/run-findings.js +106 -0
  42. package/dist/evidence/browser-hygiene.d.ts +12 -1
  43. package/dist/evidence/browser-hygiene.js +45 -2
  44. package/dist/feedback/export-bundle.js +1 -1
  45. package/dist/guest/bootstrap.d.ts +4 -2
  46. package/dist/guest/bootstrap.js +15 -7
  47. package/dist/guest/display-config.d.ts +31 -0
  48. package/dist/guest/display-config.js +35 -0
  49. package/dist/guest/external-links.d.ts +14 -0
  50. package/dist/guest/external-links.js +48 -0
  51. package/dist/guest/runtime-desktop.d.ts +9 -3
  52. package/dist/guest/runtime-desktop.js +64 -27
  53. package/dist/guest/runtime.d.ts +2 -1
  54. package/dist/guest/runtime.js +1 -1
  55. package/dist/guest-runtime-main.js +2 -1
  56. package/dist/observer/data.d.ts +4 -0
  57. package/dist/observer/run-routes.js +12 -3
  58. package/dist/observer-app.html +2 -2
  59. package/dist/routes/computer-use/e2b-desktop/desktop.js +27 -17
  60. package/dist/routes/computer-use/e2b-desktop/fidelity.js +10 -17
  61. package/dist/routes/computer-use/e2b-desktop/teardown.js +1 -1
  62. package/dist/routes/computer-use/in-process-desktop.js +2 -2
  63. package/dist/routes/computer-use/live-phase.js +1 -1
  64. package/dist/routes/computer-use/local-vm.js +34 -1
  65. package/dist/routes/computer-use/plan.js +1 -1
  66. package/dist/routes/computer-use/setup.js +2 -2
  67. package/dist/routes/scripted/plan.js +1 -1
  68. package/dist/routes/shared-world/plan.js +1 -1
  69. package/dist/routes/shared-world/route.js +1 -1
  70. package/dist/routes/terminal/plan.js +1 -1
  71. package/dist/run/display.d.ts +6 -1
  72. package/dist/run/display.js +11 -0
  73. package/dist/run/host-suspension.js +4 -1
  74. package/dist/run/preflight-receipts.d.ts +3 -12
  75. package/dist/run/preflight-receipts.js +7 -67
  76. package/dist/run/process-owner.d.ts +28 -0
  77. package/dist/run/process-owner.js +83 -0
  78. package/dist/run/projection.js +1 -1
  79. package/dist/run/reclaim.d.ts +6 -0
  80. package/dist/run/reclaim.js +32 -2
  81. package/dist/run/route-shell.js +2 -2
  82. package/dist/run/run-index.d.ts +3 -1
  83. package/dist/run/run-index.js +23 -11
  84. package/dist/run/run.d.ts +16 -5
  85. package/dist/run/run.js +25 -10
  86. package/dist/run/status.d.ts +57 -2
  87. package/dist/run/status.js +87 -1
  88. package/dist/run/stored-runs.d.ts +3 -0
  89. package/dist/run/stored-runs.js +9 -4
  90. package/dist/study/doctor.js +11 -6
  91. package/dist/study/init-templates.js +9 -3
  92. package/dist/study/mobile-emulation.d.ts +27 -0
  93. package/dist/study/mobile-emulation.js +60 -0
  94. package/dist/study/plan.js +1 -1
  95. package/dist/study/preflight-result.js +1 -1
  96. package/dist/study/run-study-events.d.ts +7 -1
  97. package/dist/study/summary.js +1 -1
  98. package/dist/study/warnings.js +5 -2
  99. package/dist/substrates/e2b/cdp-probe.d.ts +2 -6
  100. package/dist/substrates/e2b/desktop-browser.js +3 -1
  101. package/dist/substrates/e2b/desktop-cdp.d.ts +0 -2
  102. package/dist/substrates/e2b/desktop-cdp.js +0 -2
  103. package/dist/substrates/e2b/desktop-executor.d.ts +7 -0
  104. package/dist/substrates/e2b/desktop-executor.js +3 -0
  105. package/dist/substrates/e2b/desktop-handoff.d.ts +22 -0
  106. package/dist/substrates/e2b/desktop-handoff.js +86 -0
  107. package/dist/substrates/local/firecracker-desktop.d.ts +5 -0
  108. package/dist/substrates/local/firecracker-desktop.js +7 -1
  109. package/dist/substrates/local/runtime-config.d.ts +12 -0
  110. package/dist/substrates/local/runtime-config.js +55 -13
  111. package/dist/substrates/local/runtime-release.js +8 -8
  112. package/dist/substrates/local/runtime.js +1 -0
  113. package/dist/tui-app.js +115 -115
  114. package/docs/architecture/observer.md +7 -3
  115. package/docs/contracts/schemas.md +24 -3
  116. package/docs/contracts/study-analysis.md +15 -9
  117. package/docs/principles/invariants-and-defaults.md +1 -1
  118. package/docs/product/automatic-analysis.md +75 -18
  119. package/package.json +1 -1
  120. package/skills/humanish/SKILL.md +153 -31
@@ -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();
@@ -0,0 +1,14 @@
1
+ import type { CuaExecutor } from "../actors/computer-use/loop.js";
2
+ /** Where the image keeps the stand-ins. */
3
+ export declare const GUEST_EXTERNAL_LINK_BIN = "/opt/humanish/control/bin";
4
+ /**
5
+ * Write the browser profile's Preferences before the first launch, so the initial app's origin
6
+ * opens tel:, sms: and the other app schemes without Chromium's prompt. No initial URL, no file.
7
+ */
8
+ export declare function prepareGuestBrowserProfile(profileDir: string, initialUrl: string | undefined): Promise<void>;
9
+ /**
10
+ * The executor with each observation carrying the links appended to `log` since the previous
11
+ * observation, at most EXTERNAL_HANDOFF_LIMITS.perObservation. A line still being written waits
12
+ * for the next observation. Every other property of the executor is kept.
13
+ */
14
+ export declare function withExternalHandoffs(executor: CuaExecutor, log: string): CuaExecutor;
@@ -0,0 +1,48 @@
1
+ // Links the guest browser hands to another app (mailto:, tel:, sms:). The guest has no email,
2
+ // phone or messaging app. The image's stand-ins for xdg-open and xdg-email, first on the guest's
3
+ // search path, append each such link to a log in the runtime directory, and every observation the
4
+ // executor returns carries the links appended since the previous one.
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { EXTERNAL_HANDOFF_LIMITS, externalHandoffOf, } from "../actors/computer-use/external-handoff.js";
7
+ import { externalAppPreferences } from "../evidence/browser-hygiene.js";
8
+ /** Where the image keeps the stand-ins. */
9
+ export const GUEST_EXTERNAL_LINK_BIN = "/opt/humanish/control/bin";
10
+ /**
11
+ * Write the browser profile's Preferences before the first launch, so the initial app's origin
12
+ * opens tel:, sms: and the other app schemes without Chromium's prompt. No initial URL, no file.
13
+ */
14
+ export async function prepareGuestBrowserProfile(profileDir, initialUrl) {
15
+ const preferences = externalAppPreferences(initialUrl);
16
+ if (preferences === undefined)
17
+ return;
18
+ await mkdir(`${profileDir}/Default`, { recursive: true, mode: 0o700 });
19
+ await writeFile(`${profileDir}/Default/Preferences`, JSON.stringify(preferences), {
20
+ mode: 0o600,
21
+ flag: "wx",
22
+ });
23
+ }
24
+ /**
25
+ * The executor with each observation carrying the links appended to `log` since the previous
26
+ * observation, at most EXTERNAL_HANDOFF_LIMITS.perObservation. A line still being written waits
27
+ * for the next observation. Every other property of the executor is kept.
28
+ */
29
+ export function withExternalHandoffs(executor, log) {
30
+ let consumed = 0;
31
+ return {
32
+ ...executor,
33
+ observe: async () => {
34
+ const observation = await executor.observe();
35
+ const text = await readFile(log, "utf8").catch(() => "");
36
+ const lines = text
37
+ .split("\n")
38
+ .slice(0, -1)
39
+ .slice(consumed, consumed + EXTERNAL_HANDOFF_LIMITS.perObservation);
40
+ consumed += lines.length;
41
+ const externalHandoffs = lines.flatMap((line) => {
42
+ const handoff = externalHandoffOf(line);
43
+ return handoff === undefined ? [] : [handoff];
44
+ });
45
+ return externalHandoffs.length === 0 ? observation : { ...observation, externalHandoffs };
46
+ },
47
+ };
48
+ }
@@ -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";
@@ -12,6 +14,7 @@ import { GUEST_BOOTSTRAP_LIMITS, validateGuestInitialUrl } from "./bootstrap.js"
12
14
  import { startDesktopMedia, } from "./desktop-media.js";
13
15
  import { startDesktopRecorder } from "../evidence/desktop-recorder.js";
14
16
  import { createGuestProcesses, setupFailed } from "./runtime-processes.js";
17
+ import { GUEST_EXTERNAL_LINK_BIN, prepareGuestBrowserProfile, withExternalHandoffs, } from "./external-links.js";
15
18
  /** The guest image's fixed layout. Tests derive guest paths from it rather than spelling them. */
16
19
  export const GUEST_RUNTIME_PATHS = Object.freeze({
17
20
  root: "/opt/humanish/control",
@@ -19,7 +22,8 @@ export const GUEST_RUNTIME_PATHS = Object.freeze({
19
22
  home: "/home/humanish",
20
23
  });
21
24
  const GUEST_RUNTIME_ENV = Object.freeze({
22
- PATH: "/usr/bin:/bin",
25
+ // The stand-ins come first, so the browser hands a mailto: or tel: link to them.
26
+ PATH: `${GUEST_EXTERNAL_LINK_BIN}:/usr/bin:/bin`,
23
27
  HOME: "/home/humanish",
24
28
  USER: "humanish",
25
29
  LOGNAME: "humanish",
@@ -33,16 +37,18 @@ const GUEST_RUNTIME_ENV = Object.freeze({
33
37
  TMPDIR: "/tmp",
34
38
  });
35
39
  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
- ]);
40
+ function xvfbArgs(screen) {
41
+ return [
42
+ ":0",
43
+ "-screen",
44
+ "0",
45
+ `${screen.width}x${screen.height}x24`,
46
+ "-nolisten",
47
+ "tcp",
48
+ "-auth",
49
+ GUEST_RUNTIME_ENV.XAUTHORITY,
50
+ ];
51
+ }
46
52
  const SANDBOX_FEATURES = ["PID namespaces", "Network namespaces", "Seccomp-BPF sandbox"];
47
53
  const RECORDING_PATH = `${GUEST_RUNTIME_PATHS.home}/desktop-recording.mp4`;
48
54
  /** Initial document only: do not wait for app data, subresources or network idle. */
@@ -86,10 +92,16 @@ export function isAdequatelySandboxed(report) {
86
92
  return (SANDBOX_FEATURES.every((label) => new RegExp(label + "\\s+Yes").test(report)) &&
87
93
  report.includes("You are adequately sandboxed"));
88
94
  }
89
- export function chromiumLaunchArgs(media) {
95
+ /**
96
+ * The window leaves 20 pixels above and below it on the participant's screen. A mobile participant's
97
+ * user agent and touch flags reach every page and worker; CDP emulation sets the viewport.
98
+ */
99
+ export function chromiumLaunchArgs(media, display) {
100
+ const screen = display ?? DEFAULT_GUEST_SCREEN;
90
101
  return [
91
- "--window-size=960,680",
102
+ `--window-size=${screen.width},${screen.height - 40}`,
92
103
  "--window-position=0,20",
104
+ ...mobileEmulationLaunchFlags(display?.mobileEmulation),
93
105
  "--disable-background-networking",
94
106
  "--disable-component-update",
95
107
  "--no-first-run",
@@ -162,12 +174,12 @@ async function waitForDisplay(processes, signal, check) {
162
174
  throw setupFailed();
163
175
  }
164
176
  /** A recorder that fails to start leaves the desktop usable; finishRecording reports it. */
165
- async function startGuestRecorder(recording, media, env, signal) {
177
+ async function startGuestRecorder(recording, media, screen, env, signal) {
166
178
  try {
167
179
  return await startDesktopRecorder({
168
180
  display: ":0",
169
- width: 960,
170
- height: 720,
181
+ width: screen.width,
182
+ height: screen.height,
171
183
  outputPath: RECORDING_PATH,
172
184
  env,
173
185
  signal,
@@ -178,7 +190,7 @@ async function startGuestRecorder(recording, media, env, signal) {
178
190
  return undefined;
179
191
  }
180
192
  }
181
- async function launchGuestBrowser(state, media, check) {
193
+ async function launchGuestBrowser(state, media, display, check) {
182
194
  state.pendingContext = chromium.launchPersistentContext(`${GUEST_RUNTIME_PATHS.home}/browser`, {
183
195
  executablePath: "/usr/bin/chromium",
184
196
  headless: false,
@@ -186,7 +198,7 @@ async function launchGuestBrowser(state, media, check) {
186
198
  viewport: null,
187
199
  env: state.recorder?.env ?? state.media?.env ?? GUEST_RUNTIME_ENV,
188
200
  timeout: 25_000,
189
- args: chromiumLaunchArgs(media),
201
+ args: chromiumLaunchArgs(media, display),
190
202
  });
191
203
  // A browser that finishes launching after the stop is closed here; teardown may not see it.
192
204
  void state.pendingContext.then((browser) => {
@@ -216,6 +228,25 @@ async function verifyBrowserSandbox(context, page, check) {
216
228
  check();
217
229
  return report;
218
230
  }
231
+ /** Sends the emulation commands over a page session that stays attached, so they hold. */
232
+ async function emulateOn(context, page, request) {
233
+ const session = await context.newCDPSession(page);
234
+ for (const { method, params } of mobileEmulationCommands(request))
235
+ // Playwright types send by protocol method; these names come from the fixed list.
236
+ await session.send(method, params);
237
+ }
238
+ /**
239
+ * Emulate a phone on the launch page before it navigates, so the first document loads with the
240
+ * phone's viewport, touch and user agent. Tabs opened later get the same overrides when they open;
241
+ * a tab that closes before that is left alone.
242
+ */
243
+ async function emulateMobile(browser, request, check) {
244
+ await emulateOn(browser.context, browser.page, request);
245
+ check();
246
+ browser.context.on("page", (page) => {
247
+ void emulateOn(browser.context, page, request).catch(() => { });
248
+ });
249
+ }
219
250
  /** Text actions run only while the browser window that setup saw still has focus. */
220
251
  function focusGuard(native, window) {
221
252
  return async (actionSignal) => {
@@ -223,7 +254,7 @@ function focusGuard(native, window) {
223
254
  throw new ComputerUseExecutorError("action_rejected", "not_dispatched");
224
255
  };
225
256
  }
226
- async function createGuestExecutorFor(state, browser, signal, onTerminal, check) {
257
+ async function createGuestExecutorFor(state, browser, screen, signal, onTerminal, check) {
227
258
  const native = createGuestDesktopNativeTools({
228
259
  display: ":0",
229
260
  temporaryDirectory: `${GUEST_RUNTIME_PATHS.run}/capture`,
@@ -236,8 +267,8 @@ async function createGuestExecutorFor(state, browser, signal, onTerminal, check)
236
267
  assertFocusedWindow: focusGuard(native, window),
237
268
  });
238
269
  return createGuestDesktopExecutor({
239
- width: 960,
240
- height: 720,
270
+ width: screen.width,
271
+ height: screen.height,
241
272
  tools: createGuestBrowserTools(native, state.content),
242
273
  authoritySignal: signal,
243
274
  onTerminal,
@@ -329,10 +360,11 @@ function closeGuestRuntimeDesktop(state, processes, controller, detach) {
329
360
  controller.abort();
330
361
  return state.closing;
331
362
  }
332
- /** Fixed guest layout with an optional admitted initial loopback app URL. */
363
+ /** Fixed guest layout with an optional admitted initial loopback app URL and participant screen. */
333
364
  export async function createGuestRuntimeDesktop(options) {
334
365
  if (options.initialUrl !== undefined)
335
366
  validateGuestInitialUrl(options.initialUrl);
367
+ const screen = options.display ?? DEFAULT_GUEST_SCREEN;
336
368
  const controller = new AbortController();
337
369
  const signal = AbortSignal.any([options.signal, controller.signal]);
338
370
  const state = { stopping: false };
@@ -360,10 +392,11 @@ export async function createGuestRuntimeDesktop(options) {
360
392
  progress("layout");
361
393
  check();
362
394
  await verifyGuestLayout(check);
395
+ await prepareGuestBrowserProfile(`${GUEST_RUNTIME_PATHS.home}/browser`, options.initialUrl);
363
396
  progress("xauthority");
364
397
  await authorizeDisplay(processes, check);
365
398
  progress("display");
366
- processes.spawn("/usr/bin/Xvfb", XVFB_ARGS, undefined, true);
399
+ processes.spawn("/usr/bin/Xvfb", xvfbArgs(screen), undefined, true);
367
400
  await waitForDisplay(processes, signal, check);
368
401
  progress("window_manager");
369
402
  processes.spawn("/usr/bin/openbox", ["--config-file", `${GUEST_RUNTIME_PATHS.root}/openbox.xml`], undefined, true);
@@ -381,15 +414,19 @@ export async function createGuestRuntimeDesktop(options) {
381
414
  if (options.recording !== undefined) {
382
415
  progress("recording");
383
416
  const env = state.media?.env ?? GUEST_RUNTIME_ENV;
384
- state.recorder = await startGuestRecorder(options.recording, options.media, env, signal);
417
+ state.recorder = await startGuestRecorder(options.recording, options.media, screen, env, signal);
385
418
  check();
386
419
  }
387
420
  progress("browser");
388
- const browser = await launchGuestBrowser(state, options.media, check);
421
+ const browser = await launchGuestBrowser(state, options.media, options.display, check);
389
422
  progress("sandbox");
390
423
  const sandboxReport = await verifyBrowserSandbox(browser.context, browser.page, check);
424
+ if (options.display?.mobileEmulation !== undefined) {
425
+ progress("emulation");
426
+ await emulateMobile(browser, options.display.mobileEmulation, check);
427
+ }
391
428
  progress("focus");
392
- const executor = await createGuestExecutorFor(state, browser, signal, options.onTerminal, check);
429
+ const executor = await createGuestExecutorFor(state, browser, screen, signal, options.onTerminal, check);
393
430
  if (options.initialUrl !== undefined) {
394
431
  progress("navigation");
395
432
  await navigateGuestInitialPage(browser.page, options.initialUrl, signal);
@@ -400,7 +437,7 @@ export async function createGuestRuntimeDesktop(options) {
400
437
  await openGuestFixture(browser.page, check);
401
438
  }
402
439
  return {
403
- executor: state.media?.wrap(executor) ?? executor,
440
+ executor: withExternalHandoffs(state.media?.wrap(executor) ?? executor, `${GUEST_RUNTIME_ENV.XDG_RUNTIME_DIR}/humanish-links.log`),
404
441
  close,
405
442
  ...(options.recording === undefined
406
443
  ? {}
@@ -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;
@@ -23,6 +23,10 @@ export interface ObserverData {
23
23
  state: "running" | "finished" | "interrupted" | "unknown";
24
24
  observedAt: string;
25
25
  source: "local-run-status";
26
+ /** Present while the run's own process analyzes it after the run ended: when it started. */
27
+ analysis?: {
28
+ startedAt: string;
29
+ };
26
30
  };
27
31
  run: {
28
32
  runId: string;
@@ -15,7 +15,7 @@ import { displayedTraces, loadStreamTraces, } from "../run/stream-trace.js";
15
15
  import { isNodeError, isRecord } from "../run/type-guards.js";
16
16
  import { MAX_NOTE_FILE_BYTES, readRunNotes, RUN_NOTES_DIR, } from "../run/note-files.js";
17
17
  import { analysisCostOf, runCost, runCostLabel } from "../run/run-cost.js";
18
- import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, isRunStatusRecord, } from "../run/status.js";
18
+ import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, runAnalysisPhase, isRunStatusRecord, } from "../run/status.js";
19
19
  import { renderObserverHtml } from "./artifact.js";
20
20
  import { parseFeedCursors } from "./feed-cursors.js";
21
21
  import { observerFeedBody } from "./page-data.js";
@@ -98,7 +98,9 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
98
98
  const derivedRoot = cleanedRelativePath.split("/")[0];
99
99
  const derivedLeaf = path.posix.basename(cleanedRelativePath);
100
100
  // Local-only files (raw sandbox ids, the recording pid) are read in process, never served.
101
- if (derivedRoot === ".analysis-lock" ||
101
+ if (
102
+ // `.analysis-lock-stale-*` is a lock whose dead owner's next writer moved it aside.
103
+ derivedRoot?.startsWith(".analysis-lock") === true ||
102
104
  derivedLeaf.startsWith(".humanish-write-") ||
103
105
  isAnalysisRecordPath(cleanedRelativePath) ||
104
106
  isLocalOnlyRunFile(cleanedRelativePath)) {
@@ -330,9 +332,16 @@ function withLocalRunStatus(record, input, outcome) {
330
332
  state = "running";
331
333
  }
332
334
  }
335
+ // The run's own process analyzes it after the run ended; the bundle outcome does not say so.
336
+ const analysis = timestampsValid ? runAnalysisPhase(record, now) : undefined;
333
337
  return {
334
338
  ...data,
335
- runtime: { state, observedAt: new Date(now).toISOString(), source: "local-run-status" },
339
+ runtime: {
340
+ state,
341
+ observedAt: new Date(now).toISOString(),
342
+ source: "local-run-status",
343
+ ...(analysis?.state === "running" ? { analysis: { startedAt: analysis.startedAt } } : {}),
344
+ },
336
345
  };
337
346
  }
338
347
  /** internal: exported for the stream lifecycle tests. */