@crewhaus/computer-use-driver 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts DELETED
@@ -1,347 +0,0 @@
1
- /**
2
- * Catalog R18 `computer-use-driver` — Section 25 BROW.
3
- *
4
- * Cross-platform driver for mouse/keyboard/screenshot operations. The
5
- * `Driver` interface exposes:
6
- * - `screenshot()` → PNG `Uint8Array`
7
- * - `click(x, y, button?)`
8
- * - `type(text)`
9
- * - `key(combo)` // "Enter", "Tab", "Control+a"
10
- * - `scroll(dx, dy)`
11
- * - `getViewport()` → { width, height, devicePixelRatio }
12
- * - `goto(url)` // browser-only; `host` backend rejects
13
- *
14
- * v0 ships ONE concrete backend — `chromium` — driven by Playwright's
15
- * bundled headless chromium. The kickoff names this `docker-chromium`
16
- * because it's intended to run inside a Section-18 sandbox; in v0 we
17
- * lean on Playwright's own chromium-sandboxing (the chromium binary
18
- * runs as a sandboxed renderer process) and defer the explicit Docker
19
- * wrapping to a follow-up.
20
- *
21
- * The `host` backend (drives the developer's actual desktop) and
22
- * `remote` backend (CDP-style remote control) are interface stubs that
23
- * throw at `connect()` — see the kickoff explicitly forbidding the host
24
- * backend in the smoke ("never use `host` backend for the smoke since
25
- * it would drive the dev's actual desktop").
26
- */
27
- import { ComputerUseDriverError } from "./errors";
28
- import { type SsrfPinningProxy, startSsrfPinningProxy } from "./ssrf-proxy";
29
-
30
- export { ComputerUseDriverError };
31
- export {
32
- type DnsLookupFn,
33
- type SsrfPinningProxy,
34
- type StartSsrfPinningProxyOptions,
35
- startSsrfPinningProxy,
36
- } from "./ssrf-proxy";
37
-
38
- export type DriverBackend = "host" | "chromium" | "remote";
39
- export type MouseButton = "left" | "right" | "middle";
40
-
41
- export type Viewport = {
42
- readonly width: number;
43
- readonly height: number;
44
- readonly devicePixelRatio: number;
45
- };
46
-
47
- export interface Driver {
48
- readonly backend: DriverBackend;
49
- /** Open / connect / launch chromium. Idempotent. */
50
- connect(): Promise<void>;
51
- /** Navigate the chromium tab. Throws on `host` backend. */
52
- goto(url: string): Promise<void>;
53
- /** Capture a PNG screenshot. */
54
- screenshot(): Promise<Uint8Array>;
55
- click(x: number, y: number, button?: MouseButton): Promise<void>;
56
- type(text: string): Promise<void>;
57
- key(combo: string): Promise<void>;
58
- scroll(dx: number, dy: number): Promise<void>;
59
- getViewport(): Promise<Viewport>;
60
- /** Best-effort DOM snapshot. Used by tests + the post-action assertions. */
61
- domText?(): Promise<string>;
62
- disconnect(): Promise<void>;
63
- }
64
-
65
- export type CreateDriverOptions = {
66
- readonly backend: DriverBackend;
67
- /** Playwright launch overrides (e.g. headless: false). chromium-only. */
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;
83
- /** Initial viewport — chromium-only. Default 1280x720@1. */
84
- readonly viewport?: { width: number; height: number };
85
- /** Test injection: a pre-built Driver instance the factory returns verbatim. */
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>;
94
- };
95
-
96
- export function createDriver(opts: CreateDriverOptions): Driver {
97
- if (opts._injected !== undefined) return opts._injected;
98
- if (opts.backend === "chromium") return createChromiumDriver(opts);
99
- if (opts.backend === "host") {
100
- // Section 30 — host backend stays gated. Callers that have wired
101
- // a real executor use `createHostDriver` directly.
102
- return createHostDriverStub();
103
- }
104
- if (opts.backend === "remote") {
105
- // Same pattern: callers with a puppeteer-core import use
106
- // `createRemoteDriver` directly.
107
- return createRemoteDriverStub();
108
- }
109
- throw new ComputerUseDriverError(`unknown backend: ${opts.backend}`);
110
- }
111
-
112
- // Section 30 — direct exports for callers that wire backends with an
113
- // executor or puppeteer-core import.
114
- export { createHostDriver, type HostBackendOptions, type HostExecutor } from "./backends/host";
115
- export {
116
- createRemoteDriver,
117
- type PuppeteerCoreLike,
118
- type RemoteBackendOptions,
119
- } from "./backends/remote";
120
-
121
- // ---------------------------------------------------------------------------
122
- // Playwright-backed chromium driver.
123
- // ---------------------------------------------------------------------------
124
-
125
- function createChromiumDriver(opts: CreateDriverOptions): Driver {
126
- let browser: unknown;
127
- let context: unknown;
128
- let page: unknown;
129
- let connected = false;
130
- let ssrfProxy: SsrfPinningProxy | undefined;
131
-
132
- async function loadPlaywright(): Promise<{
133
- chromium: { launch: (...args: unknown[]) => unknown };
134
- }> {
135
- try {
136
- // Lazy import so non-browser users don't need Playwright in their tree.
137
- // The import is dynamic via a string indirection so `tsc` doesn't fail
138
- // when playwright (an optional peer dep) isn't installed in CI.
139
- const playwrightModule = "playwright";
140
- const mod =
141
- opts._importPlaywright !== undefined
142
- ? await opts._importPlaywright()
143
- : await import(playwrightModule);
144
- return mod as unknown as { chromium: { launch: (...args: unknown[]) => unknown } };
145
- } catch (err) {
146
- throw new ComputerUseDriverError(
147
- "Playwright not installed. Run `bun add -D playwright` and `bunx playwright install chromium` to use the chromium backend.",
148
- err,
149
- );
150
- }
151
- }
152
-
153
- return {
154
- backend: "chromium",
155
- async connect() {
156
- if (connected) return;
157
- const { chromium } = await loadPlaywright();
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
- }
201
- connected = true;
202
- },
203
-
204
- async goto(url) {
205
- if (!connected)
206
- throw new ComputerUseDriverError("driver not connected — call connect() first");
207
- await (page as { goto: (u: string, o?: unknown) => Promise<unknown> }).goto(url, {
208
- waitUntil: "domcontentloaded",
209
- });
210
- },
211
-
212
- async screenshot() {
213
- if (!connected) throw new ComputerUseDriverError("driver not connected");
214
- const buf = (await (page as { screenshot: (o?: unknown) => Promise<unknown> }).screenshot({
215
- type: "png",
216
- })) as Uint8Array;
217
- return buf;
218
- },
219
-
220
- async click(x, y, button = "left") {
221
- if (!connected) throw new ComputerUseDriverError("driver not connected");
222
- await (
223
- page as { mouse: { click: (x: number, y: number, o?: unknown) => Promise<void> } }
224
- ).mouse.click(x, y, { button });
225
- },
226
-
227
- async type(text) {
228
- if (!connected) throw new ComputerUseDriverError("driver not connected");
229
- await (page as { keyboard: { type: (t: string) => Promise<void> } }).keyboard.type(text);
230
- },
231
-
232
- async key(combo) {
233
- if (!connected) throw new ComputerUseDriverError("driver not connected");
234
- await (page as { keyboard: { press: (k: string) => Promise<void> } }).keyboard.press(combo);
235
- },
236
-
237
- async scroll(dx, dy) {
238
- if (!connected) throw new ComputerUseDriverError("driver not connected");
239
- await (page as { mouse: { wheel: (dx: number, dy: number) => Promise<void> } }).mouse.wheel(
240
- dx,
241
- dy,
242
- );
243
- },
244
-
245
- async getViewport() {
246
- if (!connected) throw new ComputerUseDriverError("driver not connected");
247
- const vp = (
248
- page as { viewportSize: () => { width: number; height: number } | null }
249
- ).viewportSize();
250
- const w = vp?.width ?? opts.viewport?.width ?? 1280;
251
- const h = vp?.height ?? opts.viewport?.height ?? 720;
252
- return { width: w, height: h, devicePixelRatio: 1 };
253
- },
254
-
255
- async domText() {
256
- if (!connected) throw new ComputerUseDriverError("driver not connected");
257
- return (page as { textContent: (sel: string) => Promise<string | null> })
258
- .textContent("body")
259
- .then((t) => t ?? "");
260
- },
261
-
262
- async disconnect() {
263
- if (!connected) return;
264
- try {
265
- await (browser as { close: () => Promise<void> }).close();
266
- } catch {
267
- // best-effort
268
- }
269
- await ssrfProxy?.close();
270
- ssrfProxy = undefined;
271
- connected = false;
272
- browser = undefined;
273
- context = undefined;
274
- page = undefined;
275
- },
276
- };
277
- }
278
-
279
- // ---------------------------------------------------------------------------
280
- // Stubs for host + remote backends.
281
- // ---------------------------------------------------------------------------
282
-
283
- function createHostDriverStub(): Driver {
284
- return {
285
- backend: "host",
286
- async connect() {
287
- throw new ComputerUseDriverError(
288
- "host backend is not implemented in v0. The kickoff explicitly forbids it for smokes (would drive the dev's actual desktop). Native macOS / Linux / Windows backends land in a follow-up gated on `CREWHAUS_BROW_HOST_SMOKE=1`.",
289
- );
290
- },
291
- async goto() {
292
- throw new ComputerUseDriverError("host backend not implemented");
293
- },
294
- async screenshot() {
295
- throw new ComputerUseDriverError("host backend not implemented");
296
- },
297
- async click() {
298
- throw new ComputerUseDriverError("host backend not implemented");
299
- },
300
- async type() {
301
- throw new ComputerUseDriverError("host backend not implemented");
302
- },
303
- async key() {
304
- throw new ComputerUseDriverError("host backend not implemented");
305
- },
306
- async scroll() {
307
- throw new ComputerUseDriverError("host backend not implemented");
308
- },
309
- async getViewport() {
310
- throw new ComputerUseDriverError("host backend not implemented");
311
- },
312
- async disconnect() {},
313
- };
314
- }
315
-
316
- function createRemoteDriverStub(): Driver {
317
- return {
318
- backend: "remote",
319
- async connect() {
320
- throw new ComputerUseDriverError(
321
- "remote backend (CDP / Browserless) is not implemented in v0",
322
- );
323
- },
324
- async goto() {
325
- throw new ComputerUseDriverError("remote backend not implemented");
326
- },
327
- async screenshot() {
328
- throw new ComputerUseDriverError("remote backend not implemented");
329
- },
330
- async click() {
331
- throw new ComputerUseDriverError("remote backend not implemented");
332
- },
333
- async type() {
334
- throw new ComputerUseDriverError("remote backend not implemented");
335
- },
336
- async key() {
337
- throw new ComputerUseDriverError("remote backend not implemented");
338
- },
339
- async scroll() {
340
- throw new ComputerUseDriverError("remote backend not implemented");
341
- },
342
- async getViewport() {
343
- throw new ComputerUseDriverError("remote backend not implemented");
344
- },
345
- async disconnect() {},
346
- };
347
- }
@@ -1,259 +0,0 @@
1
- /**
2
- * SECURITY (audit follow-up R1) — DNS-pinning proxy contract tests.
3
- *
4
- * Clients are raw `node:net` sockets writing literal HTTP — the most faithful
5
- * stand-in for a browser talking to a forward proxy, and immune to any HTTP
6
- * client library rewriting the request target. DNS + the blocked-IP predicate
7
- * are injected so no test depends on real resolution or real public hosts:
8
- * the allow-path tests stub the predicate open and point a fake public name
9
- * at a local target server; the deny-path tests use the REAL predicate.
10
- */
11
- import { afterEach, describe, expect, test } from "bun:test";
12
- import * as http from "node:http";
13
- import * as net from "node:net";
14
- import {
15
- type SsrfPinningProxy,
16
- isPrivateIp,
17
- normalizeIpv4,
18
- startSsrfPinningProxy,
19
- } from "./ssrf-proxy";
20
-
21
- const cleanups: Array<() => Promise<void> | void> = [];
22
- afterEach(async () => {
23
- while (cleanups.length > 0) {
24
- await cleanups.pop()?.();
25
- }
26
- });
27
-
28
- function track(proxy: SsrfPinningProxy): SsrfPinningProxy {
29
- cleanups.push(() => proxy.close());
30
- return proxy;
31
- }
32
-
33
- /** Write `request` to the proxy and return everything received until close. */
34
- function rawHttp(port: number, request: string): Promise<string> {
35
- return new Promise((resolve, reject) => {
36
- const sock = net.connect({ host: "127.0.0.1", port }, () => {
37
- sock.write(request);
38
- });
39
- let buf = "";
40
- sock.on("data", (d) => {
41
- buf += d.toString();
42
- });
43
- sock.on("close", () => resolve(buf));
44
- sock.on("error", reject);
45
- });
46
- }
47
-
48
- /** Local HTTP target that records the request line + Host header. */
49
- async function startTarget(): Promise<{
50
- port: number;
51
- seen: Array<{ url: string; host: string | undefined }>;
52
- }> {
53
- const seen: Array<{ url: string; host: string | undefined }> = [];
54
- const server = http.createServer((req, res) => {
55
- seen.push({ url: req.url ?? "", host: req.headers.host });
56
- res.writeHead(200, { "content-type": "text/plain" });
57
- res.end("target-says-hello");
58
- });
59
- await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
60
- cleanups.push(
61
- () =>
62
- new Promise<void>((r) => {
63
- server.closeAllConnections();
64
- server.close(() => r());
65
- }),
66
- );
67
- const addr = server.address();
68
- if (addr === null || typeof addr === "string") throw new Error("no port");
69
- return { port: addr.port, seen };
70
- }
71
-
72
- describe("IP validators (duplicated from tool-fetch — keep in sync)", () => {
73
- test.each([
74
- ["127.0.0.1", true],
75
- ["10.1.2.3", true],
76
- ["169.254.169.254", true],
77
- ["192.168.0.1", true],
78
- ["100.64.0.1", true],
79
- ["::1", true],
80
- ["fe80::1", true],
81
- ["fd00::1", true],
82
- ["::ffff:127.0.0.1", true],
83
- ["::ffff:7f00:1", true],
84
- ["8.8.8.8", false],
85
- ["93.184.216.34", false],
86
- ["2606:4700::1111", false],
87
- ])("isPrivateIp(%p) === %p", (ip, want) => {
88
- expect(isPrivateIp(ip)).toBe(want);
89
- });
90
-
91
- test.each([
92
- ["0x7f000001", "127.0.0.1"],
93
- ["2130706433", "127.0.0.1"],
94
- ["0177.0.0.1", "127.0.0.1"],
95
- ["127.1", "127.0.0.1"],
96
- ["not-an-ip", null],
97
- ])("normalizeIpv4(%p) === %p", (raw, want) => {
98
- expect(normalizeIpv4(raw)).toBe(want);
99
- });
100
- });
101
-
102
- describe("plain-HTTP forwarding (absolute-form)", () => {
103
- test("forwards to the PINNED ip, preserving the Host header for vhosts", async () => {
104
- const target = await startTarget();
105
- let lookups = 0;
106
- const proxy = track(
107
- await startSsrfPinningProxy({
108
- _lookup: async () => {
109
- lookups += 1;
110
- return { address: "127.0.0.1", family: 4 };
111
- },
112
- _isIpBlocked: () => false,
113
- }),
114
- );
115
- const res = await rawHttp(
116
- proxy.port,
117
- `GET http://fake-public.example:${target.port}/x?q=1 HTTP/1.1\r\nHost: fake-public.example:${target.port}\r\nConnection: close\r\n\r\n`,
118
- );
119
- expect(res).toContain("200");
120
- expect(res).toContain("target-says-hello");
121
- // The socket went to the pinned 127.0.0.1; the Host header kept the name.
122
- expect(target.seen).toEqual([{ url: "/x?q=1", host: `fake-public.example:${target.port}` }]);
123
- // Pinning = exactly one resolution per connection. A second lookup would
124
- // be the rebinding window this proxy exists to remove.
125
- expect(lookups).toBe(1);
126
- });
127
-
128
- test("origin-form request targets are rejected (not an origin server)", async () => {
129
- const proxy = track(await startSsrfPinningProxy());
130
- const res = await rawHttp(
131
- proxy.port,
132
- "GET /just-a-path HTTP/1.1\r\nHost: whatever\r\nConnection: close\r\n\r\n",
133
- );
134
- expect(res).toContain("400");
135
- });
136
-
137
- test("blocks localhost names without resolving", async () => {
138
- let lookups = 0;
139
- const proxy = track(
140
- await startSsrfPinningProxy({
141
- _lookup: async () => {
142
- lookups += 1;
143
- return { address: "8.8.8.8", family: 4 };
144
- },
145
- }),
146
- );
147
- const res = await rawHttp(
148
- proxy.port,
149
- "GET http://localhost:9999/ HTTP/1.1\r\nHost: localhost:9999\r\nConnection: close\r\n\r\n",
150
- );
151
- expect(res).toContain("403");
152
- expect(lookups).toBe(0);
153
- });
154
-
155
- test("blocks private IP literals, including obfuscated forms", async () => {
156
- const proxy = track(await startSsrfPinningProxy());
157
- for (const host of ["127.0.0.1", "0x7f000001", "169.254.169.254", "[::1]"]) {
158
- const res = await rawHttp(
159
- proxy.port,
160
- `GET http://${host}/ HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`,
161
- );
162
- expect(res).toContain("403");
163
- }
164
- });
165
-
166
- test("blocks a public name that RESOLVES to a private IP (rebinding shape)", async () => {
167
- const proxy = track(
168
- await startSsrfPinningProxy({
169
- _lookup: async () => ({ address: "169.254.169.254", family: 4 }),
170
- }),
171
- );
172
- const res = await rawHttp(
173
- proxy.port,
174
- "GET http://innocent-looking.example/ HTTP/1.1\r\nHost: innocent-looking.example\r\nConnection: close\r\n\r\n",
175
- );
176
- expect(res).toContain("403");
177
- expect(res).toContain("private IP 169.254.169.254");
178
- });
179
- });
180
-
181
- describe("CONNECT tunneling", () => {
182
- test("tunnels to the PINNED ip and pipes both directions", async () => {
183
- // TCP echo target.
184
- const echo = net.createServer((s) => s.pipe(s));
185
- await new Promise<void>((r) => echo.listen(0, "127.0.0.1", () => r()));
186
- cleanups.push(() => new Promise<void>((r) => echo.close(() => r())));
187
- const echoAddr = echo.address();
188
- if (echoAddr === null || typeof echoAddr === "string") throw new Error("no port");
189
-
190
- let lookups = 0;
191
- const proxy = track(
192
- await startSsrfPinningProxy({
193
- _lookup: async () => {
194
- lookups += 1;
195
- return { address: "127.0.0.1", family: 4 };
196
- },
197
- _isIpBlocked: () => false,
198
- }),
199
- );
200
-
201
- const result = await new Promise<string>((resolve, reject) => {
202
- const sock = net.connect({ host: "127.0.0.1", port: proxy.port }, () => {
203
- sock.write(
204
- `CONNECT fake-tls.example:${echoAddr.port} HTTP/1.1\r\nHost: fake-tls.example:${echoAddr.port}\r\n\r\n`,
205
- );
206
- });
207
- let buf = "";
208
- let established = false;
209
- sock.on("data", (d) => {
210
- buf += d.toString();
211
- if (!established && buf.includes("\r\n\r\n")) {
212
- if (!buf.startsWith("HTTP/1.1 200")) {
213
- reject(new Error(`tunnel refused: ${buf.split("\r\n")[0]}`));
214
- return;
215
- }
216
- established = true;
217
- buf = "";
218
- sock.write("opaque-tls-bytes");
219
- } else if (established && buf.includes("opaque-tls-bytes")) {
220
- sock.end();
221
- resolve(buf);
222
- }
223
- });
224
- sock.on("error", reject);
225
- });
226
- expect(result).toBe("opaque-tls-bytes");
227
- expect(lookups).toBe(1);
228
- });
229
-
230
- test("refuses CONNECT to private targets with 403", async () => {
231
- const proxy = track(await startSsrfPinningProxy());
232
- const res = await rawHttp(
233
- proxy.port,
234
- "CONNECT 127.0.0.1:443 HTTP/1.1\r\nHost: 127.0.0.1:443\r\n\r\n",
235
- );
236
- expect(res).toContain("403");
237
- });
238
-
239
- test("refuses malformed CONNECT targets", async () => {
240
- const proxy = track(await startSsrfPinningProxy());
241
- const res = await rawHttp(proxy.port, "CONNECT garbage HTTP/1.1\r\nHost: garbage\r\n\r\n");
242
- expect(res).toContain("403");
243
- });
244
- });
245
-
246
- describe("lifecycle", () => {
247
- test("close() actually stops accepting connections", async () => {
248
- const proxy = await startSsrfPinningProxy();
249
- await proxy.close();
250
- await expect(
251
- new Promise((resolve, reject) => {
252
- const sock = net.connect({ host: "127.0.0.1", port: proxy.port }, () =>
253
- resolve("connected"),
254
- );
255
- sock.on("error", reject);
256
- }),
257
- ).rejects.toThrow();
258
- });
259
- });