browser-automation-skill 0.71.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 +144 -0
- package/SECURITY.md +39 -0
- package/SKILL.md +206 -0
- package/bin/cli.mjs +55 -0
- package/install.sh +143 -0
- package/package.json +54 -0
- package/references/adapter-candidates.md +40 -0
- package/references/browser-mcp-cheatsheet.md +132 -0
- package/references/browser-stats-cheatsheet.md +155 -0
- package/references/chrome-devtools-mcp-cheatsheet.md +232 -0
- package/references/midscene-integration.md +359 -0
- package/references/obscura-cheatsheet.md +103 -0
- package/references/playwright-cli-cheatsheet.md +64 -0
- package/references/playwright-lib-cheatsheet.md +90 -0
- package/references/recipes/add-a-tool-adapter.md +134 -0
- package/references/recipes/agent-workflows/README.md +37 -0
- package/references/recipes/agent-workflows/cache-driven-bulk-operation.md +110 -0
- package/references/recipes/agent-workflows/flow-record-and-replay.md +102 -0
- package/references/recipes/agent-workflows/incremental-pattern-discovery.md +125 -0
- package/references/recipes/agent-workflows/login-then-scrape.md +100 -0
- package/references/recipes/anti-patterns-tool-extension.md +182 -0
- package/references/recipes/body-bytes-not-body.md +139 -0
- package/references/recipes/cache-write-security.md +210 -0
- package/references/recipes/fingerprint-rescue.md +154 -0
- package/references/recipes/model-routing.md +143 -0
- package/references/recipes/path-security.md +138 -0
- package/references/recipes/privacy-canary.md +96 -0
- package/references/recipes/visual-rescue-hook.md +182 -0
- package/references/stats-prices.json +42 -0
- package/references/stats-schema.json +77 -0
- package/references/tool-versions.md +8 -0
- package/scripts/browser-add-site.sh +113 -0
- package/scripts/browser-assert.sh +106 -0
- package/scripts/browser-audit.sh +68 -0
- package/scripts/browser-baseline.sh +135 -0
- package/scripts/browser-click.sh +100 -0
- package/scripts/browser-creds-add.sh +254 -0
- package/scripts/browser-creds-list.sh +67 -0
- package/scripts/browser-creds-migrate.sh +122 -0
- package/scripts/browser-creds-remove.sh +69 -0
- package/scripts/browser-creds-rotate-totp.sh +109 -0
- package/scripts/browser-creds-show.sh +82 -0
- package/scripts/browser-creds-totp.sh +94 -0
- package/scripts/browser-do.sh +630 -0
- package/scripts/browser-doctor.sh +365 -0
- package/scripts/browser-drag.sh +90 -0
- package/scripts/browser-extract.sh +192 -0
- package/scripts/browser-fill.sh +142 -0
- package/scripts/browser-flow.sh +316 -0
- package/scripts/browser-history.sh +187 -0
- package/scripts/browser-hover.sh +92 -0
- package/scripts/browser-inspect.sh +188 -0
- package/scripts/browser-list-sessions.sh +78 -0
- package/scripts/browser-list-sites.sh +42 -0
- package/scripts/browser-login.sh +279 -0
- package/scripts/browser-mcp.sh +65 -0
- package/scripts/browser-migrate.sh +195 -0
- package/scripts/browser-open.sh +134 -0
- package/scripts/browser-press.sh +80 -0
- package/scripts/browser-remove-session.sh +72 -0
- package/scripts/browser-remove-site.sh +68 -0
- package/scripts/browser-replay.sh +206 -0
- package/scripts/browser-route.sh +174 -0
- package/scripts/browser-select.sh +122 -0
- package/scripts/browser-show-session.sh +57 -0
- package/scripts/browser-show-site.sh +37 -0
- package/scripts/browser-snapshot.sh +176 -0
- package/scripts/browser-stats.sh +522 -0
- package/scripts/browser-tab-close.sh +112 -0
- package/scripts/browser-tab-list.sh +70 -0
- package/scripts/browser-tab-switch.sh +111 -0
- package/scripts/browser-upload.sh +132 -0
- package/scripts/browser-use.sh +60 -0
- package/scripts/browser-vlm.sh +707 -0
- package/scripts/browser-wait.sh +97 -0
- package/scripts/install-git-hooks.sh +16 -0
- package/scripts/lib/capture.sh +356 -0
- package/scripts/lib/common.sh +262 -0
- package/scripts/lib/credential.sh +237 -0
- package/scripts/lib/fingerprint-rescue.js +123 -0
- package/scripts/lib/flow.sh +448 -0
- package/scripts/lib/flow_record.sh +210 -0
- package/scripts/lib/mask.sh +49 -0
- package/scripts/lib/memory.sh +427 -0
- package/scripts/lib/migrate.sh +390 -0
- package/scripts/lib/migrators/README.md +23 -0
- package/scripts/lib/migrators/memory/v1_to_v2.sh +15 -0
- package/scripts/lib/migrators/recent_urls/README.md +13 -0
- package/scripts/lib/migrators/stats/README.md +24 -0
- package/scripts/lib/node/chrome-devtools-bridge.mjs +1812 -0
- package/scripts/lib/node/mcp-server.mjs +531 -0
- package/scripts/lib/node/mcp-tools.json +68 -0
- package/scripts/lib/node/playwright-driver.mjs +1104 -0
- package/scripts/lib/node/totp-core.mjs +52 -0
- package/scripts/lib/node/totp.mjs +52 -0
- package/scripts/lib/node/url-pattern-cluster.mjs +102 -0
- package/scripts/lib/node/url-pattern-resolver.mjs +77 -0
- package/scripts/lib/output.sh +79 -0
- package/scripts/lib/router.sh +342 -0
- package/scripts/lib/sanitize.sh +107 -0
- package/scripts/lib/secret/keychain.sh +91 -0
- package/scripts/lib/secret/libsecret.sh +74 -0
- package/scripts/lib/secret/plaintext.sh +75 -0
- package/scripts/lib/secret_backend_select.sh +57 -0
- package/scripts/lib/session.sh +153 -0
- package/scripts/lib/site.sh +126 -0
- package/scripts/lib/stats.sh +419 -0
- package/scripts/lib/tool/.gitkeep +0 -0
- package/scripts/lib/tool/chrome-devtools-mcp.sh +349 -0
- package/scripts/lib/tool/obscura.sh +249 -0
- package/scripts/lib/tool/playwright-cli.sh +155 -0
- package/scripts/lib/tool/playwright-lib.sh +106 -0
- package/scripts/lib/verb_helpers.sh +222 -0
- package/scripts/lib/visual-rescue-default.sh +145 -0
- package/scripts/regenerate-docs.sh +99 -0
- package/uninstall.sh +51 -0
|
@@ -0,0 +1,1812 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lib/node/chrome-devtools-bridge.mjs
|
|
3
|
+
//
|
|
4
|
+
// Bridge between the chrome-devtools-mcp adapter (bash) and the upstream
|
|
5
|
+
// chrome-devtools-mcp MCP server (`npx chrome-devtools-mcp@latest`, JSON-RPC
|
|
6
|
+
// 2.0 NDJSON over stdio). Mirrors `scripts/lib/node/playwright-driver.mjs`
|
|
7
|
+
// in shape: stub-mode branch up front, real-mode below.
|
|
8
|
+
//
|
|
9
|
+
// Stub mode (BROWSER_SKILL_LIB_STUB=1):
|
|
10
|
+
// - No MCP server spawned.
|
|
11
|
+
// - argv hashed (sha256 of args joined+terminated by NUL — matches the
|
|
12
|
+
// `printf '%s\0' "$@" | shasum -a 256` form so fixtures generated for the
|
|
13
|
+
// phase-5 part-1 bash stub work unchanged).
|
|
14
|
+
// - Fixture under ${CHROME_DEVTOOLS_MCP_FIXTURES_DIR} is echoed.
|
|
15
|
+
// - Miss → error JSON + exit 41 (EXIT_TOOL_UNSUPPORTED_OP).
|
|
16
|
+
// - argv logged to ${STUB_LOG_FILE}.
|
|
17
|
+
//
|
|
18
|
+
// Real mode (default):
|
|
19
|
+
// - Stateless one-shot (open / snapshot / eval / audit, when no daemon):
|
|
20
|
+
// spawn ${CHROME_DEVTOOLS_MCP_BIN:-chrome-devtools-mcp} per call, initialize,
|
|
21
|
+
// dispatch, exit. Original phase-5 part 1c behaviour.
|
|
22
|
+
// - Daemon mode (phase-5 part 1c-ii): `daemon-start` spawns a detached child
|
|
23
|
+
// that holds ONE long-lived MCP server child + the eN ↔ uid ref map +
|
|
24
|
+
// a TCP loopback IPC server. State written to
|
|
25
|
+
// ${BROWSER_SKILL_HOME}/cdt-mcp-daemon.json (mode 0600, dir 0700).
|
|
26
|
+
// - Stateful verbs (click / fill) require a running daemon — they connect
|
|
27
|
+
// over IPC and translate `ref: eN` → `uid` server-side before tools/call.
|
|
28
|
+
// Without daemon → exit 41 with hint.
|
|
29
|
+
// - Stateful verbs `inspect` / `extract` still exit 41 — bundled with their
|
|
30
|
+
// verb-script counterparts in phase-5 part 1e.
|
|
31
|
+
//
|
|
32
|
+
// Argv shape: `bridge.mjs <verb> [...args]` — same as the bash adapter passes.
|
|
33
|
+
//
|
|
34
|
+
// Tests:
|
|
35
|
+
// tests/chrome-devtools-bridge_real.bats — one-shot real mode (part 1c)
|
|
36
|
+
// tests/chrome-devtools-mcp_daemon_e2e.bats — daemon + click/fill (part 1c-ii)
|
|
37
|
+
// Both invoke this against tests/stubs/mcp-server-stub.mjs (a node script
|
|
38
|
+
// speaking the same MCP wire protocol) so CI runs without
|
|
39
|
+
// `npx chrome-devtools-mcp@latest` (which needs network + Chrome).
|
|
40
|
+
|
|
41
|
+
import { createHash } from 'node:crypto';
|
|
42
|
+
import { spawn } from 'node:child_process';
|
|
43
|
+
import {
|
|
44
|
+
readFileSync, writeFileSync, existsSync, appendFileSync,
|
|
45
|
+
unlinkSync, chmodSync, mkdirSync, openSync,
|
|
46
|
+
} from 'node:fs';
|
|
47
|
+
import { createServer, createConnection } from 'node:net';
|
|
48
|
+
import { dirname, join } from 'node:path';
|
|
49
|
+
import { fileURLToPath } from 'node:url';
|
|
50
|
+
import { homedir } from 'node:os';
|
|
51
|
+
import readline from 'node:readline';
|
|
52
|
+
|
|
53
|
+
const argv = process.argv.slice(2);
|
|
54
|
+
|
|
55
|
+
// ----------------------------------------------------------------------------
|
|
56
|
+
// Stub mode (unchanged from part 1b)
|
|
57
|
+
// ----------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function stubDispatch(args) {
|
|
60
|
+
const logFile = process.env.STUB_LOG_FILE;
|
|
61
|
+
if (logFile) {
|
|
62
|
+
const ts = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
|
|
63
|
+
let chunk = `--- ${ts} ---\n`;
|
|
64
|
+
for (const a of args) chunk += `${a}\n`;
|
|
65
|
+
appendFileSync(logFile, chunk);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const data = args.map((a) => a + '\0').join('');
|
|
69
|
+
const hash = createHash('sha256').update(data).digest('hex');
|
|
70
|
+
|
|
71
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
72
|
+
const fixturesDir =
|
|
73
|
+
process.env.CHROME_DEVTOOLS_MCP_FIXTURES_DIR ||
|
|
74
|
+
join(here, '..', '..', '..', 'tests', 'fixtures', 'chrome-devtools-mcp');
|
|
75
|
+
const fixturePath = join(fixturesDir, `${hash}.json`);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
process.stdout.write(readFileSync(fixturePath, 'utf8'));
|
|
79
|
+
} catch {
|
|
80
|
+
process.stdout.write(
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
status: 'error',
|
|
83
|
+
reason: `no fixture for argv-hash ${hash}`,
|
|
84
|
+
argv: args,
|
|
85
|
+
}) + '\n'
|
|
86
|
+
);
|
|
87
|
+
process.exit(41);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ----------------------------------------------------------------------------
|
|
92
|
+
// Real mode — constants
|
|
93
|
+
// ----------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
96
|
+
const INIT_TIMEOUT_MS = 5000;
|
|
97
|
+
const CALL_TIMEOUT_MS = 30000;
|
|
98
|
+
const AUDIT_TIMEOUT_MS = 60000; // lighthouse can take ~30-60s
|
|
99
|
+
const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
100
|
+
const IPC_TIMEOUT_MS = parseInt(process.env.BROWSER_SKILL_LIB_TIMEOUT_MS || '30000', 10);
|
|
101
|
+
|
|
102
|
+
// ----------------------------------------------------------------------------
|
|
103
|
+
// Real mode — entry dispatcher
|
|
104
|
+
// ----------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
async function realDispatch(args) {
|
|
107
|
+
if (args.length === 0) {
|
|
108
|
+
throw withExit(2, 'bridge: no verb supplied');
|
|
109
|
+
}
|
|
110
|
+
const verb = args[0];
|
|
111
|
+
const verbArgs = args.slice(1);
|
|
112
|
+
|
|
113
|
+
// Daemon lifecycle verbs.
|
|
114
|
+
if (verb === 'daemon-start') return await runDaemonStart(verbArgs);
|
|
115
|
+
if (verb === 'daemon-stop') return runDaemonStop();
|
|
116
|
+
if (verb === 'daemon-status') return runDaemonStatus();
|
|
117
|
+
|
|
118
|
+
// Stateful verbs (click / fill / select / hover / drag / upload) require a
|
|
119
|
+
// running daemon — refMap precondition.
|
|
120
|
+
if (verb === 'click' || verb === 'fill' || verb === 'select'
|
|
121
|
+
|| verb === 'hover' || verb === 'drag' || verb === 'upload') {
|
|
122
|
+
return await runStatefulViaDaemon(verb, verbArgs);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// route is daemon-state-mutating (registers rules in the daemon's
|
|
126
|
+
// routeRules slot). Daemon-required (like the stateful verbs above) but
|
|
127
|
+
// doesn't depend on refMap.
|
|
128
|
+
if (verb === 'route') {
|
|
129
|
+
return await runRouteViaDaemon(verbArgs);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// tab-list is read-only enumeration but daemon-required so it can cache
|
|
133
|
+
// results in the daemon's `tabs` slot (8-ii / 8-iii will mutate the same
|
|
134
|
+
// slot — landing 8-i daemon-required avoids retroactively changing the
|
|
135
|
+
// contract later). No args.
|
|
136
|
+
if (verb === 'tab-list') {
|
|
137
|
+
return await runTabListViaDaemon(verbArgs);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// tab-switch is the first state-mutation on tabs[] — adds currentTab
|
|
141
|
+
// pointer (1-based tab_id). Mutex selector: --by-index N | --by-url-pattern.
|
|
142
|
+
if (verb === 'tab-switch') {
|
|
143
|
+
return await runTabSwitchViaDaemon(verbArgs);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// tab-close splices from tabs[] + closes upstream page + nulls currentTab
|
|
147
|
+
// on match. Mutex selector: --tab-id N | --by-url-pattern STR.
|
|
148
|
+
if (verb === 'tab-close') {
|
|
149
|
+
return await runTabCloseViaDaemon(verbArgs);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Multi-call verbs (inspect / extract) — phase-05 part 1e-ii. Route through
|
|
153
|
+
// daemon if running (shared long-lived MCP child); otherwise spawn one-shot
|
|
154
|
+
// and run all sub-calls before shutdown.
|
|
155
|
+
if (verb === 'inspect' || verb === 'extract') {
|
|
156
|
+
return await runInspectOrExtract(verb, verbArgs);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Stateless verbs (open / snapshot / eval / audit). When daemon is running,
|
|
160
|
+
// route through it so the same MCP server child + state are reused. Without
|
|
161
|
+
// daemon, fall back to one-shot (spawn-per-call) — original part 1c path.
|
|
162
|
+
if (isDaemonAlive()) {
|
|
163
|
+
return await runStatelessViaDaemon(verb, verbArgs);
|
|
164
|
+
}
|
|
165
|
+
return await runStatelessOneShot(verb, verbArgs);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ----------------------------------------------------------------------------
|
|
169
|
+
// Stateless one-shot path (verbatim from part 1c — preserved for daemon-off)
|
|
170
|
+
// ----------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
async function runStatelessOneShot(verb, verbArgs) {
|
|
173
|
+
const tx = translateVerb(verb, verbArgs);
|
|
174
|
+
if (tx.tool === null) {
|
|
175
|
+
// Should not happen — caller already filtered click/fill/inspect/extract.
|
|
176
|
+
throw withExit(41, `verb '${verb}' has no MCP tool mapping`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const bin = process.env.CHROME_DEVTOOLS_MCP_BIN || 'chrome-devtools-mcp';
|
|
180
|
+
let child;
|
|
181
|
+
try {
|
|
182
|
+
child = spawn(bin, mcpSpawnArgs(), { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
throw withExit(41, `failed to spawn MCP server '${bin}': ${err.message}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let childExited = false;
|
|
188
|
+
let childExitCode = null;
|
|
189
|
+
child.on('exit', (code) => {
|
|
190
|
+
childExited = true;
|
|
191
|
+
childExitCode = code;
|
|
192
|
+
});
|
|
193
|
+
child.on('error', (err) => {
|
|
194
|
+
childExited = true;
|
|
195
|
+
process.stderr.write(`chrome-devtools-bridge: child error: ${err.message}\n`);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const reader = makeJsonRpcReader(child.stdout);
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
await sendJsonRpc(child.stdin, {
|
|
202
|
+
jsonrpc: '2.0',
|
|
203
|
+
id: 1,
|
|
204
|
+
method: 'initialize',
|
|
205
|
+
params: {
|
|
206
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
207
|
+
capabilities: {},
|
|
208
|
+
clientInfo: { name: 'browser-skill', version: '0.10' },
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
const initResp = await reader.waitFor(1, INIT_TIMEOUT_MS);
|
|
212
|
+
if (initResp.error) {
|
|
213
|
+
throw withExit(42, `MCP initialize failed: ${initResp.error.message}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await sendJsonRpc(child.stdin, {
|
|
217
|
+
jsonrpc: '2.0',
|
|
218
|
+
method: 'notifications/initialized',
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const callTimeout = verb === 'audit' ? AUDIT_TIMEOUT_MS : CALL_TIMEOUT_MS;
|
|
222
|
+
await sendJsonRpc(child.stdin, {
|
|
223
|
+
jsonrpc: '2.0',
|
|
224
|
+
id: 2,
|
|
225
|
+
method: 'tools/call',
|
|
226
|
+
params: { name: tx.tool, arguments: tx.args },
|
|
227
|
+
});
|
|
228
|
+
const callResp = await reader.waitFor(2, callTimeout);
|
|
229
|
+
if (callResp.error) {
|
|
230
|
+
throw withExit(42, `MCP tools/call '${tx.tool}' failed: ${callResp.error.message}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const summary = shapeResponse(verb, tx, callResp.result);
|
|
234
|
+
process.stdout.write(JSON.stringify(summary) + '\n');
|
|
235
|
+
} finally {
|
|
236
|
+
try { child.stdin.end(); } catch (_) { /* ignore */ }
|
|
237
|
+
if (!childExited) {
|
|
238
|
+
await waitForExit(child, SHUTDOWN_TIMEOUT_MS).catch(() => {
|
|
239
|
+
try { child.kill('SIGTERM'); } catch (_) { /* ignore */ }
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (childExitCode !== null && childExitCode !== 0) {
|
|
245
|
+
process.stderr.write(
|
|
246
|
+
`chrome-devtools-bridge: MCP server exited with code ${childExitCode}\n`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ----------------------------------------------------------------------------
|
|
252
|
+
// Stateful verbs via daemon IPC
|
|
253
|
+
// ----------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
// runTabListViaDaemon — read-only enumeration of tabs/pages held by the
|
|
256
|
+
// daemon. No args. Daemon-side dispatch calls upstream MCP `list_pages`,
|
|
257
|
+
// normalizes to [{tab_id, url, title}], caches in `tabs` slot, returns.
|
|
258
|
+
async function runTabListViaDaemon(_verbArgs) {
|
|
259
|
+
if (!isDaemonAlive()) {
|
|
260
|
+
process.stderr.write(
|
|
261
|
+
'chrome-devtools-bridge: tab-list requires running daemon ' +
|
|
262
|
+
'(run: node chrome-devtools-bridge.mjs daemon-start)\n'
|
|
263
|
+
);
|
|
264
|
+
process.exit(41);
|
|
265
|
+
}
|
|
266
|
+
const reply = await ipcCall({ verb: 'tab-list' });
|
|
267
|
+
emitReply(reply);
|
|
268
|
+
process.exit(reply.status === 'error' ? 30 : 0);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// runTabCloseViaDaemon — close a tab. Mutex selectors (already enforced
|
|
272
|
+
// bash-side; bridge re-validates). Argv shape: `tab-close --tab-id N` |
|
|
273
|
+
// `tab-close --by-url-pattern STR`. Daemon splices the matching entry from
|
|
274
|
+
// tabs[], asks upstream MCP to close the page, nulls currentTab on match.
|
|
275
|
+
async function runTabCloseViaDaemon(verbArgs) {
|
|
276
|
+
if (!isDaemonAlive()) {
|
|
277
|
+
process.stderr.write(
|
|
278
|
+
'chrome-devtools-bridge: tab-close requires running daemon ' +
|
|
279
|
+
'(run: node chrome-devtools-bridge.mjs daemon-start)\n'
|
|
280
|
+
);
|
|
281
|
+
process.exit(41);
|
|
282
|
+
}
|
|
283
|
+
const msg = { verb: 'tab-close' };
|
|
284
|
+
for (let i = 0; i < verbArgs.length; i++) {
|
|
285
|
+
if (verbArgs[i] === '--tab-id') msg.tab_id = parseInt(verbArgs[++i], 10);
|
|
286
|
+
if (verbArgs[i] === '--by-url-pattern') msg.by_url_pattern = verbArgs[++i];
|
|
287
|
+
}
|
|
288
|
+
if (msg.tab_id === undefined && msg.by_url_pattern === undefined) {
|
|
289
|
+
throw withExit(2, 'tab-close requires --tab-id N or --by-url-pattern STR');
|
|
290
|
+
}
|
|
291
|
+
if (msg.tab_id !== undefined && msg.by_url_pattern !== undefined) {
|
|
292
|
+
throw withExit(2, '--tab-id and --by-url-pattern are mutually exclusive');
|
|
293
|
+
}
|
|
294
|
+
const reply = await ipcCall(msg);
|
|
295
|
+
emitReply(reply);
|
|
296
|
+
process.exit(reply.status === 'error' ? 30 : 0);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// runTabSwitchViaDaemon — switch active tab. Mutex on the two selectors,
|
|
300
|
+
// already enforced bash-side; bridge re-validates defensively. Argv shape:
|
|
301
|
+
// `tab-switch --by-index N` | `tab-switch --by-url-pattern STR`. Daemon
|
|
302
|
+
// resolves selector to tab_id, calls MCP select_page, updates currentTab.
|
|
303
|
+
async function runTabSwitchViaDaemon(verbArgs) {
|
|
304
|
+
if (!isDaemonAlive()) {
|
|
305
|
+
process.stderr.write(
|
|
306
|
+
'chrome-devtools-bridge: tab-switch requires running daemon ' +
|
|
307
|
+
'(run: node chrome-devtools-bridge.mjs daemon-start)\n'
|
|
308
|
+
);
|
|
309
|
+
process.exit(41);
|
|
310
|
+
}
|
|
311
|
+
const msg = { verb: 'tab-switch' };
|
|
312
|
+
for (let i = 0; i < verbArgs.length; i++) {
|
|
313
|
+
if (verbArgs[i] === '--by-index') msg.by_index = parseInt(verbArgs[++i], 10);
|
|
314
|
+
if (verbArgs[i] === '--by-url-pattern') msg.by_url_pattern = verbArgs[++i];
|
|
315
|
+
}
|
|
316
|
+
if (msg.by_index === undefined && msg.by_url_pattern === undefined) {
|
|
317
|
+
throw withExit(2, 'tab-switch requires --by-index N or --by-url-pattern STR');
|
|
318
|
+
}
|
|
319
|
+
if (msg.by_index !== undefined && msg.by_url_pattern !== undefined) {
|
|
320
|
+
throw withExit(2, '--by-index and --by-url-pattern are mutually exclusive');
|
|
321
|
+
}
|
|
322
|
+
const reply = await ipcCall(msg);
|
|
323
|
+
emitReply(reply);
|
|
324
|
+
process.exit(reply.status === 'error' ? 30 : 0);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// runRouteViaDaemon — register a network-route rule in the daemon. Args
|
|
328
|
+
// shape from adapter: `route <pattern> <action> [--status N] [--body STR | --body-stdin]`.
|
|
329
|
+
// Daemon stores {pattern, action} (block | allow) or
|
|
330
|
+
// {pattern, action: 'fulfill', status, body} in routeRules and best-effort
|
|
331
|
+
// calls MCP route_url tool.
|
|
332
|
+
//
|
|
333
|
+
// Phase 6 part 7-ii: fulfill action adds synthetic responses. Body via
|
|
334
|
+
// --body-stdin reads from this process's stdin (passthrough from
|
|
335
|
+
// browser-route.sh, mirrors fill --secret-stdin). Body verbatim — no trailing
|
|
336
|
+
// newline strip (HTTP bodies are content, not credentials).
|
|
337
|
+
async function runRouteViaDaemon(verbArgs) {
|
|
338
|
+
if (!isDaemonAlive()) {
|
|
339
|
+
process.stderr.write(
|
|
340
|
+
'chrome-devtools-bridge: route requires running daemon ' +
|
|
341
|
+
'(run: node chrome-devtools-bridge.mjs daemon-start)\n'
|
|
342
|
+
);
|
|
343
|
+
process.exit(41);
|
|
344
|
+
}
|
|
345
|
+
const pattern = verbArgs[0];
|
|
346
|
+
const action = verbArgs[1];
|
|
347
|
+
if (!pattern) throw withExit(2, "route requires <pattern>");
|
|
348
|
+
if (!action) throw withExit(2, "route requires <action>");
|
|
349
|
+
const msg = { verb: 'route', pattern, action };
|
|
350
|
+
let useBodyStdin = false;
|
|
351
|
+
let bodyInline;
|
|
352
|
+
for (let i = 2; i < verbArgs.length; i++) {
|
|
353
|
+
switch (verbArgs[i]) {
|
|
354
|
+
case '--status': msg.status = Number(verbArgs[++i]); break;
|
|
355
|
+
case '--body': bodyInline = verbArgs[++i]; break;
|
|
356
|
+
case '--body-stdin': useBodyStdin = true; break;
|
|
357
|
+
default: break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (action === 'fulfill') {
|
|
361
|
+
if (useBodyStdin && bodyInline !== undefined) {
|
|
362
|
+
throw withExit(2, "route fulfill: --body and --body-stdin are mutually exclusive");
|
|
363
|
+
}
|
|
364
|
+
if (useBodyStdin) {
|
|
365
|
+
msg.body = await readAllStdin();
|
|
366
|
+
} else if (bodyInline !== undefined) {
|
|
367
|
+
msg.body = bodyInline;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const reply = await ipcCall(msg);
|
|
371
|
+
emitReply(reply);
|
|
372
|
+
process.exit(reply.status === 'error' ? 30 : 0);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function runStatefulViaDaemon(verb, verbArgs) {
|
|
376
|
+
if (!isDaemonAlive()) {
|
|
377
|
+
process.stderr.write(
|
|
378
|
+
`chrome-devtools-bridge: ${verb} requires running daemon ` +
|
|
379
|
+
`(run: node chrome-devtools-bridge.mjs daemon-start)\n`
|
|
380
|
+
);
|
|
381
|
+
process.exit(41);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Drag has 2-ref argv shape: `drag <src-ref> <dst-ref>`; all other stateful
|
|
385
|
+
// verbs use the single-ref shape `<verb> <ref> [...rest]`.
|
|
386
|
+
if (verb === 'drag') {
|
|
387
|
+
const srcRef = verbArgs[0];
|
|
388
|
+
const dstRef = verbArgs[1];
|
|
389
|
+
if (!srcRef || !dstRef) {
|
|
390
|
+
throw withExit(2, "drag requires both <src-ref> and <dst-ref> (eN values)");
|
|
391
|
+
}
|
|
392
|
+
const reply = await ipcCall({ verb: 'drag', src_ref: srcRef, dst_ref: dstRef });
|
|
393
|
+
emitReply(reply);
|
|
394
|
+
process.exit(reply.status === 'error' ? 30 : 0);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const ref = verbArgs[0];
|
|
398
|
+
if (!ref) throw withExit(2, `verb '${verb}' requires a ref (eN)`);
|
|
399
|
+
|
|
400
|
+
if (verb === 'upload') {
|
|
401
|
+
// Phase-6 part 6: argv shape is `upload <ref> <path>` (path comes second).
|
|
402
|
+
// Security validation already done bash-side (existence, regular-file,
|
|
403
|
+
// sensitive-pattern reject); bridge just forwards.
|
|
404
|
+
const path = verbArgs[1];
|
|
405
|
+
if (!path) throw withExit(2, "upload requires <path>");
|
|
406
|
+
const reply = await ipcCall({ verb: 'upload', ref, path });
|
|
407
|
+
emitReply(reply);
|
|
408
|
+
process.exit(reply.status === 'error' ? 30 : 0);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (verb === 'click') {
|
|
412
|
+
const reply = await ipcCall({ verb: 'click', ref });
|
|
413
|
+
emitReply(reply);
|
|
414
|
+
process.exit(reply.status === 'error' ? 30 : 0);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (verb === 'hover') {
|
|
418
|
+
// Phase-6 part 3: pointer hover. Refs only for now; --selector path is
|
|
419
|
+
// a follow-up sub-part if user demand surfaces.
|
|
420
|
+
const reply = await ipcCall({ verb: 'hover', ref });
|
|
421
|
+
emitReply(reply);
|
|
422
|
+
process.exit(reply.status === 'error' ? 30 : 0);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (verb === 'select') {
|
|
426
|
+
// Phase-6 part 2: select an <option> by value | label | index. Exactly
|
|
427
|
+
// one of these must be supplied. Argv shape from the adapter:
|
|
428
|
+
// select <ref> --value VAL
|
|
429
|
+
// select <ref> --label LABEL
|
|
430
|
+
// select <ref> --index N
|
|
431
|
+
const msg = { verb: 'select', ref };
|
|
432
|
+
for (let i = 1; i < verbArgs.length; i++) {
|
|
433
|
+
switch (verbArgs[i]) {
|
|
434
|
+
case '--value': msg.value = verbArgs[++i]; break;
|
|
435
|
+
case '--label': msg.label = verbArgs[++i]; break;
|
|
436
|
+
case '--index': msg.index = verbArgs[++i]; break;
|
|
437
|
+
default: break;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (msg.value === undefined && msg.label === undefined && msg.index === undefined) {
|
|
441
|
+
throw withExit(2, "select requires one of --value, --label, or --index");
|
|
442
|
+
}
|
|
443
|
+
const reply = await ipcCall(msg);
|
|
444
|
+
emitReply(reply);
|
|
445
|
+
process.exit(reply.status === 'error' ? 30 : 0);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// fill
|
|
449
|
+
let text = '';
|
|
450
|
+
if (verbArgs[1] === '--secret-stdin') {
|
|
451
|
+
text = await readAllStdin();
|
|
452
|
+
// Strip trailing newline that printf '%s\n' adds — secret should not include it.
|
|
453
|
+
if (text.endsWith('\n')) text = text.slice(0, -1);
|
|
454
|
+
} else if (typeof verbArgs[1] === 'string') {
|
|
455
|
+
text = verbArgs[1];
|
|
456
|
+
} else {
|
|
457
|
+
throw withExit(2, `fill requires text VALUE or --secret-stdin`);
|
|
458
|
+
}
|
|
459
|
+
const reply = await ipcCall({ verb: 'fill', ref, text });
|
|
460
|
+
// Defensive: scrub any echoed text from the reply before emitting.
|
|
461
|
+
if (reply && typeof reply === 'object') delete reply.text;
|
|
462
|
+
emitReply(reply);
|
|
463
|
+
process.exit(reply.status === 'error' ? 30 : 0);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function runStatelessViaDaemon(verb, verbArgs) {
|
|
467
|
+
// Prepare msg from verbArgs (parallel to translateVerb but for daemon shape).
|
|
468
|
+
let msg;
|
|
469
|
+
switch (verb) {
|
|
470
|
+
case 'open': {
|
|
471
|
+
const url = verbArgs[0] ?? '';
|
|
472
|
+
if (!url) throw withExit(2, "verb 'open' requires a URL");
|
|
473
|
+
msg = { verb: 'open', url };
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
case 'snapshot':
|
|
477
|
+
msg = { verb: 'snapshot' };
|
|
478
|
+
break;
|
|
479
|
+
case 'eval': {
|
|
480
|
+
const script = verbArgs[0] ?? '';
|
|
481
|
+
if (!script) throw withExit(2, "verb 'eval' requires an expression");
|
|
482
|
+
msg = { verb: 'eval', script };
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
case 'audit':
|
|
486
|
+
msg = { verb: 'audit' };
|
|
487
|
+
break;
|
|
488
|
+
case 'press': {
|
|
489
|
+
const key = verbArgs[0] ?? '';
|
|
490
|
+
if (!key) throw withExit(2, "verb 'press' requires a --key value");
|
|
491
|
+
msg = { verb: 'press', key };
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
case 'wait': {
|
|
495
|
+
const selector = verbArgs[0] ?? '';
|
|
496
|
+
if (!selector) throw withExit(2, "verb 'wait' requires a --selector value");
|
|
497
|
+
msg = { verb: 'wait', selector };
|
|
498
|
+
for (let i = 1; i < verbArgs.length; i++) {
|
|
499
|
+
if (verbArgs[i] === '--state') msg.state = verbArgs[++i];
|
|
500
|
+
if (verbArgs[i] === '--timeout') msg.timeout = parseInt(verbArgs[++i], 10);
|
|
501
|
+
}
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
default:
|
|
505
|
+
throw withExit(2, `unknown verb: ${verb}`);
|
|
506
|
+
}
|
|
507
|
+
const reply = await ipcCall(msg, verb === 'audit' ? AUDIT_TIMEOUT_MS : IPC_TIMEOUT_MS);
|
|
508
|
+
emitReply(reply);
|
|
509
|
+
process.exit(reply.status === 'error' ? 30 : 0);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function emitReply(reply) {
|
|
513
|
+
process.stdout.write(JSON.stringify(reply) + '\n');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ----------------------------------------------------------------------------
|
|
517
|
+
// Multi-call verbs: inspect / extract (phase-05 part 1e-ii)
|
|
518
|
+
// ----------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
async function runInspectOrExtract(verb, verbArgs) {
|
|
521
|
+
const msg = translateInspectExtract(verb, verbArgs);
|
|
522
|
+
let reply;
|
|
523
|
+
if (isDaemonAlive()) {
|
|
524
|
+
reply = await ipcCall(msg);
|
|
525
|
+
} else {
|
|
526
|
+
reply = await withMcpClient(async (mcpCall) => {
|
|
527
|
+
if (verb === 'inspect') return await dispatchInspect(mcpCall, msg);
|
|
528
|
+
return await dispatchExtract(mcpCall, msg);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
emitReply(reply);
|
|
532
|
+
process.exit(reply.status === 'error' ? 30 : 0);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// translateInspectExtract VERB ARGS → daemon message shape.
|
|
536
|
+
function translateInspectExtract(verb, args) {
|
|
537
|
+
const msg = { verb };
|
|
538
|
+
for (let i = 0; i < args.length; i++) {
|
|
539
|
+
switch (args[i]) {
|
|
540
|
+
case '--capture-console': msg.capture_console = true; break;
|
|
541
|
+
case '--capture-network': msg.capture_network = true; break;
|
|
542
|
+
case '--screenshot': msg.screenshot = true; break;
|
|
543
|
+
case '--selector': msg.selector = args[++i]; break;
|
|
544
|
+
case '--eval': msg.eval = args[++i]; break;
|
|
545
|
+
default: break;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return msg;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// dispatchInspect — sequential MCP calls aggregated into one summary.
|
|
552
|
+
// Order: console → network → screenshot → selector. Each only runs if its
|
|
553
|
+
// flag is set; absent flags produce no MCP call (and no result field).
|
|
554
|
+
async function dispatchInspect(mcpCall, msg) {
|
|
555
|
+
const summary = {
|
|
556
|
+
verb: 'inspect',
|
|
557
|
+
tool: 'chrome-devtools-mcp',
|
|
558
|
+
why: 'mcp/inspect',
|
|
559
|
+
status: 'ok',
|
|
560
|
+
};
|
|
561
|
+
if (msg.capture_console) {
|
|
562
|
+
const r = await mcpCall('list_console_messages', {});
|
|
563
|
+
summary.console_messages = r?.messages ?? [];
|
|
564
|
+
}
|
|
565
|
+
if (msg.capture_network) {
|
|
566
|
+
const r = await mcpCall('list_network_requests', {});
|
|
567
|
+
summary.network_requests = r?.requests ?? [];
|
|
568
|
+
}
|
|
569
|
+
if (msg.screenshot) {
|
|
570
|
+
const r = await mcpCall('take_screenshot', {});
|
|
571
|
+
summary.screenshot_path = r?.path ?? null;
|
|
572
|
+
}
|
|
573
|
+
if (msg.selector) {
|
|
574
|
+
const safeSel = JSON.stringify(msg.selector);
|
|
575
|
+
const script =
|
|
576
|
+
`Array.from(document.querySelectorAll(${safeSel})).map(el => el.textContent ? el.textContent.trim() : '')`;
|
|
577
|
+
const r = await mcpCall('evaluate_script', { script });
|
|
578
|
+
summary.matches = extractText(r);
|
|
579
|
+
}
|
|
580
|
+
return summary;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// dispatchExtract — single evaluate_script tools/call. --selector wraps in
|
|
584
|
+
// querySelectorAll → join textContent; --eval passes the raw script through.
|
|
585
|
+
async function dispatchExtract(mcpCall, msg) {
|
|
586
|
+
let script;
|
|
587
|
+
if (msg.selector) {
|
|
588
|
+
const safeSel = JSON.stringify(msg.selector);
|
|
589
|
+
script =
|
|
590
|
+
`Array.from(document.querySelectorAll(${safeSel})).map(el => el.textContent ? el.textContent.trim() : '').join('\\n')`;
|
|
591
|
+
} else if (msg.eval) {
|
|
592
|
+
script = msg.eval;
|
|
593
|
+
} else {
|
|
594
|
+
return {
|
|
595
|
+
event: 'error',
|
|
596
|
+
verb: 'extract',
|
|
597
|
+
status: 'error',
|
|
598
|
+
message: 'extract requires --selector or --eval',
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
const r = await mcpCall('evaluate_script', { script });
|
|
602
|
+
return {
|
|
603
|
+
verb: 'extract',
|
|
604
|
+
tool: 'chrome-devtools-mcp',
|
|
605
|
+
why: 'mcp/evaluate_script',
|
|
606
|
+
status: 'ok',
|
|
607
|
+
selector: msg.selector ?? null,
|
|
608
|
+
eval: msg.eval ?? null,
|
|
609
|
+
value: extractText(r),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// withMcpClient — spawn the upstream MCP server, run the initialize handshake
|
|
614
|
+
// once, hand the caller a ready `mcpCall(name, args, timeoutMs)` closure, and
|
|
615
|
+
// shut the child down on return. Used for one-shot multi-call verbs (inspect/
|
|
616
|
+
// extract when no daemon is running).
|
|
617
|
+
async function withMcpClient(fn) {
|
|
618
|
+
const bin = process.env.CHROME_DEVTOOLS_MCP_BIN || 'chrome-devtools-mcp';
|
|
619
|
+
let child;
|
|
620
|
+
try {
|
|
621
|
+
child = spawn(bin, mcpSpawnArgs(), { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
622
|
+
} catch (err) {
|
|
623
|
+
throw withExit(41, `failed to spawn MCP server '${bin}': ${err.message}`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
let childExited = false;
|
|
627
|
+
let childExitCode = null;
|
|
628
|
+
child.on('exit', (code) => { childExited = true; childExitCode = code; });
|
|
629
|
+
child.on('error', (err) => {
|
|
630
|
+
childExited = true;
|
|
631
|
+
process.stderr.write(`chrome-devtools-bridge: child error: ${err.message}\n`);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
const reader = makeJsonRpcReader(child.stdout);
|
|
635
|
+
|
|
636
|
+
try {
|
|
637
|
+
await sendJsonRpc(child.stdin, {
|
|
638
|
+
jsonrpc: '2.0',
|
|
639
|
+
id: 1,
|
|
640
|
+
method: 'initialize',
|
|
641
|
+
params: {
|
|
642
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
643
|
+
capabilities: {},
|
|
644
|
+
clientInfo: { name: 'browser-skill', version: '0.13' },
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
const initResp = await reader.waitFor(1, INIT_TIMEOUT_MS);
|
|
648
|
+
if (initResp.error) {
|
|
649
|
+
throw withExit(42, `MCP initialize failed: ${initResp.error.message}`);
|
|
650
|
+
}
|
|
651
|
+
await sendJsonRpc(child.stdin, {
|
|
652
|
+
jsonrpc: '2.0',
|
|
653
|
+
method: 'notifications/initialized',
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const mcpCall = makeMcpCall(child, reader, 100);
|
|
657
|
+
return await fn(mcpCall);
|
|
658
|
+
} finally {
|
|
659
|
+
try { child.stdin.end(); } catch (_) { /* ignore */ }
|
|
660
|
+
if (!childExited) {
|
|
661
|
+
await waitForExit(child, SHUTDOWN_TIMEOUT_MS).catch(() => {
|
|
662
|
+
try { child.kill('SIGTERM'); } catch (_) { /* ignore */ }
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
if (childExitCode !== null && childExitCode !== 0) {
|
|
666
|
+
process.stderr.write(
|
|
667
|
+
`chrome-devtools-bridge: MCP server exited with code ${childExitCode}\n`
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// mcpSpawnArgs — CLI args forwarded to the spawned upstream MCP server child.
|
|
674
|
+
// Phase-5 part 1f: when CHROME_USER_DATA_DIR is set, append `--user-data-dir
|
|
675
|
+
// DIR` so the upstream Chrome reuses the profile directory (cookies,
|
|
676
|
+
// localStorage, extensions persist). User provides the directory; capturing
|
|
677
|
+
// or automating user-data-dir creation is out of scope.
|
|
678
|
+
function mcpSpawnArgs() {
|
|
679
|
+
const args = [];
|
|
680
|
+
if (process.env.CHROME_USER_DATA_DIR) {
|
|
681
|
+
args.push('--user-data-dir', process.env.CHROME_USER_DATA_DIR);
|
|
682
|
+
}
|
|
683
|
+
return args;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// makeMcpCall — id-tracking factory for repeated tools/call invocations on a
|
|
687
|
+
// single MCP child + reader pair. Used by both daemonChildMain (long-lived)
|
|
688
|
+
// and withMcpClient (one-shot). Starts at startId+1 (so init's id=1 doesn't
|
|
689
|
+
// collide).
|
|
690
|
+
function makeMcpCall(child, reader, startId = 100) {
|
|
691
|
+
let nextId = startId;
|
|
692
|
+
return async function mcpCall(name, args, timeoutMs) {
|
|
693
|
+
const id = ++nextId;
|
|
694
|
+
await sendJsonRpc(child.stdin, {
|
|
695
|
+
jsonrpc: '2.0',
|
|
696
|
+
id,
|
|
697
|
+
method: 'tools/call',
|
|
698
|
+
params: { name, arguments: args },
|
|
699
|
+
});
|
|
700
|
+
const resp = await reader.waitFor(id, timeoutMs ?? CALL_TIMEOUT_MS);
|
|
701
|
+
if (resp.error) {
|
|
702
|
+
throw new Error(`MCP tools/call '${name}' failed: ${resp.error.message}`);
|
|
703
|
+
}
|
|
704
|
+
return resp.result;
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ----------------------------------------------------------------------------
|
|
709
|
+
// IPC client
|
|
710
|
+
// ----------------------------------------------------------------------------
|
|
711
|
+
|
|
712
|
+
function ipcCall(msg, timeoutMs) {
|
|
713
|
+
const state = readDaemonState();
|
|
714
|
+
if (!state || !isPidAlive(state.pid) || !state.port) {
|
|
715
|
+
process.stderr.write(
|
|
716
|
+
`chrome-devtools-bridge: ${msg.verb} requires running daemon ` +
|
|
717
|
+
`(run: node chrome-devtools-bridge.mjs daemon-start)\n`
|
|
718
|
+
);
|
|
719
|
+
process.exit(41);
|
|
720
|
+
}
|
|
721
|
+
const t = typeof timeoutMs === 'number' ? timeoutMs : IPC_TIMEOUT_MS;
|
|
722
|
+
return new Promise((resolve, reject) => {
|
|
723
|
+
const conn = createConnection({ host: state.host || '127.0.0.1', port: state.port });
|
|
724
|
+
let buf = '';
|
|
725
|
+
let settled = false;
|
|
726
|
+
const timer = setTimeout(() => {
|
|
727
|
+
if (settled) return;
|
|
728
|
+
settled = true;
|
|
729
|
+
try { conn.destroy(); } catch (_) { /* ignore */ }
|
|
730
|
+
reject(new Error(`ipcCall: timeout waiting for daemon reply (verb=${msg.verb})`));
|
|
731
|
+
}, t);
|
|
732
|
+
|
|
733
|
+
conn.on('connect', () => {
|
|
734
|
+
conn.write(JSON.stringify(msg) + '\n');
|
|
735
|
+
});
|
|
736
|
+
conn.on('data', (chunk) => {
|
|
737
|
+
buf += chunk.toString('utf-8');
|
|
738
|
+
const nl = buf.indexOf('\n');
|
|
739
|
+
if (nl < 0 || settled) return;
|
|
740
|
+
settled = true;
|
|
741
|
+
clearTimeout(timer);
|
|
742
|
+
try {
|
|
743
|
+
resolve(JSON.parse(buf.slice(0, nl)));
|
|
744
|
+
} catch (e) {
|
|
745
|
+
reject(e);
|
|
746
|
+
} finally {
|
|
747
|
+
try { conn.end(); } catch (_) { /* ignore */ }
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
conn.on('error', (e) => {
|
|
751
|
+
if (settled) return;
|
|
752
|
+
settled = true;
|
|
753
|
+
clearTimeout(timer);
|
|
754
|
+
reject(e);
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function readAllStdin() {
|
|
760
|
+
return new Promise((resolve, reject) => {
|
|
761
|
+
let data = '';
|
|
762
|
+
process.stdin.setEncoding('utf-8');
|
|
763
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
764
|
+
process.stdin.on('end', () => resolve(data));
|
|
765
|
+
process.stdin.on('error', reject);
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ----------------------------------------------------------------------------
|
|
770
|
+
// Daemon lifecycle (start / stop / status)
|
|
771
|
+
// ----------------------------------------------------------------------------
|
|
772
|
+
|
|
773
|
+
async function runDaemonStart(verbArgs) {
|
|
774
|
+
const isInternalServer = verbArgs.includes('--internal-server');
|
|
775
|
+
if (isInternalServer) {
|
|
776
|
+
return await daemonChildMain();
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const existing = readDaemonState();
|
|
780
|
+
if (existing && isPidAlive(existing.pid)) {
|
|
781
|
+
process.stdout.write(
|
|
782
|
+
JSON.stringify({ event: 'daemon-already-running', ...existing }) + '\n'
|
|
783
|
+
);
|
|
784
|
+
process.exit(0);
|
|
785
|
+
}
|
|
786
|
+
if (existing) {
|
|
787
|
+
try { unlinkSync(daemonStatePath()); } catch (_) { /* ignore */ }
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
mkdirSync(browserSkillHome(), { recursive: true, mode: 0o700 });
|
|
791
|
+
const logPath = join(browserSkillHome(), 'cdt-mcp-daemon.log');
|
|
792
|
+
const stderrFd = openSync(logPath, 'a', 0o600);
|
|
793
|
+
|
|
794
|
+
const child = spawn(process.execPath, [
|
|
795
|
+
fileURLToPath(import.meta.url),
|
|
796
|
+
'daemon-start',
|
|
797
|
+
'--internal-server',
|
|
798
|
+
], {
|
|
799
|
+
detached: true,
|
|
800
|
+
stdio: ['ignore', 'ignore', stderrFd],
|
|
801
|
+
env: process.env,
|
|
802
|
+
});
|
|
803
|
+
child.unref();
|
|
804
|
+
|
|
805
|
+
const stateFile = daemonStatePath();
|
|
806
|
+
const deadline = Date.now() + 10000;
|
|
807
|
+
while (Date.now() < deadline) {
|
|
808
|
+
if (existsSync(stateFile)) {
|
|
809
|
+
const state = readDaemonState();
|
|
810
|
+
if (state && isPidAlive(state.pid)) {
|
|
811
|
+
process.stdout.write(
|
|
812
|
+
JSON.stringify({ event: 'daemon-started', ...state }) + '\n'
|
|
813
|
+
);
|
|
814
|
+
process.exit(0);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
await sleep(100);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
process.stderr.write(
|
|
821
|
+
'chrome-devtools-bridge::daemon-start: timed out waiting for daemon to come up ' +
|
|
822
|
+
`(see ${logPath} for child stderr)\n`
|
|
823
|
+
);
|
|
824
|
+
process.exit(30);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function runDaemonStop() {
|
|
828
|
+
const state = readDaemonState();
|
|
829
|
+
if (!state) {
|
|
830
|
+
process.stdout.write('{"event":"daemon-not-running"}\n');
|
|
831
|
+
process.exit(0);
|
|
832
|
+
}
|
|
833
|
+
if (isPidAlive(state.pid)) {
|
|
834
|
+
try { process.kill(state.pid, 'SIGTERM'); } catch (_) { /* ignore */ }
|
|
835
|
+
}
|
|
836
|
+
// Wait briefly for daemon to clean up its state file.
|
|
837
|
+
const deadline = Date.now() + 5000;
|
|
838
|
+
while (Date.now() < deadline && existsSync(daemonStatePath())) {
|
|
839
|
+
const now = Date.now();
|
|
840
|
+
while (Date.now() - now < 50) { /* ~50ms tick */ }
|
|
841
|
+
}
|
|
842
|
+
try { unlinkSync(daemonStatePath()); } catch (_) { /* ignore */ }
|
|
843
|
+
process.stdout.write(
|
|
844
|
+
JSON.stringify({ event: 'daemon-stopped', pid: state.pid }) + '\n'
|
|
845
|
+
);
|
|
846
|
+
process.exit(0);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function runDaemonStatus() {
|
|
850
|
+
const state = readDaemonState();
|
|
851
|
+
if (state && isPidAlive(state.pid)) {
|
|
852
|
+
process.stdout.write(
|
|
853
|
+
JSON.stringify({ event: 'daemon-running', ...state }) + '\n'
|
|
854
|
+
);
|
|
855
|
+
process.exit(0);
|
|
856
|
+
}
|
|
857
|
+
process.stdout.write('{"event":"daemon-not-running"}\n');
|
|
858
|
+
process.exit(0);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ----------------------------------------------------------------------------
|
|
862
|
+
// Daemon child main — long-lived MCP server child + IPC server + ref map.
|
|
863
|
+
// ----------------------------------------------------------------------------
|
|
864
|
+
|
|
865
|
+
async function daemonChildMain() {
|
|
866
|
+
const bin = process.env.CHROME_DEVTOOLS_MCP_BIN || 'chrome-devtools-mcp';
|
|
867
|
+
|
|
868
|
+
let mcpChild;
|
|
869
|
+
try {
|
|
870
|
+
mcpChild = spawn(bin, mcpSpawnArgs(), { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
871
|
+
} catch (err) {
|
|
872
|
+
process.stderr.write(`daemon: failed to spawn MCP server '${bin}': ${err.message}\n`);
|
|
873
|
+
process.exit(41);
|
|
874
|
+
}
|
|
875
|
+
mcpChild.on('exit', (code) => {
|
|
876
|
+
process.stderr.write(`daemon: MCP server exited (code=${code}); shutting down\n`);
|
|
877
|
+
cleanup().then(() => process.exit(42)).catch(() => process.exit(42));
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
const reader = makeJsonRpcReader(mcpChild.stdout);
|
|
881
|
+
|
|
882
|
+
// Initialize handshake (once, shared across all subsequent calls).
|
|
883
|
+
await sendJsonRpc(mcpChild.stdin, {
|
|
884
|
+
jsonrpc: '2.0',
|
|
885
|
+
id: 1,
|
|
886
|
+
method: 'initialize',
|
|
887
|
+
params: {
|
|
888
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
889
|
+
capabilities: {},
|
|
890
|
+
clientInfo: { name: 'browser-skill', version: '0.10' },
|
|
891
|
+
},
|
|
892
|
+
});
|
|
893
|
+
const initResp = await reader.waitFor(1, INIT_TIMEOUT_MS);
|
|
894
|
+
if (initResp.error) {
|
|
895
|
+
process.stderr.write(`daemon: MCP initialize failed: ${initResp.error.message}\n`);
|
|
896
|
+
process.exit(42);
|
|
897
|
+
}
|
|
898
|
+
await sendJsonRpc(mcpChild.stdin, {
|
|
899
|
+
jsonrpc: '2.0',
|
|
900
|
+
method: 'notifications/initialized',
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// Daemon-side state.
|
|
904
|
+
let refMap = null;
|
|
905
|
+
const routeRules = []; // Phase-6 part 7: array of {pattern, action} entries.
|
|
906
|
+
let tabs = []; // Phase-6 part 8-i: array of {tab_id, url, title}.
|
|
907
|
+
// Replaced (not appended) on each list_pages call.
|
|
908
|
+
let currentTab = null; // Phase-6 part 8-ii: tab_id (number) of the active
|
|
909
|
+
// tab, or null if never set. Source of truth is
|
|
910
|
+
// tabs[] — currentTab is just the pointer.
|
|
911
|
+
// 8-iii will null this out if the closed tab matches.
|
|
912
|
+
const mcpCall = makeMcpCall(mcpChild, reader, 100);
|
|
913
|
+
|
|
914
|
+
// refreshTabs — call upstream list_pages and normalize to tabs[]. Shared by
|
|
915
|
+
// 'tab-list' and 'tab-switch' (the latter auto-refreshes when tabs[] empty).
|
|
916
|
+
// Returns the tabs[] array (also stored in the closure-scoped `tabs`).
|
|
917
|
+
async function refreshTabs() {
|
|
918
|
+
const result = await mcpCall('list_pages', {});
|
|
919
|
+
const raw = result?.pages ?? result?.tabs ?? [];
|
|
920
|
+
tabs = (Array.isArray(raw) ? raw : []).map((p, i) => ({
|
|
921
|
+
tab_id: i + 1,
|
|
922
|
+
url: typeof p?.url === 'string' ? p.url : '',
|
|
923
|
+
title: typeof p?.title === 'string' ? p.title : '',
|
|
924
|
+
}));
|
|
925
|
+
return tabs;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// IPC server (TCP loopback, ephemeral port — sun_path 104-char cap workaround).
|
|
929
|
+
const ipcServer = createServer((conn) => {
|
|
930
|
+
let buf = '';
|
|
931
|
+
conn.setEncoding('utf-8');
|
|
932
|
+
conn.on('data', async (chunk) => {
|
|
933
|
+
buf += chunk;
|
|
934
|
+
let nl;
|
|
935
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
936
|
+
const line = buf.slice(0, nl);
|
|
937
|
+
buf = buf.slice(nl + 1);
|
|
938
|
+
if (!line) continue;
|
|
939
|
+
let reply;
|
|
940
|
+
try {
|
|
941
|
+
const msg = JSON.parse(line);
|
|
942
|
+
reply = await dispatch(msg);
|
|
943
|
+
} catch (err) {
|
|
944
|
+
reply = {
|
|
945
|
+
event: 'error',
|
|
946
|
+
status: 'error',
|
|
947
|
+
message: err && err.message ? err.message : String(err),
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
try { conn.write(JSON.stringify(reply) + '\n'); } catch (_) { /* ignore */ }
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
conn.on('error', () => { /* client closed mid-write; ignore */ });
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
async function dispatch(msg) {
|
|
957
|
+
switch (msg.verb) {
|
|
958
|
+
case 'open': {
|
|
959
|
+
const url = msg.url;
|
|
960
|
+
const result = await mcpCall('navigate_page', { url });
|
|
961
|
+
return {
|
|
962
|
+
verb: 'open',
|
|
963
|
+
tool: 'chrome-devtools-mcp',
|
|
964
|
+
why: 'mcp/navigate_page',
|
|
965
|
+
status: result?.isError ? 'error' : 'ok',
|
|
966
|
+
url,
|
|
967
|
+
message: extractText(result),
|
|
968
|
+
attached_to_daemon: true,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
case 'snapshot': {
|
|
972
|
+
const result = await mcpCall('take_snapshot', {});
|
|
973
|
+
const elements = extractSnapshotElements(result);
|
|
974
|
+
const refs = elements.map((el, i) => ({
|
|
975
|
+
id: `e${i + 1}`,
|
|
976
|
+
role: el.role,
|
|
977
|
+
name: el.name,
|
|
978
|
+
uid: el.uid,
|
|
979
|
+
}));
|
|
980
|
+
refMap = refs;
|
|
981
|
+
return {
|
|
982
|
+
verb: 'snapshot',
|
|
983
|
+
tool: 'chrome-devtools-mcp',
|
|
984
|
+
why: 'mcp/take_snapshot',
|
|
985
|
+
status: 'ok',
|
|
986
|
+
refs,
|
|
987
|
+
attached_to_daemon: true,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
case 'click': {
|
|
991
|
+
if (!refMap) {
|
|
992
|
+
return {
|
|
993
|
+
event: 'error',
|
|
994
|
+
verb: 'click',
|
|
995
|
+
status: 'error',
|
|
996
|
+
message: 'no refs (run snapshot first)',
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
const entry = refMap.find((r) => r.id === msg.ref);
|
|
1000
|
+
if (!entry) {
|
|
1001
|
+
return {
|
|
1002
|
+
event: 'error',
|
|
1003
|
+
verb: 'click',
|
|
1004
|
+
ref: msg.ref,
|
|
1005
|
+
status: 'error',
|
|
1006
|
+
message: `ref '${msg.ref}' not found in last snapshot (${refMap.length} refs available)`,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
const result = await mcpCall('click', { uid: entry.uid });
|
|
1010
|
+
return {
|
|
1011
|
+
verb: 'click',
|
|
1012
|
+
tool: 'chrome-devtools-mcp',
|
|
1013
|
+
why: 'mcp/click',
|
|
1014
|
+
status: result?.isError ? 'error' : 'ok',
|
|
1015
|
+
ref: entry.id,
|
|
1016
|
+
uid: entry.uid,
|
|
1017
|
+
message: extractText(result),
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
case 'fill': {
|
|
1021
|
+
if (!refMap) {
|
|
1022
|
+
return {
|
|
1023
|
+
event: 'error',
|
|
1024
|
+
verb: 'fill',
|
|
1025
|
+
status: 'error',
|
|
1026
|
+
message: 'no refs (run snapshot first)',
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
const entry = refMap.find((r) => r.id === msg.ref);
|
|
1030
|
+
if (!entry) {
|
|
1031
|
+
return {
|
|
1032
|
+
event: 'error',
|
|
1033
|
+
verb: 'fill',
|
|
1034
|
+
ref: msg.ref,
|
|
1035
|
+
status: 'error',
|
|
1036
|
+
message: `ref '${msg.ref}' not found in last snapshot`,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
const text = typeof msg.text === 'string' ? msg.text : '';
|
|
1040
|
+
try {
|
|
1041
|
+
await mcpCall('fill', { uid: entry.uid, text });
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
// Defensive: if the upstream echoes the text in its error, redact.
|
|
1044
|
+
let safe = err && err.message ? err.message : String(err);
|
|
1045
|
+
if (text && safe.includes(text)) safe = safe.split(text).join('<redacted>');
|
|
1046
|
+
return {
|
|
1047
|
+
event: 'error',
|
|
1048
|
+
verb: 'fill',
|
|
1049
|
+
ref: entry.id,
|
|
1050
|
+
uid: entry.uid,
|
|
1051
|
+
status: 'error',
|
|
1052
|
+
message: `fill failed: ${safe}`,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
return {
|
|
1056
|
+
verb: 'fill',
|
|
1057
|
+
tool: 'chrome-devtools-mcp',
|
|
1058
|
+
why: 'mcp/fill',
|
|
1059
|
+
status: 'ok',
|
|
1060
|
+
ref: entry.id,
|
|
1061
|
+
uid: entry.uid,
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
case 'eval': {
|
|
1065
|
+
const result = await mcpCall('evaluate_script', { script: msg.script });
|
|
1066
|
+
return {
|
|
1067
|
+
verb: 'eval',
|
|
1068
|
+
tool: 'chrome-devtools-mcp',
|
|
1069
|
+
why: 'mcp/evaluate_script',
|
|
1070
|
+
status: 'ok',
|
|
1071
|
+
value: extractText(result),
|
|
1072
|
+
attached_to_daemon: true,
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
case 'audit': {
|
|
1076
|
+
const result = await mcpCall('lighthouse_audit', {}, AUDIT_TIMEOUT_MS);
|
|
1077
|
+
return {
|
|
1078
|
+
verb: 'audit',
|
|
1079
|
+
tool: 'chrome-devtools-mcp',
|
|
1080
|
+
why: 'mcp/lighthouse_audit',
|
|
1081
|
+
status: 'ok',
|
|
1082
|
+
message: extractText(result),
|
|
1083
|
+
scores: result?.scores ?? null,
|
|
1084
|
+
attached_to_daemon: true,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
case 'inspect': {
|
|
1088
|
+
const summary = await dispatchInspect(mcpCall, msg);
|
|
1089
|
+
return { ...summary, attached_to_daemon: true };
|
|
1090
|
+
}
|
|
1091
|
+
case 'extract': {
|
|
1092
|
+
const summary = await dispatchExtract(mcpCall, msg);
|
|
1093
|
+
return { ...summary, attached_to_daemon: true };
|
|
1094
|
+
}
|
|
1095
|
+
case 'press': {
|
|
1096
|
+
// Phase-6 part 1: keyboard press. MCP `press_key` accepts a `key`
|
|
1097
|
+
// arg (e.g. "Enter", "Tab", "Escape", "ArrowDown", "Cmd+S").
|
|
1098
|
+
// Stateless w.r.t. refMap — acts on the focused element or page.
|
|
1099
|
+
const result = await mcpCall('press_key', { key: msg.key });
|
|
1100
|
+
return {
|
|
1101
|
+
verb: 'press',
|
|
1102
|
+
tool: 'chrome-devtools-mcp',
|
|
1103
|
+
why: 'mcp/press_key',
|
|
1104
|
+
status: result?.isError ? 'error' : 'ok',
|
|
1105
|
+
key: msg.key,
|
|
1106
|
+
message: extractText(result),
|
|
1107
|
+
attached_to_daemon: true,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
case 'wait': {
|
|
1111
|
+
// Phase-6 part 4: explicit wait for an element to reach a state.
|
|
1112
|
+
// MCP `wait_for` accepts {selector, state?, timeout?}. State defaults
|
|
1113
|
+
// to "visible"; timeout defaults to MCP server's default.
|
|
1114
|
+
const callArgs = { selector: msg.selector };
|
|
1115
|
+
if (msg.state) callArgs.state = msg.state;
|
|
1116
|
+
if (msg.timeout) callArgs.timeout = msg.timeout;
|
|
1117
|
+
const result = await mcpCall('wait_for', callArgs);
|
|
1118
|
+
return {
|
|
1119
|
+
verb: 'wait',
|
|
1120
|
+
tool: 'chrome-devtools-mcp',
|
|
1121
|
+
why: 'mcp/wait_for',
|
|
1122
|
+
status: result?.isError ? 'error' : 'ok',
|
|
1123
|
+
selector: msg.selector,
|
|
1124
|
+
state: msg.state ?? 'visible',
|
|
1125
|
+
timeout: msg.timeout ?? null,
|
|
1126
|
+
message: extractText(result),
|
|
1127
|
+
attached_to_daemon: true,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
case 'tab-list': {
|
|
1131
|
+
// Phase-6 part 8-i: read-only enumeration. Calls upstream MCP
|
|
1132
|
+
// `list_pages` (best-effort name; real upstream may use a different
|
|
1133
|
+
// tool). Result normalized to [{tab_id, url, title}] where `tab_id`
|
|
1134
|
+
// is bridge-assigned (1-based, stable per call). Replaces — not
|
|
1135
|
+
// appends — the cache. Phase-6 part 8-ii adds `is_current: true`
|
|
1136
|
+
// on the entry whose tab_id matches the daemon's currentTab pointer.
|
|
1137
|
+
try {
|
|
1138
|
+
await refreshTabs();
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
return {
|
|
1141
|
+
event: 'error',
|
|
1142
|
+
verb: 'tab-list',
|
|
1143
|
+
status: 'error',
|
|
1144
|
+
message: `mcp/list_pages failed: ${err && err.message ? err.message : err}`,
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
const annotated = tabs.map((t) => (
|
|
1148
|
+
currentTab !== null && t.tab_id === currentTab ? { ...t, is_current: true } : t
|
|
1149
|
+
));
|
|
1150
|
+
return {
|
|
1151
|
+
verb: 'tab-list',
|
|
1152
|
+
tool: 'chrome-devtools-mcp',
|
|
1153
|
+
why: 'mcp/list_pages',
|
|
1154
|
+
status: 'ok',
|
|
1155
|
+
tabs: annotated,
|
|
1156
|
+
tab_count: annotated.length,
|
|
1157
|
+
current_tab_id: currentTab,
|
|
1158
|
+
attached_to_daemon: true,
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
case 'tab-close': {
|
|
1162
|
+
// Phase-6 part 8-iii: close a tab. Mutex selector. Auto-refresh
|
|
1163
|
+
// tabs[] when empty (mirrors tab-switch). Splice matching entry,
|
|
1164
|
+
// call upstream MCP `close_page` (best-effort name), and null
|
|
1165
|
+
// `currentTab` if it pointed at the closed tab. tab_id values
|
|
1166
|
+
// remain stable on remaining entries (no renumbering — agents
|
|
1167
|
+
// holding a tab_id reference shouldn't see it silently rebound).
|
|
1168
|
+
if (tabs.length === 0) {
|
|
1169
|
+
try {
|
|
1170
|
+
await refreshTabs();
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
return {
|
|
1173
|
+
event: 'error',
|
|
1174
|
+
verb: 'tab-close',
|
|
1175
|
+
status: 'error',
|
|
1176
|
+
message: `auto-refresh failed: ${err && err.message ? err.message : err}`,
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
if (tabs.length === 0) {
|
|
1181
|
+
return {
|
|
1182
|
+
event: 'error',
|
|
1183
|
+
verb: 'tab-close',
|
|
1184
|
+
status: 'error',
|
|
1185
|
+
message: 'no tabs available (upstream returned empty page list)',
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
let idx = -1;
|
|
1189
|
+
if (msg.tab_id !== undefined) {
|
|
1190
|
+
idx = tabs.findIndex((t) => t.tab_id === msg.tab_id);
|
|
1191
|
+
if (idx < 0) {
|
|
1192
|
+
return {
|
|
1193
|
+
event: 'error',
|
|
1194
|
+
verb: 'tab-close',
|
|
1195
|
+
tab_id: msg.tab_id,
|
|
1196
|
+
tab_count: tabs.length,
|
|
1197
|
+
status: 'error',
|
|
1198
|
+
message: `tab_id ${msg.tab_id} not found`,
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
} else if (typeof msg.by_url_pattern === 'string' && msg.by_url_pattern) {
|
|
1202
|
+
idx = tabs.findIndex((t) => t.url.includes(msg.by_url_pattern));
|
|
1203
|
+
if (idx < 0) {
|
|
1204
|
+
return {
|
|
1205
|
+
event: 'error',
|
|
1206
|
+
verb: 'tab-close',
|
|
1207
|
+
by_url_pattern: msg.by_url_pattern,
|
|
1208
|
+
status: 'error',
|
|
1209
|
+
message: `no tab url contains pattern: ${msg.by_url_pattern}`,
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
} else {
|
|
1213
|
+
return {
|
|
1214
|
+
event: 'error',
|
|
1215
|
+
verb: 'tab-close',
|
|
1216
|
+
status: 'error',
|
|
1217
|
+
message: 'tab-close requires --tab-id or --by-url-pattern',
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
const closed = tabs[idx];
|
|
1221
|
+
let mcpAck = null;
|
|
1222
|
+
try {
|
|
1223
|
+
const result = await mcpCall('close_page', { tab_id: closed.tab_id, url: closed.url });
|
|
1224
|
+
mcpAck = extractText(result);
|
|
1225
|
+
} catch (err) {
|
|
1226
|
+
mcpAck = `mcp-close_page-failed: ${err && err.message ? err.message : err}`;
|
|
1227
|
+
}
|
|
1228
|
+
tabs.splice(idx, 1);
|
|
1229
|
+
if (currentTab === closed.tab_id) currentTab = null;
|
|
1230
|
+
return {
|
|
1231
|
+
verb: 'tab-close',
|
|
1232
|
+
tool: 'chrome-devtools-mcp',
|
|
1233
|
+
why: 'mcp/close_page',
|
|
1234
|
+
status: 'ok',
|
|
1235
|
+
closed_tab: { ...closed },
|
|
1236
|
+
current_tab_id: currentTab,
|
|
1237
|
+
tab_count: tabs.length,
|
|
1238
|
+
mcp_ack: mcpAck,
|
|
1239
|
+
attached_to_daemon: true,
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
case 'tab-switch': {
|
|
1243
|
+
// Phase-6 part 8-ii: switch active tab via mutex selectors.
|
|
1244
|
+
// --by-index (1-based) | --by-url-pattern (substring-contains).
|
|
1245
|
+
// Auto-refreshes tabs[] when empty so agents don't have to remember
|
|
1246
|
+
// to call tab-list first. Updates `currentTab` pointer and asks the
|
|
1247
|
+
// upstream MCP to focus the corresponding page (best-effort
|
|
1248
|
+
// `select_page` — real upstream may differ).
|
|
1249
|
+
if (tabs.length === 0) {
|
|
1250
|
+
try {
|
|
1251
|
+
await refreshTabs();
|
|
1252
|
+
} catch (err) {
|
|
1253
|
+
return {
|
|
1254
|
+
event: 'error',
|
|
1255
|
+
verb: 'tab-switch',
|
|
1256
|
+
status: 'error',
|
|
1257
|
+
message: `auto-refresh failed: ${err && err.message ? err.message : err}`,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
if (tabs.length === 0) {
|
|
1262
|
+
return {
|
|
1263
|
+
event: 'error',
|
|
1264
|
+
verb: 'tab-switch',
|
|
1265
|
+
status: 'error',
|
|
1266
|
+
message: 'no tabs available (upstream returned empty page list)',
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
let target = null;
|
|
1270
|
+
if (msg.by_index !== undefined) {
|
|
1271
|
+
const idx = msg.by_index;
|
|
1272
|
+
if (!Number.isInteger(idx) || idx < 1 || idx > tabs.length) {
|
|
1273
|
+
return {
|
|
1274
|
+
event: 'error',
|
|
1275
|
+
verb: 'tab-switch',
|
|
1276
|
+
by_index: idx,
|
|
1277
|
+
tab_count: tabs.length,
|
|
1278
|
+
status: 'error',
|
|
1279
|
+
message: `--by-index ${idx} out of range (1..${tabs.length})`,
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
target = tabs[idx - 1];
|
|
1283
|
+
} else if (typeof msg.by_url_pattern === 'string' && msg.by_url_pattern) {
|
|
1284
|
+
target = tabs.find((t) => t.url.includes(msg.by_url_pattern)) || null;
|
|
1285
|
+
if (!target) {
|
|
1286
|
+
return {
|
|
1287
|
+
event: 'error',
|
|
1288
|
+
verb: 'tab-switch',
|
|
1289
|
+
by_url_pattern: msg.by_url_pattern,
|
|
1290
|
+
status: 'error',
|
|
1291
|
+
message: `no tab url contains pattern: ${msg.by_url_pattern}`,
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
} else {
|
|
1295
|
+
return {
|
|
1296
|
+
event: 'error',
|
|
1297
|
+
verb: 'tab-switch',
|
|
1298
|
+
status: 'error',
|
|
1299
|
+
message: 'tab-switch requires --by-index or --by-url-pattern',
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
let mcpAck = null;
|
|
1303
|
+
try {
|
|
1304
|
+
const result = await mcpCall('select_page', { tab_id: target.tab_id, url: target.url });
|
|
1305
|
+
mcpAck = extractText(result);
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
mcpAck = `mcp-select_page-failed: ${err && err.message ? err.message : err}`;
|
|
1308
|
+
}
|
|
1309
|
+
currentTab = target.tab_id;
|
|
1310
|
+
return {
|
|
1311
|
+
verb: 'tab-switch',
|
|
1312
|
+
tool: 'chrome-devtools-mcp',
|
|
1313
|
+
why: 'mcp/select_page',
|
|
1314
|
+
status: 'ok',
|
|
1315
|
+
current_tab: { ...target },
|
|
1316
|
+
mcp_ack: mcpAck,
|
|
1317
|
+
attached_to_daemon: true,
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
case 'route': {
|
|
1321
|
+
// Phase-6 part 7: register a network-route rule.
|
|
1322
|
+
// 7-i: block | allow.
|
|
1323
|
+
// 7-ii: fulfill — synthetic responses, requires status + body.
|
|
1324
|
+
const allowed = ['block', 'allow', 'fulfill'];
|
|
1325
|
+
if (!allowed.includes(msg.action)) {
|
|
1326
|
+
return {
|
|
1327
|
+
event: 'error',
|
|
1328
|
+
verb: 'route',
|
|
1329
|
+
status: 'error',
|
|
1330
|
+
message: `route action must be one of ${allowed.join(', ')} (got: ${msg.action})`,
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
let rule;
|
|
1334
|
+
if (msg.action === 'fulfill') {
|
|
1335
|
+
if (!Number.isInteger(msg.status) || msg.status < 100 || msg.status > 599) {
|
|
1336
|
+
return {
|
|
1337
|
+
event: 'error',
|
|
1338
|
+
verb: 'route',
|
|
1339
|
+
status: 'error',
|
|
1340
|
+
message: `route fulfill --status must be integer in 100-599 (got: ${msg.status})`,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
if (typeof msg.body !== 'string') {
|
|
1344
|
+
return {
|
|
1345
|
+
event: 'error',
|
|
1346
|
+
verb: 'route',
|
|
1347
|
+
status: 'error',
|
|
1348
|
+
message: 'route fulfill requires --body STR or --body-stdin',
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
rule = { pattern: msg.pattern, action: 'fulfill', status: msg.status, body: msg.body };
|
|
1352
|
+
} else {
|
|
1353
|
+
rule = { pattern: msg.pattern, action: msg.action };
|
|
1354
|
+
}
|
|
1355
|
+
routeRules.push(rule);
|
|
1356
|
+
// Best-effort MCP `route_url` invocation. Real upstream may use a
|
|
1357
|
+
// different tool name (e.g. network.setRequestInterception); upstream
|
|
1358
|
+
// binding hardening tracked downstream.
|
|
1359
|
+
let mcpAck = null;
|
|
1360
|
+
try {
|
|
1361
|
+
const callArgs = msg.action === 'fulfill'
|
|
1362
|
+
? { pattern: msg.pattern, action: msg.action, status: msg.status, body: msg.body }
|
|
1363
|
+
: { pattern: msg.pattern, action: msg.action };
|
|
1364
|
+
const result = await mcpCall('route_url', callArgs);
|
|
1365
|
+
mcpAck = extractText(result);
|
|
1366
|
+
} catch (err) {
|
|
1367
|
+
mcpAck = `mcp-route_url-failed: ${err && err.message ? err.message : err}`;
|
|
1368
|
+
}
|
|
1369
|
+
const reply = {
|
|
1370
|
+
verb: 'route',
|
|
1371
|
+
tool: 'chrome-devtools-mcp',
|
|
1372
|
+
why: 'mcp/route_url',
|
|
1373
|
+
status: 'ok',
|
|
1374
|
+
pattern: msg.pattern,
|
|
1375
|
+
action: msg.action,
|
|
1376
|
+
rule_count: routeRules.length,
|
|
1377
|
+
mcp_ack: mcpAck,
|
|
1378
|
+
attached_to_daemon: true,
|
|
1379
|
+
};
|
|
1380
|
+
if (msg.action === 'fulfill') {
|
|
1381
|
+
reply.fulfill_status = msg.status;
|
|
1382
|
+
reply.body_bytes = Buffer.byteLength(msg.body, 'utf8');
|
|
1383
|
+
// Body itself NOT echoed in reply — agent sent it; avoid re-emitting
|
|
1384
|
+
// potentially large or sensitive content. body_bytes is the contract.
|
|
1385
|
+
}
|
|
1386
|
+
return reply;
|
|
1387
|
+
}
|
|
1388
|
+
case 'upload': {
|
|
1389
|
+
// Phase-6 part 6: file upload to <input type=file>. eN→uid translation
|
|
1390
|
+
// + path forwarded to MCP `upload_file` tool. Bash-side validates the
|
|
1391
|
+
// path before reaching the daemon (existence + regular-file + reject
|
|
1392
|
+
// sensitive patterns); bridge just forwards.
|
|
1393
|
+
if (!refMap) {
|
|
1394
|
+
return {
|
|
1395
|
+
event: 'error',
|
|
1396
|
+
verb: 'upload',
|
|
1397
|
+
status: 'error',
|
|
1398
|
+
message: 'no refs (run snapshot first)',
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
const entry = refMap.find((r) => r.id === msg.ref);
|
|
1402
|
+
if (!entry) {
|
|
1403
|
+
return {
|
|
1404
|
+
event: 'error',
|
|
1405
|
+
verb: 'upload',
|
|
1406
|
+
ref: msg.ref,
|
|
1407
|
+
status: 'error',
|
|
1408
|
+
message: `ref '${msg.ref}' not found in last snapshot (${refMap.length} refs available)`,
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
const result = await mcpCall('upload_file', { uid: entry.uid, path: msg.path });
|
|
1412
|
+
return {
|
|
1413
|
+
verb: 'upload',
|
|
1414
|
+
tool: 'chrome-devtools-mcp',
|
|
1415
|
+
why: 'mcp/upload_file',
|
|
1416
|
+
status: result?.isError ? 'error' : 'ok',
|
|
1417
|
+
ref: entry.id,
|
|
1418
|
+
uid: entry.uid,
|
|
1419
|
+
path: msg.path,
|
|
1420
|
+
message: extractText(result),
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
case 'drag': {
|
|
1424
|
+
// Phase-6 part 5: pointer drag from src → dst. Both refs translated
|
|
1425
|
+
// via refMap to uids. MCP `drag` tool accepts {src_uid, dst_uid}.
|
|
1426
|
+
if (!refMap) {
|
|
1427
|
+
return {
|
|
1428
|
+
event: 'error',
|
|
1429
|
+
verb: 'drag',
|
|
1430
|
+
status: 'error',
|
|
1431
|
+
message: 'no refs (run snapshot first)',
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
const srcEntry = refMap.find((r) => r.id === msg.src_ref);
|
|
1435
|
+
const dstEntry = refMap.find((r) => r.id === msg.dst_ref);
|
|
1436
|
+
if (!srcEntry) {
|
|
1437
|
+
return {
|
|
1438
|
+
event: 'error',
|
|
1439
|
+
verb: 'drag',
|
|
1440
|
+
src_ref: msg.src_ref,
|
|
1441
|
+
status: 'error',
|
|
1442
|
+
message: `src ref '${msg.src_ref}' not found in last snapshot (${refMap.length} refs available)`,
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
if (!dstEntry) {
|
|
1446
|
+
return {
|
|
1447
|
+
event: 'error',
|
|
1448
|
+
verb: 'drag',
|
|
1449
|
+
dst_ref: msg.dst_ref,
|
|
1450
|
+
status: 'error',
|
|
1451
|
+
message: `dst ref '${msg.dst_ref}' not found in last snapshot`,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
const result = await mcpCall('drag', { src_uid: srcEntry.uid, dst_uid: dstEntry.uid });
|
|
1455
|
+
return {
|
|
1456
|
+
verb: 'drag',
|
|
1457
|
+
tool: 'chrome-devtools-mcp',
|
|
1458
|
+
why: 'mcp/drag',
|
|
1459
|
+
status: result?.isError ? 'error' : 'ok',
|
|
1460
|
+
src_ref: srcEntry.id,
|
|
1461
|
+
src_uid: srcEntry.uid,
|
|
1462
|
+
dst_ref: dstEntry.id,
|
|
1463
|
+
dst_uid: dstEntry.uid,
|
|
1464
|
+
message: extractText(result),
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
case 'hover': {
|
|
1468
|
+
// Phase-6 part 3: pointer hover. eN→uid translation; calls MCP
|
|
1469
|
+
// `hover` tool with the resolved uid. Stateful (refMap precondition
|
|
1470
|
+
// mirrors click/fill/select).
|
|
1471
|
+
if (!refMap) {
|
|
1472
|
+
return {
|
|
1473
|
+
event: 'error',
|
|
1474
|
+
verb: 'hover',
|
|
1475
|
+
status: 'error',
|
|
1476
|
+
message: 'no refs (run snapshot first)',
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
const entry = refMap.find((r) => r.id === msg.ref);
|
|
1480
|
+
if (!entry) {
|
|
1481
|
+
return {
|
|
1482
|
+
event: 'error',
|
|
1483
|
+
verb: 'hover',
|
|
1484
|
+
ref: msg.ref,
|
|
1485
|
+
status: 'error',
|
|
1486
|
+
message: `ref '${msg.ref}' not found in last snapshot (${refMap.length} refs available)`,
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
const result = await mcpCall('hover', { uid: entry.uid });
|
|
1490
|
+
return {
|
|
1491
|
+
verb: 'hover',
|
|
1492
|
+
tool: 'chrome-devtools-mcp',
|
|
1493
|
+
why: 'mcp/hover',
|
|
1494
|
+
status: result?.isError ? 'error' : 'ok',
|
|
1495
|
+
ref: entry.id,
|
|
1496
|
+
uid: entry.uid,
|
|
1497
|
+
message: extractText(result),
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
case 'select': {
|
|
1501
|
+
// Phase-6 part 2: pick an <option> from a <select> element. Stateful
|
|
1502
|
+
// — needs eN→uid translation from the most recent snapshot. Exactly
|
|
1503
|
+
// one of value/label/index drives the choice (caller-validated).
|
|
1504
|
+
if (!refMap) {
|
|
1505
|
+
return {
|
|
1506
|
+
event: 'error',
|
|
1507
|
+
verb: 'select',
|
|
1508
|
+
status: 'error',
|
|
1509
|
+
message: 'no refs (run snapshot first)',
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
const entry = refMap.find((r) => r.id === msg.ref);
|
|
1513
|
+
if (!entry) {
|
|
1514
|
+
return {
|
|
1515
|
+
event: 'error',
|
|
1516
|
+
verb: 'select',
|
|
1517
|
+
ref: msg.ref,
|
|
1518
|
+
status: 'error',
|
|
1519
|
+
message: `ref '${msg.ref}' not found in last snapshot (${refMap.length} refs available)`,
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
const callArgs = { uid: entry.uid };
|
|
1523
|
+
if (msg.value !== undefined) callArgs.value = msg.value;
|
|
1524
|
+
if (msg.label !== undefined) callArgs.label = msg.label;
|
|
1525
|
+
if (msg.index !== undefined) callArgs.index = parseInt(msg.index, 10);
|
|
1526
|
+
const result = await mcpCall('select_option', callArgs);
|
|
1527
|
+
return {
|
|
1528
|
+
verb: 'select',
|
|
1529
|
+
tool: 'chrome-devtools-mcp',
|
|
1530
|
+
why: 'mcp/select_option',
|
|
1531
|
+
status: result?.isError ? 'error' : 'ok',
|
|
1532
|
+
ref: entry.id,
|
|
1533
|
+
uid: entry.uid,
|
|
1534
|
+
value: msg.value ?? null,
|
|
1535
|
+
label: msg.label ?? null,
|
|
1536
|
+
index: msg.index !== undefined ? parseInt(msg.index, 10) : null,
|
|
1537
|
+
message: extractText(result),
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
default:
|
|
1541
|
+
return { event: 'error', status: 'error', message: `unknown verb '${msg.verb}'` };
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
await new Promise((resolve, reject) => {
|
|
1546
|
+
ipcServer.listen(0, '127.0.0.1', () => resolve());
|
|
1547
|
+
ipcServer.once('error', reject);
|
|
1548
|
+
});
|
|
1549
|
+
const port = ipcServer.address().port;
|
|
1550
|
+
|
|
1551
|
+
const stateFile = daemonStatePath();
|
|
1552
|
+
mkdirSync(dirname(stateFile), { recursive: true, mode: 0o700 });
|
|
1553
|
+
const state = {
|
|
1554
|
+
pid: process.pid,
|
|
1555
|
+
host: '127.0.0.1',
|
|
1556
|
+
port,
|
|
1557
|
+
mcp_bin: bin,
|
|
1558
|
+
started_at: new Date().toISOString(),
|
|
1559
|
+
schema_version: 1,
|
|
1560
|
+
};
|
|
1561
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
1562
|
+
chmodSync(stateFile, 0o600);
|
|
1563
|
+
|
|
1564
|
+
let cleanedUp = false;
|
|
1565
|
+
async function cleanup() {
|
|
1566
|
+
if (cleanedUp) return;
|
|
1567
|
+
cleanedUp = true;
|
|
1568
|
+
try { ipcServer.close(); } catch (_) { /* ignore */ }
|
|
1569
|
+
try { mcpChild.stdin.end(); } catch (_) { /* ignore */ }
|
|
1570
|
+
try { mcpChild.kill('SIGTERM'); } catch (_) { /* ignore */ }
|
|
1571
|
+
try { unlinkSync(stateFile); } catch (_) { /* ignore */ }
|
|
1572
|
+
}
|
|
1573
|
+
const onSignal = async () => {
|
|
1574
|
+
await cleanup();
|
|
1575
|
+
process.exit(0);
|
|
1576
|
+
};
|
|
1577
|
+
process.on('SIGTERM', onSignal);
|
|
1578
|
+
process.on('SIGINT', onSignal);
|
|
1579
|
+
|
|
1580
|
+
// Block forever (until signal).
|
|
1581
|
+
await new Promise(() => {});
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// ----------------------------------------------------------------------------
|
|
1585
|
+
// Translation + shaping (one-shot real mode)
|
|
1586
|
+
// ----------------------------------------------------------------------------
|
|
1587
|
+
|
|
1588
|
+
function translateVerb(verb, args) {
|
|
1589
|
+
switch (verb) {
|
|
1590
|
+
case 'open': {
|
|
1591
|
+
const url = args[0] ?? '';
|
|
1592
|
+
if (!url) throw withExit(2, "verb 'open' requires a URL");
|
|
1593
|
+
return { tool: 'navigate_page', args: { url }, verb };
|
|
1594
|
+
}
|
|
1595
|
+
case 'snapshot':
|
|
1596
|
+
return { tool: 'take_snapshot', args: {}, verb };
|
|
1597
|
+
case 'eval': {
|
|
1598
|
+
const script = args[0] ?? '';
|
|
1599
|
+
if (!script) throw withExit(2, "verb 'eval' requires an expression");
|
|
1600
|
+
return { tool: 'evaluate_script', args: { script }, verb };
|
|
1601
|
+
}
|
|
1602
|
+
case 'audit':
|
|
1603
|
+
return { tool: 'lighthouse_audit', args: {}, verb };
|
|
1604
|
+
case 'press': {
|
|
1605
|
+
const key = args[0] ?? '';
|
|
1606
|
+
if (!key) throw withExit(2, "verb 'press' requires a --key value");
|
|
1607
|
+
return { tool: 'press_key', args: { key }, verb };
|
|
1608
|
+
}
|
|
1609
|
+
case 'wait': {
|
|
1610
|
+
// Argv shape from adapter: wait <selector> [--state STATE] [--timeout MS]
|
|
1611
|
+
const selector = args[0] ?? '';
|
|
1612
|
+
if (!selector) throw withExit(2, "verb 'wait' requires a --selector value");
|
|
1613
|
+
const callArgs = { selector };
|
|
1614
|
+
for (let i = 1; i < args.length; i++) {
|
|
1615
|
+
if (args[i] === '--state') callArgs.state = args[++i];
|
|
1616
|
+
if (args[i] === '--timeout') callArgs.timeout = parseInt(args[++i], 10);
|
|
1617
|
+
}
|
|
1618
|
+
return { tool: 'wait_for', args: callArgs, verb };
|
|
1619
|
+
}
|
|
1620
|
+
case 'click':
|
|
1621
|
+
case 'fill':
|
|
1622
|
+
case 'inspect':
|
|
1623
|
+
case 'extract':
|
|
1624
|
+
return { tool: null, args: null, verb };
|
|
1625
|
+
default:
|
|
1626
|
+
throw withExit(2, `unknown verb: ${verb}`);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function shapeResponse(verb, tx, result) {
|
|
1631
|
+
const base = {
|
|
1632
|
+
verb,
|
|
1633
|
+
tool: 'chrome-devtools-mcp',
|
|
1634
|
+
why: `mcp/${tx.tool}`,
|
|
1635
|
+
status: result?.isError ? 'error' : 'ok',
|
|
1636
|
+
};
|
|
1637
|
+
switch (verb) {
|
|
1638
|
+
case 'open': {
|
|
1639
|
+
const text = extractText(result);
|
|
1640
|
+
const url = tx.args.url;
|
|
1641
|
+
return { ...base, url, message: text };
|
|
1642
|
+
}
|
|
1643
|
+
case 'snapshot': {
|
|
1644
|
+
const elements = extractSnapshotElements(result);
|
|
1645
|
+
const refs = elements.map((el, i) => ({
|
|
1646
|
+
id: `e${i + 1}`,
|
|
1647
|
+
role: el.role,
|
|
1648
|
+
name: el.name,
|
|
1649
|
+
uid: el.uid,
|
|
1650
|
+
}));
|
|
1651
|
+
return { ...base, refs };
|
|
1652
|
+
}
|
|
1653
|
+
case 'eval': {
|
|
1654
|
+
const text = extractText(result);
|
|
1655
|
+
return { ...base, value: text };
|
|
1656
|
+
}
|
|
1657
|
+
case 'audit': {
|
|
1658
|
+
const text = extractText(result);
|
|
1659
|
+
const scores = result?.scores ?? null;
|
|
1660
|
+
return { ...base, message: text, scores };
|
|
1661
|
+
}
|
|
1662
|
+
case 'press': {
|
|
1663
|
+
const text = extractText(result);
|
|
1664
|
+
return { ...base, key: tx.args.key, message: text };
|
|
1665
|
+
}
|
|
1666
|
+
case 'wait': {
|
|
1667
|
+
const text = extractText(result);
|
|
1668
|
+
return {
|
|
1669
|
+
...base,
|
|
1670
|
+
selector: tx.args.selector,
|
|
1671
|
+
state: tx.args.state ?? 'visible',
|
|
1672
|
+
timeout: tx.args.timeout ?? null,
|
|
1673
|
+
message: text,
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
default:
|
|
1677
|
+
return { ...base, raw: result };
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
function extractText(result) {
|
|
1682
|
+
const content = result?.content ?? [];
|
|
1683
|
+
const textBlock = content.find((b) => b?.type === 'text');
|
|
1684
|
+
return textBlock?.text ?? '';
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
function extractSnapshotElements(result) {
|
|
1688
|
+
const content = result?.content ?? [];
|
|
1689
|
+
const snap = content.find((b) => b?.type === 'snapshot');
|
|
1690
|
+
return snap?.elements ?? [];
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// ----------------------------------------------------------------------------
|
|
1694
|
+
// JSON-RPC NDJSON over stdio
|
|
1695
|
+
// ----------------------------------------------------------------------------
|
|
1696
|
+
|
|
1697
|
+
function sendJsonRpc(writable, msg) {
|
|
1698
|
+
return new Promise((resolve, reject) => {
|
|
1699
|
+
const line = JSON.stringify(msg) + '\n';
|
|
1700
|
+
writable.write(line, (err) => (err ? reject(err) : resolve()));
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function makeJsonRpcReader(readable) {
|
|
1705
|
+
const pending = new Map();
|
|
1706
|
+
const queue = new Map();
|
|
1707
|
+
|
|
1708
|
+
const rl = readline.createInterface({ input: readable, terminal: false });
|
|
1709
|
+
rl.on('line', (line) => {
|
|
1710
|
+
if (!line.trim()) return;
|
|
1711
|
+
let msg;
|
|
1712
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
1713
|
+
if (msg.id === undefined) return;
|
|
1714
|
+
const p = pending.get(msg.id);
|
|
1715
|
+
if (p) {
|
|
1716
|
+
clearTimeout(p.timer);
|
|
1717
|
+
pending.delete(msg.id);
|
|
1718
|
+
p.resolve(msg);
|
|
1719
|
+
} else {
|
|
1720
|
+
queue.set(msg.id, msg);
|
|
1721
|
+
}
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
return {
|
|
1725
|
+
waitFor(id, timeoutMs) {
|
|
1726
|
+
if (queue.has(id)) {
|
|
1727
|
+
const m = queue.get(id);
|
|
1728
|
+
queue.delete(id);
|
|
1729
|
+
return Promise.resolve(m);
|
|
1730
|
+
}
|
|
1731
|
+
return new Promise((resolve, reject) => {
|
|
1732
|
+
const timer = setTimeout(() => {
|
|
1733
|
+
pending.delete(id);
|
|
1734
|
+
reject(withExit(43, `timeout waiting for MCP response id=${id} after ${timeoutMs}ms`));
|
|
1735
|
+
}, timeoutMs);
|
|
1736
|
+
pending.set(id, { resolve, reject, timer });
|
|
1737
|
+
});
|
|
1738
|
+
},
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
function waitForExit(child, timeoutMs) {
|
|
1743
|
+
return new Promise((resolve, reject) => {
|
|
1744
|
+
if (child.exitCode !== null) return resolve(child.exitCode);
|
|
1745
|
+
const timer = setTimeout(() => reject(new Error('child did not exit in time')), timeoutMs);
|
|
1746
|
+
child.once('exit', (code) => {
|
|
1747
|
+
clearTimeout(timer);
|
|
1748
|
+
resolve(code);
|
|
1749
|
+
});
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
function withExit(code, message) {
|
|
1754
|
+
const err = new Error(message);
|
|
1755
|
+
err.exitCode = code;
|
|
1756
|
+
return err;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// ----------------------------------------------------------------------------
|
|
1760
|
+
// Daemon state-file helpers
|
|
1761
|
+
// ----------------------------------------------------------------------------
|
|
1762
|
+
|
|
1763
|
+
function browserSkillHome() {
|
|
1764
|
+
return process.env.BROWSER_SKILL_HOME || join(homedir(), '.browser-skill');
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
function daemonStatePath() {
|
|
1768
|
+
return join(browserSkillHome(), 'cdt-mcp-daemon.json');
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function readDaemonState() {
|
|
1772
|
+
const p = daemonStatePath();
|
|
1773
|
+
if (!existsSync(p)) return null;
|
|
1774
|
+
try {
|
|
1775
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
1776
|
+
} catch (_) {
|
|
1777
|
+
return null;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function isPidAlive(pid) {
|
|
1782
|
+
try {
|
|
1783
|
+
process.kill(pid, 0);
|
|
1784
|
+
return true;
|
|
1785
|
+
} catch (_) {
|
|
1786
|
+
return false;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function isDaemonAlive() {
|
|
1791
|
+
const s = readDaemonState();
|
|
1792
|
+
return !!(s && isPidAlive(s.pid) && s.port);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
function sleep(ms) {
|
|
1796
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// ----------------------------------------------------------------------------
|
|
1800
|
+
// Entry point — placed at end so all top-level `const` declarations above
|
|
1801
|
+
// have completed initialization before realDispatch's body references them.
|
|
1802
|
+
// ----------------------------------------------------------------------------
|
|
1803
|
+
|
|
1804
|
+
if (process.env.BROWSER_SKILL_LIB_STUB === '1') {
|
|
1805
|
+
stubDispatch(argv);
|
|
1806
|
+
process.exit(0);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
realDispatch(argv).catch((err) => {
|
|
1810
|
+
process.stderr.write(`chrome-devtools-bridge: ${err.message}\n`);
|
|
1811
|
+
process.exit(typeof err.exitCode === 'number' ? err.exitCode : 1);
|
|
1812
|
+
});
|