@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/dist/browser-session.d.ts +43 -0
  3. package/dist/browser-session.d.ts.map +1 -0
  4. package/dist/browser-session.js +500 -0
  5. package/dist/browser-session.js.map +1 -0
  6. package/dist/browser-surface.d.ts +3 -0
  7. package/dist/browser-surface.d.ts.map +1 -0
  8. package/dist/browser-surface.js +255 -0
  9. package/dist/browser-surface.js.map +1 -0
  10. package/dist/html-extract.d.ts +20 -0
  11. package/dist/html-extract.d.ts.map +1 -0
  12. package/dist/html-extract.js +122 -0
  13. package/dist/html-extract.js.map +1 -0
  14. package/dist/index.d.ts +10 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +24 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/sidecar/dispatch.d.ts +18 -0
  19. package/dist/sidecar/dispatch.d.ts.map +1 -0
  20. package/dist/sidecar/dispatch.js +294 -0
  21. package/dist/sidecar/dispatch.js.map +1 -0
  22. package/dist/sidecar/install.d.ts +37 -0
  23. package/dist/sidecar/install.d.ts.map +1 -0
  24. package/dist/sidecar/install.js +242 -0
  25. package/dist/sidecar/install.js.map +1 -0
  26. package/dist/sidecar/types.d.ts +97 -0
  27. package/dist/sidecar/types.d.ts.map +1 -0
  28. package/dist/sidecar/types.js +20 -0
  29. package/dist/sidecar/types.js.map +1 -0
  30. package/dist/sidecar.d.ts +31 -0
  31. package/dist/sidecar.d.ts.map +1 -0
  32. package/dist/sidecar.js +165 -0
  33. package/dist/sidecar.js.map +1 -0
  34. package/dist/ssrf-guard.d.ts +43 -0
  35. package/dist/ssrf-guard.d.ts.map +1 -0
  36. package/dist/ssrf-guard.js +164 -0
  37. package/dist/ssrf-guard.js.map +1 -0
  38. package/dist/web-fetch.d.ts +23 -0
  39. package/dist/web-fetch.d.ts.map +1 -0
  40. package/dist/web-fetch.js +253 -0
  41. package/dist/web-fetch.js.map +1 -0
  42. package/package.json +74 -0
  43. package/src/browser-session.test.ts +333 -0
  44. package/src/browser-session.ts +567 -0
  45. package/src/browser-surface.test.ts +367 -0
  46. package/src/browser-surface.ts +275 -0
  47. package/src/html-extract.ts +152 -0
  48. package/src/index.ts +35 -0
  49. package/src/sidecar/dispatch.test.ts +313 -0
  50. package/src/sidecar/dispatch.ts +314 -0
  51. package/src/sidecar/install.ts +283 -0
  52. package/src/sidecar/types.test.ts +26 -0
  53. package/src/sidecar/types.ts +114 -0
  54. package/src/sidecar.test.ts +57 -0
  55. package/src/sidecar.ts +167 -0
  56. package/src/ssrf-guard.test.ts +109 -0
  57. package/src/ssrf-guard.ts +165 -0
  58. package/src/web-fetch.test.ts +305 -0
  59. 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
+ });