@geometra/mcp 1.42.0 → 1.43.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/__tests__/ats-integration.test.js +4 -0
- package/dist/__tests__/server-batch-results.test.js +4 -0
- package/dist/__tests__/session-isolation.test.js +116 -1
- package/dist/__tests__/session-model.test.js +74 -0
- package/dist/server.js +187 -72
- package/dist/session.d.ts +57 -0
- package/dist/session.js +112 -10
- package/package.json +1 -1
|
@@ -71,6 +71,9 @@ vi.mock('../session.js', () => ({
|
|
|
71
71
|
prewarmProxy: mockState.prewarmProxy,
|
|
72
72
|
disconnect: vi.fn(),
|
|
73
73
|
getSession: vi.fn(() => mockState.session),
|
|
74
|
+
resolveSession: vi.fn((id) => ({ kind: 'ok', session: mockState.session, ...(id ? { id } : {}) })),
|
|
75
|
+
listSessions: vi.fn(() => [{ id: 's1', url: 'https://jobs.example.com/application' }]),
|
|
76
|
+
getDefaultSessionId: vi.fn(() => 's1'),
|
|
74
77
|
sendClick: mockState.sendClick,
|
|
75
78
|
sendType: mockState.sendType,
|
|
76
79
|
sendKey: mockState.sendKey,
|
|
@@ -78,6 +81,7 @@ vi.mock('../session.js', () => ({
|
|
|
78
81
|
sendFieldText: mockState.sendFieldText,
|
|
79
82
|
sendFieldChoice: mockState.sendFieldChoice,
|
|
80
83
|
sendFillFields: mockState.sendFillFields,
|
|
84
|
+
sendFillOtp: vi.fn(async () => ({ status: 'updated', timeoutMs: 5000, result: { cellCount: 6, filledCount: 6 } })),
|
|
81
85
|
sendListboxPick: mockState.sendListboxPick,
|
|
82
86
|
sendSelectOption: mockState.sendSelectOption,
|
|
83
87
|
sendSetChecked: mockState.sendSetChecked,
|
|
@@ -67,6 +67,9 @@ vi.mock('../session.js', () => ({
|
|
|
67
67
|
prewarmProxy: mockState.prewarmProxy,
|
|
68
68
|
disconnect: vi.fn(),
|
|
69
69
|
getSession: vi.fn(() => mockState.session),
|
|
70
|
+
resolveSession: vi.fn((id) => ({ kind: 'ok', session: mockState.session, ...(id ? { id } : {}) })),
|
|
71
|
+
listSessions: vi.fn(() => [{ id: 's1', url: 'https://jobs.example.com/application' }]),
|
|
72
|
+
getDefaultSessionId: vi.fn(() => 's1'),
|
|
70
73
|
sendClick: mockState.sendClick,
|
|
71
74
|
sendType: mockState.sendType,
|
|
72
75
|
sendKey: mockState.sendKey,
|
|
@@ -74,6 +77,7 @@ vi.mock('../session.js', () => ({
|
|
|
74
77
|
sendFieldText: mockState.sendFieldText,
|
|
75
78
|
sendFieldChoice: mockState.sendFieldChoice,
|
|
76
79
|
sendFillFields: mockState.sendFillFields,
|
|
80
|
+
sendFillOtp: vi.fn(async () => ({ status: 'updated', timeoutMs: 5000, result: { cellCount: 6, filledCount: 6 } })),
|
|
77
81
|
sendListboxPick: mockState.sendListboxPick,
|
|
78
82
|
sendSelectOption: mockState.sendSelectOption,
|
|
79
83
|
sendSetChecked: mockState.sendSetChecked,
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
30
30
|
import http from 'node:http';
|
|
31
|
-
import { buildA11yTree, connectThroughProxy, disconnect } from '../session.js';
|
|
31
|
+
import { buildA11yTree, connectThroughProxy, disconnect, getDefaultSessionId, resolveSession, } from '../session.js';
|
|
32
32
|
const PAGE_HTML = `<!doctype html>
|
|
33
33
|
<html>
|
|
34
34
|
<head><title>isolation-fixture</title></head>
|
|
@@ -190,4 +190,119 @@ describe('connectThroughProxy({ isolated: true })', () => {
|
|
|
190
190
|
expect(sessionA.id).toBe(sessionB.id);
|
|
191
191
|
disconnect({ sessionId: sessionA.id, closeProxy: true });
|
|
192
192
|
}, 30_000);
|
|
193
|
+
// ── Bug #1 regression: parallel isolated sessions must never share a
|
|
194
|
+
// default pointer, and tool-call resolution must refuse to implicitly
|
|
195
|
+
// route to an isolated session when no sessionId is passed.
|
|
196
|
+
it('three parallel isolated sessions stay strictly independent — no shared default, strict id routing', async () => {
|
|
197
|
+
// Spawn three isolated sessions in parallel, each pinned to a
|
|
198
|
+
// different local URL. Each session gets its own Chromium instance
|
|
199
|
+
// (isolated: true forces a fresh proxy every time), and each tool
|
|
200
|
+
// call in real usage would address its session by the returned id.
|
|
201
|
+
const [sessionA, sessionB, sessionC] = await Promise.all([
|
|
202
|
+
connectThroughProxy({
|
|
203
|
+
pageUrl: `${baseUrl}/page-a`,
|
|
204
|
+
headless: true,
|
|
205
|
+
isolated: true,
|
|
206
|
+
}),
|
|
207
|
+
connectThroughProxy({
|
|
208
|
+
pageUrl: `${baseUrl}/page-b`,
|
|
209
|
+
headless: true,
|
|
210
|
+
isolated: true,
|
|
211
|
+
}),
|
|
212
|
+
connectThroughProxy({
|
|
213
|
+
pageUrl: `${baseUrl}/page-a`,
|
|
214
|
+
headless: true,
|
|
215
|
+
isolated: true,
|
|
216
|
+
}),
|
|
217
|
+
]);
|
|
218
|
+
try {
|
|
219
|
+
// Invariant 1: every isolated session is flagged, and all three got
|
|
220
|
+
// distinct session ids (no collision).
|
|
221
|
+
expect(sessionA.isolated).toBe(true);
|
|
222
|
+
expect(sessionB.isolated).toBe(true);
|
|
223
|
+
expect(sessionC.isolated).toBe(true);
|
|
224
|
+
expect(new Set([sessionA.id, sessionB.id, sessionC.id]).size).toBe(3);
|
|
225
|
+
// Invariant 2: the implicit default-session pointer must not be set
|
|
226
|
+
// to any of these isolated sessions. This is the contamination fix —
|
|
227
|
+
// before v1.43, `connect()` unconditionally set defaultSessionId to
|
|
228
|
+
// whichever session most recently finished connecting, so parallel
|
|
229
|
+
// workers without an explicit sessionId would race onto each
|
|
230
|
+
// other's browsers.
|
|
231
|
+
const implicitDefault = getDefaultSessionId();
|
|
232
|
+
expect(implicitDefault).toBeNull();
|
|
233
|
+
// Invariant 3: resolveSession() with no id must refuse to pick any
|
|
234
|
+
// of these isolated sessions. Callers that omit sessionId get an
|
|
235
|
+
// ambiguous error and are forced to pass an explicit id.
|
|
236
|
+
const ambiguous = resolveSession();
|
|
237
|
+
expect(ambiguous.kind).toBe('ambiguous');
|
|
238
|
+
if (ambiguous.kind === 'ambiguous') {
|
|
239
|
+
expect(ambiguous.activeIds).toEqual(expect.arrayContaining([sessionA.id, sessionB.id, sessionC.id]));
|
|
240
|
+
expect(ambiguous.isolatedIds).toEqual(expect.arrayContaining([sessionA.id, sessionB.id, sessionC.id]));
|
|
241
|
+
}
|
|
242
|
+
// Invariant 4: explicit id routing must return exactly the
|
|
243
|
+
// requested session — never a peer — even under parallel load.
|
|
244
|
+
const lookupA = resolveSession(sessionA.id);
|
|
245
|
+
const lookupB = resolveSession(sessionB.id);
|
|
246
|
+
const lookupC = resolveSession(sessionC.id);
|
|
247
|
+
expect(lookupA.kind).toBe('ok');
|
|
248
|
+
expect(lookupB.kind).toBe('ok');
|
|
249
|
+
expect(lookupC.kind).toBe('ok');
|
|
250
|
+
if (lookupA.kind === 'ok')
|
|
251
|
+
expect(lookupA.session).toBe(sessionA);
|
|
252
|
+
if (lookupB.kind === 'ok')
|
|
253
|
+
expect(lookupB.session).toBe(sessionB);
|
|
254
|
+
if (lookupC.kind === 'ok')
|
|
255
|
+
expect(lookupC.session).toBe(sessionC);
|
|
256
|
+
// Invariant 5: each session's page still reads its own per-URL
|
|
257
|
+
// marker. This is the end-to-end "different browsers" check —
|
|
258
|
+
// even though they share the same MCP server process, the
|
|
259
|
+
// underlying Chromium / localStorage is distinct per session.
|
|
260
|
+
const [markerA, markerB, markerC] = await Promise.all([
|
|
261
|
+
waitForMarkerText(sessionA, 'marker-from-'),
|
|
262
|
+
waitForMarkerText(sessionB, 'marker-from-'),
|
|
263
|
+
waitForMarkerText(sessionC, 'marker-from-'),
|
|
264
|
+
]);
|
|
265
|
+
expect(markerA).toBe('marker-from-/page-a');
|
|
266
|
+
expect(markerB).toBe('marker-from-/page-b');
|
|
267
|
+
expect(markerC).toBe('marker-from-/page-a');
|
|
268
|
+
// Invariant 6: looking up a session id that no longer exists must
|
|
269
|
+
// return `not_found` with the full active-id list, NEVER silently
|
|
270
|
+
// fall back to the default. This is the v1.42-era contamination
|
|
271
|
+
// vector: workers were seeing their tool calls routed to whatever
|
|
272
|
+
// the "most recent" session happened to be.
|
|
273
|
+
disconnect({ sessionId: sessionB.id, closeProxy: true });
|
|
274
|
+
const afterDisconnect = resolveSession(sessionB.id);
|
|
275
|
+
expect(afterDisconnect.kind).toBe('not_found');
|
|
276
|
+
if (afterDisconnect.kind === 'not_found') {
|
|
277
|
+
expect(afterDisconnect.id).toBe(sessionB.id);
|
|
278
|
+
}
|
|
279
|
+
// And the implicit default is STILL null — disconnecting one
|
|
280
|
+
// isolated session doesn't promote the next one to default.
|
|
281
|
+
expect(getDefaultSessionId()).toBeNull();
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
disconnect({ sessionId: sessionA.id, closeProxy: true });
|
|
285
|
+
disconnect({ sessionId: sessionC.id, closeProxy: true });
|
|
286
|
+
}
|
|
287
|
+
}, 60_000);
|
|
288
|
+
it('a single non-isolated session DOES become the implicit default (back-compat)', async () => {
|
|
289
|
+
// Single pooled session — the default-fallback behavior is retained
|
|
290
|
+
// for callers that don't pass sessionId and aren't running in
|
|
291
|
+
// parallel. This preserves every existing single-worker script.
|
|
292
|
+
const session = await connectThroughProxy({
|
|
293
|
+
pageUrl: `${baseUrl}/page-a`,
|
|
294
|
+
headless: true,
|
|
295
|
+
});
|
|
296
|
+
try {
|
|
297
|
+
expect(session.isolated).toBeFalsy();
|
|
298
|
+
expect(getDefaultSessionId()).toBe(session.id);
|
|
299
|
+
const resolved = resolveSession();
|
|
300
|
+
expect(resolved.kind).toBe('ok');
|
|
301
|
+
if (resolved.kind === 'ok')
|
|
302
|
+
expect(resolved.session.id).toBe(session.id);
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
disconnect({ sessionId: session.id, closeProxy: true });
|
|
306
|
+
}
|
|
307
|
+
}, 30_000);
|
|
193
308
|
});
|
|
@@ -422,6 +422,80 @@ describe('buildFormSchemas', () => {
|
|
|
422
422
|
},
|
|
423
423
|
});
|
|
424
424
|
});
|
|
425
|
+
// Bug #3 regression (v1.43): a React Select / Headless UI / Radix
|
|
426
|
+
// autocomplete combobox renders as a plain <input role="textbox"> in the
|
|
427
|
+
// accessibility tree. The extractor tags it with meta.isAutocompleteCombobox
|
|
428
|
+
// when its ancestry matches the autocomplete-wrapper fingerprint
|
|
429
|
+
// (see isAutocompleteComboboxAncestry in packages/proxy/src/extractor.ts,
|
|
430
|
+
// which mirrors isAutocompleteCombobox in packages/proxy/src/dom-actions.ts).
|
|
431
|
+
// The form-schema classifier MUST re-tag that field as choice/listbox so
|
|
432
|
+
// fill_form routes it through pick_listbox_option — otherwise Greenhouse
|
|
433
|
+
// Remix Country pickers (and every other React Select) get fed through
|
|
434
|
+
// the plain text-fill path, the controlled form state never commits, and
|
|
435
|
+
// Submit fails with "This field is required" while the field looks filled.
|
|
436
|
+
it('re-classifies autocomplete-combobox-wrapped textboxes as choice/listbox', () => {
|
|
437
|
+
const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
|
|
438
|
+
children: [
|
|
439
|
+
node('form', 'Address', { x: 20, y: 20, width: 760, height: 480 }, {
|
|
440
|
+
path: [0],
|
|
441
|
+
children: [
|
|
442
|
+
// A plain text field — should stay classified as `text`.
|
|
443
|
+
node('textbox', 'Postal code', { x: 40, y: 60, width: 200, height: 36 }, {
|
|
444
|
+
path: [0, 0],
|
|
445
|
+
state: { required: true },
|
|
446
|
+
meta: { controlTag: 'input', inputType: 'text' },
|
|
447
|
+
}),
|
|
448
|
+
// The React Select Country picker: role=textbox but the
|
|
449
|
+
// extractor flagged its ancestry as autocomplete-combobox. The
|
|
450
|
+
// classifier must re-tag this as choice/listbox.
|
|
451
|
+
node('textbox', 'Country', { x: 40, y: 120, width: 320, height: 36 }, {
|
|
452
|
+
path: [0, 1],
|
|
453
|
+
state: { required: true, invalid: true },
|
|
454
|
+
meta: {
|
|
455
|
+
controlTag: 'input',
|
|
456
|
+
isAutocompleteCombobox: true,
|
|
457
|
+
},
|
|
458
|
+
}),
|
|
459
|
+
// A real native <combobox> wrapping a <select> — should stay
|
|
460
|
+
// classified as choice/select (NOT listbox) because the
|
|
461
|
+
// controlTag is 'select' and no autocomplete flag is set.
|
|
462
|
+
node('combobox', 'State / Region', { x: 40, y: 180, width: 320, height: 36 }, {
|
|
463
|
+
path: [0, 2],
|
|
464
|
+
state: { required: true },
|
|
465
|
+
value: 'California',
|
|
466
|
+
meta: { controlTag: 'select' },
|
|
467
|
+
}),
|
|
468
|
+
],
|
|
469
|
+
}),
|
|
470
|
+
],
|
|
471
|
+
});
|
|
472
|
+
const schemas = buildFormSchemas(tree);
|
|
473
|
+
expect(schemas).toHaveLength(1);
|
|
474
|
+
const fields = schemas[0]?.fields ?? [];
|
|
475
|
+
expect(fields).toHaveLength(3);
|
|
476
|
+
expect(fields[0]).toMatchObject({
|
|
477
|
+
kind: 'text',
|
|
478
|
+
label: 'Postal code',
|
|
479
|
+
required: true,
|
|
480
|
+
});
|
|
481
|
+
// Critical assertion: the Country textbox comes out as a listbox
|
|
482
|
+
// choice so fill_form will use pick_listbox_option.
|
|
483
|
+
expect(fields[1]).toMatchObject({
|
|
484
|
+
kind: 'choice',
|
|
485
|
+
choiceType: 'listbox',
|
|
486
|
+
label: 'Country',
|
|
487
|
+
required: true,
|
|
488
|
+
invalid: true,
|
|
489
|
+
});
|
|
490
|
+
// Back-compat: native <select> still classified as choice/select.
|
|
491
|
+
expect(fields[2]).toMatchObject({
|
|
492
|
+
kind: 'choice',
|
|
493
|
+
choiceType: 'select',
|
|
494
|
+
label: 'State / Region',
|
|
495
|
+
required: true,
|
|
496
|
+
value: 'California',
|
|
497
|
+
});
|
|
498
|
+
});
|
|
425
499
|
});
|
|
426
500
|
describe('buildFormRequiredSnapshot', () => {
|
|
427
501
|
it('keeps offscreen required fields with bounds, visibility, and scroll hints', () => {
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { performance } from 'node:perf_hooks';
|
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
|
|
6
|
-
import { connect, connectThroughProxy, disconnect,
|
|
6
|
+
import { connect, connectThroughProxy, disconnect, resolveSession, listSessions, getDefaultSessionId, prewarmProxy, sendClick, sendFillFields, sendFillOtp, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, parseSectionId, findNodeByPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
7
7
|
function checkedStateInput() {
|
|
8
8
|
return z
|
|
9
9
|
.union([z.boolean(), z.literal('mixed')])
|
|
@@ -476,9 +476,10 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
|
|
|
476
476
|
Unknown parameter names are rejected (strict schema). To wait until visible text goes away (e.g. a parsing banner), use geometra_wait_for with that substring in text and present: false — there is no textGone field.`,
|
|
477
477
|
inputSchema: geometraQueryInputSchema,
|
|
478
478
|
}, async ({ id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, maxResults, detail, sessionId }) => {
|
|
479
|
-
const
|
|
480
|
-
if (
|
|
481
|
-
return
|
|
479
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
480
|
+
if ('error' in sessionResult)
|
|
481
|
+
return sessionResult.error;
|
|
482
|
+
const session = sessionResult.session;
|
|
482
483
|
const a11y = await sessionA11yWhenReady(session);
|
|
483
484
|
if (!a11y)
|
|
484
485
|
return err('No UI tree available');
|
|
@@ -533,9 +534,10 @@ Use this when geometra_page_model tells you the page shape, but you want one dir
|
|
|
533
534
|
detail: detailInput(),
|
|
534
535
|
sessionId: sessionIdInput,
|
|
535
536
|
}, async ({ name, role, sectionText, promptText, itemText, maxResults, detail, sessionId }) => {
|
|
536
|
-
const
|
|
537
|
-
if (
|
|
538
|
-
return
|
|
537
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
538
|
+
if ('error' in sessionResult)
|
|
539
|
+
return sessionResult.error;
|
|
540
|
+
const session = sessionResult.session;
|
|
539
541
|
const a11y = await sessionA11yWhenReady(session);
|
|
540
542
|
if (!a11y)
|
|
541
543
|
return err('No UI tree available');
|
|
@@ -568,9 +570,10 @@ Use this when geometra_page_model tells you the page shape, but you want one dir
|
|
|
568
570
|
The filter matches the same fields as geometra_query (strict schema — unknown keys error). Set \`present: false\` to wait until **no** node matches — for example Ashby/Lever-style “Parsing your resume” or any “Parsing…” banner: \`{ "text": "Parsing", "present": false }\` (tune the substring to the site). Do not use a textGone parameter; use \`text\` + \`present: false\`, or \`geometra_wait_for_resume_parse\` for the usual post-upload parsing banner.`,
|
|
569
571
|
inputSchema: geometraWaitForInputSchema,
|
|
570
572
|
}, async ({ id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs, detail, sessionId }) => {
|
|
571
|
-
const
|
|
572
|
-
if (
|
|
573
|
-
return
|
|
573
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
574
|
+
if ('error' in sessionResult)
|
|
575
|
+
return sessionResult.error;
|
|
576
|
+
const session = sessionResult.session;
|
|
574
577
|
const filterProbe = {
|
|
575
578
|
id,
|
|
576
579
|
role,
|
|
@@ -620,9 +623,10 @@ The filter matches the same fields as geometra_query (strict schema — unknown
|
|
|
620
623
|
Equivalent to \`geometra_wait_for\` with \`present: false\` and \`text\` set to a banner substring. Default \`text\` is \`Parsing\` (tune per site). Strict schema (unknown keys rejected).`,
|
|
621
624
|
inputSchema: geometraWaitForResumeParseInputSchema,
|
|
622
625
|
}, async ({ text, timeoutMs, sessionId }) => {
|
|
623
|
-
const
|
|
624
|
-
if (
|
|
625
|
-
return
|
|
626
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
627
|
+
if ('error' in sessionResult)
|
|
628
|
+
return sessionResult.error;
|
|
629
|
+
const session = sessionResult.session;
|
|
626
630
|
const filter = { text };
|
|
627
631
|
const waited = await waitForSemanticCondition(session, {
|
|
628
632
|
filter,
|
|
@@ -647,9 +651,10 @@ Captures the current URL, then polls until the URL changes and a stable UI tree
|
|
|
647
651
|
expectedUrl: z.string().optional().describe('Optional URL substring to match — keeps waiting if the URL changes to something else (e.g. intermediate redirects)'),
|
|
648
652
|
sessionId: sessionIdInput,
|
|
649
653
|
}, async ({ timeoutMs, expectedUrl, sessionId }) => {
|
|
650
|
-
const
|
|
651
|
-
if (
|
|
652
|
-
return
|
|
654
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
655
|
+
if ('error' in sessionResult)
|
|
656
|
+
return sessionResult.error;
|
|
657
|
+
const session = sessionResult.session;
|
|
653
658
|
const beforeA11y = sessionA11y(session);
|
|
654
659
|
const beforeUrl = beforeA11y?.meta?.pageUrl ?? session.url;
|
|
655
660
|
const startedAt = performance.now();
|
|
@@ -719,9 +724,10 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
719
724
|
detail: detailInput(),
|
|
720
725
|
sessionId: sessionIdInput,
|
|
721
726
|
}, async ({ fields, stopOnError, failOnInvalid, includeSteps, detail, sessionId }) => {
|
|
722
|
-
const
|
|
723
|
-
if (
|
|
724
|
-
return
|
|
727
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
728
|
+
if ('error' in sessionResult)
|
|
729
|
+
return sessionResult.error;
|
|
730
|
+
const session = sessionResult.session;
|
|
725
731
|
const resolvedFields = resolveFillFieldInputs(session, fields);
|
|
726
732
|
if (!resolvedFields.ok)
|
|
727
733
|
return err(resolvedFields.error);
|
|
@@ -1262,9 +1268,10 @@ Use this first on normal HTML pages when you want to understand the page shape w
|
|
|
1262
1268
|
.describe('Attach a base64 PNG viewport screenshot. Requires @geometra/proxy. Use when geometry alone is ambiguous (icon-only buttons, visual styling cues).'),
|
|
1263
1269
|
sessionId: sessionIdInput,
|
|
1264
1270
|
}, async ({ maxPrimaryActions, maxSectionsPerKind, includeScreenshot, sessionId }) => {
|
|
1265
|
-
const
|
|
1266
|
-
if (
|
|
1267
|
-
return
|
|
1271
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1272
|
+
if ('error' in sessionResult)
|
|
1273
|
+
return sessionResult.error;
|
|
1274
|
+
const session = sessionResult.session;
|
|
1268
1275
|
const a11y = await sessionA11yWhenReady(session);
|
|
1269
1276
|
if (!a11y)
|
|
1270
1277
|
return err('No UI tree available');
|
|
@@ -1341,9 +1348,10 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
1341
1348
|
includeBounds: z.boolean().optional().default(false).describe('Include bounds for fields/actions/headings/items'),
|
|
1342
1349
|
sessionId: sessionIdInput,
|
|
1343
1350
|
}, async ({ id, maxHeadings, maxFields, fieldOffset, onlyRequiredFields, onlyInvalidFields, maxActions, actionOffset, maxLists, listOffset, maxItems, itemOffset, maxTextPreview, includeBounds, sessionId, }) => {
|
|
1344
|
-
const
|
|
1345
|
-
if (
|
|
1346
|
-
return
|
|
1351
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1352
|
+
if ('error' in sessionResult)
|
|
1353
|
+
return sessionResult.error;
|
|
1354
|
+
const session = sessionResult.session;
|
|
1347
1355
|
const a11y = await sessionA11yWhenReady(session);
|
|
1348
1356
|
if (!a11y)
|
|
1349
1357
|
return err('No UI tree available');
|
|
@@ -1383,9 +1391,10 @@ This is the preferred approach for scrolling — no need to guess pixel offsets
|
|
|
1383
1391
|
.describe('Per-scroll wait timeout (default 2500ms)'),
|
|
1384
1392
|
sessionId: sessionIdInput,
|
|
1385
1393
|
}, async ({ id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxSteps, timeoutMs, sessionId }) => {
|
|
1386
|
-
const
|
|
1387
|
-
if (
|
|
1388
|
-
return
|
|
1394
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1395
|
+
if ('error' in sessionResult)
|
|
1396
|
+
return sessionResult.error;
|
|
1397
|
+
const session = sessionResult.session;
|
|
1389
1398
|
const filter = {
|
|
1390
1399
|
id,
|
|
1391
1400
|
role,
|
|
@@ -1451,9 +1460,10 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
1451
1460
|
detail: detailInput(),
|
|
1452
1461
|
sessionId: sessionIdInput,
|
|
1453
1462
|
}, async ({ x, y, id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxRevealSteps, revealTimeoutMs, waitFor, timeoutMs, detail, sessionId }) => {
|
|
1454
|
-
const
|
|
1455
|
-
if (
|
|
1456
|
-
return
|
|
1463
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1464
|
+
if ('error' in sessionResult)
|
|
1465
|
+
return sessionResult.error;
|
|
1466
|
+
const session = sessionResult.session;
|
|
1457
1467
|
const before = sessionA11y(session);
|
|
1458
1468
|
const resolved = await resolveClickLocation(session, {
|
|
1459
1469
|
x,
|
|
@@ -1547,9 +1557,10 @@ Each character is sent as a key event through the geometry protocol. Returns a c
|
|
|
1547
1557
|
detail: detailInput(),
|
|
1548
1558
|
sessionId: sessionIdInput,
|
|
1549
1559
|
}, async ({ text, timeoutMs, detail, sessionId }) => {
|
|
1550
|
-
const
|
|
1551
|
-
if (
|
|
1552
|
-
return
|
|
1560
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1561
|
+
if ('error' in sessionResult)
|
|
1562
|
+
return sessionResult.error;
|
|
1563
|
+
const session = sessionResult.session;
|
|
1553
1564
|
const before = sessionA11y(session);
|
|
1554
1565
|
const wait = await sendType(session, text, timeoutMs);
|
|
1555
1566
|
const summary = postActionSummary(session, before, wait, detail);
|
|
@@ -1575,9 +1586,10 @@ Each character is sent as a key event through the geometry protocol. Returns a c
|
|
|
1575
1586
|
detail: detailInput(),
|
|
1576
1587
|
sessionId: sessionIdInput,
|
|
1577
1588
|
}, async ({ key, shift, ctrl, meta, alt, timeoutMs, detail, sessionId }) => {
|
|
1578
|
-
const
|
|
1579
|
-
if (
|
|
1580
|
-
return
|
|
1589
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1590
|
+
if ('error' in sessionResult)
|
|
1591
|
+
return sessionResult.error;
|
|
1592
|
+
const session = sessionResult.session;
|
|
1581
1593
|
const before = sessionA11y(session);
|
|
1582
1594
|
const wait = await sendKey(session, key, { shift, ctrl, meta, alt }, timeoutMs);
|
|
1583
1595
|
const summary = postActionSummary(session, before, wait, detail);
|
|
@@ -1586,6 +1598,46 @@ Each character is sent as a key event through the geometry protocol. Returns a c
|
|
|
1586
1598
|
...waitStatusPayload(wait),
|
|
1587
1599
|
}, detail));
|
|
1588
1600
|
});
|
|
1601
|
+
// ── fill OTP / verification-code box group ─────────────────────
|
|
1602
|
+
server.tool('geometra_fill_otp', `Fill a multi-cell OTP / verification-code input group (e.g. Greenhouse's 8-box security code, generic 6-digit 2FA widgets, Auth0 / Clerk per-char inputs). Auto-detects a row of sibling <input maxlength="1"> elements at adjacent x-coordinates and types each character through a real keyboard event cycle so React's per-cell onKeyDown handler can auto-advance focus.
|
|
1603
|
+
|
|
1604
|
+
Use this when geometra_fill_fields / geometra_fill_form fail on a 6-digit code field because the cells share accessibility bounds and the whole string gets written into cell 0 (which has maxlength=1 and silently truncates). This primitive is also auto-invoked by geometra_fill_fields and geometra_fill_form when a labeled field matches a verification-code / security-code / OTP pattern AND the underlying DOM is a cell group — you should only need to call it directly when the label doesn't match the auto-detection heuristic.
|
|
1605
|
+
|
|
1606
|
+
Detection is fully generic (no site branding). It refuses to run if the detected group's cell count is smaller than the typed value length, and it post-verifies every cell's value so you get an honest error instead of a silent "success" that leaves boxes empty.`, {
|
|
1607
|
+
value: z.string().min(1).max(32).describe('The code to type (e.g. "12345678" for an 8-digit security code)'),
|
|
1608
|
+
fieldLabel: z.string().optional().describe('Optional label to scope the OTP search (e.g. "Security code", "Verification code"). When omitted, scans the whole document for any qualifying cell group.'),
|
|
1609
|
+
perCharDelayMs: z.number().int().min(0).max(500).optional().describe('Optional per-character typing delay in milliseconds (default 30). Raise this if the widget needs longer to run its onKeyDown auto-advance.'),
|
|
1610
|
+
timeoutMs: z
|
|
1611
|
+
.number()
|
|
1612
|
+
.int()
|
|
1613
|
+
.min(500)
|
|
1614
|
+
.max(60_000)
|
|
1615
|
+
.optional()
|
|
1616
|
+
.describe('Optional action wait timeout'),
|
|
1617
|
+
detail: detailInput(),
|
|
1618
|
+
sessionId: sessionIdInput,
|
|
1619
|
+
}, async ({ value, fieldLabel, perCharDelayMs, timeoutMs, detail, sessionId }) => {
|
|
1620
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1621
|
+
if ('error' in sessionResult)
|
|
1622
|
+
return sessionResult.error;
|
|
1623
|
+
const session = sessionResult.session;
|
|
1624
|
+
const before = sessionA11y(session);
|
|
1625
|
+
try {
|
|
1626
|
+
const wait = await sendFillOtp(session, value, { fieldLabel, perCharDelayMs }, timeoutMs);
|
|
1627
|
+
const summary = postActionSummary(session, before, wait, detail);
|
|
1628
|
+
const result = wait.result;
|
|
1629
|
+
return ok(detailText(`Filled OTP code (${value.length} chars).\n${summary}`, {
|
|
1630
|
+
...compactTextValue(value),
|
|
1631
|
+
...(fieldLabel ? { fieldLabel } : {}),
|
|
1632
|
+
...(result?.cellCount !== undefined ? { cellCount: result.cellCount } : {}),
|
|
1633
|
+
...(result?.filledCount !== undefined ? { filledCount: result.filledCount } : {}),
|
|
1634
|
+
...waitStatusPayload(wait),
|
|
1635
|
+
}, detail));
|
|
1636
|
+
}
|
|
1637
|
+
catch (e) {
|
|
1638
|
+
return err(e.message);
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1589
1641
|
// ── upload files (proxy) ───────────────────────────────────────
|
|
1590
1642
|
server.tool('geometra_upload_files', `Attach local files to a file input. Requires \`@geometra/proxy\` (paths exist on the proxy host).
|
|
1591
1643
|
|
|
@@ -1613,9 +1665,10 @@ Strategies: **auto** (default) tries chooser click if x,y given, else a labeled
|
|
|
1613
1665
|
detail: detailInput(),
|
|
1614
1666
|
sessionId: sessionIdInput,
|
|
1615
1667
|
}, async ({ paths, x, y, fieldLabel, exact, strategy, dropX, dropY, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
|
|
1616
|
-
const
|
|
1617
|
-
if (
|
|
1618
|
-
return
|
|
1668
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1669
|
+
if ('error' in sessionResult)
|
|
1670
|
+
return sessionResult.error;
|
|
1671
|
+
const session = sessionResult.session;
|
|
1619
1672
|
const before = sessionA11y(session);
|
|
1620
1673
|
try {
|
|
1621
1674
|
const wait = await sendFileUpload(session, paths, {
|
|
@@ -1659,9 +1712,10 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
|
|
|
1659
1712
|
detail: detailInput(),
|
|
1660
1713
|
sessionId: sessionIdInput,
|
|
1661
1714
|
}, async ({ label, exact, openX, openY, fieldLabel, contextText, sectionText, query, timeoutMs, detail, sessionId }) => {
|
|
1662
|
-
const
|
|
1663
|
-
if (
|
|
1664
|
-
return
|
|
1715
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1716
|
+
if ('error' in sessionResult)
|
|
1717
|
+
return sessionResult.error;
|
|
1718
|
+
const session = sessionResult.session;
|
|
1665
1719
|
const before = sessionA11y(session);
|
|
1666
1720
|
try {
|
|
1667
1721
|
const wait = await sendListboxPick(session, label, {
|
|
@@ -1709,9 +1763,10 @@ Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbo
|
|
|
1709
1763
|
detail: detailInput(),
|
|
1710
1764
|
sessionId: sessionIdInput,
|
|
1711
1765
|
}, async ({ x, y, value, label, index, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
|
|
1712
|
-
const
|
|
1713
|
-
if (
|
|
1714
|
-
return
|
|
1766
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1767
|
+
if ('error' in sessionResult)
|
|
1768
|
+
return sessionResult.error;
|
|
1769
|
+
const session = sessionResult.session;
|
|
1715
1770
|
if (value === undefined && label === undefined && index === undefined) {
|
|
1716
1771
|
return err('Provide at least one of value, label, or index');
|
|
1717
1772
|
}
|
|
@@ -1750,9 +1805,10 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
1750
1805
|
detail: detailInput(),
|
|
1751
1806
|
sessionId: sessionIdInput,
|
|
1752
1807
|
}, async ({ label, checked, exact, controlType, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
|
|
1753
|
-
const
|
|
1754
|
-
if (
|
|
1755
|
-
return
|
|
1808
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1809
|
+
if ('error' in sessionResult)
|
|
1810
|
+
return sessionResult.error;
|
|
1811
|
+
const session = sessionResult.session;
|
|
1756
1812
|
const before = sessionA11y(session);
|
|
1757
1813
|
try {
|
|
1758
1814
|
const wait = await sendSetChecked(session, label, { checked, exact, controlType }, timeoutMs);
|
|
@@ -1784,9 +1840,10 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
1784
1840
|
detail: detailInput(),
|
|
1785
1841
|
sessionId: sessionIdInput,
|
|
1786
1842
|
}, async ({ deltaY, deltaX, x, y, timeoutMs, detail, sessionId }) => {
|
|
1787
|
-
const
|
|
1788
|
-
if (
|
|
1789
|
-
return
|
|
1843
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1844
|
+
if ('error' in sessionResult)
|
|
1845
|
+
return sessionResult.error;
|
|
1846
|
+
const session = sessionResult.session;
|
|
1790
1847
|
const before = sessionA11y(session);
|
|
1791
1848
|
try {
|
|
1792
1849
|
const wait = await sendWheel(session, deltaY, { deltaX, x, y }, timeoutMs);
|
|
@@ -1815,9 +1872,10 @@ Use this for dropdowns, location pickers, or any scrollable list where items are
|
|
|
1815
1872
|
scrollDelta: z.number().optional().default(300).describe('Vertical scroll delta per step (default 300)'),
|
|
1816
1873
|
sessionId: sessionIdInput,
|
|
1817
1874
|
}, async ({ listId, role, scrollX, scrollY, maxItems, maxScrollSteps, scrollDelta, sessionId }) => {
|
|
1818
|
-
const
|
|
1819
|
-
if (
|
|
1820
|
-
return
|
|
1875
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1876
|
+
if ('error' in sessionResult)
|
|
1877
|
+
return sessionResult.error;
|
|
1878
|
+
const session = sessionResult.session;
|
|
1821
1879
|
const itemRole = role ?? 'listitem';
|
|
1822
1880
|
const collected = new Map();
|
|
1823
1881
|
const cx = scrollX ?? 400;
|
|
@@ -1891,9 +1949,10 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
|
|
|
1891
1949
|
.describe('Attach a base64 PNG viewport screenshot. Requires @geometra/proxy.'),
|
|
1892
1950
|
sessionId: sessionIdInput,
|
|
1893
1951
|
}, async ({ view, maxNodes, formId, maxFields, includeOptions, includeScreenshot, sessionId }) => {
|
|
1894
|
-
const
|
|
1895
|
-
if (
|
|
1896
|
-
return
|
|
1952
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1953
|
+
if ('error' in sessionResult)
|
|
1954
|
+
return sessionResult.error;
|
|
1955
|
+
const session = sessionResult.session;
|
|
1897
1956
|
const a11y = await sessionA11yWhenReady(session);
|
|
1898
1957
|
if (!a11y)
|
|
1899
1958
|
return err('No UI tree available');
|
|
@@ -1927,10 +1986,15 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
|
|
|
1927
1986
|
// ── layout ───────────────────────────────────────────────────
|
|
1928
1987
|
server.tool('geometra_layout', `Get the raw computed layout geometry — the exact {x, y, width, height} for every node in the UI tree. This is the lowest-level view, useful for pixel-precise assertions in tests.
|
|
1929
1988
|
|
|
1930
|
-
For a token-efficient semantic view, use geometra_snapshot (default compact). For the complete nested tree, geometra_snapshot with view=full.`, {
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1989
|
+
For a token-efficient semantic view, use geometra_snapshot (default compact). For the complete nested tree, geometra_snapshot with view=full.`, {
|
|
1990
|
+
sessionId: sessionIdInput,
|
|
1991
|
+
}, async ({ sessionId }) => {
|
|
1992
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
1993
|
+
if ('error' in sessionResult)
|
|
1994
|
+
return sessionResult.error;
|
|
1995
|
+
const session = sessionResult.session;
|
|
1996
|
+
if (!session.layout)
|
|
1997
|
+
return err('No layout available yet. Wait for the next frame or call geometra_page_model.');
|
|
1934
1998
|
return ok(JSON.stringify(session.layout, null, 2));
|
|
1935
1999
|
});
|
|
1936
2000
|
// ── workflow state ───────────────────────────────────────────
|
|
@@ -1940,9 +2004,10 @@ Use this after navigating to a new page in a multi-step flow (e.g. job applicati
|
|
|
1940
2004
|
clear: z.boolean().optional().default(false).describe('Reset the workflow state'),
|
|
1941
2005
|
sessionId: sessionIdInput,
|
|
1942
2006
|
}, async ({ clear, sessionId }) => {
|
|
1943
|
-
const
|
|
1944
|
-
if (
|
|
1945
|
-
return
|
|
2007
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
2008
|
+
if ('error' in sessionResult)
|
|
2009
|
+
return sessionResult.error;
|
|
2010
|
+
const session = sessionResult.session;
|
|
1946
2011
|
if (clear) {
|
|
1947
2012
|
session.workflowState = undefined;
|
|
1948
2013
|
return ok(JSON.stringify({ cleared: true }));
|
|
@@ -2005,9 +2070,10 @@ Returns \`{ pdf, pageUrl }\` where \`pdf\` is the base64-encoded PDF bytes.`, {
|
|
|
2005
2070
|
.describe('Include background graphics and colors.'),
|
|
2006
2071
|
sessionId: sessionIdInput,
|
|
2007
2072
|
}, async ({ html, format, landscape, margin, printBackground, sessionId }) => {
|
|
2008
|
-
const
|
|
2009
|
-
if (
|
|
2010
|
-
return
|
|
2073
|
+
const sessionResult = resolveToolSession(sessionId);
|
|
2074
|
+
if ('error' in sessionResult)
|
|
2075
|
+
return sessionResult.error;
|
|
2076
|
+
const session = sessionResult.session;
|
|
2011
2077
|
try {
|
|
2012
2078
|
const wait = await sendPdfGenerate(session, {
|
|
2013
2079
|
html: html ?? undefined,
|
|
@@ -2241,10 +2307,24 @@ function pageModelResponsePayload(session, options) {
|
|
|
2241
2307
|
}
|
|
2242
2308
|
async function ensureToolSession(target, missingConnectionMessage = 'Not connected. Call geometra_connect first.') {
|
|
2243
2309
|
if (!target.url && !target.pageUrl) {
|
|
2244
|
-
const
|
|
2245
|
-
if (
|
|
2246
|
-
return { ok:
|
|
2247
|
-
|
|
2310
|
+
const resolved = resolveSession(target.sessionId);
|
|
2311
|
+
if (resolved.kind === 'ok') {
|
|
2312
|
+
return { ok: true, session: resolved.session, autoConnected: false };
|
|
2313
|
+
}
|
|
2314
|
+
if (resolved.kind === 'not_found') {
|
|
2315
|
+
return {
|
|
2316
|
+
ok: false,
|
|
2317
|
+
error: `session_not_found: no active session with id "${resolved.id}". Active sessions: ${resolved.activeIds.join(', ') || '(none)'}.`,
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
if (resolved.kind === 'ambiguous') {
|
|
2321
|
+
const isolatedSuffix = resolved.isolatedIds.length > 0 ? ` (isolated: ${resolved.isolatedIds.join(', ')})` : '';
|
|
2322
|
+
return {
|
|
2323
|
+
ok: false,
|
|
2324
|
+
error: `multiple_active_sessions_provide_id: ${resolved.activeIds.length} active sessions — ${resolved.activeIds.join(', ')}${isolatedSuffix}. Pass sessionId explicitly.`,
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
return { ok: false, error: missingConnectionMessage };
|
|
2248
2328
|
}
|
|
2249
2329
|
const normalized = normalizeConnectTarget({ url: target.url, pageUrl: target.pageUrl });
|
|
2250
2330
|
if (!normalized.ok)
|
|
@@ -3616,6 +3696,41 @@ async function captureScreenshotBase64(session) {
|
|
|
3616
3696
|
function err(text) {
|
|
3617
3697
|
return { content: [{ type: 'text', text }], isError: true };
|
|
3618
3698
|
}
|
|
3699
|
+
/**
|
|
3700
|
+
* Resolve a tool call's target session with strict routing. Returns either
|
|
3701
|
+
* a `Session` or a tool error response that the caller should propagate
|
|
3702
|
+
* verbatim. This is the server-side entry point for the Bug #1 fix — every
|
|
3703
|
+
* tool that used to do `const session = getSession(sessionId); if (!session)
|
|
3704
|
+
* return err('Not connected...')` should use this helper instead so that
|
|
3705
|
+
* ambiguous / not-found / none conditions get distinct, honest errors
|
|
3706
|
+
* instead of silently routing onto a stale default or a peer worker's
|
|
3707
|
+
* isolated session. The typical call site is:
|
|
3708
|
+
*
|
|
3709
|
+
* const sessionResult = resolveToolSession(sessionId)
|
|
3710
|
+
* if ('error' in sessionResult) return sessionResult.error
|
|
3711
|
+
* const session = sessionResult.session
|
|
3712
|
+
*/
|
|
3713
|
+
function resolveToolSession(sessionId) {
|
|
3714
|
+
const result = resolveSession(sessionId);
|
|
3715
|
+
switch (result.kind) {
|
|
3716
|
+
case 'ok':
|
|
3717
|
+
return { session: result.session };
|
|
3718
|
+
case 'none':
|
|
3719
|
+
return { error: err('Not connected. Call geometra_connect first.') };
|
|
3720
|
+
case 'not_found':
|
|
3721
|
+
return {
|
|
3722
|
+
error: err(`session_not_found: no active session with id "${result.id}". Active sessions: ${result.activeIds.length > 0 ? result.activeIds.join(', ') : '(none)'}. Call geometra_connect again to start a new session — the MCP server never silently routes an explicit sessionId onto a different session.`),
|
|
3723
|
+
};
|
|
3724
|
+
case 'ambiguous': {
|
|
3725
|
+
const isolatedSuffix = result.isolatedIds.length > 0
|
|
3726
|
+
? ` (isolated: ${result.isolatedIds.join(', ')})`
|
|
3727
|
+
: '';
|
|
3728
|
+
return {
|
|
3729
|
+
error: err(`multiple_active_sessions_provide_id: ${result.activeIds.length} active sessions — ${result.activeIds.join(', ')}${isolatedSuffix}. Pass sessionId explicitly; the implicit-default fallback is disabled while multiple sessions or any isolated session is active to prevent cross-contamination under parallel-worker load.`),
|
|
3730
|
+
};
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3619
3734
|
function hasNodeFilter(filter) {
|
|
3620
3735
|
return Object.values(filter).some(value => value !== undefined);
|
|
3621
3736
|
}
|
package/dist/session.d.ts
CHANGED
|
@@ -33,6 +33,19 @@ export interface A11yNode {
|
|
|
33
33
|
inputPattern?: string;
|
|
34
34
|
inputType?: string;
|
|
35
35
|
autocomplete?: string;
|
|
36
|
+
/**
|
|
37
|
+
* True when the extractor detected that this `<input>` (or role=textbox)
|
|
38
|
+
* lives inside an autocomplete / searchable combobox wrapper — React
|
|
39
|
+
* Select, Radix Select, Headless UI combobox, Ant Select, cmdk, etc.
|
|
40
|
+
* Set from the extractor's `isAutocompleteComboboxAncestry` detector,
|
|
41
|
+
* which mirrors `isAutocompleteCombobox` in `dom-actions.ts`. The
|
|
42
|
+
* form-schema classifier reads this to re-tag the field as
|
|
43
|
+
* `choice` / `listbox` instead of `text`, so `fill_form` routes through
|
|
44
|
+
* `pick_listbox_option` (which does the click + Enter-commit dance that
|
|
45
|
+
* plain text-fill cannot do for a controlled React Select state).
|
|
46
|
+
* See Bug #3 in the v1.43 release notes.
|
|
47
|
+
*/
|
|
48
|
+
isAutocompleteCombobox?: boolean;
|
|
36
49
|
};
|
|
37
50
|
bounds: {
|
|
38
51
|
x: number;
|
|
@@ -513,6 +526,40 @@ export declare function connectThroughProxy(options: {
|
|
|
513
526
|
isolated?: boolean;
|
|
514
527
|
}): Promise<Session>;
|
|
515
528
|
export declare function getSession(id?: string): Session | null;
|
|
529
|
+
/**
|
|
530
|
+
* Tool-side session resolution with strict routing semantics.
|
|
531
|
+
*
|
|
532
|
+
* - If `id` is passed, return exactly that session or `session_not_found`.
|
|
533
|
+
* Never fall back to the default — explicit ids mean the caller is tracking
|
|
534
|
+
* its own session and silently rerouting to some other session is worse
|
|
535
|
+
* than an honest failure.
|
|
536
|
+
* - If no `id` is passed, resolve to the default session IF AND ONLY IF
|
|
537
|
+
* there is exactly one active session AND it is not isolated. Otherwise
|
|
538
|
+
* return `ambiguous_default` so the caller is forced to provide an
|
|
539
|
+
* explicit sessionId.
|
|
540
|
+
*
|
|
541
|
+
* This is the Bug #1 session-contamination fix: tool calls that omit
|
|
542
|
+
* `sessionId` under parallel-worker load used to get routed to "most recent
|
|
543
|
+
* session", which was whatever parallel peer last called `geometra_connect`.
|
|
544
|
+
* That made cross-worker browser stomping inevitable. Forcing an explicit
|
|
545
|
+
* `sessionId` once >1 session is active (or whenever an isolated session is
|
|
546
|
+
* active) makes that class of bug structurally impossible.
|
|
547
|
+
*/
|
|
548
|
+
export type ResolveSessionResult = {
|
|
549
|
+
kind: 'ok';
|
|
550
|
+
session: Session;
|
|
551
|
+
} | {
|
|
552
|
+
kind: 'not_found';
|
|
553
|
+
id: string;
|
|
554
|
+
activeIds: string[];
|
|
555
|
+
} | {
|
|
556
|
+
kind: 'ambiguous';
|
|
557
|
+
activeIds: string[];
|
|
558
|
+
isolatedIds: string[];
|
|
559
|
+
} | {
|
|
560
|
+
kind: 'none';
|
|
561
|
+
};
|
|
562
|
+
export declare function resolveSession(id?: string): ResolveSessionResult;
|
|
516
563
|
export declare function listSessions(): Array<{
|
|
517
564
|
id: string;
|
|
518
565
|
url: string;
|
|
@@ -571,6 +618,16 @@ export declare function sendFieldChoice(session: Session, fieldLabel: string, va
|
|
|
571
618
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
572
619
|
/** Fill several semantic form fields in one proxy-side batch. */
|
|
573
620
|
export declare function sendFillFields(session: Session, fields: ProxyFillField[], timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
621
|
+
/**
|
|
622
|
+
* Fill an OTP / verification-code input group by typing char-by-char into
|
|
623
|
+
* the leftmost cell. See `fillOtp` in `packages/proxy/src/dom-actions.ts`
|
|
624
|
+
* for the detection and typing strategy. Bug #2 (v1.43) release notes
|
|
625
|
+
* cover the Greenhouse 8-box widget that made this primitive necessary.
|
|
626
|
+
*/
|
|
627
|
+
export declare function sendFillOtp(session: Session, value: string, opts?: {
|
|
628
|
+
fieldLabel?: string;
|
|
629
|
+
perCharDelayMs?: number;
|
|
630
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
574
631
|
/** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
|
|
575
632
|
export declare function sendListboxPick(session: Session, label: string, opts?: {
|
|
576
633
|
exact?: boolean;
|
package/dist/session.js
CHANGED
|
@@ -192,11 +192,36 @@ function rememberReusableProxyPageUrl(session) {
|
|
|
192
192
|
touchReusableProxy(entry);
|
|
193
193
|
}
|
|
194
194
|
function promoteDefaultSession() {
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
// Never promote an isolated session to be the implicit default. The whole
|
|
196
|
+
// point of `isolated: true` is that parallel workers should only address
|
|
197
|
+
// their session by its explicit id — falling back to "most recent" picks
|
|
198
|
+
// a random peer's browser and is exactly the contamination vector that
|
|
199
|
+
// made v1.42 apply marathons blow up. Walk the active sessions from newest
|
|
200
|
+
// to oldest and pick the newest non-isolated one; if none exist, the
|
|
201
|
+
// default goes null and `getSession(undefined)` will surface an
|
|
202
|
+
// `ambiguous_default` or `no_session` error instead of silently handing
|
|
203
|
+
// out a random isolated session.
|
|
204
|
+
const ids = Array.from(activeSessions.keys());
|
|
205
|
+
for (let i = ids.length - 1; i >= 0; i--) {
|
|
206
|
+
const id = ids[i];
|
|
207
|
+
const session = activeSessions.get(id);
|
|
208
|
+
if (session && !session.isolated) {
|
|
209
|
+
defaultSessionId = id;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
197
212
|
}
|
|
198
|
-
|
|
199
|
-
|
|
213
|
+
defaultSessionId = null;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Clear `defaultSessionId` if it currently points at the given session id.
|
|
217
|
+
* Used after tagging a fresh session as isolated — `connect()` set the
|
|
218
|
+
* default pointer before the isolation flag was applied, and we need to
|
|
219
|
+
* retract that assignment so this session is only addressable by its
|
|
220
|
+
* explicit id.
|
|
221
|
+
*/
|
|
222
|
+
function retractDefaultIfPointsTo(sessionId) {
|
|
223
|
+
if (defaultSessionId === sessionId) {
|
|
224
|
+
promoteDefaultSession();
|
|
200
225
|
}
|
|
201
226
|
}
|
|
202
227
|
function shutdownSession(id, opts) {
|
|
@@ -486,6 +511,12 @@ async function startFreshProxySession(options) {
|
|
|
486
511
|
session.proxyReusable = !options.isolated;
|
|
487
512
|
if (options.isolated) {
|
|
488
513
|
session.isolated = true;
|
|
514
|
+
// connect() already set defaultSessionId to this session. Retract
|
|
515
|
+
// that assignment so the isolated session is only addressable by its
|
|
516
|
+
// explicit id — the implicit-default fallback is the contamination
|
|
517
|
+
// vector we're fixing, and an isolated session must never be the
|
|
518
|
+
// implicit target of a tool call that omits sessionId.
|
|
519
|
+
retractDefaultIfPointsTo(session.id);
|
|
489
520
|
}
|
|
490
521
|
else {
|
|
491
522
|
setReusableProxy({ runtime }, wsUrl, {
|
|
@@ -544,6 +575,9 @@ async function startFreshProxySession(options) {
|
|
|
544
575
|
session.proxyReusable = !options.isolated;
|
|
545
576
|
if (options.isolated) {
|
|
546
577
|
session.isolated = true;
|
|
578
|
+
// See the embedded-runtime path above — an isolated session must
|
|
579
|
+
// not be the implicit default. Retract the pointer set by connect().
|
|
580
|
+
retractDefaultIfPointsTo(session.id);
|
|
547
581
|
}
|
|
548
582
|
else {
|
|
549
583
|
setReusableProxy({ child }, wsUrl, {
|
|
@@ -785,6 +819,35 @@ export function getSession(id) {
|
|
|
785
819
|
return activeSessions.get(defaultSessionId) ?? null;
|
|
786
820
|
return null;
|
|
787
821
|
}
|
|
822
|
+
export function resolveSession(id) {
|
|
823
|
+
if (id) {
|
|
824
|
+
const found = activeSessions.get(id);
|
|
825
|
+
if (found)
|
|
826
|
+
return { kind: 'ok', session: found };
|
|
827
|
+
return {
|
|
828
|
+
kind: 'not_found',
|
|
829
|
+
id,
|
|
830
|
+
activeIds: Array.from(activeSessions.keys()),
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
const active = Array.from(activeSessions.values());
|
|
834
|
+
if (active.length === 0)
|
|
835
|
+
return { kind: 'none' };
|
|
836
|
+
const isolatedIds = active.filter(s => s.isolated).map(s => s.id);
|
|
837
|
+
// Strict routing: if there is more than one active session, OR any active
|
|
838
|
+
// session is isolated (even if it's the only one), require an explicit id.
|
|
839
|
+
// The "only one isolated session" case still demands an explicit id
|
|
840
|
+
// because the point of isolation is that the caller tracks its own session
|
|
841
|
+
// and other tools must never implicitly attach to it.
|
|
842
|
+
if (active.length > 1 || isolatedIds.length > 0) {
|
|
843
|
+
return {
|
|
844
|
+
kind: 'ambiguous',
|
|
845
|
+
activeIds: active.map(s => s.id),
|
|
846
|
+
isolatedIds,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
return { kind: 'ok', session: active[0] };
|
|
850
|
+
}
|
|
788
851
|
export function listSessions() {
|
|
789
852
|
return Array.from(activeSessions.values()).map(s => ({ id: s.id, url: s.url }));
|
|
790
853
|
}
|
|
@@ -987,6 +1050,25 @@ export function sendFieldChoice(session, fieldLabel, value, opts, timeoutMs = LI
|
|
|
987
1050
|
export function sendFillFields(session, fields, timeoutMs = estimateFillBatchTimeout(fields)) {
|
|
988
1051
|
return sendAndWaitForUpdate(session, { type: 'fillFields', fields }, timeoutMs);
|
|
989
1052
|
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Fill an OTP / verification-code input group by typing char-by-char into
|
|
1055
|
+
* the leftmost cell. See `fillOtp` in `packages/proxy/src/dom-actions.ts`
|
|
1056
|
+
* for the detection and typing strategy. Bug #2 (v1.43) release notes
|
|
1057
|
+
* cover the Greenhouse 8-box widget that made this primitive necessary.
|
|
1058
|
+
*/
|
|
1059
|
+
export function sendFillOtp(session, value, opts, timeoutMs) {
|
|
1060
|
+
const payload = {
|
|
1061
|
+
type: 'fillOtp',
|
|
1062
|
+
value,
|
|
1063
|
+
};
|
|
1064
|
+
if (opts?.fieldLabel)
|
|
1065
|
+
payload.fieldLabel = opts.fieldLabel;
|
|
1066
|
+
if (opts?.perCharDelayMs !== undefined)
|
|
1067
|
+
payload.perCharDelayMs = opts.perCharDelayMs;
|
|
1068
|
+
// Budget: base 3s + 150ms/char to cover the per-cell delay plus verify.
|
|
1069
|
+
const budget = timeoutMs ?? Math.max(3_000, 3_000 + value.length * 150);
|
|
1070
|
+
return sendAndWaitForUpdate(session, payload, budget);
|
|
1071
|
+
}
|
|
990
1072
|
/** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
|
|
991
1073
|
export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE_TIMEOUT_MS) {
|
|
992
1074
|
const payload = { type: 'listboxPick', label };
|
|
@@ -1785,17 +1867,35 @@ function simpleSchemaField(root, node) {
|
|
|
1785
1867
|
const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
|
|
1786
1868
|
if (!label)
|
|
1787
1869
|
return null;
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1870
|
+
// Bug #3 (v1.43): if this node LOOKS like a plain textbox but its
|
|
1871
|
+
// ancestry fingerprints an autocomplete / searchable combobox wrapper
|
|
1872
|
+
// (React Select, Radix Select, Headless UI combobox, Ant Select, cmdk),
|
|
1873
|
+
// re-tag it as a listbox choice field. Without this, a React Select
|
|
1874
|
+
// Country picker in a Greenhouse Remix form gets classified as `text`,
|
|
1875
|
+
// fill_form routes through sendFieldText, and the controlled form
|
|
1876
|
+
// state never actually commits — Greenhouse's validator then says
|
|
1877
|
+
// "Country is required" and clears the value on submit-attempt scroll.
|
|
1878
|
+
// The autocomplete-combobox signal is set by the extractor's
|
|
1879
|
+
// isAutocompleteComboboxAncestry detector, which mirrors the
|
|
1880
|
+
// isAutocompleteCombobox helper used by pickListboxOption in
|
|
1881
|
+
// dom-actions.ts (v1.42 fix). Keeping the two detectors aligned is how
|
|
1882
|
+
// we guarantee that fill_form's classification matches what
|
|
1883
|
+
// pick_listbox_option can actually commit.
|
|
1884
|
+
const extractorSaysTextbox = node.role === 'textbox' && node.meta?.isAutocompleteCombobox === true;
|
|
1885
|
+
const classifiedRole = extractorSaysTextbox ? 'combobox' : node.role;
|
|
1886
|
+
const classifiedChoiceType = classifiedRole === 'combobox'
|
|
1887
|
+
? // Native <select> stays on choiceType 'select'; any wrapper pattern
|
|
1888
|
+
// routes through 'listbox' so pick_listbox_option handles it.
|
|
1889
|
+
node.meta?.controlTag === 'select' && node.meta?.isAutocompleteCombobox !== true
|
|
1890
|
+
? 'select'
|
|
1891
|
+
: 'listbox'
|
|
1792
1892
|
: undefined;
|
|
1793
1893
|
const format = buildFieldFormat(node);
|
|
1794
1894
|
return {
|
|
1795
1895
|
id: formFieldIdForPath(node.path),
|
|
1796
|
-
kind:
|
|
1896
|
+
kind: classifiedRole === 'combobox' ? 'choice' : 'text',
|
|
1797
1897
|
label,
|
|
1798
|
-
...(
|
|
1898
|
+
...(classifiedChoiceType ? { choiceType: classifiedChoiceType } : {}),
|
|
1799
1899
|
...(node.state?.required ? { required: true } : {}),
|
|
1800
1900
|
...(node.state?.invalid ? { invalid: true } : {}),
|
|
1801
1901
|
...compactSchemaValue(node.value, 72),
|
|
@@ -2713,6 +2813,8 @@ function walkNode(element, layout, path) {
|
|
|
2713
2813
|
meta.inputType = semantic.inputType;
|
|
2714
2814
|
if (typeof semantic?.autocomplete === 'string')
|
|
2715
2815
|
meta.autocomplete = semantic.autocomplete;
|
|
2816
|
+
if (semantic?.isAutocompleteCombobox === true)
|
|
2817
|
+
meta.isAutocompleteCombobox = true;
|
|
2716
2818
|
const children = [];
|
|
2717
2819
|
const elementChildren = element.children;
|
|
2718
2820
|
const layoutChildren = layout.children;
|
package/package.json
CHANGED