@yibie/pi-jev-browser 0.1.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/src/actions.ts ADDED
@@ -0,0 +1,205 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import type { Page } from "playwright";
3
+ import type { BrowserAction } from "./types.ts";
4
+
5
+ export async function executeActions(
6
+ page: Page,
7
+ actions: BrowserAction[],
8
+ options: { assertUrlAllowed: (url: string) => void; signal?: AbortSignal },
9
+ ): Promise<void> {
10
+ if (!Array.isArray(actions) || actions.length === 0) {
11
+ throw new Error("actions must contain at least one Jev Browser action.");
12
+ }
13
+ if (actions.length > 50) {
14
+ throw new Error("A single jev_actions call is limited to 50 actions.");
15
+ }
16
+ // Validate the whole batch before touching the page, so a malformed action
17
+ // cannot leave earlier actions half-applied.
18
+ for (const action of actions) assertActionShape(action);
19
+
20
+ for (const action of actions) {
21
+ assertActive(options.signal);
22
+ switch (action.type) {
23
+ case "click":
24
+ case "double_click": {
25
+ const modifiers = (action.keys ?? []).map(normalizeKey);
26
+ const options = {
27
+ button: normalizeButton(action.button),
28
+ clickCount: action.type === "double_click" ? 2 : 1,
29
+ modifiers,
30
+ };
31
+ await page.mouse.click(action.x, action.y, options);
32
+ break;
33
+ }
34
+ case "scroll":
35
+ if (typeof action.x === "number" && typeof action.y === "number") {
36
+ await page.mouse.move(action.x, action.y);
37
+ }
38
+ await page.mouse.wheel(action.deltaX, action.deltaY);
39
+ break;
40
+ case "type":
41
+ await page.keyboard.type(action.text);
42
+ break;
43
+ case "wait":
44
+ await delay(
45
+ Math.min(30_000, Math.max(0, action.ms ?? 1000)),
46
+ options.signal,
47
+ );
48
+ break;
49
+ case "keypress":
50
+ await page.keyboard.press(action.keys.map(normalizeKey).join("+"));
51
+ break;
52
+ case "drag":
53
+ await executeDrag(page, action.path, normalizeButton(action.button));
54
+ break;
55
+ case "move":
56
+ await page.mouse.move(action.x, action.y);
57
+ break;
58
+ case "screenshot":
59
+ break;
60
+ case "navigate":
61
+ options.assertUrlAllowed(action.url);
62
+ await page.goto(action.url, {
63
+ waitUntil: "domcontentloaded",
64
+ timeout: 30_000,
65
+ });
66
+ break;
67
+ case "back":
68
+ await page.goBack({ waitUntil: "domcontentloaded", timeout: 30_000 });
69
+ break;
70
+ case "forward":
71
+ await page.goForward({
72
+ waitUntil: "domcontentloaded",
73
+ timeout: 30_000,
74
+ });
75
+ break;
76
+ case "reload":
77
+ await page.reload({ waitUntil: "domcontentloaded", timeout: 30_000 });
78
+ break;
79
+ default:
80
+ throw new Error(
81
+ `Unsupported Jev Browser action: ${String((action as { type?: unknown }).type)}`,
82
+ );
83
+ }
84
+ }
85
+ }
86
+
87
+ export function normalizeKey(value: string): string {
88
+ const key = value.trim();
89
+ const lookup = key.toUpperCase();
90
+ const aliases: Record<string, string> = {
91
+ CTRL: "Control",
92
+ CONTROL: "Control",
93
+ CMD: "Meta",
94
+ COMMAND: "Meta",
95
+ META: "Meta",
96
+ ALT: "Alt",
97
+ OPTION: "Alt",
98
+ SHIFT: "Shift",
99
+ ENTER: "Enter",
100
+ RETURN: "Enter",
101
+ ESC: "Escape",
102
+ ESCAPE: "Escape",
103
+ SPACE: "Space",
104
+ TAB: "Tab",
105
+ BACKSPACE: "Backspace",
106
+ DELETE: "Delete",
107
+ DEL: "Delete",
108
+ HOME: "Home",
109
+ END: "End",
110
+ PGUP: "PageUp",
111
+ PAGEUP: "PageUp",
112
+ PGDN: "PageDown",
113
+ PAGEDOWN: "PageDown",
114
+ UP: "ArrowUp",
115
+ ARROWUP: "ArrowUp",
116
+ DOWN: "ArrowDown",
117
+ ARROWDOWN: "ArrowDown",
118
+ LEFT: "ArrowLeft",
119
+ ARROWLEFT: "ArrowLeft",
120
+ RIGHT: "ArrowRight",
121
+ ARROWRIGHT: "ArrowRight",
122
+ };
123
+ return aliases[lookup] ?? (key.length === 1 ? key : key);
124
+ }
125
+
126
+ function normalizeButton(value?: "left" | "right" | "wheel") {
127
+ if (!value || value === "left") return "left" as const;
128
+ if (value === "right") return "right" as const;
129
+ if (value === "wheel") return "middle" as const;
130
+ throw new Error(`Unsupported mouse button: ${String(value)}`);
131
+ }
132
+
133
+ async function executeDrag(
134
+ page: Page,
135
+ path: Array<{ x: number; y: number } | [number, number]>,
136
+ button: "left" | "right" | "middle",
137
+ ) {
138
+ if (!Array.isArray(path) || path.length < 2) {
139
+ throw new Error("drag requires a path with at least two points.");
140
+ }
141
+ const points = path.map((point) =>
142
+ Array.isArray(point) ? { x: point[0], y: point[1] } : point,
143
+ );
144
+ await page.mouse.move(points[0].x, points[0].y);
145
+ await page.mouse.down({ button });
146
+ try {
147
+ for (const point of points.slice(1)) {
148
+ await page.mouse.move(point.x, point.y, { steps: 5 });
149
+ }
150
+ } finally {
151
+ await page.mouse.up({ button });
152
+ }
153
+ }
154
+
155
+ function assertActionShape(action: BrowserAction) {
156
+ const numeric = (value: unknown) =>
157
+ typeof value === "number" && Number.isFinite(value);
158
+ const require = (condition: boolean, message: string) => {
159
+ if (!condition) throw new Error(message);
160
+ };
161
+ switch (action.type) {
162
+ case "click":
163
+ case "double_click":
164
+ case "move":
165
+ return require(
166
+ numeric(action.x) && numeric(action.y),
167
+ `${action.type} requires numeric x and y.`,
168
+ );
169
+ case "scroll":
170
+ return require(
171
+ numeric(action.deltaX) && numeric(action.deltaY),
172
+ "scroll requires numeric deltaX and deltaY.",
173
+ );
174
+ case "type":
175
+ return require(
176
+ typeof action.text === "string",
177
+ "type requires text.",
178
+ );
179
+ case "keypress":
180
+ return require(
181
+ Array.isArray(action.keys) && action.keys.length > 0,
182
+ "keypress requires at least one key.",
183
+ );
184
+ case "drag":
185
+ return require(
186
+ Array.isArray(action.path) && action.path.length >= 2,
187
+ "drag requires a path with at least two points.",
188
+ );
189
+ case "navigate":
190
+ return require(
191
+ typeof action.url === "string" && action.url.trim().length > 0,
192
+ "navigate requires a url.",
193
+ );
194
+ default:
195
+ return;
196
+ }
197
+ }
198
+
199
+ function assertActive(signal?: AbortSignal) {
200
+ if (signal?.aborted) throw new Error("Browser action was aborted.");
201
+ }
202
+
203
+ async function delay(ms: number, signal?: AbortSignal) {
204
+ await sleep(ms, undefined, { signal });
205
+ }
@@ -0,0 +1,45 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const require = createRequire(import.meta.url);
8
+
9
+ async function installChromium(): Promise<void> {
10
+ // Resolve the installed plugin's CLI so browser revisions match its dependency.
11
+ const cli = join(
12
+ dirname(require.resolve("playwright/package.json")),
13
+ "cli.js",
14
+ );
15
+ try {
16
+ await execFileAsync(process.execPath, [cli, "install", "chromium"], {
17
+ timeout: 120_000,
18
+ maxBuffer: 2 * 1024 * 1024,
19
+ windowsHide: true,
20
+ });
21
+ } catch {
22
+ // Installer output can contain proxy credentials; do not expose it to tools.
23
+ throw new Error(
24
+ "Automatic Chromium setup failed or timed out. Check network/proxy access and browser-cache permissions, then retry jev_run. On Linux, required system libraries must also be installed by the system administrator.",
25
+ );
26
+ }
27
+ }
28
+
29
+ export function createBrowserSetup(install: () => Promise<void>) {
30
+ let pending: Promise<void> | undefined;
31
+ return () => {
32
+ if (!pending) {
33
+ pending = Promise.resolve()
34
+ .then(install)
35
+ .catch((error) => {
36
+ pending = undefined;
37
+ throw error;
38
+ });
39
+ }
40
+ return pending;
41
+ };
42
+ }
43
+
44
+ // The CLI checks its cache and downloads only missing browser artifacts.
45
+ export const ensureChromium = createBrowserSetup(installChromium);
package/src/config.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import type { JevBrowserConfig } from "./types.ts";
5
+
6
+ export const CONFIG_PATH =
7
+ process.env.PI_JEV_BROWSER_CONFIG?.trim() ||
8
+ join(homedir(), ".pi", "agent", "pi-jev-browser.config.json");
9
+
10
+ const DEFAULT_CONFIG: JevBrowserConfig = {
11
+ // pi's own configured model by default: it needs no second credential and no
12
+ // extra API quota, so the loop works out of the box.
13
+ policy: "pi",
14
+ allowedOrigins: ["http://*", "https://*"],
15
+ headless: true,
16
+ recordVideo: true,
17
+ showCursor: true,
18
+ showClickIndicators: true,
19
+ outputDir: join(homedir(), ".pi", "agent", "data", "jev-browser"),
20
+ viewport: { width: 1280, height: 720 },
21
+ stream: { enabled: false, intervalMs: 1000 },
22
+ };
23
+
24
+ export function readConfigFile(path = CONFIG_PATH): Record<string, unknown> {
25
+ try {
26
+ const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
27
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
28
+ throw new Error("Invalid configuration");
29
+ return raw as Record<string, unknown>;
30
+ } catch (error) {
31
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
32
+ throw new Error(
33
+ "Cannot read pi-jev-browser.config.json. Check permissions and JSON syntax.",
34
+ );
35
+ }
36
+ }
37
+
38
+ export function readConfig(path = CONFIG_PATH): JevBrowserConfig {
39
+ // Only browser settings leave this reader; credentials stay out of browser state.
40
+ const raw = readConfigFile(path) as Partial<JevBrowserConfig>;
41
+
42
+ const viewport = {
43
+ width: boundedInteger(
44
+ raw.viewport?.width,
45
+ 640,
46
+ 2560,
47
+ DEFAULT_CONFIG.viewport.width,
48
+ ),
49
+ height: boundedInteger(
50
+ raw.viewport?.height,
51
+ 480,
52
+ 1600,
53
+ DEFAULT_CONFIG.viewport.height,
54
+ ),
55
+ };
56
+ const allowedOrigins = Array.isArray(raw.allowedOrigins)
57
+ ? raw.allowedOrigins.filter(
58
+ (value): value is string =>
59
+ typeof value === "string" && value.trim().length > 0,
60
+ )
61
+ : DEFAULT_CONFIG.allowedOrigins;
62
+
63
+ return {
64
+ policy: raw.policy === "typesafe" ? "typesafe" : DEFAULT_CONFIG.policy,
65
+ allowedOrigins,
66
+ headless: raw.headless !== false,
67
+ recordVideo: raw.recordVideo !== false,
68
+ showCursor: raw.showCursor !== false,
69
+ showClickIndicators: raw.showClickIndicators !== false,
70
+ outputDir:
71
+ typeof raw.outputDir === "string" && raw.outputDir.trim()
72
+ ? resolve(raw.outputDir.replace(/^~/, homedir()))
73
+ : DEFAULT_CONFIG.outputDir,
74
+ viewport,
75
+ stream: {
76
+ enabled: raw.stream?.enabled === true,
77
+ intervalMs: boundedInteger(
78
+ raw.stream?.intervalMs,
79
+ 250,
80
+ 10_000,
81
+ DEFAULT_CONFIG.stream.intervalMs,
82
+ ),
83
+ },
84
+ };
85
+ }
86
+
87
+ export function isUrlAllowed(value: string, patterns: string[]): boolean {
88
+ if (value === "about:blank") return true;
89
+ let url: URL;
90
+ try {
91
+ url = new URL(value);
92
+ } catch {
93
+ return false;
94
+ }
95
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
96
+
97
+ return patterns.some((pattern) => {
98
+ const normalized = pattern.trim();
99
+ if (!normalized) return false;
100
+ const expression = `^${escapeRegExp(normalized).replaceAll("\\*", ".*")}$`;
101
+ return new RegExp(expression, "i").test(url.origin);
102
+ });
103
+ }
104
+
105
+ function boundedInteger(
106
+ value: unknown,
107
+ min: number,
108
+ max: number,
109
+ fallback: number,
110
+ ) {
111
+ return typeof value === "number" && Number.isInteger(value)
112
+ ? Math.min(max, Math.max(min, value))
113
+ : fallback;
114
+ }
115
+
116
+ function escapeRegExp(value: string) {
117
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
118
+ }
@@ -0,0 +1,26 @@
1
+ import { CONFIG_PATH, readConfigFile } from "./config.ts";
2
+
3
+ /**
4
+ * Credentials for the direct TypeSafe API. Read per run without mutating
5
+ * process.env or exposing them to the browser process, which gets `env: {}`.
6
+ */
7
+ export function readTypesafeCredentials(
8
+ options: { path?: string; env?: NodeJS.ProcessEnv } = {},
9
+ ) {
10
+ const path = options.path ?? CONFIG_PATH;
11
+ const env = options.env ?? process.env;
12
+ const raw = readConfigFile(path);
13
+ const typesafe = raw.typesafe as
14
+ | { apiKey?: unknown; model?: unknown }
15
+ | undefined;
16
+ const value = (input: unknown) =>
17
+ typeof input === "string" ? input.trim() : "";
18
+ const apiKey = value(env.TYPESAFE_API_KEY) || value(typesafe?.apiKey);
19
+ if (!apiKey)
20
+ throw new Error(
21
+ `policy "typesafe" requires TYPESAFE_API_KEY in the pi process environment or typesafe.apiKey in ${path}. Get a key at https://console.typesafe.ai/keys`,
22
+ );
23
+ const model =
24
+ value(env.TYPESAFE_MODEL) || value(typesafe?.model) || "jev-latest";
25
+ return { apiKey, model };
26
+ }