@geometra/proxy 1.64.0 → 1.65.1

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.
@@ -1,48 +1,578 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
1
2
  import { performance } from 'node:perf_hooks';
2
3
  import { WebSocketServer } from 'ws';
3
4
  import { attachFiles, clearFillLookupCache, createFillLookupCache, delay, fillFields, fillOtp, pickListboxOption, resolveExistingFiles, setFieldChoice, setFieldText, selectNativeOption, setCheckedControl, wheelAt, } from './dom-actions.js';
4
5
  import { createCdpAxSessionManager } from './a11y-enrich.js';
5
6
  import { coalescePatches, diffLayout } from './diff-layout.js';
6
7
  import { extractGeometry } from './extractor.js';
7
- import { clientMessageValidationError, isClickEventMessage, isCompositionMessage, isFillFieldsMessage, isFillOtpMessage, isFileMessage, isKeyMessage, isListboxPickMessage, isNavigateMessage, isResizeMessage, isPdfGenerateMessage, isScreenshotMessage, isSetFieldChoiceMessage, isSetFieldTextMessage, isSetCheckedMessage, isSelectOptionMessage, isWheelMessage, PROXY_PROTOCOL_VERSION, } from './types.js';
8
+ import { clientMessageValidationError, isClickEventMessage, isCompositionMessage, isFillFieldsMessage, isFillOtpMessage, isFileMessage, isKeyMessage, isTypeTextMessage, isListboxPickMessage, isNavigateMessage, isResizeMessage, isPdfGenerateMessage, isScreenshotMessage, isSetFieldChoiceMessage, isSetFieldTextMessage, isSetCheckedMessage, isSelectOptionMessage, isWheelMessage, GEOMETRY_PROTOCOL_VERSION, PROXY_ACTION_PROTOCOL_VERSION, PROXY_PROTOCOL_VERSION, } from './types.js';
8
9
  const DOM_OBSERVER_BINDINGS = new WeakSet();
