@moxxy/plugin-browser 0.27.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/LICENSE +21 -0
- package/dist/browser-session.d.ts +43 -0
- package/dist/browser-session.d.ts.map +1 -0
- package/dist/browser-session.js +500 -0
- package/dist/browser-session.js.map +1 -0
- package/dist/browser-surface.d.ts +3 -0
- package/dist/browser-surface.d.ts.map +1 -0
- package/dist/browser-surface.js +255 -0
- package/dist/browser-surface.js.map +1 -0
- package/dist/html-extract.d.ts +20 -0
- package/dist/html-extract.d.ts.map +1 -0
- package/dist/html-extract.js +122 -0
- package/dist/html-extract.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/sidecar/dispatch.d.ts +18 -0
- package/dist/sidecar/dispatch.d.ts.map +1 -0
- package/dist/sidecar/dispatch.js +294 -0
- package/dist/sidecar/dispatch.js.map +1 -0
- package/dist/sidecar/install.d.ts +37 -0
- package/dist/sidecar/install.d.ts.map +1 -0
- package/dist/sidecar/install.js +242 -0
- package/dist/sidecar/install.js.map +1 -0
- package/dist/sidecar/types.d.ts +97 -0
- package/dist/sidecar/types.d.ts.map +1 -0
- package/dist/sidecar/types.js +20 -0
- package/dist/sidecar/types.js.map +1 -0
- package/dist/sidecar.d.ts +31 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +165 -0
- package/dist/sidecar.js.map +1 -0
- package/dist/ssrf-guard.d.ts +43 -0
- package/dist/ssrf-guard.d.ts.map +1 -0
- package/dist/ssrf-guard.js +164 -0
- package/dist/ssrf-guard.js.map +1 -0
- package/dist/web-fetch.d.ts +23 -0
- package/dist/web-fetch.d.ts.map +1 -0
- package/dist/web-fetch.js +253 -0
- package/dist/web-fetch.js.map +1 -0
- package/package.json +74 -0
- package/src/browser-session.test.ts +333 -0
- package/src/browser-session.ts +567 -0
- package/src/browser-surface.test.ts +367 -0
- package/src/browser-surface.ts +275 -0
- package/src/html-extract.ts +152 -0
- package/src/index.ts +35 -0
- package/src/sidecar/dispatch.test.ts +313 -0
- package/src/sidecar/dispatch.ts +314 -0
- package/src/sidecar/install.ts +283 -0
- package/src/sidecar/types.test.ts +26 -0
- package/src/sidecar/types.ts +114 -0
- package/src/sidecar.test.ts +57 -0
- package/src/sidecar.ts +167 -0
- package/src/ssrf-guard.test.ts +109 -0
- package/src/ssrf-guard.ts +165 -0
- package/src/web-fetch.test.ts +305 -0
- package/src/web-fetch.ts +311 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-RPC dispatch table for the sidecar. Each `method` here corresponds
|
|
3
|
+
* one-to-one with the wire-format methods documented in `sidecar.ts`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { assertPublicUrl } from '../ssrf-guard.js';
|
|
7
|
+
import { importPlaywright, launchWithAutoInstall } from './install.js';
|
|
8
|
+
import {
|
|
9
|
+
badParams,
|
|
10
|
+
errMsg,
|
|
11
|
+
SidecarError,
|
|
12
|
+
type BrowserKind,
|
|
13
|
+
type PlaywrightHandle,
|
|
14
|
+
type Reply,
|
|
15
|
+
type Req,
|
|
16
|
+
} from './types.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ceiling on screenshot/capture waits inside the sidecar so a page whose render
|
|
20
|
+
* is wedged fails the op cleanly instead of blocking the serial request queue
|
|
21
|
+
* indefinitely (the parent has its own per-call timeout as a backstop, but
|
|
22
|
+
* bounding it here drains the queue head sooner). Generous enough for a slow
|
|
23
|
+
* full-page screenshot; well under the parent ceiling.
|
|
24
|
+
*/
|
|
25
|
+
const SCREENSHOT_TIMEOUT_MS = 30_000;
|
|
26
|
+
/** Hard ceiling on viewport / screenshot-clip dimensions, matching Chromium's
|
|
27
|
+
* max texture/screenshot size — bounds allocation from a malformed surface
|
|
28
|
+
* message (e.g. width:1e9). */
|
|
29
|
+
const MAX_DIMENSION = 16_384;
|
|
30
|
+
|
|
31
|
+
export interface SidecarState {
|
|
32
|
+
handle: PlaywrightHandle | null;
|
|
33
|
+
/**
|
|
34
|
+
* Set after a successful auto-install of browser binaries so the next
|
|
35
|
+
* tool result can carry a `notice` letting the user/model know the
|
|
36
|
+
* one-time download happened. Cleared once the notice has been
|
|
37
|
+
* delivered (handed to the reply once, then forgotten).
|
|
38
|
+
*/
|
|
39
|
+
pendingInstallNotice: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function ensurePlaywright(
|
|
43
|
+
state: SidecarState,
|
|
44
|
+
opts: { browser?: BrowserKind; headless?: boolean },
|
|
45
|
+
): Promise<PlaywrightHandle> {
|
|
46
|
+
if (state.handle) return state.handle;
|
|
47
|
+
const pw = await importPlaywright();
|
|
48
|
+
const which = opts.browser ?? 'chromium';
|
|
49
|
+
const browserType = pw[which];
|
|
50
|
+
const { handle, installNotice } = await launchWithAutoInstall(browserType, which, opts.headless ?? true);
|
|
51
|
+
state.handle = handle;
|
|
52
|
+
if (installNotice) state.pendingInstallNotice = installNotice;
|
|
53
|
+
return state.handle;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function teardown(state: SidecarState): Promise<void> {
|
|
57
|
+
if (!state.handle) return;
|
|
58
|
+
try {
|
|
59
|
+
await state.handle.context.close();
|
|
60
|
+
await state.handle.browser.close();
|
|
61
|
+
} catch {
|
|
62
|
+
/* ignore */
|
|
63
|
+
}
|
|
64
|
+
state.handle = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function dispatch(state: SidecarState, req: Req): Promise<Reply> {
|
|
68
|
+
try {
|
|
69
|
+
return await dispatchInner(state, req);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return {
|
|
72
|
+
id: req.id,
|
|
73
|
+
ok: false,
|
|
74
|
+
error: { message: errMsg(err), kind: err instanceof SidecarError ? err.kind : 'unknown' },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function dispatchInner(state: SidecarState, req: Req): Promise<Reply> {
|
|
80
|
+
switch (req.method) {
|
|
81
|
+
case 'init': {
|
|
82
|
+
const opts = (req.params ?? {}) as { browser?: BrowserKind; headless?: boolean };
|
|
83
|
+
await ensurePlaywright(state, opts);
|
|
84
|
+
return { id: req.id, ok: true, result: { ready: true } };
|
|
85
|
+
}
|
|
86
|
+
case 'goto': {
|
|
87
|
+
const { url, waitUntil, timeoutMs } = (req.params ?? {}) as {
|
|
88
|
+
url: string;
|
|
89
|
+
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
|
90
|
+
timeoutMs?: number;
|
|
91
|
+
};
|
|
92
|
+
if (!url) throw badParams('url is required');
|
|
93
|
+
// Defence-in-depth: the parent already runs the full SSRF guard before
|
|
94
|
+
// sending this RPC, but the sidecar is a distinct process driven over
|
|
95
|
+
// JSON-RPC, so re-check here rather than trust the caller to have
|
|
96
|
+
// validated. Blocks file:// / javascript: schemes AND loopback/private/
|
|
97
|
+
// link-local (incl. 169.254.169.254 metadata)/CGNAT targets, resolving
|
|
98
|
+
// hostnames. Runs BEFORE ensurePlaywright so a blocked URL never
|
|
99
|
+
// launches (or auto-installs) a browser.
|
|
100
|
+
try {
|
|
101
|
+
// fail-closed: the browser resolves names with Chromium's own resolver,
|
|
102
|
+
// so a name node:dns can't vet must not pass through un-checked.
|
|
103
|
+
await assertPublicUrl(url, 'goto', { failClosed: true });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return { id: req.id, ok: false, error: { message: errMsg(err), kind: 'navigation' } };
|
|
106
|
+
}
|
|
107
|
+
const h = await ensurePlaywright(state, {});
|
|
108
|
+
try {
|
|
109
|
+
await h.page.goto(url, { waitUntil: waitUntil ?? 'domcontentloaded', timeout: timeoutMs ?? 30_000 });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return { id: req.id, ok: false, error: { message: errMsg(err), kind: 'navigation' } };
|
|
112
|
+
}
|
|
113
|
+
return { id: req.id, ok: true, result: { url: h.page.url() } };
|
|
114
|
+
}
|
|
115
|
+
case 'click': {
|
|
116
|
+
const h = await ensurePlaywright(state, {});
|
|
117
|
+
const { selector, timeoutMs } = (req.params ?? {}) as { selector: string; timeoutMs?: number };
|
|
118
|
+
if (!selector) throw badParams('selector is required');
|
|
119
|
+
await h.page.click(selector, { timeout: timeoutMs ?? 10_000 });
|
|
120
|
+
return { id: req.id, ok: true };
|
|
121
|
+
}
|
|
122
|
+
case 'fill': {
|
|
123
|
+
const h = await ensurePlaywright(state, {});
|
|
124
|
+
const { selector, value, timeoutMs } = (req.params ?? {}) as {
|
|
125
|
+
selector: string;
|
|
126
|
+
value: string;
|
|
127
|
+
timeoutMs?: number;
|
|
128
|
+
};
|
|
129
|
+
if (!selector) throw badParams('selector is required');
|
|
130
|
+
await h.page.fill(selector, value ?? '', { timeout: timeoutMs ?? 10_000 });
|
|
131
|
+
return { id: req.id, ok: true };
|
|
132
|
+
}
|
|
133
|
+
case 'text': {
|
|
134
|
+
const h = await ensurePlaywright(state, {});
|
|
135
|
+
const { selector } = (req.params ?? {}) as { selector?: string };
|
|
136
|
+
if (selector) {
|
|
137
|
+
const text = await h.page.textContent(selector);
|
|
138
|
+
return { id: req.id, ok: true, result: text ?? '' };
|
|
139
|
+
}
|
|
140
|
+
// Whole-document text via evaluate
|
|
141
|
+
const text = (await h.page.evaluate('document.body ? document.body.innerText : ""')) as string;
|
|
142
|
+
return { id: req.id, ok: true, result: text };
|
|
143
|
+
}
|
|
144
|
+
case 'html': {
|
|
145
|
+
const h = await ensurePlaywright(state, {});
|
|
146
|
+
const html = await h.page.content();
|
|
147
|
+
return { id: req.id, ok: true, result: html };
|
|
148
|
+
}
|
|
149
|
+
case 'screenshot': {
|
|
150
|
+
const h = await ensurePlaywright(state, {});
|
|
151
|
+
const { fullPage } = (req.params ?? {}) as { fullPage?: boolean };
|
|
152
|
+
const buf = await h.page.screenshot({ fullPage: fullPage ?? false, timeout: SCREENSHOT_TIMEOUT_MS });
|
|
153
|
+
return { id: req.id, ok: true, result: { mediaType: 'image/png', base64: buf.toString('base64') } };
|
|
154
|
+
}
|
|
155
|
+
case 'frame': {
|
|
156
|
+
// Combined live-view frame for the browser SURFACE: a JPEG screenshot
|
|
157
|
+
// plus the current url + viewport size, so the renderer can map clicks
|
|
158
|
+
// back onto the page. One round-trip per frame.
|
|
159
|
+
const h = await ensurePlaywright(state, {});
|
|
160
|
+
// quality 70 (was 55) + the context's deviceScaleFactor:2 = legible text in
|
|
161
|
+
// the live view. Reports the CSS viewport size (the image is 2× that) so the
|
|
162
|
+
// renderer keeps mapping clicks in CSS coords.
|
|
163
|
+
const buf = await h.page.screenshot({ type: 'jpeg', quality: 70, timeout: SCREENSHOT_TIMEOUT_MS });
|
|
164
|
+
const vp = h.page.viewportSize() ?? { width: 1280, height: 720 };
|
|
165
|
+
return {
|
|
166
|
+
id: req.id,
|
|
167
|
+
ok: true,
|
|
168
|
+
result: {
|
|
169
|
+
mediaType: 'image/jpeg',
|
|
170
|
+
base64: buf.toString('base64'),
|
|
171
|
+
url: h.page.url(),
|
|
172
|
+
width: vp.width,
|
|
173
|
+
height: vp.height,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
case 'mouse': {
|
|
178
|
+
const { x, y, count } = (req.params ?? {}) as { x: number; y: number; count?: number };
|
|
179
|
+
// Parity with `key`/`eval`: validate before launching/driving the page so
|
|
180
|
+
// a malformed surface message (missing/NaN coords) surfaces a clean
|
|
181
|
+
// `badParams` instead of an opaque Playwright throw from click(undefined).
|
|
182
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw badParams('x and y must be finite numbers');
|
|
183
|
+
const h = await ensurePlaywright(state, {});
|
|
184
|
+
await h.page.mouse.click(x, y, { clickCount: Math.min(3, Math.max(1, count ?? 1)) });
|
|
185
|
+
return { id: req.id, ok: true, result: { url: h.page.url() } };
|
|
186
|
+
}
|
|
187
|
+
case 'mousemove': {
|
|
188
|
+
// Hover: drives the page's pointer so :hover styles / tooltips render in
|
|
189
|
+
// the polled frame. Cheap; the surface throttles how often it sends these.
|
|
190
|
+
const { x, y } = (req.params ?? {}) as { x: number; y: number };
|
|
191
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw badParams('x and y must be finite numbers');
|
|
192
|
+
const h = await ensurePlaywright(state, {});
|
|
193
|
+
await h.page.mouse.move(x, y);
|
|
194
|
+
return { id: req.id, ok: true };
|
|
195
|
+
}
|
|
196
|
+
case 'setviewport': {
|
|
197
|
+
// Resize the page to the pane so the live view fills the container instead
|
|
198
|
+
// of being letterboxed at the default 1280×720.
|
|
199
|
+
const { width, height } = (req.params ?? {}) as { width: number; height: number };
|
|
200
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) {
|
|
201
|
+
throw badParams('width and height must be positive finite numbers');
|
|
202
|
+
}
|
|
203
|
+
// Clamp to Chromium's max so a malformed surface message (width:1e9) can't
|
|
204
|
+
// trigger a multi-GB allocation / opaque Playwright throw.
|
|
205
|
+
if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
|
|
206
|
+
throw badParams(`width and height must be <= ${MAX_DIMENSION}`);
|
|
207
|
+
}
|
|
208
|
+
const h = await ensurePlaywright(state, {});
|
|
209
|
+
await h.page.setViewportSize({ width: Math.round(width), height: Math.round(height) });
|
|
210
|
+
return { id: req.id, ok: true };
|
|
211
|
+
}
|
|
212
|
+
case 'back':
|
|
213
|
+
case 'forward':
|
|
214
|
+
case 'reload': {
|
|
215
|
+
const h = await ensurePlaywright(state, {});
|
|
216
|
+
try {
|
|
217
|
+
if (req.method === 'back') await h.page.goBack();
|
|
218
|
+
else if (req.method === 'forward') await h.page.goForward();
|
|
219
|
+
else await h.page.reload();
|
|
220
|
+
} catch (err) {
|
|
221
|
+
// No history to go to is not an error worth failing the surface over.
|
|
222
|
+
return { id: req.id, ok: false, error: { message: errMsg(err), kind: 'navigation' } };
|
|
223
|
+
}
|
|
224
|
+
return { id: req.id, ok: true, result: { url: h.page.url() } };
|
|
225
|
+
}
|
|
226
|
+
case 'key': {
|
|
227
|
+
const h = await ensurePlaywright(state, {});
|
|
228
|
+
const { key } = (req.params ?? {}) as { key: string };
|
|
229
|
+
if (!key) throw badParams('key is required');
|
|
230
|
+
// A single printable char is typed (inserts it); a named key is pressed.
|
|
231
|
+
if (key.length === 1) await h.page.keyboard.type(key);
|
|
232
|
+
else await h.page.keyboard.press(key);
|
|
233
|
+
return { id: req.id, ok: true };
|
|
234
|
+
}
|
|
235
|
+
case 'scroll': {
|
|
236
|
+
const h = await ensurePlaywright(state, {});
|
|
237
|
+
const { dy } = (req.params ?? {}) as { dy: number };
|
|
238
|
+
await h.page.mouse.wheel(0, dy ?? 0);
|
|
239
|
+
return { id: req.id, ok: true };
|
|
240
|
+
}
|
|
241
|
+
case 'zoom': {
|
|
242
|
+
// Page zoom for the surface (⌘+/⌘−). CSS `zoom` is the cheapest faithful
|
|
243
|
+
// way to scale a screenshot-streamed page; clamped to a sane range.
|
|
244
|
+
const { factor } = (req.params ?? {}) as { factor: number };
|
|
245
|
+
const f = Number.isFinite(factor) ? Math.min(5, Math.max(0.25, factor)) : 1;
|
|
246
|
+
const h = await ensurePlaywright(state, {});
|
|
247
|
+
await h.page.evaluate(`document.documentElement.style.zoom=String(${f})`);
|
|
248
|
+
return { id: req.id, ok: true };
|
|
249
|
+
}
|
|
250
|
+
case 'capture': {
|
|
251
|
+
// Sharp PNG of a region the user dragged — attached to the chat composer so
|
|
252
|
+
// the agent SEES the area ("change this to …"). Coords are CSS px; the
|
|
253
|
+
// context's deviceScaleFactor:2 makes the PNG 2× → crisp.
|
|
254
|
+
const { x, y, width, height } = (req.params ?? {}) as {
|
|
255
|
+
x: number;
|
|
256
|
+
y: number;
|
|
257
|
+
width: number;
|
|
258
|
+
height: number;
|
|
259
|
+
};
|
|
260
|
+
if (![x, y, width, height].every((n) => Number.isFinite(n)) || width < 1 || height < 1) {
|
|
261
|
+
throw badParams('x, y, width, height must be finite; width/height positive');
|
|
262
|
+
}
|
|
263
|
+
// Bound the clip so an enormous (or hostile, viewport-multiplied) region
|
|
264
|
+
// can't request a multi-GB screenshot allocation.
|
|
265
|
+
if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
|
|
266
|
+
throw badParams(`clip width and height must be <= ${MAX_DIMENSION}`);
|
|
267
|
+
}
|
|
268
|
+
const h = await ensurePlaywright(state, {});
|
|
269
|
+
const buf = await h.page.screenshot({ type: 'png', clip: { x, y, width, height }, timeout: SCREENSHOT_TIMEOUT_MS });
|
|
270
|
+
return { id: req.id, ok: true, result: { mediaType: 'image/png', base64: buf.toString('base64') } };
|
|
271
|
+
}
|
|
272
|
+
case 'pick': {
|
|
273
|
+
// Identify the element at (x,y) so the user can hand it to the agent
|
|
274
|
+
// ("change this XXX to YYY"). Returns a best-effort CSS selector + a short
|
|
275
|
+
// text snippet; the agent's browser_session tool can act on the selector.
|
|
276
|
+
const { x, y } = (req.params ?? {}) as { x: number; y: number };
|
|
277
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw badParams('x and y must be finite numbers');
|
|
278
|
+
const h = await ensurePlaywright(state, {});
|
|
279
|
+
const expr =
|
|
280
|
+
`(() => { const x=${x}, y=${y}; const el=document.elementFromPoint(x,y);` +
|
|
281
|
+
` if(!el) return null;` +
|
|
282
|
+
` const sel=(e)=>{ if(e.id) return '#'+CSS.escape(e.id); const p=[]; let n=e;` +
|
|
283
|
+
` while(n && n.nodeType===1 && n!==document.body){ let s=n.tagName.toLowerCase();` +
|
|
284
|
+
` if(n.classList && n.classList.length) s+='.'+Array.from(n.classList).slice(0,2).map(c=>CSS.escape(c)).join('.');` +
|
|
285
|
+
` const par=n.parentElement; if(par){ const same=Array.from(par.children).filter(c=>c.tagName===n.tagName);` +
|
|
286
|
+
` if(same.length>1) s+=':nth-of-type('+(same.indexOf(n)+1)+')'; } p.unshift(s); n=n.parentElement; }` +
|
|
287
|
+
` return p.join(' > '); };` +
|
|
288
|
+
` return { selector: sel(el), tag: el.tagName.toLowerCase(), text: (el.textContent||'').replace(/\\s+/g,' ').trim().slice(0,140) }; })()`;
|
|
289
|
+
const info = await h.page.evaluate(expr);
|
|
290
|
+
return { id: req.id, ok: true, result: info };
|
|
291
|
+
}
|
|
292
|
+
case 'eval': {
|
|
293
|
+
const h = await ensurePlaywright(state, {});
|
|
294
|
+
const { expression } = (req.params ?? {}) as { expression: string };
|
|
295
|
+
if (!expression) throw badParams('expression is required');
|
|
296
|
+
const value = await h.page.evaluate(expression);
|
|
297
|
+
return { id: req.id, ok: true, result: value };
|
|
298
|
+
}
|
|
299
|
+
case 'url': {
|
|
300
|
+
const h = await ensurePlaywright(state, {});
|
|
301
|
+
return { id: req.id, ok: true, result: h.page.url() };
|
|
302
|
+
}
|
|
303
|
+
case 'close': {
|
|
304
|
+
await teardown(state);
|
|
305
|
+
return { id: req.id, ok: true };
|
|
306
|
+
}
|
|
307
|
+
default:
|
|
308
|
+
return {
|
|
309
|
+
id: req.id,
|
|
310
|
+
ok: false,
|
|
311
|
+
error: { message: `unknown method: ${req.method}`, kind: 'runtime' },
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright lifecycle: import, launch, and one-shot auto-install of the
|
|
3
|
+
* per-browser binary. Keeps the dispatch layer free of node:child_process
|
|
4
|
+
* + Playwright-import noise.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { assertPublicUrl } from '../ssrf-guard.js';
|
|
9
|
+
import { SidecarError } from './types.js';
|
|
10
|
+
import type { BrowserKind, BrowserType, PageHandle, PlaywrightHandle } from './types.js';
|
|
11
|
+
|
|
12
|
+
export interface LaunchResult {
|
|
13
|
+
handle: PlaywrightHandle;
|
|
14
|
+
/** Set when the browser binary was auto-downloaded during this launch. */
|
|
15
|
+
installNotice: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function importPlaywright(): Promise<{
|
|
19
|
+
chromium: BrowserType;
|
|
20
|
+
firefox: BrowserType;
|
|
21
|
+
webkit: BrowserType;
|
|
22
|
+
}> {
|
|
23
|
+
try {
|
|
24
|
+
return (await import('playwright')) as never;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
const underlying = err instanceof Error ? err.message : String(err);
|
|
27
|
+
// Distinguish "the npm package isn't installed" (recoverable — the surface
|
|
28
|
+
// can offer a one-click install, see `installPlaywrightPackage`) from any
|
|
29
|
+
// other import failure. The `kind` rides the JSON-RPC reply so the browser
|
|
30
|
+
// surface shows an "Install" affordance instead of a dead-end error.
|
|
31
|
+
throw new SidecarError(
|
|
32
|
+
`Playwright is not installed. Run \`pnpm add playwright\` (or \`npm i playwright\`) and then \`npx playwright install\` in the moxxy install dir.\n` +
|
|
33
|
+
`Underlying: ${underlying}`,
|
|
34
|
+
isModuleNotFound(underlying) ? 'needs-install' : 'init',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when an import failure is "the `playwright` package can't be found"
|
|
40
|
+
* (vs. a load/runtime error inside an installed package). */
|
|
41
|
+
function isModuleNotFound(message: string): boolean {
|
|
42
|
+
return (
|
|
43
|
+
/cannot find (package|module) ['"]?playwright/i.test(message) ||
|
|
44
|
+
/ERR_MODULE_NOT_FOUND/i.test(message) ||
|
|
45
|
+
/failed to resolve ['"]?playwright/i.test(message)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface InstallPlaywrightOptions {
|
|
50
|
+
/** Directory whose `node_modules` should receive `playwright` — the CLI
|
|
51
|
+
* install root (e.g. `<userData>/cli`). `npm` runs with this as its cwd. */
|
|
52
|
+
readonly rootDir: string;
|
|
53
|
+
/** Which browser engine binary to download after the npm package lands. */
|
|
54
|
+
readonly browser?: BrowserKind;
|
|
55
|
+
/** Per-line progress (npm/npx stdout+stderr) for streaming to the UI. */
|
|
56
|
+
readonly onProgress?: (line: string) => void;
|
|
57
|
+
readonly signal?: AbortSignal;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Install the `playwright` npm package into `rootDir`, then download the browser
|
|
62
|
+
* engine binary — the two halves the desktop browser surface needs. Driven by
|
|
63
|
+
* the surface AFTER the user consents (the download is ~200MB). Streams progress
|
|
64
|
+
* via `onProgress`; resolves on success, rejects with the failing step's output.
|
|
65
|
+
*
|
|
66
|
+
* Lives next to {@link importPlaywright} (which reports the `needs-install` that
|
|
67
|
+
* triggers this) but is invoked in the RUNNER process — `rootDir`'s node_modules
|
|
68
|
+
* is the one the sidecar later imports `playwright` from.
|
|
69
|
+
*/
|
|
70
|
+
/** Pin the npm install to this range so the one-click installer fetches a
|
|
71
|
+
* vetted, deterministic version (matching the package's `playwright`
|
|
72
|
+
* peerDependency) rather than whatever "latest" resolves to. */
|
|
73
|
+
const PLAYWRIGHT_VERSION_RANGE = '^1.40.0';
|
|
74
|
+
|
|
75
|
+
export async function installPlaywrightPackage(opts: InstallPlaywrightOptions): Promise<void> {
|
|
76
|
+
const which = opts.browser ?? 'chromium';
|
|
77
|
+
opts.onProgress?.(
|
|
78
|
+
`Installing the playwright npm package (${PLAYWRIGHT_VERSION_RANGE}) into ${opts.rootDir}…`,
|
|
79
|
+
);
|
|
80
|
+
// `--save-exact false` keeps the range; the explicit version pin (vs bare
|
|
81
|
+
// "playwright") stops a hijacked registry "latest" from being pulled.
|
|
82
|
+
await runProcess(
|
|
83
|
+
'npm',
|
|
84
|
+
['install', '--no-fund', '--no-audit', `playwright@${PLAYWRIGHT_VERSION_RANGE}`],
|
|
85
|
+
opts,
|
|
86
|
+
);
|
|
87
|
+
opts.onProgress?.(`Downloading the ${which} browser engine (~150MB, one-time)…`);
|
|
88
|
+
await runProcess('npx', ['playwright', 'install', which], opts);
|
|
89
|
+
opts.onProgress?.('Playwright installed.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Spawn a child, forward its output to `onProgress`, resolve on exit-0. */
|
|
93
|
+
function runProcess(cmd: string, args: string[], opts: InstallPlaywrightOptions): Promise<void> {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
if (opts.signal?.aborted) return reject(new Error('install aborted'));
|
|
96
|
+
const child = spawn(cmd, args, { cwd: opts.rootDir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
97
|
+
let tail = '';
|
|
98
|
+
const onChunk = (chunk: Buffer): void => {
|
|
99
|
+
const text = chunk.toString('utf8');
|
|
100
|
+
for (const line of text.split(/\r?\n/)) if (line.trim()) opts.onProgress?.(line);
|
|
101
|
+
tail = (tail + text).slice(-4000);
|
|
102
|
+
};
|
|
103
|
+
child.stdout.on('data', onChunk);
|
|
104
|
+
child.stderr.on('data', onChunk);
|
|
105
|
+
const onAbort = (): void => {
|
|
106
|
+
child.kill();
|
|
107
|
+
};
|
|
108
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
109
|
+
child.once('error', (err) => {
|
|
110
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
111
|
+
reject(err);
|
|
112
|
+
});
|
|
113
|
+
child.once('close', (code) => {
|
|
114
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
115
|
+
if (code === 0) resolve();
|
|
116
|
+
else
|
|
117
|
+
reject(
|
|
118
|
+
new Error(`\`${cmd} ${args.join(' ')}\` failed (exit ${code}): ${tail.trim() || '(no output)'}`),
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Try to launch the browser. If the binary isn't downloaded yet
|
|
126
|
+
* (Playwright distinguishes the npm install from the per-browser
|
|
127
|
+
* binary download), run `npx playwright install <which>` once and
|
|
128
|
+
* retry. The install can take 30s–2min on the first run depending on
|
|
129
|
+
* connection; we surface progress on stderr (parent forwards to the
|
|
130
|
+
* logger) and return a one-shot notice for the first tool response.
|
|
131
|
+
*/
|
|
132
|
+
export async function launchWithAutoInstall(
|
|
133
|
+
browserType: BrowserType,
|
|
134
|
+
which: BrowserKind,
|
|
135
|
+
headless: boolean,
|
|
136
|
+
): Promise<LaunchResult> {
|
|
137
|
+
try {
|
|
138
|
+
return { handle: await launchOnce(browserType, headless), installNotice: null };
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (!isMissingBrowserError(err)) throw err;
|
|
141
|
+
process.stderr.write(
|
|
142
|
+
`moxxy-browser: ${which} binary missing, running \`npx playwright install ${which}\` ` +
|
|
143
|
+
`(one-time, ~150MB). This may take a minute…\n`,
|
|
144
|
+
);
|
|
145
|
+
try {
|
|
146
|
+
await runPlaywrightInstall(which);
|
|
147
|
+
} catch (installErr) {
|
|
148
|
+
const msg = installErr instanceof Error ? installErr.message : String(installErr);
|
|
149
|
+
throw new SidecarError(
|
|
150
|
+
`Playwright browser auto-install failed: ${msg}. ` +
|
|
151
|
+
`Run \`npx playwright install ${which}\` manually in the moxxy dir.`,
|
|
152
|
+
'init',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
process.stderr.write(`moxxy-browser: install complete, retrying launch\n`);
|
|
156
|
+
return {
|
|
157
|
+
handle: await launchOnce(browserType, headless),
|
|
158
|
+
installNotice: `Auto-installed Playwright ${which} browser (~150MB, one-time).`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function launchOnce(browserType: BrowserType, headless: boolean): Promise<PlaywrightHandle> {
|
|
164
|
+
const browser = await browserType.launch({ headless });
|
|
165
|
+
// deviceScaleFactor: 2 so the live-view / region screenshots are captured at
|
|
166
|
+
// Retina density — a 1× capture upscaled into a HiDPI pane is what made the
|
|
167
|
+
// surface look blurry. Costs ~2× the screenshot bytes; worth it for crisp text.
|
|
168
|
+
const context = (await browser.newContext({ deviceScaleFactor: 2 })) as PlaywrightHandle['context'];
|
|
169
|
+
await installNavigationSsrfGuard(context);
|
|
170
|
+
const page = (await context.newPage()) as unknown as PageHandle;
|
|
171
|
+
return { browser, context, page };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Block navigations to private/loopback origins for the lifetime of the
|
|
176
|
+
* context. The goto RPC is validated in the parent AND in dispatch, but a
|
|
177
|
+
* page reached via a legitimate public goto can then redirect or
|
|
178
|
+
* script-navigate itself to e.g. http://169.254.169.254/ — those navigations
|
|
179
|
+
* never pass through the RPC layer, so we intercept them here. Navigation
|
|
180
|
+
* requests (top-level + iframes, which covers HTTP redirect hops too) go
|
|
181
|
+
* through the same `assertPublicUrl` guard as web_fetch; everything else is
|
|
182
|
+
* passed through untouched so ordinary page loads don't pay a DNS round-trip
|
|
183
|
+
* per subresource.
|
|
184
|
+
*
|
|
185
|
+
* Residual risk: by default SUBRESOURCE requests (img/fetch/script) from a
|
|
186
|
+
* loaded page are NOT filtered — a hostile page can fire blind GET/POSTs at
|
|
187
|
+
* internal services as side effects (the browser's same-origin policy stops it
|
|
188
|
+
* READING the responses, but the request still lands). Opt into filtering every
|
|
189
|
+
* request — at the cost of a (cached) DNS resolution per distinct host — by
|
|
190
|
+
* setting `MOXXY_BROWSER_FILTER_SUBRESOURCES=1`.
|
|
191
|
+
*/
|
|
192
|
+
async function installNavigationSsrfGuard(context: PlaywrightHandle['context']): Promise<void> {
|
|
193
|
+
if (typeof context.route !== 'function') {
|
|
194
|
+
// Loose projection: tests pass a stub without route(). But a REAL Playwright
|
|
195
|
+
// context always has route(), so if a production context ever reaches here
|
|
196
|
+
// the in-page navigation guard is silently absent — make that loud on stderr
|
|
197
|
+
// (the parent forwards it to the logger) instead of failing open invisibly.
|
|
198
|
+
process.stderr.write(
|
|
199
|
+
'moxxy-browser: WARNING context.route() unavailable — in-page navigation SSRF guard NOT installed\n',
|
|
200
|
+
);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const filterSubresources = process.env.MOXXY_BROWSER_FILTER_SUBRESOURCES === '1';
|
|
204
|
+
// Cache per-host guard verdicts so the opt-in subresource path doesn't pay a
|
|
205
|
+
// DNS round-trip per request (pages fire dozens at the same few hosts).
|
|
206
|
+
// Bounded so a page referencing thousands of distinct hosts can't grow it
|
|
207
|
+
// without limit (oldest entries evicted; re-resolved on next sight).
|
|
208
|
+
const verdictCache = new Map<string, boolean>();
|
|
209
|
+
const MAX_VERDICT_CACHE = 2048;
|
|
210
|
+
const remember = (host: string, blocked: boolean): void => {
|
|
211
|
+
if (verdictCache.size >= MAX_VERDICT_CACHE) {
|
|
212
|
+
const oldest = verdictCache.keys().next().value;
|
|
213
|
+
if (oldest !== undefined) verdictCache.delete(oldest);
|
|
214
|
+
}
|
|
215
|
+
verdictCache.set(host, blocked);
|
|
216
|
+
};
|
|
217
|
+
await context.route('**/*', async (route) => {
|
|
218
|
+
const request = route.request();
|
|
219
|
+
const isNav = request.isNavigationRequest();
|
|
220
|
+
if (!isNav && !filterSubresources) return route.continue();
|
|
221
|
+
const url = request.url();
|
|
222
|
+
let host = '';
|
|
223
|
+
try {
|
|
224
|
+
host = new URL(url).host;
|
|
225
|
+
} catch {
|
|
226
|
+
// Unparseable URL — only the navigation path needs a hard verdict.
|
|
227
|
+
if (isNav) return route.abort('blockedbyclient');
|
|
228
|
+
return route.continue();
|
|
229
|
+
}
|
|
230
|
+
// Navigations are always re-checked live (cheap, and the cache is keyed by
|
|
231
|
+
// host so a vetted host stays vetted). Subresources consult the cache first.
|
|
232
|
+
const cached = !isNav ? verdictCache.get(host) : undefined;
|
|
233
|
+
if (cached === true) return route.abort('blockedbyclient');
|
|
234
|
+
if (cached === false) return route.continue();
|
|
235
|
+
try {
|
|
236
|
+
// fail-closed: an unresolvable name must not navigate the browser (whose
|
|
237
|
+
// resolver may differ from node:dns).
|
|
238
|
+
await assertPublicUrl(
|
|
239
|
+
url,
|
|
240
|
+
isNav ? 'browser_session navigation' : 'browser_session subresource',
|
|
241
|
+
{ failClosed: true },
|
|
242
|
+
);
|
|
243
|
+
remember(host, false);
|
|
244
|
+
} catch {
|
|
245
|
+
remember(host, true);
|
|
246
|
+
return route.abort('blockedbyclient');
|
|
247
|
+
}
|
|
248
|
+
return route.continue();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isMissingBrowserError(err: unknown): boolean {
|
|
253
|
+
if (!(err instanceof Error)) return false;
|
|
254
|
+
// Playwright's "Executable doesn't exist at …" launch error fires
|
|
255
|
+
// when the npm package is installed but the per-browser binary
|
|
256
|
+
// hasn't been downloaded. The message stays stable across versions.
|
|
257
|
+
return /Executable doesn'?t exist at/i.test(err.message);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Run `npx playwright install <which>` and stream its output to the
|
|
262
|
+
* sidecar's stderr so the operator can watch progress. Resolves on
|
|
263
|
+
* exit-0; rejects with the tail of stderr otherwise.
|
|
264
|
+
*/
|
|
265
|
+
function runPlaywrightInstall(which: BrowserKind): Promise<void> {
|
|
266
|
+
return new Promise((resolve, reject) => {
|
|
267
|
+
const child = spawn('npx', ['playwright', 'install', which], {
|
|
268
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
269
|
+
});
|
|
270
|
+
let stderrTail = '';
|
|
271
|
+
child.stdout.on('data', (chunk: Buffer) => process.stderr.write(chunk));
|
|
272
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
273
|
+
process.stderr.write(chunk);
|
|
274
|
+
stderrTail += chunk.toString('utf8');
|
|
275
|
+
if (stderrTail.length > 4000) stderrTail = stderrTail.slice(-4000);
|
|
276
|
+
});
|
|
277
|
+
child.once('error', (err) => reject(err));
|
|
278
|
+
child.once('close', (code) => {
|
|
279
|
+
if (code === 0) resolve();
|
|
280
|
+
else reject(new Error(`exit ${code}: ${stderrTail.trim() || '(no stderr)'}`));
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { badParams, SidecarError } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `SidecarError` is the typed carrier that lets `dispatch` map a thrown error
|
|
6
|
+
* onto the wire reply's `error.kind` without the old untyped `(e as Error &
|
|
7
|
+
* { kind? }).kind = …` double-cast. These cover the carrier itself; the mapping
|
|
8
|
+
* is exercised end-to-end in dispatch.test.ts.
|
|
9
|
+
*/
|
|
10
|
+
describe('SidecarError carrier', () => {
|
|
11
|
+
it('is a real Error carrying message + kind', () => {
|
|
12
|
+
const err = new SidecarError('boom', 'init');
|
|
13
|
+
expect(err).toBeInstanceOf(Error);
|
|
14
|
+
expect(err).toBeInstanceOf(SidecarError);
|
|
15
|
+
expect(err.message).toBe('boom');
|
|
16
|
+
expect(err.kind).toBe('init');
|
|
17
|
+
expect(err.name).toBe('SidecarError');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('badParams produces a runtime-kind SidecarError', () => {
|
|
21
|
+
const err = badParams('selector is required');
|
|
22
|
+
expect(err).toBeInstanceOf(SidecarError);
|
|
23
|
+
expect(err.message).toBe('selector is required');
|
|
24
|
+
expect(err.kind).toBe('runtime');
|
|
25
|
+
});
|
|
26
|
+
});
|