@remcp/runtime 0.2.168 → 0.2.170
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/extended/browser-actions.mjs +6 -10
- package/src/extended/browser-endpoint.mjs +9 -2
- package/src/extended/browser-launch-lifecycle.mjs +36 -0
- package/src/extended/browser-session.mjs +9 -1
- package/src/extended/browser-viewport.mjs +22 -0
- package/src/extended/common.mjs +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.170 — 2026-10-20
|
|
4
|
+
|
|
5
|
+
- Reapply per-target CDP viewport emulation when opening a new guarded browser session; bound state to 64 targets and expire after 10 minutes.
|
|
6
|
+
- Fix inaccurate mobile browser QA readback after separate `set_viewport` and DOM/snapshot calls without widening browser-origin permissions.
|
|
7
|
+
- Add viewport persistence, target-isolation and expiry tests.
|
|
8
|
+
|
|
9
|
+
## 0.2.169 — 2026-10-19
|
|
10
|
+
|
|
11
|
+
- Emit redacted, bounded lifecycle events when ReMCP-owned Chrome exits and distinguish clean exit, nonzero exit, OS signal and spawn error.
|
|
12
|
+
- On a missing default loopback CDP endpoint, include a fresh, explicitly non-causal hint only if the managed browser process recently exited. Never auto-launch browser_tabs/browser_snapshot, and never reveal URLs, tab content, Chrome arguments, credentials or profile paths.
|
|
13
|
+
- Add detached subprocess, privacy, TTL and missing-listener regression tests.
|
|
14
|
+
|
|
3
15
|
## 0.2.165 — 2026-10-18
|
|
4
16
|
|
|
5
17
|
**Crash-session recovery without losing browser automation when the local snapshot budget fills.**
|
package/package.json
CHANGED
|
@@ -9,8 +9,9 @@ import { preserveInterruptedBrowserSession } from './browser-session-backup.mjs'
|
|
|
9
9
|
import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
import { assertBrowserRemoteEnabled, assertSafeEvaluationExpression } from './browser-endpoint.mjs';
|
|
13
|
-
import { callPageFunction, evaluate, withTarget } from './browser-session.mjs';
|
|
12
|
+
import { assertBrowserRemoteEnabled, assertSafeEvaluationExpression, endpointUrl } from './browser-endpoint.mjs';
|
|
13
|
+
import { callPageFunction, evaluate, withTarget, productionSessionKey } from './browser-session.mjs';
|
|
14
|
+
import { rememberBrowserViewport } from './browser-viewport.mjs';
|
|
14
15
|
import { browserNavigate, browserSnapshot, browserTabs, findExpression } from './browser-read.mjs';
|
|
15
16
|
import { executeBrowserWorkflow, validateBrowserWorkflow } from './browser-workflow.mjs';
|
|
16
17
|
|
|
@@ -226,14 +227,9 @@ export async function browserAction(args) {
|
|
|
226
227
|
const width = clamp(args.width, 1280, 200, 8192);
|
|
227
228
|
const height = clamp(args.height, 720, 200, 8192);
|
|
228
229
|
const deviceScaleFactor = Math.max(0.1, Math.min(8, Number(args.device_scale_factor) || 1));
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
deviceScaleFactor,
|
|
233
|
-
mobile: Boolean(args.mobile),
|
|
234
|
-
screenWidth: width,
|
|
235
|
-
screenHeight: height,
|
|
236
|
-
});
|
|
230
|
+
const metrics = { width, height, deviceScaleFactor, mobile:Boolean(args.mobile), screenWidth:width, screenHeight:height };
|
|
231
|
+
await session.send('Emulation.setDeviceMetricsOverride', metrics);
|
|
232
|
+
rememberBrowserViewport(productionSessionKey(endpointUrl(args.endpoint), target), metrics);
|
|
237
233
|
return jsonResult({ target_id:target.id, action, width, height, device_scale_factor:deviceScaleFactor, mobile:Boolean(args.mobile) });
|
|
238
234
|
}
|
|
239
235
|
if (action === 'upload') {
|
|
@@ -8,6 +8,7 @@ import { resolveSafePath } from '../util.mjs';
|
|
|
8
8
|
import { selectSafeBrowserLaunchProfile } from './browser-session-backup.mjs';
|
|
9
9
|
import { shouldRestoreBrowserSession } from './browser-session-directory.mjs';
|
|
10
10
|
import { shareBrowserDiscovery } from './browser-discovery-flight.mjs';
|
|
11
|
+
import { observeBrowserLaunchExit, browserLaunchExitHint } from './browser-launch-lifecycle.mjs';
|
|
11
12
|
import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
|
|
12
13
|
|
|
13
14
|
|
|
@@ -110,7 +111,13 @@ export async function ensureBrowserEndpoint(endpoint) {
|
|
|
110
111
|
// evidence before a restart, including profiles with only Tabs_* left.
|
|
111
112
|
const selected = selectSafeBrowserLaunchProfile(profile);
|
|
112
113
|
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.');
|
|
113
|
-
|
|
114
|
+
const restore = shouldRestoreBrowserSession(selected.profile);
|
|
115
|
+
const startedAt = Date.now();
|
|
116
|
+
spawnDetached(executable, browserAutoLaunchArgs(selected.profile), {
|
|
117
|
+
onExit:value => observeBrowserLaunchExit(value, { startedAt, restore, isolated:selected.isolated }),
|
|
118
|
+
onError:({ code }) => observeBrowserLaunchExit({ error:code || 'UNKNOWN' },
|
|
119
|
+
{ startedAt, restore, isolated:selected.isolated }),
|
|
120
|
+
});
|
|
114
121
|
if (!await waitForBrowserEndpoint(5000)) throw new Error('ReMCP launched a browser but its local CDP endpoint did not become ready');
|
|
115
122
|
return true;
|
|
116
123
|
})().finally(() => { browserLaunchPromise = null; });
|
|
@@ -415,7 +422,7 @@ export async function listBrowserTargets(endpoint, { targetId = null, readOnlySi
|
|
|
415
422
|
if (!/fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|abort|timed out|network error/i.test(message)) throw error;
|
|
416
423
|
const configured = Boolean(endpoint || process.env.REMCP_CDP_URL);
|
|
417
424
|
const label = configured ? 'The configured CDP endpoint' : 'Browser CDP endpoint';
|
|
418
|
-
throw new Error(`${label} is unavailable at ${base.origin}. Read-only browser_tabs and browser_snapshot do not launch a browser. Use ui_snapshot and ui_find for the existing browser window; do not retry this CDP endpoint unchanged. Only browser_navigate(action=new_tab), when explicitly requested, may start a local isolated browser
|
|
425
|
+
throw new Error(`${label} is unavailable at ${base.origin}. Read-only browser_tabs and browser_snapshot do not launch a browser. Use ui_snapshot and ui_find for the existing browser window; do not retry this CDP endpoint unchanged. Only browser_navigate(action=new_tab), when explicitly requested, may start a local isolated browser.${browserLaunchExitHint({ configured })}`);
|
|
419
426
|
}
|
|
420
427
|
// Retain the current bounded concurrent DNS/security checks for up to 1000 tabs.
|
|
421
428
|
return filterBrowserTargets(list, { targetId, validate });
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Bounded, content-free telemetry for ReMCP-owned Chrome child lifecycles.
|
|
2
|
+
let recentExit = null;
|
|
3
|
+
const HINT_WINDOW_MS = 60_000;
|
|
4
|
+
|
|
5
|
+
export function classifyBrowserLaunchExit({ code = null, signal = null, error = null } = {}) {
|
|
6
|
+
if (error) return 'spawn_error';
|
|
7
|
+
if (typeof signal === 'string' && /^SIG[A-Z0-9]+$/.test(signal)) return 'signaled';
|
|
8
|
+
if (code === 0) return 'clean_exit';
|
|
9
|
+
if (Number.isInteger(code)) return 'nonzero_exit';
|
|
10
|
+
return 'unknown_exit';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function observeBrowserLaunchExit({ code = null, signal = null, error = null } = {}, {
|
|
14
|
+
startedAt = Date.now(), now = Date.now(), restore = false, isolated = false,
|
|
15
|
+
log = console.warn,
|
|
16
|
+
} = {}) {
|
|
17
|
+
const event = {
|
|
18
|
+
event:'dedicated_browser_exit',
|
|
19
|
+
reason:classifyBrowserLaunchExit({ code, signal, error }),
|
|
20
|
+
exit_code:Number.isInteger(code) ? code : null,
|
|
21
|
+
signal:typeof signal === 'string' && /^SIG[A-Z0-9]+$/.test(signal) ? signal : null,
|
|
22
|
+
elapsed_ms:Math.min(86_400_000, Math.max(0, now - startedAt)),
|
|
23
|
+
restore_requested:Boolean(restore),
|
|
24
|
+
recovery_profile:Boolean(isolated),
|
|
25
|
+
};
|
|
26
|
+
recentExit = { at:now, reason:event.reason };
|
|
27
|
+
log(`ReMCP browser lifecycle: ${JSON.stringify(event)}`);
|
|
28
|
+
return event;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Advisory only: a Chrome launcher can exit without bringing CDP down. Never
|
|
32
|
+
// claim this event caused a later endpoint failure or authorize a relaunch.
|
|
33
|
+
export function browserLaunchExitHint({ now = Date.now(), configured = false } = {}) {
|
|
34
|
+
if (configured || !recentExit || now < recentExit.at || now - recentExit.at > HINT_WINDOW_MS) return '';
|
|
35
|
+
return ` A recently launched ReMCP browser process exited (${recentExit.reason}); the event may be unrelated to CDP availability.`;
|
|
36
|
+
}
|
|
@@ -10,6 +10,7 @@ import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDet
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
import { MAX_CDP_MESSAGE_BYTES, PRODUCTION_SESSION_IDLE_MS, assertResolvedBrowserUrl, assertResolvedPageUrl, chooseTarget, endpointUrl, ensureBrowserEndpoint, productionBrowserSessions, requestJson, validatedTargetWebSocketUrl } from './browser-endpoint.mjs';
|
|
13
|
+
import { getBrowserViewport } from './browser-viewport.mjs';
|
|
13
14
|
|
|
14
15
|
export class CdpSession {
|
|
15
16
|
constructor(url, timeoutMs) {
|
|
@@ -384,17 +385,20 @@ export async function withTarget(args = {}, callback) {
|
|
|
384
385
|
const cached = findGuardedCachedTarget(args, endpoint);
|
|
385
386
|
const target = cached?.target || await chooseTarget(args);
|
|
386
387
|
const production = process.env.NODE_ENV === 'production';
|
|
387
|
-
const
|
|
388
|
+
const viewportKey = productionSessionKey(endpoint, target);
|
|
389
|
+
const key = production ? viewportKey : '';
|
|
388
390
|
let record = cached?.record || (production ? productionBrowserSessions.get(key) : null);
|
|
389
391
|
if (record?.session?.closed || (record?.session?.socket && record.session.socket.readyState !== 1)) {
|
|
390
392
|
closeProductionSession(key, record);
|
|
391
393
|
record = null;
|
|
392
394
|
}
|
|
393
395
|
let session = record?.session;
|
|
396
|
+
let openedSession = false;
|
|
394
397
|
let removeGuard = record?.removeGuard || (() => {});
|
|
395
398
|
if (!session) {
|
|
396
399
|
try {
|
|
397
400
|
session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
|
|
401
|
+
openedSession = true;
|
|
398
402
|
removeGuard = await installBrowserRequestGuard(session);
|
|
399
403
|
if (production) record = retainProductionSession(key, session, removeGuard, target.id, endpoint, false, target);
|
|
400
404
|
} catch (error) {
|
|
@@ -408,6 +412,10 @@ export async function withTarget(args = {}, callback) {
|
|
|
408
412
|
}
|
|
409
413
|
let operationFailed = false;
|
|
410
414
|
try {
|
|
415
|
+
if (openedSession) {
|
|
416
|
+
const viewport = getBrowserViewport(viewportKey);
|
|
417
|
+
if (viewport) await session.send('Emulation.setDeviceMetricsOverride', viewport);
|
|
418
|
+
}
|
|
411
419
|
const currentUrl = await evaluate(session, 'location.href');
|
|
412
420
|
await assertResolvedPageUrl(currentUrl);
|
|
413
421
|
// A reused target's discovery metadata can be old after navigation.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// CDP Emulation settings belong to a protocol session. Cache only bounded,
|
|
2
|
+
// non-sensitive viewport parameters so a newly opened session can reapply them.
|
|
3
|
+
const MAX_TARGETS = 64;
|
|
4
|
+
const IDLE_MS = 10 * 60_000;
|
|
5
|
+
const overrides = new Map();
|
|
6
|
+
|
|
7
|
+
export function rememberBrowserViewport(key, metrics, at = Date.now()) {
|
|
8
|
+
if (!key) return;
|
|
9
|
+
overrides.delete(key);
|
|
10
|
+
overrides.set(key, { metrics: { ...metrics }, expiresAt: at + IDLE_MS });
|
|
11
|
+
for (const [target, entry] of overrides) {
|
|
12
|
+
if (entry.expiresAt <= at) overrides.delete(target);
|
|
13
|
+
}
|
|
14
|
+
while (overrides.size > MAX_TARGETS) overrides.delete(overrides.keys().next().value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getBrowserViewport(key, at = Date.now()) {
|
|
18
|
+
const entry = overrides.get(key);
|
|
19
|
+
if (!entry) return null;
|
|
20
|
+
if (entry.expiresAt <= at) { overrides.delete(key); return null; }
|
|
21
|
+
return { ...entry.metrics };
|
|
22
|
+
}
|
package/src/extended/common.mjs
CHANGED
|
@@ -238,6 +238,18 @@ export function spawnDetached(file, args = [], options = {}) {
|
|
|
238
238
|
stdio: 'ignore',
|
|
239
239
|
windowsHide: true,
|
|
240
240
|
});
|
|
241
|
+
// Opt-in process lifecycle callbacks for long-lived detached services.
|
|
242
|
+
// Do not forward command arguments or OS error messages into telemetry.
|
|
243
|
+
if (typeof options.onExit === 'function') {
|
|
244
|
+
child.once('exit', (code, signal) => {
|
|
245
|
+
try { options.onExit({ pid: child.pid ?? null, code, signal }); } catch {}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (typeof options.onError === 'function') {
|
|
249
|
+
child.once('error', error => {
|
|
250
|
+
try { options.onError({ pid: child.pid ?? null, code: String(error?.code || 'UNKNOWN') }); } catch {}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
241
253
|
child.unref();
|
|
242
254
|
return child.pid;
|
|
243
255
|
}
|