humanish 0.15.3 → 0.17.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 (48) hide show
  1. package/README.md +49 -6
  2. package/dist/concurrent-shared-world-lab.js +20 -7
  3. package/dist/concurrent-shared-world-lab.js.map +1 -1
  4. package/dist/cua-actor-lab.d.ts +101 -4
  5. package/dist/cua-actor-lab.js +456 -77
  6. package/dist/cua-actor-lab.js.map +1 -1
  7. package/dist/device-presets.d.ts +4 -4
  8. package/dist/device-presets.js +5 -5
  9. package/dist/device-presets.js.map +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/lab-config.d.ts +2 -2
  13. package/dist/observer-assets.js +92 -15
  14. package/dist/observer-assets.js.map +1 -1
  15. package/dist/observer-auth.d.ts +21 -0
  16. package/dist/observer-auth.js +92 -0
  17. package/dist/observer-auth.js.map +1 -0
  18. package/dist/observer-library.d.ts +19 -0
  19. package/dist/observer-library.js +189 -0
  20. package/dist/observer-library.js.map +1 -0
  21. package/dist/observer-serve.d.ts +111 -0
  22. package/dist/observer-serve.js +369 -0
  23. package/dist/observer-serve.js.map +1 -0
  24. package/dist/observer.d.ts +31 -0
  25. package/dist/observer.js +10 -5
  26. package/dist/observer.js.map +1 -1
  27. package/dist/oss-meta-lab.js +2 -4
  28. package/dist/oss-meta-lab.js.map +1 -1
  29. package/dist/program.js +242 -0
  30. package/dist/program.js.map +1 -1
  31. package/dist/run.d.ts +38 -0
  32. package/dist/run.js +71 -1
  33. package/dist/run.js.map +1 -1
  34. package/dist/serve-tunnel.d.ts +16 -0
  35. package/dist/serve-tunnel.js +104 -0
  36. package/dist/serve-tunnel.js.map +1 -0
  37. package/dist/shared-world-lab.d.ts +20 -1
  38. package/dist/shared-world-lab.js +197 -23
  39. package/dist/shared-world-lab.js.map +1 -1
  40. package/docs/architecture/observer.md +7 -0
  41. package/docs/architecture/serve.md +140 -0
  42. package/docs/assets/humanish-drawdb-hero.png +0 -0
  43. package/docs/contracts/run-bundle.md +19 -0
  44. package/docs/contracts/schemas.md +23 -1
  45. package/docs/goals/current.md +4 -3
  46. package/docs/principles/invariants-and-defaults.md +1 -0
  47. package/docs/ramp/README.md +1 -1
  48. package/package.json +1 -1
