@remcp/runtime 0.2.165 → 0.2.167
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/package.json
CHANGED
|
@@ -3,9 +3,10 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { isIP } from 'node:net';
|
|
5
5
|
import { lookup } from 'node:dns/promises';
|
|
6
|
-
import {
|
|
6
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
7
7
|
import { resolveSafePath } from '../util.mjs';
|
|
8
8
|
import { selectSafeBrowserLaunchProfile } from './browser-session-backup.mjs';
|
|
9
|
+
import { shouldRestoreBrowserSession } from './browser-session-directory.mjs';
|
|
9
10
|
import { shareBrowserDiscovery } from './browser-discovery-flight.mjs';
|
|
10
11
|
import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
|
|
11
12
|
|
|
@@ -64,54 +65,7 @@ export function browserDataDir() {
|
|
|
64
65
|
return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'remcp', 'browser-cdp');
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
// agent. The agent can be restarted by an npm update while Chrome is open.
|
|
69
|
-
// If Chrome was interrupted and already persisted tabs, ask Chromium itself to
|
|
70
|
-
// restore the session. Never parse, persist, or relay the sensitive tab URLs.
|
|
71
|
-
// A clean user-initiated quit must not resurrect a deliberately closed session.
|
|
72
|
-
export function shouldRestoreBrowserSession(profile) {
|
|
73
|
-
let prefsFd;
|
|
74
|
-
let sessionFd;
|
|
75
|
-
try {
|
|
76
|
-
const defaultProfile = path.join(profile, 'Default');
|
|
77
|
-
// Open first, then validate the file descriptor: checking the path with
|
|
78
|
-
// lstat before reading it creates a symlink-swap TOCTOU vulnerability.
|
|
79
|
-
prefsFd = openSync(path.join(defaultProfile, 'Preferences'),
|
|
80
|
-
fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
|
|
81
|
-
const prefsStat = fstatSync(prefsFd);
|
|
82
|
-
if (!prefsStat.isFile() || prefsStat.size > 4 * 1024 * 1024) return false;
|
|
83
|
-
const prefs = JSON.parse(readFileSync(prefsFd, 'utf8'));
|
|
84
|
-
if (prefs?.profile?.exit_type !== 'Crashed') return false;
|
|
85
|
-
|
|
86
|
-
const sessionDir = path.join(defaultProfile, 'Sessions');
|
|
87
|
-
let readableDir = sessionDir;
|
|
88
|
-
if (process.platform !== 'win32') {
|
|
89
|
-
// Use the opened directory even if another process renames the path.
|
|
90
|
-
// O_NOFOLLOW refuses a linked Sessions directory on Unix platforms.
|
|
91
|
-
sessionFd = openSync(sessionDir,
|
|
92
|
-
fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY || 0) | (fsConstants.O_NOFOLLOW || 0));
|
|
93
|
-
if (!fstatSync(sessionFd).isDirectory()) return false;
|
|
94
|
-
readableDir = `${process.platform === 'darwin' ? '/dev/fd' : '/proc/self/fd'}/${sessionFd}`;
|
|
95
|
-
}
|
|
96
|
-
return readdirSync(readableDir).some(name => {
|
|
97
|
-
if (!/^Tabs_\d+$/.test(name)) return false;
|
|
98
|
-
let tabFd;
|
|
99
|
-
try {
|
|
100
|
-
tabFd = openSync(path.join(readableDir, name),
|
|
101
|
-
fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
|
|
102
|
-
const stat = fstatSync(tabFd);
|
|
103
|
-
return stat.isFile() && stat.size > 0;
|
|
104
|
-
} catch { return false; }
|
|
105
|
-
finally { if (tabFd !== undefined) closeSync(tabFd); }
|
|
106
|
-
});
|
|
107
|
-
} catch {
|
|
108
|
-
// A new, malformed or inaccessible profile must start fresh, not fail CDP.
|
|
109
|
-
return false;
|
|
110
|
-
} finally {
|
|
111
|
-
if (sessionFd !== undefined) closeSync(sessionFd);
|
|
112
|
-
if (prefsFd !== undefined) closeSync(prefsFd);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
68
|
+
export { shouldRestoreBrowserSession };
|
|
115
69
|
|
|
116
70
|
export function browserAutoLaunchArgs(profile, {
|
|
117
71
|
platform = process.platform,
|
|
@@ -144,8 +98,8 @@ export async function waitForBrowserEndpoint(timeoutMs = 5000) {
|
|
|
144
98
|
|
|
145
99
|
export async function ensureBrowserEndpoint(endpoint) {
|
|
146
100
|
assertBrowserRemoteEnabled();
|
|
147
|
-
if (endpoint || process.env.REMCP_CDP_URL) return;
|
|
148
|
-
if (await browserCapabilityAvailable(undefined, 250)) return;
|
|
101
|
+
if (endpoint || process.env.REMCP_CDP_URL) return false;
|
|
102
|
+
if (await browserCapabilityAvailable(undefined, 250)) return false;
|
|
149
103
|
if (!browserLaunchPromise) {
|
|
150
104
|
browserLaunchPromise = (async () => {
|
|
151
105
|
const executable = browserExecutable();
|
|
@@ -158,9 +112,10 @@ export async function ensureBrowserEndpoint(endpoint) {
|
|
|
158
112
|
if (selected.isolated) console.warn('ReMCP browser: using an isolated recovery profile; existing session and backups are preserved, and sign-in state is not copied.');
|
|
159
113
|
spawnDetached(executable, browserAutoLaunchArgs(selected.profile));
|
|
160
114
|
if (!await waitForBrowserEndpoint(5000)) throw new Error('ReMCP launched a browser but its local CDP endpoint did not become ready');
|
|
115
|
+
return true;
|
|
161
116
|
})().finally(() => { browserLaunchPromise = null; });
|
|
162
117
|
}
|
|
163
|
-
|
|
118
|
+
return browserLaunchPromise;
|
|
164
119
|
}
|
|
165
120
|
|
|
166
121
|
export async function browserControlAvailable(endpoint, timeoutMs = 500) {
|
|
@@ -239,11 +194,17 @@ export function isPrivateBrowserHost(hostname) {
|
|
|
239
194
|
}
|
|
240
195
|
|
|
241
196
|
export function endpointUrl(value) {
|
|
197
|
+
if (value != null && typeof value !== 'string' && !(value instanceof URL)) {
|
|
198
|
+
throw new Error('endpoint must be a valid local http(s) URL');
|
|
199
|
+
}
|
|
242
200
|
let url;
|
|
243
|
-
try {
|
|
244
|
-
|
|
201
|
+
try {
|
|
202
|
+
const provided = value instanceof URL ? value.href : optionalString(value);
|
|
203
|
+
url = new URL(provided || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT);
|
|
204
|
+
} catch { throw new Error('endpoint must be a valid local http(s) URL'); }
|
|
245
205
|
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('endpoint must use http or https');
|
|
246
206
|
if (!isLoopbackHost(url.hostname)) throw new Error('Browser CDP endpoint must be loopback-only');
|
|
207
|
+
if (url.username || url.password) throw new Error('Browser CDP endpoint must not contain credentials');
|
|
247
208
|
return url;
|
|
248
209
|
}
|
|
249
210
|
|
|
@@ -98,18 +98,62 @@ export async function browserNavigate(args = {}) {
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// CDP accessibility can include an input's current value even when it is
|
|
102
|
+
// masked on screen. The protected AX property must be checked before the
|
|
103
|
+
// node is mirrored to structuredContent or snapshot diff state.
|
|
104
|
+
export function safeAccessibilityValue(node) {
|
|
105
|
+
const props = Array.isArray(node?.properties) ? node.properties : [];
|
|
106
|
+
const protectedControl = props.some(item => item?.name === 'protected'
|
|
107
|
+
&& (item?.value?.value === true || String(item?.value?.value) === 'true'));
|
|
108
|
+
const role = String(node?.role?.value || '').toLowerCase();
|
|
109
|
+
if (protectedControl || role === 'password' || role === 'passwordtext') return '';
|
|
110
|
+
return boundedText(node?.value?.value, 1000);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Chromium's AX tree can repeat editable input values in descendant
|
|
114
|
+
// StaticText/InlineTextBox names. Blank the complete editable subtree before
|
|
115
|
+
// storing a snapshot diff or sending structured content to the MCP host.
|
|
116
|
+
export function sensitiveAccessibilityDescendants(nodes) {
|
|
117
|
+
const indexed = new Map(nodes.map(node => [node.nodeId, node]));
|
|
118
|
+
const secret = new Set();
|
|
119
|
+
const queue = [];
|
|
120
|
+
for (const node of nodes) {
|
|
121
|
+
const role = String(node.role?.value || '').toLowerCase();
|
|
122
|
+
const properties = Array.isArray(node.properties) ? node.properties : [];
|
|
123
|
+
const editable = properties.some(item => item?.name === 'editable'
|
|
124
|
+
&& ![false,'false',null].includes(item?.value?.value));
|
|
125
|
+
const protectedNode = properties.some(item => item?.name === 'protected'
|
|
126
|
+
&& [true,'true'].includes(item?.value?.value));
|
|
127
|
+
if (['textbox','searchbox','combobox','password','passwordtext','spinbutton'].includes(role)
|
|
128
|
+
|| editable || protectedNode) {
|
|
129
|
+
secret.add(node.nodeId);
|
|
130
|
+
for (const id of node.childIds || []) queue.push(id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
while (queue.length) {
|
|
134
|
+
const id = queue.pop();
|
|
135
|
+
if (secret.has(id)) continue;
|
|
136
|
+
secret.add(id);
|
|
137
|
+
for (const child of indexed.get(id)?.childIds || []) queue.push(child);
|
|
138
|
+
}
|
|
139
|
+
return secret;
|
|
140
|
+
}
|
|
141
|
+
|
|
101
142
|
export async function browserSnapshot(args) {
|
|
102
143
|
return withTarget(args, async (session, target) => {
|
|
103
144
|
await session.send('Accessibility.enable');
|
|
104
145
|
const { nodes = [] } = await session.send('Accessibility.getFullAXTree');
|
|
105
146
|
const limit = clamp(args.max_nodes, 1500, 1, 10_000);
|
|
147
|
+
const redacted = sensitiveAccessibilityDescendants(nodes);
|
|
106
148
|
const simplified = nodes.slice(0, limit).map(node => ({
|
|
107
149
|
node_id: boundedText(node.nodeId, 256),
|
|
108
150
|
ignored: Boolean(node.ignored),
|
|
109
151
|
role: boundedText(node.role?.value, 256),
|
|
110
|
-
name:
|
|
111
|
-
|
|
112
|
-
|
|
152
|
+
name: redacted.has(node.nodeId) && !['textbox','searchbox','combobox','spinbutton'].includes(
|
|
153
|
+
String(node.role?.value||'').toLowerCase()
|
|
154
|
+
) ? '' : boundedText(node.name?.value, 1000),
|
|
155
|
+
value: redacted.has(node.nodeId) ? '' : safeAccessibilityValue(node),
|
|
156
|
+
description: redacted.has(node.nodeId) ? '' : boundedText(node.description?.value, 1000),
|
|
113
157
|
child_ids: Array.isArray(node.childIds) ? node.childIds.slice(0, 200).map(id => boundedText(id, 256)) : [],
|
|
114
158
|
backend_dom_node_id: node.backendDOMNodeId || null,
|
|
115
159
|
}));
|
|
@@ -285,9 +329,19 @@ export function findExpression(args) {
|
|
|
285
329
|
} else {
|
|
286
330
|
selected = selected.slice(0, ${limit});
|
|
287
331
|
}
|
|
332
|
+
// Credentials are never sent back in browser_find; even a normal
|
|
333
|
+
// text input can carry an OTP or API token via its name/autocomplete.
|
|
334
|
+
function isSensitiveControl(el) {
|
|
335
|
+
const tag = el.tagName.toLowerCase();
|
|
336
|
+
if (tag !== 'input' && tag !== 'textarea') return false;
|
|
337
|
+
if (tag === 'input' && String(el.type || '').toLowerCase() === 'password') return true;
|
|
338
|
+
const description = ['id','name','autocomplete'].map(key => el.getAttribute(key) || '').join(' ');
|
|
339
|
+
return /(?:password|passwd|passcode|one.time.code|otp|secret|api.?key|auth.?token|access.?token|recovery.?code)/i.test(description);
|
|
340
|
+
}
|
|
288
341
|
return selected.map(({ el }) => {
|
|
289
342
|
const b = el.getBoundingClientRect();
|
|
290
|
-
|
|
343
|
+
const sensitive = isSensitiveControl(el);
|
|
344
|
+
return { selector: cssPath(el), tag: el.tagName.toLowerCase(), role: semanticRole(el), name: el.getAttribute('aria-label') || el.getAttribute('name') || '', text: sensitive ? '' : visibleText(el).slice(0, 500), value: !sensitive && 'value' in el ? String(el.value).slice(0, 500) : '', x: b.x, y: b.y, width: b.width, height: b.height, disabled: Boolean(el.disabled) };
|
|
291
345
|
});
|
|
292
346
|
})()`;
|
|
293
347
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import process from 'node:process';
|
|
3
|
+
import { scanSessionDirectory, verifySessionDirectory } from './browser-session-directory.mjs';
|
|
3
4
|
import { closeSync, fchmodSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, writeFileSync, writeSync } from 'node:fs';
|
|
4
5
|
|
|
5
6
|
const MAX_FILE_BYTES = 32 * 1024 * 1024;
|
|
@@ -95,12 +96,14 @@ export function preserveInterruptedBrowserSession(profile) {
|
|
|
95
96
|
catch (error) { if (error?.code === 'ENOENT') return { saved: false, reason: 'no-sessions' }; throw error; }
|
|
96
97
|
if (!fstatSync(dirFd).isDirectory()) throw new Error('Session directory is unsafe');
|
|
97
98
|
}
|
|
98
|
-
const
|
|
99
|
+
const { readable: readableDir, names } = scanSessionDirectory(sessionDir, dirFd);
|
|
99
100
|
const files = [];
|
|
100
101
|
let total = 0;
|
|
101
|
-
for (const name of
|
|
102
|
+
for (const name of names.sort()) {
|
|
103
|
+
verifySessionDirectory(sessionDir, dirFd);
|
|
102
104
|
if (!/^(?:Session|Tabs)_\d+$/.test(name)) continue;
|
|
103
105
|
const details = ownedRegularFile(path.join(readableDir, name), MAX_FILE_BYTES);
|
|
106
|
+
verifySessionDirectory(sessionDir, dirFd);
|
|
104
107
|
if (!details) throw new Error('Session file is unsafe, empty or oversized');
|
|
105
108
|
total += details.size;
|
|
106
109
|
files.push({ name, ...details });
|
|
@@ -125,7 +128,11 @@ export function preserveInterruptedBrowserSession(profile) {
|
|
|
125
128
|
if (snapshots.length >= MAX_SNAPSHOTS) throw new Error('Session recovery snapshot limit reached');
|
|
126
129
|
const snapshotDir = mkdtempSync(path.join(recoveryDir, 'snapshot-'));
|
|
127
130
|
try {
|
|
128
|
-
for (const file of files)
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
verifySessionDirectory(sessionDir, dirFd);
|
|
133
|
+
rawCopy(path.join(readableDir, file.name), path.join(snapshotDir, file.name), file);
|
|
134
|
+
verifySessionDirectory(sessionDir, dirFd);
|
|
135
|
+
}
|
|
129
136
|
writeFileSync(path.join(snapshotDir, 'manifest.json'), JSON.stringify({ fingerprint, count: files.length, hasWindowSession: files.some(f => f.name.startsWith('Session_')) }), { flag: 'wx', mode: 0o600 });
|
|
130
137
|
return { saved: true, reason: 'saved', count: files.length, bytes: total };
|
|
131
138
|
} catch (error) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Keep Chrome recovery's directory-file-descriptor safety on Unix, while
|
|
2
|
+
// accommodating macOS: its /dev/fd/N aliases cannot be scanned as directories.
|
|
3
|
+
// This profile belongs to ReMCP, not to the user's normal Chrome profile.
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
|
|
10
|
+
const NOFOLLOW = constants.O_NOFOLLOW || 0;
|
|
11
|
+
const DIRECTORY = constants.O_DIRECTORY || 0;
|
|
12
|
+
|
|
13
|
+
export function verifySessionDirectory(directory, fd, platform = process.platform) {
|
|
14
|
+
if (platform !== 'darwin') return;
|
|
15
|
+
const opened = fstatSync(fd);
|
|
16
|
+
const current = lstatSync(directory);
|
|
17
|
+
if (!opened.isDirectory() || !current.isDirectory() ||
|
|
18
|
+
opened.dev !== current.dev || opened.ino !== current.ino ||
|
|
19
|
+
(typeof process.getuid === 'function' && current.uid !== process.getuid())) {
|
|
20
|
+
throw new Error('Browser session directory changed or is unsafe');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function scanSessionDirectory(directory, fd, platform = process.platform) {
|
|
25
|
+
const readable = platform === 'win32' ? directory
|
|
26
|
+
: platform === 'darwin' ? directory : `/proc/self/fd/${fd}`;
|
|
27
|
+
verifySessionDirectory(directory, fd, platform);
|
|
28
|
+
const names = readdirSync(readable);
|
|
29
|
+
verifySessionDirectory(directory, fd, platform);
|
|
30
|
+
return { readable, names };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function shouldRestoreBrowserSession(profile) {
|
|
34
|
+
let prefsFd;
|
|
35
|
+
let sessionFd;
|
|
36
|
+
try {
|
|
37
|
+
const defaultProfile = path.join(profile, 'Default');
|
|
38
|
+
prefsFd = openSync(path.join(defaultProfile, 'Preferences'), constants.O_RDONLY | NOFOLLOW);
|
|
39
|
+
const prefsStat = fstatSync(prefsFd);
|
|
40
|
+
if (!prefsStat.isFile() || prefsStat.size > 4 * 1024 * 1024) return false;
|
|
41
|
+
const prefs = JSON.parse(readFileSync(prefsFd, 'utf8'));
|
|
42
|
+
if (prefs?.profile?.exit_type !== 'Crashed') return false;
|
|
43
|
+
|
|
44
|
+
const sessionDir = path.join(defaultProfile, 'Sessions');
|
|
45
|
+
if (process.platform !== 'win32') {
|
|
46
|
+
sessionFd = openSync(sessionDir, constants.O_RDONLY | DIRECTORY | NOFOLLOW);
|
|
47
|
+
if (!fstatSync(sessionFd).isDirectory()) return false;
|
|
48
|
+
}
|
|
49
|
+
const { readable, names } = scanSessionDirectory(sessionDir, sessionFd);
|
|
50
|
+
const found = names.some(name => {
|
|
51
|
+
if (!/^Tabs_\d+$/.test(name)) return false;
|
|
52
|
+
let tabFd;
|
|
53
|
+
try {
|
|
54
|
+
verifySessionDirectory(sessionDir, sessionFd);
|
|
55
|
+
tabFd = openSync(path.join(readable, name), constants.O_RDONLY | NOFOLLOW);
|
|
56
|
+
const stat = fstatSync(tabFd);
|
|
57
|
+
verifySessionDirectory(sessionDir, sessionFd);
|
|
58
|
+
return stat.isFile() && stat.size > 0;
|
|
59
|
+
} catch { return false; }
|
|
60
|
+
finally { if (tabFd !== undefined) closeSync(tabFd); }
|
|
61
|
+
});
|
|
62
|
+
verifySessionDirectory(sessionDir, sessionFd);
|
|
63
|
+
return found;
|
|
64
|
+
} catch {
|
|
65
|
+
// Never restore from missing, malformed, symlinked or inaccessible evidence.
|
|
66
|
+
return false;
|
|
67
|
+
} finally {
|
|
68
|
+
if (sessionFd !== undefined) closeSync(sessionFd);
|
|
69
|
+
if (prefsFd !== undefined) closeSync(prefsFd);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -290,8 +290,35 @@ export function retainProductionSession(key, session, removeGuard, targetId, end
|
|
|
290
290
|
return record;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
// New Chromium windows may report CDP readiness before their session restore
|
|
294
|
+
// finishes. Only cold-start navigation pays this bounded settling cost; all
|
|
295
|
+
// warm clicks, existing tabs and browser reads keep their current fast path.
|
|
296
|
+
export const COLD_BROWSER_SETTLE_MS = 1200;
|
|
297
|
+
export const COLD_TARGET_VERIFY_MS = 700;
|
|
298
|
+
|
|
299
|
+
export class BrowserTargetDisappearedError extends Error {
|
|
300
|
+
constructor() {
|
|
301
|
+
super('Browser CDP target disappeared during startup; the requested tab was not retained. Retry once after checking browser readiness.');
|
|
302
|
+
this.name = 'BrowserTargetDisappearedError';
|
|
303
|
+
this.code = 'BROWSER_TARGET_DISAPPEARED';
|
|
304
|
+
this.retryable = true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function assertCreatedTargetAvailable(targetId, endpoint, {
|
|
309
|
+
request = requestJson, pause = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
310
|
+
verifyAfterMs = 0,
|
|
311
|
+
} = {}) {
|
|
312
|
+
if (verifyAfterMs > 0) await pause(verifyAfterMs);
|
|
313
|
+
const targets = await request('/json/list', endpoint, { timeout_ms:2000 });
|
|
314
|
+
if (!Array.isArray(targets) || !targets.some(item => item?.id === targetId && item?.type === 'page')) {
|
|
315
|
+
throw new BrowserTargetDisappearedError();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
293
319
|
export async function createGuardedNewTab(url, args) {
|
|
294
|
-
await ensureBrowserEndpoint(args.endpoint);
|
|
320
|
+
const autoLaunched = await ensureBrowserEndpoint(args.endpoint);
|
|
321
|
+
if (autoLaunched) await new Promise(resolve => setTimeout(resolve, COLD_BROWSER_SETTLE_MS));
|
|
295
322
|
const created = await requestJson('/json/new', args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
|
|
296
323
|
if (!created?.id) throw new Error('Browser did not return a new page target');
|
|
297
324
|
const endpoint = endpointUrl(args.endpoint);
|
|
@@ -325,6 +352,12 @@ export async function createGuardedNewTab(url, args) {
|
|
|
325
352
|
if (loaded) await loaded;
|
|
326
353
|
const finalUrl = await evaluate(session, 'location.href');
|
|
327
354
|
await assertResolvedPageUrl(finalUrl);
|
|
355
|
+
// A successful Page.navigate is not enough: Chrome's cold-start session
|
|
356
|
+
// restoration can asynchronously close its targets. Fail rather than
|
|
357
|
+
// returning a stale ID the next MCP tool call cannot use.
|
|
358
|
+
await assertCreatedTargetAvailable(target.id, endpoint, {
|
|
359
|
+
verifyAfterMs:autoLaunched ? COLD_TARGET_VERIFY_MS : 0,
|
|
360
|
+
});
|
|
328
361
|
return { target, navigation, finalUrl };
|
|
329
362
|
} catch (error) {
|
|
330
363
|
operationFailed = true;
|