@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,367 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { PassThrough } from 'node:stream';
3
+ import { buildBrowserSurface } from './browser-surface.js';
4
+ import { closeBrowserSidecar, type SidecarStream } from './browser-session.js';
5
+
6
+ /**
7
+ * The browser surface polls the shared sidecar's `frame` method a few times a
8
+ * second and forwards each JPEG as a `surface.data` payload. These tests drive
9
+ * a FAKE sidecar (injected via deps.spawnFn) whose per-method behaviour the
10
+ * test controls, plus fake timers to step the poll interval — so the
11
+ * inFlight guard, the FAIL_GRACE status logic, snapshot catch-up, and the
12
+ * input coordinate de-normalization are exercised without Playwright.
13
+ */
14
+
15
+ const FRAME_INTERVAL_MS = 300;
16
+ const FAIL_GRACE = 6;
17
+
18
+ type Reply = { ok: true; result: unknown } | { ok: false; message: string; kind?: string };
19
+
20
+ interface FakeSidecar {
21
+ spawn: (path: string) => SidecarStream;
22
+ /** Set the handler invoked for each incoming method. */
23
+ setHandler: (fn: (method: string, params: unknown) => Reply) => void;
24
+ received: Array<{ method: string; params: unknown }>;
25
+ }
26
+
27
+ function makeControllableSpawn(): FakeSidecar {
28
+ const received: Array<{ method: string; params: unknown }> = [];
29
+ let handler: (method: string, params: unknown) => Reply = () => ({ ok: false, message: 'no handler' });
30
+ const spawn = (_path: string): SidecarStream => {
31
+ const stdin = new PassThrough();
32
+ const stdout = new PassThrough();
33
+ let buf = '';
34
+ stdin.on('data', (chunk) => {
35
+ buf += chunk.toString('utf8');
36
+ let nl: number;
37
+ while ((nl = buf.indexOf('\n')) !== -1) {
38
+ const line = buf.slice(0, nl);
39
+ buf = buf.slice(nl + 1);
40
+ if (!line.trim()) continue;
41
+ const reqMsg = JSON.parse(line) as { id: string; method: string; params: unknown };
42
+ received.push({ method: reqMsg.method, params: reqMsg.params });
43
+ const r = handler(reqMsg.method, reqMsg.params);
44
+ const reply = r.ok
45
+ ? { id: reqMsg.id, ok: true, result: r.result }
46
+ : { id: reqMsg.id, ok: false, error: { message: r.message, kind: r.kind } };
47
+ stdout.write(JSON.stringify(reply) + '\n');
48
+ }
49
+ });
50
+ const exitListeners: Array<(code: number | null) => void> = [];
51
+ return {
52
+ stdin,
53
+ stdout,
54
+ kill: () => {
55
+ for (const l of exitListeners) l(0);
56
+ return true;
57
+ },
58
+ once: (_event, listener) => {
59
+ exitListeners.push(listener as (code: number | null) => void);
60
+ },
61
+ };
62
+ };
63
+ return { spawn, setHandler: (fn) => (handler = fn), received };
64
+ }
65
+
66
+ const frame = (over: Partial<{ base64: string; mediaType: string; url: string; width: number; height: number }> = {}) => ({
67
+ base64: 'AAAA',
68
+ mediaType: 'image/jpeg',
69
+ url: 'https://example.com/',
70
+ width: 1000,
71
+ height: 500,
72
+ ...over,
73
+ });
74
+
75
+ /** Flush microtasks so the async tick() round-trips through the fake streams. */
76
+ const flush = async (): Promise<void> => {
77
+ for (let i = 0; i < 6; i++) await Promise.resolve();
78
+ };
79
+
80
+ afterEach(async () => {
81
+ vi.useRealTimers();
82
+ await closeBrowserSidecar();
83
+ });
84
+
85
+ describe('browser surface polling lifecycle', () => {
86
+ it('emits a frame payload and tracks it as the snapshot', async () => {
87
+ vi.useFakeTimers();
88
+ const sidecar = makeControllableSpawn();
89
+ sidecar.setHandler((method) => (method === 'frame' ? { ok: true, result: frame() } : { ok: false, message: 'x' }));
90
+
91
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
92
+ const payloads: unknown[] = [];
93
+ surface.onData((p) => payloads.push(p));
94
+
95
+ await flush(); // the immediate kick tick()
96
+ expect(payloads).toEqual([{ type: 'frame', base64: 'AAAA', mime: 'image/jpeg', url: 'https://example.com/' }]);
97
+ expect(surface.snapshot()).toEqual({ type: 'frame', base64: 'AAAA', mime: 'image/jpeg', url: 'https://example.com/' });
98
+
99
+ surface.close();
100
+ });
101
+
102
+ it('snapshot reports "Starting browser…" before any frame arrives', () => {
103
+ vi.useFakeTimers();
104
+ const sidecar = makeControllableSpawn();
105
+ sidecar.setHandler(() => ({ ok: false, message: 'not yet' }));
106
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
107
+ expect(surface.snapshot()).toEqual({ type: 'status', text: 'Starting browser…' });
108
+ surface.close();
109
+ });
110
+
111
+ it('does NOT emit a status on the first failures, but DOES on the FAIL_GRACE-th (no prior frame)', async () => {
112
+ vi.useFakeTimers();
113
+ const sidecar = makeControllableSpawn();
114
+ sidecar.setHandler(() => ({ ok: false, message: 'launch failed' }));
115
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
116
+ const statuses: Array<{ type: string; text?: string }> = [];
117
+ surface.onData((p) => statuses.push(p as { type: string; text?: string }));
118
+
119
+ await flush(); // tick #1 (the kick)
120
+ expect(statuses).toHaveLength(0);
121
+ // Advance to the FAIL_GRACE-th failure. Each interval triggers one tick.
122
+ for (let i = 1; i < FAIL_GRACE; i++) {
123
+ await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS);
124
+ await flush();
125
+ }
126
+ expect(statuses).toEqual([{ type: 'status', text: 'Browser unavailable: launch failed' }]);
127
+
128
+ // Further failures do NOT re-emit (status fires once per failure streak).
129
+ await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS);
130
+ await flush();
131
+ expect(statuses).toHaveLength(1);
132
+
133
+ surface.close();
134
+ });
135
+
136
+ it('a successful frame resets the failure counter', async () => {
137
+ vi.useFakeTimers();
138
+ const sidecar = makeControllableSpawn();
139
+ let ok = false;
140
+ sidecar.setHandler(() => (ok ? { ok: true, result: frame() } : { ok: false, message: 'flaky' }));
141
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
142
+ const events: Array<{ type: string }> = [];
143
+ surface.onData((p) => events.push(p as { type: string }));
144
+
145
+ await flush(); // fail #1
146
+ await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS); // fail #2
147
+ await flush();
148
+ ok = true; // a good frame lands, resetting fails
149
+ await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS);
150
+ await flush();
151
+ ok = false; // start failing again
152
+
153
+ // Because the good frame reset `fails` to 0, we must accrue FAIL_GRACE
154
+ // fresh failures via FAIL_GRACE more ticks before the status fires.
155
+ for (let i = 0; i < FAIL_GRACE; i++) {
156
+ await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS);
157
+ await flush();
158
+ }
159
+ // The single status here is the "disconnected" one (a prior frame existed).
160
+ const statuses = events.filter((e) => e.type === 'status');
161
+ expect(statuses).toHaveLength(1);
162
+ expect((statuses[0] as { text: string }).text).toMatch(/disconnected/);
163
+
164
+ surface.close();
165
+ });
166
+
167
+ it('surfaces a "disconnected" status when the page dies after a frame was shown', async () => {
168
+ vi.useFakeTimers();
169
+ const sidecar = makeControllableSpawn();
170
+ let alive = true;
171
+ sidecar.setHandler(() => (alive ? { ok: true, result: frame() } : { ok: false, message: 'page crashed' }));
172
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
173
+ const events: Array<{ type: string; text?: string }> = [];
174
+ surface.onData((p) => events.push(p as { type: string; text?: string }));
175
+
176
+ await flush(); // good frame
177
+ alive = false;
178
+ for (let i = 0; i < FAIL_GRACE; i++) {
179
+ await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS);
180
+ await flush();
181
+ }
182
+ const statuses = events.filter((e) => e.type === 'status');
183
+ expect(statuses).toEqual([{ type: 'status', text: 'Browser disconnected: page crashed' }]);
184
+ // The stale frame is still available via snapshot.
185
+ expect(surface.snapshot()).toMatchObject({ type: 'frame' });
186
+
187
+ surface.close();
188
+ });
189
+
190
+ it('a needs-install error pauses polling and emits an install prompt', async () => {
191
+ vi.useFakeTimers();
192
+ const sidecar = makeControllableSpawn();
193
+ sidecar.setHandler(() => ({ ok: false, message: 'Playwright is not installed.', kind: 'needs-install' }));
194
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
195
+ const events: Array<{ type: string; needsInstall?: boolean; text?: string }> = [];
196
+ surface.onData((p) => events.push(p as { type: string; needsInstall?: boolean }));
197
+
198
+ await flush(); // the kick tick() hits the needs-install reply immediately
199
+ expect(events).toEqual([
200
+ expect.objectContaining({ type: 'status', needsInstall: true }),
201
+ ]);
202
+ // Snapshot for a late viewer also reports the install affordance.
203
+ expect(surface.snapshot()).toMatchObject({ type: 'status', needsInstall: true });
204
+
205
+ // Polling is paused: no further `frame` calls reach the sidecar (we don't
206
+ // spin on an import that will keep failing).
207
+ const before = sidecar.received.length;
208
+ await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS * 4);
209
+ await flush();
210
+ expect(sidecar.received.length).toBe(before);
211
+
212
+ surface.close();
213
+ });
214
+
215
+ it('treats a malformed frame reply as a failed tick (no blank frame emitted)', async () => {
216
+ vi.useFakeTimers();
217
+ const sidecar = makeControllableSpawn();
218
+ // The sidecar replies ok, but with a frame missing base64/url — a partial or
219
+ // altered reply. The surface must NOT stream {base64: undefined} to subs.
220
+ sidecar.setHandler((method) =>
221
+ method === 'frame' ? { ok: true, result: { mediaType: 'image/jpeg' } } : { ok: false, message: 'x' },
222
+ );
223
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
224
+ const events: Array<{ type: string }> = [];
225
+ surface.onData((p) => events.push(p as { type: string }));
226
+
227
+ await flush();
228
+ // No frame payload streamed (it was treated as a failure).
229
+ expect(events.filter((e) => e.type === 'frame')).toHaveLength(0);
230
+ // Drive past FAIL_GRACE: a status surfaces instead of silent blanks.
231
+ for (let i = 1; i < FAIL_GRACE; i++) {
232
+ await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS);
233
+ await flush();
234
+ }
235
+ expect(events.some((e) => e.type === 'status')).toBe(true);
236
+
237
+ surface.close();
238
+ });
239
+
240
+ it('close() stops the poll — no further frame calls reach the sidecar', async () => {
241
+ vi.useFakeTimers();
242
+ const sidecar = makeControllableSpawn();
243
+ sidecar.setHandler(() => ({ ok: true, result: frame() }));
244
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
245
+ await flush();
246
+ const before = sidecar.received.length;
247
+ surface.close();
248
+ await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS * 4);
249
+ await flush();
250
+ expect(sidecar.received.length).toBe(before);
251
+ });
252
+ });
253
+
254
+ describe('browser surface input mapping', () => {
255
+ it('click maps normalized fx/fy to viewport pixel coords from the last frame', async () => {
256
+ vi.useFakeTimers();
257
+ const sidecar = makeControllableSpawn();
258
+ sidecar.setHandler((method) =>
259
+ method === 'frame' ? { ok: true, result: frame({ width: 1000, height: 500 }) } : { ok: true, result: { url: 'x' } },
260
+ );
261
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
262
+ await flush(); // establish last frame (1000x500)
263
+
264
+ await surface.input({ type: 'click', fx: 0.5, fy: 0.2 });
265
+ await flush();
266
+ const mouseCall = sidecar.received.find((r) => r.method === 'mouse');
267
+ expect(mouseCall?.params).toEqual({ x: 500, y: 100, count: 1 });
268
+
269
+ surface.close();
270
+ });
271
+
272
+ it('click before any frame uses the 1280x720 fallback viewport', async () => {
273
+ vi.useFakeTimers();
274
+ const sidecar = makeControllableSpawn();
275
+ // Frame fails so `last` stays null, but mouse succeeds.
276
+ sidecar.setHandler((method) => (method === 'mouse' ? { ok: true, result: { url: 'x' } } : { ok: false, message: 'no frame' }));
277
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
278
+ await flush();
279
+
280
+ await surface.input({ type: 'click', fx: 1, fy: 1 });
281
+ await flush();
282
+ const mouseCall = sidecar.received.find((r) => r.method === 'mouse');
283
+ expect(mouseCall?.params).toEqual({ x: 1280, y: 720, count: 1 });
284
+
285
+ surface.close();
286
+ });
287
+
288
+ it('resize sets the page viewport so the view fills the pane', async () => {
289
+ vi.useFakeTimers();
290
+ const sidecar = makeControllableSpawn();
291
+ sidecar.setHandler(() => ({ ok: true, result: frame() }));
292
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
293
+ await flush();
294
+ await surface.resize?.({ width: 900, height: 600 });
295
+ await flush();
296
+ const vp = sidecar.received.find((r) => r.method === 'setviewport');
297
+ expect(vp?.params).toEqual({ width: 900, height: 600 });
298
+ surface.close();
299
+ });
300
+
301
+ it('dblclick forwards clickCount 2; hover + nav reach their sidecar methods', async () => {
302
+ vi.useFakeTimers();
303
+ const sidecar = makeControllableSpawn();
304
+ sidecar.setHandler((method) => (method === 'frame' ? { ok: true, result: frame({ width: 1000, height: 500 }) } : { ok: true, result: {} }));
305
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
306
+ await flush();
307
+
308
+ await surface.input({ type: 'dblclick', fx: 0.5, fy: 0.5 });
309
+ await surface.input({ type: 'move', fx: 0.1, fy: 0.2 });
310
+ await surface.input({ type: 'reload' });
311
+ await flush();
312
+
313
+ expect(sidecar.received.find((r) => r.method === 'mouse')?.params).toEqual({ x: 500, y: 250, count: 2 });
314
+ expect(sidecar.received.find((r) => r.method === 'mousemove')?.params).toEqual({ x: 100, y: 100 });
315
+ expect(sidecar.received.some((r) => r.method === 'reload')).toBe(true);
316
+ surface.close();
317
+ });
318
+
319
+ it('zoom forwards the factor; capture maps the region and emits the PNG', async () => {
320
+ vi.useFakeTimers();
321
+ const sidecar = makeControllableSpawn();
322
+ sidecar.setHandler((method) => {
323
+ if (method === 'frame') return { ok: true, result: frame({ width: 1000, height: 500 }) };
324
+ if (method === 'capture') return { ok: true, result: { mediaType: 'image/png', base64: 'PNGDATA' } };
325
+ return { ok: true, result: {} };
326
+ });
327
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
328
+ const events: Array<{ type: string; base64?: string }> = [];
329
+ surface.onData((p) => events.push(p as { type: string }));
330
+ await flush();
331
+
332
+ await surface.input({ type: 'zoom', factor: 1.5 });
333
+ await surface.input({ type: 'capture', fx: 0.1, fy: 0.2, fw: 0.5, fh: 0.4 });
334
+ await flush();
335
+
336
+ expect(sidecar.received.find((r) => r.method === 'zoom')?.params).toEqual({ factor: 1.5 });
337
+ // normalized region × viewport (1000×500) → CSS px clip.
338
+ expect(sidecar.received.find((r) => r.method === 'capture')?.params).toEqual({
339
+ x: 100,
340
+ y: 100,
341
+ width: 500,
342
+ height: 200,
343
+ });
344
+ expect(events.find((e) => e.type === 'captured')?.base64).toBe('PNGDATA');
345
+ surface.close();
346
+ });
347
+
348
+ it('navigate proxies goto and surfaces a goto rejection as a status line', async () => {
349
+ vi.useFakeTimers();
350
+ const sidecar = makeControllableSpawn();
351
+ sidecar.setHandler((method) => {
352
+ if (method === 'goto') return { ok: false, message: 'SSRF blocked' };
353
+ return { ok: false, message: 'no frame' };
354
+ });
355
+ const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
356
+ const events: Array<{ type: string; text?: string }> = [];
357
+ surface.onData((p) => events.push(p as { type: string; text?: string }));
358
+ await flush();
359
+
360
+ await surface.input({ type: 'navigate', url: 'http://10.0.0.1/' });
361
+ await flush();
362
+ expect(events.some((e) => e.type === 'status' && e.text === 'SSRF blocked')).toBe(true);
363
+ expect(sidecar.received.some((r) => r.method === 'goto')).toBe(true);
364
+
365
+ surface.close();
366
+ });
367
+ });
@@ -0,0 +1,275 @@
1
+ import { defineSurface, type SurfaceInstance } from '@moxxy/sdk';
2
+ import {
3
+ browserSidecarCall,
4
+ closeBrowserSidecar,
5
+ resolveBrowserInstallRoot,
6
+ sidecarErrorKind,
7
+ type BrowserSessionDeps,
8
+ } from './browser-session.js';
9
+ import { installPlaywrightPackage } from './sidecar/install.js';
10
+
11
+ /**
12
+ * The `browser` surface: a live, in-window view of the SAME Playwright page the
13
+ * `browser_session` tool drives. We "stream" the page by polling a JPEG frame
14
+ * (`frame` sidecar method) a few times a second and forwarding it as a
15
+ * `surface.data` payload; the user's clicks/keys/scroll/navigate are proxied
16
+ * back onto the page via the sidecar's coordinate-based input methods. So the
17
+ * agent and the user operate ONE shared page — agent navigations show up in the
18
+ * pane, and the user can take over.
19
+ *
20
+ * Why polling and not a CDP `Page.startScreencast` push: the screencast only
21
+ * emits on visual change, so a freshly-opened (blank / static / headless) page
22
+ * produces no frames at all — the pane sat on "Loading…" forever with the
23
+ * underlying error swallowed. A screenshot poll always yields a frame (even a
24
+ * blank one), so the view comes up reliably and a real launch/install failure
25
+ * surfaces as a status line instead of an indefinite spinner. Polling rides the
26
+ * existing sidecar RPC surface and works on every Playwright browser, not just
27
+ * Chromium.
28
+ */
29
+
30
+ const FRAME_INTERVAL_MS = 300;
31
+ /** Consecutive `frame` failures (with no frame ever seen) before we stop
32
+ * assuming "still launching" and show the error. ~6 × 300ms ≈ 1.8s grace. */
33
+ const FAIL_GRACE = 6;
34
+
35
+ interface Frame {
36
+ mediaType: string;
37
+ base64: string;
38
+ url: string;
39
+ width: number;
40
+ height: number;
41
+ }
42
+
43
+ /** Validate a sidecar `frame` reply before emitting it: a partial / altered
44
+ * reply must be treated as a failed tick (so FAIL_GRACE eventually surfaces a
45
+ * status) rather than streamed to subscribers as `{base64: undefined, …}`. */
46
+ function isFrame(v: unknown): v is Frame {
47
+ if (!v || typeof v !== 'object') return false;
48
+ const f = v as Record<string, unknown>;
49
+ return typeof f.base64 === 'string' && typeof f.mediaType === 'string' && typeof f.url === 'string';
50
+ }
51
+
52
+ export function buildBrowserSurface(deps?: BrowserSessionDeps) {
53
+ return defineSurface({
54
+ kind: 'browser',
55
+ description: "A live view of the agent's browser; click, type, and navigate.",
56
+ open: (): SurfaceInstance => {
57
+ const dataSubs = new Set<(payload: unknown) => void>();
58
+ let last: Frame | null = null;
59
+ let timer: ReturnType<typeof setInterval> | null = null;
60
+ let inFlight = false;
61
+ let fails = 0;
62
+ // Set once we detect the `playwright` npm package isn't installed. Polling
63
+ // pauses (no point retrying an import that will keep failing) and the pane
64
+ // shows an Install affordance; an `install` input clears it.
65
+ let needsInstall = false;
66
+ let installing = false;
67
+
68
+ const emit = (payload: unknown): void => {
69
+ for (const cb of dataSubs) cb(payload);
70
+ };
71
+
72
+ const stopPolling = (): void => {
73
+ if (timer) clearInterval(timer);
74
+ timer = null;
75
+ };
76
+ const startPolling = (): void => {
77
+ if (timer) return;
78
+ void tick();
79
+ timer = setInterval(() => void tick(), FRAME_INTERVAL_MS);
80
+ };
81
+
82
+ const tick = async (): Promise<void> => {
83
+ if (inFlight || needsInstall || installing) return; // don't pile up / retry a known-missing dep
84
+ inFlight = true;
85
+ try {
86
+ const reply = await browserSidecarCall('frame', {}, deps);
87
+ if (!isFrame(reply)) throw new Error('malformed frame reply from sidecar');
88
+ last = reply;
89
+ fails = 0;
90
+ emit({ type: 'frame', base64: reply.base64, mime: reply.mediaType, url: reply.url });
91
+ } catch (err) {
92
+ // The `playwright` npm package is simply absent — recoverable. Pause
93
+ // polling and ask the user (the download is ~200MB) rather than spin on
94
+ // a failing import or dump a raw "not installed" error.
95
+ if (sidecarErrorKind(err) === 'needs-install') {
96
+ needsInstall = true;
97
+ stopPolling();
98
+ emit({
99
+ type: 'status',
100
+ needsInstall: true,
101
+ text: 'The browser engine (Playwright) is not installed. It is a one-time ~200MB download.',
102
+ });
103
+ return;
104
+ }
105
+ // The first failures are usually the browser still launching (or a
106
+ // one-time binary install). Only surface a hard error once it's
107
+ // clearly not transient, so the user isn't left on a silent spinner.
108
+ // Fire EXACTLY on the FAIL_GRACE-th consecutive failure (a successful
109
+ // frame resets `fails` to 0), so the status is emitted once per
110
+ // failure streak rather than every tick. We surface it whether or not
111
+ // a prior frame exists: with no frame yet the page never came up;
112
+ // with a prior frame the page later died and the pane would otherwise
113
+ // sit frozen on a stale screenshot with no hint the live view is dead.
114
+ if (++fails === FAIL_GRACE) {
115
+ const message = err instanceof Error ? err.message : String(err);
116
+ emit({
117
+ type: 'status',
118
+ text: last ? `Browser disconnected: ${message}` : `Browser unavailable: ${message}`,
119
+ });
120
+ }
121
+ } finally {
122
+ inFlight = false;
123
+ }
124
+ };
125
+
126
+ // After an interaction, grab a frame now and once more shortly after, so a
127
+ // click/keypress that kicks off async work (a navigation, a menu opening,
128
+ // an animation) shows up promptly instead of waiting for the next poll.
129
+ const bump = (): void => {
130
+ void tick();
131
+ setTimeout(() => void tick(), 140);
132
+ };
133
+
134
+ // Kick an immediate frame (launches the browser), then poll.
135
+ startPolling();
136
+
137
+ const runInstall = async (): Promise<void> => {
138
+ if (installing) return;
139
+ installing = true;
140
+ stopPolling();
141
+ emit({ type: 'status', text: 'Installing browser engine… (one-time, ~200MB)' });
142
+ try {
143
+ await installPlaywrightPackage({
144
+ rootDir: resolveBrowserInstallRoot(deps),
145
+ onProgress: (line) => emit({ type: 'status', text: line }),
146
+ });
147
+ // The sidecar cached its failed `import('playwright')`; drop it so the
148
+ // next frame call respawns a fresh sidecar that imports the now-present
149
+ // package.
150
+ await closeBrowserSidecar();
151
+ needsInstall = false;
152
+ fails = 0;
153
+ emit({ type: 'status', text: 'Installed. Starting browser…' });
154
+ startPolling();
155
+ } catch (err) {
156
+ const message = err instanceof Error ? err.message : String(err);
157
+ emit({ type: 'status', needsInstall: true, text: `Install failed: ${message}` });
158
+ } finally {
159
+ installing = false;
160
+ }
161
+ };
162
+
163
+ return {
164
+ id: 'browser',
165
+ kind: 'browser',
166
+ onData: (cb) => {
167
+ dataSubs.add(cb);
168
+ return () => dataSubs.delete(cb);
169
+ },
170
+ snapshot: () =>
171
+ needsInstall
172
+ ? {
173
+ type: 'status',
174
+ needsInstall: true,
175
+ text: 'The browser engine (Playwright) is not installed. It is a one-time ~200MB download.',
176
+ }
177
+ : last
178
+ ? { type: 'frame', base64: last.base64, mime: last.mediaType, url: last.url }
179
+ : { type: 'status', text: 'Starting browser…' },
180
+ resize: async (size) => {
181
+ // Match the page viewport to the pane so the live view fills the
182
+ // container (no letterboxing) and click coords map 1:1.
183
+ if (!size.width || !size.height) return;
184
+ await browserSidecarCall('setviewport', { width: size.width, height: size.height }, deps).catch(
185
+ () => undefined,
186
+ );
187
+ void tick();
188
+ },
189
+ input: async (msg) => {
190
+ if (msg.type === 'install') {
191
+ await runInstall();
192
+ return;
193
+ }
194
+ const vw = last?.width ?? 1280;
195
+ const vh = last?.height ?? 720;
196
+ if (msg.type === 'navigate' && typeof msg.url === 'string') {
197
+ // The sidecar's goto re-runs the SSRF guard (loopback/private/
198
+ // metadata blocked), so a hostile URL never navigates. Unlike the
199
+ // pointer/key proxies below, a navigate rejection is user-meaningful
200
+ // (bad URL format, SSRF block) — surface it as a status line instead
201
+ // of swallowing it, so the user knows why the address bar did nothing.
202
+ try {
203
+ await browserSidecarCall('goto', { url: msg.url }, deps);
204
+ } catch (err) {
205
+ const text = err instanceof Error ? err.message : String(err);
206
+ emit({ type: 'status', text });
207
+ }
208
+ bump();
209
+ } else if (
210
+ (msg.type === 'click' || msg.type === 'dblclick') &&
211
+ typeof msg.fx === 'number' &&
212
+ typeof msg.fy === 'number'
213
+ ) {
214
+ const count = msg.type === 'dblclick' ? 2 : 1;
215
+ await browserSidecarCall('mouse', { x: msg.fx * vw, y: msg.fy * vh, count }, deps).catch(
216
+ () => undefined,
217
+ );
218
+ bump();
219
+ } else if (msg.type === 'move' && typeof msg.fx === 'number' && typeof msg.fy === 'number') {
220
+ // Hover — drive the pointer so :hover styles/tooltips render. A single
221
+ // follow-up frame (not a bump) keeps the cost down at move frequency.
222
+ await browserSidecarCall('mousemove', { x: msg.fx * vw, y: msg.fy * vh }, deps).catch(
223
+ () => undefined,
224
+ );
225
+ void tick();
226
+ } else if (
227
+ msg.type === 'capture' &&
228
+ typeof msg.fx === 'number' &&
229
+ typeof msg.fy === 'number' &&
230
+ typeof msg.fw === 'number' &&
231
+ typeof msg.fh === 'number'
232
+ ) {
233
+ // Sharp PNG of the dragged region → handed back to the pane, which
234
+ // attaches it to the chat composer (the user then describes the change).
235
+ const shot = (await browserSidecarCall(
236
+ 'capture',
237
+ { x: msg.fx * vw, y: msg.fy * vh, width: msg.fw * vw, height: msg.fh * vh },
238
+ deps,
239
+ ).catch(() => null)) as unknown;
240
+ // Validate before emitting so a partial/altered reply doesn't hand
241
+ // the composer a {base64: undefined} attachment.
242
+ if (
243
+ shot &&
244
+ typeof shot === 'object' &&
245
+ typeof (shot as { base64?: unknown }).base64 === 'string' &&
246
+ typeof (shot as { mediaType?: unknown }).mediaType === 'string'
247
+ ) {
248
+ const s = shot as { base64: string; mediaType: string };
249
+ emit({ type: 'captured', base64: s.base64, mediaType: s.mediaType });
250
+ }
251
+ } else if (msg.type === 'zoom' && typeof msg.factor === 'number') {
252
+ await browserSidecarCall('zoom', { factor: msg.factor }, deps).catch(() => undefined);
253
+ bump();
254
+ } else if (msg.type === 'key' && typeof msg.key === 'string') {
255
+ await browserSidecarCall('key', { key: msg.key }, deps).catch(() => undefined);
256
+ bump();
257
+ } else if (msg.type === 'scroll' && typeof msg.dy === 'number') {
258
+ await browserSidecarCall('scroll', { dy: msg.dy }, deps).catch(() => undefined);
259
+ bump();
260
+ } else if (msg.type === 'back' || msg.type === 'forward' || msg.type === 'reload') {
261
+ await browserSidecarCall(msg.type, {}, deps).catch(() => undefined);
262
+ bump();
263
+ }
264
+ },
265
+ close: () => {
266
+ if (timer) clearInterval(timer);
267
+ timer = null;
268
+ dataSubs.clear();
269
+ // The underlying page stays alive for the agent's browser_session tool;
270
+ // it's torn down on session shutdown (closeBrowserSidecar).
271
+ },
272
+ };
273
+ },
274
+ });
275
+ }