9
- async function bindDomObserverBridge(page, scheduleExtract) {
10
- if (DOM_OBSERVER_BINDINGS.has(page))
10
+ const DEFAULT_PROXY_HOST = '127.0.0.1';
11
+ const DEFAULT_MAX_PAYLOAD_BYTES = 4 * 1024 * 1024;
12
+ const DEFAULT_ACTION_REQUEST_LEDGER_ENTRIES = 65_536;
13
+ /** A completed action must not be starved by a page that mutates forever. */
14
+ const MAX_INPUT_ACK_LATENCY_MS = 250;
15
+ // The page-side bridge lives in the untrusted main world. Even if application
16
+ // code calls the exposed "settled" hook directly, it must not be able to turn
17
+ // that hint into an unbounded extraction loop on the proxy host.
18
+ const MIN_IMMEDIATE_EXTRACT_INTERVAL_MS = 250;
19
+ const PROXY_PROTOCOL_CAPABILITIES = {
20
+ transport: 'proxy',
21
+ authenticatedController: true,
22
+ requestScopedAcks: true,
23
+ actionDeadlines: true,
24
+ idempotentRequestIds: true,
25
+ atomicTypeText: true,
26
+ proxyActions: true,
27
+ exactFieldIdentity: true,
28
+ verifiedFileUploads: true,
29
+ };
30
+ const PROXY_GEOMETRY_METADATA = {
31
+ protocolVersion: GEOMETRY_PROTOCOL_VERSION,
32
+ geometryProtocolVersion: GEOMETRY_PROTOCOL_VERSION,
33
+ proxyActionProtocolVersion: PROXY_ACTION_PROTOCOL_VERSION,
34
+ protocolCapabilities: PROXY_PROTOCOL_CAPABILITIES,
35
+ };
36
+ const PROXY_ACTION_METADATA = {
37
+ // Keep the legacy field at v2 so @geometra/mcp@1.64.x clients installed
38
+ // through their caret proxy dependency continue to accept exact-field acks.
39
+ protocolVersion: PROXY_PROTOCOL_VERSION,
40
+ geometryProtocolVersion: GEOMETRY_PROTOCOL_VERSION,
41
+ proxyActionProtocolVersion: PROXY_ACTION_PROTOCOL_VERSION,
42
+ protocolCapabilities: PROXY_PROTOCOL_CAPABILITIES,
43
+ };
44
+ /**
45
+ * Runs in every page/frame realm before application code.
46
+ *
47
+ * Keep this function closure-free: Playwright serializes it for addInitScript.
48
+ * Closed shadow roots are deliberately held in a WeakMap rather than exposed
49
+ * through DOM attributes. The extractor consumes the same Symbol.for key in
50
+ * its in-page evaluation.
51
+ */
52
+ function installPageFreshnessInstrumentation(config) {
53
+ const { installToken, notifyBindingName } = config;
54
+ const CLOSED_ROOTS_KEY = Symbol.for('geometra.closedShadowRoots');
55
+ // This key is minted in the trusted host process and injected immediately
56
+ // before installation. A page can preseed a public Symbol.for name, but it
57
+ // cannot predict this per-binding capability before an init script runs.
58
+ const INSTALL_STATE_KEY = `__geometraProxyFreshnessInstalled_${installToken}`;
59
+ const global = globalThis;
60
+ const globalRecord = global;
61
+ if (globalRecord[INSTALL_STATE_KEY] === installToken)
11
62
  return;
12
- await page.exposeFunction('__geometraProxyNotify', () => {
13
- scheduleExtract();
14
- });
15
- await page.addInitScript(() => {
16
- const w = window;
17
- if (w.__geometraProxyObserverBootstrapped)
63
+ let existingRegistry;
64
+ try {
65
+ existingRegistry = global[CLOSED_ROOTS_KEY];
66
+ }
67
+ catch {
68
+ // A hostile getter must not disable the independent freshness signals.
69
+ }
70
+ let closedRoots = existingRegistry instanceof WeakMap
71
+ ? existingRegistry
72
+ : undefined;
73
+ if (!closedRoots) {
74
+ const candidate = new WeakMap();
75
+ try {
76
+ const existingDescriptor = Object.getOwnPropertyDescriptor(global, CLOSED_ROOTS_KEY);
77
+ if (existingDescriptor) {
78
+ // Supplying only value preserves the flags of a non-configurable but
79
+ // writable data property. Configurable collisions are replaced too.
80
+ Object.defineProperty(global, CLOSED_ROOTS_KEY, { value: candidate });
81
+ }
82
+ else {
83
+ Object.defineProperty(global, CLOSED_ROOTS_KEY, {
84
+ value: candidate,
85
+ configurable: false,
86
+ enumerable: false,
87
+ writable: false,
88
+ });
89
+ }
90
+ if (global[CLOSED_ROOTS_KEY] === candidate)
91
+ closedRoots = candidate;
92
+ }
93
+ catch {
94
+ // Fail closed for semantic access to closed roots. attachShadow remains
95
+ // usable and the mutation/resize/CSS freshness machinery still boots.
96
+ }
97
+ }
98
+ // Capture the Playwright bridge before application code can replace or
99
+ // delete its writable main-world property. All instrumentation signals use
100
+ // this private closure reference from here onward.
101
+ const bindingCandidate = globalRecord[notifyBindingName];
102
+ const notifyBridge = typeof bindingCandidate === 'function'
103
+ ? bindingCandidate.bind(global)
104
+ : undefined;
105
+ const NOTIFY_MIN_INTERVAL_MS = 25;
106
+ let notifyInFlight = false;
107
+ let notifyPending = false;
108
+ let settledNotifyPending = false;
109
+ let notifyTimer;
110
+ let lastNotifyStartedAt = Number.NEGATIVE_INFINITY;
111
+ const drainNotify = () => {
112
+ if (!notifyBridge || notifyInFlight || notifyTimer !== undefined || !notifyPending)
18
113
  return;
19
- const install = () => {
20
- if (w.__geometraProxyObserverInstalled)
21
- return;
22
- const root = document.documentElement;
23
- if (!root)
24
- return;
25
- const observer = new MutationObserver(() => {
26
- void w.__geometraProxyNotify?.();
114
+ const elapsed = global.performance.now() - lastNotifyStartedAt;
115
+ const remaining = Math.max(0, NOTIFY_MIN_INTERVAL_MS - elapsed);
116
+ if (remaining > 0) {
117
+ // Anchor the cadence to the prior dispatch instead of resetting this
118
+ // timer for every mutation. A page that changes forever therefore keeps
119
+ // making bounded progress and still emits its final trailing signal.
120
+ notifyTimer = global.setTimeout(() => {
121
+ notifyTimer = undefined;
122
+ drainNotify();
123
+ }, Math.ceil(remaining));
124
+ return;
125
+ }
126
+ const urgency = settledNotifyPending ? 'settled' : undefined;
127
+ notifyPending = false;
128
+ settledNotifyPending = false;
129
+ notifyInFlight = true;
130
+ lastNotifyStartedAt = global.performance.now();
131
+ let bridgeCall;
132
+ try {
133
+ bridgeCall = Promise.resolve(notifyBridge(urgency));
134
+ }
135
+ catch {
136
+ notifyInFlight = false;
137
+ drainNotify();
138
+ return;
139
+ }
140
+ void bridgeCall.catch(() => { }).finally(() => {
141
+ notifyInFlight = false;
142
+ drainNotify();
143
+ });
144
+ };
145
+ const notify = (urgency) => {
146
+ if (!notifyBridge)
147
+ return;
148
+ notifyPending = true;
149
+ // A final settle hint must survive coalescing with any number of ordinary
150
+ // mutation signals so the host can bypass its trailing debounce once.
151
+ if (urgency === 'settled')
152
+ settledNotifyPending = true;
153
+ drainNotify();
154
+ };
155
+ // A sampler is started only by a signal that can imply layout is actively
156
+ // changing. It has a hard per-signal horizon and never polls an idle page.
157
+ const MAX_SETTLE_MS = 500;
158
+ let settleUntil = 0;
159
+ let settleAnimationFrame;
160
+ const sampleUntilSettled = () => {
161
+ if (global.performance.now() < settleUntil) {
162
+ notify();
163
+ settleAnimationFrame = global.requestAnimationFrame(sampleUntilSettled);
164
+ }
165
+ else {
166
+ settleAnimationFrame = undefined;
167
+ // Bypass the trailing debounce for the final sample. This makes 500ms
168
+ // a hard scheduling bound for a finite layout activity window instead
169
+ // of silently turning it into 500ms + debounceMs.
170
+ notify('settled');
171
+ }
172
+ };
173
+ const markLayoutActivity = () => {
174
+ settleUntil = Math.max(settleUntil, global.performance.now() + MAX_SETTLE_MS);
175
+ notify();
176
+ if (settleAnimationFrame === undefined) {
177
+ settleAnimationFrame = global.requestAnimationFrame(sampleUntilSettled);
178
+ }
179
+ };
180
+ let resizeObserver;
181
+ const resizeTargets = new WeakSet();
182
+ // Bounding the target count avoids turning observation into an unbounded
183
+ // mirror of very large application DOMs. CSSOM/event sampling remains the
184
+ // fallback for nodes beyond this cap.
185
+ const MAX_RESIZE_TARGETS = 4_096;
186
+ let resizeTargetCount = 0;
187
+ const observeResizeTarget = (element) => {
188
+ if (!resizeObserver || resizeTargets.has(element) || resizeTargetCount >= MAX_RESIZE_TARGETS)
189
+ return;
190
+ resizeTargets.add(element);
191
+ resizeTargetCount += 1;
192
+ resizeObserver.observe(element);
193
+ };
194
+ const observeResizeCandidates = (root) => {
195
+ if (root instanceof Element)
196
+ observeResizeTarget(root);
197
+ for (const element of root.querySelectorAll('a,button,input,select,textarea,iframe,img,canvas,svg,video,[role],[contenteditable],[tabindex]')) {
198
+ observeResizeTarget(element);
199
+ if (resizeTargetCount >= MAX_RESIZE_TARGETS)
200
+ break;
201
+ }
202
+ };
203
+ if (typeof ResizeObserver === 'function') {
204
+ resizeObserver = new ResizeObserver(() => notify());
205
+ }
206
+ const mutationObserver = new MutationObserver(records => {
207
+ for (const record of records) {
208
+ for (const node of record.addedNodes) {
209
+ if (node instanceof Element || node instanceof ShadowRoot) {
210
+ observeResizeCandidates(node);
211
+ }
212
+ }
213
+ }
214
+ notify();
215
+ });
216
+ const observeRoot = (root) => {
217
+ mutationObserver.observe(root, {
218
+ subtree: true,
219
+ childList: true,
220
+ attributes: true,
221
+ characterData: true,
222
+ });
223
+ observeResizeCandidates(root);
224
+ };
225
+ const attachShadowDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'attachShadow');
226
+ const nativeAttachShadow = attachShadowDescriptor?.value;
227
+ if (nativeAttachShadow) {
228
+ const instrumentedAttachShadow = function attachShadow(init) {
229
+ const root = Reflect.apply(nativeAttachShadow, this, [init]);
230
+ if (init?.mode === 'closed')
231
+ closedRoots?.set(this, root);
232
+ observeRoot(root);
233
+ markLayoutActivity();
234
+ return root;
235
+ };
236
+ // Preserve the original method's own surface where the platform permits
237
+ // it. The wrapper already has the native one-argument call signature.
238
+ try {
239
+ Object.defineProperty(instrumentedAttachShadow, 'name', {
240
+ value: nativeAttachShadow.name,
241
+ configurable: true,
27
242
  });
28
- observer.observe(root, {
29
- subtree: true,
30
- childList: true,
31
- attributes: true,
32
- characterData: true,
243
+ Object.defineProperty(instrumentedAttachShadow, 'toString', {
244
+ value: nativeAttachShadow.toString.bind(nativeAttachShadow),
245
+ configurable: true,
33
246
  });
34
- w.__geometraProxyObserverInstalled = true;
247
+ }
248
+ catch {
249
+ // Function metadata is cosmetic; never make attachShadow unavailable.
250
+ }
251
+ try {
252
+ Object.defineProperty(Element.prototype, 'attachShadow', {
253
+ ...attachShadowDescriptor,
254
+ value: instrumentedAttachShadow,
255
+ });
256
+ }
257
+ catch {
258
+ // Hardened realms may lock platform prototypes. Keep the rest of the
259
+ // freshness instrumentation available even when closed-root capture is
260
+ // impossible in that realm.
261
+ }
262
+ }
263
+ const wrapLayoutMutator = (prototype, property) => {
264
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, property);
265
+ const native = descriptor?.value;
266
+ if (!descriptor || typeof native !== 'function')
267
+ return;
268
+ const wrapped = function (...args) {
269
+ const result = Reflect.apply(native, this, args);
270
+ markLayoutActivity();
271
+ if (result && typeof result.then === 'function') {
272
+ void Promise.resolve(result).then(markLayoutActivity, markLayoutActivity);
273
+ }
274
+ return result;
35
275
  };
36
- if (document.readyState === 'loading') {
37
- document.addEventListener('DOMContentLoaded', install, { once: true });
276
+ try {
277
+ Object.defineProperty(wrapped, 'name', { value: native.name, configurable: true });
278
+ Object.defineProperty(wrapped, 'length', { value: native.length, configurable: true });
38
279
  }
39
- else {
40
- install();
280
+ catch {
281
+ // Function metadata is best-effort only.
282
+ }
283
+ try {
284
+ Object.defineProperty(prototype, property, { ...descriptor, value: wrapped });
285
+ }
286
+ catch {
287
+ // A locked CSSOM prototype still has ResizeObserver/event fallbacks.
288
+ }
289
+ };
290
+ const wrapLayoutSetter = (prototype, property) => {
291
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, property);
292
+ const nativeSet = descriptor?.set;
293
+ if (!descriptor || !nativeSet)
294
+ return;
295
+ const wrappedSet = function (value) {
296
+ Reflect.apply(nativeSet, this, [value]);
297
+ markLayoutActivity();
298
+ };
299
+ try {
300
+ Object.defineProperty(prototype, property, { ...descriptor, set: wrappedSet });
301
+ }
302
+ catch {
303
+ // A locked CSSOM prototype still has ResizeObserver/event fallbacks.
304
+ }
305
+ };
306
+ const instrumentedDeclarations = new WeakSet();
307
+ const nativeGetPropertyValue = CSSStyleDeclaration.prototype.getPropertyValue;
308
+ const nativeSetProperty = CSSStyleDeclaration.prototype.setProperty;
309
+ // CSSStyleDeclaration exposes CSS properties as own WebIDL named
310
+ // properties. Assignments such as rule.style.transform = 'translateX(...)'
311
+ // do not call the JavaScript-visible setProperty method and do not produce a
312
+ // DOM mutation. Instrument geometry-bearing declarations when a CSS rule's
313
+ // style getter first exposes them so cached declarations remain observable.
314
+ const GEOMETRY_DECLARATION_PROPERTIES = [
315
+ 'alignContent', 'alignItems', 'alignSelf', 'alignmentBaseline',
316
+ 'animation', 'animationDelay', 'animationDuration', 'animationName', 'animationPlayState',
317
+ 'aspectRatio',
318
+ 'blockSize', 'inlineSize', 'minBlockSize', 'maxBlockSize', 'minInlineSize', 'maxInlineSize',
319
+ 'border', 'borderBlock', 'borderInline', 'borderWidth', 'borderTopWidth', 'borderRightWidth',
320
+ 'borderBottomWidth', 'borderLeftWidth', 'boxSizing',
321
+ 'clear', 'clip', 'clipPath', 'columnCount', 'columnGap', 'columnWidth', 'columns',
322
+ 'contain', 'content', 'contentVisibility',
323
+ 'direction', 'display',
324
+ 'flex', 'flexBasis', 'flexDirection', 'flexFlow', 'flexGrow', 'flexShrink', 'flexWrap',
325
+ 'float', 'cssFloat',
326
+ 'font', 'fontFamily', 'fontSize', 'fontStretch', 'fontStyle', 'fontWeight',
327
+ 'gap', 'grid', 'gridArea', 'gridAutoColumns', 'gridAutoFlow', 'gridAutoRows',
328
+ 'gridColumn', 'gridColumnEnd', 'gridColumnStart', 'gridRow', 'gridRowEnd', 'gridRowStart',
329
+ 'gridTemplate', 'gridTemplateAreas', 'gridTemplateColumns', 'gridTemplateRows',
330
+ 'height', 'minHeight', 'maxHeight',
331
+ 'inset', 'insetBlock', 'insetBlockEnd', 'insetBlockStart', 'insetInline', 'insetInlineEnd',
332
+ 'insetInlineStart', 'top', 'right', 'bottom', 'left',
333
+ 'justifyContent', 'justifyItems', 'justifySelf',
334
+ 'letterSpacing', 'lineHeight',
335
+ 'margin', 'marginBlock', 'marginBlockEnd', 'marginBlockStart', 'marginInline', 'marginInlineEnd',
336
+ 'marginInlineStart', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
337
+ 'objectFit', 'objectPosition', 'opacity', 'order', 'overflow', 'overflowX', 'overflowY',
338
+ 'padding', 'paddingBlock', 'paddingBlockEnd', 'paddingBlockStart', 'paddingInline',
339
+ 'paddingInlineEnd', 'paddingInlineStart', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
340
+ 'perspective', 'perspectiveOrigin', 'position',
341
+ 'rotate', 'rowGap', 'scale',
342
+ 'textIndent', 'transform', 'transformBox', 'transformOrigin', 'transition', 'translate',
343
+ 'verticalAlign', 'visibility', 'whiteSpace',
344
+ 'width', 'minWidth', 'maxWidth', 'wordSpacing', 'writingMode', 'zoom',
345
+ ];
346
+ const cssPropertyName = (property) => {
347
+ if (property === 'cssFloat')
348
+ return 'float';
349
+ const dashed = property.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`);
350
+ return /^(webkit|moz|ms|o)-/.test(dashed) ? `-${dashed}` : dashed;
351
+ };
352
+ const instrumentDeclaration = (declaration) => {
353
+ if (instrumentedDeclarations.has(declaration))
354
+ return;
355
+ instrumentedDeclarations.add(declaration);
356
+ for (const property of GEOMETRY_DECLARATION_PROPERTIES) {
357
+ const descriptor = Object.getOwnPropertyDescriptor(declaration, property);
358
+ if (!descriptor?.configurable)
359
+ continue;
360
+ const cssName = cssPropertyName(property);
361
+ try {
362
+ Object.defineProperty(declaration, property, {
363
+ configurable: true,
364
+ enumerable: descriptor.enumerable,
365
+ get() {
366
+ return Reflect.apply(nativeGetPropertyValue, declaration, [cssName]);
367
+ },
368
+ set(value) {
369
+ Reflect.apply(nativeSetProperty, declaration, [cssName, value == null ? '' : String(value), '']);
370
+ markLayoutActivity();
371
+ },
372
+ });
373
+ }
374
+ catch {
375
+ // The rule-style getter sampler below remains a bounded fallback.
376
+ }
377
+ }
378
+ };
379
+ const wrapRuleStyleGetter = (prototype) => {
380
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, 'style');
381
+ const nativeGet = descriptor?.get;
382
+ if (!descriptor || !nativeGet)
383
+ return;
384
+ const wrappedGet = function () {
385
+ const declaration = Reflect.apply(nativeGet, this, []);
386
+ instrumentDeclaration(declaration);
387
+ // Also cover newly introduced CSS properties that are not yet in the
388
+ // explicit geometry list. Access starts one bounded settle window; it
389
+ // never creates persistent polling.
390
+ markLayoutActivity();
391
+ return declaration;
392
+ };
393
+ try {
394
+ Object.defineProperty(prototype, 'style', { ...descriptor, get: wrappedGet });
395
+ }
396
+ catch {
397
+ // Locked rule prototypes retain ResizeObserver/event fallbacks.
398
+ }
399
+ };
400
+ if (typeof CSSStyleSheet !== 'undefined') {
401
+ for (const method of ['insertRule', 'deleteRule', 'replace', 'replaceSync']) {
402
+ wrapLayoutMutator(CSSStyleSheet.prototype, method);
403
+ }
404
+ }
405
+ if (typeof CSSStyleDeclaration !== 'undefined') {
406
+ for (const method of ['setProperty', 'removeProperty']) {
407
+ wrapLayoutMutator(CSSStyleDeclaration.prototype, method);
41
408
  }
42
- w.__geometraProxyObserverBootstrapped = true;
409
+ wrapLayoutSetter(CSSStyleDeclaration.prototype, 'cssText');
410
+ }
411
+ for (const constructorName of ['CSSStyleRule', 'CSSKeyframeRule', 'CSSFontFaceRule', 'CSSPageRule']) {
412
+ const constructor = global[constructorName];
413
+ if (constructor?.prototype)
414
+ wrapRuleStyleGetter(constructor.prototype);
415
+ }
416
+ const notifyEvent = () => notify();
417
+ const activityEvent = () => markLayoutActivity();
418
+ global.addEventListener('scroll', notifyEvent, { capture: true, passive: true });
419
+ global.addEventListener('resize', notifyEvent, { passive: true });
420
+ document.addEventListener('transitionrun', activityEvent, true);
421
+ document.addEventListener('transitionstart', activityEvent, true);
422
+ document.addEventListener('transitionend', activityEvent, true);
423
+ document.addEventListener('transitioncancel', activityEvent, true);
424
+ document.addEventListener('animationstart', activityEvent, true);
425
+ document.addEventListener('animationiteration', activityEvent, true);
426
+ document.addEventListener('animationend', activityEvent, true);
427
+ document.addEventListener('animationcancel', activityEvent, true);
428
+ document.addEventListener('load', event => {
429
+ const target = event.target;
430
+ if (target instanceof HTMLLinkElement && target.relList.contains('stylesheet')) {
431
+ markLayoutActivity();
432
+ }
433
+ }, true);
434
+ const fonts = document.fonts;
435
+ if (fonts) {
436
+ fonts.addEventListener('loading', activityEvent);
437
+ fonts.addEventListener('loadingdone', activityEvent);
438
+ fonts.addEventListener('loadingerror', activityEvent);
439
+ void fonts.ready.then(markLayoutActivity, notify);
440
+ }
441
+ // A Document can be observed before the parser creates documentElement, so
442
+ // install the mutation signal immediately and add resize targets once they
443
+ // exist.
444
+ observeRoot(document);
445
+ const installDocumentResizeTargets = () => {
446
+ if (document.documentElement)
447
+ observeResizeTarget(document.documentElement);
448
+ if (document.body)
449
+ observeResizeTarget(document.body);
450
+ };
451
+ if (document.documentElement) {
452
+ installDocumentResizeTargets();
453
+ }
454
+ else {
455
+ document.addEventListener('DOMContentLoaded', installDocumentResizeTargets, { once: true });
456
+ }
457
+ Object.defineProperty(global, INSTALL_STATE_KEY, {
458
+ value: installToken,
459
+ configurable: false,
460
+ enumerable: false,
461
+ writable: false,
462
+ });
463
+ }
464
+ async function bindDomObserverBridge(page, scheduleExtract) {
465
+ if (DOM_OBSERVER_BINDINGS.has(page))
466
+ return;
467
+ const installToken = randomBytes(32).toString('base64url');
468
+ const notifyBindingName = `__geometraProxyNotify_${installToken}`;
469
+ const installConfig = { installToken, notifyBindingName };
470
+ await page.exposeFunction(notifyBindingName, (urgency) => {
471
+ scheduleExtract(urgency === 'settled');
43
472
  });
473
+ // addInitScript is the critical path: it runs before application scripts in
474
+ // every subsequent main-frame and child-frame document.
475
+ await page.addInitScript(installPageFreshnessInstrumentation, installConfig);
476
+ // Also instrument a document that was already loaded before the bridge was
477
+ // bound. Closed roots created before this point cannot be recovered, which
478
+ // is why the runtime primes the bridge before its first navigation.
479
+ await Promise.all(page.frames().map(async (frame) => {
480
+ await frame.evaluate(installPageFreshnessInstrumentation, installConfig).catch(() => { });
481
+ }));
44
482
  DOM_OBSERVER_BINDINGS.add(page);
45
483
  }
484
+ function canonicalJson(value) {
485
+ if (Array.isArray(value)) {
486
+ return `[${value.map(canonicalJson).join(',')}]`;
487
+ }
488
+ if (value !== null && typeof value === 'object') {
489
+ const record = value;
490
+ return `{${Object.keys(record)
491
+ .sort()
492
+ .map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
493
+ .join(',')}}`;
494
+ }
495
+ return JSON.stringify(value) ?? 'null';
496
+ }
497
+ function sha256(value) {
498
+ return createHash('sha256').update(value).digest('hex');
499
+ }
500
+ const ACTION_FINGERPRINT_TRANSPORT_KEYS = new Set([
501
+ 'requestId',
502
+ 'actionTimeoutMs',
503
+ 'protocolVersion',
504
+ 'geometryProtocolVersion',
505
+ 'proxyActionProtocolVersion',
506
+ ]);
507
+ function actionPayloadFingerprint(payload) {
508
+ const semanticPayload = Object.fromEntries(Object.entries(payload).filter(([key]) => !ACTION_FINGERPRINT_TRANSPORT_KEYS.has(key)));
509
+ return sha256(canonicalJson(semanticPayload));
510
+ }
511
+ export function createActionRequestLedger(maxEntries = DEFAULT_ACTION_REQUEST_LEDGER_ENTRIES) {
512
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
513
+ throw new Error('Action request ledger size must be a positive safe integer');
514
+ }
515
+ const entries = new Map();
516
+ return {
517
+ remember(requestId, payload) {
518
+ // Hash request IDs as well as payloads so every retained entry has a
519
+ // fixed upper memory bound even when a peer sends an unusually long ID.
520
+ const requestKey = sha256(requestId);
521
+ const payloadFingerprint = actionPayloadFingerprint(payload);
522
+ const remembered = entries.get(requestKey);
523
+ if (remembered !== undefined) {
524
+ return remembered.fingerprint === payloadFingerprint ? 'duplicate' : 'conflict';
525
+ }
526
+ if (entries.size >= maxEntries) {
527
+ // Terminal means only that the handler returned; it does not prove
528
+ // the controller received the correlated ACK. Never evict a request
529
+ // ID within a runtime, or an ambiguous retry could mutate twice.
530
+ return 'capacity';
531
+ }
532
+ entries.set(requestKey, { fingerprint: payloadFingerprint, state: 'live' });
533
+ return 'accepted';
534
+ },
535
+ complete(requestId) {
536
+ const requestKey = sha256(requestId);
537
+ const remembered = entries.get(requestKey);
538
+ if (!remembered || remembered.state === 'terminal')
539
+ return;
540
+ entries.delete(requestKey);
541
+ entries.set(requestKey, { ...remembered, state: 'terminal' });
542
+ },
543
+ };
544
+ }
545
+ const MUTATING_CLIENT_MESSAGE_TYPES = new Set([
546
+ 'event',
547
+ 'key',
548
+ 'typeText',
549
+ 'resize',
550
+ 'navigate',
551
+ 'composition',
552
+ 'file',
553
+ 'setFieldText',
554
+ 'setFieldChoice',
555
+ 'fillFields',
556
+ 'fillOtp',
557
+ 'listboxPick',
558
+ 'selectOption',
559
+ 'setChecked',
560
+ 'wheel',
561
+ 'pdfGenerate',
562
+ ]);
563
+ function isMutatingClientMessage(msg) {
564
+ return MUTATING_CLIENT_MESSAGE_TYPES.has(msg.type);
565
+ }
566
+ class ActionDeadlineExpiredError extends Error {
567
+ }
568
+ function assertActionDeadline(msg, receivedAt) {
569
+ if (!isMutatingClientMessage(msg))
570
+ return;
571
+ const actionTimeoutMs = msg.actionTimeoutMs;
572
+ if (actionTimeoutMs !== undefined && performance.now() - receivedAt >= actionTimeoutMs) {
573
+ throw new ActionDeadlineExpiredError('Action deadline expired; request was not executed');
574
+ }
575
+ }
46
576
  function isProtocolCompatible(peerVersion) {
47
577
  if (peerVersion === undefined)
48
578
  return true;
@@ -50,6 +580,38 @@ function isProtocolCompatible(peerVersion) {
50
580
  return false;
51
581
  return peerVersion <= PROXY_PROTOCOL_VERSION;
52
582
  }
583
+ function generatedAuthToken() {
584
+ return randomBytes(32).toString('base64url');
585
+ }
586
+ function normalizedAuthToken(value) {
587
+ const token = value?.trim() || generatedAuthToken();
588
+ if (token.length < 32) {
589
+ throw new Error('geometra-proxy authToken must contain at least 32 characters');
590
+ }
591
+ return token;
592
+ }
593
+ function bearerTokenMatches(header, expected) {
594
+ if (!header?.startsWith('Bearer '))
595
+ return false;
596
+ const received = header.slice('Bearer '.length);
597
+ const receivedBytes = Buffer.from(received);
598
+ const expectedBytes = Buffer.from(expected);
599
+ return receivedBytes.length === expectedBytes.length && timingSafeEqual(receivedBytes, expectedBytes);
600
+ }
601
+ function protocolCompatibilityError(msg) {
602
+ const record = msg;
603
+ const geometryVersion = record.geometryProtocolVersion;
604
+ if (geometryVersion !== undefined && (typeof geometryVersion !== 'number' ||
605
+ !Number.isFinite(geometryVersion) ||
606
+ geometryVersion > GEOMETRY_PROTOCOL_VERSION)) {
607
+ return `Client geometry protocol ${String(geometryVersion)} is newer than proxy geometry protocol ${GEOMETRY_PROTOCOL_VERSION}`;
608
+ }
609
+ const actionVersion = record.proxyActionProtocolVersion ?? record.protocolVersion;
610
+ if (!isProtocolCompatible(actionVersion)) {
611
+ return `Client proxy-action protocol ${String(actionVersion)} is newer than proxy action protocol ${PROXY_ACTION_PROTOCOL_VERSION}`;
612
+ }
613
+ return null;
614
+ }
53
615
  function cloneLayout(layout) {
54
616
  return structuredClone(layout);
55
617
  }
@@ -105,13 +667,15 @@ async function applyKeyPhase(page, msg) {
105
667
  if (msg.shiftKey)
106
668
  await page.keyboard.up('Shift');
107
669
  }
108
- export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitForBeforeInput, onViewportOrInput, onHandlerError) {
109
- const sendWireError = (message, requestId) => {
670
+ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitForBeforeInput, onViewportOrInput, onHandlerError, security) {
671
+ const receivedAt = security?.receivedAt ?? performance.now();
672
+ const sendWireError = (message, requestId, code) => {
110
673
  ws.send(JSON.stringify({
111
674
  type: 'error',
112
675
  message,
676
+ ...(code ? { code } : {}),
113
677
  ...(requestId ? { requestId } : {}),
114
- protocolVersion: PROXY_PROTOCOL_VERSION,
678
+ ...PROXY_ACTION_METADATA,
115
679
  }));
116
680
  };
117
681
  let parsed;
@@ -131,9 +695,9 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
131
695
  return;
132
696
  }
133
697
  const msg = parsed;
134
- const pv = 'protocolVersion' in msg ? msg.protocolVersion : undefined;
135
- if (!isProtocolCompatible(pv)) {
136
- sendWireError(`Client protocol ${String(pv)} is newer than proxy protocol ${PROXY_PROTOCOL_VERSION}`, requestId);
698
+ const compatibilityError = protocolCompatibilityError(msg);
699
+ if (compatibilityError) {
700
+ sendWireError(compatibilityError, requestId);
137
701
  return;
138
702
  }
139
703
  const validationError = clientMessageValidationError(msg);
@@ -141,18 +705,46 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
141
705
  sendWireError(validationError, requestId);
142
706
  return;
143
707
  }
708
+ // Reserve the request ID before consulting its deadline. An expired replay
709
+ // must be reported as a duplicate/conflict and must never become eligible
710
+ // to mutate merely because the original deadline elapsed.
711
+ let acceptedRequestId;
712
+ if (requestId && isMutatingClientMessage(msg) && security?.actionRequestLedger) {
713
+ const reservation = security.actionRequestLedger.remember(requestId, msg);
714
+ if (reservation === 'duplicate') {
715
+ sendWireError('Duplicate requestId; action was not repeated', requestId, 'DUPLICATE_REQUEST');
716
+ return;
717
+ }
718
+ if (reservation === 'conflict') {
719
+ sendWireError('requestId was already used with a different payload; action was not executed', requestId, 'REQUEST_ID_CONFLICT');
720
+ return;
721
+ }
722
+ if (reservation === 'capacity') {
723
+ sendWireError('Action request ledger is at capacity; action was not executed. Close and reconnect the proxy runtime before sending more actions.', requestId, 'REQUEST_LEDGER_CAPACITY');
724
+ return;
725
+ }
726
+ acceptedRequestId = requestId;
727
+ }
144
728
  try {
729
+ // These guards do not cancel an action that already started. They prevent
730
+ // queued work from beginning after the receipt-relative timeout.
731
+ assertActionDeadline(msg, receivedAt);
145
732
  const page = await waitForPage();
733
+ assertActionDeadline(msg, receivedAt);
146
734
  if (isResizeMessage(msg)) {
147
735
  const w = Math.max(1, Math.floor(msg.width));
148
736
  const h = Math.max(1, Math.floor(msg.height));
737
+ assertActionDeadline(msg, receivedAt);
149
738
  await page.setViewportSize({ width: w, height: h });
150
739
  onViewportOrInput('resize', requestId);
151
740
  return;
152
741
  }
742
+ assertActionDeadline(msg, receivedAt);
153
743
  await waitForBeforeInput();
744
+ assertActionDeadline(msg, receivedAt);
154
745
  if (isNavigateMessage(msg)) {
155
746
  clearFillLookupCache(fieldLookupCache);
747
+ assertActionDeadline(msg, receivedAt);
156
748
  await page.goto(msg.url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
157
749
  onViewportOrInput('input', requestId, { pageUrl: page.url() });
158
750
  return;
@@ -169,6 +761,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
169
761
  // "session crashed for unknown reason". Bug surfaced by JobForge
170
762
  // round-2 marathon — Cloudflare FDE NYC #312 and Airtable PM AI #94.
171
763
  const urlBefore = page.url();
764
+ assertActionDeadline(msg, receivedAt);
172
765
  await page.mouse.click(x, y);
173
766
  // Give the navigation a brief window to start. We don't await
174
767
  // waitForNavigation here because most clicks DON'T navigate, and
@@ -199,26 +792,37 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
199
792
  return;
200
793
  }
201
794
  if (isKeyMessage(msg)) {
795
+ assertActionDeadline(msg, receivedAt);
202
796
  await applyKeyPhase(page, msg);
203
797
  onViewportOrInput('input', requestId);
204
798
  return;
205
799
  }
800
+ if (isTypeTextMessage(msg)) {
801
+ assertActionDeadline(msg, receivedAt);
802
+ await page.keyboard.type(msg.text);
803
+ onViewportOrInput('input', requestId);
804
+ return;
805
+ }
206
806
  if (isCompositionMessage(msg)) {
207
807
  const data = typeof msg.data === 'string' ? msg.data : '';
208
808
  if (msg.eventType === 'onCompositionUpdate' || msg.eventType === 'onCompositionEnd') {
809
+ assertActionDeadline(msg, receivedAt);
209
810
  await page.keyboard.insertText(data);
210
811
  onViewportOrInput('input', requestId);
211
812
  }
212
813
  return;
213
814
  }
214
815
  if (isFileMessage(msg)) {
215
- const paths = resolveExistingFiles(msg.paths);
816
+ const paths = resolveExistingFiles(msg.paths, security?.allowedFileRoots);
817
+ assertActionDeadline(msg, receivedAt);
216
818
  await attachFiles(page, paths, {
217
819
  clickX: msg.x,
218
820
  clickY: msg.y,
219
821
  fieldId: msg.fieldId,
220
822
  fieldKey: msg.fieldKey,
221
823
  fieldLabel: msg.fieldLabel,
824
+ contextText: msg.contextText,
825
+ sectionText: msg.sectionText,
222
826
  exact: msg.exact,
223
827
  strategy: msg.strategy,
224
828
  dropX: msg.dropX,
@@ -228,6 +832,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
228
832
  return;
229
833
  }
230
834
  if (isSetFieldTextMessage(msg)) {
835
+ assertActionDeadline(msg, receivedAt);
231
836
  await setFieldText(page, msg.fieldLabel, msg.value, {
232
837
  fieldId: msg.fieldId,
233
838
  fieldKey: msg.fieldKey,
@@ -240,6 +845,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
240
845
  return;
241
846
  }
242
847
  if (isSetFieldChoiceMessage(msg)) {
848
+ assertActionDeadline(msg, receivedAt);
243
849
  await setFieldChoice(page, msg.fieldLabel, msg.value, {
244
850
  fieldId: msg.fieldId,
245
851
  fieldKey: msg.fieldKey,
@@ -253,12 +859,17 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
253
859
  return;
254
860
  }
255
861
  if (isFillFieldsMessage(msg)) {
256
- await fillFields(page, msg.fields, fieldLookupCache);
862
+ const authorizedFields = msg.fields.map(field => field.kind === 'file'
863
+ ? { ...field, paths: resolveExistingFiles(field.paths, security?.allowedFileRoots) }
864
+ : field);
865
+ assertActionDeadline(msg, receivedAt);
866
+ await fillFields(page, authorizedFields, fieldLookupCache);
257
867
  const result = await fillFieldsAckResult(page);
258
868
  onViewportOrInput('input', requestId, result);
259
869
  return;
260
870
  }
261
871
  if (isFillOtpMessage(msg)) {
872
+ assertActionDeadline(msg, receivedAt);
262
873
  const result = await fillOtp(page, msg.value, {
263
874
  fieldLabel: msg.fieldLabel,
264
875
  perCharDelayMs: msg.perCharDelayMs,
@@ -271,6 +882,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
271
882
  return;
272
883
  }
273
884
  if (isListboxPickMessage(msg)) {
885
+ assertActionDeadline(msg, receivedAt);
274
886
  await pickListboxOption(page, msg.label, {
275
887
  exact: msg.exact,
276
888
  openX: msg.openX,
@@ -285,6 +897,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
285
897
  return;
286
898
  }
287
899
  if (isSelectOptionMessage(msg)) {
900
+ assertActionDeadline(msg, receivedAt);
288
901
  await selectNativeOption(page, msg.x, msg.y, {
289
902
  value: msg.value,
290
903
  label: msg.label,
@@ -294,6 +907,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
294
907
  return;
295
908
  }
296
909
  if (isSetCheckedMessage(msg)) {
910
+ assertActionDeadline(msg, receivedAt);
297
911
  await setCheckedControl(page, msg.label, {
298
912
  fieldKey: msg.fieldKey,
299
913
  checked: msg.checked,
@@ -310,6 +924,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
310
924
  const dy = typeof msg.deltaY === 'number' && Number.isFinite(msg.deltaY) ? msg.deltaY : 0;
311
925
  const x = typeof msg.x === 'number' && Number.isFinite(msg.x) ? msg.x : undefined;
312
926
  const y = typeof msg.y === 'number' && Number.isFinite(msg.y) ? msg.y : undefined;
927
+ assertActionDeadline(msg, receivedAt);
313
928
  await wheelAt(page, dx, dy, x, y);
314
929
  onViewportOrInput('input', requestId);
315
930
  return;
@@ -327,8 +942,10 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
327
942
  const marginValue = msg.margin ?? '1cm';
328
943
  const margin = { top: marginValue, right: marginValue, bottom: marginValue, left: marginValue };
329
944
  if (msg.html) {
945
+ assertActionDeadline(msg, receivedAt);
330
946
  await page.setContent(msg.html, { waitUntil: 'networkidle', timeout: 30_000 });
331
947
  }
948
+ assertActionDeadline(msg, receivedAt);
332
949
  const buffer = await page.pdf({
333
950
  format,
334
951
  landscape,
@@ -342,8 +959,17 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
342
959
  sendWireError(`Unsupported client message type "${msg.type}"`, requestId);
343
960
  }
344
961
  catch (err) {
345
- onHandlerError(err);
346
- sendWireError(err instanceof Error ? err.message : String(err), requestId);
962
+ if (!(err instanceof ActionDeadlineExpiredError))
963
+ onHandlerError(err);
964
+ sendWireError(err instanceof Error ? err.message : String(err), requestId, err instanceof ActionDeadlineExpiredError
965
+ ? 'ACTION_EXPIRED'
966
+ : acceptedRequestId
967
+ ? 'ACTION_OUTCOME_AMBIGUOUS'
968
+ : undefined);
969
+ }
970
+ finally {
971
+ if (acceptedRequestId)
972
+ security?.actionRequestLedger?.complete(acceptedRequestId);
347
973
  }
348
974
  }
349
975
  async function fillFieldsAckResult(page) {
@@ -355,9 +981,11 @@ async function fillFieldsAckResult(page) {
355
981
  // or similar. Counting both gives fill_form a reliable signal for whether
356
982
  // a custom dropdown commit actually landed, which is the authoritative
357
983
  // check used throughout the listbox pick pipeline (see readAriaInvalid in
358
- // dom-actions.ts). The `:is()` selector de-duplicates the two passes so
359
- // elements that match both just count once.
360
- const invalidSelector = ':is(:invalid, [aria-invalid="true"]:is(input, textarea, select, [role="combobox"], [role="listbox"], [role="spinbutton"], [role="searchbox"], [role="textbox"]))';
984
+ // dom-actions.ts). Restrict native validity to actual controls because
985
+ // `:invalid` also matches ancestor <form> and <fieldset> elements whenever
986
+ // one descendant is invalid. The outer `:is()` de-duplicates controls that
987
+ // also advertise aria-invalid.
988
+ const invalidSelector = ':is(:is(input, textarea, select):invalid, [aria-invalid="true"]:is(input, textarea, select, [role="combobox"], [role="listbox"], [role="spinbutton"], [role="searchbox"], [role="textbox"]))';
361
989
  const [invalidCount, alertCount, dialogCount, busyCount] = await Promise.all([
362
990
  countAcrossFrames(frames, invalidSelector),
363
991
  countAcrossFrames(frames, '[role="alert"], [role="alertdialog"]'),
@@ -455,12 +1083,53 @@ async function countAcrossFrames(frames, selector) {
455
1083
  }
456
1084
  export function startGeometryWebSocket(options) {
457
1085
  const debounceMs = options.debounceMs ?? 50;
1086
+ const host = options.host?.trim() || DEFAULT_PROXY_HOST;
1087
+ const authToken = normalizedAuthToken(options.authToken);
1088
+ const allowedOrigins = new Set(options.allowedOrigins ?? []);
458
1089
  const clients = new Set();
459
- const wss = new WebSocketServer({ port: options.port });
460
- const axSessionManager = createCdpAxSessionManager();
1090
+ let controllerClaimed = false;
1091
+ const wss = new WebSocketServer({
1092
+ port: options.port,
1093
+ host,
1094
+ maxPayload: Math.max(1024, options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES),
1095
+ verifyClient(info, done) {
1096
+ const origin = typeof info.req.headers.origin === 'string' ? info.req.headers.origin : undefined;
1097
+ if (origin !== undefined && !allowedOrigins.has(origin)) {
1098
+ done(false, 403, 'WebSocket Origin is not allowed');
1099
+ return;
1100
+ }
1101
+ const authorization = typeof info.req.headers.authorization === 'string'
1102
+ ? info.req.headers.authorization
1103
+ : undefined;
1104
+ if (!bearerTokenMatches(authorization, authToken)) {
1105
+ done(false, 401, 'Bearer capability required');
1106
+ return;
1107
+ }
1108
+ const liveController = Array.from(clients).some(client => client.readyState === client.OPEN || client.readyState === client.CONNECTING);
1109
+ // Permit a clean handover while the prior controller is already
1110
+ // CLOSING; reject both a live controller and a concurrent handshake.
1111
+ if (controllerClaimed && (clients.size === 0 || liveController)) {
1112
+ done(false, 409, 'A Geometra controller is already connected');
1113
+ return;
1114
+ }
1115
+ controllerClaimed = true;
1116
+ done(true);
1117
+ },
1118
+ });
1119
+ let closing = false;
1120
+ const axSessionManager = createCdpAxSessionManager(() => {
1121
+ if (!closing)
1122
+ scheduleExtract(true);
1123
+ });
461
1124
  let prevLayout = null;
462
1125
  let prevTreeJson = null;
463
1126
  let debounceTimer = null;
1127
+ let inputAckFlushTimer = null;
1128
+ let immediateExtractTimer = null;
1129
+ let axRetryTimer = null;
1130
+ let axRetryDueAtMs = Number.POSITIVE_INFINITY;
1131
+ let immediateExtractRequestedAfterActive = false;
1132
+ let lastExtractCompletedAtMs = Number.NEGATIVE_INFINITY;
464
1133
  let extracting = false;
465
1134
  let pendingExtract = false;
466
1135
  let extractSequence = 0;
@@ -468,6 +1137,7 @@ export function startGeometryWebSocket(options) {
468
1137
  let actionQueue = Promise.resolve();
469
1138
  let pendingInputAcks = [];
470
1139
  const fieldLookupCache = createFillLookupCache();
1140
+ const actionRequestLedger = createActionRequestLedger();
471
1141
  const beforeInput = options.beforeInput?.then(() => undefined);
472
1142
  const trace = { extractCount: 0 };
473
1143
  const pagePromise = Promise.resolve(options.page);
@@ -500,14 +1170,20 @@ export function startGeometryWebSocket(options) {
500
1170
  type: 'ack',
501
1171
  ...(requestId ? { requestId } : {}),
502
1172
  ...(result !== undefined ? { result } : {}),
503
- protocolVersion: PROXY_PROTOCOL_VERSION,
1173
+ ...PROXY_ACTION_METADATA,
504
1174
  }));
505
1175
  }
506
1176
  }
1177
+ if (pendingInputAcks.length === 0) {
1178
+ clearInputAckFlushTimer();
1179
+ }
1180
+ else {
1181
+ armInputAckFlushTimer();
1182
+ }
507
1183
  }
508
1184
  function sendPendingInputErrors(message) {
509
1185
  if (pendingInputAcks.length === 0) {
510
- const errText = JSON.stringify({ type: 'error', message, protocolVersion: PROXY_PROTOCOL_VERSION });
1186
+ const errText = JSON.stringify({ type: 'error', message, ...PROXY_ACTION_METADATA });
511
1187
  for (const ws of clients) {
512
1188
  if (ws.readyState === ws.OPEN)
513
1189
  ws.send(errText);
@@ -516,13 +1192,15 @@ export function startGeometryWebSocket(options) {
516
1192
  }
517
1193
  const pending = pendingInputAcks;
518
1194
  pendingInputAcks = [];
1195
+ clearInputAckFlushTimer();
519
1196
  for (const { ws, requestId } of pending) {
520
1197
  if (ws.readyState === ws.OPEN) {
521
1198
  ws.send(JSON.stringify({
522
1199
  type: 'error',
523
1200
  message,
1201
+ code: 'ACTION_OUTCOME_AMBIGUOUS',
524
1202
  ...(requestId ? { requestId } : {}),
525
- protocolVersion: PROXY_PROTOCOL_VERSION,
1203
+ ...PROXY_ACTION_METADATA,
526
1204
  }));
527
1205
  }
528
1206
  }
@@ -535,7 +1213,7 @@ export function startGeometryWebSocket(options) {
535
1213
  type: 'frame',
536
1214
  layout: snap.layout,
537
1215
  tree: snap.tree,
538
- protocolVersion: PROXY_PROTOCOL_VERSION,
1216
+ ...PROXY_GEOMETRY_METADATA,
539
1217
  };
540
1218
  prevLayout = cloneLayout(snap.layout);
541
1219
  prevTreeJson = snap.treeJson;
@@ -551,11 +1229,11 @@ export function startGeometryWebSocket(options) {
551
1229
  type: 'frame',
552
1230
  layout: snap.layout,
553
1231
  tree: snap.tree,
554
- protocolVersion: PROXY_PROTOCOL_VERSION,
1232
+ ...PROXY_GEOMETRY_METADATA,
555
1233
  };
556
1234
  }
557
1235
  else {
558
- outbound = { type: 'patch', patches, protocolVersion: PROXY_PROTOCOL_VERSION };
1236
+ outbound = { type: 'patch', patches, ...PROXY_GEOMETRY_METADATA };
559
1237
  }
560
1238
  prevLayout = cloneLayout(snap.layout);
561
1239
  prevTreeJson = snap.treeJson;
@@ -583,7 +1261,7 @@ export function startGeometryWebSocket(options) {
583
1261
  };
584
1262
  const page = await waitForPage();
585
1263
  const extractStartedAt = performance.now();
586
- const snap = await extractGeometryWithRecovery(page, axSessionManager, extractorTrace, recoveryTrace);
1264
+ const snap = await extractGeometryWithRecovery(page, axSessionManager, extractorTrace, recoveryTrace, () => scheduleExtract(true), delayMs => scheduleAxRetry(delayMs));
587
1265
  const extractMs = performance.now() - extractStartedAt;
588
1266
  const broadcastStartedAt = performance.now();
589
1267
  const changed = broadcastSnapshot(snap);
@@ -631,20 +1309,113 @@ export function startGeometryWebSocket(options) {
631
1309
  }
632
1310
  finally {
633
1311
  extracting = false;
1312
+ lastExtractCompletedAtMs = performance.now();
1313
+ if (immediateExtractRequestedAfterActive) {
1314
+ immediateExtractRequestedAfterActive = false;
1315
+ armImmediateExtract();
1316
+ }
634
1317
  }
635
1318
  return changed;
636
1319
  }
637
- function scheduleExtract() {
638
- if (debounceTimer !== null)
1320
+ function clearDebounceTimer() {
1321
+ if (debounceTimer !== null) {
639
1322
  clearTimeout(debounceTimer);
640
- debounceTimer = setTimeout(() => {
641
1323
  debounceTimer = null;
642
- void runExtractQueued()
643
- .then(() => {
644
- sendPendingInputAcks();
645
- })
646
- .catch(err => options.onError?.(err));
647
- }, debounceMs);
1324
+ }
1325
+ }
1326
+ function clearInputAckFlushTimer() {
1327
+ if (inputAckFlushTimer !== null) {
1328
+ clearTimeout(inputAckFlushTimer);
1329
+ inputAckFlushTimer = null;
1330
+ }
1331
+ }
1332
+ function clearImmediateExtractTimer() {
1333
+ if (immediateExtractTimer !== null) {
1334
+ clearTimeout(immediateExtractTimer);
1335
+ immediateExtractTimer = null;
1336
+ }
1337
+ immediateExtractRequestedAfterActive = false;
1338
+ }
1339
+ function clearAxRetryTimer() {
1340
+ if (axRetryTimer !== null) {
1341
+ clearTimeout(axRetryTimer);
1342
+ axRetryTimer = null;
1343
+ }
1344
+ axRetryDueAtMs = Number.POSITIVE_INFINITY;
1345
+ }
1346
+ function scheduleAxRetry(delayMs) {
1347
+ const boundedDelayMs = Math.max(1, Math.ceil(delayMs));
1348
+ const dueAtMs = performance.now() + boundedDelayMs;
1349
+ if (axRetryTimer !== null && dueAtMs >= axRetryDueAtMs)
1350
+ return;
1351
+ clearAxRetryTimer();
1352
+ axRetryDueAtMs = dueAtMs;
1353
+ axRetryTimer = setTimeout(() => {
1354
+ axRetryTimer = null;
1355
+ axRetryDueAtMs = Number.POSITIVE_INFINITY;
1356
+ scheduleExtract(true);
1357
+ }, boundedDelayMs);
1358
+ }
1359
+ function runScheduledExtract() {
1360
+ void runExtractQueued()
1361
+ .then(() => {
1362
+ sendPendingInputAcks();
1363
+ })
1364
+ .catch(err => options.onError?.(err));
1365
+ }
1366
+ function flushDebouncedExtract() {
1367
+ clearDebounceTimer();
1368
+ runScheduledExtract();
1369
+ }
1370
+ function armInputAckFlushTimer() {
1371
+ if (pendingInputAcks.length === 0 || inputAckFlushTimer !== null)
1372
+ return;
1373
+ // This timer is anchored to the first pending action ack and is never
1374
+ // reset by page mutations. Mutation-only traffic does not arm it, so an
1375
+ // animated page retains the normal trailing-edge debounce behavior.
1376
+ inputAckFlushTimer = setTimeout(() => {
1377
+ inputAckFlushTimer = null;
1378
+ if (pendingInputAcks.length === 0)
1379
+ return;
1380
+ clearDebounceTimer();
1381
+ runScheduledExtract();
1382
+ }, MAX_INPUT_ACK_LATENCY_MS);
1383
+ }
1384
+ function armImmediateExtract() {
1385
+ if (extracting) {
1386
+ // Do not turn a page hint into runExtractQueued's pending loop. One
1387
+ // coalesced refresh becomes eligible only after the active extraction
1388
+ // completes and the host has had a cooldown window.
1389
+ immediateExtractRequestedAfterActive = true;
1390
+ return;
1391
+ }
1392
+ if (immediateExtractTimer !== null)
1393
+ return;
1394
+ const elapsed = performance.now() - lastExtractCompletedAtMs;
1395
+ const remaining = Math.max(0, MIN_IMMEDIATE_EXTRACT_INTERVAL_MS - elapsed);
1396
+ if (remaining === 0) {
1397
+ runScheduledExtract();
1398
+ return;
1399
+ }
1400
+ immediateExtractTimer = setTimeout(() => {
1401
+ immediateExtractTimer = null;
1402
+ if (extracting) {
1403
+ immediateExtractRequestedAfterActive = true;
1404
+ }
1405
+ else {
1406
+ runScheduledExtract();
1407
+ }
1408
+ }, remaining);
1409
+ }
1410
+ function scheduleExtract(immediate = false) {
1411
+ if (immediate) {
1412
+ clearDebounceTimer();
1413
+ armImmediateExtract();
1414
+ return;
1415
+ }
1416
+ if (debounceTimer !== null)
1417
+ clearTimeout(debounceTimer);
1418
+ debounceTimer = setTimeout(flushDebouncedExtract, debounceMs);
648
1419
  }
649
1420
  wss.on('listening', () => {
650
1421
  const addr = wss.address();
@@ -656,6 +1427,13 @@ export function startGeometryWebSocket(options) {
656
1427
  });
657
1428
  wss.on('connection', (ws) => {
658
1429
  clients.add(ws);
1430
+ // Authentication happens during the HTTP upgrade, but MCP still needs a
1431
+ // protocol-level attestation before it can trust a spawned proxy. Send it
1432
+ // immediately so lazy extraction can remain lazy without weakening the
1433
+ // capability handshake.
1434
+ if (ws.readyState === ws.OPEN) {
1435
+ ws.send(JSON.stringify({ type: 'hello', ...PROXY_GEOMETRY_METADATA }));
1436
+ }
659
1437
  if (prevLayout && prevTreeJson !== null) {
660
1438
  const snap = {
661
1439
  layout: prevLayout,
@@ -666,16 +1444,29 @@ export function startGeometryWebSocket(options) {
666
1444
  type: 'frame',
667
1445
  layout: snap.layout,
668
1446
  tree: snap.tree,
669
- protocolVersion: PROXY_PROTOCOL_VERSION,
1447
+ ...PROXY_GEOMETRY_METADATA,
670
1448
  });
671
1449
  if (ws.readyState === ws.OPEN)
672
1450
  ws.send(text);
673
1451
  }
674
1452
  ws.on('message', (raw) => {
1453
+ // Capture receipt before queueing so actionTimeoutMs includes time spent
1454
+ // waiting behind earlier Playwright work and uses this host's monotonic
1455
+ // clock rather than trusting the controller's wall clock.
1456
+ const receivedAt = performance.now();
675
1457
  actionQueue = actionQueue
676
1458
  .then(() => handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitForBeforeInput, (kind, requestId, result) => {
677
1459
  if (kind === 'resize') {
678
- void runExtractQueued();
1460
+ if (requestId) {
1461
+ pendingInputAcks.push({
1462
+ ws,
1463
+ requestId,
1464
+ afterExtractSequence: extractSequence,
1465
+ });
1466
+ }
1467
+ void runExtractQueued()
1468
+ .then(() => sendPendingInputAcks())
1469
+ .catch(err => options.onError?.(err));
679
1470
  }
680
1471
  else {
681
1472
  pendingInputAcks.push({
@@ -685,36 +1476,48 @@ export function startGeometryWebSocket(options) {
685
1476
  ...(result !== undefined ? { result } : {}),
686
1477
  });
687
1478
  scheduleExtract();
1479
+ armInputAckFlushTimer();
688
1480
  }
689
- }, err => options.onError?.(err)))
1481
+ }, err => options.onError?.(err), { allowedFileRoots: options.allowedFileRoots, actionRequestLedger, receivedAt }))
690
1482
  .catch(err => options.onError?.(err));
691
1483
  });
692
1484
  ws.on('close', () => {
693
1485
  clients.delete(ws);
1486
+ controllerClaimed = clients.size > 0;
694
1487
  pendingInputAcks = pendingInputAcks.filter(entry => entry.ws !== ws);
1488
+ if (pendingInputAcks.length === 0)
1489
+ clearInputAckFlushTimer();
695
1490
  });
696
1491
  });
697
1492
  return {
1493
+ authToken,
1494
+ host,
698
1495
  scheduleExtract,
699
1496
  flushExtract: async () => {
700
1497
  await actionQueue.catch(() => { });
701
- if (debounceTimer !== null) {
702
- clearTimeout(debounceTimer);
703
- debounceTimer = null;
704
- }
1498
+ clearDebounceTimer();
1499
+ clearInputAckFlushTimer();
1500
+ clearImmediateExtractTimer();
705
1501
  await runExtractQueued();
706
1502
  sendPendingInputAcks();
707
1503
  },
708
1504
  getTrace: () => structuredClone(trace),
709
- close: () => new Promise((resolve, reject) => {
710
- void axSessionManager.close().finally(() => {
711
- for (const ws of clients) {
712
- ws.close();
713
- }
714
- clients.clear();
715
- wss.close(err => (err ? reject(err) : resolve()));
1505
+ close: () => {
1506
+ closing = true;
1507
+ return new Promise((resolve, reject) => {
1508
+ void axSessionManager.close().finally(() => {
1509
+ clearDebounceTimer();
1510
+ clearInputAckFlushTimer();
1511
+ clearImmediateExtractTimer();
1512
+ clearAxRetryTimer();
1513
+ for (const ws of clients) {
1514
+ ws.close();
1515
+ }
1516
+ clients.clear();
1517
+ wss.close(err => (err ? reject(err) : resolve()));
1518
+ });
716
1519
  });
717
- }),
1520
+ },
718
1521
  };
719
1522
  }
720
1523
  export async function primeDomObserver(page, scheduleExtract) {
@@ -722,27 +1525,12 @@ export async function primeDomObserver(page, scheduleExtract) {
722
1525
  }
723
1526
  export async function installDomObserver(page, scheduleExtract) {
724
1527
  await bindDomObserverBridge(page, scheduleExtract);
725
- await page.evaluate(() => {
726
- const w = window;
727
- if (w.__geometraProxyObserverInstalled)
728
- return;
729
- const observer = new MutationObserver(() => {
730
- void w.__geometraProxyNotify?.();
731
- });
732
- observer.observe(document.documentElement, {
733
- subtree: true,
734
- childList: true,
735
- attributes: true,
736
- characterData: true,
737
- });
738
- w.__geometraProxyObserverInstalled = true;
739
- });
740
1528
  }
741
1529
  function isNavigationTransitionError(err) {
742
1530
  const message = err instanceof Error ? err.message : String(err);
743
1531
  return /Execution context was destroyed|Cannot find context with specified id|Frame was detached|navigation/i.test(message);
744
1532
  }
745
- async function extractGeometryWithRecovery(page, axSessionManager, extractTrace, recoveryTrace) {
1533
+ async function extractGeometryWithRecovery(page, axSessionManager, extractTrace, recoveryTrace, onAxReady, onAxRetryDue) {
746
1534
  let lastNavigationError = null;
747
1535
  let domContentLoadedWaitMs = 0;
748
1536
  let loadWaitMs = 0;
@@ -751,7 +1539,12 @@ async function extractGeometryWithRecovery(page, axSessionManager, extractTrace,
751
1539
  if (recoveryTrace) {
752
1540
  recoveryTrace.attemptCount = attempt + 1;
753
1541
  }
754
- return await extractGeometry(page, { axSessionManager, trace: extractTrace });
1542
+ return await extractGeometry(page, {
1543
+ axSessionManager,
1544
+ trace: extractTrace,
1545
+ onAxReady,
1546
+ onAxRetryDue,
1547
+ });
755
1548
  }
756
1549
  catch (err) {
757
1550
  if (page.isClosed() || !isNavigationTransitionError(err))