humanish 0.139.0 → 0.140.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +5 -4
- package/dist/actors/codex/operator-instructions.d.ts +5 -0
- package/dist/actors/codex/operator-instructions.js +9 -0
- package/dist/actors/codex/restricted-launch.d.ts +10 -2
- package/dist/actors/codex/restricted-launch.js +65 -16
- package/dist/actors/codex/restricted-participant.d.ts +4 -0
- package/dist/actors/codex/restricted-participant.js +9 -0
- package/dist/actors/codex/restricted-policy.d.ts +5 -2
- package/dist/actors/codex/restricted-policy.js +90 -72
- package/dist/actors/codex/restricted-session.d.ts +4 -0
- package/dist/actors/codex/restricted-session.js +8 -0
- package/dist/analysis/automatic-completion.js +24 -0
- package/dist/analysis/automatic-config.d.ts +15 -6
- package/dist/analysis/automatic-config.js +25 -9
- package/dist/cli/commands/study-route-computer-use.js +2 -3
- package/dist/cli/commands/study-route-scripted.js +3 -4
- package/dist/cli/commands/study-route-shared-world.js +2 -3
- package/dist/cli/commands/study-route-terminal.js +3 -4
- package/dist/cli/commands/study-run.js +1 -1
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.js +3 -0
- package/dist/cli/findings.d.ts +17 -14
- package/dist/cli/findings.js +37 -31
- package/dist/cli/io.d.ts +5 -1
- package/dist/cli/io.js +11 -2
- package/dist/cli/run-findings.d.ts +19 -0
- package/dist/cli/run-findings.js +106 -0
- package/dist/guest/bootstrap.d.ts +4 -2
- package/dist/guest/bootstrap.js +15 -7
- package/dist/guest/display-config.d.ts +31 -0
- package/dist/guest/display-config.js +35 -0
- package/dist/guest/runtime-desktop.d.ts +9 -3
- package/dist/guest/runtime-desktop.js +59 -25
- package/dist/guest/runtime.d.ts +2 -1
- package/dist/guest/runtime.js +1 -1
- package/dist/guest-runtime-main.js +2 -1
- package/dist/routes/computer-use/e2b-desktop/fidelity.js +10 -17
- package/dist/routes/computer-use/local-vm.js +34 -1
- package/dist/routes/computer-use/plan.js +1 -1
- package/dist/routes/scripted/plan.js +1 -1
- package/dist/routes/shared-world/plan.js +1 -1
- package/dist/routes/terminal/plan.js +1 -1
- package/dist/run/route-shell.js +1 -1
- package/dist/study/doctor.d.ts +3 -0
- package/dist/study/doctor.js +22 -7
- package/dist/study/init-templates.js +9 -3
- package/dist/study/mobile-emulation.d.ts +27 -0
- package/dist/study/mobile-emulation.js +60 -0
- package/dist/study/plan.js +1 -1
- package/dist/study/preflight-result.js +1 -1
- package/dist/study/summary.js +1 -1
- package/dist/study/warnings.js +5 -2
- package/dist/substrates/e2b/cdp-probe.d.ts +2 -6
- package/dist/substrates/e2b/desktop-cdp.d.ts +0 -2
- package/dist/substrates/e2b/desktop-cdp.js +0 -2
- package/dist/substrates/local/firecracker-desktop.d.ts +5 -0
- package/dist/substrates/local/firecracker-desktop.js +7 -1
- package/dist/substrates/local/runtime-config.d.ts +12 -0
- package/dist/substrates/local/runtime-config.js +55 -13
- package/dist/substrates/local/runtime-release.js +8 -8
- package/dist/substrates/local/runtime.js +1 -0
- package/docs/contracts/study-analysis.md +14 -8
- package/docs/principles/invariants-and-defaults.md +1 -1
- package/docs/product/automatic-analysis.md +46 -11
- package/package.json +1 -1
- package/skills/humanish/SKILL.md +45 -26
package/dist/guest/bootstrap.js
CHANGED
|
@@ -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 +
|
|
9
|
+
bytes: 65_536 + 2048,
|
|
9
10
|
readyBytes: 1024,
|
|
10
11
|
initialUrlBytes: 65_536,
|
|
11
12
|
admissionMs: 5000,
|
|
@@ -42,7 +43,7 @@ export function guestReadyTimeoutMs(initialUrl) {
|
|
|
42
43
|
? 0
|
|
43
44
|
: GUEST_BOOTSTRAP_LIMITS.navigationMs + GUEST_BOOTSTRAP_LIMITS.paintMs));
|
|
44
45
|
}
|
|
45
|
-
function canonical(identity, ready, initialUrl, media, recording) {
|
|
46
|
+
function canonical(identity, ready, initialUrl, media, recording, display) {
|
|
46
47
|
const { generation, challenge, runtimeRevision } = validateBrowserControlIdentity(identity);
|
|
47
48
|
const fixed = { generation, challenge, runtimeRevision };
|
|
48
49
|
if (initialUrl !== undefined && (ready || validateGuestInitialUrl(initialUrl) !== initialUrl))
|
|
@@ -52,6 +53,8 @@ function canonical(identity, ready, initialUrl, media, recording) {
|
|
|
52
53
|
if (recording !== undefined &&
|
|
53
54
|
(ready || !desktopRecordingConfigSchema.safeParse(recording).success))
|
|
54
55
|
throw refused();
|
|
56
|
+
if (display !== undefined && (ready || !guestDisplayConfigSchema.safeParse(display).success))
|
|
57
|
+
throw refused();
|
|
55
58
|
return JSON.stringify(ready
|
|
56
59
|
? { version: 1, ready: true, identity: fixed }
|
|
57
60
|
: {
|
|
@@ -62,10 +65,11 @@ function canonical(identity, ready, initialUrl, media, recording) {
|
|
|
62
65
|
...(recording === undefined
|
|
63
66
|
? {}
|
|
64
67
|
: { recording: desktopRecordingConfigSchema.parse(recording) }),
|
|
68
|
+
...(display === undefined ? {} : { display: guestDisplayConfigSchema.parse(display) }),
|
|
65
69
|
});
|
|
66
70
|
}
|
|
67
|
-
export function encodeGuestBootstrap(identity, ready = false, initialUrl, media, recording) {
|
|
68
|
-
const bytes = Buffer.from(canonical(identity, ready, initialUrl, media, recording));
|
|
71
|
+
export function encodeGuestBootstrap(identity, ready = false, initialUrl, media, recording, display) {
|
|
72
|
+
const bytes = Buffer.from(canonical(identity, ready, initialUrl, media, recording, display));
|
|
69
73
|
if (bytes.length > (ready ? GUEST_BOOTSTRAP_LIMITS.readyBytes : GUEST_BOOTSTRAP_LIMITS.bytes))
|
|
70
74
|
throw refused();
|
|
71
75
|
const frame = Buffer.alloc(4 + bytes.length);
|
|
@@ -89,16 +93,18 @@ function parseBootstrap(bytes, revision, ready) {
|
|
|
89
93
|
const initialUrl = "initialUrl" in value ? value.initialUrl : undefined;
|
|
90
94
|
const media = "media" in value ? guestMediaConfigSchema.parse(value.media) : undefined;
|
|
91
95
|
const recording = "recording" in value ? desktopRecordingConfigSchema.parse(value.recording) : undefined;
|
|
96
|
+
const display = "display" in value ? guestDisplayConfigSchema.parse(value.display) : undefined;
|
|
92
97
|
if (initialUrl !== undefined && typeof initialUrl !== "string")
|
|
93
98
|
throw refused();
|
|
94
99
|
if (identity.runtimeRevision !== revision ||
|
|
95
|
-
canonical(identity, ready, initialUrl, media, recording) !== bytes.toString("ascii"))
|
|
100
|
+
canonical(identity, ready, initialUrl, media, recording, display) !== bytes.toString("ascii"))
|
|
96
101
|
throw refused();
|
|
97
102
|
return {
|
|
98
103
|
identity,
|
|
99
104
|
...(initialUrl === undefined ? {} : { initialUrl }),
|
|
100
105
|
...(media === undefined ? {} : { media }),
|
|
101
106
|
...(recording === undefined ? {} : { recording }),
|
|
107
|
+
...(display === undefined ? {} : { display }),
|
|
102
108
|
};
|
|
103
109
|
}
|
|
104
110
|
catch {
|
|
@@ -116,6 +122,7 @@ export class GuestBootstrapReader {
|
|
|
116
122
|
initialUrl;
|
|
117
123
|
media;
|
|
118
124
|
recording;
|
|
125
|
+
display;
|
|
119
126
|
resolve;
|
|
120
127
|
reject;
|
|
121
128
|
header = Buffer.alloc(4);
|
|
@@ -190,6 +197,7 @@ export class GuestBootstrapReader {
|
|
|
190
197
|
this.initialUrl = parsed.initialUrl;
|
|
191
198
|
this.media = parsed.media;
|
|
192
199
|
this.recording = parsed.recording;
|
|
200
|
+
this.display = parsed.display;
|
|
193
201
|
this.admitted = true;
|
|
194
202
|
clearTimeout(this.timer);
|
|
195
203
|
this.resolve(parsed.identity);
|
|
@@ -244,9 +252,9 @@ export class GuestBootstrapReader {
|
|
|
244
252
|
}
|
|
245
253
|
}
|
|
246
254
|
/** Fixed preface on an already acquired Firecracker stream; no path discovery/retry. */
|
|
247
|
-
export async function connectGuestBootstrap(stream, identity, signal, initialUrl, media, recording) {
|
|
255
|
+
export async function connectGuestBootstrap(stream, identity, signal, initialUrl, media, recording, display) {
|
|
248
256
|
validateBrowserControlIdentity(identity);
|
|
249
|
-
const request = encodeGuestBootstrap(identity, false, initialUrl, media, recording);
|
|
257
|
+
const request = encodeGuestBootstrap(identity, false, initialUrl, media, recording, display);
|
|
250
258
|
await new Promise((resolve, reject) => {
|
|
251
259
|
let bytes = Buffer.alloc(0), done = false;
|
|
252
260
|
const timer = setTimeout(() => finish(new ComputerUseExecutorError("deadline_exceeded", "not_dispatched")), GUEST_BOOTSTRAP_LIMITS.connectMs);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** The screen a guest starts when the bootstrap names none. */
|
|
3
|
+
export declare const DEFAULT_GUEST_SCREEN: Readonly<{
|
|
4
|
+
width: 960;
|
|
5
|
+
height: 720;
|
|
6
|
+
}>;
|
|
7
|
+
/**
|
|
8
|
+
* The screens a guest admits. The narrowest is the narrowest window Chromium renders; the widest
|
|
9
|
+
* is the widest device preset. At 1920×1200 an uncompressed 24-bit frame is 6.9 MB, so every
|
|
10
|
+
* capture fits the 8 MiB browser-control PNG limit. Below 480 pixels high the page area under
|
|
11
|
+
* Chromium's toolbar is too short to use.
|
|
12
|
+
*/
|
|
13
|
+
export declare const GUEST_SCREEN_LIMITS: Readonly<{
|
|
14
|
+
minWidth: 500;
|
|
15
|
+
maxWidth: 1920;
|
|
16
|
+
minHeight: 480;
|
|
17
|
+
maxHeight: 1200;
|
|
18
|
+
}>;
|
|
19
|
+
/** One participant's Xvfb screen, and the mobile emulation its pages get, if any. */
|
|
20
|
+
export declare const guestDisplayConfigSchema: z.ZodObject<{
|
|
21
|
+
width: z.ZodNumber;
|
|
22
|
+
height: z.ZodNumber;
|
|
23
|
+
mobileEmulation: z.ZodOptional<z.ZodObject<{
|
|
24
|
+
width: z.ZodNumber;
|
|
25
|
+
height: z.ZodNumber;
|
|
26
|
+
deviceScaleFactor: z.ZodNumber;
|
|
27
|
+
touch: z.ZodBoolean;
|
|
28
|
+
userAgent: z.ZodString;
|
|
29
|
+
}, z.core.$strict>>;
|
|
30
|
+
}, z.core.$strict>;
|
|
31
|
+
export type GuestDisplayConfig = z.infer<typeof guestDisplayConfigSchema>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { MIN_DESKTOP_RENDER_WIDTH } from "../study/device-presets.js";
|
|
3
|
+
/** The screen a guest starts when the bootstrap names none. */
|
|
4
|
+
export const DEFAULT_GUEST_SCREEN = Object.freeze({ width: 960, height: 720 });
|
|
5
|
+
/**
|
|
6
|
+
* The screens a guest admits. The narrowest is the narrowest window Chromium renders; the widest
|
|
7
|
+
* is the widest device preset. At 1920×1200 an uncompressed 24-bit frame is 6.9 MB, so every
|
|
8
|
+
* capture fits the 8 MiB browser-control PNG limit. Below 480 pixels high the page area under
|
|
9
|
+
* Chromium's toolbar is too short to use.
|
|
10
|
+
*/
|
|
11
|
+
export const GUEST_SCREEN_LIMITS = Object.freeze({
|
|
12
|
+
minWidth: MIN_DESKTOP_RENDER_WIDTH,
|
|
13
|
+
maxWidth: 1920,
|
|
14
|
+
minHeight: 480,
|
|
15
|
+
maxHeight: 1200,
|
|
16
|
+
});
|
|
17
|
+
const side = (max) => z.number().int().min(1).max(max);
|
|
18
|
+
/** One participant's Xvfb screen, and the mobile emulation its pages get, if any. */
|
|
19
|
+
export const guestDisplayConfigSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
width: z.number().int().min(GUEST_SCREEN_LIMITS.minWidth).max(GUEST_SCREEN_LIMITS.maxWidth),
|
|
22
|
+
height: z.number().int().min(GUEST_SCREEN_LIMITS.minHeight).max(GUEST_SCREEN_LIMITS.maxHeight),
|
|
23
|
+
mobileEmulation: z
|
|
24
|
+
.object({
|
|
25
|
+
width: side(GUEST_SCREEN_LIMITS.maxWidth),
|
|
26
|
+
height: side(GUEST_SCREEN_LIMITS.maxHeight),
|
|
27
|
+
deviceScaleFactor: z.number().positive().max(4),
|
|
28
|
+
touch: z.boolean(),
|
|
29
|
+
// The bootstrap frame is ASCII; a launch flag carries it too.
|
|
30
|
+
userAgent: z.string().regex(/^[\x20-\x7e]{1,512}$/),
|
|
31
|
+
})
|
|
32
|
+
.strict()
|
|
33
|
+
.optional(),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Stats } from "node:fs";
|
|
2
2
|
import { type BrowserContext, type Page } from "playwright-core";
|
|
3
|
+
import { type GuestDisplayConfig } from "./display-config.js";
|
|
3
4
|
import type { GuestRuntimeDesktop } from "./runtime.js";
|
|
4
5
|
import type { GuestMediaConfig } from "./media-config.js";
|
|
5
6
|
import { type GuestDesktopMediaOptions } from "./desktop-media.js";
|
|
@@ -10,7 +11,7 @@ export declare const GUEST_RUNTIME_PATHS: Readonly<{
|
|
|
10
11
|
run: "/run/humanish";
|
|
11
12
|
home: "/home/humanish";
|
|
12
13
|
}>;
|
|
13
|
-
export type GuestRuntimePhase = "layout" | "xauthority" | "display" | "window_manager" | "media" | "recording" | "browser" | "sandbox" | "focus" | "fixture" | "navigation";
|
|
14
|
+
export type GuestRuntimePhase = "layout" | "xauthority" | "display" | "window_manager" | "media" | "recording" | "browser" | "sandbox" | "emulation" | "focus" | "fixture" | "navigation";
|
|
14
15
|
/** Initial document only: do not wait for app data, subresources or network idle. */
|
|
15
16
|
export declare function navigateGuestInitialPage(page: Pick<Page, "goto" | "waitForFunction">, initialUrl: string, signal: AbortSignal): Promise<void>;
|
|
16
17
|
/** /run/humanish and /home/humanish: real directories private to the guest user. */
|
|
@@ -19,7 +20,11 @@ export declare function isPrivateGuestDirectory(stat: Pick<Stats, "isDirectory"
|
|
|
19
20
|
export declare function isPinnedOpenboxConfig(stat: Pick<Stats, "isFile" | "uid" | "gid" | "nlink" | "size" | "mode">): boolean;
|
|
20
21
|
/** chrome://sandbox must report every namespace and seccomp feature and the overall verdict. */
|
|
21
22
|
export declare function isAdequatelySandboxed(report: string): boolean;
|
|
22
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The window leaves 20 pixels above and below it on the participant's screen. A mobile participant's
|
|
25
|
+
* user agent and touch flags reach every page and worker; CDP emulation sets the viewport.
|
|
26
|
+
*/
|
|
27
|
+
export declare function chromiumLaunchArgs(media: GuestMediaConfig | undefined, display?: GuestDisplayConfig): string[];
|
|
23
28
|
/** The devices to start; the permission policy only changes Chromium's flags. */
|
|
24
29
|
export declare function desktopMediaRequest(media: GuestMediaConfig): GuestDesktopMediaOptions["media"];
|
|
25
30
|
/** Audio needs Pulse, which only the microphone path starts. */
|
|
@@ -27,7 +32,7 @@ export declare function recorderAudio(recording: DesktopRecordingConfig, media:
|
|
|
27
32
|
audioSources: DesktopRecordingAudioSource[];
|
|
28
33
|
pulseReady: boolean;
|
|
29
34
|
};
|
|
30
|
-
/** Fixed guest layout with an optional admitted initial loopback app URL. */
|
|
35
|
+
/** Fixed guest layout with an optional admitted initial loopback app URL and participant screen. */
|
|
31
36
|
export declare function createGuestRuntimeDesktop(options: {
|
|
32
37
|
signal: AbortSignal;
|
|
33
38
|
onTerminal(): void;
|
|
@@ -35,6 +40,7 @@ export declare function createGuestRuntimeDesktop(options: {
|
|
|
35
40
|
initialUrl?: string;
|
|
36
41
|
media?: GuestMediaConfig;
|
|
37
42
|
recording?: DesktopRecordingConfig;
|
|
43
|
+
display?: GuestDisplayConfig;
|
|
38
44
|
}): Promise<GuestRuntimeDesktop & {
|
|
39
45
|
readonly owner: {
|
|
40
46
|
context: BrowserContext;
|
|
@@ -3,6 +3,8 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { lstat, mkdir, open, rm } from "node:fs/promises";
|
|
4
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
5
5
|
import { chromium } from "playwright-core";
|
|
6
|
+
import { DEFAULT_GUEST_SCREEN } from "./display-config.js";
|
|
7
|
+
import { mobileEmulationCommands, mobileEmulationLaunchFlags, } from "../study/mobile-emulation.js";
|
|
6
8
|
import { createGuestDesktopNativeTools } from "./desktop-native.js";
|
|
7
9
|
import { createGuestChromiumText } from "./chromium-text.js";
|
|
8
10
|
import { createGuestBrowserTools } from "./browser-tools.js";
|
|
@@ -33,16 +35,18 @@ const GUEST_RUNTIME_ENV = Object.freeze({
|
|
|
33
35
|
TMPDIR: "/tmp",
|
|
34
36
|
});
|
|
35
37
|
const CONFIG_SHA = "4ae1c52eab748a3624b3948ce792647be258caba70c8c72038b9a43be6459552";
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
function xvfbArgs(screen) {
|
|
39
|
+
return [
|
|
40
|
+
":0",
|
|
41
|
+
"-screen",
|
|
42
|
+
"0",
|
|
43
|
+
`${screen.width}x${screen.height}x24`,
|
|
44
|
+
"-nolisten",
|
|
45
|
+
"tcp",
|
|
46
|
+
"-auth",
|
|
47
|
+
GUEST_RUNTIME_ENV.XAUTHORITY,
|
|
48
|
+
];
|
|
49
|
+
}
|
|
46
50
|
const SANDBOX_FEATURES = ["PID namespaces", "Network namespaces", "Seccomp-BPF sandbox"];
|
|
47
51
|
const RECORDING_PATH = `${GUEST_RUNTIME_PATHS.home}/desktop-recording.mp4`;
|
|
48
52
|
/** Initial document only: do not wait for app data, subresources or network idle. */
|
|
@@ -86,10 +90,16 @@ export function isAdequatelySandboxed(report) {
|
|
|
86
90
|
return (SANDBOX_FEATURES.every((label) => new RegExp(label + "\\s+Yes").test(report)) &&
|
|
87
91
|
report.includes("You are adequately sandboxed"));
|
|
88
92
|
}
|
|
89
|
-
|
|
93
|
+
/**
|
|
94
|
+
* The window leaves 20 pixels above and below it on the participant's screen. A mobile participant's
|
|
95
|
+
* user agent and touch flags reach every page and worker; CDP emulation sets the viewport.
|
|
96
|
+
*/
|
|
97
|
+
export function chromiumLaunchArgs(media, display) {
|
|
98
|
+
const screen = display ?? DEFAULT_GUEST_SCREEN;
|
|
90
99
|
return [
|
|
91
|
-
|
|
100
|
+
`--window-size=${screen.width},${screen.height - 40}`,
|
|
92
101
|
"--window-position=0,20",
|
|
102
|
+
...mobileEmulationLaunchFlags(display?.mobileEmulation),
|
|
93
103
|
"--disable-background-networking",
|
|
94
104
|
"--disable-component-update",
|
|
95
105
|
"--no-first-run",
|
|
@@ -162,12 +172,12 @@ async function waitForDisplay(processes, signal, check) {
|
|
|
162
172
|
throw setupFailed();
|
|
163
173
|
}
|
|
164
174
|
/** A recorder that fails to start leaves the desktop usable; finishRecording reports it. */
|
|
165
|
-
async function startGuestRecorder(recording, media, env, signal) {
|
|
175
|
+
async function startGuestRecorder(recording, media, screen, env, signal) {
|
|
166
176
|
try {
|
|
167
177
|
return await startDesktopRecorder({
|
|
168
178
|
display: ":0",
|
|
169
|
-
width:
|
|
170
|
-
height:
|
|
179
|
+
width: screen.width,
|
|
180
|
+
height: screen.height,
|
|
171
181
|
outputPath: RECORDING_PATH,
|
|
172
182
|
env,
|
|
173
183
|
signal,
|
|
@@ -178,7 +188,7 @@ async function startGuestRecorder(recording, media, env, signal) {
|
|
|
178
188
|
return undefined;
|
|
179
189
|
}
|
|
180
190
|
}
|
|
181
|
-
async function launchGuestBrowser(state, media, check) {
|
|
191
|
+
async function launchGuestBrowser(state, media, display, check) {
|
|
182
192
|
state.pendingContext = chromium.launchPersistentContext(`${GUEST_RUNTIME_PATHS.home}/browser`, {
|
|
183
193
|
executablePath: "/usr/bin/chromium",
|
|
184
194
|
headless: false,
|
|
@@ -186,7 +196,7 @@ async function launchGuestBrowser(state, media, check) {
|
|
|
186
196
|
viewport: null,
|
|
187
197
|
env: state.recorder?.env ?? state.media?.env ?? GUEST_RUNTIME_ENV,
|
|
188
198
|
timeout: 25_000,
|
|
189
|
-
args: chromiumLaunchArgs(media),
|
|
199
|
+
args: chromiumLaunchArgs(media, display),
|
|
190
200
|
});
|
|
191
201
|
// A browser that finishes launching after the stop is closed here; teardown may not see it.
|
|
192
202
|
void state.pendingContext.then((browser) => {
|
|
@@ -216,6 +226,25 @@ async function verifyBrowserSandbox(context, page, check) {
|
|
|
216
226
|
check();
|
|
217
227
|
return report;
|
|
218
228
|
}
|
|
229
|
+
/** Sends the emulation commands over a page session that stays attached, so they hold. */
|
|
230
|
+
async function emulateOn(context, page, request) {
|
|
231
|
+
const session = await context.newCDPSession(page);
|
|
232
|
+
for (const { method, params } of mobileEmulationCommands(request))
|
|
233
|
+
// Playwright types send by protocol method; these names come from the fixed list.
|
|
234
|
+
await session.send(method, params);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Emulate a phone on the launch page before it navigates, so the first document loads with the
|
|
238
|
+
* phone's viewport, touch and user agent. Tabs opened later get the same overrides when they open;
|
|
239
|
+
* a tab that closes before that is left alone.
|
|
240
|
+
*/
|
|
241
|
+
async function emulateMobile(browser, request, check) {
|
|
242
|
+
await emulateOn(browser.context, browser.page, request);
|
|
243
|
+
check();
|
|
244
|
+
browser.context.on("page", (page) => {
|
|
245
|
+
void emulateOn(browser.context, page, request).catch(() => { });
|
|
246
|
+
});
|
|
247
|
+
}
|
|
219
248
|
/** Text actions run only while the browser window that setup saw still has focus. */
|
|
220
249
|
function focusGuard(native, window) {
|
|
221
250
|
return async (actionSignal) => {
|
|
@@ -223,7 +252,7 @@ function focusGuard(native, window) {
|
|
|
223
252
|
throw new ComputerUseExecutorError("action_rejected", "not_dispatched");
|
|
224
253
|
};
|
|
225
254
|
}
|
|
226
|
-
async function createGuestExecutorFor(state, browser, signal, onTerminal, check) {
|
|
255
|
+
async function createGuestExecutorFor(state, browser, screen, signal, onTerminal, check) {
|
|
227
256
|
const native = createGuestDesktopNativeTools({
|
|
228
257
|
display: ":0",
|
|
229
258
|
temporaryDirectory: `${GUEST_RUNTIME_PATHS.run}/capture`,
|
|
@@ -236,8 +265,8 @@ async function createGuestExecutorFor(state, browser, signal, onTerminal, check)
|
|
|
236
265
|
assertFocusedWindow: focusGuard(native, window),
|
|
237
266
|
});
|
|
238
267
|
return createGuestDesktopExecutor({
|
|
239
|
-
width:
|
|
240
|
-
height:
|
|
268
|
+
width: screen.width,
|
|
269
|
+
height: screen.height,
|
|
241
270
|
tools: createGuestBrowserTools(native, state.content),
|
|
242
271
|
authoritySignal: signal,
|
|
243
272
|
onTerminal,
|
|
@@ -329,10 +358,11 @@ function closeGuestRuntimeDesktop(state, processes, controller, detach) {
|
|
|
329
358
|
controller.abort();
|
|
330
359
|
return state.closing;
|
|
331
360
|
}
|
|
332
|
-
/** Fixed guest layout with an optional admitted initial loopback app URL. */
|
|
361
|
+
/** Fixed guest layout with an optional admitted initial loopback app URL and participant screen. */
|
|
333
362
|
export async function createGuestRuntimeDesktop(options) {
|
|
334
363
|
if (options.initialUrl !== undefined)
|
|
335
364
|
validateGuestInitialUrl(options.initialUrl);
|
|
365
|
+
const screen = options.display ?? DEFAULT_GUEST_SCREEN;
|
|
336
366
|
const controller = new AbortController();
|
|
337
367
|
const signal = AbortSignal.any([options.signal, controller.signal]);
|
|
338
368
|
const state = { stopping: false };
|
|
@@ -363,7 +393,7 @@ export async function createGuestRuntimeDesktop(options) {
|
|
|
363
393
|
progress("xauthority");
|
|
364
394
|
await authorizeDisplay(processes, check);
|
|
365
395
|
progress("display");
|
|
366
|
-
processes.spawn("/usr/bin/Xvfb",
|
|
396
|
+
processes.spawn("/usr/bin/Xvfb", xvfbArgs(screen), undefined, true);
|
|
367
397
|
await waitForDisplay(processes, signal, check);
|
|
368
398
|
progress("window_manager");
|
|
369
399
|
processes.spawn("/usr/bin/openbox", ["--config-file", `${GUEST_RUNTIME_PATHS.root}/openbox.xml`], undefined, true);
|
|
@@ -381,15 +411,19 @@ export async function createGuestRuntimeDesktop(options) {
|
|
|
381
411
|
if (options.recording !== undefined) {
|
|
382
412
|
progress("recording");
|
|
383
413
|
const env = state.media?.env ?? GUEST_RUNTIME_ENV;
|
|
384
|
-
state.recorder = await startGuestRecorder(options.recording, options.media, env, signal);
|
|
414
|
+
state.recorder = await startGuestRecorder(options.recording, options.media, screen, env, signal);
|
|
385
415
|
check();
|
|
386
416
|
}
|
|
387
417
|
progress("browser");
|
|
388
|
-
const browser = await launchGuestBrowser(state, options.media, check);
|
|
418
|
+
const browser = await launchGuestBrowser(state, options.media, options.display, check);
|
|
389
419
|
progress("sandbox");
|
|
390
420
|
const sandboxReport = await verifyBrowserSandbox(browser.context, browser.page, check);
|
|
421
|
+
if (options.display?.mobileEmulation !== undefined) {
|
|
422
|
+
progress("emulation");
|
|
423
|
+
await emulateMobile(browser, options.display.mobileEmulation, check);
|
|
424
|
+
}
|
|
391
425
|
progress("focus");
|
|
392
|
-
const executor = await createGuestExecutorFor(state, browser, signal, options.onTerminal, check);
|
|
426
|
+
const executor = await createGuestExecutorFor(state, browser, screen, signal, options.onTerminal, check);
|
|
393
427
|
if (options.initialUrl !== undefined) {
|
|
394
428
|
progress("navigation");
|
|
395
429
|
await navigateGuestInitialPage(browser.page, options.initialUrl, signal);
|
package/dist/guest/runtime.d.ts
CHANGED
|
@@ -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<{
|
package/dist/guest/runtime.js
CHANGED
|
@@ -83,7 +83,7 @@ export async function runGuestRuntime(options) {
|
|
|
83
83
|
void close();
|
|
84
84
|
}, guestReadyTimeoutMs(reader.initialUrl));
|
|
85
85
|
options.marker("A");
|
|
86
|
-
preparing = options.createDesktop(authority, terminal, reader.initialUrl, reader.media, reader.recording);
|
|
86
|
+
preparing = options.createDesktop(authority, terminal, reader.initialUrl, reader.media, reader.recording, reader.display);
|
|
87
87
|
desktop = await Promise.race([
|
|
88
88
|
preparing,
|
|
89
89
|
new Promise((_, reject) => {
|
|
@@ -27,12 +27,13 @@ try {
|
|
|
27
27
|
if (writeSync(Number(markerFd), value) !== 1)
|
|
28
28
|
throw new Error();
|
|
29
29
|
},
|
|
30
|
-
createDesktop: (signal, onTerminal, initialUrl, media, recording) => createGuestRuntimeDesktop({
|
|
30
|
+
createDesktop: (signal, onTerminal, initialUrl, media, recording, display) => createGuestRuntimeDesktop({
|
|
31
31
|
signal,
|
|
32
32
|
onTerminal,
|
|
33
33
|
...(initialUrl === undefined ? {} : { initialUrl }),
|
|
34
34
|
...(media === undefined ? {} : { media }),
|
|
35
35
|
...(recording === undefined ? {} : { recording }),
|
|
36
|
+
...(display === undefined ? {} : { display }),
|
|
36
37
|
}),
|
|
37
38
|
});
|
|
38
39
|
const result = await runtime.closed;
|
|
@@ -2,18 +2,17 @@
|
|
|
2
2
|
// browser-state observer that watches it for drift, and the final browser geometry.
|
|
3
3
|
import { redactText, toErrorMessage } from "../../../evidence/redaction.js";
|
|
4
4
|
import { readDetachedLog } from "../../../substrates/detached.js";
|
|
5
|
-
import { applyMobileEmulation,
|
|
5
|
+
import { applyMobileEmulation, makeChromeBrowserStateObserver, } from "../../../substrates/e2b/desktop-cdp.js";
|
|
6
|
+
import { mobileEmulationLaunchFlags, participantMobileEmulation, } from "../../../study/mobile-emulation.js";
|
|
6
7
|
import { captureDesktopBrowserGeometry } from "../../../substrates/e2b/desktop-geometry.js";
|
|
7
8
|
import { e2bShell } from "../../../substrates/e2b/shell.js";
|
|
9
|
+
/** The participant's emulation request; see participantMobileEmulation for who gets one. */
|
|
10
|
+
function emulationFor(residual, spec) {
|
|
11
|
+
return participantMobileEmulation(residual.execution?.desktop?.fidelity, spec.planned.device.preset);
|
|
12
|
+
}
|
|
8
13
|
/** Only participants on a mobile preset are emulated; the flags go on the browser command line. */
|
|
9
14
|
export function mobileLaunchFlags(residual, spec) {
|
|
10
|
-
|
|
11
|
-
if (!requested?.mobileEmulation || !spec.planned.device.preset.isMobile)
|
|
12
|
-
return [];
|
|
13
|
-
return [
|
|
14
|
-
`--user-agent=${requested.userAgent ?? DEFAULT_MOBILE_USER_AGENT}`,
|
|
15
|
-
...(requested.touch === false ? [] : ["--touch-events=enabled"]),
|
|
16
|
-
];
|
|
15
|
+
return mobileEmulationLaunchFlags(emulationFor(residual, spec));
|
|
17
16
|
}
|
|
18
17
|
/** The launched browser's DevTools address, as far as the launch recorded it. */
|
|
19
18
|
export function cdpEndpoint(identity, targetUrl) {
|
|
@@ -33,24 +32,18 @@ export function cdpEndpoint(identity, targetUrl) {
|
|
|
33
32
|
*/
|
|
34
33
|
export async function applyParticipantMobileFidelity(args) {
|
|
35
34
|
const { spec, deps } = args;
|
|
36
|
-
const request = deps.residual
|
|
35
|
+
const request = emulationFor(deps.residual, spec);
|
|
37
36
|
const none = {
|
|
38
37
|
applied: undefined,
|
|
39
38
|
emulatedTargetId: undefined,
|
|
40
39
|
holderName: undefined,
|
|
41
40
|
};
|
|
42
|
-
if (
|
|
41
|
+
if (request === undefined)
|
|
43
42
|
return none;
|
|
44
43
|
if (args.browserFamily !== "chromium") {
|
|
45
44
|
throw new Error(`execution.desktop.fidelity.mobileEmulation needs Chrome or Chromium for participant ${spec.planned.id}; the launched browser family is ${args.browserFamily}. Set execution.desktop.browser: chrome.`);
|
|
46
45
|
}
|
|
47
|
-
const applied = await applyMobileEmulation(args.desktop, deps.requestTimeoutMs, cdpEndpoint(args.launchIdentity, args.targetUrl), {
|
|
48
|
-
width: spec.planned.device.preset.width,
|
|
49
|
-
height: spec.planned.device.preset.height,
|
|
50
|
-
deviceScaleFactor: request.deviceScaleFactor ?? spec.planned.device.preset.deviceScaleFactor,
|
|
51
|
-
touch: request.touch ?? true,
|
|
52
|
-
userAgent: request.userAgent ?? DEFAULT_MOBILE_USER_AGENT,
|
|
53
|
-
}, {
|
|
46
|
+
const applied = await applyMobileEmulation(args.desktop, deps.requestTimeoutMs, cdpEndpoint(args.launchIdentity, args.targetUrl), request, {
|
|
54
47
|
targetId: args.targetId,
|
|
55
48
|
...(args.network === undefined ? {} : { network: args.network.request }),
|
|
56
49
|
});
|
|
@@ -3,7 +3,9 @@ import path from "node:path";
|
|
|
3
3
|
import { participantList } from "../../study/study-fields.js";
|
|
4
4
|
import { inboxRecipientFor, } from "./participant-desktop.js";
|
|
5
5
|
import { createLocalFirecrackerDesktop, } from "../../substrates/local/firecracker-desktop.js";
|
|
6
|
-
import { localBrowserDefaults, localBrowserUnsupportedReason, } from "../../substrates/local/runtime-config.js";
|
|
6
|
+
import { localBrowserDefaults, localBrowserUnsupportedReason, localGuestDisplay, } from "../../substrates/local/runtime-config.js";
|
|
7
|
+
import { mobileEmulationCommands } from "../../study/mobile-emulation.js";
|
|
8
|
+
import { declaredScreenForRender } from "../../substrates/e2b/desktop-geometry.js";
|
|
7
9
|
import { localRuntimeCapacity, prepareLocalRuntime } from "../../substrates/local/runtime.js";
|
|
8
10
|
import { dockerCommandLine, usesLima } from "../../substrates/local/runtime-host.js";
|
|
9
11
|
import { outOfMemoryMessage } from "../../substrates/local/capacity.js";
|
|
@@ -52,6 +54,30 @@ function mediaEvidence(media) {
|
|
|
52
54
|
},
|
|
53
55
|
};
|
|
54
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The geometry a local participant records when its guest emulates a phone: the requested screen
|
|
59
|
+
* and the emulation the guest applied before the first page loaded. The guest does not read back
|
|
60
|
+
* what the page reports, so the block has no `resolved` part and says so.
|
|
61
|
+
*/
|
|
62
|
+
function emulatedGeometry(run, display) {
|
|
63
|
+
const { device } = run.planned;
|
|
64
|
+
const declared = declaredScreenForRender(device.preset, device.name, device.resolution);
|
|
65
|
+
const request = display.mobileEmulation;
|
|
66
|
+
return {
|
|
67
|
+
screen: {
|
|
68
|
+
requested: { width: display.width, height: display.height },
|
|
69
|
+
...(declared === undefined ? {} : { declared }),
|
|
70
|
+
},
|
|
71
|
+
fidelity: {
|
|
72
|
+
tier: "mobile-emulated",
|
|
73
|
+
requested: request,
|
|
74
|
+
applied: mobileEmulationCommands(request).map((command) => command.method),
|
|
75
|
+
},
|
|
76
|
+
warnings: [
|
|
77
|
+
`Mobile emulation was applied for participant ${run.planned.id} in the local guest, which does not read back the page's own report; desktopGeometry.fidelity carries the request without a resolved block.`,
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
55
81
|
/** One participant's Firecracker desktop and optional captured inbox, released in finalize. */
|
|
56
82
|
function createLocalParticipantDesktop(context, run, warnings, artifactRoot) {
|
|
57
83
|
const { config, media, recording, state } = context;
|
|
@@ -71,11 +97,13 @@ function createLocalParticipantDesktop(context, run, warnings, artifactRoot) {
|
|
|
71
97
|
const runtime = await context.assets();
|
|
72
98
|
if (address && email?.kind === "fake" && email.external)
|
|
73
99
|
inbox = await startLocalCapturedInbox(email.external, address);
|
|
100
|
+
const display = localGuestDisplay(config, run.planned.device);
|
|
74
101
|
session = await createLocalFirecrackerDesktop({
|
|
75
102
|
assets: runtime,
|
|
76
103
|
...(inbox ? { inboxUrl: inbox.url } : {}),
|
|
77
104
|
...(media === undefined ? {} : { media }),
|
|
78
105
|
...(recording === undefined ? {} : { recording }),
|
|
106
|
+
...(display === undefined ? {} : { display }),
|
|
79
107
|
appUrl: run.planned.targetUrl ?? config.subject.appUrl,
|
|
80
108
|
outputRoot: path.join(context.cwd, ".humanish", "local-runtime"),
|
|
81
109
|
...(context.signal === undefined ? {} : { signal: context.signal }),
|
|
@@ -83,6 +111,11 @@ function createLocalParticipantDesktop(context, run, warnings, artifactRoot) {
|
|
|
83
111
|
state.sessions.push(session);
|
|
84
112
|
if (media !== undefined)
|
|
85
113
|
evidence.desktopBrowser = mediaEvidence(media);
|
|
114
|
+
if (display?.mobileEmulation !== undefined)
|
|
115
|
+
evidence.desktopGeometry = emulatedGeometry(run, {
|
|
116
|
+
...display,
|
|
117
|
+
mobileEmulation: display.mobileEmulation,
|
|
118
|
+
});
|
|
86
119
|
},
|
|
87
120
|
async openSession() {
|
|
88
121
|
if (!session)
|
|
@@ -343,7 +343,7 @@ export function planComputerUseStudy(config, input) {
|
|
|
343
343
|
...(actor === undefined ? {} : { actor }),
|
|
344
344
|
},
|
|
345
345
|
});
|
|
346
|
-
const analysis = resolveAutomaticAnalysis(config.review?.analysis);
|
|
346
|
+
const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
|
|
347
347
|
if (!analysis.ok)
|
|
348
348
|
return refuse("before-scope", "HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message);
|
|
349
349
|
const tasksReason = taskProtocolValidationReason(config, true);
|
|
@@ -34,7 +34,7 @@ export function planScriptedStudy(config, input) {
|
|
|
34
34
|
const beforeScope = { beforeScope: true };
|
|
35
35
|
if (String(config.comms?.email?.kind) === "real")
|
|
36
36
|
return refuse("HUMANISH_SCRIPTED_SCENARIO_INVALID", "Real email receiving is unsupported on the scripted route. Use a supported hosted computer-use browser study.", beforeScope);
|
|
37
|
-
const analysis = resolveAutomaticAnalysis(config.review?.analysis);
|
|
37
|
+
const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
|
|
38
38
|
if (!analysis.ok)
|
|
39
39
|
return refuse("HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message, beforeScope);
|
|
40
40
|
const tasksReason = taskProtocolValidationReason(config, false);
|
|
@@ -38,7 +38,7 @@ export function planSharedWorldStudy(config, input) {
|
|
|
38
38
|
networkCaptureValidationReason(config, "shared-world");
|
|
39
39
|
if (mediaReason)
|
|
40
40
|
return refuse(invalid, mediaReason);
|
|
41
|
-
const analysis = resolveAutomaticAnalysis(config.review?.analysis);
|
|
41
|
+
const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
|
|
42
42
|
if (!analysis.ok)
|
|
43
43
|
return refuse("HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message);
|
|
44
44
|
const tasksReason = taskProtocolValidationReason(config, false);
|
|
@@ -39,7 +39,7 @@ export function planTerminalStudy(config, input) {
|
|
|
39
39
|
const modal = sandboxProvider === "modal" ? modalRefusal(config) : undefined;
|
|
40
40
|
if (modal !== undefined)
|
|
41
41
|
return refuse(modal.code, modal.message);
|
|
42
|
-
const analysis = resolveAutomaticAnalysis(config.review?.analysis);
|
|
42
|
+
const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
|
|
43
43
|
if (!analysis.ok)
|
|
44
44
|
return refuse("HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message);
|
|
45
45
|
const tasksReason = taskProtocolValidationReason(config, false);
|
package/dist/run/route-shell.js
CHANGED
|
@@ -42,7 +42,7 @@ export async function admitRoute(shell) {
|
|
|
42
42
|
* or default analysis is recorded as skipped.
|
|
43
43
|
*/
|
|
44
44
|
export function completeRefusalAnalysis(refused, config, input) {
|
|
45
|
-
const analysis = resolveAutomaticAnalysis(config.review?.analysis);
|
|
45
|
+
const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
|
|
46
46
|
return completeAutomaticAnalysis(refused, undefined, analysis.ok ? analysis.config : undefined, input, { trigger: config.review?.analysis === undefined ? "default" : "explicit" });
|
|
47
47
|
}
|
|
48
48
|
/** A result as its route's outcome. StudyOutcome pairs each route with its result type. */
|
package/dist/study/doctor.d.ts
CHANGED
|
@@ -22,10 +22,13 @@ type CodexReadiness = {
|
|
|
22
22
|
protocolIncompatibilities?: readonly string[];
|
|
23
23
|
/** Schema values beyond the baseline, recorded by a launch that passed. */
|
|
24
24
|
protocolAdditions?: readonly string[];
|
|
25
|
+
/** What in the operator's Codex setup the launch refused, without paths or values. */
|
|
26
|
+
refusalReason?: string;
|
|
25
27
|
};
|
|
26
28
|
/** What doctor's hosted Codex participant check found: a readiness result and what it admitted. */
|
|
27
29
|
type HostedCodexReadiness = CodexReadiness & {
|
|
28
30
|
cliVersion?: string;
|
|
31
|
+
operatorInstructionFiles?: readonly string[];
|
|
29
32
|
resolvedModel?: string;
|
|
30
33
|
authentication?: "chatgpt-account" | "api-key";
|
|
31
34
|
};
|