@bubstack/moe-glass 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/agents/browser-user.md +105 -0
- package/dist/LICENSE +25 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22517 -0
- package/dist/index.js.map +1 -0
- package/dist/payload.d.ts +214 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +325 -0
- package/dist/payload.js.map +1 -0
- package/package.json +59 -0
- package/skills/browsing/COMMANDLINE-USAGE.md +595 -0
- package/skills/browsing/EXAMPLES.md +717 -0
- package/skills/browsing/README.md +55 -0
- package/skills/browsing/SKILL.md +478 -0
- package/skills/browsing/chrome-ws +1021 -0
- package/skills/browsing/chrome-ws-lib.js +461 -0
- package/skills/browsing/host-override.js +98 -0
- package/skills/browsing/lib/browser-bridge.js +175 -0
- package/skills/browsing/lib/browser-session.js +137 -0
- package/skills/browsing/lib/capture.js +499 -0
- package/skills/browsing/lib/cdp-router.js +72 -0
- package/skills/browsing/lib/cdp-utils.js +18 -0
- package/skills/browsing/lib/chrome-launcher-helpers.js +374 -0
- package/skills/browsing/lib/chrome-process.js +464 -0
- package/skills/browsing/lib/console-logging.js +70 -0
- package/skills/browsing/lib/cookies.js +17 -0
- package/skills/browsing/lib/dialogs-render.js +154 -0
- package/skills/browsing/lib/dialogs-router.js +117 -0
- package/skills/browsing/lib/dialogs.js +254 -0
- package/skills/browsing/lib/element-selector.js +91 -0
- package/skills/browsing/lib/evaluation.js +85 -0
- package/skills/browsing/lib/extraction.js +55 -0
- package/skills/browsing/lib/file-upload.js +56 -0
- package/skills/browsing/lib/html-diff.js +122 -0
- package/skills/browsing/lib/key-definitions.js +149 -0
- package/skills/browsing/lib/keyboard-input.js +288 -0
- package/skills/browsing/lib/mouse.js +423 -0
- package/skills/browsing/lib/navigation.js +272 -0
- package/skills/browsing/lib/page-scripts/dom-summary.js +31 -0
- package/skills/browsing/lib/page-scripts/markdown.js +85 -0
- package/skills/browsing/lib/page-scripts/permission-shim.js +80 -0
- package/skills/browsing/lib/page-session.js +106 -0
- package/skills/browsing/lib/profile-lock.js +179 -0
- package/skills/browsing/lib/screenshot.js +171 -0
- package/skills/browsing/lib/select-option.js +99 -0
- package/skills/browsing/lib/session-state.js +66 -0
- package/skills/browsing/lib/tabs.js +144 -0
- package/skills/browsing/lib/viewport.js +103 -0
- package/skills/browsing/lib/websocket-client.js +162 -0
- package/skills/browsing/package.json +11 -0
- package/skills/browsing/test-chrome-args.js +81 -0
- package/skills/browsing/test-cookies.js +21 -0
- package/skills/browsing/test-e2e.sh +51 -0
- package/skills/browsing/test-extract.sh +17 -0
- package/skills/browsing/test-interact.sh +11 -0
- package/skills/browsing/test-navigate.sh +9 -0
- package/skills/browsing/test-raw.sh +8 -0
- package/skills/browsing/test-tabs.sh +15 -0
- package/skills/browsing/test-viewport.js +27 -0
- package/skills/browsing/test-wait.sh +9 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
const {
|
|
2
|
+
readProfileMeta,
|
|
3
|
+
writeProfileMeta,
|
|
4
|
+
clearProfileMeta,
|
|
5
|
+
isPortAlive,
|
|
6
|
+
findAvailablePort,
|
|
7
|
+
findPidOnPort,
|
|
8
|
+
findOrphanChromeForProfile,
|
|
9
|
+
buildChromeArgs,
|
|
10
|
+
getChromeProfileDir,
|
|
11
|
+
} = require('./chrome-launcher-helpers');
|
|
12
|
+
const profileLock = require('./profile-lock');
|
|
13
|
+
const { spawn } = require('child_process');
|
|
14
|
+
const { existsSync, mkdirSync } = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Chrome process lifecycle + profile management. Reads and writes session
|
|
19
|
+
* state heavily, so it gets the state bag directly (not just helpers like
|
|
20
|
+
* the action modules do). Also takes the few cross-section helpers it
|
|
21
|
+
* needs — chromeHttp for graceful shutdown, getTabs/newTab for the
|
|
22
|
+
* show/hide tab-restoration flow.
|
|
23
|
+
*
|
|
24
|
+
* `attachChromeProcess({ state, chromeHttp, getTabs, newTab })` returns
|
|
25
|
+
* the bound methods.
|
|
26
|
+
*/
|
|
27
|
+
function attachChromeProcess({ state, chromeHttp, getTabs, newTab }) {
|
|
28
|
+
// Read-once derived constants from the per-session host-override.
|
|
29
|
+
const CHROME_DEBUG_HOST = state.hostOverride.getHost();
|
|
30
|
+
const CHROME_DEBUG_PORT = state.hostOverride.getPort();
|
|
31
|
+
|
|
32
|
+
// Per-MCP-instance lock on the profile name. Acquired lazily on the first
|
|
33
|
+
// startChrome that uses the default-derived profile. Released by the exit
|
|
34
|
+
// handler below.
|
|
35
|
+
function ensureProfileLock() {
|
|
36
|
+
if (state._profileLockPath) return; // already locked
|
|
37
|
+
|
|
38
|
+
// Don't auto-disambiguate when the caller (or env) was explicit about the
|
|
39
|
+
// profile. An explicit profile signals intentional sharing — the user
|
|
40
|
+
// wants subsequent MCPs to reconnect to that exact Chrome.
|
|
41
|
+
if (state._profileExplicit) {
|
|
42
|
+
const lockPath = profileLock.acquire(state.chromeProfileName);
|
|
43
|
+
if (lockPath) state._profileLockPath = lockPath;
|
|
44
|
+
// If we can't get it (another live MCP holds the same explicit name),
|
|
45
|
+
// we still proceed — the existing reconnect/adopt flow takes over and
|
|
46
|
+
// the user gets what they asked for: a shared Chrome.
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { profileName, lockPath, slot } =
|
|
51
|
+
profileLock.acquireWithFallback(state.chromeProfileName);
|
|
52
|
+
if (slot > 1) {
|
|
53
|
+
console.error(
|
|
54
|
+
`Another MCP holds profile '${state.chromeProfileName}'; ` +
|
|
55
|
+
`using '${profileName}' instead. Set CHROME_WS_PROFILE to opt out of auto-disambiguation.`
|
|
56
|
+
);
|
|
57
|
+
state.chromeProfileName = profileName;
|
|
58
|
+
// Force the launcher to rederive userDataDir from the new name on next
|
|
59
|
+
// spawn — the cached one points at the old (locked) profile dir.
|
|
60
|
+
state.chromeUserDataDir = null;
|
|
61
|
+
}
|
|
62
|
+
state._profileLockPath = lockPath;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Release the lock when this MCP process exits. Registered once per attach.
|
|
66
|
+
// Both 'exit' (clean) and the SIG* paths are covered. fs.unlinkSync in the
|
|
67
|
+
// 'exit' handler is intentional — async work can't run there.
|
|
68
|
+
if (!state._profileLockExitHandlerRegistered) {
|
|
69
|
+
state._profileLockExitHandlerRegistered = true;
|
|
70
|
+
const releaseOnce = () => {
|
|
71
|
+
if (state._profileLockPath) {
|
|
72
|
+
profileLock.release(state._profileLockPath);
|
|
73
|
+
state._profileLockPath = null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
process.on('exit', releaseOnce);
|
|
77
|
+
process.on('SIGINT', () => { releaseOnce(); process.exit(130); });
|
|
78
|
+
process.on('SIGTERM', () => { releaseOnce(); process.exit(143); });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function startChrome(headless = null, profileName = null, port = null) {
|
|
82
|
+
if (headless !== null) {
|
|
83
|
+
state.chromeHeadless = headless;
|
|
84
|
+
}
|
|
85
|
+
if (profileName !== null) {
|
|
86
|
+
state.chromeProfileName = profileName;
|
|
87
|
+
state._profileExplicit = true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// First-call lock acquisition. Auto-disambiguates when the default profile
|
|
91
|
+
// is contended; respects explicit profile choice.
|
|
92
|
+
ensureProfileLock();
|
|
93
|
+
|
|
94
|
+
// --- Step 1: Reuse an already-running Chrome on this profile ---
|
|
95
|
+
// Enables reconnection after MCP restart while Chrome is still alive.
|
|
96
|
+
if (!port) {
|
|
97
|
+
const meta = readProfileMeta(state.chromeProfileName);
|
|
98
|
+
if (meta && meta.port) {
|
|
99
|
+
if (await isPortAlive(CHROME_DEBUG_HOST, meta.port, meta.pid)) {
|
|
100
|
+
state.activePort = meta.port;
|
|
101
|
+
console.error(`Reconnected to existing Chrome (port: ${meta.port}, PID: ${meta.pid}, profile: ${state.chromeProfileName})`);
|
|
102
|
+
return false; // reconnected — no new Chrome spawned
|
|
103
|
+
}
|
|
104
|
+
// Stale meta.json — Chrome died without cleanup
|
|
105
|
+
clearProfileMeta(state.chromeProfileName);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Step 1.5: Adopt an orphan Chrome that holds our profile lock ---
|
|
109
|
+
// If a previous MCP session exited without cleanup, there may be a Chrome
|
|
110
|
+
// still running with our profile. Detect via process inspection: filter ps for
|
|
111
|
+
// chrome processes with --user-data-dir=<our profile dir> and --remote-debugging-port=N.
|
|
112
|
+
const orphanInfo = await Promise.resolve().then(() => findOrphanChromeForProfile(state.chromeProfileName));
|
|
113
|
+
if (orphanInfo && await isPortAlive(CHROME_DEBUG_HOST, orphanInfo.port, orphanInfo.pid)) {
|
|
114
|
+
state.activePort = orphanInfo.port;
|
|
115
|
+
// Persist meta.json so subsequent runs hit Step 1 directly.
|
|
116
|
+
writeProfileMeta(state.chromeProfileName, { port: orphanInfo.port, pid: orphanInfo.pid });
|
|
117
|
+
console.error(`Adopted orphan Chrome (port: ${orphanInfo.port}, PID: ${orphanInfo.pid}, profile: ${state.chromeProfileName})`);
|
|
118
|
+
return false; // adopted — no new Chrome spawned
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Step 2: Choose a port ---
|
|
123
|
+
// Priority: explicit port param > CHROME_WS_PORT env var > dynamic allocation.
|
|
124
|
+
const HAS_ENV_PORT = process.env.CHROME_WS_PORT !== undefined;
|
|
125
|
+
let chosenPort;
|
|
126
|
+
if (port) {
|
|
127
|
+
chosenPort = port;
|
|
128
|
+
} else if (HAS_ENV_PORT) {
|
|
129
|
+
chosenPort = CHROME_DEBUG_PORT; // already parsed from env by host-override.js
|
|
130
|
+
} else {
|
|
131
|
+
chosenPort = await findAvailablePort();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- Step 3: Find Chrome binary ---
|
|
135
|
+
const chromePaths = {
|
|
136
|
+
darwin: [
|
|
137
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
138
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium'
|
|
139
|
+
],
|
|
140
|
+
linux: [
|
|
141
|
+
'/usr/bin/google-chrome',
|
|
142
|
+
'/usr/bin/chromium-browser',
|
|
143
|
+
'/usr/bin/chromium'
|
|
144
|
+
],
|
|
145
|
+
win32: [
|
|
146
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
147
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
|
|
148
|
+
]
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const platform = os.platform();
|
|
152
|
+
const paths = chromePaths[platform] || [];
|
|
153
|
+
|
|
154
|
+
// CHROME_WS_BROWSER overrides auto-detection (documented in README.md /
|
|
155
|
+
// COMMANDLINE-USAGE.md, already honored by the chrome-ws CLI) — this path
|
|
156
|
+
// is what the MCP `use_browser` tool actually runs, so it needs the same.
|
|
157
|
+
// A stale or mistyped override would otherwise be spawned as-is, surfacing as
|
|
158
|
+
// an opaque "Chrome did not become ready" timeout, so fall back to
|
|
159
|
+
// auto-detection when the path does not exist.
|
|
160
|
+
let chromePath = process.env.CHROME_WS_BROWSER;
|
|
161
|
+
if (chromePath && !existsSync(chromePath)) {
|
|
162
|
+
console.error(`CHROME_WS_BROWSER is set to ${chromePath} but no file exists there; falling back to auto-detection`);
|
|
163
|
+
chromePath = null;
|
|
164
|
+
}
|
|
165
|
+
if (!chromePath) {
|
|
166
|
+
for (const path of paths) {
|
|
167
|
+
if (existsSync(path)) {
|
|
168
|
+
chromePath = path;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!chromePath) {
|
|
175
|
+
const overrideNote = process.env.CHROME_WS_BROWSER
|
|
176
|
+
? ` (CHROME_WS_BROWSER=${process.env.CHROME_WS_BROWSER} does not exist)`
|
|
177
|
+
: '';
|
|
178
|
+
throw new Error(`Chrome not found. Searched: ${paths.join(', ')}${overrideNote}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Persistent profile directory (re-used across sessions).
|
|
182
|
+
if (!state.chromeUserDataDir) {
|
|
183
|
+
state.chromeUserDataDir = getChromeProfileDir(state.chromeProfileName);
|
|
184
|
+
mkdirSync(state.chromeUserDataDir, { recursive: true });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- Step 4: Launch Chrome with the chosen port ---
|
|
188
|
+
const args = buildChromeArgs({
|
|
189
|
+
chosenPort,
|
|
190
|
+
chromeUserDataDir: state.chromeUserDataDir,
|
|
191
|
+
chromeHeadless: state.chromeHeadless,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const proc = spawn(chromePath, args, {
|
|
195
|
+
detached: true,
|
|
196
|
+
stdio: 'ignore'
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
proc.unref();
|
|
200
|
+
state.chromeProcess = proc;
|
|
201
|
+
state.activePort = chosenPort;
|
|
202
|
+
|
|
203
|
+
// Clear the handle if Chrome exits on its own after launch.
|
|
204
|
+
proc.on('exit', () => {
|
|
205
|
+
if (state.chromeProcess === proc) {
|
|
206
|
+
state.chromeProcess = null;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Spawn failures (EACCES, EISDIR — e.g. CHROME_WS_BROWSER pointing at the
|
|
211
|
+
// .app bundle instead of the inner binary) arrive as an async 'error'
|
|
212
|
+
// event; without a listener that's an uncaught exception that kills the
|
|
213
|
+
// whole server. Capture it and fail the launch below.
|
|
214
|
+
let spawnError = null;
|
|
215
|
+
proc.on('error', (err) => {
|
|
216
|
+
spawnError = err;
|
|
217
|
+
if (state.chromeProcess === proc) {
|
|
218
|
+
state.chromeProcess = null;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Poll until Chrome's debug port is accepting connections (or 15s timeout).
|
|
223
|
+
const POLL_INTERVAL_MS = 200;
|
|
224
|
+
const POLL_TIMEOUT_MS = 15000;
|
|
225
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
226
|
+
while (Date.now() < deadline) {
|
|
227
|
+
if (spawnError) {
|
|
228
|
+
throw new Error(`Failed to launch Chrome (${chromePath}): ${spawnError.message}`);
|
|
229
|
+
}
|
|
230
|
+
if (await isPortAlive(CHROME_DEBUG_HOST, chosenPort)) break;
|
|
231
|
+
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
232
|
+
}
|
|
233
|
+
if (spawnError) {
|
|
234
|
+
throw new Error(`Failed to launch Chrome (${chromePath}): ${spawnError.message}`);
|
|
235
|
+
}
|
|
236
|
+
if (!(await isPortAlive(CHROME_DEBUG_HOST, chosenPort))) {
|
|
237
|
+
state.chromeProcess = null;
|
|
238
|
+
throw new Error(`Chrome did not become ready on port ${chosenPort} within ${POLL_TIMEOUT_MS}ms`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- Step 5: Persist port assignment in meta.json ---
|
|
242
|
+
writeProfileMeta(state.chromeProfileName, {
|
|
243
|
+
port: chosenPort,
|
|
244
|
+
pid: proc.pid,
|
|
245
|
+
headless: state.chromeHeadless,
|
|
246
|
+
profileName: state.chromeProfileName,
|
|
247
|
+
userDataDir: state.chromeUserDataDir,
|
|
248
|
+
startedAt: new Date().toISOString()
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const mode = state.chromeHeadless ? 'headless' : 'headed';
|
|
252
|
+
console.error(`Chrome started in ${mode} mode (PID: ${proc.pid}, port: ${chosenPort}, profile: ${state.chromeProfileName})`);
|
|
253
|
+
return true; // new Chrome was spawned
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function closeBridge() {
|
|
257
|
+
if (!state.browserSession) return;
|
|
258
|
+
// Race against a short timeout — don't let a hung close block SIGTERM.
|
|
259
|
+
await Promise.race([
|
|
260
|
+
Promise.resolve().then(() => state.browserSession.close()).catch(() => {}),
|
|
261
|
+
new Promise((r) => setTimeout(r, 500)),
|
|
262
|
+
]);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function killChrome() {
|
|
266
|
+
await closeBridge();
|
|
267
|
+
let pidToKill = null;
|
|
268
|
+
|
|
269
|
+
if (state.chromeProcess && state.chromeProcess.pid) {
|
|
270
|
+
pidToKill = state.chromeProcess.pid;
|
|
271
|
+
} else if (state.activePort) {
|
|
272
|
+
// We didn't launch this Chrome (or already dropped the handle), but we
|
|
273
|
+
// know the port. Kill whoever holds it so showBrowser/hideBrowser can
|
|
274
|
+
// restart cleanly in the target mode.
|
|
275
|
+
pidToKill = findPidOnPort(state.activePort);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (pidToKill === null) {
|
|
279
|
+
// Nothing to kill. Still clear meta.json so other sessions don't
|
|
280
|
+
// think there's a Chrome here.
|
|
281
|
+
clearProfileMeta(state.chromeProfileName);
|
|
282
|
+
state.chromeProcess = null;
|
|
283
|
+
state.activePort = CHROME_DEBUG_PORT;
|
|
284
|
+
state.resetBridge?.();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
// Try graceful shutdown via CDP first.
|
|
290
|
+
try {
|
|
291
|
+
await chromeHttp('/json/close', 'GET');
|
|
292
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
293
|
+
} catch (_e) {
|
|
294
|
+
// Ignore — Chrome might already be dead.
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
process.kill(pidToKill, 'SIGTERM');
|
|
299
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
300
|
+
} catch (_e) {
|
|
301
|
+
// Process might already be dead.
|
|
302
|
+
}
|
|
303
|
+
} catch (e) {
|
|
304
|
+
console.error(`Error killing Chrome: ${e.message}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
clearProfileMeta(state.chromeProfileName);
|
|
308
|
+
state.chromeProcess = null;
|
|
309
|
+
state.activePort = CHROME_DEBUG_PORT;
|
|
310
|
+
state.resetBridge?.();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Switch headless/headed by killing and restarting Chrome on the same port,
|
|
314
|
+
// then reopening any non-blank tabs that were open. Pages re-request via GET,
|
|
315
|
+
// so POST-based state is lost — this is a deliberate trade-off documented in
|
|
316
|
+
// the showBrowser/hideBrowser return strings.
|
|
317
|
+
async function restartInMode({ targetHeadless, alreadyMessage, doneMessage }) {
|
|
318
|
+
// Only skip the restart if Chrome is actually running in the desired mode.
|
|
319
|
+
// After an external kill the mode flag may be stale, so cross-check with
|
|
320
|
+
// an actual liveness probe before returning the "already X" short-circuit.
|
|
321
|
+
if (state.chromeHeadless === targetHeadless) {
|
|
322
|
+
const chromeAlive = state.activePort
|
|
323
|
+
? await isPortAlive(CHROME_DEBUG_HOST, state.activePort)
|
|
324
|
+
: false;
|
|
325
|
+
if (chromeAlive) {
|
|
326
|
+
return alreadyMessage;
|
|
327
|
+
}
|
|
328
|
+
// Chrome is dead despite matching mode flag — fall through and restart.
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const transition = targetHeadless ? 'headless mode (hiding browser window)' : 'headed mode (browser window will be visible)';
|
|
332
|
+
console.error(`Switching to ${transition}...`);
|
|
333
|
+
console.error('WARNING: This will restart Chrome and lose any POST-based page state');
|
|
334
|
+
|
|
335
|
+
let currentTabs = [];
|
|
336
|
+
try {
|
|
337
|
+
const tabs = await getTabs();
|
|
338
|
+
currentTabs = tabs.map(t => t.url).filter(url => url && url !== 'about:blank');
|
|
339
|
+
} catch (_e) {
|
|
340
|
+
// Chrome not running — nothing to capture.
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
await killChrome();
|
|
344
|
+
// killChrome() resets state.activePort to CHROME_DEBUG_PORT; use that
|
|
345
|
+
// reset value rather than a pre-kill snapshot which may carry a wedged port.
|
|
346
|
+
await startChrome(targetHeadless, null, null);
|
|
347
|
+
|
|
348
|
+
if (currentTabs.length > 0) {
|
|
349
|
+
console.error(`Reopening ${currentTabs.length} tab(s)...`);
|
|
350
|
+
for (const url of currentTabs) {
|
|
351
|
+
try {
|
|
352
|
+
await newTab(url);
|
|
353
|
+
} catch (e) {
|
|
354
|
+
console.error(`Failed to reopen ${url}: ${e.message}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return doneMessage;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function showBrowser() {
|
|
363
|
+
return restartInMode({
|
|
364
|
+
targetHeadless: false,
|
|
365
|
+
alreadyMessage: 'Browser is already visible',
|
|
366
|
+
doneMessage: 'Browser window is now visible. Note: Pages were reloaded via GET requests.',
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function hideBrowser() {
|
|
371
|
+
return restartInMode({
|
|
372
|
+
targetHeadless: true,
|
|
373
|
+
alreadyMessage: 'Browser is already in headless mode',
|
|
374
|
+
doneMessage: 'Browser is now in headless mode. Note: Pages were reloaded via GET requests.',
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function getBrowserMode() {
|
|
379
|
+
// If we spawned Chrome ourselves, trust the handle and report directly.
|
|
380
|
+
// killChrome/exit handlers clear chromeProcess, so a non-null handle is
|
|
381
|
+
// the strongest signal that Chrome is alive.
|
|
382
|
+
let running, pid;
|
|
383
|
+
if (state.chromeProcess) {
|
|
384
|
+
running = true;
|
|
385
|
+
pid = state.chromeProcess.pid;
|
|
386
|
+
} else {
|
|
387
|
+
// Bridge reconnected to a Chrome we didn't spawn — either via
|
|
388
|
+
// meta.json (prior MCP session left it running) or orphan adoption.
|
|
389
|
+
// state.chromeProcess is null but state.activePort is set and the
|
|
390
|
+
// CDP works. Resolve the pid from meta.json or port scan, then
|
|
391
|
+
// verify Chrome is actually answering on activePort.
|
|
392
|
+
const meta = readProfileMeta(state.chromeProfileName);
|
|
393
|
+
pid = (meta && meta.pid) ? meta.pid : (state.activePort ? findPidOnPort(state.activePort) : null);
|
|
394
|
+
running = state.activePort
|
|
395
|
+
? await isPortAlive(CHROME_DEBUG_HOST, state.activePort, pid)
|
|
396
|
+
: false;
|
|
397
|
+
if (!running) pid = null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Always report the configured profile/profileDir/port so the caller knows
|
|
401
|
+
// what would happen on next start, regardless of whether Chrome is running.
|
|
402
|
+
// When stopped, chromeUserDataDir may be null (not yet derived); derive it
|
|
403
|
+
// lazily from the profile name so the response is always informative.
|
|
404
|
+
const profileDir = state.chromeUserDataDir ?? getChromeProfileDir(state.chromeProfileName);
|
|
405
|
+
return {
|
|
406
|
+
headless: state.chromeHeadless,
|
|
407
|
+
mode: state.chromeHeadless ? 'headless' : 'headed',
|
|
408
|
+
running,
|
|
409
|
+
pid,
|
|
410
|
+
port: state.activePort,
|
|
411
|
+
profile: state.chromeProfileName,
|
|
412
|
+
profileDir,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function getChromePid() {
|
|
417
|
+
return state.chromeProcess ? state.chromeProcess.pid : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function getActivePort() {
|
|
421
|
+
return state.activePort;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function getProfileName() {
|
|
425
|
+
return state.chromeProfileName;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function setProfileName(profileName) {
|
|
429
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(profileName)) {
|
|
430
|
+
throw new Error('Invalid profile name. Only alphanumeric characters, hyphens, and underscores are allowed.');
|
|
431
|
+
}
|
|
432
|
+
if (state.chromeProcess) {
|
|
433
|
+
throw new Error('Cannot change profile while Chrome is running. Kill Chrome first.');
|
|
434
|
+
}
|
|
435
|
+
// An explicit set_profile is the user opting OUT of auto-disambiguation:
|
|
436
|
+
// they want to share Chrome with another process that uses this exact
|
|
437
|
+
// name. Release whatever default-slot lock we already hold (if any), set
|
|
438
|
+
// the flag so ensureProfileLock() takes the explicit path next time, and
|
|
439
|
+
// forget the previous lock path.
|
|
440
|
+
if (state._profileLockPath) {
|
|
441
|
+
profileLock.release(state._profileLockPath);
|
|
442
|
+
state._profileLockPath = null;
|
|
443
|
+
}
|
|
444
|
+
state._profileExplicit = true;
|
|
445
|
+
state.chromeProfileName = profileName;
|
|
446
|
+
state.chromeUserDataDir = null; // Reset so next startChrome() uses new profile
|
|
447
|
+
state.activePort = CHROME_DEBUG_PORT; // Reset so a prior rotated port doesn't carry forward
|
|
448
|
+
return `Profile set to: ${profileName}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
startChrome,
|
|
453
|
+
killChrome,
|
|
454
|
+
showBrowser,
|
|
455
|
+
hideBrowser,
|
|
456
|
+
getBrowserMode,
|
|
457
|
+
getChromePid,
|
|
458
|
+
getActivePort,
|
|
459
|
+
getProfileName,
|
|
460
|
+
setProfileName,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
module.exports = { attachChromeProcess };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page console-message capture.
|
|
3
|
+
*
|
|
4
|
+
* `enableConsoleLogging` subscribes to `Runtime.consoleAPICalled` events on
|
|
5
|
+
* the existing pageSession (bridge) connection and streams console output into
|
|
6
|
+
* `state.consoleMessages` keyed by `sessionId`.
|
|
7
|
+
*
|
|
8
|
+
* `getConsoleMessages` reads the buffer — optionally filtered by timestamp.
|
|
9
|
+
* `clearConsoleMessages` resets the buffer for a tab.
|
|
10
|
+
*
|
|
11
|
+
* `attachConsoleLogging({ state, getPageSession })` returns the bound API.
|
|
12
|
+
*/
|
|
13
|
+
function attachConsoleLogging({ state, getPageSession }) {
|
|
14
|
+
async function enableConsoleLogging(tabIndexOrWsUrl) {
|
|
15
|
+
const ps = await getPageSession(tabIndexOrWsUrl);
|
|
16
|
+
|
|
17
|
+
if (!state.consoleMessages.has(ps.sessionId)) {
|
|
18
|
+
state.consoleMessages.set(ps.sessionId, []);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
await ps.enableDomain('Runtime');
|
|
22
|
+
|
|
23
|
+
ps.onEvent((msg) => {
|
|
24
|
+
if (msg.method === 'Runtime.consoleAPICalled') {
|
|
25
|
+
const entry = msg.params;
|
|
26
|
+
const timestamp = new Date().toISOString();
|
|
27
|
+
const level = entry.type || 'log';
|
|
28
|
+
const args = entry.args || [];
|
|
29
|
+
|
|
30
|
+
const text = args.map(arg => {
|
|
31
|
+
if (arg.type === 'string') return arg.value;
|
|
32
|
+
if (arg.type === 'number') return String(arg.value);
|
|
33
|
+
if (arg.type === 'boolean') return String(arg.value);
|
|
34
|
+
if (arg.type === 'object') return arg.description || '[Object]';
|
|
35
|
+
return String(arg.value || arg.description || arg.type);
|
|
36
|
+
}).join(' ');
|
|
37
|
+
|
|
38
|
+
const messages = state.consoleMessages.get(ps.sessionId) || [];
|
|
39
|
+
// Dedup: skip if the last entry has the same level+text at the same
|
|
40
|
+
// timestamp (prevents double-fire when multiple CDP event listeners
|
|
41
|
+
// route the same console call through the same handler).
|
|
42
|
+
const last = messages[messages.length - 1];
|
|
43
|
+
if (!last || last.timestamp !== timestamp || last.level !== level || last.text !== text) {
|
|
44
|
+
messages.push({ timestamp, level, text });
|
|
45
|
+
state.consoleMessages.set(ps.sessionId, messages);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function getConsoleMessages(tabIndexOrWsUrl, sinceTime = null) {
|
|
52
|
+
const ps = await getPageSession(tabIndexOrWsUrl);
|
|
53
|
+
const messages = state.consoleMessages.get(ps.sessionId) || [];
|
|
54
|
+
|
|
55
|
+
if (!sinceTime) {
|
|
56
|
+
return messages;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return messages.filter(msg => new Date(msg.timestamp) > sinceTime);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function clearConsoleMessages(tabIndexOrWsUrl) {
|
|
63
|
+
const ps = await getPageSession(tabIndexOrWsUrl);
|
|
64
|
+
state.consoleMessages.set(ps.sessionId, []);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { enableConsoleLogging, getConsoleMessages, clearConsoleMessages };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { attachConsoleLogging };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie management — currently just a single "clear everything" action.
|
|
3
|
+
*
|
|
4
|
+
* Takes `getPageSession(tabIndexOrWsUrl)`: a resolver provided by chrome-ws-lib
|
|
5
|
+
* that handles both tab-index and ws-url inputs, lazy-bootstraps the CDP bridge,
|
|
6
|
+
* and returns a pageSession driving CDP via flatten mode.
|
|
7
|
+
*/
|
|
8
|
+
function attachCookies({ getPageSession }) {
|
|
9
|
+
async function clearCookies(tabIndexOrWsUrl) {
|
|
10
|
+
const ps = await getPageSession(tabIndexOrWsUrl);
|
|
11
|
+
await ps.send('Network.clearBrowserCookies', {});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return { clearCookies };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { attachCookies };
|