@geometra/mcp 1.64.0 → 1.65.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/dist/session.js CHANGED
@@ -1,8 +1,9 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { performance } from 'node:perf_hooks';
3
3
  import WebSocket from 'ws';
4
4
  import { resolveStealthMode, spawnGeometraProxy, startEmbeddedGeometraProxy, } from './proxy-spawn.js';
5
5
  import { completeSessionLifecycle, failSessionLifecycle, heartbeatSessionLifecycle, initializeSessionLifecycle, recordSessionSnapshot, } from './session-state.js';
6
+ import { REDACTED_STATE_URL, sanitizeUrlToOrigin } from './state-privacy.js';
6
7
  /**
7
8
  * Stable identity for an outbound proxy config, used as the reusable-pool
8
9
  * partition key. Two sessions with different proxy configs MUST NOT share a
@@ -20,12 +21,22 @@ const activeSessions = new Map();
20
21
  let defaultSessionId = null;
21
22
  const MAX_ACTIVE_SESSIONS = 5;
22
23
  function generateSessionId() { return `s_${randomUUID()}`; }
24
+ class SessionCapacityError extends Error {
25
+ constructor(active, pending) {
26
+ super(`Geometra MCP already has ${active} active sessions and ${pending} pending connection(s) ` +
27
+ `(limit ${MAX_ACTIVE_SESSIONS}). Disconnect an explicit session before connecting another; ` +
28
+ 'active owners are never evicted implicitly.');
29
+ this.name = 'SessionCapacityError';
30
+ }
31
+ }
32
+ const pendingSessionOwnership = new Set();
23
33
  let reusableProxies = [];
24
34
  const REUSABLE_PROXY_POOL_LIMIT = 6;
25
35
  /** Close idle reusable proxies after 5 minutes of inactivity. */
26
36
  const REUSABLE_PROXY_IDLE_TTL_MS = 5 * 60 * 1000;
27
37
  let idleProxyTimer = null;
28
38
  const trackedReusableProxyChildren = new WeakSet();
39
+ const embeddedProxyClosePromises = new WeakMap();
29
40
  const ACTION_UPDATE_TIMEOUT_MS = 2000;
30
41
  const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
31
42
  const FILL_BATCH_BASE_TIMEOUT_MS = 2500;
@@ -37,13 +48,418 @@ const FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS = 225;
37
48
  const FILL_BATCH_FILE_FIELD_TIMEOUT_MS = 5000;
38
49
  const FILL_BATCH_MAX_TIMEOUT_MS = 60_000;
39
50
  const SESSION_RECONNECT_TIMEOUT_MS = 5_000;
40
- const PROXY_PROTOCOL_VERSION = 2;
41
- let nextRequestSequence = 0;
51
+ const GEOMETRY_PROTOCOL_VERSION = 1;
52
+ const PROXY_ACTION_PROTOCOL_VERSION = 2;
53
+ const MAX_AMBIGUOUS_OPERATIONS_PER_SESSION = 64;
54
+ const MAX_AMBIGUOUS_WIRE_BYTES_PER_SESSION = 1024 * 1024;
55
+ const MUTATING_PROXY_ACTION_TYPES = new Set([
56
+ 'event',
57
+ 'key',
58
+ 'typeText',
59
+ 'resize',
60
+ 'navigate',
61
+ 'composition',
62
+ 'file',
63
+ 'setFieldText',
64
+ 'setFieldChoice',
65
+ 'fillFields',
66
+ 'fillOtp',
67
+ 'listboxPick',
68
+ 'selectOption',
69
+ 'setChecked',
70
+ 'wheel',
71
+ 'pdfGenerate',
72
+ ]);
73
+ class GeometraWireError extends Error {
74
+ code;
75
+ requestId;
76
+ actionId;
77
+ constructor(message, code, requestId, actionId) {
78
+ super(message);
79
+ this.name = 'GeometraWireError';
80
+ this.code = code;
81
+ this.requestId = requestId;
82
+ this.actionId = actionId;
83
+ }
84
+ }
85
+ const SAFE_NON_EXECUTION_WIRE_CODES = new Set([
86
+ 'ACTION_EXPIRED',
87
+ 'REQUEST_ID_CONFLICT',
88
+ 'REQUEST_LEDGER_CAPACITY',
89
+ ]);
90
+ function wireErrorFromMessage(msg, actionId) {
91
+ return new GeometraWireError(typeof msg.message === 'string' ? msg.message : 'Geometra server error', typeof msg.code === 'string' ? msg.code : undefined, typeof msg.requestId === 'string' ? msg.requestId : undefined, actionId);
92
+ }
93
+ function isRecord(value) {
94
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
95
+ }
96
+ function optionalFiniteProtocolVersion(value, field) {
97
+ if (value === undefined)
98
+ return undefined;
99
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
100
+ throw new Error(`Invalid Geometra server message: ${field} must be a finite number when present`);
101
+ }
102
+ return value;
103
+ }
104
+ function validatePatchMessage(patches) {
105
+ if (!Array.isArray(patches)) {
106
+ throw new Error('Invalid Geometra server patch: patches must be an array');
107
+ }
108
+ for (const patch of patches) {
109
+ if (!isRecord(patch) || !Array.isArray(patch.path) || !patch.path.every(segment => typeof segment === 'number' && Number.isInteger(segment) && segment >= 0)) {
110
+ throw new Error('Invalid Geometra server patch: every patch needs a non-negative integer path');
111
+ }
112
+ for (const key of ['x', 'y', 'width', 'height']) {
113
+ const value = patch[key];
114
+ if (value === undefined)
115
+ continue;
116
+ if (typeof value !== 'number' || !Number.isFinite(value) || ((key === 'width' || key === 'height') && value < 0)) {
117
+ throw new Error(`Invalid Geometra server patch: ${key} must be a finite${key === 'width' || key === 'height' ? ' non-negative' : ''} number`);
118
+ }
119
+ }
120
+ }
121
+ }
122
+ function parseInboundServerMessage(data) {
123
+ let parsed;
124
+ try {
125
+ parsed = JSON.parse(String(data));
126
+ }
127
+ catch {
128
+ throw new Error('Invalid Geometra server message: expected valid JSON');
129
+ }
130
+ if (!isRecord(parsed) || typeof parsed.type !== 'string') {
131
+ throw new Error('Invalid Geometra server message: expected an object with a string type');
132
+ }
133
+ const geometryVersion = optionalFiniteProtocolVersion(parsed.geometryProtocolVersion, 'geometryProtocolVersion');
134
+ const proxyActionVersion = optionalFiniteProtocolVersion(parsed.proxyActionProtocolVersion, 'proxyActionProtocolVersion');
135
+ const legacyVersion = optionalFiniteProtocolVersion(parsed.protocolVersion, 'protocolVersion');
136
+ if (geometryVersion !== undefined && geometryVersion > GEOMETRY_PROTOCOL_VERSION) {
137
+ throw new Error(`Server geometry protocol ${geometryVersion} is newer than MCP geometry protocol ${GEOMETRY_PROTOCOL_VERSION}`);
138
+ }
139
+ if (proxyActionVersion !== undefined && proxyActionVersion > PROXY_ACTION_PROTOCOL_VERSION) {
140
+ throw new Error(`Server proxy-action protocol ${proxyActionVersion} is newer than MCP proxy-action protocol ${PROXY_ACTION_PROTOCOL_VERSION}`);
141
+ }
142
+ if (geometryVersion === undefined && proxyActionVersion === undefined && legacyVersion !== undefined && legacyVersion > PROXY_ACTION_PROTOCOL_VERSION) {
143
+ throw new Error(`Server protocol ${legacyVersion} is newer than MCP's supported geometry/proxy protocols`);
144
+ }
145
+ if (parsed.type === 'frame') {
146
+ if (!isRecord(parsed.layout) || !isRecord(parsed.tree)) {
147
+ throw new Error('Invalid Geometra server frame: layout and tree must be objects');
148
+ }
149
+ for (const key of ['x', 'y', 'width', 'height']) {
150
+ const value = parsed.layout[key];
151
+ if (typeof value !== 'number' || !Number.isFinite(value) || ((key === 'width' || key === 'height') && value < 0)) {
152
+ throw new Error(`Invalid Geometra server frame: layout.${key} is invalid`);
153
+ }
154
+ }
155
+ if (!Array.isArray(parsed.layout.children)) {
156
+ throw new Error('Invalid Geometra server frame: layout.children must be an array');
157
+ }
158
+ }
159
+ else if (parsed.type === 'patch') {
160
+ validatePatchMessage(parsed.patches);
161
+ }
162
+ else if (parsed.type === 'error' && typeof parsed.message !== 'string') {
163
+ throw new Error('Invalid Geometra server error: message must be a string');
164
+ }
165
+ else if (parsed.type === 'ack' && parsed.requestId !== undefined && typeof parsed.requestId !== 'string') {
166
+ throw new Error('Invalid Geometra server ack: requestId must be a string when present');
167
+ }
168
+ return parsed;
169
+ }
170
+ function updatePeerProtocol(session, msg) {
171
+ const geometryVersion = typeof msg.geometryProtocolVersion === 'number'
172
+ ? msg.geometryProtocolVersion
173
+ : undefined;
174
+ const proxyActionVersion = typeof msg.proxyActionProtocolVersion === 'number'
175
+ ? msg.proxyActionProtocolVersion
176
+ : undefined;
177
+ const legacyVersion = typeof msg.protocolVersion === 'number' ? msg.protocolVersion : undefined;
178
+ const capabilities = isRecord(msg.protocolCapabilities) ? msg.protocolCapabilities : undefined;
179
+ const transport = capabilities?.transport;
180
+ if (geometryVersion !== undefined || proxyActionVersion !== undefined || capabilities) {
181
+ session.peerAdvertisedSplitProtocol = true;
182
+ }
183
+ session.peerGeometryProtocolVersion = geometryVersion ?? (legacyVersion === PROXY_ACTION_PROTOCOL_VERSION ? GEOMETRY_PROTOCOL_VERSION : legacyVersion) ?? session.peerGeometryProtocolVersion ?? GEOMETRY_PROTOCOL_VERSION;
184
+ session.peerProxyActionProtocolVersion = proxyActionVersion ?? (legacyVersion === PROXY_ACTION_PROTOCOL_VERSION ? legacyVersion : session.peerProxyActionProtocolVersion);
185
+ if (transport === 'native' || transport === 'proxy') {
186
+ session.peerTransport = transport;
187
+ }
188
+ else if (proxyActionVersion !== undefined || legacyVersion === PROXY_ACTION_PROTOCOL_VERSION) {
189
+ session.peerTransport = 'proxy';
190
+ }
191
+ if (capabilities) {
192
+ session.peerProtocolCapabilities = {
193
+ authenticatedController: typeof capabilities.authenticatedController === 'boolean' ? capabilities.authenticatedController : undefined,
194
+ requestScopedAcks: typeof capabilities.requestScopedAcks === 'boolean' ? capabilities.requestScopedAcks : undefined,
195
+ actionDeadlines: typeof capabilities.actionDeadlines === 'boolean' ? capabilities.actionDeadlines : undefined,
196
+ idempotentRequestIds: typeof capabilities.idempotentRequestIds === 'boolean' ? capabilities.idempotentRequestIds : undefined,
197
+ atomicTypeText: typeof capabilities.atomicTypeText === 'boolean' ? capabilities.atomicTypeText : undefined,
198
+ proxyActions: typeof capabilities.proxyActions === 'boolean' ? capabilities.proxyActions : undefined,
199
+ exactFieldIdentity: typeof capabilities.exactFieldIdentity === 'boolean' ? capabilities.exactFieldIdentity : undefined,
200
+ verifiedFileUploads: typeof capabilities.verifiedFileUploads === 'boolean' ? capabilities.verifiedFileUploads : undefined,
201
+ binaryFraming: typeof capabilities.binaryFraming === 'boolean' ? capabilities.binaryFraming : undefined,
202
+ };
203
+ }
204
+ }
205
+ function outboundProtocolMetadata(session) {
206
+ const geometryVersion = Math.min(session.peerGeometryProtocolVersion ?? GEOMETRY_PROTOCOL_VERSION, GEOMETRY_PROTOCOL_VERSION);
207
+ const proxyActionVersion = session.peerProxyActionProtocolVersion === undefined
208
+ ? undefined
209
+ : Math.min(session.peerProxyActionProtocolVersion, PROXY_ACTION_PROTOCOL_VERSION);
210
+ if (session.peerAdvertisedSplitProtocol) {
211
+ return {
212
+ protocolVersion: proxyActionVersion ?? geometryVersion,
213
+ geometryProtocolVersion: geometryVersion,
214
+ ...(proxyActionVersion !== undefined ? { proxyActionProtocolVersion: proxyActionVersion } : {}),
215
+ };
216
+ }
217
+ return { protocolVersion: proxyActionVersion ?? geometryVersion };
218
+ }
219
+ function canonicalActionJson(value) {
220
+ if (Array.isArray(value))
221
+ return `[${value.map(canonicalActionJson).join(',')}]`;
222
+ if (value !== null && typeof value === 'object') {
223
+ const record = value;
224
+ return `{${Object.keys(record)
225
+ .sort()
226
+ .map(key => `${JSON.stringify(key)}:${canonicalActionJson(record[key])}`)
227
+ .join(',')}}`;
228
+ }
229
+ return JSON.stringify(value) ?? 'null';
230
+ }
231
+ function actionFingerprint(value) {
232
+ return createHash('sha256').update(canonicalActionJson(value)).digest('hex');
233
+ }
234
+ function isMutatingProxyAction(message) {
235
+ return typeof message.type === 'string' && MUTATING_PROXY_ACTION_TYPES.has(message.type);
236
+ }
237
+ function actionTimeoutFor(session, message, timeoutMs) {
238
+ if (session.peerTransport !== 'proxy' ||
239
+ session.peerProtocolCapabilities?.actionDeadlines !== true ||
240
+ !isMutatingProxyAction(message)) {
241
+ return undefined;
242
+ }
243
+ return timeoutMs;
244
+ }
245
+ function ambiguousOperationFor(session, fingerprint) {
246
+ return session.ambiguousOperations?.get(fingerprint) ?? session.inFlightMutations?.get(fingerprint);
247
+ }
248
+ function assertCanStartMutatingOperation(session, fingerprint) {
249
+ const operations = session.ambiguousOperations;
250
+ const trackedCount = (operations?.size ?? 0) + (session.inFlightMutations?.size ?? 0);
251
+ if (operations?.has(fingerprint) || session.inFlightMutations?.has(fingerprint) || trackedCount < MAX_AMBIGUOUS_OPERATIONS_PER_SESSION)
252
+ return;
253
+ throw new Error(`Session ${session.id} has ${trackedCount} unresolved action outcomes. ` +
254
+ 'Inspect or reconnect the session before sending another mutation; Geometra refused to risk a duplicate action.');
255
+ }
256
+ function rememberAmbiguousOperation(session, operation) {
257
+ if (session.inFlightMutations?.get(operation.fingerprint) === operation) {
258
+ session.inFlightMutations.delete(operation.fingerprint);
259
+ }
260
+ const operations = session.ambiguousOperations ?? new Map();
261
+ session.ambiguousOperations = operations;
262
+ if (!operations.has(operation.fingerprint)) {
263
+ const retainedWireBytes = Array.from(operations.values()).reduce((total, candidate) => total + retainedOperationBytes(candidate), 0);
264
+ const operationWireBytes = retainedOperationBytes(operation);
265
+ if (retainedWireBytes + operationWireBytes > MAX_AMBIGUOUS_WIRE_BYTES_PER_SESSION) {
266
+ // Retain the hash and identities so the action remains blocked, but do
267
+ // not retain potentially sensitive field values merely for ergonomics.
268
+ operation.wireMessages = [];
269
+ operation.idempotent = false;
270
+ }
271
+ }
272
+ operations.set(operation.fingerprint, operation);
273
+ }
274
+ const OMITTED_TERMINAL_RESULT = {
275
+ retained: false,
276
+ reason: 'Terminal action result exceeded the 1 MiB per-session ambiguity retention cap.',
277
+ };
278
+ function retainedCompletionBytes(operation) {
279
+ if (operation.completion?.kind !== 'result' || operation.completion.value.result === undefined)
280
+ return 0;
281
+ try {
282
+ return Buffer.byteLength(JSON.stringify(operation.completion.value.result));
283
+ }
284
+ catch {
285
+ return 0;
286
+ }
287
+ }
288
+ function retainedOperationBytes(operation) {
289
+ return operation.wireMessages.reduce((sum, wire) => sum + Buffer.byteLength(wire), 0) +
290
+ retainedCompletionBytes(operation);
291
+ }
292
+ function boundedTerminalResult(session, operation, result) {
293
+ if (!operation.permanentTombstone || result === undefined)
294
+ return result;
295
+ let resultBytes;
296
+ try {
297
+ resultBytes = Buffer.byteLength(JSON.stringify(result));
298
+ }
299
+ catch {
300
+ return OMITTED_TERMINAL_RESULT;
301
+ }
302
+ const otherOperations = new Set([
303
+ ...Array.from(session.ambiguousOperations?.values() ?? []),
304
+ ...Array.from(session.inFlightMutations?.values() ?? []),
305
+ ]);
306
+ otherOperations.delete(operation);
307
+ const retainedBytes = Array.from(otherOperations).reduce((total, candidate) => total + retainedOperationBytes(candidate), 0);
308
+ if (retainedBytes + resultBytes <= MAX_AMBIGUOUS_WIRE_BYTES_PER_SESSION)
309
+ return result;
310
+ const markerBytes = Buffer.byteLength(JSON.stringify(OMITTED_TERMINAL_RESULT));
311
+ return retainedBytes + markerBytes <= MAX_AMBIGUOUS_WIRE_BYTES_PER_SESSION
312
+ ? OMITTED_TERMINAL_RESULT
313
+ : undefined;
314
+ }
315
+ function retainTerminalResult(session, operation, value) {
316
+ operation.wireMessages = [];
317
+ const result = boundedTerminalResult(session, operation, value.result);
318
+ operation.completion = {
319
+ kind: 'result',
320
+ value: {
321
+ ...value,
322
+ ...(result !== undefined ? { result } : {}),
323
+ },
324
+ };
325
+ }
326
+ function retainTerminalError(operation, error) {
327
+ operation.wireMessages = [];
328
+ operation.completion = { kind: 'error', error };
329
+ }
330
+ function trackInFlightMutation(session, operation) {
331
+ if (!operation.mutating)
332
+ return;
333
+ const retainedWireBytes = [
334
+ ...Array.from(session.ambiguousOperations?.values() ?? []),
335
+ ...Array.from(session.inFlightMutations?.values() ?? []),
336
+ ].reduce((total, candidate) => total + retainedOperationBytes(candidate), 0);
337
+ const operationWireBytes = retainedOperationBytes(operation);
338
+ if (retainedWireBytes + operationWireBytes > MAX_AMBIGUOUS_WIRE_BYTES_PER_SESSION) {
339
+ throw new Error('In-flight action replay data would exceed the 1 MiB per-session safety cap; Geometra refused to send the mutation.');
340
+ }
341
+ const operations = session.inFlightMutations ?? new Map();
342
+ session.inFlightMutations = operations;
343
+ operations.set(operation.fingerprint, operation);
344
+ }
345
+ function forgetAmbiguousOperation(session, operation) {
346
+ if (session.ambiguousOperations?.get(operation.fingerprint) === operation) {
347
+ session.ambiguousOperations.delete(operation.fingerprint);
348
+ }
349
+ if (session.inFlightMutations?.get(operation.fingerprint) === operation) {
350
+ session.inFlightMutations.delete(operation.fingerprint);
351
+ }
352
+ }
353
+ function stickyAmbiguousError(operation, error) {
354
+ const marker = `Outcome is ambiguous for actionId ${operation.actionId} (requestId ${operation.requestId})`;
355
+ if (error instanceof GeometraWireError &&
356
+ error.requestId === operation.requestId &&
357
+ error.actionId === operation.actionId &&
358
+ error.message.includes(marker)) {
359
+ return error;
360
+ }
361
+ const sourceMessage = error instanceof Error ? error.message : String(error);
362
+ return new GeometraWireError(`${sourceMessage} ${marker}; do not retry blindly.`, error instanceof GeometraWireError
363
+ ? error.code ?? 'ACTION_OUTCOME_AMBIGUOUS'
364
+ : 'ACTION_OUTCOME_AMBIGUOUS', operation.requestId, operation.actionId);
365
+ }
366
+ function cacheLateAmbiguousOutcome(session, msg) {
367
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
368
+ if (!requestId)
369
+ return;
370
+ const tracked = [
371
+ ...Array.from(session.ambiguousOperations?.values() ?? []),
372
+ ...Array.from(session.inFlightMutations?.values() ?? []),
373
+ ];
374
+ const operation = tracked.find(candidate => candidate.requestIds.includes(requestId));
375
+ if (!operation || operation.completion)
376
+ return;
377
+ if (msg.type === 'error') {
378
+ if (msg.code === 'DUPLICATE_REQUEST')
379
+ return;
380
+ const error = wireErrorFromMessage(msg, operation.actionId);
381
+ if (typeof msg.code === 'string' &&
382
+ SAFE_NON_EXECUTION_WIRE_CODES.has(msg.code) &&
383
+ operation.requestIds.length === 1) {
384
+ retainTerminalError(operation, error);
385
+ }
386
+ else {
387
+ // A logical action may have many phases. Once any phase fails, a later
388
+ // final-phase ACK cannot prove that the whole action completed.
389
+ operation.wireMessages = [];
390
+ operation.stickyError = stickyAmbiguousError(operation, error);
391
+ }
392
+ return;
393
+ }
394
+ if (msg.type === 'ack') {
395
+ if (requestId !== operation.requestId)
396
+ return;
397
+ if (operation.stickyError)
398
+ return;
399
+ // Navigation and similar operations require a fresh post-action frame.
400
+ // A late ACK cannot establish that evidence on its own.
401
+ if (operation.requireUpdateOnAck)
402
+ return;
403
+ const peerProtocolVersion = typeof msg.proxyActionProtocolVersion === 'number'
404
+ ? msg.proxyActionProtocolVersion
405
+ : typeof msg.protocolVersion === 'number'
406
+ ? msg.protocolVersion
407
+ : session.peerProxyActionProtocolVersion;
408
+ if (operation.requiredProtocolVersion !== undefined && (peerProtocolVersion === undefined || peerProtocolVersion < operation.requiredProtocolVersion)) {
409
+ operation.wireMessages = [];
410
+ operation.stickyError = stickyAmbiguousError(operation, new Error(`Proxy protocol ${peerProtocolVersion ?? 'unknown'} cannot guarantee exact field identity; ` +
411
+ `protocol ${operation.requiredProtocolVersion}+ is required. Update and reconnect the Geometra proxy.`));
412
+ return;
413
+ }
414
+ retainTerminalResult(session, operation, {
415
+ status: 'acknowledged',
416
+ timeoutMs: operation.timeoutMs,
417
+ requestId: operation.requestId,
418
+ actionId: operation.actionId,
419
+ ...(msg.result !== undefined ? { result: msg.result } : {}),
420
+ });
421
+ return;
422
+ }
423
+ }
42
424
  function invalidateSessionCaches(session) {
43
425
  session.cachedA11y = null;
44
426
  session.cachedA11yRevision = -1;
45
427
  session.cachedFormSchemas?.clear();
46
428
  }
