@mehmoodqureshi/chrome-mcp 0.5.1 → 0.6.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/dist/shared/policy.d.ts +7 -0
- package/dist/shared/policy.js +19 -5
- package/dist/shared/protocol.d.ts +4 -0
- package/dist/src/bridge/datadir.d.ts +39 -0
- package/dist/src/bridge/datadir.js +66 -0
- package/dist/src/bridge/server.d.ts +18 -3
- package/dist/src/bridge/server.js +132 -29
- package/dist/src/bridge/tasks.d.ts +44 -0
- package/dist/src/bridge/tasks.js +131 -0
- package/dist/src/bridge/workspace.d.ts +54 -0
- package/dist/src/bridge/workspace.js +162 -0
- package/dist/src/cli.js +120 -2
- package/dist/src/config.d.ts +19 -1
- package/dist/src/config.js +52 -0
- package/dist/src/executor/extension-executor.d.ts +3 -0
- package/dist/src/executor/extension-executor.js +29 -4
- package/dist/src/executor/select.d.ts +3 -0
- package/dist/src/executor/select.js +13 -1
- package/dist/src/executor/types.d.ts +13 -0
- package/dist/src/mcp/tools.js +67 -8
- package/extension-dist/background.js +90 -30
- package/extension-dist/options.html +7 -0
- package/extension-dist/options.js +5 -2
- package/package.json +1 -1
package/dist/shared/policy.d.ts
CHANGED
|
@@ -30,5 +30,12 @@ export type PolicyVerdict = {
|
|
|
30
30
|
* verdict; the caller throws its own error type on `{ ok: false }`.
|
|
31
31
|
*/
|
|
32
32
|
export declare function evaluatePolicy(url: string, method: WireMethod, policy: WirePolicy): PolicyVerdict;
|
|
33
|
+
/**
|
|
34
|
+
* A plain-English, actionable message for a blocked domain. Tells the user what
|
|
35
|
+
* happened, why it's blocked (safety, not a bug), which sites ARE allowed, and
|
|
36
|
+
* the exact one-line change to permit this one — so a non-technical user is never
|
|
37
|
+
* left at a dead end. Never includes the token or any other secret.
|
|
38
|
+
*/
|
|
39
|
+
export declare function blockedDomainMessage(method: string, host: string, policy: WirePolicy): string;
|
|
33
40
|
/** A wire policy that allows nothing — the safe default when none was delivered. */
|
|
34
41
|
export declare const DENY_ALL_WIRE_POLICY: WirePolicy;
|
package/dist/shared/policy.js
CHANGED
|
@@ -17,6 +17,7 @@ exports.isUrlGated = isUrlGated;
|
|
|
17
17
|
exports.hostOf = hostOf;
|
|
18
18
|
exports.isDomainAllowed = isDomainAllowed;
|
|
19
19
|
exports.evaluatePolicy = evaluatePolicy;
|
|
20
|
+
exports.blockedDomainMessage = blockedDomainMessage;
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
21
22
|
// Method classification
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
@@ -131,14 +132,27 @@ function evaluatePolicy(url, method, policy) {
|
|
|
131
132
|
return { ok: true };
|
|
132
133
|
if (!isDomainAllowed(url, policy)) {
|
|
133
134
|
const host = hostOf(url) || url;
|
|
134
|
-
return {
|
|
135
|
-
ok: false,
|
|
136
|
-
reason: `"${method}" denied: ${host} is not in the domain allowlist. ` +
|
|
137
|
-
`Add it to allowDomains, or pass --unsafe-all-domains.`,
|
|
138
|
-
};
|
|
135
|
+
return { ok: false, reason: blockedDomainMessage(method, host, policy) };
|
|
139
136
|
}
|
|
140
137
|
return { ok: true };
|
|
141
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* A plain-English, actionable message for a blocked domain. Tells the user what
|
|
141
|
+
* happened, why it's blocked (safety, not a bug), which sites ARE allowed, and
|
|
142
|
+
* the exact one-line change to permit this one — so a non-technical user is never
|
|
143
|
+
* left at a dead end. Never includes the token or any other secret.
|
|
144
|
+
*/
|
|
145
|
+
function blockedDomainMessage(method, host, policy) {
|
|
146
|
+
const allowed = policy.allowDomains.filter((d) => d !== '*');
|
|
147
|
+
const allowedLine = allowed.length
|
|
148
|
+
? `Currently allowed: ${allowed.join(', ')}.`
|
|
149
|
+
: `Right now no sites are allowed.`;
|
|
150
|
+
return (`Blocked: "${method}" can't run on ${host} because it isn't on this browser tool's allowed-sites list. ` +
|
|
151
|
+
`This is a safety limit (the tool drives your real, logged-in browser, so it only touches sites you've approved) — not an error. ` +
|
|
152
|
+
`${allowedLine} ` +
|
|
153
|
+
`To allow ${host}, add it to the chrome-mcp settings as: --allow-domain "${host}" (or "*.${host}" to include subdomains), ` +
|
|
154
|
+
`then restart/reconnect. To allow every site (less safe), use --unsafe-all-domains.`);
|
|
155
|
+
}
|
|
142
156
|
/** A wire policy that allows nothing — the safe default when none was delivered. */
|
|
143
157
|
exports.DENY_ALL_WIRE_POLICY = {
|
|
144
158
|
allowDomains: [],
|
|
@@ -48,6 +48,10 @@ export interface HelloFrame extends BaseFrame {
|
|
|
48
48
|
version: string;
|
|
49
49
|
chrome: string;
|
|
50
50
|
};
|
|
51
|
+
/** Routing label: which profile this browser pairs as. Absent/empty → "default".
|
|
52
|
+
* NOT a security boundary (the token is) — it selects which connection slot the
|
|
53
|
+
* server routes commands to, so several browsers can stay paired at once. */
|
|
54
|
+
profile?: string;
|
|
51
55
|
}
|
|
52
56
|
/**
|
|
53
57
|
* The wire-serializable subset of the server's policy, delivered in `welcome` so
|
|
@@ -6,3 +6,42 @@
|
|
|
6
6
|
/** Create (if needed) and return the data dir, 0700 so only the user can read it. */
|
|
7
7
|
export declare function ensureDataDir(dir?: string): string;
|
|
8
8
|
export declare function handshakePath(dir: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* One-time move of the pre-0.6 flat layout into the `default` profile's
|
|
11
|
+
* workspace: `<dataDir>/cdp-profile` → `profiles/default/cdp-profile` (logins)
|
|
12
|
+
* and `<dataDir>/downloads` → `profiles/default/tasks/default/downloads`.
|
|
13
|
+
*
|
|
14
|
+
* Idempotent and conservative: a leg is migrated only when the legacy dir exists
|
|
15
|
+
* AND its target does not, so it runs at most once and never clobbers a profile
|
|
16
|
+
* the user has already populated. Must be called BEFORE {@link ensureWorkspace},
|
|
17
|
+
* which would otherwise create the (empty) target and block the rename. Returns
|
|
18
|
+
* a human-readable description of each move performed.
|
|
19
|
+
*/
|
|
20
|
+
export declare function migrateLegacyLayout(dataDir: string): string[];
|
|
21
|
+
export interface Workspace {
|
|
22
|
+
/** The data dir this workspace lives under — needed to switch profile/task at runtime. */
|
|
23
|
+
dataDir: string;
|
|
24
|
+
profile: string;
|
|
25
|
+
task: string;
|
|
26
|
+
/** `profiles/<profile>/` — passed to the CDP executor as its userDataDir. */
|
|
27
|
+
profileDir: string;
|
|
28
|
+
/** `profiles/<profile>/tasks/<task>/` — per-run artifact root. */
|
|
29
|
+
taskDir: string;
|
|
30
|
+
/** `profiles/<profile>/tasks/<task>/downloads` — captured files for this run. */
|
|
31
|
+
downloadDir: string;
|
|
32
|
+
/** `profiles/<profile>/tasks/<task>/results` — extracted text/markdown/links. */
|
|
33
|
+
resultsDir: string;
|
|
34
|
+
/** `profiles/<profile>/tasks/<task>/screenshots` — PNGs captured during the run. */
|
|
35
|
+
screenshotsDir: string;
|
|
36
|
+
/** `profiles/<profile>/tasks/<task>/history.jsonl` — append-only action log. */
|
|
37
|
+
historyPath: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Create (0700) the profile + task directories and stamp the task with a
|
|
41
|
+
* `meta.json`, returning the resolved paths. The CDP profile (identity: cookies
|
|
42
|
+
* & logins) and the downloads (per-run artifacts) live under here so distinct
|
|
43
|
+
* identities and distinct runs never collide. `createdAt` is preserved across
|
|
44
|
+
* restarts so a resumed task keeps its original timestamp; the meta write is
|
|
45
|
+
* best-effort and never fatal.
|
|
46
|
+
*/
|
|
47
|
+
export declare function ensureWorkspace(dataDir: string, profile: string, task: string, meta?: Record<string, unknown>): Workspace;
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
exports.ensureDataDir = ensureDataDir;
|
|
9
9
|
exports.handshakePath = handshakePath;
|
|
10
|
+
exports.migrateLegacyLayout = migrateLegacyLayout;
|
|
11
|
+
exports.ensureWorkspace = ensureWorkspace;
|
|
10
12
|
const node_fs_1 = require("node:fs");
|
|
11
13
|
const node_path_1 = require("node:path");
|
|
12
14
|
const config_1 = require("../config");
|
|
@@ -19,4 +21,68 @@ function ensureDataDir(dir) {
|
|
|
19
21
|
function handshakePath(dir) {
|
|
20
22
|
return (0, node_path_1.join)(dir, 'handshake.json');
|
|
21
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* One-time move of the pre-0.6 flat layout into the `default` profile's
|
|
26
|
+
* workspace: `<dataDir>/cdp-profile` → `profiles/default/cdp-profile` (logins)
|
|
27
|
+
* and `<dataDir>/downloads` → `profiles/default/tasks/default/downloads`.
|
|
28
|
+
*
|
|
29
|
+
* Idempotent and conservative: a leg is migrated only when the legacy dir exists
|
|
30
|
+
* AND its target does not, so it runs at most once and never clobbers a profile
|
|
31
|
+
* the user has already populated. Must be called BEFORE {@link ensureWorkspace},
|
|
32
|
+
* which would otherwise create the (empty) target and block the rename. Returns
|
|
33
|
+
* a human-readable description of each move performed.
|
|
34
|
+
*/
|
|
35
|
+
function migrateLegacyLayout(dataDir) {
|
|
36
|
+
const moved = [];
|
|
37
|
+
const legs = [
|
|
38
|
+
{ from: (0, node_path_1.join)(dataDir, 'cdp-profile'), to: (0, node_path_1.join)((0, config_1.resolveProfileDir)(dataDir, 'default'), 'cdp-profile') },
|
|
39
|
+
{ from: (0, node_path_1.join)(dataDir, 'downloads'), to: (0, node_path_1.join)((0, config_1.resolveTaskDir)(dataDir, 'default', 'default'), 'downloads') },
|
|
40
|
+
];
|
|
41
|
+
for (const { from, to } of legs) {
|
|
42
|
+
if (!(0, node_fs_1.existsSync)(from) || (0, node_fs_1.existsSync)(to))
|
|
43
|
+
continue;
|
|
44
|
+
try {
|
|
45
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(to), { recursive: true, mode: 0o700 });
|
|
46
|
+
(0, node_fs_1.renameSync)(from, to);
|
|
47
|
+
moved.push(`${from} → ${to}`);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* best effort: a failed move leaves the legacy dir untouched and usable */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return moved;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Create (0700) the profile + task directories and stamp the task with a
|
|
57
|
+
* `meta.json`, returning the resolved paths. The CDP profile (identity: cookies
|
|
58
|
+
* & logins) and the downloads (per-run artifacts) live under here so distinct
|
|
59
|
+
* identities and distinct runs never collide. `createdAt` is preserved across
|
|
60
|
+
* restarts so a resumed task keeps its original timestamp; the meta write is
|
|
61
|
+
* best-effort and never fatal.
|
|
62
|
+
*/
|
|
63
|
+
function ensureWorkspace(dataDir, profile, task, meta = {}) {
|
|
64
|
+
const profileDir = (0, config_1.resolveProfileDir)(dataDir, profile);
|
|
65
|
+
const taskDir = (0, config_1.resolveTaskDir)(dataDir, profile, task);
|
|
66
|
+
const downloadDir = (0, node_path_1.join)(taskDir, 'downloads');
|
|
67
|
+
const resultsDir = (0, node_path_1.join)(taskDir, 'results');
|
|
68
|
+
const screenshotsDir = (0, node_path_1.join)(taskDir, 'screenshots');
|
|
69
|
+
const historyPath = (0, node_path_1.join)(taskDir, 'history.jsonl');
|
|
70
|
+
// Create the three artifact buckets up front (0700) so every capture path can
|
|
71
|
+
// assume its directory exists.
|
|
72
|
+
for (const d of [downloadDir, resultsDir, screenshotsDir]) {
|
|
73
|
+
(0, node_fs_1.mkdirSync)(d, { recursive: true, mode: 0o700 });
|
|
74
|
+
}
|
|
75
|
+
const metaPath = (0, node_path_1.join)(taskDir, 'meta.json');
|
|
76
|
+
try {
|
|
77
|
+
let createdAt;
|
|
78
|
+
if ((0, node_fs_1.existsSync)(metaPath)) {
|
|
79
|
+
createdAt = JSON.parse((0, node_fs_1.readFileSync)(metaPath, 'utf8')).createdAt;
|
|
80
|
+
}
|
|
81
|
+
(0, node_fs_1.writeFileSync)(metaPath, JSON.stringify({ ...meta, profile, task, createdAt: createdAt ?? meta.createdAt }, null, 2), { mode: 0o600 });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* non-fatal: a missing meta.json never blocks the server */
|
|
85
|
+
}
|
|
86
|
+
return { dataDir, profile, task, profileDir, taskDir, downloadDir, resultsDir, screenshotsDir, historyPath };
|
|
87
|
+
}
|
|
22
88
|
//# sourceMappingURL=datadir.js.map
|
|
@@ -34,24 +34,39 @@ export interface BridgeOptions {
|
|
|
34
34
|
export declare class BridgeServer {
|
|
35
35
|
private readonly opts;
|
|
36
36
|
private wss;
|
|
37
|
-
|
|
37
|
+
/** Profile routing key → its live connection. Multiple browsers stay paired at
|
|
38
|
+
* once; a command is routed to the connection for its target profile. A new
|
|
39
|
+
* hello for the SAME profile supersedes that profile's connection only. */
|
|
40
|
+
private conns;
|
|
38
41
|
private boundPort;
|
|
39
42
|
private readonly heartbeatMs;
|
|
40
43
|
constructor(opts: BridgeOptions);
|
|
41
44
|
/** Bind and start listening. Returns the actual port (useful with port 0). */
|
|
42
45
|
start(): Promise<number>;
|
|
46
|
+
/** One bind attempt. Resolves with a listening server or rejects with the listen error. */
|
|
47
|
+
private listenOnce;
|
|
43
48
|
stop(): Promise<void>;
|
|
44
49
|
get port(): number;
|
|
50
|
+
/** True when ANY browser is paired (used as the selector's cheap gate). */
|
|
45
51
|
hasActiveExtension(): boolean;
|
|
46
|
-
/**
|
|
52
|
+
/** True when the given profile has a live connection. */
|
|
53
|
+
hasConnection(profile: string): boolean;
|
|
54
|
+
/** Profiles with a live connection right now. */
|
|
55
|
+
connectedProfiles(): string[];
|
|
56
|
+
/**
|
|
57
|
+
* Send a command to the connection for `opts.profile` (default "default").
|
|
58
|
+
* Rejects with an actionable message if that profile has no live browser.
|
|
59
|
+
*/
|
|
47
60
|
sendCommand(method: WireMethod, params: Record<string, unknown>, opts?: {
|
|
48
61
|
tabId?: string;
|
|
49
62
|
timeoutMs?: number;
|
|
63
|
+
profile?: string;
|
|
50
64
|
}): Promise<unknown>;
|
|
65
|
+
private noPairMessage;
|
|
51
66
|
status(): {
|
|
52
67
|
extensionConnected: boolean;
|
|
53
68
|
port: number;
|
|
54
|
-
|
|
69
|
+
connectedProfiles: string[];
|
|
55
70
|
};
|
|
56
71
|
private handleConnection;
|
|
57
72
|
private reject;
|
|
@@ -20,14 +20,45 @@ const policy_1 = require("../../shared/policy");
|
|
|
20
20
|
const types_1 = require("../executor/types");
|
|
21
21
|
const connection_1 = require("./connection");
|
|
22
22
|
const auth_1 = require("./auth");
|
|
23
|
+
const config_1 = require("../config");
|
|
24
|
+
/** The routing label for a hello with no/blank profile — the back-compat default. */
|
|
25
|
+
const DEFAULT_PROFILE = 'default';
|
|
26
|
+
/** Reduce a hello's profile label to a safe routing key; blank/invalid → "default". */
|
|
27
|
+
function routeKey(profile) {
|
|
28
|
+
if (!profile || !profile.trim())
|
|
29
|
+
return DEFAULT_PROFILE;
|
|
30
|
+
try {
|
|
31
|
+
return (0, config_1.sanitizeName)(profile, 'profile');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return DEFAULT_PROFILE;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
23
37
|
const HELLO_TIMEOUT_MS = 5_000;
|
|
24
38
|
const DEFAULT_HEARTBEAT_MS = 15_000;
|
|
25
39
|
/** Max pre-auth frames a socket may send before a valid hello (anti-idle-hold). */
|
|
26
40
|
const MAX_PREAUTH_FRAMES = 10;
|
|
41
|
+
/** How long to wait for a just-replaced instance to release a fixed port before giving up. */
|
|
42
|
+
const PORT_WAIT_MS = 4_000;
|
|
43
|
+
/** Pause between port-bind retries while waiting for the old listener to exit. */
|
|
44
|
+
const PORT_RETRY_MS = 250;
|
|
45
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
46
|
+
/** A friendly, actionable message for the rare case the port stays busy past PORT_WAIT_MS. */
|
|
47
|
+
function portBusyMessage(host, port) {
|
|
48
|
+
return (`Couldn't start: another program is already using ${host}:${port}.\n` +
|
|
49
|
+
`This is almost always a previous chrome-mcp that didn't shut down. To fix it:\n` +
|
|
50
|
+
` 1. Fully quit/restart your MCP host (e.g. Claude Code), or\n` +
|
|
51
|
+
` 2. Stop the leftover process, then reconnect:\n` +
|
|
52
|
+
` macOS/Linux: lsof -nP -iTCP:${port} -sTCP:LISTEN then kill <PID>\n` +
|
|
53
|
+
` 3. Or run chrome-mcp with a different port: --port <number>`);
|
|
54
|
+
}
|
|
27
55
|
class BridgeServer {
|
|
28
56
|
opts;
|
|
29
57
|
wss = null;
|
|
30
|
-
|
|
58
|
+
/** Profile routing key → its live connection. Multiple browsers stay paired at
|
|
59
|
+
* once; a command is routed to the connection for its target profile. A new
|
|
60
|
+
* hello for the SAME profile supersedes that profile's connection only. */
|
|
61
|
+
conns = new Map();
|
|
31
62
|
boundPort = 0;
|
|
32
63
|
heartbeatMs;
|
|
33
64
|
constructor(opts) {
|
|
@@ -38,21 +69,63 @@ class BridgeServer {
|
|
|
38
69
|
async start() {
|
|
39
70
|
if (this.wss)
|
|
40
71
|
return this.boundPort;
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
72
|
+
const host = this.opts.host ?? protocol_1.BRIDGE_HOST;
|
|
73
|
+
const port = this.opts.port ?? 0;
|
|
74
|
+
// A fixed port can be briefly held by a just-replaced instance of ourselves
|
|
75
|
+
// (e.g. on a host "Reconnect"). Rather than crash with a cryptic EADDRINUSE,
|
|
76
|
+
// wait-and-retry for a few seconds so the old process can release it; only if
|
|
77
|
+
// it never frees up do we surface a plain-English, actionable error.
|
|
78
|
+
const deadline = Date.now() + PORT_WAIT_MS;
|
|
79
|
+
for (let attempt = 1;; attempt++) {
|
|
80
|
+
try {
|
|
81
|
+
const wss = await this.listenOnce(host, port);
|
|
82
|
+
const addr = wss.address();
|
|
83
|
+
this.boundPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
84
|
+
this.wss = wss;
|
|
85
|
+
this.log(`bridge listening on ${host}:${this.boundPort}`);
|
|
86
|
+
return this.boundPort;
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
const inUse = err?.code === 'EADDRINUSE';
|
|
90
|
+
if (!inUse || port === 0 || Date.now() >= deadline) {
|
|
91
|
+
if (inUse)
|
|
92
|
+
throw new Error(portBusyMessage(host, port));
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
if (attempt === 1)
|
|
96
|
+
this.log(`port ${host}:${port} busy — waiting for the previous instance to release it…`);
|
|
97
|
+
await delay(PORT_RETRY_MS);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** One bind attempt. Resolves with a listening server or rejects with the listen error. */
|
|
102
|
+
listenOnce(host, port) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const wss = new ws_1.WebSocketServer({ host, port });
|
|
105
|
+
const onError = (err) => {
|
|
106
|
+
wss.off('listening', onListening);
|
|
107
|
+
// Close so the failed server doesn't linger and leak a handle on retry.
|
|
108
|
+
try {
|
|
109
|
+
wss.close();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* ignore */
|
|
113
|
+
}
|
|
114
|
+
reject(err);
|
|
115
|
+
};
|
|
116
|
+
const onListening = () => {
|
|
117
|
+
wss.off('error', onError);
|
|
118
|
+
wss.on('connection', (ws) => this.handleConnection(ws));
|
|
119
|
+
resolve(wss);
|
|
120
|
+
};
|
|
121
|
+
wss.once('error', onError);
|
|
122
|
+
wss.once('listening', onListening);
|
|
46
123
|
});
|
|
47
|
-
const addr = wss.address();
|
|
48
|
-
this.boundPort = typeof addr === 'object' && addr ? addr.port : (this.opts.port ?? 0);
|
|
49
|
-
this.wss = wss;
|
|
50
|
-
this.log(`bridge listening on ${this.opts.host ?? protocol_1.BRIDGE_HOST}:${this.boundPort}`);
|
|
51
|
-
return this.boundPort;
|
|
52
124
|
}
|
|
53
125
|
async stop() {
|
|
54
|
-
this.
|
|
55
|
-
|
|
126
|
+
for (const conn of this.conns.values())
|
|
127
|
+
conn.close(1001, 'server stopping');
|
|
128
|
+
this.conns.clear();
|
|
56
129
|
const wss = this.wss;
|
|
57
130
|
this.wss = null;
|
|
58
131
|
if (wss)
|
|
@@ -61,21 +134,48 @@ class BridgeServer {
|
|
|
61
134
|
get port() {
|
|
62
135
|
return this.boundPort;
|
|
63
136
|
}
|
|
137
|
+
/** True when ANY browser is paired (used as the selector's cheap gate). */
|
|
64
138
|
hasActiveExtension() {
|
|
65
|
-
|
|
139
|
+
for (const conn of this.conns.values())
|
|
140
|
+
if (conn.isOpen())
|
|
141
|
+
return true;
|
|
142
|
+
return false;
|
|
66
143
|
}
|
|
67
|
-
/**
|
|
144
|
+
/** True when the given profile has a live connection. */
|
|
145
|
+
hasConnection(profile) {
|
|
146
|
+
const conn = this.conns.get(routeKey(profile));
|
|
147
|
+
return !!conn && conn.isOpen();
|
|
148
|
+
}
|
|
149
|
+
/** Profiles with a live connection right now. */
|
|
150
|
+
connectedProfiles() {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const [profile, conn] of this.conns)
|
|
153
|
+
if (conn.isOpen())
|
|
154
|
+
out.push(profile);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Send a command to the connection for `opts.profile` (default "default").
|
|
159
|
+
* Rejects with an actionable message if that profile has no live browser.
|
|
160
|
+
*/
|
|
68
161
|
async sendCommand(method, params, opts) {
|
|
69
|
-
|
|
70
|
-
|
|
162
|
+
const profile = routeKey(opts?.profile);
|
|
163
|
+
const conn = this.conns.get(profile);
|
|
164
|
+
if (!conn || !conn.isOpen()) {
|
|
165
|
+
throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', this.noPairMessage(profile));
|
|
71
166
|
}
|
|
72
|
-
return
|
|
167
|
+
return conn.sendCommand(method, params, opts);
|
|
168
|
+
}
|
|
169
|
+
noPairMessage(profile) {
|
|
170
|
+
return (`No browser is paired for profile "${profile}". In that Chrome's chrome-mcp ` +
|
|
171
|
+
`extension Options, set Port ${this.boundPort}, paste the token, set Profile to ` +
|
|
172
|
+
`"${profile}", and Save — then it joins without disturbing your other profiles.`);
|
|
73
173
|
}
|
|
74
174
|
status() {
|
|
75
175
|
return {
|
|
76
176
|
extensionConnected: this.hasActiveExtension(),
|
|
77
177
|
port: this.boundPort,
|
|
78
|
-
|
|
178
|
+
connectedProfiles: this.connectedProfiles(),
|
|
79
179
|
};
|
|
80
180
|
}
|
|
81
181
|
// -- internals ----------------------------------------------------------
|
|
@@ -119,11 +219,11 @@ class BridgeServer {
|
|
|
119
219
|
this.reject(ws, 'bad_token');
|
|
120
220
|
return;
|
|
121
221
|
}
|
|
122
|
-
// Authenticated. Hand the socket to an ExtensionConnection.
|
|
222
|
+
// Authenticated. Hand the socket to an ExtensionConnection under its profile.
|
|
123
223
|
authed = true;
|
|
124
224
|
clearTimeout(helloTimer);
|
|
125
225
|
ws.off('message', onMessage);
|
|
126
|
-
this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' });
|
|
226
|
+
this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' }, routeKey(frame.profile));
|
|
127
227
|
};
|
|
128
228
|
ws.on('message', onMessage);
|
|
129
229
|
ws.on('error', () => {
|
|
@@ -140,12 +240,14 @@ class BridgeServer {
|
|
|
140
240
|
/* ignore */
|
|
141
241
|
}
|
|
142
242
|
}
|
|
143
|
-
promote(ws, ext) {
|
|
243
|
+
promote(ws, ext, profile) {
|
|
144
244
|
const sessionId = (0, node_crypto_1.randomUUID)();
|
|
145
|
-
|
|
146
|
-
|
|
245
|
+
// Supersede only the SAME profile's connection (a re-pair). Other profiles
|
|
246
|
+
// keep their live connections, so several browsers stay paired at once.
|
|
247
|
+
const prev = this.conns.get(profile);
|
|
248
|
+
if (prev && prev.isOpen()) {
|
|
147
249
|
const differentId = prev.extId !== ext.id;
|
|
148
|
-
this.log(`extension "${ext.id}" superseded
|
|
250
|
+
this.log(`extension "${ext.id}" superseded profile "${profile}" connection "${prev.extId}"` +
|
|
149
251
|
(differentId ? ' (DIFFERENT id — possible hijack; surfaced to status)' : ''));
|
|
150
252
|
try {
|
|
151
253
|
this.opts.onDisplacement?.({ oldExtId: prev.extId, newExtId: ext.id, differentId });
|
|
@@ -164,11 +266,12 @@ class BridgeServer {
|
|
|
164
266
|
onEvent: this.opts.onEvent,
|
|
165
267
|
onLog: (m) => this.log(m),
|
|
166
268
|
onClose: () => {
|
|
167
|
-
if
|
|
168
|
-
|
|
269
|
+
// Only clear if a newer re-pair hasn't already replaced this slot.
|
|
270
|
+
if (this.conns.get(profile)?.sessionId === sessionId)
|
|
271
|
+
this.conns.delete(profile);
|
|
169
272
|
},
|
|
170
273
|
});
|
|
171
|
-
this.
|
|
274
|
+
this.conns.set(profile, conn);
|
|
172
275
|
const welcome = {
|
|
173
276
|
type: 'welcome',
|
|
174
277
|
v: protocol_1.PROTOCOL_VERSION,
|
|
@@ -178,7 +281,7 @@ class BridgeServer {
|
|
|
178
281
|
policy: this.opts.policy ?? policy_1.DENY_ALL_WIRE_POLICY,
|
|
179
282
|
};
|
|
180
283
|
this.send(ws, welcome);
|
|
181
|
-
this.log(`extension paired (session ${sessionId}, id "${ext.id}")`);
|
|
284
|
+
this.log(`extension paired (profile "${profile}", session ${sessionId}, id "${ext.id}")`);
|
|
182
285
|
}
|
|
183
286
|
send(ws, frame) {
|
|
184
287
|
try {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/bridge/tasks.ts — listing and garbage-collection over the per-profile
|
|
3
|
+
* task workspaces written by `ensureWorkspace` (datadir.ts).
|
|
4
|
+
*
|
|
5
|
+
* A task is `profiles/<profile>/tasks/<task>/`, carrying a `meta.json` and a
|
|
6
|
+
* `downloads/` bucket. These helpers walk that tree read-only (listTasks) or
|
|
7
|
+
* prune it (gcTasks). `now` is threaded in rather than read from the clock so
|
|
8
|
+
* GC is deterministic under test.
|
|
9
|
+
*/
|
|
10
|
+
export interface TaskInfo {
|
|
11
|
+
profile: string;
|
|
12
|
+
task: string;
|
|
13
|
+
/** Absolute path to `profiles/<profile>/tasks/<task>/`. */
|
|
14
|
+
dir: string;
|
|
15
|
+
/** ISO timestamp from meta.json, falling back to the dir's mtime. */
|
|
16
|
+
createdAt: string;
|
|
17
|
+
/** Total bytes under the task dir (downloads + meta.json; excludes the profile). */
|
|
18
|
+
bytes: number;
|
|
19
|
+
/** File count in `downloads/`. */
|
|
20
|
+
downloads: number;
|
|
21
|
+
}
|
|
22
|
+
/** Enumerate every task across every profile, newest first. */
|
|
23
|
+
export declare function listTasks(dataDir: string): TaskInfo[];
|
|
24
|
+
export interface GcOptions {
|
|
25
|
+
/** Remove tasks created more than this many days ago. */
|
|
26
|
+
olderThanDays?: number;
|
|
27
|
+
/** Always retain the newest N tasks (per scope), regardless of age. */
|
|
28
|
+
keep?: number;
|
|
29
|
+
/** Limit to a single profile; otherwise all profiles. */
|
|
30
|
+
profile?: string;
|
|
31
|
+
/** Compute what would be removed without deleting anything. */
|
|
32
|
+
dryRun?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface GcResult {
|
|
35
|
+
removed: TaskInfo[];
|
|
36
|
+
freedBytes: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Prune task workspaces. A task is removed when it is NOT among the newest
|
|
40
|
+
* `keep` (if set) AND is older than `olderThanDays` (if set). At least one of
|
|
41
|
+
* `keep`/`olderThanDays` must be provided — the caller is responsible for
|
|
42
|
+
* refusing an unbounded sweep. `dryRun` reports the selection without deleting.
|
|
43
|
+
*/
|
|
44
|
+
export declare function gcTasks(dataDir: string, opts: GcOptions, now: number): GcResult;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/bridge/tasks.ts — listing and garbage-collection over the per-profile
|
|
4
|
+
* task workspaces written by `ensureWorkspace` (datadir.ts).
|
|
5
|
+
*
|
|
6
|
+
* A task is `profiles/<profile>/tasks/<task>/`, carrying a `meta.json` and a
|
|
7
|
+
* `downloads/` bucket. These helpers walk that tree read-only (listTasks) or
|
|
8
|
+
* prune it (gcTasks). `now` is threaded in rather than read from the clock so
|
|
9
|
+
* GC is deterministic under test.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.listTasks = listTasks;
|
|
13
|
+
exports.gcTasks = gcTasks;
|
|
14
|
+
const node_fs_1 = require("node:fs");
|
|
15
|
+
const node_path_1 = require("node:path");
|
|
16
|
+
function subdirs(dir) {
|
|
17
|
+
try {
|
|
18
|
+
return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })
|
|
19
|
+
.filter((e) => e.isDirectory())
|
|
20
|
+
.map((e) => e.name);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function dirSize(dir) {
|
|
27
|
+
let total = 0;
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
for (const e of entries) {
|
|
36
|
+
const p = (0, node_path_1.join)(dir, e.name);
|
|
37
|
+
if (e.isDirectory())
|
|
38
|
+
total += dirSize(p);
|
|
39
|
+
else {
|
|
40
|
+
try {
|
|
41
|
+
total += (0, node_fs_1.statSync)(p).size;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* vanished mid-walk */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return total;
|
|
49
|
+
}
|
|
50
|
+
function countFiles(dir) {
|
|
51
|
+
try {
|
|
52
|
+
return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true }).filter((e) => e.isFile()).length;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function readCreatedAt(taskDir) {
|
|
59
|
+
try {
|
|
60
|
+
const meta = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(taskDir, 'meta.json'), 'utf8'));
|
|
61
|
+
if (typeof meta.createdAt === 'string')
|
|
62
|
+
return meta.createdAt;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* fall through to mtime */
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return (0, node_fs_1.statSync)(taskDir).mtime.toISOString();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Enumerate every task across every profile, newest first. */
|
|
75
|
+
function listTasks(dataDir) {
|
|
76
|
+
const profilesRoot = (0, node_path_1.join)(dataDir, 'profiles');
|
|
77
|
+
if (!(0, node_fs_1.existsSync)(profilesRoot))
|
|
78
|
+
return [];
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const profile of subdirs(profilesRoot)) {
|
|
81
|
+
const tasksRoot = (0, node_path_1.join)(profilesRoot, profile, 'tasks');
|
|
82
|
+
for (const task of subdirs(tasksRoot)) {
|
|
83
|
+
const dir = (0, node_path_1.join)(tasksRoot, task);
|
|
84
|
+
out.push({
|
|
85
|
+
profile,
|
|
86
|
+
task,
|
|
87
|
+
dir,
|
|
88
|
+
createdAt: readCreatedAt(dir),
|
|
89
|
+
bytes: dirSize(dir),
|
|
90
|
+
downloads: countFiles((0, node_path_1.join)(dir, 'downloads')),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Prune task workspaces. A task is removed when it is NOT among the newest
|
|
98
|
+
* `keep` (if set) AND is older than `olderThanDays` (if set). At least one of
|
|
99
|
+
* `keep`/`olderThanDays` must be provided — the caller is responsible for
|
|
100
|
+
* refusing an unbounded sweep. `dryRun` reports the selection without deleting.
|
|
101
|
+
*/
|
|
102
|
+
function gcTasks(dataDir, opts, now) {
|
|
103
|
+
if (opts.olderThanDays === undefined && opts.keep === undefined) {
|
|
104
|
+
throw new Error('gcTasks requires olderThanDays or keep (refusing to remove every task)');
|
|
105
|
+
}
|
|
106
|
+
const scoped = listTasks(dataDir).filter((t) => !opts.profile || t.profile === opts.profile);
|
|
107
|
+
const protectedDirs = new Set(opts.keep === undefined ? [] : scoped.slice(0, opts.keep).map((t) => t.dir));
|
|
108
|
+
const ageCutoffMs = (opts.olderThanDays ?? 0) * 86_400_000;
|
|
109
|
+
const removed = scoped.filter((t) => {
|
|
110
|
+
if (protectedDirs.has(t.dir))
|
|
111
|
+
return false;
|
|
112
|
+
if (opts.olderThanDays !== undefined) {
|
|
113
|
+
const created = Date.parse(t.createdAt);
|
|
114
|
+
if (Number.isNaN(created) || now - created <= ageCutoffMs)
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
});
|
|
119
|
+
if (!opts.dryRun) {
|
|
120
|
+
for (const t of removed) {
|
|
121
|
+
try {
|
|
122
|
+
(0, node_fs_1.rmSync)(t.dir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
/* best effort */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { removed, freedBytes: removed.reduce((sum, t) => sum + t.bytes, 0) };
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=tasks.js.map
|