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.
- package/README.md +49 -6
- package/dist/concurrent-shared-world-lab.js +20 -7
- package/dist/concurrent-shared-world-lab.js.map +1 -1
- package/dist/cua-actor-lab.d.ts +101 -4
- package/dist/cua-actor-lab.js +456 -77
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/device-presets.d.ts +4 -4
- package/dist/device-presets.js +5 -5
- package/dist/device-presets.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lab-config.d.ts +2 -2
- package/dist/observer-assets.js +92 -15
- package/dist/observer-assets.js.map +1 -1
- package/dist/observer-auth.d.ts +21 -0
- package/dist/observer-auth.js +92 -0
- package/dist/observer-auth.js.map +1 -0
- package/dist/observer-library.d.ts +19 -0
- package/dist/observer-library.js +189 -0
- package/dist/observer-library.js.map +1 -0
- package/dist/observer-serve.d.ts +111 -0
- package/dist/observer-serve.js +369 -0
- package/dist/observer-serve.js.map +1 -0
- package/dist/observer.d.ts +31 -0
- package/dist/observer.js +10 -5
- package/dist/observer.js.map +1 -1
- package/dist/oss-meta-lab.js +2 -4
- package/dist/oss-meta-lab.js.map +1 -1
- package/dist/program.js +242 -0
- package/dist/program.js.map +1 -1
- package/dist/run.d.ts +38 -0
- package/dist/run.js +71 -1
- package/dist/run.js.map +1 -1
- package/dist/serve-tunnel.d.ts +16 -0
- package/dist/serve-tunnel.js +104 -0
- package/dist/serve-tunnel.js.map +1 -0
- package/dist/shared-world-lab.d.ts +20 -1
- package/dist/shared-world-lab.js +197 -23
- package/dist/shared-world-lab.js.map +1 -1
- package/docs/architecture/observer.md +7 -0
- package/docs/architecture/serve.md +140 -0
- package/docs/assets/humanish-drawdb-hero.png +0 -0
- package/docs/contracts/run-bundle.md +19 -0
- package/docs/contracts/schemas.md +23 -1
- package/docs/goals/current.md +4 -3
- package/docs/principles/invariants-and-defaults.md +1 -0
- package/docs/ramp/README.md +1 -1
- 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;
|
package/dist/shared-world-lab.js
CHANGED
|
@@ -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
|
|
21
|
-
// (composed into its persona context) — physical per-role geometry is the concurrent
|
|
22
|
-
// job. Each role
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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;
|