@crewhaus/computer-use-driver 0.1.1 → 0.1.2

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.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Catalog R18 — chromium (Playwright-backed) driver contract tests.
3
+ *
4
+ * Playwright is an optional peer dep that may be present in the tree. To stay
5
+ * deterministic (no real browser launch, no real clock/network), we intercept
6
+ * the lazy `import("playwright")` with `mock.module` and drive a fully stubbed
7
+ * browser → context → page chain. Each test (re)establishes the mock before
8
+ * use because `mock.module` is process-global and one test deliberately makes
9
+ * the import fail.
10
+ */
11
+ import { afterEach, describe, expect, mock, test } from "bun:test";
12
+ import * as net from "node:net";
13
+ import { createDriver } from "./index.js";
14
+
15
+ /** Parse the JSON recorded by the fake's `launch …` entry. */
16
+ function parseLaunch(entry: string | undefined): Record<string, unknown> {
17
+ if (entry === undefined || !entry.startsWith("launch ")) {
18
+ throw new Error(`expected a launch record, got: ${entry}`);
19
+ }
20
+ return JSON.parse(entry.slice("launch ".length)) as Record<string, unknown>;
21
+ }
22
+
23
+ /** True once nothing is listening on `port` (loopback). */
24
+ function portRefuses(port: number): Promise<boolean> {
25
+ return new Promise((resolve) => {
26
+ const sock = net.connect({ host: "127.0.0.1", port }, () => {
27
+ sock.destroy();
28
+ resolve(false);
29
+ });
30
+ sock.on("error", () => resolve(true));
31
+ });
32
+ }
33
+
34
+ /** Records every call routed through the fake Playwright surface. */
35
+ type Rec = string[];
36
+
37
+ /** Build a fake `playwright` module whose page records into `rec`. */
38
+ function fakePlaywright(rec: Rec, overrides?: { viewportSize?: () => unknown }) {
39
+ const page = {
40
+ async goto(url: string, optsArg?: unknown) {
41
+ rec.push(`goto ${url} ${JSON.stringify(optsArg)}`);
42
+ },
43
+ async screenshot(optsArg?: { type?: string }) {
44
+ rec.push(`screenshot ${optsArg?.type}`);
45
+ // PNG magic header so the returned Uint8Array is recognizable.
46
+ return new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
47
+ },
48
+ mouse: {
49
+ async click(x: number, y: number, optsArg?: { button?: string }) {
50
+ rec.push(`click ${x},${y} ${optsArg?.button}`);
51
+ },
52
+ async wheel(dx: number, dy: number) {
53
+ rec.push(`wheel ${dx},${dy}`);
54
+ },
55
+ },
56
+ keyboard: {
57
+ async type(t: string) {
58
+ rec.push(`type ${t}`);
59
+ },
60
+ async press(k: string) {
61
+ rec.push(`press ${k}`);
62
+ },
63
+ },
64
+ viewportSize: overrides?.viewportSize ?? (() => ({ width: 1280, height: 720 })),
65
+ async textContent(sel: string) {
66
+ rec.push(`textContent ${sel}`);
67
+ return "hello body";
68
+ },
69
+ };
70
+ const context = {
71
+ async newPage() {
72
+ rec.push("newPage");
73
+ return page;
74
+ },
75
+ };
76
+ const browser = {
77
+ async newContext(ctxOpts: unknown) {
78
+ rec.push(`newContext ${JSON.stringify(ctxOpts)}`);
79
+ return context;
80
+ },
81
+ async close() {
82
+ rec.push("close");
83
+ },
84
+ };
85
+ return {
86
+ chromium: {
87
+ async launch(launchOpts: unknown) {
88
+ rec.push(`launch ${JSON.stringify(launchOpts)}`);
89
+ return browser;
90
+ },
91
+ },
92
+ __page: page,
93
+ __browser: browser,
94
+ };
95
+ }
96
+
97
+ function installPlaywright(rec: Rec, overrides?: { viewportSize?: () => unknown }) {
98
+ const fake = fakePlaywright(rec, overrides);
99
+ mock.module("playwright", () => fake);
100
+ return fake;
101
+ }
102
+
103
+ afterEach(() => {
104
+ // Leave a benign working module registered so per-test fakes cannot bleed
105
+ // into unrelated tests / files run later in the process. We deliberately do
106
+ // NOT restore the real playwright: it is an optional peer dep that may be
107
+ // absent, nothing else in this package's test process imports it, and a
108
+ // working fake guarantees no test can ever launch a real browser.
109
+ mock.module("playwright", () => fakePlaywright([]));
110
+ });
111
+
112
+ describe("chromium driver — connect / lifecycle", () => {
113
+ test("connect launches with defaults, creates context + page", async () => {
114
+ const rec: Rec = [];
115
+ installPlaywright(rec);
116
+ const d = createDriver({ backend: "chromium" });
117
+ await d.connect();
118
+ const launch = parseLaunch(rec[0]);
119
+ expect(launch["headless"]).toBe(true);
120
+ // SECURITY default — the DNS-pinning proxy is on, with the implicit
121
+ // localhost proxy-bypass removed (see ssrf-proxy.ts).
122
+ expect(launch["proxy"]).toMatchObject({ bypass: "<-loopback>" });
123
+ expect((launch["proxy"] as { server: string }).server).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
124
+ expect(rec.slice(1)).toEqual([
125
+ `newContext ${JSON.stringify({ viewport: { width: 1280, height: 720 } })}`,
126
+ "newPage",
127
+ ]);
128
+ await d.disconnect();
129
+ });
130
+
131
+ test("connect honours playwrightOptions + viewport overrides", async () => {
132
+ const rec: Rec = [];
133
+ installPlaywright(rec);
134
+ const d = createDriver({
135
+ backend: "chromium",
136
+ playwrightOptions: { headless: false, slowMo: 5 },
137
+ viewport: { width: 800, height: 600 },
138
+ });
139
+ await d.connect();
140
+ const launch = parseLaunch(rec[0]);
141
+ expect(launch["headless"]).toBe(false);
142
+ expect(launch["slowMo"]).toBe(5);
143
+ expect(rec[1]).toBe(`newContext ${JSON.stringify({ viewport: { width: 800, height: 600 } })}`);
144
+ await d.disconnect();
145
+ });
146
+
147
+ test("connect is idempotent — second call is a no-op", async () => {
148
+ const rec: Rec = [];
149
+ installPlaywright(rec);
150
+ const d = createDriver({ backend: "chromium" });
151
+ await d.connect();
152
+ const afterFirst = rec.length;
153
+ await d.connect();
154
+ expect(rec.length).toBe(afterFirst);
155
+ });
156
+
157
+ // The playwright-import-failure path (loadPlaywright catch) is covered in
158
+ // chromium-import-fail.test.ts via the `_importPlaywright` injection seam.
159
+ // It cannot be tested with mock.module here: bun evaluates a throwing
160
+ // factory eagerly once the module has been imported (even as a fake, as
161
+ // these tests do), so registration itself would blow up.
162
+ });
163
+
164
+ describe("chromium driver — operations after connect", () => {
165
+ async function connected(rec: Rec, overrides?: { viewportSize?: () => unknown }) {
166
+ installPlaywright(rec, overrides);
167
+ const d = createDriver({ backend: "chromium" });
168
+ await d.connect();
169
+ rec.length = 0; // drop connect noise; focus on the operation under test
170
+ return d;
171
+ }
172
+
173
+ test("goto navigates with domcontentloaded wait", async () => {
174
+ const rec: Rec = [];
175
+ const d = await connected(rec);
176
+ await d.goto("https://example.com");
177
+ expect(rec).toEqual([
178
+ `goto https://example.com ${JSON.stringify({ waitUntil: "domcontentloaded" })}`,
179
+ ]);
180
+ });
181
+
182
+ test("screenshot returns the page PNG bytes", async () => {
183
+ const rec: Rec = [];
184
+ const d = await connected(rec);
185
+ const png = await d.screenshot();
186
+ expect(png).toBeInstanceOf(Uint8Array);
187
+ expect(Array.from(png)).toEqual([0x89, 0x50, 0x4e, 0x47]);
188
+ expect(rec).toEqual(["screenshot png"]);
189
+ });
190
+
191
+ test("click defaults to the left button", async () => {
192
+ const rec: Rec = [];
193
+ const d = await connected(rec);
194
+ await d.click(10, 20);
195
+ expect(rec).toEqual(["click 10,20 left"]);
196
+ });
197
+
198
+ test("click forwards an explicit button", async () => {
199
+ const rec: Rec = [];
200
+ const d = await connected(rec);
201
+ await d.click(30, 40, "right");
202
+ expect(rec).toEqual(["click 30,40 right"]);
203
+ });
204
+
205
+ test("type forwards to keyboard.type", async () => {
206
+ const rec: Rec = [];
207
+ const d = await connected(rec);
208
+ await d.type("hello world");
209
+ expect(rec).toEqual(["type hello world"]);
210
+ });
211
+
212
+ test("key forwards to keyboard.press", async () => {
213
+ const rec: Rec = [];
214
+ const d = await connected(rec);
215
+ await d.key("Control+a");
216
+ expect(rec).toEqual(["press Control+a"]);
217
+ });
218
+
219
+ test("scroll forwards to mouse.wheel", async () => {
220
+ const rec: Rec = [];
221
+ const d = await connected(rec);
222
+ await d.scroll(0, 240);
223
+ expect(rec).toEqual(["wheel 0,240"]);
224
+ });
225
+
226
+ test("getViewport uses the live page viewportSize when available", async () => {
227
+ const rec: Rec = [];
228
+ const d = await connected(rec);
229
+ const vp = await d.getViewport();
230
+ expect(vp).toEqual({ width: 1280, height: 720, devicePixelRatio: 1 });
231
+ });
232
+
233
+ test("getViewport falls back to opts.viewport when page reports null", async () => {
234
+ const rec: Rec = [];
235
+ installPlaywright(rec, { viewportSize: () => null });
236
+ const d = createDriver({ backend: "chromium", viewport: { width: 640, height: 480 } });
237
+ await d.connect();
238
+ const vp = await d.getViewport();
239
+ expect(vp).toEqual({ width: 640, height: 480, devicePixelRatio: 1 });
240
+ });
241
+
242
+ test("getViewport falls back to 1280x720 when page is null and no opts viewport", async () => {
243
+ const rec: Rec = [];
244
+ installPlaywright(rec, { viewportSize: () => null });
245
+ const d = createDriver({ backend: "chromium" });
246
+ await d.connect();
247
+ const vp = await d.getViewport();
248
+ expect(vp).toEqual({ width: 1280, height: 720, devicePixelRatio: 1 });
249
+ });
250
+
251
+ test("domText returns the body text content", async () => {
252
+ const rec: Rec = [];
253
+ const d = await connected(rec);
254
+ const text = await d.domText?.();
255
+ expect(text).toBe("hello body");
256
+ expect(rec).toEqual(["textContent body"]);
257
+ });
258
+
259
+ test("domText coalesces null body text to empty string", async () => {
260
+ const rec: Rec = [];
261
+ const fake = installPlaywright(rec);
262
+ // Page reports no body text content → driver must coalesce null → "".
263
+ (fake.__page as { textContent: (s: string) => Promise<string | null> }).textContent =
264
+ async () => null;
265
+ const d = createDriver({ backend: "chromium" });
266
+ await d.connect();
267
+ const text = await d.domText?.();
268
+ expect(text).toBe("");
269
+ });
270
+ });
271
+
272
+ describe("chromium driver — pre-connect guards", () => {
273
+ test("every operation throws 'not connected' before connect()", async () => {
274
+ const d = createDriver({ backend: "chromium" });
275
+ await expect(d.goto("https://x.com")).rejects.toThrow(/not connected/);
276
+ await expect(d.screenshot()).rejects.toThrow(/not connected/);
277
+ await expect(d.click(0, 0)).rejects.toThrow(/not connected/);
278
+ await expect(d.type("x")).rejects.toThrow(/not connected/);
279
+ await expect(d.key("Enter")).rejects.toThrow(/not connected/);
280
+ await expect(d.scroll(0, 0)).rejects.toThrow(/not connected/);
281
+ await expect(d.getViewport()).rejects.toThrow(/not connected/);
282
+ await expect(d.domText?.()).rejects.toThrow(/not connected/);
283
+ });
284
+ });
285
+
286
+ describe("chromium driver — SSRF pinning proxy wiring (audit follow-up R1)", () => {
287
+ test("the proxy in launchOpts is a LIVE local server, closed by disconnect", async () => {
288
+ const rec: Rec = [];
289
+ installPlaywright(rec);
290
+ const d = createDriver({ backend: "chromium" });
291
+ await d.connect();
292
+ const launch = parseLaunch(rec[0]);
293
+ const url = new URL((launch["proxy"] as { server: string }).server);
294
+ const port = Number.parseInt(url.port, 10);
295
+ expect(await portRefuses(port)).toBe(false); // listening while connected
296
+ await d.disconnect();
297
+ expect(await portRefuses(port)).toBe(true); // closed after disconnect
298
+ });
299
+
300
+ test("ssrfProxy: false disables the proxy (documented escape hatch)", async () => {
301
+ const rec: Rec = [];
302
+ installPlaywright(rec);
303
+ const d = createDriver({ backend: "chromium", ssrfProxy: false });
304
+ await d.connect();
305
+ const launch = parseLaunch(rec[0]);
306
+ expect(launch["proxy"]).toBeUndefined();
307
+ await d.disconnect();
308
+ });
309
+
310
+ test("an operator-supplied playwrightOptions.proxy wins (ours is skipped)", async () => {
311
+ const rec: Rec = [];
312
+ installPlaywright(rec);
313
+ const d = createDriver({
314
+ backend: "chromium",
315
+ playwrightOptions: { proxy: { server: "http://corp-egress:3128" } },
316
+ });
317
+ await d.connect();
318
+ const launch = parseLaunch(rec[0]);
319
+ expect(launch["proxy"]).toEqual({ server: "http://corp-egress:3128" });
320
+ await d.disconnect();
321
+ });
322
+
323
+ test("launch failure closes the proxy instead of leaking it", async () => {
324
+ const rec: Rec = [];
325
+ const fake = installPlaywright(rec);
326
+ (fake.chromium as { launch: (o: unknown) => Promise<unknown> }).launch = async (
327
+ launchOpts: unknown,
328
+ ) => {
329
+ rec.push(`launch ${JSON.stringify(launchOpts)}`);
330
+ throw new Error("chromium exploded");
331
+ };
332
+ const d = createDriver({ backend: "chromium" });
333
+ await expect(d.connect()).rejects.toThrow("chromium exploded");
334
+ const launch = parseLaunch(rec[0]);
335
+ const url = new URL((launch["proxy"] as { server: string }).server);
336
+ expect(await portRefuses(Number.parseInt(url.port, 10))).toBe(true);
337
+ });
338
+ });
339
+
340
+ describe("chromium driver — disconnect", () => {
341
+ test("disconnect closes the browser and resets state", async () => {
342
+ const rec: Rec = [];
343
+ installPlaywright(rec);
344
+ const d = createDriver({ backend: "chromium" });
345
+ await d.connect();
346
+ rec.length = 0;
347
+ await d.disconnect();
348
+ expect(rec).toEqual(["close"]);
349
+ // After disconnect, operations once again report "not connected".
350
+ await expect(d.screenshot()).rejects.toThrow(/not connected/);
351
+ });
352
+
353
+ test("disconnect before connect is a no-op", async () => {
354
+ const rec: Rec = [];
355
+ installPlaywright(rec);
356
+ const d = createDriver({ backend: "chromium" });
357
+ await d.disconnect();
358
+ expect(rec).toEqual([]);
359
+ });
360
+
361
+ test("disconnect swallows browser.close() failures (best-effort)", async () => {
362
+ const rec: Rec = [];
363
+ const fake = installPlaywright(rec);
364
+ (fake.__browser as { close: () => Promise<void> }).close = async () => {
365
+ rec.push("close-throw");
366
+ throw new Error("close failed");
367
+ };
368
+ const d = createDriver({ backend: "chromium" });
369
+ await d.connect();
370
+ rec.length = 0;
371
+ await d.disconnect(); // must not reject
372
+ expect(rec).toEqual(["close-throw"]);
373
+ // State is still reset despite the close failure.
374
+ await expect(d.click(0, 0)).rejects.toThrow(/not connected/);
375
+ });
376
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+
3
+ export class ComputerUseDriverError extends CrewhausError {
4
+ override readonly name = "ComputerUseDriverError";
5
+ constructor(message: string, cause?: unknown) {
6
+ super("tool", message, cause);
7
+ }
8
+ }
package/src/index.test.ts CHANGED
@@ -87,3 +87,55 @@ describe("createDriver", () => {
87
87
  await expect(d.click(0, 0)).rejects.toThrow(/not connected/);
88
88
  });
89
89
  });
