@mehmoodqureshi/chrome-mcp 0.1.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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/shared/download.d.ts +15 -0
- package/dist/shared/download.js +0 -0
- package/dist/shared/protocol.d.ts +114 -0
- package/dist/shared/protocol.js +55 -0
- package/dist/src/bridge/auth.d.ts +32 -0
- package/dist/src/bridge/auth.js +76 -0
- package/dist/src/bridge/connection.d.ts +48 -0
- package/dist/src/bridge/connection.js +192 -0
- package/dist/src/bridge/datadir.d.ts +8 -0
- package/dist/src/bridge/datadir.js +22 -0
- package/dist/src/bridge/server.d.ts +58 -0
- package/dist/src/bridge/server.js +178 -0
- package/dist/src/cli.d.ts +11 -0
- package/dist/src/cli.js +93 -0
- package/dist/src/config.d.ts +42 -0
- package/dist/src/config.js +188 -0
- package/dist/src/executor/cdp-executor.d.ts +131 -0
- package/dist/src/executor/cdp-executor.js +422 -0
- package/dist/src/executor/extension-executor.d.ts +102 -0
- package/dist/src/executor/extension-executor.js +124 -0
- package/dist/src/executor/manager.d.ts +43 -0
- package/dist/src/executor/manager.js +94 -0
- package/dist/src/executor/select.d.ts +23 -0
- package/dist/src/executor/select.js +53 -0
- package/dist/src/executor/stub-executor.d.ts +60 -0
- package/dist/src/executor/stub-executor.js +118 -0
- package/dist/src/executor/types.d.ts +192 -0
- package/dist/src/executor/types.js +24 -0
- package/dist/src/mcp/envelopes.d.ts +13 -0
- package/dist/src/mcp/envelopes.js +30 -0
- package/dist/src/mcp/helpers.d.ts +37 -0
- package/dist/src/mcp/helpers.js +71 -0
- package/dist/src/mcp/markdown-extract.d.ts +9 -0
- package/dist/src/mcp/markdown-extract.js +61 -0
- package/dist/src/mcp/server.d.ts +18 -0
- package/dist/src/mcp/server.js +82 -0
- package/dist/src/mcp/tools.d.ts +32 -0
- package/dist/src/mcp/tools.js +267 -0
- package/dist/src/mcp/validators.d.ts +32 -0
- package/dist/src/mcp/validators.js +104 -0
- package/dist/src/security/policy.d.ts +48 -0
- package/dist/src/security/policy.js +155 -0
- package/docs/BLUEPRINT.md +596 -0
- package/extension-dist/background.js +567 -0
- package/extension-dist/manifest.json +12 -0
- package/extension-dist/options.html +32 -0
- package/extension-dist/options.js +37 -0
- package/package.json +69 -0
- package/scripts/postinstall.js +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mehmood Ur Rehman Qureshi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# chrome-mcp
|
|
2
|
+
|
|
3
|
+
Drive a **real Chrome browser** from Claude (or any MCP host). One pluggable
|
|
4
|
+
`Executor` interface, two backends:
|
|
5
|
+
|
|
6
|
+
- **Extension (primary):** an MV3 extension drives your real Chrome — real
|
|
7
|
+
logins, real cookies — via `chrome.scripting`/`chrome.tabs`. The CLI runs a
|
|
8
|
+
localhost WebSocket server; the extension dials in.
|
|
9
|
+
- **CDP fallback:** when no extension is paired, the CLI launches/attaches a
|
|
10
|
+
Playwright-driven Chromium for scripted/headless use.
|
|
11
|
+
|
|
12
|
+
Distributed as an `npx` CLI (the MCP server) plus a load-unpacked extension.
|
|
13
|
+
|
|
14
|
+
> **Full design:** [`docs/BLUEPRINT.md`](docs/BLUEPRINT.md) — architecture, wire
|
|
15
|
+
> protocol, the complete tool surface, the extension manifest, the security
|
|
16
|
+
> model, and the phased build plan.
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
**1. Register the MCP server** with your host (e.g. Claude Desktop / Code):
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"chrome-mcp": {
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["-y", "@mehmoodqureshi/chrome-mcp", "--allow-domain", "example.com", "--enable-mutations"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
By default everything is **deny-all** (no domains, no eval, no mutations). Grant
|
|
34
|
+
exactly what you need with `--allow-domain <glob>` (repeatable), `--enable-mutations`,
|
|
35
|
+
`--enable-downloads`, `--unsafe-enable-eval`, or `--unsafe-all-domains`.
|
|
36
|
+
|
|
37
|
+
**2. Load the extension** (to drive your *real* Chrome): build it, then
|
|
38
|
+
`chrome://extensions` → enable Developer mode → **Load unpacked** → select
|
|
39
|
+
`extension-dist/`.
|
|
40
|
+
|
|
41
|
+
**3. Pair it:** run `npx chrome-mcp --print-pairing` to write the handshake and
|
|
42
|
+
print its path, open the extension's **Options** page, and paste the `port` +
|
|
43
|
+
`token` from `~/.chrome-mcp/handshake.json`. (Without the extension, the CLI
|
|
44
|
+
falls back to a Playwright-driven Chromium automatically.)
|
|
45
|
+
|
|
46
|
+
The 26 tools cover tabs, navigation, interaction (`click`/`type`/`press`/`hover`/
|
|
47
|
+
`scroll`), reads (`get_text`/`get_html`/`screenshot`/`eval`/`wait_for`), helpers
|
|
48
|
+
(`extract_links`/`read_as_markdown`/`fill_form`/`download_file`), and `chrome_status`.
|
|
49
|
+
|
|
50
|
+
## Status
|
|
51
|
+
|
|
52
|
+
v0.1.0 — all six build phases complete and green (50 automated tests + a gated
|
|
53
|
+
headed extension smoke). End-to-end working: `npx chrome-mcp` ⇄ bridge ⇄
|
|
54
|
+
extension ⇄ your real Chrome, with a Playwright CDP fallback.
|
|
55
|
+
|
|
56
|
+
- [x] **Phase 0 — Contracts & skeleton:** `shared/protocol.ts` (wire contract),
|
|
57
|
+
`src/executor/types.ts` (Executor interface), `src/security/policy.ts`
|
|
58
|
+
(default-deny policy + capability gates), `src/config.ts` (CLI/env/policy
|
|
59
|
+
resolution), build + test harness.
|
|
60
|
+
- [x] **Phase 1 — MCP server + StubExecutor:** `mcp/server.ts` (clean-stdout
|
|
61
|
+
stdio), `mcp/tools.ts` (23-tool catalog + never-throw dispatch +
|
|
62
|
+
drift-check), validators/envelopes/helpers, `ExecutorManager` +
|
|
63
|
+
`StubExecutor`, `cli.ts`. Point an MCP host at `node dist/src/cli.js` today.
|
|
64
|
+
- [x] **Phase 2 — WebSocket bridge + auth:** `bridge/server.ts` (loopback WS,
|
|
65
|
+
hello-token gate, welcome/unauthorized, displacement), `bridge/auth.ts`
|
|
66
|
+
(per-boot 256-bit token, atomic-0600 handshake, SHA-256 `timingSafeEqual`),
|
|
67
|
+
`bridge/connection.ts` (id-correlation, method-aware timeouts, backpressure,
|
|
68
|
+
reject-all-on-close, heartbeat).
|
|
69
|
+
- [x] **Phase 3 — ExtensionExecutor + CdpExecutor + selection:**
|
|
70
|
+
`executor/extension-executor.ts` (Executor over the bridge),
|
|
71
|
+
`executor/cdp-executor.ts` (Playwright connect/launch + lock recovery +
|
|
72
|
+
tab resolution), `executor/select.ts` (extension-if-ping-responsive else
|
|
73
|
+
CDP). CLI now starts the bridge, writes the 0600 handshake, and serves a
|
|
74
|
+
real backend. Adds `playwright`.
|
|
75
|
+
- [x] **Phase 4 — MV3 extension:** `extension/` — `manifest.json`,
|
|
76
|
+
`sw/ws-client.ts` (dial + hello/welcome + pong), `sw/executor.ts`
|
|
77
|
+
(chrome.scripting/chrome.tabs command impls), `sw/router.ts` (never-throw +
|
|
78
|
+
drift), `sw/background.ts` (top-level listeners + 25s keepalive/reconnect),
|
|
79
|
+
options page (manual pairing), esbuild build → `extension-dist/`. Verified
|
|
80
|
+
by a live `--load-extension` smoke (pair → navigate → get_text). Adds
|
|
81
|
+
`esbuild` + `@types/chrome`.
|
|
82
|
+
- [x] **Phase 5 — Helpers, downloads, HITL:** hardened `download_file`
|
|
83
|
+
(`shared/download.ts` — path-traversal/dangerous-ext sanitize + size cap,
|
|
84
|
+
wired into both backends), richer `read_as_markdown`, and a human-in-the-loop
|
|
85
|
+
harness (`hitl/` — `npm run test:hitl [-- --include-mutating]`) with pure,
|
|
86
|
+
unit-tested gating. 50 automated tests.
|
|
87
|
+
- [x] **Phase 6 — Packaging & docs:** `files` whitelist (ships `dist/src`,
|
|
88
|
+
`dist/shared`, `extension-dist`, LICENSE, blueprint — not source/tests),
|
|
89
|
+
`prepack` build, `bin`, quickstart + `.mcp.json` snippet. Verified by a
|
|
90
|
+
tarball install smoke (`npm pack` → install → MCP `tools/list`).
|
|
91
|
+
|
|
92
|
+
## Security posture (default)
|
|
93
|
+
|
|
94
|
+
**Deny-all safe mode.** With no policy configured: empty domain allowlist,
|
|
95
|
+
`eval` off, downloads off, mutating tools off. Opt in explicitly:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
chrome-mcp --allow-domain example.com --enable-mutations
|
|
99
|
+
chrome-mcp --policy ./policy.json # see policy.example.json
|
|
100
|
+
chrome-mcp --unsafe-all-domains # loud footgun
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The per-boot 256-bit token in `~/.chrome-mcp/handshake.json` (mode 0600) is the
|
|
104
|
+
only trust boundary; it is never written to stdout/stderr.
|
|
105
|
+
|
|
106
|
+
## Develop
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
npm install
|
|
110
|
+
npm run typecheck # server/test sources
|
|
111
|
+
npm run typecheck:ext # extension sources (@types/chrome)
|
|
112
|
+
npm run build:ext # bundle the extension → extension-dist/
|
|
113
|
+
npm test # builds, then runs node --test on dist/test
|
|
114
|
+
RUN_EXT_SMOKE=1 node --test dist/test/extension-smoke.test.js # live, headed
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## The extension
|
|
118
|
+
|
|
119
|
+
`extension/` builds (esbuild) to `extension-dist/`, loaded via
|
|
120
|
+
`chrome://extensions` → **Load unpacked** → select `extension-dist/`. Pair it
|
|
121
|
+
from the extension's **Options** page using the `port` + `token` from
|
|
122
|
+
`~/.chrome-mcp/handshake.json` (run `npx chrome-mcp --print-pairing` to get the
|
|
123
|
+
path).
|
|
124
|
+
|
|
125
|
+
> **v1 uses `chrome.scripting`/`chrome.tabs`, not `chrome.debugger`.** No
|
|
126
|
+
> "is being debugged" banner, CSP-safe reads (isolated world), and it's testable
|
|
127
|
+
> under Playwright. Trade-off: clicks/typing are synthetic DOM events, not
|
|
128
|
+
> OS-level trusted input, and `screenshot` is visible-tab only. A trusted-input
|
|
129
|
+
> `chrome.debugger` backend is a documented future upgrade (BLUEPRINT §10).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/download.ts — download-name hardening, shared by the server (CDP
|
|
3
|
+
* fallback) and the extension so both sanitize identically.
|
|
4
|
+
*
|
|
5
|
+
* An LLM driven by injected page content could be steered to `download_file` a
|
|
6
|
+
* malicious payload to a predictable name, so we: strip path separators and
|
|
7
|
+
* traversal, drop control/illegal characters, refuse leading dots, neutralize
|
|
8
|
+
* dangerous executable extensions (→ `.download`), and cap the length.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MAX_DOWNLOAD_BYTES: number;
|
|
11
|
+
/** Extensions we never let land with their original suffix. */
|
|
12
|
+
export declare const DANGEROUS_EXTENSIONS: ReadonlySet<string>;
|
|
13
|
+
export declare function sanitizeDownloadName(name?: string): string;
|
|
14
|
+
/** True when `bytes` is within the cap. */
|
|
15
|
+
export declare function isWithinSizeCap(bytes: number): boolean;
|
|
Binary file
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/protocol.ts — THE SINGLE SOURCE OF TRUTH for the wire contract.
|
|
3
|
+
*
|
|
4
|
+
* Imported VERBATIM by both the server build (`src/`) and the extension build
|
|
5
|
+
* (`extension/`). Nothing about the bridge wire format, the default port, or the
|
|
6
|
+
* protocol version may be redeclared anywhere else — if it is, the two ends can
|
|
7
|
+
* silently drift. (Phase 0 verification asserts there is exactly one copy.)
|
|
8
|
+
*
|
|
9
|
+
* The server is the WebSocket SERVER; the extension is the single privileged
|
|
10
|
+
* CLIENT that dials in. Methods on the wire mirror the MCP primitives 1:1.
|
|
11
|
+
* Helpers (extract_links / read_as_markdown / fill_form) are NOT on the wire —
|
|
12
|
+
* they are composed server-side from these primitives. Only `download_file` is a
|
|
13
|
+
* wire method beyond the primitives.
|
|
14
|
+
*/
|
|
15
|
+
/** Bumped on any breaking change to the frames below. */
|
|
16
|
+
export declare const PROTOCOL_VERSION: 1;
|
|
17
|
+
export type ProtocolVersion = typeof PROTOCOL_VERSION;
|
|
18
|
+
/**
|
|
19
|
+
* Default loopback port the bridge binds and the extension dials. NOT a security
|
|
20
|
+
* boundary (the token is) — a fixed port is only a convenience; the canonical
|
|
21
|
+
* port travels with the token in `handshake.json`, and ephemeral port `0` is
|
|
22
|
+
* supported because the extension re-reads the handshake on every failed dial.
|
|
23
|
+
*/
|
|
24
|
+
export declare const DEFAULT_WS_PORT: 38017;
|
|
25
|
+
/** Loopback host. Never bind 0.0.0.0. */
|
|
26
|
+
export declare const BRIDGE_HOST: "127.0.0.1";
|
|
27
|
+
/** WebSocket close codes we use deliberately. */
|
|
28
|
+
export declare const CLOSE_UNAUTHORIZED: 4401;
|
|
29
|
+
export declare const CLOSE_SUPERSEDED: 4000;
|
|
30
|
+
/**
|
|
31
|
+
* Every method that may travel on the wire = the MCP primitives 1:1, plus
|
|
32
|
+
* `download_file` (privileged, executor-owned) and `ping_probe` (a short-deadline
|
|
33
|
+
* responsiveness check used to detect a dead-but-not-yet-reconnected worker).
|
|
34
|
+
*/
|
|
35
|
+
export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'eval' | 'wait_for' | 'download_file' | 'ping_probe';
|
|
36
|
+
/** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
|
|
37
|
+
export declare const WIRE_METHODS: readonly WireMethod[];
|
|
38
|
+
export type ExecutorErrorCode = 'NO_TARGET' | 'TARGET_GONE' | 'DETACHED' | 'DEVTOOLS_OPEN' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_THREW' | 'TIMEOUT' | 'BAD_ARGS' | 'CDP_ERROR' | 'POLICY_DENIED' | 'DOWNLOAD_FAILED' | 'UNKNOWN_METHOD';
|
|
39
|
+
export interface BaseFrame {
|
|
40
|
+
type: string;
|
|
41
|
+
v: ProtocolVersion;
|
|
42
|
+
}
|
|
43
|
+
export interface HelloFrame extends BaseFrame {
|
|
44
|
+
type: 'hello';
|
|
45
|
+
token: string;
|
|
46
|
+
ext: {
|
|
47
|
+
id: string;
|
|
48
|
+
version: string;
|
|
49
|
+
chrome: string;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export interface WelcomeFrame extends BaseFrame {
|
|
53
|
+
type: 'welcome';
|
|
54
|
+
serverVersion: string;
|
|
55
|
+
sessionId: string;
|
|
56
|
+
heartbeatMs: number;
|
|
57
|
+
}
|
|
58
|
+
export interface UnauthFrame extends BaseFrame {
|
|
59
|
+
type: 'unauthorized';
|
|
60
|
+
reason: 'bad_token' | 'bad_version' | 'timeout';
|
|
61
|
+
}
|
|
62
|
+
export interface CommandFrame extends BaseFrame {
|
|
63
|
+
type: 'command';
|
|
64
|
+
id: string;
|
|
65
|
+
method: WireMethod;
|
|
66
|
+
params: Record<string, unknown>;
|
|
67
|
+
tabId?: string;
|
|
68
|
+
timeoutMs: number;
|
|
69
|
+
}
|
|
70
|
+
export interface ResultFrame extends BaseFrame {
|
|
71
|
+
type: 'result';
|
|
72
|
+
id: string;
|
|
73
|
+
ok: true;
|
|
74
|
+
/** For `screenshot`: { dataBase64, mimeType, width, height, truncated }. */
|
|
75
|
+
data: unknown;
|
|
76
|
+
}
|
|
77
|
+
export interface ErrorFrame extends BaseFrame {
|
|
78
|
+
type: 'error';
|
|
79
|
+
id: string;
|
|
80
|
+
ok: false;
|
|
81
|
+
error: {
|
|
82
|
+
code: ExecutorErrorCode;
|
|
83
|
+
message: string;
|
|
84
|
+
data?: Record<string, unknown>;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export type WireEvent = 'tab_created' | 'tab_removed' | 'tab_updated' | 'detached' | 'target_gone';
|
|
88
|
+
export interface EventFrame extends BaseFrame {
|
|
89
|
+
type: 'event';
|
|
90
|
+
event: WireEvent;
|
|
91
|
+
data: Record<string, unknown>;
|
|
92
|
+
}
|
|
93
|
+
export interface PingFrame extends BaseFrame {
|
|
94
|
+
type: 'ping';
|
|
95
|
+
ts: number;
|
|
96
|
+
}
|
|
97
|
+
export interface PongFrame extends BaseFrame {
|
|
98
|
+
type: 'pong';
|
|
99
|
+
ts: number;
|
|
100
|
+
}
|
|
101
|
+
/** Frames the SERVER sends to the extension. */
|
|
102
|
+
export type ServerFrame = CommandFrame | WelcomeFrame | UnauthFrame | PingFrame;
|
|
103
|
+
/** Frames the EXTENSION sends to the server. */
|
|
104
|
+
export type ExtensionFrame = HelloFrame | ResultFrame | ErrorFrame | EventFrame | PongFrame;
|
|
105
|
+
export type Frame = ServerFrame | ExtensionFrame;
|
|
106
|
+
/** Shape of `$CHROME_MCP_DATA/handshake.json`. The token is a secret. */
|
|
107
|
+
export interface HandshakeFile {
|
|
108
|
+
v: ProtocolVersion;
|
|
109
|
+
port: number;
|
|
110
|
+
token: string;
|
|
111
|
+
pid: number;
|
|
112
|
+
ts: number;
|
|
113
|
+
expectedExtensionId?: string;
|
|
114
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* shared/protocol.ts — THE SINGLE SOURCE OF TRUTH for the wire contract.
|
|
4
|
+
*
|
|
5
|
+
* Imported VERBATIM by both the server build (`src/`) and the extension build
|
|
6
|
+
* (`extension/`). Nothing about the bridge wire format, the default port, or the
|
|
7
|
+
* protocol version may be redeclared anywhere else — if it is, the two ends can
|
|
8
|
+
* silently drift. (Phase 0 verification asserts there is exactly one copy.)
|
|
9
|
+
*
|
|
10
|
+
* The server is the WebSocket SERVER; the extension is the single privileged
|
|
11
|
+
* CLIENT that dials in. Methods on the wire mirror the MCP primitives 1:1.
|
|
12
|
+
* Helpers (extract_links / read_as_markdown / fill_form) are NOT on the wire —
|
|
13
|
+
* they are composed server-side from these primitives. Only `download_file` is a
|
|
14
|
+
* wire method beyond the primitives.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.WIRE_METHODS = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
|
|
18
|
+
/** Bumped on any breaking change to the frames below. */
|
|
19
|
+
exports.PROTOCOL_VERSION = 1;
|
|
20
|
+
/**
|
|
21
|
+
* Default loopback port the bridge binds and the extension dials. NOT a security
|
|
22
|
+
* boundary (the token is) — a fixed port is only a convenience; the canonical
|
|
23
|
+
* port travels with the token in `handshake.json`, and ephemeral port `0` is
|
|
24
|
+
* supported because the extension re-reads the handshake on every failed dial.
|
|
25
|
+
*/
|
|
26
|
+
exports.DEFAULT_WS_PORT = 38017;
|
|
27
|
+
/** Loopback host. Never bind 0.0.0.0. */
|
|
28
|
+
exports.BRIDGE_HOST = '127.0.0.1';
|
|
29
|
+
/** WebSocket close codes we use deliberately. */
|
|
30
|
+
exports.CLOSE_UNAUTHORIZED = 4401;
|
|
31
|
+
exports.CLOSE_SUPERSEDED = 4000;
|
|
32
|
+
/** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
|
|
33
|
+
exports.WIRE_METHODS = [
|
|
34
|
+
'tabs_list',
|
|
35
|
+
'tab_select',
|
|
36
|
+
'tab_new',
|
|
37
|
+
'tab_close',
|
|
38
|
+
'navigate',
|
|
39
|
+
'back',
|
|
40
|
+
'forward',
|
|
41
|
+
'reload',
|
|
42
|
+
'click',
|
|
43
|
+
'type',
|
|
44
|
+
'press',
|
|
45
|
+
'hover',
|
|
46
|
+
'scroll',
|
|
47
|
+
'screenshot',
|
|
48
|
+
'get_text',
|
|
49
|
+
'get_html',
|
|
50
|
+
'eval',
|
|
51
|
+
'wait_for',
|
|
52
|
+
'download_file',
|
|
53
|
+
'ping_probe',
|
|
54
|
+
];
|
|
55
|
+
//# sourceMappingURL=protocol.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/bridge/auth.ts — the ONE auth model (every other variant in the design
|
|
3
|
+
* drafts was deleted on purpose).
|
|
4
|
+
*
|
|
5
|
+
* - Fresh 256-bit token EVERY boot, never persisted across restarts.
|
|
6
|
+
* - Written atomically (tmp + rename) to `handshake.json` at mode 0600; the
|
|
7
|
+
* mode is re-verified after write and we FAIL CLOSED if it can't be set.
|
|
8
|
+
* - Compared by hashing both sides to SHA-256 and `timingSafeEqual`-ing the
|
|
9
|
+
* digests — no length precondition, no length leak.
|
|
10
|
+
* - The token is NEVER written to stdout/stderr or any log (a test asserts it).
|
|
11
|
+
*/
|
|
12
|
+
import { type HandshakeFile } from '../../shared/protocol';
|
|
13
|
+
/** A fresh 256-bit token, base64url. Generated once per server boot. */
|
|
14
|
+
export declare function generateToken(): string;
|
|
15
|
+
/** Constant-time token compare via fixed-length SHA-256 digests. */
|
|
16
|
+
export declare function tokensMatch(a: string, b: string): boolean;
|
|
17
|
+
export interface WriteHandshakeFields {
|
|
18
|
+
port: number;
|
|
19
|
+
token: string;
|
|
20
|
+
expectedExtensionId?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Atomically write the handshake at 0600 and verify the mode. Throws (fail
|
|
24
|
+
* closed) if the file ends up group/other-readable — the token is the entire
|
|
25
|
+
* trust boundary, so a loose permission is a hard error, not a warning.
|
|
26
|
+
*/
|
|
27
|
+
export declare function writeHandshake(dir: string, fields: WriteHandshakeFields): string;
|
|
28
|
+
export declare function readHandshake(dir: string): HandshakeFile;
|
|
29
|
+
/** Best-effort removal (kill switch / clean shutdown). */
|
|
30
|
+
export declare function removeHandshake(dir: string): void;
|
|
31
|
+
/** Redact a token for any human-facing string (defense against accidental logs). */
|
|
32
|
+
export declare function redactToken(s: string, token: string): string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/bridge/auth.ts — the ONE auth model (every other variant in the design
|
|
4
|
+
* drafts was deleted on purpose).
|
|
5
|
+
*
|
|
6
|
+
* - Fresh 256-bit token EVERY boot, never persisted across restarts.
|
|
7
|
+
* - Written atomically (tmp + rename) to `handshake.json` at mode 0600; the
|
|
8
|
+
* mode is re-verified after write and we FAIL CLOSED if it can't be set.
|
|
9
|
+
* - Compared by hashing both sides to SHA-256 and `timingSafeEqual`-ing the
|
|
10
|
+
* digests — no length precondition, no length leak.
|
|
11
|
+
* - The token is NEVER written to stdout/stderr or any log (a test asserts it).
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.generateToken = generateToken;
|
|
15
|
+
exports.tokensMatch = tokensMatch;
|
|
16
|
+
exports.writeHandshake = writeHandshake;
|
|
17
|
+
exports.readHandshake = readHandshake;
|
|
18
|
+
exports.removeHandshake = removeHandshake;
|
|
19
|
+
exports.redactToken = redactToken;
|
|
20
|
+
const node_fs_1 = require("node:fs");
|
|
21
|
+
const node_crypto_1 = require("node:crypto");
|
|
22
|
+
const protocol_1 = require("../../shared/protocol");
|
|
23
|
+
const datadir_1 = require("./datadir");
|
|
24
|
+
/** A fresh 256-bit token, base64url. Generated once per server boot. */
|
|
25
|
+
function generateToken() {
|
|
26
|
+
return (0, node_crypto_1.randomBytes)(32).toString('base64url');
|
|
27
|
+
}
|
|
28
|
+
/** Constant-time token compare via fixed-length SHA-256 digests. */
|
|
29
|
+
function tokensMatch(a, b) {
|
|
30
|
+
const ha = (0, node_crypto_1.createHash)('sha256').update(a, 'utf8').digest();
|
|
31
|
+
const hb = (0, node_crypto_1.createHash)('sha256').update(b, 'utf8').digest();
|
|
32
|
+
return (0, node_crypto_1.timingSafeEqual)(ha, hb);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Atomically write the handshake at 0600 and verify the mode. Throws (fail
|
|
36
|
+
* closed) if the file ends up group/other-readable — the token is the entire
|
|
37
|
+
* trust boundary, so a loose permission is a hard error, not a warning.
|
|
38
|
+
*/
|
|
39
|
+
function writeHandshake(dir, fields) {
|
|
40
|
+
const path = (0, datadir_1.handshakePath)(dir);
|
|
41
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
42
|
+
const payload = {
|
|
43
|
+
v: protocol_1.PROTOCOL_VERSION,
|
|
44
|
+
port: fields.port,
|
|
45
|
+
token: fields.token,
|
|
46
|
+
pid: process.pid,
|
|
47
|
+
ts: Date.now(),
|
|
48
|
+
expectedExtensionId: fields.expectedExtensionId,
|
|
49
|
+
};
|
|
50
|
+
(0, node_fs_1.writeFileSync)(tmp, JSON.stringify(payload), { mode: 0o600 });
|
|
51
|
+
(0, node_fs_1.chmodSync)(tmp, 0o600);
|
|
52
|
+
(0, node_fs_1.renameSync)(tmp, path);
|
|
53
|
+
(0, node_fs_1.chmodSync)(path, 0o600);
|
|
54
|
+
const mode = (0, node_fs_1.statSync)(path).mode & 0o777;
|
|
55
|
+
if ((mode & 0o077) !== 0) {
|
|
56
|
+
throw new Error(`handshake file ${path} is group/other-accessible (mode ${mode.toString(8)}); refusing to expose the token`);
|
|
57
|
+
}
|
|
58
|
+
return path;
|
|
59
|
+
}
|
|
60
|
+
function readHandshake(dir) {
|
|
61
|
+
return JSON.parse((0, node_fs_1.readFileSync)((0, datadir_1.handshakePath)(dir), 'utf8'));
|
|
62
|
+
}
|
|
63
|
+
/** Best-effort removal (kill switch / clean shutdown). */
|
|
64
|
+
function removeHandshake(dir) {
|
|
65
|
+
try {
|
|
66
|
+
(0, node_fs_1.unlinkSync)((0, datadir_1.handshakePath)(dir));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* already gone */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Redact a token for any human-facing string (defense against accidental logs). */
|
|
73
|
+
function redactToken(s, token) {
|
|
74
|
+
return token ? s.split(token).join('«redacted»') : s;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/bridge/connection.ts — one authenticated extension connection.
|
|
3
|
+
*
|
|
4
|
+
* Owns the pending-request table: each `sendCommand` mints an id, sends a
|
|
5
|
+
* CommandFrame, and parks a {resolve,reject,timer} until the matching
|
|
6
|
+
* result/error frame arrives. Guarantees:
|
|
7
|
+
* - method-aware per-request timeout that rejects the ONE call (never closes
|
|
8
|
+
* the socket),
|
|
9
|
+
* - reject-ALL-pending with EXTENSION_DISCONNECTED on close,
|
|
10
|
+
* - backpressure rejection (screenshots are large; never queue unboundedly),
|
|
11
|
+
* - app-level ping/pong heartbeat (optional; disabled when heartbeatMs<=0).
|
|
12
|
+
*/
|
|
13
|
+
import type { WebSocket } from 'ws';
|
|
14
|
+
import { type WireEvent, type WireMethod } from '../../shared/protocol';
|
|
15
|
+
export interface ConnectionDeps {
|
|
16
|
+
ws: WebSocket;
|
|
17
|
+
extId: string;
|
|
18
|
+
sessionId: string;
|
|
19
|
+
heartbeatMs: number;
|
|
20
|
+
onEvent?: (event: WireEvent, data: Record<string, unknown>) => void;
|
|
21
|
+
onClose?: (code: number) => void;
|
|
22
|
+
onLog?: (message: string) => void;
|
|
23
|
+
}
|
|
24
|
+
export declare class ExtensionConnection {
|
|
25
|
+
readonly extId: string;
|
|
26
|
+
readonly sessionId: string;
|
|
27
|
+
private readonly ws;
|
|
28
|
+
private readonly pending;
|
|
29
|
+
private seq;
|
|
30
|
+
private closed;
|
|
31
|
+
private heartbeat;
|
|
32
|
+
private missedPongs;
|
|
33
|
+
private readonly onEvent?;
|
|
34
|
+
private readonly onClose?;
|
|
35
|
+
private readonly onLog?;
|
|
36
|
+
constructor(deps: ConnectionDeps);
|
|
37
|
+
/** Send a command and await its result (or reject on error/timeout/disconnect). */
|
|
38
|
+
sendCommand(method: WireMethod, params: Record<string, unknown>, opts?: {
|
|
39
|
+
tabId?: string;
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}): Promise<unknown>;
|
|
42
|
+
close(code: number, reason?: string): void;
|
|
43
|
+
isOpen(): boolean;
|
|
44
|
+
private handleMessage;
|
|
45
|
+
private settle;
|
|
46
|
+
private handleClose;
|
|
47
|
+
private startHeartbeat;
|
|
48
|
+
}
|