@geometra/mcp 1.63.2 → 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,12 +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
- 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
+ }
41
424
  function invalidateSessionCaches(session) {
42
425
  session.cachedA11y = null;
43
426
  session.cachedA11yRevision = -1;
44
427
  session.cachedFormSchemas?.clear();
45
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
+ }
46
463
  function sameReusableProxyEntry(entry, proxy) {
47
464
  return ('child' in proxy && !!entry.child && entry.child === proxy.child)
48
465
  || ('runtime' in proxy && !!entry.runtime && entry.runtime === proxy.runtime);
@@ -61,6 +478,8 @@ function reusableProxyEntryIsActive(entry) {
61
478
  }
62
479
  function clearReusableProxiesIfExited() {
63
480
  reusableProxies = reusableProxies.filter(entry => {
481
+ if (entry.closed)
482
+ return false;
64
483
  if (entry.child) {
65
484
  return !entry.child.killed && entry.child.exitCode === null && entry.child.signalCode === null;
66
485
  }
@@ -75,7 +494,27 @@ function updateReusableProxySnapshotState(entry, session) {
75
494
  entry.snapshotReady = true;
76
495
  }
77
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
+ }
78
514
  function closeReusableProxy(entry) {
515
+ if (entry.closed)
516
+ return;
517
+ entry.closed = true;
79
518
  reusableProxies = reusableProxies.filter(candidate => candidate !== entry);
80
519
  if (entry.child) {
81
520
  try {
@@ -87,24 +526,15 @@ function closeReusableProxy(entry) {
87
526
  ensureIdleProxyTimer();
88
527
  return;
89
528
  }
90
- void entry.runtime?.close().catch(() => { });
529
+ if (entry.runtime)
530
+ void closeEmbeddedProxyRuntime(entry.runtime);
91
531
  ensureIdleProxyTimer();
92
532
  }
93
533
  function closeReusableProxies() {
94
534
  clearReusableProxiesIfExited();
95
535
  const proxies = [...reusableProxies];
96
- reusableProxies = [];
97
536
  for (const entry of proxies) {
98
- if (entry.child) {
99
- try {
100
- entry.child.kill('SIGTERM');
101
- }
102
- catch {
103
- /* ignore */
104
- }
105
- continue;
106
- }
107
- void entry.runtime?.close().catch(() => { });
537
+ closeReusableProxy(entry);
108
538
  }
109
539
  }
110
540
  function evictIdleReusableProxies() {
@@ -142,11 +572,13 @@ function enforceReusableProxyPoolLimit() {
142
572
  function setReusableProxy(proxy, wsUrl, opts) {
143
573
  clearReusableProxiesIfExited();
144
574
  const now = Date.now();
575
+ const authToken = 'child' in proxy ? proxy.authToken : proxy.runtime.authToken;
145
576
  const proxyKey = proxyKeyFor(opts.proxy);
146
577
  const stealth = resolveStealthMode(opts.stealth);
147
578
  const existing = reusableProxies.find(entry => sameReusableProxyEntry(entry, proxy));
148
579
  if (existing) {
149
580
  existing.wsUrl = wsUrl;
581
+ existing.authToken = authToken;
150
582
  existing.headless = opts.headless !== false;
151
583
  existing.stealth = stealth;
152
584
  existing.slowMo = opts.slowMo ?? 0;
@@ -163,6 +595,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
163
595
  const entry = {
164
596
  child,
165
597
  wsUrl,
598
+ authToken,
166
599
  headless: opts.headless !== false,
167
600
  stealth,
168
601
  slowMo: opts.slowMo ?? 0,
@@ -190,6 +623,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
190
623
  reusableProxies.push({
191
624
  runtime: proxy.runtime,
192
625
  wsUrl,
626
+ authToken,
193
627
  headless: opts.headless !== false,
194
628
  stealth,
195
629
  slowMo: opts.slowMo ?? 0,
@@ -235,23 +669,15 @@ function promoteDefaultSession() {
235
669
  }
236
670
  defaultSessionId = null;
237
671
  }
238
- /**
239
- * Clear `defaultSessionId` if it currently points at the given session id.
240
- * Used after tagging a fresh session as isolated — `connect()` set the
241
- * default pointer before the isolation flag was applied, and we need to
242
- * retract that assignment so this session is only addressable by its
243
- * explicit id.
244
- */
245
- function retractDefaultIfPointsTo(sessionId) {
246
- if (defaultSessionId === sessionId) {
247
- promoteDefaultSession();
248
- }
249
- }
250
672
  function shutdownSession(id, opts) {
251
673
  const prev = activeSessions.get(id);
252
674
  if (!prev)
253
675
  return;
254
- 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;
255
681
  safeCompleteSessionLifecycle(prev, opts?.reason ?? 'disconnect', {
256
682
  closeProxy: opts?.closeProxy ?? false,
257
683
  forceCloseProxy,
@@ -260,7 +686,8 @@ function shutdownSession(id, opts) {
260
686
  if (defaultSessionId === id)
261
687
  promoteDefaultSession();
262
688
  stopSessionHeartbeat(prev);
263
- releaseSessionResources(prev, { closeProxy: opts?.closeProxy ?? false });
689
+ releaseSessionResources(prev, { closeProxy: (opts?.closeProxy ?? false) || hasUnsettledMutation });
690
+ invalidateSessionUiState(prev);
264
691
  }
265
692
  function releaseSessionResources(session, opts) {
266
693
  try {
@@ -311,19 +738,65 @@ function releaseSessionResources(session, opts) {
311
738
  closeReusableProxy(entry);
312
739
  return;
313
740
  }
314
- void session.proxyRuntime.close().catch(() => { });
741
+ void closeEmbeddedProxyRuntime(session.proxyRuntime);
742
+ }
743
+ }
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);
315
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;
316
769
  }
317
- /** Evict the oldest session when at capacity. */
318
- function evictOldestSession() {
319
- if (activeSessions.size < MAX_ACTIVE_SESSIONS)
770
+ function releaseSessionOwnership(lease) {
771
+ if (!lease.reserved)
320
772
  return;
321
- const oldestId = activeSessions.keys().next().value;
322
- 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);
323
787
  }
324
788
  function formatUnknownError(err) {
325
789
  return err instanceof Error ? err.message : String(err);
326
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
+ }
327
800
  function rejectOnRuntimeReadyFailure(runtime) {
328
801
  return new Promise((_, reject) => {
329
802
  runtime.ready.catch(reject);
@@ -423,11 +896,8 @@ function reusableProxyMatchesOptions(entry, options) {
423
896
  function findExactReusableProxy(options) {
424
897
  clearReusableProxiesIfExited();
425
898
  return reusableProxies
426
- .filter(entry => reusableProxyMatchesOptions(entry, options))
427
- .sort((a, b) => {
428
- const activeBonus = reusableProxyEntryIsActive(b) ? 1 : reusableProxyEntryIsActive(a) ? -1 : 0;
429
- return activeBonus || b.lastUsedAt - a.lastUsedAt;
430
- })[0];
899
+ .filter(entry => !reusableProxyEntryIsActive(entry) && reusableProxyMatchesOptions(entry, options))
900
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0];
431
901
  }
432
902
  function findReusableProxy(options) {
433
903
  clearReusableProxiesIfExited();
@@ -438,7 +908,8 @@ function findReusableProxy(options) {
438
908
  const desiredHeight = options.height ?? 720;
439
909
  const desiredProxyKey = proxyKeyFor(options.proxy);
440
910
  return reusableProxies
441
- .filter(entry => entry.headless === desiredHeadless
911
+ .filter(entry => !reusableProxyEntryIsActive(entry)
912
+ && entry.headless === desiredHeadless
442
913
  && entry.stealth === desiredStealth
443
914
  && entry.slowMo === desiredSlowMo
444
915
  // Proxy partition is hard: a session with a caller-provided proxy MUST
@@ -452,8 +923,6 @@ function findReusableProxy(options) {
452
923
  value += 100;
453
924
  if (entry.width === desiredWidth && entry.height === desiredHeight)
454
925
  value += 10;
455
- if (reusableProxyEntryIsActive(entry))
456
- value += 5;
457
926
  return value;
458
927
  };
459
928
  return score(b) - score(a) || b.lastUsedAt - a.lastUsedAt;
@@ -492,7 +961,7 @@ export async function prewarmProxy(options) {
492
961
  await runtime.ready;
493
962
  }
494
963
  catch (err) {
495
- await runtime.close().catch(() => { });
964
+ await closeEmbeddedProxyRuntime(runtime);
496
965
  throw err;
497
966
  }
498
967
  setReusableProxy({ runtime }, wsUrl, {
@@ -521,7 +990,7 @@ export async function prewarmProxy(options) {
521
990
  embeddedFailure = err;
522
991
  }
523
992
  try {
524
- const { child, wsUrl } = await spawnGeometraProxy({
993
+ const { child, wsUrl, authToken } = await spawnGeometraProxy({
525
994
  pageUrl: options.pageUrl,
526
995
  port: options.port ?? 0,
527
996
  headless: options.headless,
@@ -531,7 +1000,7 @@ export async function prewarmProxy(options) {
531
1000
  stealth: options.stealth,
532
1001
  proxy: options.proxy,
533
1002
  });
534
- setReusableProxy({ child }, wsUrl, {
1003
+ setReusableProxy({ child, authToken }, wsUrl, {
535
1004
  headless: options.headless,
536
1005
  slowMo: options.slowMo,
537
1006
  stealth: options.stealth,
@@ -561,17 +1030,15 @@ async function attachToReusableProxy(proxy, options) {
561
1030
  const desiredWidth = options.width ?? proxy.width;
562
1031
  const desiredHeight = options.height ?? proxy.height;
563
1032
  const needsSnapshotKickoff = options.awaitInitialFrame !== false && !proxy.snapshotReady;
564
- let reusedExistingSession = null;
565
- for (const s of activeSessions.values()) {
566
- if ((proxy.child && s.proxyChild === proxy.child) || (proxy.runtime && s.proxyRuntime === proxy.runtime)) {
567
- reusedExistingSession = s;
568
- break;
569
- }
1033
+ if (reusableProxyEntryIsActive(proxy)) {
1034
+ throw new Error('Reusable proxy already has an active session owner');
570
1035
  }
571
- const session = reusedExistingSession ?? await connect(proxy.wsUrl, {
1036
+ const session = await connect(proxy.wsUrl, {
572
1037
  skipInitialResize: true,
573
1038
  closePreviousProxy: false,
574
1039
  awaitInitialFrame: needsSnapshotKickoff ? false : options.awaitInitialFrame,
1040
+ authToken: proxy.authToken,
1041
+ isolated: false,
575
1042
  });
576
1043
  if (!session) {
577
1044
  throw new Error('Failed to attach to reusable proxy session');
@@ -580,166 +1047,103 @@ async function attachToReusableProxy(proxy, options) {
580
1047
  session.proxyRuntime = proxy.runtime;
581
1048
  session.proxyReusable = true;
582
1049
  touchReusableProxy(proxy);
583
- let resizeKickoffMs;
584
- if (needsSnapshotKickoff || desiredWidth !== proxy.width || desiredHeight !== proxy.height) {
585
- const resizeStartedAt = performance.now();
586
- const resizeWait = await sendResizeAndWaitForUpdate(session, desiredWidth, desiredHeight, 5_000);
587
- resizeKickoffMs = performance.now() - resizeStartedAt;
588
- if (needsSnapshotKickoff && resizeWait.status === 'timed_out' && (!session.tree || !session.layout)) {
589
- throw new Error('Timed out waiting for initial proxy snapshot after resize kickoff');
590
- }
591
- proxy.width = desiredWidth;
592
- 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
+ };
593
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;
594
1093
  }
595
- const currentUrl = session.cachedA11y?.meta?.pageUrl ?? proxy.pageUrl;
596
- let navigateMs;
597
- if (currentUrl !== options.pageUrl) {
598
- const navigateStartedAt = performance.now();
599
- await sendNavigate(session, options.pageUrl, 15_000);
600
- navigateMs = performance.now() - navigateStartedAt;
601
- proxy.pageUrl = options.pageUrl;
602
- 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;
603
1100
  }
604
- const baseConnectTrace = !reusedExistingSession ? session.connectTrace : undefined;
605
- session.connectTrace = {
606
- mode: 'reused-proxy',
607
- reused: true,
608
- awaitInitialFrame: options.awaitInitialFrame !== false,
609
- connectMs: baseConnectTrace?.totalMs ?? 0,
610
- wsOpenMs: baseConnectTrace?.wsOpenMs,
611
- firstFrameMs: baseConnectTrace?.firstFrameMs,
612
- resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
613
- snapshotKickoff: needsSnapshotKickoff,
614
- resizeKickoffMs,
615
- navigateMs,
616
- totalMs: performance.now() - startedAt,
617
- };
618
- updateReusableProxySnapshotState(proxy, session);
619
- safeRecordSessionSnapshot(session, 'session.proxy_attached', {
620
- transportMode: 'reused-proxy',
621
- targetPageUrl: options.pageUrl,
622
- reusedExistingSession: reusedExistingSession !== null,
623
- });
624
- return session;
625
1101
  }
626
- async function startFreshProxySession(options) {
1102
+ async function startFreshProxySession(options, initialOwnershipLease) {
627
1103
  const startedAt = performance.now();
628
1104
  const eagerInitialExtract = options.eagerInitialExtract !== undefined
629
1105
  ? options.eagerInitialExtract
630
1106
  : options.awaitInitialFrame !== false
631
1107
  ? undefined
632
1108
  : false;
1109
+ let ownershipLease = reserveSessionOwnership(initialOwnershipLease);
633
1110
  let pendingEmbeddedRuntime;
1111
+ let pendingEmbeddedConnect;
1112
+ let attachedEmbeddedSession;
634
1113
  try {
635
- const proxyStartStartedAt = performance.now();
636
- const { runtime, wsUrl } = await startEmbeddedGeometraProxy({
637
- pageUrl: options.pageUrl,
638
- port: options.port ?? 0,
639
- headless: options.headless,
640
- width: options.width,
641
- height: options.height,
642
- slowMo: options.slowMo,
643
- stealth: options.stealth,
644
- eagerInitialExtract,
645
- proxy: options.proxy,
646
- });
647
- pendingEmbeddedRuntime = runtime;
648
- const proxyStartMs = performance.now() - proxyStartStartedAt;
649
- const session = await Promise.race([
650
- connect(wsUrl, {
651
- skipInitialResize: true,
652
- closePreviousProxy: false,
653
- awaitInitialFrame: options.awaitInitialFrame,
654
- }),
655
- rejectOnRuntimeReadyFailure(runtime),
656
- ]);
657
- // Connect succeeded — the session now owns the runtime, so the
658
- // catch-block cleanup below must not also close it.
659
- pendingEmbeddedRuntime = undefined;
660
- session.proxyRuntime = runtime;
661
- session.proxyReusable = !options.isolated;
662
- if (options.isolated) {
663
- session.isolated = true;
664
- // connect() already set defaultSessionId to this session. Retract
665
- // that assignment so the isolated session is only addressable by its
666
- // explicit id — the implicit-default fallback is the contamination
667
- // vector we're fixing, and an isolated session must never be the
668
- // implicit target of a tool call that omits sessionId.
669
- retractDefaultIfPointsTo(session.id);
670
- }
671
- else {
672
- 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,
673
1119
  headless: options.headless,
674
- slowMo: options.slowMo,
675
- stealth: options.stealth,
676
1120
  width: options.width,
677
1121
  height: options.height,
678
- pageUrl: options.pageUrl,
679
- snapshotReady: Boolean(session.tree && session.layout),
1122
+ slowMo: options.slowMo,
1123
+ stealth: options.stealth,
1124
+ eagerInitialExtract,
680
1125
  proxy: options.proxy,
681
1126
  });
682
- }
683
- const baseConnectTrace = session.connectTrace;
684
- session.connectTrace = {
685
- mode: 'fresh-proxy',
686
- reused: false,
687
- awaitInitialFrame: options.awaitInitialFrame !== false,
688
- proxyStartMode: 'embedded',
689
- proxyStartMs,
690
- connectMs: baseConnectTrace?.totalMs,
691
- wsOpenMs: baseConnectTrace?.wsOpenMs,
692
- firstFrameMs: baseConnectTrace?.firstFrameMs,
693
- resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
694
- totalMs: performance.now() - startedAt,
695
- };
696
- safeRecordSessionSnapshot(session, 'session.proxy_attached', {
697
- transportMode: 'fresh-proxy',
698
- proxyStartMode: 'embedded',
699
- requestedPageUrl: options.pageUrl,
700
- isolated: options.isolated === true,
701
- });
702
- return session;
703
- }
704
- catch (e) {
705
- // If startEmbeddedGeometraProxy() returned but the subsequent connect()
706
- // threw (e.g. WebSocket failure, first-frame timeout), the runtime
707
- // still owns a launched Chromium that nobody is going to clean up.
708
- // Without this, that Chromium leaks for the lifetime of the MCP server
709
- // every time we fall through to the child-process fallback path.
710
- if (pendingEmbeddedRuntime) {
711
- const leaked = pendingEmbeddedRuntime;
712
- void leaked.close().catch(() => { });
713
- }
714
- const proxyStartStartedAt = performance.now();
715
- const { child, wsUrl } = await spawnGeometraProxy({
716
- pageUrl: options.pageUrl,
717
- port: options.port ?? 0,
718
- headless: options.headless,
719
- width: options.width,
720
- height: options.height,
721
- slowMo: options.slowMo,
722
- stealth: options.stealth,
723
- eagerInitialExtract,
724
- proxy: options.proxy,
725
- });
726
- const proxyStartMs = performance.now() - proxyStartStartedAt;
727
- try {
728
- const session = await connect(wsUrl, {
1127
+ pendingEmbeddedRuntime = runtime;
1128
+ const proxyStartMs = performance.now() - proxyStartStartedAt;
1129
+ pendingEmbeddedConnect = connectWithOwnership(wsUrl, {
729
1130
  skipInitialResize: true,
730
1131
  closePreviousProxy: false,
731
1132
  awaitInitialFrame: options.awaitInitialFrame,
1133
+ authToken: runtime.authToken,
1134
+ isolated: options.isolated === true,
1135
+ ownershipLease,
732
1136
  });
733
- 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;
734
1144
  session.proxyReusable = !options.isolated;
735
- if (options.isolated) {
736
- session.isolated = true;
737
- // See the embedded-runtime path above — an isolated session must
738
- // not be the implicit default. Retract the pointer set by connect().
739
- retractDefaultIfPointsTo(session.id);
740
- }
741
- else {
742
- setReusableProxy({ child }, wsUrl, {
1145
+ if (!options.isolated) {
1146
+ setReusableProxy({ runtime }, wsUrl, {
743
1147
  headless: options.headless,
744
1148
  slowMo: options.slowMo,
745
1149
  stealth: options.stealth,
@@ -755,7 +1159,7 @@ async function startFreshProxySession(options) {
755
1159
  mode: 'fresh-proxy',
756
1160
  reused: false,
757
1161
  awaitInitialFrame: options.awaitInitialFrame !== false,
758
- proxyStartMode: 'child',
1162
+ proxyStartMode: 'embedded',
759
1163
  proxyStartMs,
760
1164
  connectMs: baseConnectTrace?.totalMs,
761
1165
  wsOpenMs: baseConnectTrace?.wsOpenMs,
@@ -765,39 +1169,155 @@ async function startFreshProxySession(options) {
765
1169
  };
766
1170
  safeRecordSessionSnapshot(session, 'session.proxy_attached', {
767
1171
  transportMode: 'fresh-proxy',
768
- proxyStartMode: 'child',
769
- requestedPageUrl: options.pageUrl,
1172
+ proxyStartMode: 'embedded',
1173
+ requestedPageOrigin: lifecycleUrlOrigin(options.pageUrl),
770
1174
  isolated: options.isolated === true,
771
1175
  });
772
1176
  return session;
773
1177
  }
774
- 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;
775
1201
  try {
776
- 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;
777
1259
  }
778
- catch {
779
- /* 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 });
780
1276
  }
781
- throw fallbackError instanceof Error ? fallbackError : e;
782
1277
  }
783
1278
  }
1279
+ finally {
1280
+ releaseSessionOwnership(ownershipLease);
1281
+ }
784
1282
  }
785
1283
  /**
786
1284
  * Connect to a running Geometra server. Waits for the first frame so that
787
1285
  * layout/tree state is available immediately after connection.
788
1286
  */
789
1287
  export function connect(url, opts) {
1288
+ return connectWithOwnership(url, opts);
1289
+ }
1290
+ function connectWithOwnership(url, opts) {
790
1291
  return new Promise((resolve, reject) => {
791
1292
  const startedAt = performance.now();
792
1293
  clearReusableProxiesIfExited();
793
- evictOldestSession();
794
- 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
+ }
795
1313
  const session = {
796
1314
  id: generateSessionId(),
797
1315
  ws,
798
1316
  layout: null,
799
1317
  tree: null,
800
1318
  url,
1319
+ ...(opts?.authToken ? { transportAuthToken: opts.authToken } : {}),
1320
+ isolated: opts?.isolated === true,
801
1321
  updateRevision: 0,
802
1322
  connectTrace: {
803
1323
  mode: 'direct-ws',
@@ -808,6 +1328,8 @@ export function connect(url, opts) {
808
1328
  cachedA11y: null,
809
1329
  cachedA11yRevision: -1,
810
1330
  cachedFormSchemas: new Map(),
1331
+ hasFreshFrame: false,
1332
+ disposed: false,
811
1333
  heartbeatInterval: null,
812
1334
  heartbeatLastMessageAt: Date.now(),
813
1335
  heartbeatPendingPongBy: null,
@@ -822,6 +1344,7 @@ export function connect(url, opts) {
822
1344
  catch {
823
1345
  /* ignore */
824
1346
  }
1347
+ releaseSessionOwnership(ownershipLease);
825
1348
  reject(new Error(`Failed to initialize durable session state for ${url}: ${formatUnknownError(err)}`));
826
1349
  return;
827
1350
  }
@@ -832,7 +1355,7 @@ export function connect(url, opts) {
832
1355
  const timeout = setTimeout(() => {
833
1356
  if (!resolved) {
834
1357
  safeFailSessionLifecycle(session, 'connect_timeout', {
835
- transportUrl: url,
1358
+ transportOrigin: lifecycleUrlOrigin(url),
836
1359
  timeoutMs: 10_000,
837
1360
  });
838
1361
  resolved = true;
@@ -842,6 +1365,7 @@ export function connect(url, opts) {
842
1365
  catch {
843
1366
  /* ignore */
844
1367
  }
1368
+ releaseSessionOwnership(ownershipLease);
845
1369
  reject(new Error(`Connection to ${url} timed out after 10s`));
846
1370
  }
847
1371
  }, 10_000);
@@ -854,22 +1378,24 @@ export function connect(url, opts) {
854
1378
  if (!opts?.skipInitialResize) {
855
1379
  const width = opts?.width ?? 1024;
856
1380
  const height = opts?.height ?? 768;
857
- ws.send(JSON.stringify({ type: 'resize', width, height }));
858
- }
859
- if (opts?.awaitInitialFrame === false && !resolved) {
860
- resolved = true;
861
- clearTimeout(timeout);
862
- if (session.connectTrace) {
863
- session.connectTrace.resolvedWithoutInitialFrame = true;
864
- 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)}`));
865
1398
  }
866
- activeSessions.set(session.id, session);
867
- defaultSessionId = session.id;
868
- startSessionHeartbeat(session);
869
- safeRecordSessionSnapshot(session, 'session.open', {
870
- awaitInitialFrame: false,
871
- });
872
- resolve(session);
873
1399
  }
874
1400
  });
875
1401
  ws.on('message', (data) => {
@@ -877,25 +1403,42 @@ export function connect(url, opts) {
877
1403
  if (session.ws !== ws)
878
1404
  return;
879
1405
  try {
880
- 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
+ }
881
1427
  if (msg.type === 'frame') {
882
- session.layout = msg.layout;
883
- session.tree = msg.tree;
884
- session.updateRevision++;
885
- invalidateSessionCaches(session);
886
1428
  const connectTrace = session.connectTrace;
887
1429
  const firstFrame = connectTrace?.firstFrameMs === undefined;
888
1430
  if (connectTrace && connectTrace.firstFrameMs === undefined) {
889
1431
  connectTrace.firstFrameMs = performance.now() - startedAt;
890
1432
  }
891
1433
  if (!resolved) {
892
- resolved = true;
893
1434
  clearTimeout(timeout);
894
1435
  if (session.connectTrace) {
895
1436
  session.connectTrace.totalMs = performance.now() - startedAt;
896
1437
  }
897
- activeSessions.set(session.id, session);
898
- defaultSessionId = session.id;
1438
+ claimSessionOwnership(ownershipLease, session);
1439
+ resolved = true;
1440
+ if (!session.isolated)
1441
+ defaultSessionId = session.id;
899
1442
  startSessionHeartbeat(session);
900
1443
  safeRecordSessionSnapshot(session, 'session.connected');
901
1444
  resolve(session);
@@ -906,34 +1449,62 @@ export function connect(url, opts) {
906
1449
  });
907
1450
  }
908
1451
  }
909
- else if (msg.type === 'patch' && session.layout) {
910
- applyPatches(session.layout, msg.patches);
911
- session.updateRevision++;
912
- 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);
913
1477
  }
914
1478
  }
915
- catch { /* ignore malformed messages */ }
916
1479
  });
917
1480
  ws.on('error', (err) => {
1481
+ if (session.ws === ws) {
1482
+ rememberReusableProxyPageUrl(session);
1483
+ invalidateSessionUiState(session);
1484
+ }
918
1485
  if (!resolved) {
919
1486
  safeFailSessionLifecycle(session, 'websocket_error', {
920
- transportUrl: url,
921
- message: err.message,
1487
+ transportOrigin: lifecycleUrlOrigin(url),
1488
+ errorCode: err.code ?? 'websocket_error',
922
1489
  });
923
1490
  resolved = true;
924
1491
  clearTimeout(timeout);
1492
+ releaseSessionOwnership(ownershipLease);
925
1493
  reject(new Error(`WebSocket error connecting to ${url}: ${err.message}`));
926
1494
  }
927
1495
  });
928
1496
  ws.on('close', () => {
929
1497
  if (session.ws !== ws)
930
1498
  return;
1499
+ rememberReusableProxyPageUrl(session);
1500
+ invalidateSessionUiState(session);
931
1501
  if (!resolved) {
932
1502
  safeFailSessionLifecycle(session, 'websocket_closed_before_ready', {
933
- transportUrl: url,
1503
+ transportOrigin: lifecycleUrlOrigin(url),
934
1504
  });
935
1505
  resolved = true;
936
1506
  clearTimeout(timeout);
1507
+ releaseSessionOwnership(ownershipLease);
937
1508
  reject(new Error(`Connection to ${url} closed before first frame`));
938
1509
  return;
939
1510
  }
@@ -951,20 +1522,22 @@ export function connect(url, opts) {
951
1522
  */
952
1523
  export async function connectThroughProxy(options) {
953
1524
  clearReusableProxiesIfExited();
1525
+ const isolated = options.isolated !== false;
1526
+ const normalizedOptions = { ...options, isolated };
954
1527
  // Isolated sessions skip the pool entirely. They always get their own
955
1528
  // brand-new Chromium and never reuse a proxy from a prior call. The
956
1529
  // tag flows down so startFreshProxySession knows to keep this proxy out
957
1530
  // of the pool on success and so shutdownSession knows to force-close it.
958
- if (options.isolated) {
959
- return await startFreshProxySession({ ...options, isolated: true });
1531
+ if (isolated) {
1532
+ const ownershipLease = reserveSessionOwnership();
1533
+ return await startFreshProxySession(normalizedOptions, ownershipLease);
960
1534
  }
961
1535
  let reuseFailure;
962
1536
  // Loop because if a candidate is currently being attached by another
963
- // concurrent connectThroughProxy call we wait for it, then re-pick. The
964
- // second pick may now find the same entry already bound to an active
965
- // session attachToReusableProxy's reusedExistingSession branch then
966
- // returns that session instead of opening a second WebSocket. Bounded
967
- // 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.
968
1541
  for (let attempt = 0; attempt < REUSABLE_PROXY_POOL_LIMIT + 1; attempt++) {
969
1542
  const reusableProxy = findReusableProxy(options);
970
1543
  if (!reusableProxy)
@@ -979,10 +1552,16 @@ export async function connectThroughProxy(options) {
979
1552
  let releaseLock = () => { };
980
1553
  reusableProxy.attachLock = new Promise(resolve => { releaseLock = resolve; });
981
1554
  try {
1555
+ if (reusableProxyEntryIsActive(reusableProxy))
1556
+ continue;
982
1557
  return await attachToReusableProxy(reusableProxy, options);
983
1558
  }
984
1559
  catch (err) {
1560
+ if (err instanceof SessionCapacityError)
1561
+ throw err;
985
1562
  reuseFailure = err;
1563
+ if (reusableProxyEntryIsActive(reusableProxy))
1564
+ continue;
986
1565
  closeReusableProxy(reusableProxy);
987
1566
  break;
988
1567
  }
@@ -992,9 +1571,12 @@ export async function connectThroughProxy(options) {
992
1571
  }
993
1572
  }
994
1573
  try {
995
- return await startFreshProxySession(options);
1574
+ const ownershipLease = reserveSessionOwnership();
1575
+ return await startFreshProxySession(normalizedOptions, ownershipLease);
996
1576
  }
997
1577
  catch (e) {
1578
+ if (e instanceof SessionCapacityError)
1579
+ throw e;
998
1580
  if (reuseFailure) {
999
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 });
1000
1582
  }
@@ -1011,13 +1593,22 @@ export function getSession(id) {
1011
1593
  export function pruneDisconnectedSessions() {
1012
1594
  const removedIds = [];
1013
1595
  for (const [id, session] of activeSessions.entries()) {
1014
- 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))
1015
1601
  continue;
1016
1602
  removedIds.push(id);
1603
+ session.disposed = true;
1604
+ session.ambiguousOperations?.clear();
1605
+ session.inFlightMutations?.clear();
1017
1606
  activeSessions.delete(id);
1018
1607
  if (defaultSessionId === id) {
1019
1608
  promoteDefaultSession();
1020
1609
  }
1610
+ stopSessionHeartbeat(session);
1611
+ releaseSessionResources(session, { closeProxy: true });
1021
1612
  }
1022
1613
  return removedIds;
1023
1614
  }
@@ -1058,13 +1649,28 @@ export function getDefaultSessionId() {
1058
1649
  }
1059
1650
  export function disconnect(opts) {
1060
1651
  if (opts?.sessionId) {
1061
- 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;
1062
1656
  }
1063
- else if (defaultSessionId) {
1064
- 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(', ')}.`);
1065
1663
  }
1066
- if (opts?.closeProxy)
1067
- 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();
1068
1674
  }
1069
1675
  function estimateFillBatchTimeout(fields) {
1070
1676
  let total = FILL_BATCH_BASE_TIMEOUT_MS;
@@ -1100,7 +1706,13 @@ function estimateFillBatchTimeout(fields) {
1100
1706
  }
1101
1707
  export function waitForUiCondition(session, predicate, timeoutMs) {
1102
1708
  return new Promise((resolve) => {
1709
+ const transport = session.ws;
1103
1710
  const check = () => {
1711
+ if (session.ws !== transport) {
1712
+ cleanup();
1713
+ resolve(false);
1714
+ return;
1715
+ }
1104
1716
  let matched;
1105
1717
  try {
1106
1718
  matched = predicate();
@@ -1126,11 +1738,11 @@ export function waitForUiCondition(session, predicate, timeoutMs) {
1126
1738
  };
1127
1739
  function cleanup() {
1128
1740
  clearTimeout(timeout);
1129
- session.ws.off('message', onMessage);
1130
- session.ws.off('close', onClose);
1741
+ transport.off('message', onMessage);
1742
+ transport.off('close', onClose);
1131
1743
  }
1132
- session.ws.on('message', onMessage);
1133
- session.ws.on('close', onClose);
1744
+ transport.on('message', onMessage);
1745
+ transport.on('close', onClose);
1134
1746
  check();
1135
1747
  });
1136
1748
  }
@@ -1147,9 +1759,19 @@ function reconnectUrlForSession(session) {
1147
1759
  }
1148
1760
  return null;
1149
1761
  }
1150
- async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
1762
+ async function openWebSocket(url, session, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS, viewport) {
1151
1763
  return await new Promise((resolve, reject) => {
1152
- 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;
1153
1775
  const timeout = setTimeout(() => {
1154
1776
  cleanup();
1155
1777
  try {
@@ -1158,23 +1780,61 @@ async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
1158
1780
  catch {
1159
1781
  /* ignore */
1160
1782
  }
1161
- reject(new Error(`Reconnect to ${url} timed out after ${timeoutMs}ms`));
1783
+ reject(new Error(`Reconnect handshake to ${url} timed out after ${timeoutMs}ms`));
1162
1784
  }, timeoutMs);
1163
1785
  const onOpen = () => {
1164
- cleanup();
1165
- 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
+ }));
1166
1796
  };
1167
1797
  const onError = (err) => {
1168
1798
  cleanup();
1169
1799
  reject(new Error(`WebSocket reconnect failed for ${url}: ${err.message}`));
1170
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
+ };
1171
1827
  function cleanup() {
1172
1828
  clearTimeout(timeout);
1173
1829
  ws.off('open', onOpen);
1174
1830
  ws.off('error', onError);
1831
+ ws.off('close', onClose);
1832
+ ws.off('message', onMessage);
1175
1833
  }
1176
1834
  ws.on('open', onOpen);
1177
1835
  ws.on('error', onError);
1836
+ ws.on('close', onClose);
1837
+ ws.on('message', onMessage);
1178
1838
  });
1179
1839
  }
1180
1840
  function bindReconnectedSocket(session, ws) {
@@ -1183,29 +1843,31 @@ function bindReconnectedSocket(session, ws) {
1183
1843
  if (session.ws !== ws)
1184
1844
  return;
1185
1845
  try {
1186
- const msg = JSON.parse(String(data));
1187
- if (msg.type === 'frame') {
1188
- session.layout = msg.layout;
1189
- session.tree = msg.tree;
1190
- session.updateRevision++;
1191
- invalidateSessionCaches(session);
1192
- }
1193
- else if (msg.type === 'patch' && session.layout) {
1194
- applyPatches(session.layout, msg.patches);
1195
- session.updateRevision++;
1196
- invalidateSessionCaches(session);
1197
- }
1846
+ applyInboundSessionMessage(session, data);
1198
1847
  }
1199
1848
  catch {
1200
- /* ignore malformed messages */
1849
+ rememberReusableProxyPageUrl(session);
1850
+ invalidateSessionUiState(session);
1851
+ try {
1852
+ ws.close(1002, 'Invalid protocol message');
1853
+ }
1854
+ catch { /* ignore */ }
1201
1855
  }
1202
1856
  });
1203
1857
  ws.on('pong', () => {
1204
1858
  noteSessionSocketActivity(session, ws);
1205
1859
  });
1860
+ ws.on('error', () => {
1861
+ if (session.ws !== ws)
1862
+ return;
1863
+ rememberReusableProxyPageUrl(session);
1864
+ invalidateSessionUiState(session);
1865
+ });
1206
1866
  ws.on('close', () => {
1207
1867
  if (session.ws !== ws)
1208
1868
  return;
1869
+ rememberReusableProxyPageUrl(session);
1870
+ invalidateSessionUiState(session);
1209
1871
  if (activeSessions.get(session.id) === session && !session.lifecycleFinalized) {
1210
1872
  safeRecordSessionSnapshot(session, 'session.transport_closed', {
1211
1873
  reconnectable: reconnectUrlForSession(session) !== null,
@@ -1213,7 +1875,13 @@ function bindReconnectedSocket(session, ws) {
1213
1875
  }
1214
1876
  });
1215
1877
  }
1216
- 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
+ }
1217
1885
  if (session.ws.readyState === WebSocket.OPEN)
1218
1886
  return;
1219
1887
  if (session.reconnectInFlight) {
@@ -1221,15 +1889,31 @@ async function ensureSessionConnected(session) {
1221
1889
  if (!recovered) {
1222
1890
  throw new Error('Not connected');
1223
1891
  }
1892
+ if (sessionIsDisposedOrUnowned(session)) {
1893
+ throw new Error(`Session ${session.id} was disconnected while reconnecting`);
1894
+ }
1224
1895
  return;
1225
1896
  }
1226
1897
  const targetUrl = reconnectUrlForSession(session);
1227
1898
  if (!targetUrl) {
1228
1899
  throw new Error('Not connected');
1229
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);
1230
1907
  const reconnectPromise = (async () => {
1231
1908
  try {
1232
- 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
+ }
1233
1917
  try {
1234
1918
  session.ws.close();
1235
1919
  }
@@ -1239,28 +1923,33 @@ async function ensureSessionConnected(session) {
1239
1923
  session.ws = nextWs;
1240
1924
  noteSessionSocketActivity(session, nextWs);
1241
1925
  bindReconnectedSocket(session, nextWs);
1926
+ applyInboundSessionMessage(session, handshakeMessage);
1242
1927
  activeSessions.set(session.id, session);
1243
1928
  if (!session.isolated) {
1244
1929
  defaultSessionId = session.id;
1245
1930
  }
1246
1931
  startSessionHeartbeat(session);
1247
1932
  safeRecordSessionSnapshot(session, 'session.reconnected', {
1248
- targetUrl,
1933
+ targetOrigin: lifecycleUrlOrigin(targetUrl),
1249
1934
  });
1250
1935
  return true;
1251
1936
  }
1252
1937
  catch (err) {
1253
1938
  safeFailSessionLifecycle(session, 'reconnect_failed', {
1254
- targetUrl,
1255
- message: formatUnknownError(err),
1939
+ targetOrigin: lifecycleUrlOrigin(targetUrl),
1940
+ errorName: err instanceof Error ? err.name : 'Error',
1256
1941
  });
1257
1942
  if (activeSessions.get(session.id) === session) {
1258
1943
  activeSessions.delete(session.id);
1259
1944
  if (defaultSessionId === session.id)
1260
1945
  promoteDefaultSession();
1261
1946
  }
1947
+ session.disposed = true;
1948
+ session.ambiguousOperations?.clear();
1949
+ session.inFlightMutations?.clear();
1262
1950
  stopSessionHeartbeat(session);
1263
1951
  releaseSessionResources(session, { closeProxy: true });
1952
+ invalidateSessionUiState(session);
1264
1953
  throw err;
1265
1954
  }
1266
1955
  })();
@@ -1275,12 +1964,16 @@ async function ensureSessionConnected(session) {
1275
1964
  if (!recovered) {
1276
1965
  throw new Error('Not connected');
1277
1966
  }
1967
+ if (sessionIsDisposedOrUnowned(session)) {
1968
+ throw new Error(`Session ${session.id} was disconnected while reconnecting`);
1969
+ }
1278
1970
  }
1279
1971
  async function sendResizeAndWaitForUpdate(session, width, height, timeoutMs = 5_000) {
1280
- await ensureSessionConnected(session);
1281
- const startRevision = session.updateRevision;
1282
- session.ws.send(JSON.stringify({ type: 'resize', width, height }));
1283
- return await waitForNextUpdate(session, timeoutMs, undefined, startRevision);
1972
+ return await sendAndWaitForUpdate(session, {
1973
+ type: 'resize',
1974
+ width,
1975
+ height,
1976
+ }, timeoutMs);
1284
1977
  }
1285
1978
  /**
1286
1979
  * Send a click event at (x, y) and wait for the next frame/patch response.
@@ -1299,10 +1992,30 @@ export function sendClick(session, x, y, timeoutMs) {
1299
1992
  export function sendType(session, text, timeoutMs) {
1300
1993
  return (async () => {
1301
1994
  await ensureSessionConnected(session);
1302
- // 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.
1303
2015
  for (const char of text) {
1304
2016
  const keyEvent = {
1305
2017
  type: 'key',
2018
+ ...outboundProtocolMetadata(session),
1306
2019
  eventType: 'onKeyDown',
1307
2020
  key: char,
1308
2021
  code: `Key${char.toUpperCase()}`,
@@ -1311,11 +2024,41 @@ export function sendType(session, text, timeoutMs) {
1311
2024
  metaKey: false,
1312
2025
  altKey: false,
1313
2026
  };
1314
- session.ws.send(JSON.stringify(keyEvent));
1315
- 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
+ });
2038
+ }
2039
+ if (keyEvents.length === 0) {
2040
+ return {
2041
+ status: 'acknowledged',
2042
+ timeoutMs: waitTimeoutMs,
2043
+ requestId: randomUUID(),
2044
+ actionId,
2045
+ };
1316
2046
  }
1317
- // Wait briefly for server to process and send update
1318
- return await waitForNextUpdate(session, timeoutMs);
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);
1319
2062
  })();
1320
2063
  }
1321
2064
  /**
@@ -1335,7 +2078,9 @@ export function sendKey(session, key, modifiers, timeoutMs) {
1335
2078
  }
1336
2079
  /**
1337
2080
  * Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
1338
- * 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.
1339
2084
  */
1340
2085
  export function sendFileUpload(session, paths, opts, timeoutMs) {
1341
2086
  const payload = { type: 'file', paths };
@@ -1345,6 +2090,10 @@ export function sendFileUpload(session, paths, opts, timeoutMs) {
1345
2090
  }
1346
2091
  if (opts?.fieldLabel)
1347
2092
  payload.fieldLabel = opts.fieldLabel;
2093
+ if (opts?.fieldId)
2094
+ payload.fieldId = opts.fieldId;
2095
+ if (opts?.fieldKey)
2096
+ payload.fieldKey = opts.fieldKey;
1348
2097
  if (opts?.exact !== undefined)
1349
2098
  payload.exact = opts.exact;
1350
2099
  if (opts?.strategy)
@@ -1353,6 +2102,10 @@ export function sendFileUpload(session, paths, opts, timeoutMs) {
1353
2102
  payload.dropX = opts.drop.x;
1354
2103
  payload.dropY = opts.drop.y;
1355
2104
  }
2105
+ if (opts?.contextText !== undefined)
2106
+ payload.contextText = opts.contextText;
2107
+ if (opts?.sectionText !== undefined)
2108
+ payload.sectionText = opts.sectionText;
1356
2109
  return sendAndWaitForUpdate(session, payload, timeoutMs);
1357
2110
  }
1358
2111
  /** Set a labeled text-like field (`input`, `textarea`, contenteditable, ARIA textbox) semantically. */
@@ -1366,6 +2119,8 @@ export function sendFieldText(session, fieldLabel, value, opts, timeoutMs) {
1366
2119
  payload.exact = opts.exact;
1367
2120
  if (opts?.fieldId)
1368
2121
  payload.fieldId = opts.fieldId;
2122
+ if (opts?.fieldKey)
2123
+ payload.fieldKey = opts.fieldKey;
1369
2124
  if (opts?.typingDelayMs !== undefined)
1370
2125
  payload.typingDelayMs = opts.typingDelayMs;
1371
2126
  if (opts?.imeFriendly !== undefined)
@@ -1387,6 +2142,10 @@ export function sendFieldChoice(session, fieldLabel, value, opts, timeoutMs = LI
1387
2142
  payload.choiceType = opts.choiceType;
1388
2143
  if (opts?.fieldId)
1389
2144
  payload.fieldId = opts.fieldId;
2145
+ if (opts?.fieldKey)
2146
+ payload.fieldKey = opts.fieldKey;
2147
+ if (opts?.optionIndex !== undefined)
2148
+ payload.optionIndex = opts.optionIndex;
1390
2149
  return sendAndWaitForUpdate(session, payload, timeoutMs);
1391
2150
  }
1392
2151
  /** Fill several semantic form fields in one proxy-side batch. */
@@ -1417,14 +2176,14 @@ export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE
1417
2176
  const payload = { type: 'listboxPick', label };
1418
2177
  if (opts?.exact !== undefined)
1419
2178
  payload.exact = opts.exact;
1420
- if (opts?.open) {
1421
- payload.openX = opts.open.x;
1422
- payload.openY = opts.open.y;
1423
- }
1424
2179
  if (opts?.fieldLabel)
1425
2180
  payload.fieldLabel = opts.fieldLabel;
1426
2181
  if (opts?.query)
1427
2182
  payload.query = opts.query;
2183
+ if (opts?.fieldId)
2184
+ payload.fieldId = opts.fieldId;
2185
+ if (opts?.fieldKey)
2186
+ payload.fieldKey = opts.fieldKey;
1428
2187
  return sendAndWaitForUpdate(session, payload, timeoutMs);
1429
2188
  }
1430
2189
  /** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
@@ -1445,6 +2204,12 @@ export function sendSetChecked(session, label, opts, timeoutMs) {
1445
2204
  payload.exact = opts.exact;
1446
2205
  if (opts?.controlType)
1447
2206
  payload.controlType = opts.controlType;
2207
+ if (opts?.fieldKey)
2208
+ payload.fieldKey = opts.fieldKey;
2209
+ if (opts?.contextText)
2210
+ payload.contextText = opts.contextText;
2211
+ if (opts?.sectionText)
2212
+ payload.sectionText = opts.sectionText;
1448
2213
  return sendAndWaitForUpdate(session, payload, timeoutMs);
1449
2214
  }
1450
2215
  /** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
@@ -1480,9 +2245,8 @@ export function sendNavigate(session, url, timeoutMs = 15_000) {
1480
2245
  url,
1481
2246
  }, timeoutMs, { requireUpdateOnAck: true });
1482
2247
  safeRecordSessionSnapshot(session, 'session.navigate', {
1483
- requestedUrl: url,
2248
+ requestedOrigin: lifecycleUrlOrigin(url),
1484
2249
  status: result.status,
1485
- result: result.result ?? null,
1486
2250
  });
1487
2251
  return result;
1488
2252
  })();
@@ -1537,6 +2301,11 @@ const FORM_FIELD_ROLES = new Set([
1537
2301
  'checkbox',
1538
2302
  'radio',
1539
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
+ }
1540
2309
  const ACTION_ROLES = new Set([
1541
2310
  'button',
1542
2311
  'link',
@@ -2154,6 +2923,8 @@ function schemaOptionLabel(node) {
2154
2923
  return sanitizeFieldName(node.name, 80) ?? sanitizeInlineName(node.name, 80);
2155
2924
  }
2156
2925
  function isGroupedChoiceControl(node) {
2926
+ if (node.meta?.coordinateOnly === true)
2927
+ return false;
2157
2928
  return node.role === 'radio' || node.role === 'checkbox' || (node.role === 'button' && node.focusable);
2158
2929
  }
2159
2930
  function groupedChoiceForNode(root, formNode, seed) {
@@ -2219,10 +2990,36 @@ function buildFieldFormat(node) {
2219
2990
  format.pattern = m.inputPattern;
2220
2991
  if (m.inputType)
2221
2992
  format.inputType = m.inputType;
2993
+ else if (m.fileInput)
2994
+ format.inputType = 'file';
2222
2995
  if (m.autocomplete)
2223
2996
  format.autocomplete = m.autocomplete;
2997
+ if (m.accept)
2998
+ format.accept = m.accept;
2999
+ if (m.multiple)
3000
+ format.multiple = true;
2224
3001
  return Object.keys(format).length > 0 ? format : undefined;
2225
3002
  }
3003
+ function nativeSchemaOptions(node, fieldIdentity) {
3004
+ const options = node.meta?.options;
3005
+ if (!options)
3006
+ return undefined;
3007
+ return options.map(option => ({
3008
+ id: `${fieldIdentity}:option:${option.index}`,
3009
+ value: option.value,
3010
+ label: option.label,
3011
+ index: option.index,
3012
+ ...(option.disabled ? { disabled: true } : {}),
3013
+ ...(option.selected ? { selected: true } : {}),
3014
+ }));
3015
+ }
3016
+ function uniqueSchemaFieldKey(root, node) {
3017
+ const fieldKey = node.meta?.controlKey;
3018
+ if (!fieldKey)
3019
+ return undefined;
3020
+ const matches = collectDescendants(root, candidate => candidate.meta?.controlKey === fieldKey);
3021
+ return matches.length === 1 ? fieldKey : undefined;
3022
+ }
2226
3023
  function simpleSchemaField(root, node) {
2227
3024
  const context = nodeContextForNode(root, node);
2228
3025
  const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
@@ -2251,15 +3048,51 @@ function simpleSchemaField(root, node) {
2251
3048
  ? 'select'
2252
3049
  : 'listbox'
2253
3050
  : undefined;
3051
+ const fieldKey = uniqueSchemaFieldKey(root, node);
3052
+ const id = fieldKey ?? formFieldIdForPath(node.path);
2254
3053
  const format = buildFieldFormat(node);
3054
+ const optionDetails = classifiedChoiceType === 'select' ? nativeSchemaOptions(node, id) : undefined;
3055
+ const enabledOptions = optionDetails
3056
+ ? dedupeStrings(optionDetails.filter(option => !option.disabled).map(option => option.label), 64)
3057
+ : undefined;
2255
3058
  return {
2256
- id: formFieldIdForPath(node.path),
3059
+ id,
3060
+ ...(fieldKey ? { fieldKey } : {}),
2257
3061
  kind: classifiedRole === 'combobox' ? 'choice' : 'text',
2258
3062
  label,
2259
3063
  ...(classifiedChoiceType ? { choiceType: classifiedChoiceType } : {}),
2260
3064
  ...(node.state?.required ? { required: true } : {}),
2261
3065
  ...(node.state?.invalid ? { invalid: true } : {}),
2262
3066
  ...compactSchemaValue(node.value, 72),
3067
+ ...(optionDetails ? { optionCount: optionDetails.length, optionDetails } : {}),
3068
+ ...(enabledOptions && enabledOptions.length > 0 ? { options: enabledOptions } : {}),
3069
+ ...(enabledOptions && computeOptionAliases(enabledOptions) ? { aliases: computeOptionAliases(enabledOptions) } : {}),
3070
+ ...(format ? { format } : {}),
3071
+ ...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
3072
+ };
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 } : {}),
2263
3096
  ...(format ? { format } : {}),
2264
3097
  ...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
2265
3098
  };
@@ -2304,8 +3137,10 @@ function toggleSchemaField(root, node) {
2304
3137
  return null;
2305
3138
  const context = nodeContextForNode(root, node);
2306
3139
  const controlType = node.role === 'radio' ? 'radio' : 'checkbox';
3140
+ const fieldKey = uniqueSchemaFieldKey(root, node);
2307
3141
  return {
2308
- id: formFieldIdForPath(node.path),
3142
+ id: fieldKey ?? formFieldIdForPath(node.path),
3143
+ ...(fieldKey ? { fieldKey } : {}),
2309
3144
  kind: 'toggle',
2310
3145
  label,
2311
3146
  controlType,
@@ -2330,7 +3165,10 @@ function detectFormSections(formNode, fields) {
2330
3165
  return [];
2331
3166
  const fieldIdToPath = new Map();
2332
3167
  for (const field of fields) {
2333
- const parsed = parseFormFieldId(field.id);
3168
+ const authoredMatches = field.fieldKey
3169
+ ? collectDescendants(formNode, node => node.meta?.controlKey === field.fieldKey)
3170
+ : [];
3171
+ const parsed = authoredMatches.length === 1 ? authoredMatches[0].path : parseFormFieldId(field.id);
2334
3172
  if (parsed)
2335
3173
  fieldIdToPath.set(field.id, parsed);
2336
3174
  }
@@ -2351,17 +3189,25 @@ function detectFormSections(formNode, fields) {
2351
3189
  return sections;
2352
3190
  }
2353
3191
  function buildFormSchemaForNode(root, formNode, options) {
2354
- const candidates = sortByBounds(collectDescendants(formNode, candidate => candidate.role === 'textbox' ||
3192
+ const candidates = sortByBounds(collectDescendants(formNode, candidate => candidate.meta?.coordinateOnly !== true && (candidate.role === 'textbox' ||
2355
3193
  candidate.role === 'combobox' ||
2356
3194
  candidate.role === 'checkbox' ||
2357
3195
  candidate.role === 'radio' ||
2358
- (candidate.role === 'button' && candidate.focusable)));
3196
+ candidate.meta?.fileInput === true ||
3197
+ (candidate.role === 'button' && candidate.focusable))));
2359
3198
  const consumed = new Set();
2360
3199
  const fields = [];
2361
3200
  for (const candidate of candidates) {
2362
3201
  const candidateKey = pathKey(candidate.path);
2363
3202
  if (consumed.has(candidateKey))
2364
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
+ }
2365
3211
  if (candidate.role === 'textbox' || candidate.role === 'combobox') {
2366
3212
  const field = simpleSchemaField(root, candidate);
2367
3213
  if (field)
@@ -2428,6 +3274,7 @@ function presentFormSchemaFields(fields, options) {
2428
3274
  next.booleanChoice = true;
2429
3275
  if (!includeOptions) {
2430
3276
  delete next.options;
3277
+ delete next.optionDetails;
2431
3278
  delete next.aliases;
2432
3279
  }
2433
3280
  if (includeContext === 'none') {
@@ -2655,7 +3502,7 @@ export function buildPageModel(root, options) {
2655
3502
  landmarks.push(toLandmarkModel(node));
2656
3503
  }
2657
3504
  if (node.role === 'form') {
2658
- const fields = collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role));
3505
+ const fields = collectDescendants(node, isFormFieldNode);
2659
3506
  const actions = collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable);
2660
3507
  const name = sectionDisplayName(node, 'form');
2661
3508
  forms.push({
@@ -2668,7 +3515,7 @@ export function buildPageModel(root, options) {
2668
3515
  });
2669
3516
  }
2670
3517
  if (DIALOG_ROLES.has(node.role)) {
2671
- const fields = collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role));
3518
+ const fields = collectDescendants(node, isFormFieldNode);
2672
3519
  const actions = collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable);
2673
3520
  const name = sectionDisplayName(node, 'dialog');
2674
3521
  dialogs.push({
@@ -2742,8 +3589,11 @@ export function buildFormSchemas(root, options) {
2742
3589
  ...(root.role === 'form' ? [root] : []),
2743
3590
  ...collectDescendants(root, candidate => candidate.role === 'form'),
2744
3591
  ];
2745
- // Infer forms from group/region containers with 2+ form fields (e.g. Ashby-style UIs without <form>)
2746
- 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 => {
2747
3597
  if (candidate.role !== 'group' && candidate.role !== 'region')
2748
3598
  return false;
2749
3599
  // Skip descendants of explicit forms
@@ -2753,14 +3603,173 @@ export function buildFormSchemas(root, options) {
2753
3603
  return false;
2754
3604
  }
2755
3605
  }
2756
- const fields = collectDescendants(candidate, child => FORM_FIELD_ROLES.has(child.role));
2757
- 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;
2758
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()];
2759
3638
  const forms = sortByBounds([...explicitForms, ...inferredForms]);
2760
3639
  return forms
2761
3640
  .filter(form => !options?.formId || sectionIdForPath('form', form.path) === options.formId)
2762
3641
  .map(form => buildFormSchemaForNode(root, form, options));
2763
3642
  }
3643
+ export function buildFormGraphs(root, options) {
3644
+ const pageUrl = typeof root.meta?.pageUrl === 'string' && root.meta.pageUrl.length > 0 ? root.meta.pageUrl : undefined;
3645
+ return buildFormSchemas(root, {
3646
+ ...options,
3647
+ includeOptions: true,
3648
+ }).map(schema => formSchemaToFormGraph(schema, pageUrl));
3649
+ }
3650
+ export function formSchemaToFormGraph(schema, pageUrl) {
3651
+ const sourceId = 'geometra-page';
3652
+ const formSlug = slugPathSegment(schema.name ?? schema.formId);
3653
+ const pathCounts = new Map();
3654
+ return {
3655
+ formgraph: '0.1',
3656
+ id: `geometra:${schema.formId}`,
3657
+ title: schema.name ?? `Geometra form ${schema.formId}`,
3658
+ description: 'FormGraph-compatible projection of a Geometra form schema.',
3659
+ sources: [
3660
+ {
3661
+ id: sourceId,
3662
+ kind: 'html',
3663
+ title: schema.name ?? 'Geometra-discovered web form',
3664
+ ...(pageUrl ? { url: pageUrl } : {}),
3665
+ },
3666
+ ],
3667
+ fields: schema.fields.map(field => formSchemaFieldToFormGraphField(field, {
3668
+ formSlug,
3669
+ sourceId,
3670
+ pathCounts,
3671
+ })),
3672
+ evidence: [],
3673
+ dependencies: [],
3674
+ review: {
3675
+ autoSubmitAllowed: false,
3676
+ requiredBeforeSubmit: true,
3677
+ },
3678
+ metadata: {
3679
+ producer: 'geometra',
3680
+ geometra: {
3681
+ formId: schema.formId,
3682
+ fieldCount: schema.fieldCount,
3683
+ requiredCount: schema.requiredCount,
3684
+ invalidCount: schema.invalidCount,
3685
+ ...(schema.sections ? { sections: schema.sections } : {}),
3686
+ },
3687
+ },
3688
+ };
3689
+ }
3690
+ function formSchemaFieldToFormGraphField(field, opts) {
3691
+ const basePath = `web.forms.${opts.formSlug}.${slugPathSegment(field.label || field.id)}`;
3692
+ const path = uniquePath(basePath, opts.pathCounts);
3693
+ const aliases = fieldAliases(field);
3694
+ const options = field.optionDetails
3695
+ ?.filter(option => !option.disabled)
3696
+ .map(option => ({ value: option.value, label: option.label }))
3697
+ ?? field.options?.map(option => ({ value: option, label: option }));
3698
+ const inputType = field.format?.inputType?.toLowerCase();
3699
+ const metadata = {
3700
+ geometra: {
3701
+ fieldId: field.id,
3702
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
3703
+ kind: field.kind,
3704
+ ...(field.choiceType ? { choiceType: field.choiceType } : {}),
3705
+ ...(field.controlType ? { controlType: field.controlType } : {}),
3706
+ ...(field.booleanChoice ? { booleanChoice: true } : {}),
3707
+ ...(field.invalid ? { invalid: true } : {}),
3708
+ ...(field.format ? { format: field.format } : {}),
3709
+ ...(field.context ? { context: field.context } : {}),
3710
+ ...(field.optionDetails ? { optionDetails: field.optionDetails } : {}),
3711
+ },
3712
+ };
3713
+ return {
3714
+ id: field.fieldKey ?? field.id,
3715
+ path,
3716
+ label: field.label,
3717
+ kind: formGraphFieldKind(field),
3718
+ ...(field.required ? { required: true } : {}),
3719
+ ...(field.invalid ? { reviewRequired: true } : {}),
3720
+ ...(aliases.length > 0 ? { aliases } : {}),
3721
+ ...(options && options.length > 0 ? { options } : {}),
3722
+ ...(field.format?.pattern ? { constraints: { pattern: field.format.pattern } } : {}),
3723
+ sourceAnchors: [
3724
+ {
3725
+ sourceId: opts.sourceId,
3726
+ kind: 'html',
3727
+ fieldName: field.fieldKey ?? field.id,
3728
+ pointer: `geometra:${field.fieldKey ?? field.id}`,
3729
+ },
3730
+ ],
3731
+ ...(inputType ? { metadata: { ...metadata, inputType } } : { metadata }),
3732
+ };
3733
+ }
3734
+ function formGraphFieldKind(field) {
3735
+ if (field.kind === 'toggle' || field.booleanChoice)
3736
+ return 'boolean';
3737
+ if (field.kind === 'choice' || field.kind === 'multi_choice')
3738
+ return 'enum';
3739
+ const inputType = field.format?.inputType?.toLowerCase();
3740
+ if (inputType === 'email')
3741
+ return 'email';
3742
+ if (inputType === 'tel' || inputType === 'phone')
3743
+ return 'phone';
3744
+ if (inputType === 'date')
3745
+ return 'date';
3746
+ if (inputType === 'number')
3747
+ return 'number';
3748
+ if (field.valueLength !== undefined && field.valueLength > 120)
3749
+ return 'textarea';
3750
+ return 'text';
3751
+ }
3752
+ function fieldAliases(field) {
3753
+ const values = new Set();
3754
+ if (field.format?.placeholder)
3755
+ values.add(field.format.placeholder);
3756
+ if (field.context?.prompt && normalizeUiText(field.context.prompt) !== normalizeUiText(field.label)) {
3757
+ values.add(field.context.prompt);
3758
+ }
3759
+ return [...values].filter(value => value.trim().length > 0);
3760
+ }
3761
+ function uniquePath(basePath, seen) {
3762
+ const count = seen.get(basePath) ?? 0;
3763
+ seen.set(basePath, count + 1);
3764
+ return count === 0 ? basePath : `${basePath}.${count + 1}`;
3765
+ }
3766
+ function slugPathSegment(value) {
3767
+ const normalized = normalizeUiText(value)
3768
+ .toLowerCase()
3769
+ .replace(/[^a-z0-9]+/g, '.')
3770
+ .replace(/^\.+|\.+$/g, '');
3771
+ return normalized || 'field';
3772
+ }
2764
3773
  /**
2765
3774
  * Required-field snapshot for automation: every required field in a form, including
2766
3775
  * offscreen entries, annotated with visibility and scroll hints so agents do not
@@ -2779,7 +3788,10 @@ export function buildFormRequiredSnapshot(root, options) {
2779
3788
  const formNode = parsedForm ? findNodeByPath(root, parsedForm.path) : null;
2780
3789
  const fields = schema.fields
2781
3790
  .map(field => {
2782
- const fieldPath = parseFormFieldId(field.id);
3791
+ const authoredMatches = field.fieldKey && formNode
3792
+ ? collectDescendants(formNode, node => node.meta?.controlKey === field.fieldKey)
3793
+ : [];
3794
+ const fieldPath = authoredMatches.length === 1 ? authoredMatches[0].path : parseFormFieldId(field.id);
2783
3795
  const target = fieldPath ? findNodeByPath(root, fieldPath) ?? formNode : formNode;
2784
3796
  if (!target)
2785
3797
  return null;
@@ -2846,7 +3858,7 @@ export function expandPageSection(root, id, options) {
2846
3858
  const maxTextPreview = options?.maxTextPreview ?? 6;
2847
3859
  const includeBounds = options?.includeBounds ?? false;
2848
3860
  const headingsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
2849
- const fieldsAll = sortByBounds(collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role)));
3861
+ const fieldsAll = sortByBounds(collectDescendants(node, isFormFieldNode));
2850
3862
  const actionsAll = sortByBounds(collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable));
2851
3863
  const nestedListsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'list' && pathKey(candidate.path) !== pathKey(node.path)));
2852
3864
  const itemsAll = actualKind === 'list'
@@ -3183,6 +4195,32 @@ function normalizeCheckedState(value) {
3183
4195
  return false;
3184
4196
  return undefined;
3185
4197
  }
4198
+ function normalizeSemanticOptions(value) {
4199
+ if (!Array.isArray(value))
4200
+ return undefined;
4201
+ const options = [];
4202
+ const seenIndices = new Set();
4203
+ for (const entry of value.slice(0, 500)) {
4204
+ if (!entry || typeof entry !== 'object')
4205
+ continue;
4206
+ const record = entry;
4207
+ if (typeof record.value !== 'string' || typeof record.label !== 'string')
4208
+ continue;
4209
+ if (typeof record.index !== 'number' || !Number.isInteger(record.index) || record.index < 0)
4210
+ continue;
4211
+ if (seenIndices.has(record.index))
4212
+ continue;
4213
+ seenIndices.add(record.index);
4214
+ options.push({
4215
+ value: record.value,
4216
+ label: record.label,
4217
+ disabled: record.disabled === true,
4218
+ selected: record.selected === true,
4219
+ index: record.index,
4220
+ });
4221
+ }
4222
+ return options;
4223
+ }
3186
4224
  function normalizeA11yRoleHint(value) {
3187
4225
  if (typeof value !== 'string')
3188
4226
  return undefined;
@@ -3239,6 +4277,15 @@ function walkNode(element, layout, path) {
3239
4277
  meta.scrollY = semantic.scrollY;
3240
4278
  if (typeof semantic?.tag === 'string' && semantic.tag.trim().length > 0)
3241
4279
  meta.controlTag = semantic.tag;
4280
+ if (typeof semantic?.controlKey === 'string' && semantic.controlKey.trim().length > 0)
4281
+ meta.controlKey = semantic.controlKey.trim();
4282
+ if (typeof semantic?.controlId === 'string' && semantic.controlId.trim().length > 0)
4283
+ meta.controlId = semantic.controlId.trim();
4284
+ if (typeof semantic?.controlName === 'string' && semantic.controlName.trim().length > 0)
4285
+ meta.controlName = semantic.controlName.trim();
4286
+ const semanticOptions = normalizeSemanticOptions(semantic?.options);
4287
+ if (semanticOptions)
4288
+ meta.options = semanticOptions;
3242
4289
  if (typeof semantic?.placeholder === 'string')
3243
4290
  meta.placeholder = semantic.placeholder;
3244
4291
  if (typeof semantic?.inputPattern === 'string')
@@ -3247,6 +4294,14 @@ function walkNode(element, layout, path) {
3247
4294
  meta.inputType = semantic.inputType;
3248
4295
  if (typeof semantic?.autocomplete === 'string')
3249
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;
3250
4305
  if (semantic?.isAutocompleteCombobox === true)
3251
4306
  meta.isAutocompleteCombobox = true;
3252
4307
  const children = [];
@@ -3350,24 +4405,186 @@ function applyPatches(layout, patches) {
3350
4405
  }
3351
4406
  async function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, opts) {
3352
4407
  await ensureSessionConnected(session);
3353
- const requestId = `req-${++nextRequestSequence}`;
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);
4421
+ const requiresExactFieldIdentity = typeof message.fieldKey === 'string' || (message.type === 'fillFields' &&
4422
+ Array.isArray(message.fields) &&
4423
+ message.fields.some(field => typeof field === 'object' && field !== null && typeof field.fieldKey === 'string'));
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, {
4454
+ ...opts,
4455
+ ...(requiresExactFieldIdentity ? { requiredProtocolVersion: PROXY_ACTION_PROTOCOL_VERSION } : {}),
4456
+ });
4457
+ }
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
+ }
3354
4498
  const startRevision = session.updateRevision;
3355
- session.ws.send(JSON.stringify({ ...message, requestId }));
3356
- return await waitForNextUpdate(session, timeoutMs, requestId, startRevision, opts);
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
+ }
3357
4548
  }
3358
- function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision, opts) {
4549
+ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, operation, startRevision = session.updateRevision, opts, ignoreDuplicateRequestError = false, sendAfterSubscribe) {
3359
4550
  return new Promise((resolve, reject) => {
4551
+ const transport = session.ws;
4552
+ const { requestId, actionId } = operation;
4553
+ const operationRequestIds = new Set(operation.requestIds);
3360
4554
  let ackSeen = false;
3361
4555
  let ackResult;
3362
- const ackPayload = () => (ackSeen && ackResult !== undefined ? { result: ackResult } : {});
4556
+ const ackPayload = () => ({
4557
+ requestId,
4558
+ actionId,
4559
+ ...(ackSeen && ackResult !== undefined ? { result: ackResult } : {}),
4560
+ });
3363
4561
  const onMessage = (data) => {
4562
+ if (session.ws !== transport)
4563
+ return;
3364
4564
  try {
3365
- const msg = JSON.parse(String(data));
4565
+ const msg = parseInboundServerMessage(data);
4566
+ updatePeerProtocol(session, msg);
3366
4567
  const messageRequestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
3367
4568
  if (requestId) {
3368
- 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) {
3369
4580
  cleanup();
3370
- 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));
3371
4588
  return;
3372
4589
  }
3373
4590
  if ((msg.type === 'frame' || (msg.type === 'patch' && session.layout)) && ackSeen && session.updateRevision > startRevision) {
@@ -3380,6 +4597,20 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
3380
4597
  return;
3381
4598
  }
3382
4599
  if (msg.type === 'ack' && messageRequestId === requestId) {
4600
+ const peerProtocolVersion = typeof msg.proxyActionProtocolVersion === 'number'
4601
+ ? msg.proxyActionProtocolVersion
4602
+ : typeof msg.protocolVersion === 'number'
4603
+ ? msg.protocolVersion
4604
+ : session.peerProxyActionProtocolVersion;
4605
+ if (opts?.requiredProtocolVersion !== undefined && (peerProtocolVersion === undefined || peerProtocolVersion < opts.requiredProtocolVersion)) {
4606
+ cleanup();
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);
4612
+ return;
4613
+ }
3383
4614
  ackSeen = true;
3384
4615
  ackResult = msg.result;
3385
4616
  if (!opts?.requireUpdateOnAck || session.updateRevision > startRevision) {
@@ -3395,45 +4626,95 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
3395
4626
  }
3396
4627
  if (msg.type === 'error') {
3397
4628
  cleanup();
3398
- reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
4629
+ reject(wireErrorFromMessage(msg, actionId));
3399
4630
  return;
3400
4631
  }
3401
4632
  if (msg.type === 'frame') {
3402
4633
  cleanup();
3403
- resolve({ status: 'updated', timeoutMs });
4634
+ resolve({ status: 'updated', timeoutMs, ...ackPayload() });
3404
4635
  }
3405
4636
  else if (msg.type === 'patch' && session.layout) {
3406
4637
  cleanup();
3407
- resolve({ status: 'updated', timeoutMs });
4638
+ resolve({ status: 'updated', timeoutMs, ...ackPayload() });
3408
4639
  }
3409
4640
  else if (msg.type === 'ack') {
3410
4641
  cleanup();
3411
4642
  resolve({
3412
4643
  status: 'acknowledged',
3413
4644
  timeoutMs,
4645
+ requestId,
4646
+ actionId,
3414
4647
  ...(msg.result !== undefined ? { result: msg.result } : {}),
3415
4648
  });
3416
4649
  }
3417
4650
  }
3418
- catch { /* ignore */ }
4651
+ catch (err) {
4652
+ cleanup();
4653
+ reject(err instanceof Error ? err : new Error(String(err)));
4654
+ }
3419
4655
  };
3420
4656
  // Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
3421
4657
  const timeout = setTimeout(() => {
3422
4658
  cleanup();
3423
- 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) {
3424
4667
  resolve({ status: 'updated', timeoutMs, ...ackPayload() });
3425
4668
  return;
3426
4669
  }
3427
- if (requestId && ackSeen) {
4670
+ if (requestId && ackSeen && (!opts?.requireUpdateOnAck || session.updateRevision > startRevision)) {
3428
4671
  resolve({ status: 'acknowledged', timeoutMs, ...ackPayload() });
3429
4672
  return;
3430
4673
  }
3431
- resolve({ status: 'timed_out', timeoutMs });
4674
+ if (operation.mutating)
4675
+ rememberAmbiguousOperation(session, operation);
4676
+ resolve({ status: 'timed_out', timeoutMs, requestId, actionId });
3432
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
+ };
3433
4690
  function cleanup() {
3434
4691
  clearTimeout(timeout);
3435
- 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
+ }
3436
4718
  }
3437
- session.ws.on('message', onMessage);
3438
4719
  });
3439
4720
  }