90
+
91
+ describe("host backend stub (createDriver → host)", () => {
92
+ const d = createDriver({ backend: "host" });
93
+
94
+ test("connect rejects with the kickoff-forbidden diagnostic", async () => {
95
+ await expect(d.connect()).rejects.toThrow(/host backend is not implemented in v0/);
96
+ });
97
+
98
+ test("every operation rejects as not implemented", async () => {
99
+ await expect(d.goto("https://x.com")).rejects.toThrow(/host backend not implemented/);
100
+ await expect(d.screenshot()).rejects.toThrow(/host backend not implemented/);
101
+ await expect(d.click(1, 2)).rejects.toThrow(/host backend not implemented/);
102
+ await expect(d.type("x")).rejects.toThrow(/host backend not implemented/);
103
+ await expect(d.key("Enter")).rejects.toThrow(/host backend not implemented/);
104
+ await expect(d.scroll(0, 0)).rejects.toThrow(/host backend not implemented/);
105
+ await expect(d.getViewport()).rejects.toThrow(/host backend not implemented/);
106
+ });
107
+
108
+ test("disconnect is a silent no-op", async () => {
109
+ await expect(d.disconnect()).resolves.toBeUndefined();
110
+ });
111
+
112
+ test("operation rejections are ComputerUseDriverError instances", async () => {
113
+ await expect(d.click(0, 0)).rejects.toBeInstanceOf(ComputerUseDriverError);
114
+ });
115
+ });
116
+
117
+ describe("remote backend stub (createDriver → remote)", () => {
118
+ const d = createDriver({ backend: "remote" });
119
+
120
+ test("connect rejects (v0 stub)", async () => {
121
+ await expect(d.connect()).rejects.toThrow(/remote backend.*not implemented in v0/);
122
+ });
123
+
124
+ test("every operation rejects as not implemented", async () => {
125
+ await expect(d.goto("https://x.com")).rejects.toThrow(/remote backend not implemented/);
126
+ await expect(d.screenshot()).rejects.toThrow(/remote backend not implemented/);
127
+ await expect(d.click(1, 2)).rejects.toThrow(/remote backend not implemented/);
128
+ await expect(d.type("x")).rejects.toThrow(/remote backend not implemented/);
129
+ await expect(d.key("Enter")).rejects.toThrow(/remote backend not implemented/);
130
+ await expect(d.scroll(0, 0)).rejects.toThrow(/remote backend not implemented/);
131
+ await expect(d.getViewport()).rejects.toThrow(/remote backend not implemented/);
132
+ });
133
+
134
+ test("disconnect is a silent no-op", async () => {
135
+ await expect(d.disconnect()).resolves.toBeUndefined();
136
+ });
137
+
138
+ test("operation rejections are ComputerUseDriverError instances", async () => {
139
+ await expect(d.type("x")).rejects.toBeInstanceOf(ComputerUseDriverError);
140
+ });
141
+ });
package/src/index.ts CHANGED
@@ -24,14 +24,16 @@
24
24
  * backend in the smoke ("never use `host` backend for the smoke since
25
25
  * it would drive the dev's actual desktop").
26
26
  */
27
- import { CrewhausError } from "@crewhaus/errors";
27
+ import { ComputerUseDriverError } from "./errors";
28
+ import { type SsrfPinningProxy, startSsrfPinningProxy } from "./ssrf-proxy";
28
29
 
29
- export class ComputerUseDriverError extends CrewhausError {
30
- override readonly name = "ComputerUseDriverError";
31
- constructor(message: string, cause?: unknown) {
32
- super("tool", message, cause);
33
- }
34
- }
30
+ export { ComputerUseDriverError };
31
+ export {
32
+ type DnsLookupFn,
33
+ type SsrfPinningProxy,
34
+ type StartSsrfPinningProxyOptions,
35
+ startSsrfPinningProxy,
36
+ } from "./ssrf-proxy";
35
37
 
36
38
  export type DriverBackend = "host" | "chromium" | "remote";
37
39
  export type MouseButton = "left" | "right" | "middle";
@@ -64,10 +66,31 @@ export type CreateDriverOptions = {
64
66
  readonly backend: DriverBackend;
65
67
  /** Playwright launch overrides (e.g. headless: false). chromium-only. */
66
68
  readonly playwrightOptions?: Record<string, unknown>;
69
+ /**
70
+ * SECURITY (audit follow-up R1) — route ALL chromium traffic through a
71
+ * local DNS-pinning forward proxy (see `ssrf-proxy.ts`). The proxy resolves
72
+ * each hostname once, validates the IP against the private/loopback/
73
+ * link-local/metadata floor, and dials that exact pinned IP, closing the
74
+ * DNS-rebinding TOCTOU the pre-goto guard in tool-navigate cannot close
75
+ * (the browser re-resolves at connect time, and sub-resource fetches never
76
+ * pass the guard at all). Default true. Set `false` ONLY when the browser
77
+ * must reach private/internal targets (e.g. testing an intranet app) AND
78
+ * the page content is trusted. Ignored when `playwrightOptions.proxy` is
79
+ * set — an operator-supplied proxy wins, and is then responsible for its
80
+ * own egress policy. chromium-only.
81
+ */
82
+ readonly ssrfProxy?: boolean;
67
83
  /** Initial viewport — chromium-only. Default 1280x720@1. */
68
84
  readonly viewport?: { width: number; height: number };
69
85
  /** Test injection: a pre-built Driver instance the factory returns verbatim. */
70
86
  readonly _injected?: Driver;
87
+ /**
88
+ * Test injection: replaces the chromium backend's dynamic
89
+ * `import("playwright")`. Lets the import-failure path be exercised without
90
+ * `mock.module` — a throwing module mock cannot be registered once
91
+ * playwright has been imported anywhere in the test process. chromium-only.
92
+ */
93
+ readonly _importPlaywright?: () => Promise<unknown>;
71
94
  };
72
95
 
73
96
  export function createDriver(opts: CreateDriverOptions): Driver {
@@ -104,6 +127,7 @@ function createChromiumDriver(opts: CreateDriverOptions): Driver {
104
127
  let context: unknown;
105
128
  let page: unknown;
106
129
  let connected = false;
130
+ let ssrfProxy: SsrfPinningProxy | undefined;
107
131
 
108
132
  async function loadPlaywright(): Promise<{
109
133
  chromium: { launch: (...args: unknown[]) => unknown };
@@ -113,7 +137,10 @@ function createChromiumDriver(opts: CreateDriverOptions): Driver {
113
137
  // The import is dynamic via a string indirection so `tsc` doesn't fail
114
138
  // when playwright (an optional peer dep) isn't installed in CI.
115
139
  const playwrightModule = "playwright";
116
- const mod = await import(playwrightModule);
140
+ const mod =
141
+ opts._importPlaywright !== undefined
142
+ ? await opts._importPlaywright()
143
+ : await import(playwrightModule);
117
144
  return mod as unknown as { chromium: { launch: (...args: unknown[]) => unknown } };
118
145
  } catch (err) {
119
146
  throw new ComputerUseDriverError(
@@ -128,20 +155,49 @@ function createChromiumDriver(opts: CreateDriverOptions): Driver {
128
155
  async connect() {
129
156
  if (connected) return;
130
157
  const { chromium } = await loadPlaywright();
131
- // Playwright defaults: chromium runs in its own sandbox (its own
132
- // sandboxed renderer process). Headless by default.
133
- const launchOpts: Record<string, unknown> = {
134
- headless: true,
135
- ...(opts.playwrightOptions ?? {}),
136
- };
137
- browser = await (chromium.launch as (o: unknown) => Promise<unknown>)(launchOpts);
138
- const ctxOpts: Record<string, unknown> = {
139
- viewport: opts.viewport ?? { width: 1280, height: 720 },
140
- };
141
- context = await (browser as { newContext: (o: unknown) => Promise<unknown> }).newContext(
142
- ctxOpts,
143
- );
144
- page = await (context as { newPage: () => Promise<unknown> }).newPage();
158
+ const userOpts = opts.playwrightOptions ?? {};
159
+ // SECURITY DNS-pinning proxy on by default (see CreateDriverOptions.
160
+ // ssrfProxy). An operator-supplied playwrightOptions.proxy wins; the
161
+ // proxy starts only after the playwright import succeeds so the
162
+ // import-failure path can't leak a listening server.
163
+ const useSsrfProxy = opts.ssrfProxy !== false && userOpts["proxy"] === undefined;
164
+ if (useSsrfProxy) {
165
+ ssrfProxy = await startSsrfPinningProxy();
166
+ }
167
+ try {
168
+ // Playwright defaults: chromium runs in its own sandbox (its own
169
+ // sandboxed renderer process). Headless by default. The
170
+ // `<-loopback>` bypass rule removes Chromium's implicit proxy bypass
171
+ // for localhost targets, so loopback requests also route through the
172
+ // pinning proxy — and get blocked there.
173
+ const launchOpts: Record<string, unknown> = {
174
+ headless: true,
175
+ ...userOpts,
176
+ ...(ssrfProxy !== undefined
177
+ ? { proxy: { server: ssrfProxy.url, bypass: "<-loopback>" } }
178
+ : {}),
179
+ };
180
+ browser = await (chromium.launch as (o: unknown) => Promise<unknown>)(launchOpts);
181
+ const ctxOpts: Record<string, unknown> = {
182
+ viewport: opts.viewport ?? { width: 1280, height: 720 },
183
+ };
184
+ context = await (browser as { newContext: (o: unknown) => Promise<unknown> }).newContext(
185
+ ctxOpts,
186
+ );
187
+ page = await (context as { newPage: () => Promise<unknown> }).newPage();
188
+ } catch (err) {
189
+ // Launch/context failure must not leak the proxy server (or a
190
+ // half-launched browser).
191
+ try {
192
+ await (browser as { close?: () => Promise<void> } | undefined)?.close?.();
193
+ } catch {
194
+ // best-effort
195
+ }
196
+ browser = undefined;
197
+ await ssrfProxy?.close();
198
+ ssrfProxy = undefined;
199
+ throw err;
200
+ }
145
201
  connected = true;
146
202
  },
147
203
 
@@ -210,6 +266,8 @@ function createChromiumDriver(opts: CreateDriverOptions): Driver {
210
266
  } catch {
211
267
  // best-effort
212
268
  }
269
+ await ssrfProxy?.close();
270
+ ssrfProxy = undefined;
213
271
  connected = false;
214
272
  browser = undefined;
215
273
  context = undefined;