@crewhaus/computer-use-driver 0.1.3 → 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/dist/backends/host.d.ts +43 -0
- package/dist/backends/host.js +75 -0
- package/dist/backends/remote.d.ts +51 -0
- package/dist/backends/remote.js +85 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +7 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +257 -0
- package/dist/ssrf-proxy.d.ts +26 -0
- package/dist/ssrf-proxy.js +341 -0
- package/package.json +9 -6
- package/src/backends/backends.test.ts +0 -396
- package/src/backends/host.ts +0 -96
- package/src/backends/remote.ts +0 -127
- package/src/chromium-import-fail.test.ts +0 -39
- package/src/chromium.test.ts +0 -376
- package/src/errors.ts +0 -8
- package/src/index.test.ts +0 -141
- package/src/index.ts +0 -347
- package/src/ssrf-proxy.test.ts +0 -259
- package/src/ssrf-proxy.ts +0 -376
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 30 — host backend for `computer-use-driver`. Drives the
|
|
3
|
+
* dev's actual desktop via the OS-level computer-use surface (macOS
|
|
4
|
+
* Quartz / Linux X11+xdotool / Windows SendInput). Production
|
|
5
|
+
* deployments use this for cross-app workflows that no browser can
|
|
6
|
+
* reach (Maps, Notes, Photos, Finder, native System Settings).
|
|
7
|
+
*
|
|
8
|
+
* **Safety gate:** the constructor refuses to return a working driver
|
|
9
|
+
* unless `CREWHAUS_BROW_HOST_ENABLED=1` is set in the environment. The
|
|
10
|
+
* goal is fail-loud ergonomics: deployment errors that try to use the
|
|
11
|
+
* host backend without explicit opt-in should immediately surface
|
|
12
|
+
* rather than silently driving the dev's machine.
|
|
13
|
+
*
|
|
14
|
+
* The OS-level command dispatch is delegated to a caller-supplied
|
|
15
|
+
* `HostExecutor` so tests + the smoke harness can stub the surface.
|
|
16
|
+
* In production, codegen wires a thin wrapper that shells out to
|
|
17
|
+
* `xdotool` (Linux), `cliclick` (macOS), or PowerShell (Windows).
|
|
18
|
+
*/
|
|
19
|
+
import { type Driver, type Viewport } from "../index";
|
|
20
|
+
export type HostExecutor = {
|
|
21
|
+
/** Click at absolute screen coordinates. Button: "left" | "right" | "middle". */
|
|
22
|
+
click(x: number, y: number, button: string): Promise<void>;
|
|
23
|
+
/** Type a string at the current focus. */
|
|
24
|
+
type(text: string): Promise<void>;
|
|
25
|
+
/** Press a single key (or modifier+key, e.g. "cmd+space"). */
|
|
26
|
+
key(combo: string): Promise<void>;
|
|
27
|
+
/** Scroll at absolute screen coordinates. */
|
|
28
|
+
scroll(x: number, y: number, deltaX: number, deltaY: number): Promise<void>;
|
|
29
|
+
/** Capture a PNG screenshot of the entire screen, returning base64. */
|
|
30
|
+
screenshot(): Promise<{
|
|
31
|
+
pngBase64: string;
|
|
32
|
+
viewport: Viewport;
|
|
33
|
+
}>;
|
|
34
|
+
/** Disconnect / clean up any handles. */
|
|
35
|
+
close?(): Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
export type HostBackendOptions = {
|
|
38
|
+
/** Required to pass; otherwise the constructor throws. */
|
|
39
|
+
readonly enabled: boolean;
|
|
40
|
+
/** Test injection (or production OS shim). */
|
|
41
|
+
readonly executor: HostExecutor;
|
|
42
|
+
};
|
|
43
|
+
export declare function createHostDriver(opts: HostBackendOptions): Driver;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 30 — host backend for `computer-use-driver`. Drives the
|
|
3
|
+
* dev's actual desktop via the OS-level computer-use surface (macOS
|
|
4
|
+
* Quartz / Linux X11+xdotool / Windows SendInput). Production
|
|
5
|
+
* deployments use this for cross-app workflows that no browser can
|
|
6
|
+
* reach (Maps, Notes, Photos, Finder, native System Settings).
|
|
7
|
+
*
|
|
8
|
+
* **Safety gate:** the constructor refuses to return a working driver
|
|
9
|
+
* unless `CREWHAUS_BROW_HOST_ENABLED=1` is set in the environment. The
|
|
10
|
+
* goal is fail-loud ergonomics: deployment errors that try to use the
|
|
11
|
+
* host backend without explicit opt-in should immediately surface
|
|
12
|
+
* rather than silently driving the dev's machine.
|
|
13
|
+
*
|
|
14
|
+
* The OS-level command dispatch is delegated to a caller-supplied
|
|
15
|
+
* `HostExecutor` so tests + the smoke harness can stub the surface.
|
|
16
|
+
* In production, codegen wires a thin wrapper that shells out to
|
|
17
|
+
* `xdotool` (Linux), `cliclick` (macOS), or PowerShell (Windows).
|
|
18
|
+
*/
|
|
19
|
+
import { ComputerUseDriverError } from "../index";
|
|
20
|
+
export function createHostDriver(opts) {
|
|
21
|
+
if (!opts.enabled) {
|
|
22
|
+
throw new ComputerUseDriverError("host backend disabled. Set CREWHAUS_BROW_HOST_ENABLED=1 to opt in (this drives the actual desktop — never enable in smoke tests).");
|
|
23
|
+
}
|
|
24
|
+
let connected = false;
|
|
25
|
+
return {
|
|
26
|
+
backend: "host",
|
|
27
|
+
async connect() {
|
|
28
|
+
connected = true;
|
|
29
|
+
},
|
|
30
|
+
async goto(_url) {
|
|
31
|
+
// The host backend doesn't navigate URLs — callers should open
|
|
32
|
+
// a browser app first via the OS shell. We surface this as a
|
|
33
|
+
// soft-fail since some workflows want goto to be a no-op.
|
|
34
|
+
throw new ComputerUseDriverError("host backend cannot navigate URLs directly — open a browser via OS shell first");
|
|
35
|
+
},
|
|
36
|
+
async screenshot() {
|
|
37
|
+
if (!connected)
|
|
38
|
+
throw new ComputerUseDriverError("host driver not connected");
|
|
39
|
+
const { pngBase64 } = await opts.executor.screenshot();
|
|
40
|
+
return Buffer.from(pngBase64, "base64");
|
|
41
|
+
},
|
|
42
|
+
async click(x, y, button = "left") {
|
|
43
|
+
if (!connected)
|
|
44
|
+
throw new ComputerUseDriverError("host driver not connected");
|
|
45
|
+
await opts.executor.click(x, y, button);
|
|
46
|
+
},
|
|
47
|
+
async type(text) {
|
|
48
|
+
if (!connected)
|
|
49
|
+
throw new ComputerUseDriverError("host driver not connected");
|
|
50
|
+
await opts.executor.type(text);
|
|
51
|
+
},
|
|
52
|
+
async key(combo) {
|
|
53
|
+
if (!connected)
|
|
54
|
+
throw new ComputerUseDriverError("host driver not connected");
|
|
55
|
+
await opts.executor.key(combo);
|
|
56
|
+
},
|
|
57
|
+
async scroll(dx, dy) {
|
|
58
|
+
if (!connected)
|
|
59
|
+
throw new ComputerUseDriverError("host driver not connected");
|
|
60
|
+
// Driver.scroll has no cursor coords; pass (0, 0) for the executor's
|
|
61
|
+
// anchor and let the host shim scroll at the focused window's origin.
|
|
62
|
+
await opts.executor.scroll(0, 0, dx, dy);
|
|
63
|
+
},
|
|
64
|
+
async getViewport() {
|
|
65
|
+
if (!connected)
|
|
66
|
+
throw new ComputerUseDriverError("host driver not connected");
|
|
67
|
+
const shot = await opts.executor.screenshot();
|
|
68
|
+
return shot.viewport;
|
|
69
|
+
},
|
|
70
|
+
async disconnect() {
|
|
71
|
+
connected = false;
|
|
72
|
+
await opts.executor.close?.();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 30 — remote backend for `computer-use-driver`. Connects to a
|
|
3
|
+
* remote chromium instance over Chrome DevTools Protocol (browserless.io,
|
|
4
|
+
* BrowserBase, self-hosted DevTools). Same `Driver` surface as the
|
|
5
|
+
* Playwright-backed `chromium` backend; the difference is the connection
|
|
6
|
+
* layer.
|
|
7
|
+
*
|
|
8
|
+
* Production deployments install `puppeteer-core` and pass it as
|
|
9
|
+
* `_puppeteer`. Without it, the constructor returns a driver whose
|
|
10
|
+
* `connect()` throws a clear diagnostic — matching the SQS / Vapi
|
|
11
|
+
* pattern of "abstraction always builds; deployment-time SDK presence
|
|
12
|
+
* gates the operation".
|
|
13
|
+
*/
|
|
14
|
+
import { type Driver, type Viewport } from "../index";
|
|
15
|
+
export type RemoteBackendOptions = {
|
|
16
|
+
/** Browserless / BrowserBase WebSocket endpoint. */
|
|
17
|
+
readonly url: string;
|
|
18
|
+
/** Optional viewport override; defaults to 1280×800. */
|
|
19
|
+
readonly viewport?: Viewport;
|
|
20
|
+
/**
|
|
21
|
+
* Test / production injection: `puppeteer-core` `import` namespace.
|
|
22
|
+
* When undefined, the driver throws at `connect()` with a hint to
|
|
23
|
+
* install puppeteer-core.
|
|
24
|
+
*/
|
|
25
|
+
readonly _puppeteer?: PuppeteerCoreLike;
|
|
26
|
+
};
|
|
27
|
+
export type PuppeteerCoreLike = {
|
|
28
|
+
connect(opts: {
|
|
29
|
+
browserWSEndpoint: string;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
newPage(): Promise<{
|
|
32
|
+
goto(url: string): Promise<void>;
|
|
33
|
+
screenshot(opts?: {
|
|
34
|
+
encoding?: "base64";
|
|
35
|
+
}): Promise<string | Buffer>;
|
|
36
|
+
mouse: {
|
|
37
|
+
click(x: number, y: number, opts?: {
|
|
38
|
+
button?: string;
|
|
39
|
+
}): Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
keyboard: {
|
|
42
|
+
type(text: string): Promise<void>;
|
|
43
|
+
press(key: string): Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
evaluate<T>(fn: () => T): Promise<T>;
|
|
46
|
+
close(): Promise<void>;
|
|
47
|
+
}>;
|
|
48
|
+
close(): Promise<void>;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
export declare function createRemoteDriver(opts: RemoteBackendOptions): Driver;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 30 — remote backend for `computer-use-driver`. Connects to a
|
|
3
|
+
* remote chromium instance over Chrome DevTools Protocol (browserless.io,
|
|
4
|
+
* BrowserBase, self-hosted DevTools). Same `Driver` surface as the
|
|
5
|
+
* Playwright-backed `chromium` backend; the difference is the connection
|
|
6
|
+
* layer.
|
|
7
|
+
*
|
|
8
|
+
* Production deployments install `puppeteer-core` and pass it as
|
|
9
|
+
* `_puppeteer`. Without it, the constructor returns a driver whose
|
|
10
|
+
* `connect()` throws a clear diagnostic — matching the SQS / Vapi
|
|
11
|
+
* pattern of "abstraction always builds; deployment-time SDK presence
|
|
12
|
+
* gates the operation".
|
|
13
|
+
*/
|
|
14
|
+
import { ComputerUseDriverError } from "../index";
|
|
15
|
+
export function createRemoteDriver(opts) {
|
|
16
|
+
if (!opts.url)
|
|
17
|
+
throw new ComputerUseDriverError("remote backend requires url");
|
|
18
|
+
let browser;
|
|
19
|
+
let page;
|
|
20
|
+
let connected = false;
|
|
21
|
+
function ensure() {
|
|
22
|
+
if (!page)
|
|
23
|
+
throw new ComputerUseDriverError("remote driver not connected");
|
|
24
|
+
return page;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
backend: "remote",
|
|
28
|
+
async connect() {
|
|
29
|
+
const pup = opts._puppeteer;
|
|
30
|
+
if (!pup) {
|
|
31
|
+
throw new ComputerUseDriverError("remote backend requires `puppeteer-core` to be installed and passed as `_puppeteer`. e.g. `import * as pc from 'puppeteer-core'; createDriver({ backend: 'remote', url, _puppeteer: pc })`.");
|
|
32
|
+
}
|
|
33
|
+
browser = await pup.connect({ browserWSEndpoint: opts.url });
|
|
34
|
+
page = await browser.newPage();
|
|
35
|
+
connected = true;
|
|
36
|
+
},
|
|
37
|
+
async goto(url) {
|
|
38
|
+
await ensure().goto(url);
|
|
39
|
+
},
|
|
40
|
+
async screenshot() {
|
|
41
|
+
const buffer = await ensure().screenshot({ encoding: "base64" });
|
|
42
|
+
const pngBase64 = typeof buffer === "string" ? buffer : buffer.toString("base64");
|
|
43
|
+
return Buffer.from(pngBase64, "base64");
|
|
44
|
+
},
|
|
45
|
+
async click(x, y, button = "left") {
|
|
46
|
+
await ensure().mouse.click(x, y, { button });
|
|
47
|
+
},
|
|
48
|
+
async type(text) {
|
|
49
|
+
await ensure().keyboard.type(text);
|
|
50
|
+
},
|
|
51
|
+
async key(combo) {
|
|
52
|
+
await ensure().keyboard.press(combo);
|
|
53
|
+
},
|
|
54
|
+
async scroll(dx, dy) {
|
|
55
|
+
// Puppeteer doesn't expose mouse.wheel directly; dispatch from the
|
|
56
|
+
// page context where `window` is defined. The eval-with-args shape
|
|
57
|
+
// isn't on the local PuppeteerCoreLike type; cast to puppeteer's
|
|
58
|
+
// actual surface to call it.
|
|
59
|
+
const pageWithArgs = ensure();
|
|
60
|
+
await pageWithArgs.evaluate((d) => {
|
|
61
|
+
globalThis.scrollBy(d.dx, d.dy);
|
|
62
|
+
}, { dx, dy });
|
|
63
|
+
},
|
|
64
|
+
async getViewport() {
|
|
65
|
+
const v = opts.viewport;
|
|
66
|
+
if (v !== undefined)
|
|
67
|
+
return v;
|
|
68
|
+
return { width: 1280, height: 800, devicePixelRatio: 1 };
|
|
69
|
+
},
|
|
70
|
+
async disconnect() {
|
|
71
|
+
try {
|
|
72
|
+
if (page)
|
|
73
|
+
await page.close();
|
|
74
|
+
if (browser)
|
|
75
|
+
await browser.close();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* best-effort */
|
|
79
|
+
}
|
|
80
|
+
connected = false;
|
|
81
|
+
page = undefined;
|
|
82
|
+
browser = undefined;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
export { ComputerUseDriverError };
|
|
29
|
+
export { type DnsLookupFn, type SsrfPinningProxy, type StartSsrfPinningProxyOptions, startSsrfPinningProxy, } from "./ssrf-proxy";
|
|
30
|
+
export type DriverBackend = "host" | "chromium" | "remote";
|
|
31
|
+
export type MouseButton = "left" | "right" | "middle";
|
|
32
|
+
export type Viewport = {
|
|
33
|
+
readonly width: number;
|
|
34
|
+
readonly height: number;
|
|
35
|
+
readonly devicePixelRatio: number;
|
|
36
|
+
};
|
|
37
|
+
export interface Driver {
|
|
38
|
+
readonly backend: DriverBackend;
|
|
39
|
+
/** Open / connect / launch chromium. Idempotent. */
|
|
40
|
+
connect(): Promise<void>;
|
|
41
|
+
/** Navigate the chromium tab. Throws on `host` backend. */
|
|
42
|
+
goto(url: string): Promise<void>;
|
|
43
|
+
/** Capture a PNG screenshot. */
|
|
44
|
+
screenshot(): Promise<Uint8Array>;
|
|
45
|
+
click(x: number, y: number, button?: MouseButton): Promise<void>;
|
|
46
|
+
type(text: string): Promise<void>;
|
|
47
|
+
key(combo: string): Promise<void>;
|
|
48
|
+
scroll(dx: number, dy: number): Promise<void>;
|
|
49
|
+
getViewport(): Promise<Viewport>;
|
|
50
|
+
/** Best-effort DOM snapshot. Used by tests + the post-action assertions. */
|
|
51
|
+
domText?(): Promise<string>;
|
|
52
|
+
disconnect(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
export type CreateDriverOptions = {
|
|
55
|
+
readonly backend: DriverBackend;
|
|
56
|
+
/** Playwright launch overrides (e.g. headless: false). chromium-only. */
|
|
57
|
+
readonly playwrightOptions?: Record<string, unknown>;
|
|
58
|
+
/**
|
|
59
|
+
* SECURITY (audit follow-up R1) — route ALL chromium traffic through a
|
|
60
|
+
* local DNS-pinning forward proxy (see `ssrf-proxy.ts`). The proxy resolves
|
|
61
|
+
* each hostname once, validates the IP against the private/loopback/
|
|
62
|
+
* link-local/metadata floor, and dials that exact pinned IP, closing the
|
|
63
|
+
* DNS-rebinding TOCTOU the pre-goto guard in tool-navigate cannot close
|
|
64
|
+
* (the browser re-resolves at connect time, and sub-resource fetches never
|
|
65
|
+
* pass the guard at all). Default true. Set `false` ONLY when the browser
|
|
66
|
+
* must reach private/internal targets (e.g. testing an intranet app) AND
|
|
67
|
+
* the page content is trusted. Ignored when `playwrightOptions.proxy` is
|
|
68
|
+
* set — an operator-supplied proxy wins, and is then responsible for its
|
|
69
|
+
* own egress policy. chromium-only.
|
|
70
|
+
*/
|
|
71
|
+
readonly ssrfProxy?: boolean;
|
|
72
|
+
/** Initial viewport — chromium-only. Default 1280x720@1. */
|
|
73
|
+
readonly viewport?: {
|
|
74
|
+
width: number;
|
|
75
|
+
height: number;
|
|
76
|
+
};
|
|
77
|
+
/** Test injection: a pre-built Driver instance the factory returns verbatim. */
|
|
78
|
+
readonly _injected?: Driver;
|
|
79
|
+
/**
|
|
80
|
+
* Test injection: replaces the chromium backend's dynamic
|
|
81
|
+
* `import("playwright")`. Lets the import-failure path be exercised without
|
|
82
|
+
* `mock.module` — a throwing module mock cannot be registered once
|
|
83
|
+
* playwright has been imported anywhere in the test process. chromium-only.
|
|
84
|
+
*/
|
|
85
|
+
readonly _importPlaywright?: () => Promise<unknown>;
|
|
86
|
+
};
|
|
87
|
+
export declare function createDriver(opts: CreateDriverOptions): Driver;
|
|
88
|
+
export { createHostDriver, type HostBackendOptions, type HostExecutor } from "./backends/host";
|
|
89
|
+
export { createRemoteDriver, type PuppeteerCoreLike, type RemoteBackendOptions, } from "./backends/remote";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
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 { startSsrfPinningProxy } from "./ssrf-proxy";
|
|
29
|
+
export { ComputerUseDriverError };
|
|
30
|
+
export { startSsrfPinningProxy, } from "./ssrf-proxy";
|
|
31
|
+
export function createDriver(opts) {
|
|
32
|
+
if (opts._injected !== undefined)
|
|
33
|
+
return opts._injected;
|
|
34
|
+
if (opts.backend === "chromium")
|
|
35
|
+
return createChromiumDriver(opts);
|
|
36
|
+
if (opts.backend === "host") {
|
|
37
|
+
// Section 30 — host backend stays gated. Callers that have wired
|
|
38
|
+
// a real executor use `createHostDriver` directly.
|
|
39
|
+
return createHostDriverStub();
|
|
40
|
+
}
|
|
41
|
+
if (opts.backend === "remote") {
|
|
42
|
+
// Same pattern: callers with a puppeteer-core import use
|
|
43
|
+
// `createRemoteDriver` directly.
|
|
44
|
+
return createRemoteDriverStub();
|
|
45
|
+
}
|
|
46
|
+
throw new ComputerUseDriverError(`unknown backend: ${opts.backend}`);
|
|
47
|
+
}
|
|
48
|
+
// Section 30 — direct exports for callers that wire backends with an
|
|
49
|
+
// executor or puppeteer-core import.
|
|
50
|
+
export { createHostDriver } from "./backends/host";
|
|
51
|
+
export { createRemoteDriver, } from "./backends/remote";
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Playwright-backed chromium driver.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
function createChromiumDriver(opts) {
|
|
56
|
+
let browser;
|
|
57
|
+
let context;
|
|
58
|
+
let page;
|
|
59
|
+
let connected = false;
|
|
60
|
+
let ssrfProxy;
|
|
61
|
+
async function loadPlaywright() {
|
|
62
|
+
try {
|
|
63
|
+
// Lazy import so non-browser users don't need Playwright in their tree.
|
|
64
|
+
// The import is dynamic via a string indirection so `tsc` doesn't fail
|
|
65
|
+
// when playwright (an optional peer dep) isn't installed in CI.
|
|
66
|
+
const playwrightModule = "playwright";
|
|
67
|
+
const mod = opts._importPlaywright !== undefined
|
|
68
|
+
? await opts._importPlaywright()
|
|
69
|
+
: await import(playwrightModule);
|
|
70
|
+
return mod;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
throw new ComputerUseDriverError("Playwright not installed. Run `bun add -D playwright` and `bunx playwright install chromium` to use the chromium backend.", err);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
backend: "chromium",
|
|
78
|
+
async connect() {
|
|
79
|
+
if (connected)
|
|
80
|
+
return;
|
|
81
|
+
const { chromium } = await loadPlaywright();
|
|
82
|
+
const userOpts = opts.playwrightOptions ?? {};
|
|
83
|
+
// SECURITY — DNS-pinning proxy on by default (see CreateDriverOptions.
|
|
84
|
+
// ssrfProxy). An operator-supplied playwrightOptions.proxy wins; the
|
|
85
|
+
// proxy starts only after the playwright import succeeds so the
|
|
86
|
+
// import-failure path can't leak a listening server.
|
|
87
|
+
const useSsrfProxy = opts.ssrfProxy !== false && userOpts["proxy"] === undefined;
|
|
88
|
+
if (useSsrfProxy) {
|
|
89
|
+
ssrfProxy = await startSsrfPinningProxy();
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
// Playwright defaults: chromium runs in its own sandbox (its own
|
|
93
|
+
// sandboxed renderer process). Headless by default. The
|
|
94
|
+
// `<-loopback>` bypass rule removes Chromium's implicit proxy bypass
|
|
95
|
+
// for localhost targets, so loopback requests also route through the
|
|
96
|
+
// pinning proxy — and get blocked there.
|
|
97
|
+
const launchOpts = {
|
|
98
|
+
headless: true,
|
|
99
|
+
...userOpts,
|
|
100
|
+
...(ssrfProxy !== undefined
|
|
101
|
+
? { proxy: { server: ssrfProxy.url, bypass: "<-loopback>" } }
|
|
102
|
+
: {}),
|
|
103
|
+
};
|
|
104
|
+
browser = await chromium.launch(launchOpts);
|
|
105
|
+
const ctxOpts = {
|
|
106
|
+
viewport: opts.viewport ?? { width: 1280, height: 720 },
|
|
107
|
+
};
|
|
108
|
+
context = await browser.newContext(ctxOpts);
|
|
109
|
+
page = await context.newPage();
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// Launch/context failure must not leak the proxy server (or a
|
|
113
|
+
// half-launched browser).
|
|
114
|
+
try {
|
|
115
|
+
await browser?.close?.();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// best-effort
|
|
119
|
+
}
|
|
120
|
+
browser = undefined;
|
|
121
|
+
await ssrfProxy?.close();
|
|
122
|
+
ssrfProxy = undefined;
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
connected = true;
|
|
126
|
+
},
|
|
127
|
+
async goto(url) {
|
|
128
|
+
if (!connected)
|
|
129
|
+
throw new ComputerUseDriverError("driver not connected — call connect() first");
|
|
130
|
+
await page.goto(url, {
|
|
131
|
+
waitUntil: "domcontentloaded",
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
async screenshot() {
|
|
135
|
+
if (!connected)
|
|
136
|
+
throw new ComputerUseDriverError("driver not connected");
|
|
137
|
+
const buf = (await page.screenshot({
|
|
138
|
+
type: "png",
|
|
139
|
+
}));
|
|
140
|
+
return buf;
|
|
141
|
+
},
|
|
142
|
+
async click(x, y, button = "left") {
|
|
143
|
+
if (!connected)
|
|
144
|
+
throw new ComputerUseDriverError("driver not connected");
|
|
145
|
+
await page.mouse.click(x, y, { button });
|
|
146
|
+
},
|
|
147
|
+
async type(text) {
|
|
148
|
+
if (!connected)
|
|
149
|
+
throw new ComputerUseDriverError("driver not connected");
|
|
150
|
+
await page.keyboard.type(text);
|
|
151
|
+
},
|
|
152
|
+
async key(combo) {
|
|
153
|
+
if (!connected)
|
|
154
|
+
throw new ComputerUseDriverError("driver not connected");
|
|
155
|
+
await page.keyboard.press(combo);
|
|
156
|
+
},
|
|
157
|
+
async scroll(dx, dy) {
|
|
158
|
+
if (!connected)
|
|
159
|
+
throw new ComputerUseDriverError("driver not connected");
|
|
160
|
+
await page.mouse.wheel(dx, dy);
|
|
161
|
+
},
|
|
162
|
+
async getViewport() {
|
|
163
|
+
if (!connected)
|
|
164
|
+
throw new ComputerUseDriverError("driver not connected");
|
|
165
|
+
const vp = page.viewportSize();
|
|
166
|
+
const w = vp?.width ?? opts.viewport?.width ?? 1280;
|
|
167
|
+
const h = vp?.height ?? opts.viewport?.height ?? 720;
|
|
168
|
+
return { width: w, height: h, devicePixelRatio: 1 };
|
|
169
|
+
},
|
|
170
|
+
async domText() {
|
|
171
|
+
if (!connected)
|
|
172
|
+
throw new ComputerUseDriverError("driver not connected");
|
|
173
|
+
return page
|
|
174
|
+
.textContent("body")
|
|
175
|
+
.then((t) => t ?? "");
|
|
176
|
+
},
|
|
177
|
+
async disconnect() {
|
|
178
|
+
if (!connected)
|
|
179
|
+
return;
|
|
180
|
+
try {
|
|
181
|
+
await browser.close();
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// best-effort
|
|
185
|
+
}
|
|
186
|
+
await ssrfProxy?.close();
|
|
187
|
+
ssrfProxy = undefined;
|
|
188
|
+
connected = false;
|
|
189
|
+
browser = undefined;
|
|
190
|
+
context = undefined;
|
|
191
|
+
page = undefined;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Stubs for host + remote backends.
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
function createHostDriverStub() {
|
|
199
|
+
return {
|
|
200
|
+
backend: "host",
|
|
201
|
+
async connect() {
|
|
202
|
+
throw new ComputerUseDriverError("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`.");
|
|
203
|
+
},
|
|
204
|
+
async goto() {
|
|
205
|
+
throw new ComputerUseDriverError("host backend not implemented");
|
|
206
|
+
},
|
|
207
|
+
async screenshot() {
|
|
208
|
+
throw new ComputerUseDriverError("host backend not implemented");
|
|
209
|
+
},
|
|
210
|
+
async click() {
|
|
211
|
+
throw new ComputerUseDriverError("host backend not implemented");
|
|
212
|
+
},
|
|
213
|
+
async type() {
|
|
214
|
+
throw new ComputerUseDriverError("host backend not implemented");
|
|
215
|
+
},
|
|
216
|
+
async key() {
|
|
217
|
+
throw new ComputerUseDriverError("host backend not implemented");
|
|
218
|
+
},
|
|
219
|
+
async scroll() {
|
|
220
|
+
throw new ComputerUseDriverError("host backend not implemented");
|
|
221
|
+
},
|
|
222
|
+
async getViewport() {
|
|
223
|
+
throw new ComputerUseDriverError("host backend not implemented");
|
|
224
|
+
},
|
|
225
|
+
async disconnect() { },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function createRemoteDriverStub() {
|
|
229
|
+
return {
|
|
230
|
+
backend: "remote",
|
|
231
|
+
async connect() {
|
|
232
|
+
throw new ComputerUseDriverError("remote backend (CDP / Browserless) is not implemented in v0");
|
|
233
|
+
},
|
|
234
|
+
async goto() {
|
|
235
|
+
throw new ComputerUseDriverError("remote backend not implemented");
|
|
236
|
+
},
|
|
237
|
+
async screenshot() {
|
|
238
|
+
throw new ComputerUseDriverError("remote backend not implemented");
|
|
239
|
+
},
|
|
240
|
+
async click() {
|
|
241
|
+
throw new ComputerUseDriverError("remote backend not implemented");
|
|
242
|
+
},
|
|
243
|
+
async type() {
|
|
244
|
+
throw new ComputerUseDriverError("remote backend not implemented");
|
|
245
|
+
},
|
|
246
|
+
async key() {
|
|
247
|
+
throw new ComputerUseDriverError("remote backend not implemented");
|
|
248
|
+
},
|
|
249
|
+
async scroll() {
|
|
250
|
+
throw new ComputerUseDriverError("remote backend not implemented");
|
|
251
|
+
},
|
|
252
|
+
async getViewport() {
|
|
253
|
+
throw new ComputerUseDriverError("remote backend not implemented");
|
|
254
|
+
},
|
|
255
|
+
async disconnect() { },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type DnsLookupFn = (host: string) => Promise<{
|
|
2
|
+
readonly address: string;
|
|
3
|
+
readonly family: number;
|
|
4
|
+
}>;
|
|
5
|
+
export type SsrfPinningProxy = {
|
|
6
|
+
/** Proxy URL for the browser's launch options, e.g. `http://127.0.0.1:49321`. */
|
|
7
|
+
readonly url: string;
|
|
8
|
+
readonly port: number;
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export type StartSsrfPinningProxyOptions = {
|
|
12
|
+
/** Test seam: replaces the DNS resolver. */
|
|
13
|
+
readonly _lookup?: DnsLookupFn;
|
|
14
|
+
/** Test seam: replaces the blocked-IP predicate. */
|
|
15
|
+
readonly _isIpBlocked?: (ip: string) => boolean;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Normalise an IPv4 literal to canonical dotted-decimal. Browsers and many
|
|
19
|
+
* HTTP stacks accept octal (`0177.0.0.1`), hex (`0x7f.0.0.1` / `0x7f000001`),
|
|
20
|
+
* and 32-bit integer (`2130706433`) forms — all of which resolve to the same
|
|
21
|
+
* address as `127.0.0.1`. Canonicalising first means `isPrivateIp` classifies
|
|
22
|
+
* them. Returns dotted-decimal, or `null` if `raw` is not an IPv4 literal.
|
|
23
|
+
*/
|
|
24
|
+
export declare function normalizeIpv4(raw: string): string | null;
|
|
25
|
+
export declare function isPrivateIp(addr: string): boolean;
|
|
26
|
+
export declare function startSsrfPinningProxy(opts?: StartSsrfPinningProxyOptions): Promise<SsrfPinningProxy>;
|