429
+ function invalidateSessionUiState(session) {
430
+ session.hasFreshFrame = false;
431
+ session.layout = null;
432
+ session.tree = null;
433
+ invalidateSessionCaches(session);
434
+ }
435
+ function applyInboundSessionMessage(session, data) {
436
+ const msg = parseInboundServerMessage(data);
437
+ updatePeerProtocol(session, msg);
438
+ if (msg.type === 'frame') {
439
+ session.layout = msg.layout;
440
+ session.tree = msg.tree;
441
+ session.hasFreshFrame = true;
442
+ session.updateRevision++;
443
+ invalidateSessionCaches(session);
444
+ }
445
+ else if (msg.type === 'patch' && session.hasFreshFrame === true && session.layout) {
446
+ applyPatches(session.layout, msg.patches);
447
+ session.updateRevision++;
448
+ invalidateSessionCaches(session);
449
+ }
450
+ cacheLateAmbiguousOutcome(session, msg);
451
+ return msg;
452
+ }
453
+ function assertAuthenticatedProxyHandshake(session) {
454
+ const expectedAuthenticatedProxy = session.transportAuthToken !== undefined;
455
+ const advertisedProxy = session.peerTransport === 'proxy';
456
+ if (!expectedAuthenticatedProxy && !advertisedProxy)
457
+ return;
458
+ if (session.peerTransport !== 'proxy' ||
459
+ session.peerProtocolCapabilities?.authenticatedController !== true) {
460
+ throw new Error('Geometra proxy did not attest authenticated controller support. Rebuild or update @geometra/proxy; unauthenticated proxy transports are refused.');
461
+ }
462
+ }
47
463
  function sameReusableProxyEntry(entry, proxy) {
48
464
  return ('child' in proxy && !!entry.child && entry.child === proxy.child)
49
465
  || ('runtime' in proxy && !!entry.runtime && entry.runtime === proxy.runtime);
@@ -62,6 +478,8 @@ function reusableProxyEntryIsActive(entry) {
62
478
  }
63
479
  function clearReusableProxiesIfExited() {
64
480
  reusableProxies = reusableProxies.filter(entry => {
481
+ if (entry.closed)
482
+ return false;
65
483
  if (entry.child) {
66
484
  return !entry.child.killed && entry.child.exitCode === null && entry.child.signalCode === null;
67
485
  }
@@ -76,7 +494,27 @@ function updateReusableProxySnapshotState(entry, session) {
76
494
  entry.snapshotReady = true;
77
495
  }
78
496
  }
497
+ function closeEmbeddedProxyRuntime(runtime) {
498
+ const existing = embeddedProxyClosePromises.get(runtime);
499
+ if (existing)
500
+ return existing;
501
+ let settle;
502
+ const closing = new Promise(resolve => { settle = resolve; });
503
+ // Publish the promise before invoking user/runtime code so even a
504
+ // re-entrant cleanup observes the same close operation.
505
+ embeddedProxyClosePromises.set(runtime, closing);
506
+ try {
507
+ void runtime.close().then(settle, settle);
508
+ }
509
+ catch {
510
+ settle();
511
+ }
512
+ return closing;
513
+ }
79
514
  function closeReusableProxy(entry) {
515
+ if (entry.closed)
516
+ return;
517
+ entry.closed = true;
80
518
  reusableProxies = reusableProxies.filter(candidate => candidate !== entry);
81
519
  if (entry.child) {
82
520
  try {
@@ -88,24 +526,15 @@ function closeReusableProxy(entry) {
88
526
  ensureIdleProxyTimer();
89
527
  return;
90
528
  }
91
- void entry.runtime?.close().catch(() => { });
529
+ if (entry.runtime)
530
+ void closeEmbeddedProxyRuntime(entry.runtime);
92
531
  ensureIdleProxyTimer();
93
532
  }
94
533
  function closeReusableProxies() {
95
534
  clearReusableProxiesIfExited();
96
535
  const proxies = [...reusableProxies];
97
- reusableProxies = [];
98
536
  for (const entry of proxies) {
99
- if (entry.child) {
100
- try {
101
- entry.child.kill('SIGTERM');
102
- }
103
- catch {
104
- /* ignore */
105
- }
106
- continue;
107
- }
108
- void entry.runtime?.close().catch(() => { });
537
+ closeReusableProxy(entry);
109
538
  }
110
539
  }
111
540
  function evictIdleReusableProxies() {
@@ -143,11 +572,13 @@ function enforceReusableProxyPoolLimit() {
143
572
  function setReusableProxy(proxy, wsUrl, opts) {
144
573
  clearReusableProxiesIfExited();
145
574
  const now = Date.now();
575
+ const authToken = 'child' in proxy ? proxy.authToken : proxy.runtime.authToken;
146
576
  const proxyKey = proxyKeyFor(opts.proxy);
147
577
  const stealth = resolveStealthMode(opts.stealth);
148
578
  const existing = reusableProxies.find(entry => sameReusableProxyEntry(entry, proxy));
149
579
  if (existing) {
150
580
  existing.wsUrl = wsUrl;
581
+ existing.authToken = authToken;
151
582
  existing.headless = opts.headless !== false;
152
583
  existing.stealth = stealth;
153
584
  existing.slowMo = opts.slowMo ?? 0;
@@ -164,6 +595,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
164
595
  const entry = {
165
596
  child,
166
597
  wsUrl,
598
+ authToken,
167
599
  headless: opts.headless !== false,
168
600
  stealth,
169
601
  slowMo: opts.slowMo ?? 0,
@@ -191,6 +623,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
191
623
  reusableProxies.push({
192
624
  runtime: proxy.runtime,
193
625
  wsUrl,
626
+ authToken,
194
627
  headless: opts.headless !== false,
195
628
  stealth,
196
629
  slowMo: opts.slowMo ?? 0,
@@ -236,23 +669,15 @@ function promoteDefaultSession() {
236
669
  }
237
670
  defaultSessionId = null;
238
671
  }
239
- /**
240
- * Clear `defaultSessionId` if it currently points at the given session id.
241
- * Used after tagging a fresh session as isolated — `connect()` set the
242
- * default pointer before the isolation flag was applied, and we need to
243
- * retract that assignment so this session is only addressable by its
244
- * explicit id.
245
- */
246
- function retractDefaultIfPointsTo(sessionId) {
247
- if (defaultSessionId === sessionId) {
248
- promoteDefaultSession();
249
- }
250
- }
251
672
  function shutdownSession(id, opts) {
252
673
  const prev = activeSessions.get(id);
253
674
  if (!prev)
254
675
  return;
255
- const forceCloseProxy = prev.isolated === true;
676
+ const hasUnsettledMutation = Boolean(prev.inFlightMutations?.size || prev.ambiguousOperations?.size);
677
+ prev.disposed = true;
678
+ prev.ambiguousOperations?.clear();
679
+ prev.inFlightMutations?.clear();
680
+ const forceCloseProxy = prev.isolated === true || hasUnsettledMutation;
256
681
  safeCompleteSessionLifecycle(prev, opts?.reason ?? 'disconnect', {
257
682
  closeProxy: opts?.closeProxy ?? false,
258
683
  forceCloseProxy,
@@ -261,7 +686,8 @@ function shutdownSession(id, opts) {
261
686
  if (defaultSessionId === id)
262
687
  promoteDefaultSession();
263
688
  stopSessionHeartbeat(prev);
264
- releaseSessionResources(prev, { closeProxy: opts?.closeProxy ?? false });
689
+ releaseSessionResources(prev, { closeProxy: (opts?.closeProxy ?? false) || hasUnsettledMutation });
690
+ invalidateSessionUiState(prev);
265
691
  }
266
692
  function releaseSessionResources(session, opts) {
267
693
  try {
@@ -312,19 +738,65 @@ function releaseSessionResources(session, opts) {
312
738
  closeReusableProxy(entry);
313
739
  return;
314
740
  }
315
- void session.proxyRuntime.close().catch(() => { });
741
+ void closeEmbeddedProxyRuntime(session.proxyRuntime);
316
742
  }
317
743
  }
318
- /** Evict the oldest session when at capacity. */
319
- function evictOldestSession() {
320
- if (activeSessions.size < MAX_ACTIVE_SESSIONS)
744
+ /**
745
+ * Reserve one ownership slot before any asynchronous transport or browser
746
+ * startup. Passing the same lease is idempotent, which lets an embedded
747
+ * connection hand its reservation through to `connect()` and safely
748
+ * reacquire it before a child-process fallback.
749
+ */
750
+ function reserveSessionOwnership(existing) {
751
+ if (existing?.reserved && pendingSessionOwnership.has(existing))
752
+ return existing;
753
+ pruneDisconnectedSessions();
754
+ if (activeSessions.size + pendingSessionOwnership.size >= MAX_ACTIVE_SESSIONS) {
755
+ throw new SessionCapacityError(activeSessions.size, pendingSessionOwnership.size);
756
+ }
757
+ const lease = existing ?? { id: randomUUID(), reserved: false, connectAttemptActive: false };
758
+ lease.reserved = true;
759
+ pendingSessionOwnership.add(lease);
760
+ return lease;
761
+ }
762
+ function beginSessionOwnershipAttempt(existing) {
763
+ const lease = reserveSessionOwnership(existing);
764
+ if (lease.connectAttemptActive) {
765
+ throw new Error(`Session ownership lease ${lease.id} is already assigned to a pending connection attempt`);
766
+ }
767
+ lease.connectAttemptActive = true;
768
+ return lease;
769
+ }
770
+ function releaseSessionOwnership(lease) {
771
+ if (!lease.reserved)
321
772
  return;
322
- const oldestId = activeSessions.keys().next().value;
323
- shutdownSession(oldestId, { closeProxy: false, reason: 'evicted' });
773
+ pendingSessionOwnership.delete(lease);
774
+ lease.reserved = false;
775
+ lease.connectAttemptActive = false;
776
+ }
777
+ function claimSessionOwnership(lease, session) {
778
+ if (!lease.reserved || !pendingSessionOwnership.has(lease)) {
779
+ throw new Error(`Session ownership lease ${lease.id} was not reserved when connection completed`);
780
+ }
781
+ // The transition is synchronous: no other connect can observe a moment
782
+ // where both the pending reservation and active owner are absent.
783
+ pendingSessionOwnership.delete(lease);
784
+ lease.reserved = false;
785
+ lease.connectAttemptActive = false;
786
+ activeSessions.set(session.id, session);
324
787
  }
325
788
  function formatUnknownError(err) {
326
789
  return err instanceof Error ? err.message : String(err);
327
790
  }
791
+ /**
792
+ * Durable lifecycle records only need enough URL context to identify the
793
+ * remote origin. Paths, query strings, fragments, and credentials can carry
794
+ * application data or bearer capabilities, so call sites must not forward
795
+ * them to the persistence layer.
796
+ */
797
+ function lifecycleUrlOrigin(rawUrl) {
798
+ return sanitizeUrlToOrigin(rawUrl) ?? REDACTED_STATE_URL;
799
+ }
328
800
  function rejectOnRuntimeReadyFailure(runtime) {
329
801
  return new Promise((_, reject) => {
330
802
  runtime.ready.catch(reject);
@@ -424,11 +896,8 @@ function reusableProxyMatchesOptions(entry, options) {
424
896
  function findExactReusableProxy(options) {
425
897
  clearReusableProxiesIfExited();
426
898
  return reusableProxies
427
- .filter(entry => reusableProxyMatchesOptions(entry, options))
428
- .sort((a, b) => {
429
- const activeBonus = reusableProxyEntryIsActive(b) ? 1 : reusableProxyEntryIsActive(a) ? -1 : 0;
430
- return activeBonus || b.lastUsedAt - a.lastUsedAt;
431
- })[0];
899
+ .filter(entry => !reusableProxyEntryIsActive(entry) && reusableProxyMatchesOptions(entry, options))
900
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0];
432
901
  }
433
902
  function findReusableProxy(options) {
434
903
  clearReusableProxiesIfExited();
@@ -439,7 +908,8 @@ function findReusableProxy(options) {
439
908
  const desiredHeight = options.height ?? 720;
440
909
  const desiredProxyKey = proxyKeyFor(options.proxy);
441
910
  return reusableProxies
442
- .filter(entry => entry.headless === desiredHeadless
911
+ .filter(entry => !reusableProxyEntryIsActive(entry)
912
+ && entry.headless === desiredHeadless
443
913
  && entry.stealth === desiredStealth
444
914
  && entry.slowMo === desiredSlowMo
445
915
  // Proxy partition is hard: a session with a caller-provided proxy MUST
@@ -453,8 +923,6 @@ function findReusableProxy(options) {
453
923
  value += 100;
454
924
  if (entry.width === desiredWidth && entry.height === desiredHeight)
455
925
  value += 10;
456
- if (reusableProxyEntryIsActive(entry))
457
- value += 5;
458
926
  return value;
459
927
  };
460
928
  return score(b) - score(a) || b.lastUsedAt - a.lastUsedAt;
@@ -493,7 +961,7 @@ export async function prewarmProxy(options) {
493
961
  await runtime.ready;
494
962
  }
495
963
  catch (err) {
496
- await runtime.close().catch(() => { });
964
+ await closeEmbeddedProxyRuntime(runtime);
497
965
  throw err;
498
966
  }
499
967
  setReusableProxy({ runtime }, wsUrl, {
@@ -522,7 +990,7 @@ export async function prewarmProxy(options) {
522
990
  embeddedFailure = err;
523
991
  }
524
992
  try {
525
- const { child, wsUrl } = await spawnGeometraProxy({
993
+ const { child, wsUrl, authToken } = await spawnGeometraProxy({
526
994
  pageUrl: options.pageUrl,
527
995
  port: options.port ?? 0,
528
996
  headless: options.headless,
@@ -532,7 +1000,7 @@ export async function prewarmProxy(options) {
532
1000
  stealth: options.stealth,
533
1001
  proxy: options.proxy,
534
1002
  });
535
- setReusableProxy({ child }, wsUrl, {
1003
+ setReusableProxy({ child, authToken }, wsUrl, {
536
1004
  headless: options.headless,
537
1005
  slowMo: options.slowMo,
538
1006
  stealth: options.stealth,
@@ -562,17 +1030,15 @@ async function attachToReusableProxy(proxy, options) {
562
1030
  const desiredWidth = options.width ?? proxy.width;
563
1031
  const desiredHeight = options.height ?? proxy.height;
564
1032
  const needsSnapshotKickoff = options.awaitInitialFrame !== false && !proxy.snapshotReady;
565
- let reusedExistingSession = null;
566
- for (const s of activeSessions.values()) {
567
- if ((proxy.child && s.proxyChild === proxy.child) || (proxy.runtime && s.proxyRuntime === proxy.runtime)) {
568
- reusedExistingSession = s;
569
- break;
570
- }
1033
+ if (reusableProxyEntryIsActive(proxy)) {
1034
+ throw new Error('Reusable proxy already has an active session owner');
571
1035
  }
572
- const session = reusedExistingSession ?? await connect(proxy.wsUrl, {
1036
+ const session = await connect(proxy.wsUrl, {
573
1037
  skipInitialResize: true,
574
1038
  closePreviousProxy: false,
575
1039
  awaitInitialFrame: needsSnapshotKickoff ? false : options.awaitInitialFrame,
1040
+ authToken: proxy.authToken,
1041
+ isolated: false,
576
1042
  });
577
1043
  if (!session) {
578
1044
  throw new Error('Failed to attach to reusable proxy session');
@@ -581,166 +1047,103 @@ async function attachToReusableProxy(proxy, options) {
581
1047
  session.proxyRuntime = proxy.runtime;
582
1048
  session.proxyReusable = true;
583
1049
  touchReusableProxy(proxy);
584
- let resizeKickoffMs;
585
- if (needsSnapshotKickoff || desiredWidth !== proxy.width || desiredHeight !== proxy.height) {
586
- const resizeStartedAt = performance.now();
587
- const resizeWait = await sendResizeAndWaitForUpdate(session, desiredWidth, desiredHeight, 5_000);
588
- resizeKickoffMs = performance.now() - resizeStartedAt;
589
- if (needsSnapshotKickoff && resizeWait.status === 'timed_out' && (!session.tree || !session.layout)) {
590
- throw new Error('Timed out waiting for initial proxy snapshot after resize kickoff');
591
- }
592
- proxy.width = desiredWidth;
593
- proxy.height = desiredHeight;
1050
+ try {
1051
+ let resizeKickoffMs;
1052
+ if (needsSnapshotKickoff || desiredWidth !== proxy.width || desiredHeight !== proxy.height) {
1053
+ const resizeStartedAt = performance.now();
1054
+ const resizeWait = await sendResizeAndWaitForUpdate(session, desiredWidth, desiredHeight, 5_000);
1055
+ resizeKickoffMs = performance.now() - resizeStartedAt;
1056
+ if (needsSnapshotKickoff && resizeWait.status === 'timed_out' && (!session.tree || !session.layout)) {
1057
+ throw new Error('Timed out waiting for initial proxy snapshot after resize kickoff');
1058
+ }
1059
+ proxy.width = desiredWidth;
1060
+ proxy.height = desiredHeight;
1061
+ updateReusableProxySnapshotState(proxy, session);
1062
+ }
1063
+ const currentUrl = session.cachedA11y?.meta?.pageUrl ?? proxy.pageUrl;
1064
+ let navigateMs;
1065
+ if (currentUrl !== options.pageUrl) {
1066
+ const navigateStartedAt = performance.now();
1067
+ await sendNavigate(session, options.pageUrl, 15_000);
1068
+ navigateMs = performance.now() - navigateStartedAt;
1069
+ proxy.pageUrl = options.pageUrl;
1070
+ updateReusableProxySnapshotState(proxy, session);
1071
+ }
1072
+ const baseConnectTrace = session.connectTrace;
1073
+ session.connectTrace = {
1074
+ mode: 'reused-proxy',
1075
+ reused: true,
1076
+ awaitInitialFrame: options.awaitInitialFrame !== false,
1077
+ connectMs: baseConnectTrace?.totalMs ?? 0,
1078
+ wsOpenMs: baseConnectTrace?.wsOpenMs,
1079
+ firstFrameMs: baseConnectTrace?.firstFrameMs,
1080
+ resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
1081
+ snapshotKickoff: needsSnapshotKickoff,
1082
+ resizeKickoffMs,
1083
+ navigateMs,
1084
+ totalMs: performance.now() - startedAt,
1085
+ };
594
1086
  updateReusableProxySnapshotState(proxy, session);
1087
+ safeRecordSessionSnapshot(session, 'session.proxy_attached', {
1088
+ transportMode: 'reused-proxy',
1089
+ targetPageOrigin: lifecycleUrlOrigin(options.pageUrl),
1090
+ reusedExistingSession: false,
1091
+ });
1092
+ return session;
595
1093
  }
596
- const currentUrl = session.cachedA11y?.meta?.pageUrl ?? proxy.pageUrl;
597
- let navigateMs;
598
- if (currentUrl !== options.pageUrl) {
599
- const navigateStartedAt = performance.now();
600
- await sendNavigate(session, options.pageUrl, 15_000);
601
- navigateMs = performance.now() - navigateStartedAt;
602
- proxy.pageUrl = options.pageUrl;
603
- updateReusableProxySnapshotState(proxy, session);
1094
+ catch (err) {
1095
+ // The provisional WebSocket session must not remain an active owner when
1096
+ // resize/navigation fails. Release only the session here; the caller owns
1097
+ // eviction of the stale warm proxy entry.
1098
+ shutdownSession(session.id, { closeProxy: false, reason: 'proxy_attach_failed' });
1099
+ throw err;
604
1100
  }
605
- const baseConnectTrace = !reusedExistingSession ? session.connectTrace : undefined;
606
- session.connectTrace = {
607
- mode: 'reused-proxy',
608
- reused: true,
609
- awaitInitialFrame: options.awaitInitialFrame !== false,
610
- connectMs: baseConnectTrace?.totalMs ?? 0,
611
- wsOpenMs: baseConnectTrace?.wsOpenMs,
612
- firstFrameMs: baseConnectTrace?.firstFrameMs,
613
- resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
614
- snapshotKickoff: needsSnapshotKickoff,
615
- resizeKickoffMs,
616
- navigateMs,
617
- totalMs: performance.now() - startedAt,
618
- };
619
- updateReusableProxySnapshotState(proxy, session);
620
- safeRecordSessionSnapshot(session, 'session.proxy_attached', {
621
- transportMode: 'reused-proxy',
622
- targetPageUrl: options.pageUrl,
623
- reusedExistingSession: reusedExistingSession !== null,
624
- });
625
- return session;
626
1101
  }
627
- async function startFreshProxySession(options) {
1102
+ async function startFreshProxySession(options, initialOwnershipLease) {
628
1103
  const startedAt = performance.now();
629
1104
  const eagerInitialExtract = options.eagerInitialExtract !== undefined
630
1105
  ? options.eagerInitialExtract
631
1106
  : options.awaitInitialFrame !== false
632
1107
  ? undefined
633
1108
  : false;
1109
+ let ownershipLease = reserveSessionOwnership(initialOwnershipLease);
634
1110
  let pendingEmbeddedRuntime;
1111
+ let pendingEmbeddedConnect;
1112
+ let attachedEmbeddedSession;
635
1113
  try {
636
- const proxyStartStartedAt = performance.now();
637
- const { runtime, wsUrl } = await startEmbeddedGeometraProxy({
638
- pageUrl: options.pageUrl,
639
- port: options.port ?? 0,
640
- headless: options.headless,
641
- width: options.width,
642
- height: options.height,
643
- slowMo: options.slowMo,
644
- stealth: options.stealth,
645
- eagerInitialExtract,
646
- proxy: options.proxy,
647
- });
648
- pendingEmbeddedRuntime = runtime;
649
- const proxyStartMs = performance.now() - proxyStartStartedAt;
650
- const session = await Promise.race([
651
- connect(wsUrl, {
652
- skipInitialResize: true,
653
- closePreviousProxy: false,
654
- awaitInitialFrame: options.awaitInitialFrame,
655
- }),
656
- rejectOnRuntimeReadyFailure(runtime),
657
- ]);
658
- // Connect succeeded — the session now owns the runtime, so the
659
- // catch-block cleanup below must not also close it.
660
- pendingEmbeddedRuntime = undefined;
661
- session.proxyRuntime = runtime;
662
- session.proxyReusable = !options.isolated;
663
- if (options.isolated) {
664
- session.isolated = true;
665
- // connect() already set defaultSessionId to this session. Retract
666
- // that assignment so the isolated session is only addressable by its
667
- // explicit id — the implicit-default fallback is the contamination
668
- // vector we're fixing, and an isolated session must never be the
669
- // implicit target of a tool call that omits sessionId.
670
- retractDefaultIfPointsTo(session.id);
671
- }
672
- else {
673
- setReusableProxy({ runtime }, wsUrl, {
1114
+ try {
1115
+ const proxyStartStartedAt = performance.now();
1116
+ const { runtime, wsUrl } = await startEmbeddedGeometraProxy({
1117
+ pageUrl: options.pageUrl,
1118
+ port: options.port ?? 0,
674
1119
  headless: options.headless,
675
- slowMo: options.slowMo,
676
- stealth: options.stealth,
677
1120
  width: options.width,
678
1121
  height: options.height,
679
- pageUrl: options.pageUrl,
680
- snapshotReady: Boolean(session.tree && session.layout),
1122
+ slowMo: options.slowMo,
1123
+ stealth: options.stealth,
1124
+ eagerInitialExtract,
681
1125
  proxy: options.proxy,
682
1126
  });
683
- }
684
- const baseConnectTrace = session.connectTrace;
685
- session.connectTrace = {
686
- mode: 'fresh-proxy',
687
- reused: false,
688
- awaitInitialFrame: options.awaitInitialFrame !== false,
689
- proxyStartMode: 'embedded',
690
- proxyStartMs,
691
- connectMs: baseConnectTrace?.totalMs,
692
- wsOpenMs: baseConnectTrace?.wsOpenMs,
693
- firstFrameMs: baseConnectTrace?.firstFrameMs,
694
- resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
695
- totalMs: performance.now() - startedAt,
696
- };
697
- safeRecordSessionSnapshot(session, 'session.proxy_attached', {
698
- transportMode: 'fresh-proxy',
699
- proxyStartMode: 'embedded',
700
- requestedPageUrl: options.pageUrl,
701
- isolated: options.isolated === true,
702
- });
703
- return session;
704
- }
705
- catch (e) {
706
- // If startEmbeddedGeometraProxy() returned but the subsequent connect()
707
- // threw (e.g. WebSocket failure, first-frame timeout), the runtime
708
- // still owns a launched Chromium that nobody is going to clean up.
709
- // Without this, that Chromium leaks for the lifetime of the MCP server
710
- // every time we fall through to the child-process fallback path.
711
- if (pendingEmbeddedRuntime) {
712
- const leaked = pendingEmbeddedRuntime;
713
- void leaked.close().catch(() => { });
714
- }
715
- const proxyStartStartedAt = performance.now();
716
- const { child, wsUrl } = await spawnGeometraProxy({
717
- pageUrl: options.pageUrl,
718
- port: options.port ?? 0,
719
- headless: options.headless,
720
- width: options.width,
721
- height: options.height,
722
- slowMo: options.slowMo,
723
- stealth: options.stealth,
724
- eagerInitialExtract,
725
- proxy: options.proxy,
726
- });
727
- const proxyStartMs = performance.now() - proxyStartStartedAt;
728
- try {
729
- const session = await connect(wsUrl, {
1127
+ pendingEmbeddedRuntime = runtime;
1128
+ const proxyStartMs = performance.now() - proxyStartStartedAt;
1129
+ pendingEmbeddedConnect = connectWithOwnership(wsUrl, {
730
1130
  skipInitialResize: true,
731
1131
  closePreviousProxy: false,
732
1132
  awaitInitialFrame: options.awaitInitialFrame,
1133
+ authToken: runtime.authToken,
1134
+ isolated: options.isolated === true,
1135
+ ownershipLease,
733
1136
  });
734
- session.proxyChild = child;
1137
+ const session = await Promise.race([
1138
+ pendingEmbeddedConnect,
1139
+ rejectOnRuntimeReadyFailure(runtime),
1140
+ ]);
1141
+ pendingEmbeddedConnect = undefined;
1142
+ attachedEmbeddedSession = session;
1143
+ session.proxyRuntime = runtime;
735
1144
  session.proxyReusable = !options.isolated;
736
- if (options.isolated) {
737
- session.isolated = true;
738
- // See the embedded-runtime path above — an isolated session must
739
- // not be the implicit default. Retract the pointer set by connect().
740
- retractDefaultIfPointsTo(session.id);
741
- }
742
- else {
743
- setReusableProxy({ child }, wsUrl, {
1145
+ if (!options.isolated) {
1146
+ setReusableProxy({ runtime }, wsUrl, {
744
1147
  headless: options.headless,
745
1148
  slowMo: options.slowMo,
746
1149
  stealth: options.stealth,
@@ -756,7 +1159,7 @@ async function startFreshProxySession(options) {
756
1159
  mode: 'fresh-proxy',
757
1160
  reused: false,
758
1161
  awaitInitialFrame: options.awaitInitialFrame !== false,
759
- proxyStartMode: 'child',
1162
+ proxyStartMode: 'embedded',
760
1163
  proxyStartMs,
761
1164
  connectMs: baseConnectTrace?.totalMs,
762
1165
  wsOpenMs: baseConnectTrace?.wsOpenMs,
@@ -766,39 +1169,155 @@ async function startFreshProxySession(options) {
766
1169
  };
767
1170
  safeRecordSessionSnapshot(session, 'session.proxy_attached', {
768
1171
  transportMode: 'fresh-proxy',
769
- proxyStartMode: 'child',
770
- requestedPageUrl: options.pageUrl,
1172
+ proxyStartMode: 'embedded',
1173
+ requestedPageOrigin: lifecycleUrlOrigin(options.pageUrl),
771
1174
  isolated: options.isolated === true,
772
1175
  });
773
1176
  return session;
774
1177
  }
775
- catch (fallbackError) {
1178
+ catch (embeddedFailure) {
1179
+ // `runtime.ready` can reject before the parallel WebSocket connect
1180
+ // settles. Close the runtime and await that losing connect before this
1181
+ // lease is eligible for child fallback; one reservation can never back
1182
+ // two simultaneous attempts.
1183
+ if (attachedEmbeddedSession) {
1184
+ shutdownSession(attachedEmbeddedSession.id, { closeProxy: true, reason: 'embedded_proxy_start_failed' });
1185
+ }
1186
+ if (pendingEmbeddedRuntime) {
1187
+ await closeEmbeddedProxyRuntime(pendingEmbeddedRuntime);
1188
+ }
1189
+ if (pendingEmbeddedConnect) {
1190
+ const orphanedSession = await pendingEmbeddedConnect.catch(() => undefined);
1191
+ pendingEmbeddedConnect = undefined;
1192
+ if (orphanedSession) {
1193
+ orphanedSession.proxyRuntime = pendingEmbeddedRuntime;
1194
+ orphanedSession.proxyReusable = false;
1195
+ shutdownSession(orphanedSession.id, { closeProxy: true, reason: 'embedded_proxy_ready_failed' });
1196
+ }
1197
+ }
1198
+ ownershipLease = reserveSessionOwnership(ownershipLease);
1199
+ let pendingChild;
1200
+ let attachedChildSession;
776
1201
  try {
777
- child.kill('SIGTERM');
1202
+ const proxyStartStartedAt = performance.now();
1203
+ const { child, wsUrl, authToken } = await spawnGeometraProxy({
1204
+ pageUrl: options.pageUrl,
1205
+ port: options.port ?? 0,
1206
+ headless: options.headless,
1207
+ width: options.width,
1208
+ height: options.height,
1209
+ slowMo: options.slowMo,
1210
+ stealth: options.stealth,
1211
+ eagerInitialExtract,
1212
+ proxy: options.proxy,
1213
+ });
1214
+ pendingChild = child;
1215
+ const proxyStartMs = performance.now() - proxyStartStartedAt;
1216
+ const session = await connectWithOwnership(wsUrl, {
1217
+ skipInitialResize: true,
1218
+ closePreviousProxy: false,
1219
+ awaitInitialFrame: options.awaitInitialFrame,
1220
+ authToken,
1221
+ isolated: options.isolated === true,
1222
+ ownershipLease,
1223
+ });
1224
+ attachedChildSession = session;
1225
+ session.proxyChild = child;
1226
+ session.proxyReusable = !options.isolated;
1227
+ if (!options.isolated) {
1228
+ setReusableProxy({ child, authToken }, wsUrl, {
1229
+ headless: options.headless,
1230
+ slowMo: options.slowMo,
1231
+ stealth: options.stealth,
1232
+ width: options.width,
1233
+ height: options.height,
1234
+ pageUrl: options.pageUrl,
1235
+ snapshotReady: Boolean(session.tree && session.layout),
1236
+ proxy: options.proxy,
1237
+ });
1238
+ }
1239
+ const baseConnectTrace = session.connectTrace;
1240
+ session.connectTrace = {
1241
+ mode: 'fresh-proxy',
1242
+ reused: false,
1243
+ awaitInitialFrame: options.awaitInitialFrame !== false,
1244
+ proxyStartMode: 'child',
1245
+ proxyStartMs,
1246
+ connectMs: baseConnectTrace?.totalMs,
1247
+ wsOpenMs: baseConnectTrace?.wsOpenMs,
1248
+ firstFrameMs: baseConnectTrace?.firstFrameMs,
1249
+ resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
1250
+ totalMs: performance.now() - startedAt,
1251
+ };
1252
+ safeRecordSessionSnapshot(session, 'session.proxy_attached', {
1253
+ transportMode: 'fresh-proxy',
1254
+ proxyStartMode: 'child',
1255
+ requestedPageOrigin: lifecycleUrlOrigin(options.pageUrl),
1256
+ isolated: options.isolated === true,
1257
+ });
1258
+ return session;
778
1259
  }
779
- catch {
780
- /* ignore */
1260
+ catch (fallbackError) {
1261
+ if (attachedChildSession) {
1262
+ shutdownSession(attachedChildSession.id, { closeProxy: true, reason: 'child_proxy_start_failed' });
1263
+ }
1264
+ else if (pendingChild) {
1265
+ try {
1266
+ pendingChild.kill('SIGTERM');
1267
+ }
1268
+ catch {
1269
+ /* ignore */
1270
+ }
1271
+ }
1272
+ if (fallbackError instanceof SessionCapacityError)
1273
+ throw fallbackError;
1274
+ throw new Error(`Failed to start embedded browser session: ${formatUnknownError(embeddedFailure)}\n` +
1275
+ `Child-process proxy fallback also failed: ${formatUnknownError(fallbackError)}`, { cause: fallbackError });
781
1276
  }
782
- throw fallbackError instanceof Error ? fallbackError : e;
783
1277
  }
784
1278
  }
1279
+ finally {
1280
+ releaseSessionOwnership(ownershipLease);
1281
+ }
785
1282
  }
786
1283
  /**
787
1284
  * Connect to a running Geometra server. Waits for the first frame so that
788
1285
  * layout/tree state is available immediately after connection.
789
1286
  */
790
1287
  export function connect(url, opts) {
1288
+ return connectWithOwnership(url, opts);
1289
+ }
1290
+ function connectWithOwnership(url, opts) {
791
1291
  return new Promise((resolve, reject) => {
792
1292
  const startedAt = performance.now();
793
1293
  clearReusableProxiesIfExited();
794
- evictOldestSession();
795
- const ws = new WebSocket(url);
1294
+ let ownershipLease;
1295
+ try {
1296
+ ownershipLease = beginSessionOwnershipAttempt(opts?.ownershipLease);
1297
+ }
1298
+ catch (err) {
1299
+ reject(err instanceof Error ? err : new Error(String(err)));
1300
+ return;
1301
+ }
1302
+ let ws;
1303
+ try {
1304
+ ws = new WebSocket(url, opts?.authToken
1305
+ ? { headers: { Authorization: `Bearer ${opts.authToken}` } }
1306
+ : undefined);
1307
+ }
1308
+ catch (err) {
1309
+ releaseSessionOwnership(ownershipLease);
1310
+ reject(err instanceof Error ? err : new Error(String(err)));
1311
+ return;
1312
+ }
796
1313
  const session = {
797
1314
  id: generateSessionId(),
798
1315
  ws,
799
1316
  layout: null,
800
1317
  tree: null,
801
1318
  url,
1319
+ ...(opts?.authToken ? { transportAuthToken: opts.authToken } : {}),
1320
+ isolated: opts?.isolated === true,
802
1321
  updateRevision: 0,
803
1322
  connectTrace: {
804
1323
  mode: 'direct-ws',
@@ -809,6 +1328,8 @@ export function connect(url, opts) {
809
1328
  cachedA11y: null,
810
1329
  cachedA11yRevision: -1,
811
1330
  cachedFormSchemas: new Map(),
1331
+ hasFreshFrame: false,
1332
+ disposed: false,
812
1333
  heartbeatInterval: null,
813
1334
  heartbeatLastMessageAt: Date.now(),
814
1335
  heartbeatPendingPongBy: null,
@@ -823,6 +1344,7 @@ export function connect(url, opts) {
823
1344
  catch {
824
1345
  /* ignore */
825
1346
  }
1347
+ releaseSessionOwnership(ownershipLease);
826
1348
  reject(new Error(`Failed to initialize durable session state for ${url}: ${formatUnknownError(err)}`));
827
1349
  return;
828
1350
  }
@@ -833,7 +1355,7 @@ export function connect(url, opts) {
833
1355
  const timeout = setTimeout(() => {
834
1356
  if (!resolved) {
835
1357
  safeFailSessionLifecycle(session, 'connect_timeout', {
836
- transportUrl: url,
1358
+ transportOrigin: lifecycleUrlOrigin(url),
837
1359
  timeoutMs: 10_000,
838
1360
  });
839
1361
  resolved = true;
@@ -843,6 +1365,7 @@ export function connect(url, opts) {
843
1365
  catch {
844
1366
  /* ignore */
845
1367
  }
1368
+ releaseSessionOwnership(ownershipLease);
846
1369
  reject(new Error(`Connection to ${url} timed out after 10s`));
847
1370
  }
848
1371
  }, 10_000);
@@ -855,22 +1378,24 @@ export function connect(url, opts) {
855
1378
  if (!opts?.skipInitialResize) {
856
1379
  const width = opts?.width ?? 1024;
857
1380
  const height = opts?.height ?? 768;
858
- ws.send(JSON.stringify({ type: 'resize', width, height, protocolVersion: PROXY_PROTOCOL_VERSION }));
859
- }
860
- if (opts?.awaitInitialFrame === false && !resolved) {
861
- resolved = true;
862
- clearTimeout(timeout);
863
- if (session.connectTrace) {
864
- session.connectTrace.resolvedWithoutInitialFrame = true;
865
- session.connectTrace.totalMs = performance.now() - startedAt;
1381
+ // Start with the shared GEOM v1 envelope. The first frame explicitly
1382
+ // advertises whether this is a native server or a proxy-action peer.
1383
+ // Legacy proxy v1/v2 endpoints both accept this compatibility hello.
1384
+ try {
1385
+ ws.send(JSON.stringify({ type: 'resize', width, height, protocolVersion: GEOMETRY_PROTOCOL_VERSION }));
1386
+ }
1387
+ catch (err) {
1388
+ if (resolved)
1389
+ return;
1390
+ resolved = true;
1391
+ clearTimeout(timeout);
1392
+ releaseSessionOwnership(ownershipLease);
1393
+ try {
1394
+ ws.close();
1395
+ }
1396
+ catch { /* ignore */ }
1397
+ reject(new Error(`Failed to initialize connection to ${url}: ${formatUnknownError(err)}`));
866
1398
  }
867
- activeSessions.set(session.id, session);
868
- defaultSessionId = session.id;
869
- startSessionHeartbeat(session);
870
- safeRecordSessionSnapshot(session, 'session.open', {
871
- awaitInitialFrame: false,
872
- });
873
- resolve(session);
874
1399
  }
875
1400
  });
876
1401
  ws.on('message', (data) => {
@@ -878,25 +1403,42 @@ export function connect(url, opts) {
878
1403
  if (session.ws !== ws)
879
1404
  return;
880
1405
  try {
881
- const msg = JSON.parse(String(data));
1406
+ const msg = applyInboundSessionMessage(session, data);
1407
+ if (msg.type === 'hello' || msg.type === 'frame') {
1408
+ assertAuthenticatedProxyHandshake(session);
1409
+ }
1410
+ if (msg.type === 'hello' && opts?.awaitInitialFrame === false && !resolved) {
1411
+ clearTimeout(timeout);
1412
+ if (session.connectTrace) {
1413
+ session.connectTrace.resolvedWithoutInitialFrame = true;
1414
+ session.connectTrace.totalMs = performance.now() - startedAt;
1415
+ }
1416
+ claimSessionOwnership(ownershipLease, session);
1417
+ resolved = true;
1418
+ if (!session.isolated)
1419
+ defaultSessionId = session.id;
1420
+ startSessionHeartbeat(session);
1421
+ safeRecordSessionSnapshot(session, 'session.open', {
1422
+ awaitInitialFrame: false,
1423
+ authenticatedProxyHandshake: true,
1424
+ });
1425
+ resolve(session);
1426
+ }
882
1427
  if (msg.type === 'frame') {
883
- session.layout = msg.layout;
884
- session.tree = msg.tree;
885
- session.updateRevision++;
886
- invalidateSessionCaches(session);
887
1428
  const connectTrace = session.connectTrace;
888
1429
  const firstFrame = connectTrace?.firstFrameMs === undefined;
889
1430
  if (connectTrace && connectTrace.firstFrameMs === undefined) {
890
1431
  connectTrace.firstFrameMs = performance.now() - startedAt;
891
1432
  }
892
1433
  if (!resolved) {
893
- resolved = true;
894
1434
  clearTimeout(timeout);
895
1435
  if (session.connectTrace) {
896
1436
  session.connectTrace.totalMs = performance.now() - startedAt;
897
1437
  }
898
- activeSessions.set(session.id, session);
899
- defaultSessionId = session.id;
1438
+ claimSessionOwnership(ownershipLease, session);
1439
+ resolved = true;
1440
+ if (!session.isolated)
1441
+ defaultSessionId = session.id;
900
1442
  startSessionHeartbeat(session);
901
1443
  safeRecordSessionSnapshot(session, 'session.connected');
902
1444
  resolve(session);
@@ -907,34 +1449,62 @@ export function connect(url, opts) {
907
1449
  });
908
1450
  }
909
1451
  }
910
- else if (msg.type === 'patch' && session.layout) {
911
- applyPatches(session.layout, msg.patches);
912
- session.updateRevision++;
913
- invalidateSessionCaches(session);
1452
+ else if (msg.type === 'error' && !resolved) {
1453
+ resolved = true;
1454
+ clearTimeout(timeout);
1455
+ try {
1456
+ ws.close();
1457
+ }
1458
+ catch { /* ignore */ }
1459
+ releaseSessionOwnership(ownershipLease);
1460
+ reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
1461
+ }
1462
+ }
1463
+ catch (err) {
1464
+ rememberReusableProxyPageUrl(session);
1465
+ try {
1466
+ ws.close(1002, 'Invalid protocol message');
1467
+ }
1468
+ catch { /* ignore */ }
1469
+ if (!resolved) {
1470
+ resolved = true;
1471
+ clearTimeout(timeout);
1472
+ releaseSessionOwnership(ownershipLease);
1473
+ reject(err instanceof Error ? err : new Error(String(err)));
1474
+ }
1475
+ else {
1476
+ invalidateSessionUiState(session);
914
1477
  }
915
1478
  }
916
- catch { /* ignore malformed messages */ }
917
1479
  });
918
1480
  ws.on('error', (err) => {
1481
+ if (session.ws === ws) {
1482
+ rememberReusableProxyPageUrl(session);
1483
+ invalidateSessionUiState(session);
1484
+ }
919
1485
  if (!resolved) {
920
1486
  safeFailSessionLifecycle(session, 'websocket_error', {
921
- transportUrl: url,
922
- message: err.message,
1487
+ transportOrigin: lifecycleUrlOrigin(url),
1488
+ errorCode: err.code ?? 'websocket_error',
923
1489
  });
924
1490
  resolved = true;
925
1491
  clearTimeout(timeout);
1492
+ releaseSessionOwnership(ownershipLease);
926
1493
  reject(new Error(`WebSocket error connecting to ${url}: ${err.message}`));
927
1494
  }
928
1495
  });
929
1496
  ws.on('close', () => {
930
1497
  if (session.ws !== ws)
931
1498
  return;
1499
+ rememberReusableProxyPageUrl(session);
1500
+ invalidateSessionUiState(session);
932
1501
  if (!resolved) {
933
1502
  safeFailSessionLifecycle(session, 'websocket_closed_before_ready', {
934
- transportUrl: url,
1503
+ transportOrigin: lifecycleUrlOrigin(url),
935
1504
  });
936
1505
  resolved = true;
937
1506
  clearTimeout(timeout);
1507
+ releaseSessionOwnership(ownershipLease);
938
1508
  reject(new Error(`Connection to ${url} closed before first frame`));
939
1509
  return;
940
1510
  }
@@ -952,20 +1522,22 @@ export function connect(url, opts) {
952
1522
  */
953
1523
  export async function connectThroughProxy(options) {
954
1524
  clearReusableProxiesIfExited();
1525
+ const isolated = options.isolated !== false;
1526
+ const normalizedOptions = { ...options, isolated };
955
1527
  // Isolated sessions skip the pool entirely. They always get their own
956
1528
  // brand-new Chromium and never reuse a proxy from a prior call. The
957
1529
  // tag flows down so startFreshProxySession knows to keep this proxy out
958
1530
  // of the pool on success and so shutdownSession knows to force-close it.
959
- if (options.isolated) {
960
- return await startFreshProxySession({ ...options, isolated: true });
1531
+ if (isolated) {
1532
+ const ownershipLease = reserveSessionOwnership();
1533
+ return await startFreshProxySession(normalizedOptions, ownershipLease);
961
1534
  }
962
1535
  let reuseFailure;
963
1536
  // Loop because if a candidate is currently being attached by another
964
- // concurrent connectThroughProxy call we wait for it, then re-pick. The
965
- // second pick may now find the same entry already bound to an active
966
- // session attachToReusableProxy's reusedExistingSession branch then
967
- // returns that session instead of opening a second WebSocket. Bounded
968
- // by the pool size to defend against pathological churn.
1537
+ // concurrent connectThroughProxy call we wait for it, then re-pick. Active
1538
+ // entries are never eligible: the second caller either claims another idle
1539
+ // warm browser or starts a fresh one. Bounded by the pool size to defend
1540
+ // against pathological churn.
969
1541
  for (let attempt = 0; attempt < REUSABLE_PROXY_POOL_LIMIT + 1; attempt++) {
970
1542
  const reusableProxy = findReusableProxy(options);
971
1543
  if (!reusableProxy)
@@ -980,10 +1552,16 @@ export async function connectThroughProxy(options) {
980
1552
  let releaseLock = () => { };
981
1553
  reusableProxy.attachLock = new Promise(resolve => { releaseLock = resolve; });
982
1554
  try {
1555
+ if (reusableProxyEntryIsActive(reusableProxy))
1556
+ continue;
983
1557
  return await attachToReusableProxy(reusableProxy, options);
984
1558
  }
985
1559
  catch (err) {
1560
+ if (err instanceof SessionCapacityError)
1561
+ throw err;
986
1562
  reuseFailure = err;
1563
+ if (reusableProxyEntryIsActive(reusableProxy))
1564
+ continue;
987
1565
  closeReusableProxy(reusableProxy);
988
1566
  break;
989
1567
  }
@@ -993,9 +1571,12 @@ export async function connectThroughProxy(options) {
993
1571
  }
994
1572
  }
995
1573
  try {
996
- return await startFreshProxySession(options);
1574
+ const ownershipLease = reserveSessionOwnership();
1575
+ return await startFreshProxySession(normalizedOptions, ownershipLease);
997
1576
  }
998
1577
  catch (e) {
1578
+ if (e instanceof SessionCapacityError)
1579
+ throw e;
999
1580
  if (reuseFailure) {
1000
1581
  throw new Error(`Failed to recover reusable browser session after it became stale: ${formatUnknownError(reuseFailure)}\nFresh proxy start also failed: ${formatUnknownError(e)}`, { cause: e });
1001
1582
  }
@@ -1012,13 +1593,22 @@ export function getSession(id) {
1012
1593
  export function pruneDisconnectedSessions() {
1013
1594
  const removedIds = [];
1014
1595
  for (const [id, session] of activeSessions.entries()) {
1015
- if (session.ws.readyState === WebSocket.OPEN || session.reconnectInFlight || reconnectUrlForSession(session))
1596
+ if (session.ws.readyState === WebSocket.OPEN)
1597
+ continue;
1598
+ rememberReusableProxyPageUrl(session);
1599
+ invalidateSessionUiState(session);
1600
+ if (session.reconnectInFlight || reconnectUrlForSession(session))
1016
1601
  continue;
1017
1602
  removedIds.push(id);
1603
+ session.disposed = true;
1604
+ session.ambiguousOperations?.clear();
1605
+ session.inFlightMutations?.clear();
1018
1606
  activeSessions.delete(id);
1019
1607
  if (defaultSessionId === id) {
1020
1608
  promoteDefaultSession();
1021
1609
  }
1610
+ stopSessionHeartbeat(session);
1611
+ releaseSessionResources(session, { closeProxy: true });
1022
1612
  }
1023
1613
  return removedIds;
1024
1614
  }
@@ -1059,13 +1649,28 @@ export function getDefaultSessionId() {
1059
1649
  }
1060
1650
  export function disconnect(opts) {
1061
1651
  if (opts?.sessionId) {
1062
- shutdownSession(opts.sessionId, { closeProxy: opts?.closeProxy ?? false, reason: 'disconnect' });
1652
+ if (!activeSessions.has(opts.sessionId))
1653
+ return;
1654
+ shutdownSession(opts.sessionId, { closeProxy: opts.closeProxy ?? false, reason: 'disconnect' });
1655
+ return;
1063
1656
  }
1064
- else if (defaultSessionId) {
1065
- shutdownSession(defaultSessionId, { closeProxy: opts?.closeProxy ?? false, reason: 'disconnect' });
1657
+ const resolved = resolveSession();
1658
+ if (resolved.kind === 'none')
1659
+ return;
1660
+ if (resolved.kind === 'ambiguous') {
1661
+ throw new Error(`Cannot disconnect without an explicit sessionId while session routing is ambiguous. ` +
1662
+ `Active sessions: ${resolved.activeIds.join(', ')}.`);
1066
1663
  }
1067
- if (opts?.closeProxy)
1068
- closeReusableProxies();
1664
+ if (resolved.kind === 'not_found')
1665
+ return;
1666
+ shutdownSession(resolved.session.id, { closeProxy: opts?.closeProxy ?? false, reason: 'disconnect' });
1667
+ }
1668
+ /** Process/test teardown only. User-facing disconnects are always owner-scoped. */
1669
+ export function shutdownAllSessionsAndProxies() {
1670
+ for (const id of [...activeSessions.keys()]) {
1671
+ shutdownSession(id, { closeProxy: true, reason: 'process_shutdown' });
1672
+ }
1673
+ closeReusableProxies();
1069
1674
  }
1070
1675
  function estimateFillBatchTimeout(fields) {
1071
1676
  let total = FILL_BATCH_BASE_TIMEOUT_MS;
@@ -1101,7 +1706,13 @@ function estimateFillBatchTimeout(fields) {
1101
1706
  }
1102
1707
  export function waitForUiCondition(session, predicate, timeoutMs) {
1103
1708
  return new Promise((resolve) => {
1709
+ const transport = session.ws;
1104
1710
  const check = () => {
1711
+ if (session.ws !== transport) {
1712
+ cleanup();
1713
+ resolve(false);
1714
+ return;
1715
+ }
1105
1716
  let matched;
1106
1717
  try {
1107
1718
  matched = predicate();
@@ -1127,11 +1738,11 @@ export function waitForUiCondition(session, predicate, timeoutMs) {
1127
1738
  };
1128
1739
  function cleanup() {
1129
1740
  clearTimeout(timeout);
1130
- session.ws.off('message', onMessage);
1131
- session.ws.off('close', onClose);
1741
+ transport.off('message', onMessage);
1742
+ transport.off('close', onClose);
1132
1743
  }
1133
- session.ws.on('message', onMessage);
1134
- session.ws.on('close', onClose);
1744
+ transport.on('message', onMessage);
1745
+ transport.on('close', onClose);
1135
1746
  check();
1136
1747
  });
1137
1748
  }
@@ -1148,9 +1759,19 @@ function reconnectUrlForSession(session) {
1148
1759
  }
1149
1760
  return null;
1150
1761
  }
1151
- async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
1762
+ async function openWebSocket(url, session, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS, viewport) {
1152
1763
  return await new Promise((resolve, reject) => {
1153
- const ws = new WebSocket(url);
1764
+ const ws = new WebSocket(url, session.transportAuthToken
1765
+ ? { headers: { Authorization: `Bearer ${session.transportAuthToken}` } }
1766
+ : undefined);
1767
+ // Every transport replacement must negotiate from fresh evidence. Keeping
1768
+ // the prior socket's capability flags would let a different process take
1769
+ // over the endpoint and inherit an authenticated-proxy attestation.
1770
+ session.peerTransport = undefined;
1771
+ session.peerGeometryProtocolVersion = undefined;
1772
+ session.peerProxyActionProtocolVersion = undefined;
1773
+ session.peerAdvertisedSplitProtocol = undefined;
1774
+ session.peerProtocolCapabilities = undefined;
1154
1775
  const timeout = setTimeout(() => {
1155
1776
  cleanup();
1156
1777
  try {
@@ -1159,23 +1780,61 @@ async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
1159
1780
  catch {
1160
1781
  /* ignore */
1161
1782
  }
1162
- reject(new Error(`Reconnect to ${url} timed out after ${timeoutMs}ms`));
1783
+ reject(new Error(`Reconnect handshake to ${url} timed out after ${timeoutMs}ms`));
1163
1784
  }, timeoutMs);
1164
1785
  const onOpen = () => {
1165
- cleanup();
1166
- resolve(ws);
1786
+ const width = viewport?.width ?? 1024;
1787
+ const height = viewport?.height ?? 768;
1788
+ // Current proxies attest immediately with `hello`. This compatibility
1789
+ // resize prompts native/legacy peers that only emit a frame after input.
1790
+ ws.send(JSON.stringify({
1791
+ type: 'resize',
1792
+ width,
1793
+ height,
1794
+ protocolVersion: GEOMETRY_PROTOCOL_VERSION,
1795
+ }));
1167
1796
  };
1168
1797
  const onError = (err) => {
1169
1798
  cleanup();
1170
1799
  reject(new Error(`WebSocket reconnect failed for ${url}: ${err.message}`));
1171
1800
  };
1801
+ const onClose = () => {
1802
+ cleanup();
1803
+ reject(new Error(`WebSocket reconnect to ${url} closed before capability handshake`));
1804
+ };
1805
+ const onMessage = (data) => {
1806
+ try {
1807
+ const msg = parseInboundServerMessage(data);
1808
+ updatePeerProtocol(session, msg);
1809
+ if (msg.type === 'error') {
1810
+ throw new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error');
1811
+ }
1812
+ if (msg.type !== 'hello' && msg.type !== 'frame')
1813
+ return;
1814
+ assertAuthenticatedProxyHandshake(session);
1815
+ cleanup();
1816
+ resolve({ ws, handshakeMessage: data });
1817
+ }
1818
+ catch (err) {
1819
+ cleanup();
1820
+ try {
1821
+ ws.close(1002, 'Invalid capability handshake');
1822
+ }
1823
+ catch { /* ignore */ }
1824
+ reject(err instanceof Error ? err : new Error(String(err)));
1825
+ }
1826
+ };
1172
1827
  function cleanup() {
1173
1828
  clearTimeout(timeout);
1174
1829
  ws.off('open', onOpen);
1175
1830
  ws.off('error', onError);
1831
+ ws.off('close', onClose);
1832
+ ws.off('message', onMessage);
1176
1833
  }
1177
1834
  ws.on('open', onOpen);
1178
1835
  ws.on('error', onError);
1836
+ ws.on('close', onClose);
1837
+ ws.on('message', onMessage);
1179
1838
  });
1180
1839
  }
1181
1840
  function bindReconnectedSocket(session, ws) {
@@ -1184,29 +1843,31 @@ function bindReconnectedSocket(session, ws) {
1184
1843
  if (session.ws !== ws)
1185
1844
  return;
1186
1845
  try {
1187
- const msg = JSON.parse(String(data));
1188
- if (msg.type === 'frame') {
1189
- session.layout = msg.layout;
1190
- session.tree = msg.tree;
1191
- session.updateRevision++;
1192
- invalidateSessionCaches(session);
1193
- }
1194
- else if (msg.type === 'patch' && session.layout) {
1195
- applyPatches(session.layout, msg.patches);
1196
- session.updateRevision++;
1197
- invalidateSessionCaches(session);
1198
- }
1846
+ applyInboundSessionMessage(session, data);
1199
1847
  }
1200
1848
  catch {
1201
- /* ignore malformed messages */
1849
+ rememberReusableProxyPageUrl(session);
1850
+ invalidateSessionUiState(session);
1851
+ try {
1852
+ ws.close(1002, 'Invalid protocol message');
1853
+ }
1854
+ catch { /* ignore */ }
1202
1855
  }
1203
1856
  });
1204
1857
  ws.on('pong', () => {
1205
1858
  noteSessionSocketActivity(session, ws);
1206
1859
  });
1860
+ ws.on('error', () => {
1861
+ if (session.ws !== ws)
1862
+ return;
1863
+ rememberReusableProxyPageUrl(session);
1864
+ invalidateSessionUiState(session);
1865
+ });
1207
1866
  ws.on('close', () => {
1208
1867
  if (session.ws !== ws)
1209
1868
  return;
1869
+ rememberReusableProxyPageUrl(session);
1870
+ invalidateSessionUiState(session);
1210
1871
  if (activeSessions.get(session.id) === session && !session.lifecycleFinalized) {
1211
1872
  safeRecordSessionSnapshot(session, 'session.transport_closed', {
1212
1873
  reconnectable: reconnectUrlForSession(session) !== null,
@@ -1214,7 +1875,13 @@ function bindReconnectedSocket(session, ws) {
1214
1875
  }
1215
1876
  });
1216
1877
  }
1217
- async function ensureSessionConnected(session) {
1878
+ function sessionIsDisposedOrUnowned(session) {
1879
+ return session.disposed === true || activeSessions.get(session.id) !== session;
1880
+ }
1881
+ export async function ensureSessionConnected(session) {
1882
+ if (sessionIsDisposedOrUnowned(session)) {
1883
+ throw new Error(`Session ${session.id} is disconnected or no longer owns its transport`);
1884
+ }
1218
1885
  if (session.ws.readyState === WebSocket.OPEN)
1219
1886
  return;
1220
1887
  if (session.reconnectInFlight) {
@@ -1222,15 +1889,31 @@ async function ensureSessionConnected(session) {
1222
1889
  if (!recovered) {
1223
1890
  throw new Error('Not connected');
1224
1891
  }
1892
+ if (sessionIsDisposedOrUnowned(session)) {
1893
+ throw new Error(`Session ${session.id} was disconnected while reconnecting`);
1894
+ }
1225
1895
  return;
1226
1896
  }
1227
1897
  const targetUrl = reconnectUrlForSession(session);
1228
1898
  if (!targetUrl) {
1229
1899
  throw new Error('Not connected');
1230
1900
  }
1901
+ const reconnectViewport = {
1902
+ width: typeof session.layout?.width === 'number' ? session.layout.width : 1024,
1903
+ height: typeof session.layout?.height === 'number' ? session.layout.height : 768,
1904
+ };
1905
+ rememberReusableProxyPageUrl(session);
1906
+ invalidateSessionUiState(session);
1231
1907
  const reconnectPromise = (async () => {
1232
1908
  try {
1233
- const nextWs = await openWebSocket(targetUrl);
1909
+ const { ws: nextWs, handshakeMessage } = await openWebSocket(targetUrl, session, SESSION_RECONNECT_TIMEOUT_MS, reconnectViewport);
1910
+ if (sessionIsDisposedOrUnowned(session)) {
1911
+ try {
1912
+ nextWs.close();
1913
+ }
1914
+ catch { /* ignore */ }
1915
+ throw new Error(`Session ${session.id} was disconnected while reconnecting`);
1916
+ }
1234
1917
  try {
1235
1918
  session.ws.close();
1236
1919
  }
@@ -1240,28 +1923,33 @@ async function ensureSessionConnected(session) {
1240
1923
  session.ws = nextWs;
1241
1924
  noteSessionSocketActivity(session, nextWs);
1242
1925
  bindReconnectedSocket(session, nextWs);
1926
+ applyInboundSessionMessage(session, handshakeMessage);
1243
1927
  activeSessions.set(session.id, session);
1244
1928
  if (!session.isolated) {
1245
1929
  defaultSessionId = session.id;
1246
1930
  }
1247
1931
  startSessionHeartbeat(session);
1248
1932
  safeRecordSessionSnapshot(session, 'session.reconnected', {
1249
- targetUrl,
1933
+ targetOrigin: lifecycleUrlOrigin(targetUrl),
1250
1934
  });
1251
1935
  return true;
1252
1936
  }
1253
1937
  catch (err) {
1254
1938
  safeFailSessionLifecycle(session, 'reconnect_failed', {
1255
- targetUrl,
1256
- message: formatUnknownError(err),
1939
+ targetOrigin: lifecycleUrlOrigin(targetUrl),
1940
+ errorName: err instanceof Error ? err.name : 'Error',
1257
1941
  });
1258
1942
  if (activeSessions.get(session.id) === session) {
1259
1943
  activeSessions.delete(session.id);
1260
1944
  if (defaultSessionId === session.id)
1261
1945
  promoteDefaultSession();
1262
1946
  }
1947
+ session.disposed = true;
1948
+ session.ambiguousOperations?.clear();
1949
+ session.inFlightMutations?.clear();
1263
1950
  stopSessionHeartbeat(session);
1264
1951
  releaseSessionResources(session, { closeProxy: true });
1952
+ invalidateSessionUiState(session);
1265
1953
  throw err;
1266
1954
  }
1267
1955
  })();
@@ -1276,12 +1964,16 @@ async function ensureSessionConnected(session) {
1276
1964
  if (!recovered) {
1277
1965
  throw new Error('Not connected');
1278
1966
  }
1967
+ if (sessionIsDisposedOrUnowned(session)) {
1968
+ throw new Error(`Session ${session.id} was disconnected while reconnecting`);
1969
+ }
1279
1970
  }
1280
1971
  async function sendResizeAndWaitForUpdate(session, width, height, timeoutMs = 5_000) {
1281
- await ensureSessionConnected(session);
1282
- const startRevision = session.updateRevision;
1283
- session.ws.send(JSON.stringify({ type: 'resize', width, height, protocolVersion: PROXY_PROTOCOL_VERSION }));
1284
- return await waitForNextUpdate(session, timeoutMs, undefined, startRevision);
1972
+ return await sendAndWaitForUpdate(session, {
1973
+ type: 'resize',
1974
+ width,
1975
+ height,
1976
+ }, timeoutMs);
1285
1977
  }
1286
1978
  /**
1287
1979
  * Send a click event at (x, y) and wait for the next frame/patch response.
@@ -1300,11 +1992,30 @@ export function sendClick(session, x, y, timeoutMs) {
1300
1992
  export function sendType(session, text, timeoutMs) {
1301
1993
  return (async () => {
1302
1994
  await ensureSessionConnected(session);
1303
- // Send each character as keydown + keyup
1995
+ const waitTimeoutMs = timeoutMs ?? ACTION_UPDATE_TIMEOUT_MS;
1996
+ if (session.peerTransport === 'proxy' && session.peerProtocolCapabilities?.atomicTypeText === true) {
1997
+ if (text.length > 65_536) {
1998
+ throw new Error('geometra_type text exceeds the proxy atomic typing limit of 65,536 characters');
1999
+ }
2000
+ return await sendAndWaitForUpdate(session, { type: 'typeText', text }, waitTimeoutMs);
2001
+ }
2002
+ const fingerprint = actionFingerprint({ type: 'typeSequence', text });
2003
+ const existing = ambiguousOperationFor(session, fingerprint);
2004
+ if (existing) {
2005
+ return await sendPreparedActionOperation(session, existing, waitTimeoutMs, undefined, true);
2006
+ }
2007
+ assertCanStartMutatingOperation(session, fingerprint);
2008
+ const actionId = randomUUID();
2009
+ const actionTimeoutMs = actionTimeoutFor(session, { type: 'key' }, waitTimeoutMs);
2010
+ const keyEvents = [];
2011
+ // Give every key phase its own identity. Waiting on the final key-up is
2012
+ // sufficient because the proxy processes its action queue in wire order;
2013
+ // a scoped acknowledgement for that phase therefore confirms that every
2014
+ // earlier phase in this type sequence has also finished.
1304
2015
  for (const char of text) {
1305
2016
  const keyEvent = {
1306
2017
  type: 'key',
1307
- protocolVersion: PROXY_PROTOCOL_VERSION,
2018
+ ...outboundProtocolMetadata(session),
1308
2019
  eventType: 'onKeyDown',
1309
2020
  key: char,
1310
2021
  code: `Key${char.toUpperCase()}`,
@@ -1313,11 +2024,41 @@ export function sendType(session, text, timeoutMs) {
1313
2024
  metaKey: false,
1314
2025
  altKey: false,
1315
2026
  };
1316
- session.ws.send(JSON.stringify(keyEvent));
1317
- session.ws.send(JSON.stringify({ ...keyEvent, eventType: 'onKeyUp' }));
2027
+ keyEvents.push({
2028
+ ...keyEvent,
2029
+ ...(actionTimeoutMs !== undefined ? { actionTimeoutMs } : {}),
2030
+ requestId: randomUUID(),
2031
+ });
2032
+ keyEvents.push({
2033
+ ...keyEvent,
2034
+ eventType: 'onKeyUp',
2035
+ ...(actionTimeoutMs !== undefined ? { actionTimeoutMs } : {}),
2036
+ requestId: randomUUID(),
2037
+ });
1318
2038
  }
1319
- // Wait briefly for server to process and send update
1320
- return await waitForNextUpdate(session, timeoutMs);
2039
+ if (keyEvents.length === 0) {
2040
+ return {
2041
+ status: 'acknowledged',
2042
+ timeoutMs: waitTimeoutMs,
2043
+ requestId: randomUUID(),
2044
+ actionId,
2045
+ };
2046
+ }
2047
+ const finalRequestId = keyEvents[keyEvents.length - 1].requestId;
2048
+ const operation = {
2049
+ fingerprint,
2050
+ actionId,
2051
+ requestId: finalRequestId,
2052
+ requestIds: keyEvents.map(keyEvent => keyEvent.requestId),
2053
+ wireMessages: keyEvents.map(keyEvent => JSON.stringify(keyEvent)),
2054
+ ...(actionTimeoutMs !== undefined ? { actionTimeoutMs } : {}),
2055
+ timeoutMs: waitTimeoutMs,
2056
+ idempotent: session.peerTransport === 'proxy' &&
2057
+ session.peerProtocolCapabilities?.idempotentRequestIds === true,
2058
+ mutating: true,
2059
+ };
2060
+ trackInFlightMutation(session, operation);
2061
+ return await sendPreparedActionOperation(session, operation, waitTimeoutMs);
1321
2062
  })();
1322
2063
  }
1323
2064
  /**
@@ -1337,7 +2078,9 @@ export function sendKey(session, key, modifiers, timeoutMs) {
1337
2078
  }
1338
2079
  /**
1339
2080
  * Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
1340
- * Optional `x`,`y` click opens a file chooser; omit to use the first `input[type=file]` in any frame.
2081
+ * Optional `x`,`y` click opens a file chooser. Callers must provide an explicit
2082
+ * coordinate or semantic target; MCP never intentionally requests a global
2083
+ * first-file-input fallback.
1341
2084
  */
1342
2085
  export function sendFileUpload(session, paths, opts, timeoutMs) {
1343
2086
  const payload = { type: 'file', paths };
@@ -1359,6 +2102,10 @@ export function sendFileUpload(session, paths, opts, timeoutMs) {
1359
2102
  payload.dropX = opts.drop.x;
1360
2103
  payload.dropY = opts.drop.y;
1361
2104
  }
2105
+ if (opts?.contextText !== undefined)
2106
+ payload.contextText = opts.contextText;
2107
+ if (opts?.sectionText !== undefined)
2108
+ payload.sectionText = opts.sectionText;
1362
2109
  return sendAndWaitForUpdate(session, payload, timeoutMs);
1363
2110
  }
1364
2111
  /** Set a labeled text-like field (`input`, `textarea`, contenteditable, ARIA textbox) semantically. */
@@ -1429,10 +2176,6 @@ export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE
1429
2176
  const payload = { type: 'listboxPick', label };
1430
2177
  if (opts?.exact !== undefined)
1431
2178
  payload.exact = opts.exact;
1432
- if (opts?.open) {
1433
- payload.openX = opts.open.x;
1434
- payload.openY = opts.open.y;
1435
- }
1436
2179
  if (opts?.fieldLabel)
1437
2180
  payload.fieldLabel = opts.fieldLabel;
1438
2181
  if (opts?.query)
@@ -1502,9 +2245,8 @@ export function sendNavigate(session, url, timeoutMs = 15_000) {
1502
2245
  url,
1503
2246
  }, timeoutMs, { requireUpdateOnAck: true });
1504
2247
  safeRecordSessionSnapshot(session, 'session.navigate', {
1505
- requestedUrl: url,
2248
+ requestedOrigin: lifecycleUrlOrigin(url),
1506
2249
  status: result.status,
1507
- result: result.result ?? null,
1508
2250
  });
1509
2251
  return result;
1510
2252
  })();
@@ -1559,6 +2301,11 @@ const FORM_FIELD_ROLES = new Set([
1559
2301
  'checkbox',
1560
2302
  'radio',
1561
2303
  ]);
2304
+ function isFormFieldNode(node) {
2305
+ if (node.meta?.coordinateOnly === true)
2306
+ return false;
2307
+ return FORM_FIELD_ROLES.has(node.role) || node.meta?.fileInput === true;
2308
+ }
1562
2309
  const ACTION_ROLES = new Set([
1563
2310
  'button',
1564
2311
  'link',
@@ -2176,6 +2923,8 @@ function schemaOptionLabel(node) {
2176
2923
  return sanitizeFieldName(node.name, 80) ?? sanitizeInlineName(node.name, 80);
2177
2924
  }
2178
2925
  function isGroupedChoiceControl(node) {
2926
+ if (node.meta?.coordinateOnly === true)
2927
+ return false;
2179
2928
  return node.role === 'radio' || node.role === 'checkbox' || (node.role === 'button' && node.focusable);
2180
2929
  }
2181
2930
  function groupedChoiceForNode(root, formNode, seed) {
@@ -2241,8 +2990,14 @@ function buildFieldFormat(node) {
2241
2990
  format.pattern = m.inputPattern;
2242
2991
  if (m.inputType)
2243
2992
  format.inputType = m.inputType;
2993
+ else if (m.fileInput)
2994
+ format.inputType = 'file';
2244
2995
  if (m.autocomplete)
2245
2996
  format.autocomplete = m.autocomplete;
2997
+ if (m.accept)
2998
+ format.accept = m.accept;
2999
+ if (m.multiple)
3000
+ format.multiple = true;
2246
3001
  return Object.keys(format).length > 0 ? format : undefined;
2247
3002
  }
2248
3003
  function nativeSchemaOptions(node, fieldIdentity) {
@@ -2316,6 +3071,32 @@ function simpleSchemaField(root, node) {
2316
3071
  ...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
2317
3072
  };
2318
3073
  }
3074
+ function fileSchemaField(root, node) {
3075
+ const context = nodeContextForNode(root, node);
3076
+ // Hidden dropzone inputs are commonly missing an accessible name even when
3077
+ // they retain an authored `name`/`id`. Keep them in the schema so required
3078
+ // upload fields cannot disappear; the authored identity remains the exact
3079
+ // action target and the generated fallback is display-only when no such
3080
+ // identity exists.
3081
+ const label = fieldLabel(node)
3082
+ ?? sanitizeInlineName(node.name, 80)
3083
+ ?? sanitizeInlineName(node.meta?.controlName, 80)
3084
+ ?? sanitizeInlineName(node.meta?.controlId, 80)
3085
+ ?? context?.prompt
3086
+ ?? 'Unlabeled file upload';
3087
+ const fieldKey = uniqueSchemaFieldKey(root, node);
3088
+ const format = buildFieldFormat(node);
3089
+ return {
3090
+ id: fieldKey ?? formFieldIdForPath(node.path),
3091
+ ...(fieldKey ? { fieldKey } : {}),
3092
+ kind: 'file',
3093
+ label,
3094
+ ...(node.state?.required ? { required: true } : {}),
3095
+ ...(node.state?.invalid ? { invalid: true } : {}),
3096
+ ...(format ? { format } : {}),
3097
+ ...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
3098
+ };
3099
+ }
2319
3100
  function groupedSchemaField(root, grouped) {
2320
3101
  const optionEntries = grouped.controls
2321
3102
  .map(control => ({
@@ -2408,17 +3189,25 @@ function detectFormSections(formNode, fields) {
2408
3189
  return sections;
2409
3190
  }
2410
3191
  function buildFormSchemaForNode(root, formNode, options) {
2411
- const candidates = sortByBounds(collectDescendants(formNode, candidate => candidate.role === 'textbox' ||
3192
+ const candidates = sortByBounds(collectDescendants(formNode, candidate => candidate.meta?.coordinateOnly !== true && (candidate.role === 'textbox' ||
2412
3193
  candidate.role === 'combobox' ||
2413
3194
  candidate.role === 'checkbox' ||
2414
3195
  candidate.role === 'radio' ||
2415
- (candidate.role === 'button' && candidate.focusable)));
3196
+ candidate.meta?.fileInput === true ||
3197
+ (candidate.role === 'button' && candidate.focusable))));
2416
3198
  const consumed = new Set();
2417
3199
  const fields = [];
2418
3200
  for (const candidate of candidates) {
2419
3201
  const candidateKey = pathKey(candidate.path);
2420
3202
  if (consumed.has(candidateKey))
2421
3203
  continue;
3204
+ if (candidate.meta?.fileInput === true) {
3205
+ const field = fileSchemaField(root, candidate);
3206
+ if (field)
3207
+ fields.push(field);
3208
+ consumed.add(candidateKey);
3209
+ continue;
3210
+ }
2422
3211
  if (candidate.role === 'textbox' || candidate.role === 'combobox') {
2423
3212
  const field = simpleSchemaField(root, candidate);
2424
3213
  if (field)
@@ -2713,7 +3502,7 @@ export function buildPageModel(root, options) {
2713
3502
  landmarks.push(toLandmarkModel(node));
2714
3503
  }
2715
3504
  if (node.role === 'form') {
2716
- const fields = collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role));
3505
+ const fields = collectDescendants(node, isFormFieldNode);
2717
3506
  const actions = collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable);
2718
3507
  const name = sectionDisplayName(node, 'form');
2719
3508
  forms.push({
@@ -2726,7 +3515,7 @@ export function buildPageModel(root, options) {
2726
3515
  });
2727
3516
  }
2728
3517
  if (DIALOG_ROLES.has(node.role)) {
2729
- const fields = collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role));
3518
+ const fields = collectDescendants(node, isFormFieldNode);
2730
3519
  const actions = collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable);
2731
3520
  const name = sectionDisplayName(node, 'dialog');
2732
3521
  dialogs.push({
@@ -2800,8 +3589,11 @@ export function buildFormSchemas(root, options) {
2800
3589
  ...(root.role === 'form' ? [root] : []),
2801
3590
  ...collectDescendants(root, candidate => candidate.role === 'form'),
2802
3591
  ];
2803
- // Infer forms from group/region containers with 2+ form fields (e.g. Ashby-style UIs without <form>)
2804
- const inferredForms = collectDescendants(root, candidate => {
3592
+ // Infer forms from group/region containers with 2+ form fields (e.g.
3593
+ // Ashby-style UIs without <form>). A standalone upload is also a complete
3594
+ // form surface when it is required or has a unique authored identity;
3595
+ // otherwise common one-field upload widgets disappear from the schema.
3596
+ const inferredCandidates = collectDescendants(root, candidate => {
2805
3597
  if (candidate.role !== 'group' && candidate.role !== 'region')
2806
3598
  return false;
2807
3599
  // Skip descendants of explicit forms
@@ -2811,9 +3603,38 @@ export function buildFormSchemas(root, options) {
2811
3603
  return false;
2812
3604
  }
2813
3605
  }
2814
- const fields = collectDescendants(candidate, child => FORM_FIELD_ROLES.has(child.role));
2815
- return fields.length >= 2;
3606
+ const fields = collectDescendants(candidate, isFormFieldNode);
3607
+ if (fields.length >= 2)
3608
+ return true;
3609
+ if (fields.length !== 1 || fields[0]?.meta?.fileInput !== true)
3610
+ return false;
3611
+ const file = fields[0];
3612
+ return file.state?.required === true || uniqueSchemaFieldKey(root, file) !== undefined;
2816
3613
  });
3614
+ // Nested layout groups often describe the same inferred form surface. Keep
3615
+ // one stable container for an identical field set so callers do not receive
3616
+ // duplicate formIds and then fail with an artificial ambiguous-form error.
3617
+ // Explicit native forms are intentionally untouched.
3618
+ const inferredByFieldSet = new Map();
3619
+ for (const candidate of inferredCandidates) {
3620
+ const fieldSet = collectDescendants(candidate, isFormFieldNode)
3621
+ .map(field => pathKey(field.path))
3622
+ .sort()
3623
+ .join('|');
3624
+ const current = inferredByFieldSet.get(fieldSet);
3625
+ if (!current) {
3626
+ inferredByFieldSet.set(fieldSet, candidate);
3627
+ continue;
3628
+ }
3629
+ const currentNamed = Boolean(sanitizeContainerName(current.name, 80));
3630
+ const candidateNamed = Boolean(sanitizeContainerName(candidate.name, 80));
3631
+ const candidateIsPreferred = candidateNamed !== currentNamed
3632
+ ? candidateNamed
3633
+ : candidate.path.length > current.path.length || (candidate.path.length === current.path.length && pathKey(candidate.path) < pathKey(current.path));
3634
+ if (candidateIsPreferred)
3635
+ inferredByFieldSet.set(fieldSet, candidate);
3636
+ }
3637
+ const inferredForms = [...inferredByFieldSet.values()];
2817
3638
  const forms = sortByBounds([...explicitForms, ...inferredForms]);
2818
3639
  return forms
2819
3640
  .filter(form => !options?.formId || sectionIdForPath('form', form.path) === options.formId)
@@ -2884,6 +3705,7 @@ function formSchemaFieldToFormGraphField(field, opts) {
2884
3705
  ...(field.controlType ? { controlType: field.controlType } : {}),
2885
3706
  ...(field.booleanChoice ? { booleanChoice: true } : {}),
2886
3707
  ...(field.invalid ? { invalid: true } : {}),
3708
+ ...(field.format ? { format: field.format } : {}),
2887
3709
  ...(field.context ? { context: field.context } : {}),
2888
3710
  ...(field.optionDetails ? { optionDetails: field.optionDetails } : {}),
2889
3711
  },
@@ -3036,7 +3858,7 @@ export function expandPageSection(root, id, options) {
3036
3858
  const maxTextPreview = options?.maxTextPreview ?? 6;
3037
3859
  const includeBounds = options?.includeBounds ?? false;
3038
3860
  const headingsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
3039
- const fieldsAll = sortByBounds(collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role)));
3861
+ const fieldsAll = sortByBounds(collectDescendants(node, isFormFieldNode));
3040
3862
  const actionsAll = sortByBounds(collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable));
3041
3863
  const nestedListsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'list' && pathKey(candidate.path) !== pathKey(node.path)));
3042
3864
  const itemsAll = actualKind === 'list'
@@ -3472,6 +4294,14 @@ function walkNode(element, layout, path) {
3472
4294
  meta.inputType = semantic.inputType;
3473
4295
  if (typeof semantic?.autocomplete === 'string')
3474
4296
  meta.autocomplete = semantic.autocomplete;
4297
+ if (semantic?.fileInput === true)
4298
+ meta.fileInput = true;
4299
+ if (typeof semantic?.accept === 'string' && semantic.accept.trim().length > 0)
4300
+ meta.accept = semantic.accept.trim();
4301
+ if (semantic?.multiple === true)
4302
+ meta.multiple = true;
4303
+ if (semantic?.coordinateOnly === true)
4304
+ meta.coordinateOnly = true;
3475
4305
  if (semantic?.isAutocompleteCombobox === true)
3476
4306
  meta.isAutocompleteCombobox = true;
3477
4307
  const children = [];
@@ -3575,30 +4405,186 @@ function applyPatches(layout, patches) {
3575
4405
  }
3576
4406
  async function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, opts) {
3577
4407
  await ensureSessionConnected(session);
3578
- const requestId = `req-${++nextRequestSequence}`;
3579
- const startRevision = session.updateRevision;
3580
- session.ws.send(JSON.stringify({ ...message, requestId, protocolVersion: PROXY_PROTOCOL_VERSION }));
4408
+ const includesFileMutation = message.type === 'file' || (message.type === 'fillFields' &&
4409
+ Array.isArray(message.fields) &&
4410
+ message.fields.some(field => typeof field === 'object' && field !== null && field.kind === 'file'));
4411
+ if (includesFileMutation && session.peerProtocolCapabilities?.verifiedFileUploads !== true) {
4412
+ throw new Error('file_upload_capability_required: This peer does not advertise verified file uploads. Update @geometra/proxy to v1.65.0 or newer and reconnect. No file mutation was sent.');
4413
+ }
4414
+ const fingerprint = actionFingerprint(message);
4415
+ const existing = ambiguousOperationFor(session, fingerprint);
4416
+ if (existing) {
4417
+ return await sendPreparedActionOperation(session, existing, timeoutMs, opts, true);
4418
+ }
4419
+ if (isMutatingProxyAction(message))
4420
+ assertCanStartMutatingOperation(session, fingerprint);
3581
4421
  const requiresExactFieldIdentity = typeof message.fieldKey === 'string' || (message.type === 'fillFields' &&
3582
4422
  Array.isArray(message.fields) &&
3583
4423
  message.fields.some(field => typeof field === 'object' && field !== null && typeof field.fieldKey === 'string'));
3584
- return await waitForNextUpdate(session, timeoutMs, requestId, startRevision, {
4424
+ if (requiresExactFieldIdentity && session.peerProxyActionProtocolVersion !== undefined && (session.peerProxyActionProtocolVersion < PROXY_ACTION_PROTOCOL_VERSION ||
4425
+ session.peerProtocolCapabilities?.exactFieldIdentity === false)) {
4426
+ throw new Error(`Proxy protocol ${session.peerProxyActionProtocolVersion} cannot guarantee exact field identity; protocol ${PROXY_ACTION_PROTOCOL_VERSION}+ is required. Update and reconnect the Geometra proxy.`);
4427
+ }
4428
+ const actionId = randomUUID();
4429
+ const requestId = randomUUID();
4430
+ const actionTimeoutMs = actionTimeoutFor(session, message, timeoutMs);
4431
+ const wireMessage = {
4432
+ ...message,
4433
+ ...(actionTimeoutMs !== undefined ? { actionTimeoutMs } : {}),
4434
+ requestId,
4435
+ ...outboundProtocolMetadata(session),
4436
+ };
4437
+ const operation = {
4438
+ fingerprint,
4439
+ actionId,
4440
+ requestId,
4441
+ requestIds: [requestId],
4442
+ wireMessages: [JSON.stringify(wireMessage)],
4443
+ ...(actionTimeoutMs !== undefined ? { actionTimeoutMs } : {}),
4444
+ timeoutMs,
4445
+ idempotent: session.peerTransport === 'proxy' &&
4446
+ session.peerProtocolCapabilities?.idempotentRequestIds === true,
4447
+ mutating: isMutatingProxyAction(message),
4448
+ ...(opts?.requireUpdateOnAck ? { requireUpdateOnAck: true } : {}),
4449
+ ...(requiresExactFieldIdentity ? { requiredProtocolVersion: PROXY_ACTION_PROTOCOL_VERSION } :
4450
+ opts?.requiredProtocolVersion !== undefined ? { requiredProtocolVersion: opts.requiredProtocolVersion } : {}),
4451
+ };
4452
+ trackInFlightMutation(session, operation);
4453
+ return await sendPreparedActionOperation(session, operation, timeoutMs, {
3585
4454
  ...opts,
3586
- ...(requiresExactFieldIdentity ? { requiredProtocolVersion: PROXY_PROTOCOL_VERSION } : {}),
4455
+ ...(requiresExactFieldIdentity ? { requiredProtocolVersion: PROXY_ACTION_PROTOCOL_VERSION } : {}),
3587
4456
  });
3588
4457
  }
3589
- function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision, opts) {
4458
+ async function sendPreparedActionOperation(session, operation, timeoutMs, opts, retry = false) {
4459
+ if (operation.stickyError)
4460
+ throw operation.stickyError;
4461
+ if (operation.completion) {
4462
+ if (operation.completion.kind === 'error') {
4463
+ // Completion errors are restricted to proven non-execution. Deliver
4464
+ // that correlated evidence once, then allow a genuinely fresh intent.
4465
+ forgetAmbiguousOperation(session, operation);
4466
+ throw operation.completion.error;
4467
+ }
4468
+ if (!operation.permanentTombstone)
4469
+ forgetAmbiguousOperation(session, operation);
4470
+ return operation.completion.value;
4471
+ }
4472
+ if (operation.permanentTombstone) {
4473
+ // At least one indistinguishable caller already observed an unconfirmed
4474
+ // outcome. Never replay this fingerprint within the same session.
4475
+ return {
4476
+ status: 'timed_out',
4477
+ timeoutMs: operation.timeoutMs,
4478
+ requestId: operation.requestId,
4479
+ actionId: operation.actionId,
4480
+ };
4481
+ }
4482
+ if (retry && (!operation.idempotent ||
4483
+ session.peerTransport !== 'proxy' ||
4484
+ session.peerProtocolCapabilities?.idempotentRequestIds !== true)) {
4485
+ // An older/native peer cannot guarantee that replaying an identical wire
4486
+ // message is safe. The second indistinguishable caller therefore makes
4487
+ // this identity a permanent session tombstone even before the first
4488
+ // caller settles.
4489
+ operation.permanentTombstone = true;
4490
+ rememberAmbiguousOperation(session, operation);
4491
+ return {
4492
+ status: 'timed_out',
4493
+ timeoutMs: operation.timeoutMs,
4494
+ requestId: operation.requestId,
4495
+ actionId: operation.actionId,
4496
+ };
4497
+ }
4498
+ const startRevision = session.updateRevision;
4499
+ try {
4500
+ const result = await waitForNextUpdate(session, timeoutMs, operation, startRevision, {
4501
+ ...(operation.requireUpdateOnAck ? { requireUpdateOnAck: true } : {}),
4502
+ ...(operation.requiredProtocolVersion !== undefined
4503
+ ? { requiredProtocolVersion: operation.requiredProtocolVersion }
4504
+ : {}),
4505
+ }, operation.idempotent, (transport) => {
4506
+ for (const wireMessage of operation.wireMessages)
4507
+ transport.send(wireMessage);
4508
+ });
4509
+ if (result.status === 'timed_out') {
4510
+ operation.permanentTombstone = true;
4511
+ rememberAmbiguousOperation(session, operation);
4512
+ return result;
4513
+ }
4514
+ retainTerminalResult(session, operation, result);
4515
+ if (!operation.permanentTombstone)
4516
+ forgetAmbiguousOperation(session, operation);
4517
+ return result;
4518
+ }
4519
+ catch (err) {
4520
+ const safelyDidNotExecute = err instanceof GeometraWireError &&
4521
+ err.code !== undefined &&
4522
+ SAFE_NON_EXECUTION_WIRE_CODES.has(err.code) &&
4523
+ operation.requestIds.length === 1;
4524
+ if (safelyDidNotExecute) {
4525
+ const terminalError = err instanceof Error ? err : new Error(String(err));
4526
+ retainTerminalError(operation, terminalError);
4527
+ // This caller received correlated proof that the mutation never began.
4528
+ // That delivery consumes the tombstone, even if another waiter had
4529
+ // previously timed out.
4530
+ forgetAmbiguousOperation(session, operation);
4531
+ throw terminalError;
4532
+ }
4533
+ if (operation.mutating) {
4534
+ // A transport/handler/extraction error can arrive after a browser-side
4535
+ // mutation. Preserve the exact identity so a later call cannot create
4536
+ // a fresh mutation merely because the first response was an error.
4537
+ rememberAmbiguousOperation(session, operation);
4538
+ const ambiguousError = stickyAmbiguousError(operation, err);
4539
+ operation.wireMessages = [];
4540
+ operation.stickyError = ambiguousError;
4541
+ throw ambiguousError;
4542
+ }
4543
+ else {
4544
+ forgetAmbiguousOperation(session, operation);
4545
+ }
4546
+ throw err;
4547
+ }
4548
+ }
4549
+ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, operation, startRevision = session.updateRevision, opts, ignoreDuplicateRequestError = false, sendAfterSubscribe) {
3590
4550
  return new Promise((resolve, reject) => {
4551
+ const transport = session.ws;
4552
+ const { requestId, actionId } = operation;
4553
+ const operationRequestIds = new Set(operation.requestIds);
3591
4554
  let ackSeen = false;
3592
4555
  let ackResult;
3593
- const ackPayload = () => (ackSeen && ackResult !== undefined ? { result: ackResult } : {});
4556
+ const ackPayload = () => ({
4557
+ requestId,
4558
+ actionId,
4559
+ ...(ackSeen && ackResult !== undefined ? { result: ackResult } : {}),
4560
+ });
3594
4561
  const onMessage = (data) => {
4562
+ if (session.ws !== transport)
4563
+ return;
3595
4564
  try {
3596
- const msg = JSON.parse(String(data));
4565
+ const msg = parseInboundServerMessage(data);
4566
+ updatePeerProtocol(session, msg);
3597
4567
  const messageRequestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
3598
4568
  if (requestId) {
3599
- if (msg.type === 'error' && (messageRequestId === requestId || messageRequestId === undefined)) {
4569
+ const requiresScopedAck = session.peerProtocolCapabilities?.requestScopedAcks === true;
4570
+ if (ignoreDuplicateRequestError &&
4571
+ msg.type === 'error' &&
4572
+ messageRequestId !== undefined &&
4573
+ operationRequestIds.has(messageRequestId) &&
4574
+ msg.code === 'DUPLICATE_REQUEST') {
4575
+ // The proxy has guaranteed that this replay did not mutate. Keep
4576
+ // waiting for the original action's late correlated terminal ACK.
4577
+ return;
4578
+ }
4579
+ if (operation.stickyError) {
3600
4580
  cleanup();
3601
- reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
4581
+ reject(operation.stickyError);
4582
+ return;
4583
+ }
4584
+ if (msg.type === 'error' && ((messageRequestId !== undefined && operationRequestIds.has(messageRequestId)) ||
4585
+ (!requiresScopedAck && messageRequestId === undefined))) {
4586
+ cleanup();
4587
+ reject(wireErrorFromMessage(msg, actionId));
3602
4588
  return;
3603
4589
  }
3604
4590
  if ((msg.type === 'frame' || (msg.type === 'patch' && session.layout)) && ackSeen && session.updateRevision > startRevision) {
@@ -3611,10 +4597,18 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
3611
4597
  return;
3612
4598
  }
3613
4599
  if (msg.type === 'ack' && messageRequestId === requestId) {
3614
- const peerProtocolVersion = typeof msg.protocolVersion === 'number' ? msg.protocolVersion : undefined;
4600
+ const peerProtocolVersion = typeof msg.proxyActionProtocolVersion === 'number'
4601
+ ? msg.proxyActionProtocolVersion
4602
+ : typeof msg.protocolVersion === 'number'
4603
+ ? msg.protocolVersion
4604
+ : session.peerProxyActionProtocolVersion;
3615
4605
  if (opts?.requiredProtocolVersion !== undefined && (peerProtocolVersion === undefined || peerProtocolVersion < opts.requiredProtocolVersion)) {
3616
4606
  cleanup();
3617
- reject(new Error(`Proxy protocol ${peerProtocolVersion ?? 'unknown'} cannot guarantee exact field identity; protocol ${opts.requiredProtocolVersion}+ is required. Update and reconnect the Geometra proxy.`));
4607
+ const protocolError = stickyAmbiguousError(operation, new Error(`Proxy protocol ${peerProtocolVersion ?? 'unknown'} cannot guarantee exact field identity; protocol ${opts.requiredProtocolVersion}+ is required. Update and reconnect the Geometra proxy.`));
4608
+ operation.stickyError = protocolError;
4609
+ if (operation.mutating)
4610
+ rememberAmbiguousOperation(session, operation);
4611
+ reject(protocolError);
3618
4612
  return;
3619
4613
  }
3620
4614
  ackSeen = true;
@@ -3632,45 +4626,95 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
3632
4626
  }
3633
4627
  if (msg.type === 'error') {
3634
4628
  cleanup();
3635
- reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
4629
+ reject(wireErrorFromMessage(msg, actionId));
3636
4630
  return;
3637
4631
  }
3638
4632
  if (msg.type === 'frame') {
3639
4633
  cleanup();
3640
- resolve({ status: 'updated', timeoutMs });
4634
+ resolve({ status: 'updated', timeoutMs, ...ackPayload() });
3641
4635
  }
3642
4636
  else if (msg.type === 'patch' && session.layout) {
3643
4637
  cleanup();
3644
- resolve({ status: 'updated', timeoutMs });
4638
+ resolve({ status: 'updated', timeoutMs, ...ackPayload() });
3645
4639
  }
3646
4640
  else if (msg.type === 'ack') {
3647
4641
  cleanup();
3648
4642
  resolve({
3649
4643
  status: 'acknowledged',
3650
4644
  timeoutMs,
4645
+ requestId,
4646
+ actionId,
3651
4647
  ...(msg.result !== undefined ? { result: msg.result } : {}),
3652
4648
  });
3653
4649
  }
3654
4650
  }
3655
- catch { /* ignore */ }
4651
+ catch (err) {
4652
+ cleanup();
4653
+ reject(err instanceof Error ? err : new Error(String(err)));
4654
+ }
3656
4655
  };
3657
4656
  // Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
3658
4657
  const timeout = setTimeout(() => {
3659
4658
  cleanup();
3660
- if (requestId && session.updateRevision > startRevision) {
4659
+ if (session.ws !== transport) {
4660
+ if (operation.mutating)
4661
+ rememberAmbiguousOperation(session, operation);
4662
+ resolve({ status: 'timed_out', timeoutMs, requestId, actionId });
4663
+ return;
4664
+ }
4665
+ const requiresScopedAck = session.peerProtocolCapabilities?.requestScopedAcks === true;
4666
+ if (requestId && !requiresScopedAck && session.updateRevision > startRevision) {
3661
4667
  resolve({ status: 'updated', timeoutMs, ...ackPayload() });
3662
4668
  return;
3663
4669
  }
3664
- if (requestId && ackSeen) {
4670
+ if (requestId && ackSeen && (!opts?.requireUpdateOnAck || session.updateRevision > startRevision)) {
3665
4671
  resolve({ status: 'acknowledged', timeoutMs, ...ackPayload() });
3666
4672
  return;
3667
4673
  }
3668
- resolve({ status: 'timed_out', timeoutMs });
4674
+ if (operation.mutating)
4675
+ rememberAmbiguousOperation(session, operation);
4676
+ resolve({ status: 'timed_out', timeoutMs, requestId, actionId });
3669
4677
  }, timeoutMs);
4678
+ const onClose = () => {
4679
+ cleanup();
4680
+ if (operation.mutating)
4681
+ rememberAmbiguousOperation(session, operation);
4682
+ reject(new GeometraWireError('Action transport closed before a correlated terminal response arrived', 'TRANSPORT_CLOSED', requestId, actionId));
4683
+ };
4684
+ const onError = (error) => {
4685
+ cleanup();
4686
+ if (operation.mutating)
4687
+ rememberAmbiguousOperation(session, operation);
4688
+ reject(new GeometraWireError(`Action transport failed before a correlated terminal response arrived: ${error.message}`, 'TRANSPORT_ERROR', requestId, actionId));
4689
+ };
3670
4690
  function cleanup() {
3671
4691
  clearTimeout(timeout);
3672
- session.ws.off('message', onMessage);
4692
+ transport.off('message', onMessage);
4693
+ transport.off('close', onClose);
4694
+ transport.off('error', onError);
4695
+ }
4696
+ transport.on('message', onMessage);
4697
+ transport.on('close', onClose);
4698
+ transport.on('error', onError);
4699
+ if (operation.completion) {
4700
+ cleanup();
4701
+ if (operation.completion.kind === 'error')
4702
+ reject(operation.completion.error);
4703
+ else
4704
+ resolve(operation.completion.value);
4705
+ return;
4706
+ }
4707
+ if (sendAfterSubscribe) {
4708
+ try {
4709
+ if (session.ws !== transport || transport.readyState !== WebSocket.OPEN) {
4710
+ throw new Error('Action transport changed before the request could be sent');
4711
+ }
4712
+ sendAfterSubscribe(transport);
4713
+ }
4714
+ catch (err) {
4715
+ cleanup();
4716
+ reject(err instanceof Error ? err : new Error(String(err)));
4717
+ }
3673
4718
  }
3674
- session.ws.on('message', onMessage);
3675
4719
  });
3676
4720
  }