@@ -0,0 +1,16 @@
1
+ import { spawn } from "node:child_process";
2
+ export type ServeTunnelErrorCode = "HUMANISH_SERVE_TUNNEL_NOT_FOUND" | "HUMANISH_SERVE_TUNNEL_START_FAILED";
3
+ export declare class ServeTunnelError extends Error {
4
+ readonly code: ServeTunnelErrorCode;
5
+ constructor(code: ServeTunnelErrorCode, message: string);
6
+ }
7
+ export interface ServeTunnel {
8
+ url: string;
9
+ close(): Promise<void>;
10
+ }
11
+ export declare function startNgrokTunnel(options: {
12
+ port: number;
13
+ domain?: string;
14
+ timeoutMs?: number;
15
+ spawnImpl?: typeof spawn;
16
+ }): Promise<ServeTunnel>;
@@ -0,0 +1,104 @@
1
+ import { spawn } from "node:child_process";
2
+ export class ServeTunnelError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.name = "ServeTunnelError";
7
+ this.code = code;
8
+ }
9
+ }
10
+ export async function startNgrokTunnel(options) {
11
+ const spawnImpl = options.spawnImpl ?? spawn;
12
+ const timeoutMs = options.timeoutMs ?? 15_000;
13
+ const args = [
14
+ "http",
15
+ "--log",
16
+ "stdout",
17
+ "--log-format",
18
+ "json",
19
+ ...(options.domain ? ["--url", options.domain] : []),
20
+ String(options.port)
21
+ ];
22
+ const child = spawnImpl("ngrok", args, { stdio: ["ignore", "pipe", "ignore"] });
23
+ const url = await new Promise((resolve, reject) => {
24
+ let settled = false;
25
+ let buffered = "";
26
+ const settle = (outcome) => {
27
+ if (settled) {
28
+ return;
29
+ }
30
+ settled = true;
31
+ clearTimeout(timer);
32
+ if ("url" in outcome) {
33
+ resolve(outcome.url);
34
+ }
35
+ else {
36
+ killChild(child);
37
+ reject(outcome.error);
38
+ }
39
+ };
40
+ const timer = setTimeout(() => {
41
+ settle({
42
+ error: new ServeTunnelError("HUMANISH_SERVE_TUNNEL_START_FAILED", `ngrok did not report a started tunnel within ${timeoutMs}ms.`)
43
+ });
44
+ }, timeoutMs);
45
+ child.once("error", (error) => {
46
+ settle({
47
+ error: error.code === "ENOENT"
48
+ ? new ServeTunnelError("HUMANISH_SERVE_TUNNEL_NOT_FOUND", "ngrok binary not found on PATH. Install ngrok, or run your own tunnel and pass --public-url <origin>.")
49
+ : new ServeTunnelError("HUMANISH_SERVE_TUNNEL_START_FAILED", `ngrok failed to start: ${error.message}`)
50
+ });
51
+ });
52
+ child.once("exit", (code) => {
53
+ settle({
54
+ error: new ServeTunnelError("HUMANISH_SERVE_TUNNEL_START_FAILED", `ngrok exited (${code ?? "signal"}) before reporting a started tunnel.`)
55
+ });
56
+ });
57
+ child.stdout?.setEncoding("utf8");
58
+ child.stdout?.on("data", (chunk) => {
59
+ buffered += chunk;
60
+ const lines = buffered.split("\n");
61
+ buffered = lines.pop() ?? "";
62
+ for (const line of lines) {
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(line);
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ if (typeof parsed === "object"
71
+ && parsed !== null
72
+ && parsed.msg === "started tunnel"
73
+ && typeof parsed.url === "string") {
74
+ settle({ url: parsed.url });
75
+ return;
76
+ }
77
+ }
78
+ });
79
+ });
80
+ return {
81
+ url,
82
+ close: async () => {
83
+ await killChild(child);
84
+ }
85
+ };
86
+ }
87
+ function killChild(child) {
88
+ return new Promise((resolve) => {
89
+ if (child.exitCode !== null || child.signalCode !== null) {
90
+ resolve();
91
+ return;
92
+ }
93
+ child.once("exit", () => resolve());
94
+ // Reclamation stays scoped to the exact child this call created.
95
+ child.kill("SIGTERM");
96
+ setTimeout(() => {
97
+ if (child.exitCode === null && child.signalCode === null) {
98
+ child.kill("SIGKILL");
99
+ }
100
+ resolve();
101
+ }, 2_000).unref();
102
+ });
103
+ }
104
+ //# sourceMappingURL=serve-tunnel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve-tunnel.js","sourceRoot":"","sources":["../src/serve-tunnel.ts"],"names":[],"mappings":"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"}
@@ -11,7 +11,7 @@ import { type LabConfig, type LabSubjectStateCheckpoint } from "./lab-config.js"
11
11
  import { renderObserver, type ObserverResult } from "./observer.js";
12
12
  import type { LocalTreeArchive } from "./source-archive.js";
13
13
  import type { StopWhen } from "./stop-conditions.js";
14
- import { type RunBundle, type RunSubjectProvenance, type SharedWorldCheckpoint } from "./run.js";
14
+ import { type RunBundle, type RunDesktopGeometry, type RunSubjectProvenance, type SharedWorldCheckpoint } from "./run.js";
15
15
  export declare const SHARED_WORLD_LAB_SCHEMA = "humanish.shared-world-lab-result.v1";
16
16
  export declare const SHARED_WORLD_LAB_PROVIDER_METADATA: {
17
17
  readonly mode: "shared-world-lab";
@@ -156,6 +156,7 @@ interface RoleOutcome {
156
156
  sessionError?: string;
157
157
  screenshots: string[];
158
158
  desktopBrowser?: DesktopBrowserEvidence;
159
+ desktopGeometry?: RunDesktopGeometry;
159
160
  /** Set when fail-fast skipped this role before it ran. */
160
161
  skippedReason?: string;
161
162
  noEngagement: boolean;
@@ -163,6 +164,23 @@ interface RoleOutcome {
163
164
  /** The checkpoint snapshot taken AFTER this role's turn (absent for skipped roles). */
164
165
  afterCheckpoint?: SharedWorldCheckpoint;
165
166
  }
167
+ /**
168
+ * Self-match-proof pkill/pgrep -f pattern for a seat's unique profile dir: bracket the first
169
+ * character so the in-sandbox shell running the termination script (whose own command line
170
+ * carries this pattern) can never match itself. Exported (pure) for contract tests.
171
+ */
172
+ export declare function seatProfilePkillPattern(profileDir: string): string;
173
+ /**
174
+ * Build the in-sandbox command that ends ONE seat's browser when its turn ends (pure; exported
175
+ * for contract tests). All roles share the ONE desktop, so a prior seat's browser left alive
176
+ * could keep polling/holding websockets and mutating the shared plane during a later role's
177
+ * turn, with its authenticated window one Alt-Tab away from the current actor. The recorded
178
+ * launch PID (a setsid session leader) is the primary kill (its process group takes the whole
179
+ * browser tree); pkill -f on the seat's unique profile dir is the fallback; a short bounded
180
+ * wait escalates to SIGKILL. Exit is always 0: a termination failure degrades to the caller's
181
+ * warning, never a failed run.
182
+ */
183
+ export declare function buildSeatBrowserTerminationCommand(processId: string | undefined, profileDir: string): string;
166
184
  /** Combine a snapshot's per-probe digests into ONE sha256-16 (digest-only; no raw value). */
167
185
  export declare function combineCheckpointDigest(parts: string[]): string;
168
186
  /**
@@ -201,6 +219,7 @@ export declare function buildSharedWorldBundle(args: {
201
219
  subject: RunSubjectProvenance;
202
220
  sandboxResolution: [number, number];
203
221
  sandboxPreset: DevicePreset;
222
+ desktopGeometry?: RunDesktopGeometry;
204
223
  seedDigest: string;
205
224
  subjectCommit?: string;
206
225
  failFastReason?: string;
@@ -17,16 +17,16 @@
17
17
  // persist DIGEST-ONLY. The concurrent (getHost) topology, a handoff/barrier grammar, an onTurn
18
18
  // hook, and real per-role login are named NON-GOALS (PR2+).
19
19
  //
20
- // FIDELITY NOTE: one sandbox has ONE desktop geometry, so a role's `device` is a PROMPT SIGNAL
21
- // (composed into its persona context) — physical per-role geometry is the concurrent topology's
22
- // job. Each role's stream viewport records the sandbox's actual rendered resolution (honest).
20
+ // FIDELITY NOTE: one sandbox has ONE screen geometry, so a role's `device` is a PROMPT SIGNAL
21
+ // (composed into its persona context) — physical per-role screen geometry is the concurrent
22
+ // topology's job. Each role records its measured browser viewport separately from that screen.
23
23
  import { randomBytes } from "node:crypto";
24
24
  import path from "node:path";
25
25
  import { runDesktopCommandOrThrow, toErrorMessage } from "./command-failure.js";
26
26
  import { adapterScoreFailureMessage, applyBrowserAdapterHooks } from "./adapter-extension.js";
27
27
  import { actorRegistry, isCuaActorDescriptor } from "./actor-registry.js";
28
28
  import { CHROMIUM_EVIDENCE_HYGIENE_FLAGS, chromiumEvidenceProfilePreferencesJson } from "./browser-evidence-hygiene.js";
29
- import { commandDigestOf, composeLaneInstructions, defaultPackLocalTree, makeChromeBrowserStateObserver, makeLaneWriteScreenshot, provisionCloneSubject, provisionLocalTreeSubject, resolveLaneDevice, resolveSubjectState, SUBJECT_DIR } from "./cua-actor-lab.js";
29
+ import { commandDigestOf, composeLaneInstructions, defaultPackLocalTree, makeChromeBrowserStateObserver, captureDesktopBrowserGeometry, inspectDesktopScreenGeometry, makeLaneWriteScreenshot, provisionCloneSubject, provisionLocalTreeSubject, resolveLaneDevice, resolveSubjectState, SUBJECT_DIR, desktopBrowserFamily } from "./cua-actor-lab.js";
30
30
  import { createDesktopSandbox, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
31
31
  import { runDetachedStep } from "./e2b-detached.js";
32
32
  import { resolveSeatUrl, sharedWorldValidationReason } from "./lab-config.js";
@@ -43,6 +43,8 @@ export const SHARED_WORLD_LAB_PROVIDER_METADATA = {
43
43
  const DEFAULT_SESSION_TIMEOUT_MS = 300_000;
44
44
  // Settle after opening a seat's browser, before the session's first screenshot.
45
45
  const BROWSER_SETTLE_MS = 8_000;
46
+ // In-sandbox budget for ending one seat's browser at turn end (TERM, short wait, KILL).
47
+ const SEAT_BROWSER_TERMINATION_TIMEOUT_MS = 15_000;
46
48
  // Server-side reclamation buffer past the loop's own wall-clock stop.
47
49
  const SANDBOX_TIMEOUT_BUFFER_MS = 10 * 60_000;
48
50
  // Room for the one-time clone/install/build/start/probe + the sequential per-role sessions.
@@ -90,15 +92,27 @@ async function launchSeatBrowser(desktop, args) {
90
92
  " local label=\"$1\"",
91
93
  " local binary=\"$2\"",
92
94
  " if ! command -v \"$binary\" >/dev/null 2>&1; then return 127; fi",
93
- " echo \"HUMANISH_BROWSER_RESOLVED=$label\"",
94
- " pkill -f '[r]emote-debugging-port=9222' 2>/dev/null || true",
95
95
  " prepare_chrome_profile",
96
- " setsid -f \"$binary\" --new-window --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222 --user-data-dir=\"$profile_dir\" \"${chrome_debug_flags[@]}\" \"$seat_url\" > /dev/null 2>&1 < /dev/null",
96
+ " rm -f \"$profile_dir/DevToolsActivePort\"",
97
+ " setsid \"$binary\" --new-window --remote-debugging-address=127.0.0.1 --remote-debugging-port=0 --user-data-dir=\"$profile_dir\" \"${chrome_debug_flags[@]}\" \"$seat_url\" > /dev/null 2>&1 < /dev/null &",
98
+ " local launch_pid=$!",
99
+ " echo \"HUMANISH_BROWSER_RESOLVED=$label\"",
100
+ " echo \"HUMANISH_BROWSER_PID=$launch_pid\"",
101
+ " for _ in $(seq 1 30); do",
102
+ " if [ -s \"$profile_dir/DevToolsActivePort\" ]; then",
103
+ " head -n 1 \"$profile_dir/DevToolsActivePort\" | sed 's/^/HUMANISH_BROWSER_CDP_PORT=/'",
104
+ " break",
105
+ " fi",
106
+ " sleep 0.1",
107
+ " done",
97
108
  "}",
98
109
  "launch_firefox() {",
99
110
  " if ! command -v firefox >/dev/null 2>&1; then return 127; fi",
111
+ " setsid firefox --new-instance --no-remote --new-window --profile \"$profile_dir\" \"$seat_url\" > /dev/null 2>&1 < /dev/null &",
112
+ " local launch_pid=$!",
100
113
  " echo \"HUMANISH_BROWSER_RESOLVED=firefox\"",
101
- " setsid -f firefox --new-window --profile \"$profile_dir\" \"$seat_url\" > /dev/null 2>&1 < /dev/null",
114
+ " echo \"HUMANISH_BROWSER_PID=$launch_pid\"",
115
+ " echo \"HUMANISH_BROWSER_PROFILE_DIR=$profile_dir\"",
102
116
  "}",
103
117
  "case \"$browser_preference\" in",
104
118
  " chrome)",
@@ -121,15 +135,60 @@ async function launchSeatBrowser(desktop, args) {
121
135
  if (args.browserPreference !== undefined && args.browserPreference !== "default" && result.exitCode !== undefined && result.exitCode !== 0) {
122
136
  throw new Error(`requested desktop browser "${args.browserPreference}" could not be launched for shared-world seat`);
123
137
  }
124
- if (args.browserPreference === undefined) {
125
- return undefined;
126
- }
127
138
  const resolved = (result.stdout ?? "").match(/^HUMANISH_BROWSER_RESOLVED=(\S+)$/m)?.[1];
139
+ const processId = (result.stdout ?? "").match(/^HUMANISH_BROWSER_PID=(\d+)$/m)?.[1];
140
+ const profileDir = (result.stdout ?? "").match(/^HUMANISH_BROWSER_PROFILE_DIR=(\S+)$/m)?.[1] ?? args.profileDir;
141
+ const cdpPortRaw = (result.stdout ?? "").match(/^HUMANISH_BROWSER_CDP_PORT=(\d+)$/m)?.[1];
142
+ const cdpPort = cdpPortRaw === undefined ? undefined : Number(cdpPortRaw);
128
143
  return {
129
- requested,
130
- ...(resolved === undefined ? {} : { resolved })
144
+ family: desktopBrowserFamily(resolved ?? requested),
145
+ ...(processId === undefined
146
+ ? {}
147
+ : { identity: { processId, profileDir, targetUrl: args.seatUrl, ...(cdpPort === undefined ? {} : { cdpPort }) } }),
148
+ ...(args.browserPreference === undefined
149
+ ? {}
150
+ : { evidence: { requested, ...(resolved === undefined ? {} : { resolved }) } })
131
151
  };
132
152
  }
153
+ /**
154
+ * Self-match-proof pkill/pgrep -f pattern for a seat's unique profile dir: bracket the first
155
+ * character so the in-sandbox shell running the termination script (whose own command line
156
+ * carries this pattern) can never match itself. Exported (pure) for contract tests.
157
+ */
158
+ export function seatProfilePkillPattern(profileDir) {
159
+ const head = profileDir.slice(0, 1);
160
+ const tail = profileDir.slice(1).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
161
+ return `[${head}]${tail}`;
162
+ }
163
+ /**
164
+ * Build the in-sandbox command that ends ONE seat's browser when its turn ends (pure; exported
165
+ * for contract tests). All roles share the ONE desktop, so a prior seat's browser left alive
166
+ * could keep polling/holding websockets and mutating the shared plane during a later role's
167
+ * turn, with its authenticated window one Alt-Tab away from the current actor. The recorded
168
+ * launch PID (a setsid session leader) is the primary kill (its process group takes the whole
169
+ * browser tree); pkill -f on the seat's unique profile dir is the fallback; a short bounded
170
+ * wait escalates to SIGKILL. Exit is always 0: a termination failure degrades to the caller's
171
+ * warning, never a failed run.
172
+ */
173
+ export function buildSeatBrowserTerminationCommand(processId, profileDir) {
174
+ return [
175
+ "set -u",
176
+ `launch_pid=${shellQuote(processId ?? "")}`,
177
+ `profile_pattern=${shellQuote(seatProfilePkillPattern(profileDir))}`,
178
+ 'if [ -n "$launch_pid" ]; then',
179
+ ' kill -TERM -- "-$launch_pid" 2>/dev/null || kill -TERM "$launch_pid" 2>/dev/null || true',
180
+ "fi",
181
+ 'pkill -TERM -f "$profile_pattern" 2>/dev/null || true',
182
+ "for _ in $(seq 1 20); do",
183
+ ' if ! pgrep -f "$profile_pattern" >/dev/null 2>&1; then',
184
+ " exit 0",
185
+ " fi",
186
+ " sleep 0.1",
187
+ "done",
188
+ 'pkill -KILL -f "$profile_pattern" 2>/dev/null || true',
189
+ "exit 0"
190
+ ].join("\n");
191
+ }
133
192
  /** Combine a snapshot's per-probe digests into ONE sha256-16 (digest-only; no raw value). */
134
193
  export function combineCheckpointDigest(parts) {
135
194
  return commandDigestOf(parts.join("\n"));
@@ -300,6 +359,9 @@ export async function runSharedWorldLab(options) {
300
359
  let sandboxId;
301
360
  let killed = false;
302
361
  let failFastReason;
362
+ let sharedScreenGeometry = {
363
+ screen: { requested: { width: sandboxResolution[0], height: sandboxResolution[1] } }
364
+ };
303
365
  // Pack the working tree ONCE per run, on the host, BEFORE any sandbox is created (mirrors the
304
366
  // cua route's ordering): a packing failure fails the run closed here, never spending sandbox
305
367
  // cost. Dry-run packs nothing (no fs side effects; the contract bundle carries no archiveSha256).
@@ -353,6 +415,23 @@ export async function runSharedWorldLab(options) {
353
415
  if (hooks.prepareDesktop) {
354
416
  await hooks.prepareDesktop(desktop);
355
417
  }
418
+ const screenGeometry = await inspectDesktopScreenGeometry({
419
+ desktop,
420
+ laneId: "shared-world",
421
+ requestedScreen: sandboxResolution,
422
+ requestTimeoutMs
423
+ });
424
+ if (screenGeometry.verified) {
425
+ sharedScreenGeometry = {
426
+ screen: { ...sharedScreenGeometry.screen, verified: screenGeometry.verified }
427
+ };
428
+ }
429
+ if (screenGeometry.warning) {
430
+ warnings.push(screenGeometry.warning);
431
+ sharedScreenGeometry = { ...sharedScreenGeometry, warnings: [screenGeometry.warning] };
432
+ }
433
+ if (screenGeometry.error)
434
+ throw new Error(screenGeometry.error);
356
435
  // Provision the shared plane ONCE: clone + install/build + seed + serve + readiness probe
357
436
  // (clone route), or upload/extract the once-per-run packed archive + the SAME shared serve
358
437
  // pipeline (local-tree route). One stderr line per phase boundary by default; hooks.onPhase
@@ -418,15 +497,38 @@ export async function runSharedWorldLab(options) {
418
497
  let session;
419
498
  let sessionError;
420
499
  let desktopBrowser;
500
+ let launchedBrowserFamily = "unknown";
501
+ let browserLaunchIdentity;
502
+ let browserLaunched = false;
503
+ let initialBrowserGeometry;
504
+ let browserWindowId;
505
+ let browserTargetId;
506
+ let desktopGeometry = sharedScreenGeometry;
421
507
  try {
422
508
  // Fresh isolated browser profile per seat, opened at the role's same-origin loopback entry.
423
- desktopBrowser = await launchSeatBrowser(desktop, {
509
+ const browserLaunch = await launchSeatBrowser(desktop, {
424
510
  ...(config.execution?.desktop?.browser === undefined ? {} : { browserPreference: config.execution.desktop.browser }),
425
511
  profileDir: spec.profileDir,
426
512
  seatUrl: spec.seatUrl,
427
513
  requestTimeoutMs
428
514
  });
515
+ desktopBrowser = browserLaunch.evidence;
516
+ launchedBrowserFamily = browserLaunch.family;
517
+ browserLaunchIdentity = browserLaunch.identity;
518
+ browserLaunched = true;
429
519
  await desktop.wait(BROWSER_SETTLE_MS).catch(() => undefined);
520
+ const browserGeometry = await captureDesktopBrowserGeometry({
521
+ desktop,
522
+ browserFamily: launchedBrowserFamily,
523
+ ...(browserLaunchIdentity === undefined ? {} : { launchIdentity: browserLaunchIdentity }),
524
+ laneId: spec.roleId,
525
+ targetUrl: spec.seatUrl,
526
+ requestedScreen: sandboxResolution,
527
+ requestTimeoutMs
528
+ });
529
+ initialBrowserGeometry = browserGeometry;
530
+ browserWindowId = browserGeometry.browserWindowId;
531
+ browserTargetId = browserGeometry.browserTargetId;
430
532
  const sessionOptions = {
431
533
  instructions: spec.instructions,
432
534
  persona: spec.persona,
@@ -436,9 +538,17 @@ export async function runSharedWorldLab(options) {
436
538
  ...(config.actors[0]?.model ? { model: config.actors[0].model } : {})
437
539
  },
438
540
  desktop: desktop,
439
- executorOptions: {
440
- observeBrowserState: makeChromeBrowserStateObserver(desktop, requestTimeoutMs)
441
- },
541
+ ...(launchedBrowserFamily === "chromium"
542
+ ? {
543
+ executorOptions: {
544
+ observeBrowserState: makeChromeBrowserStateObserver(desktop, requestTimeoutMs, {
545
+ ...(browserLaunchIdentity?.cdpPort === undefined ? {} : { cdpPort: browserLaunchIdentity.cdpPort }),
546
+ ...(browserLaunchIdentity?.profileDir === undefined ? {} : { profileDir: browserLaunchIdentity.profileDir }),
547
+ targetUrl: spec.seatUrl
548
+ }, browserTargetId)
549
+ }
550
+ }
551
+ : {}),
442
552
  redactScreenshots,
443
553
  scrubText: scrubKnownValues,
444
554
  writeScreenshot,
@@ -449,6 +559,49 @@ export async function runSharedWorldLab(options) {
449
559
  catch (error) {
450
560
  sessionError = redactText(scrubKnownValues(toErrorMessage(error)));
451
561
  }
562
+ if (browserLaunched) {
563
+ const finalGeometry = await captureDesktopBrowserGeometry({
564
+ desktop,
565
+ browserFamily: launchedBrowserFamily,
566
+ ...(browserLaunchIdentity === undefined ? {} : { launchIdentity: browserLaunchIdentity }),
567
+ ...(browserWindowId === undefined ? {} : { browserWindowId }),
568
+ ...(browserTargetId === undefined ? {} : { browserTargetId }),
569
+ laneId: spec.roleId,
570
+ targetUrl: spec.seatUrl,
571
+ requestedScreen: sandboxResolution,
572
+ requestTimeoutMs,
573
+ resize: false
574
+ }).catch((error) => ({
575
+ warnings: [`Final browser geometry measurement failed for lane ${spec.roleId}: ${redactText(scrubKnownValues(toErrorMessage(error)))}`]
576
+ }));
577
+ // Chosen capture rule (mirrors runCuaLane): seat-end-if-it-measured-anything, else
578
+ // seat-open. A seat-end capture that measured EITHER field wins whole, so a partial
579
+ // seat-end capture omits fields the seat-open capture had (honest omission); only a
580
+ // seat-end capture that measured NOTHING falls back to the seat-open capture.
581
+ const chosenGeometry = finalGeometry.browserWindow !== undefined || finalGeometry.viewport !== undefined
582
+ ? finalGeometry
583
+ : initialBrowserGeometry ?? finalGeometry;
584
+ const geometryWarnings = [...new Set(chosenGeometry.warnings.map((warning) => scrubKnownValues(warning)))];
585
+ warnings.push(...geometryWarnings);
586
+ desktopGeometry = {
587
+ ...sharedScreenGeometry,
588
+ ...(chosenGeometry.browserWindow === undefined ? {} : { browserWindow: chosenGeometry.browserWindow }),
589
+ ...(chosenGeometry.viewport === undefined ? {} : { viewport: chosenGeometry.viewport }),
590
+ ...((sharedScreenGeometry.warnings?.length ?? 0) + geometryWarnings.length === 0
591
+ ? {}
592
+ : { warnings: [...(sharedScreenGeometry.warnings ?? []), ...geometryWarnings] })
593
+ };
594
+ // End THIS seat's browser now that its turn (and its final geometry capture) is done:
595
+ // every role shares the ONE desktop, so this is the per-seat identity boundary. Runs
596
+ // after the final seat too. Bounded + best-effort: a failure degrades to an explicit
597
+ // warning (the run continues), never a hang.
598
+ try {
599
+ await desktop.commands.run(buildSeatBrowserTerminationCommand(browserLaunchIdentity?.processId, spec.profileDir), { requestTimeoutMs, timeoutMs: SEAT_BROWSER_TERMINATION_TIMEOUT_MS });
600
+ }
601
+ catch (error) {
602
+ warnings.push(`Seat browser termination failed for role ${spec.roleId} (run continues; the seat's browser may remain open on the shared desktop): ${redactText(scrubKnownValues(toErrorMessage(error)))}`);
603
+ }
604
+ }
452
605
  if (session) {
453
606
  await writeContainedOutputFile(runPaths, spec.traceArtifactPath, `${JSON.stringify(session.trace, null, 2)}\n`, "utf8");
454
607
  if (session.trace.redaction.screenshots === "raw") {
@@ -482,6 +635,7 @@ export async function runSharedWorldLab(options) {
482
635
  ...(sessionError === undefined ? {} : { sessionError }),
483
636
  screenshots,
484
637
  ...(desktopBrowser === undefined ? {} : { desktopBrowser }),
638
+ desktopGeometry,
485
639
  noEngagement,
486
640
  harnessError,
487
641
  afterCheckpoint
@@ -566,6 +720,7 @@ export async function runSharedWorldLab(options) {
566
720
  subject,
567
721
  sandboxResolution,
568
722
  sandboxPreset,
723
+ desktopGeometry: sharedScreenGeometry,
569
724
  seedDigest: seedRecipeDigest(config),
570
725
  ...(planeCommit === undefined ? {} : { subjectCommit: planeCommit }),
571
726
  ...(failFastReason === undefined ? {} : { failFastReason })
@@ -719,6 +874,9 @@ export function buildSharedWorldBundle(args) {
719
874
  roleSpecs.forEach((spec, index) => {
720
875
  const outcome = roleOutcomes[index];
721
876
  const session = outcome?.session;
877
+ const desktopGeometry = outcome?.desktopGeometry ?? args.desktopGeometry ?? {
878
+ screen: { requested: { width: args.sandboxResolution[0], height: args.sandboxResolution[1] } }
879
+ };
722
880
  const screenshots = outcome?.screenshots ?? [];
723
881
  const lastScreenshot = screenshots[screenshots.length - 1];
724
882
  const status = outcome?.skippedReason !== undefined
@@ -768,12 +926,17 @@ export function buildSharedWorldBundle(args) {
768
926
  embed: lastScreenshot
769
927
  ? { kind: "screenshot", url: lastScreenshot, title: `Shared desktop, role ${spec.roleId} (${screenshotMode})` }
770
928
  : { kind: "placeholder", title: `Shared desktop, role ${spec.roleId}` },
771
- viewport: {
772
- width: args.sandboxResolution[0],
773
- height: args.sandboxResolution[1],
774
- deviceScaleFactor: args.sandboxPreset.deviceScaleFactor,
775
- isMobile: args.sandboxPreset.isMobile
776
- },
929
+ ...(desktopGeometry.viewport === undefined
930
+ ? {}
931
+ : {
932
+ viewport: {
933
+ width: desktopGeometry.viewport.width,
934
+ height: desktopGeometry.viewport.height,
935
+ deviceScaleFactor: desktopGeometry.viewport.deviceScaleFactor,
936
+ isMobile: args.sandboxPreset.isMobile
937
+ }
938
+ }),
939
+ desktopGeometry,
777
940
  ui: {
778
941
  route: spec.seatUrl,
779
942
  intent: `Watch role ${spec.roleId} (${spec.persona.id}) drive the SHARED app (one plane; sequential turn).`,
@@ -841,6 +1004,17 @@ export function buildSharedWorldBundle(args) {
841
1004
  streamId: spec.streamId
842
1005
  });
843
1006
  }
1007
+ for (const warning of desktopGeometry.warnings ?? []) {
1008
+ events.push({
1009
+ id: nextEventId(`geometry-warning-${spec.roleId}`),
1010
+ at: createdAt,
1011
+ level: "warn",
1012
+ type: "shared-world.geometry.warning",
1013
+ message: warning,
1014
+ simId: spec.simId,
1015
+ streamId: spec.streamId
1016
+ });
1017
+ }
844
1018
  });
845
1019
  // Build the shared-world evidence block (the timeline + plane + attribution ceiling).
846
1020
  const seedDigest = args.seedDigest;