@trusty-squire/mcp 1.1.4 → 1.1.5
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/README.md +28 -16
- package/dist/api-client.d.ts +2 -2
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js.map +1 -1
- package/dist/bot/browser.d.ts +39 -3
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +682 -205
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/pay-operator.d.ts +2 -1
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +123 -15
- package/dist/bot/pay-operator.js.map +1 -1
- package/dist/bot/provision-session.d.ts +58 -14
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +757 -64
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/tools/operate-pay.d.ts.map +1 -1
- package/dist/tools/operate-pay.js +36 -11
- package/dist/tools/operate-pay.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +6 -3
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +70 -14
- package/dist/tools/provision-drive.js.map +1 -1
- package/package.json +2 -1
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
// - no credential is ever read back to the agent except via the explicit
|
|
17
17
|
// `finish`/extract path; the vault stays write-only.
|
|
18
18
|
import { createHash, randomInt, randomUUID } from "node:crypto";
|
|
19
|
-
import {
|
|
19
|
+
import { chmodSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { BrowserController, } from "./browser.js";
|
|
20
23
|
import { TwoCaptchaSolver } from "./captcha-solver-2captcha.js";
|
|
21
24
|
import { extractApiKeyFromText, isTruncatedCapture } from "./credential-text.js";
|
|
22
25
|
import { pickVerificationLink } from "./email-verification.js";
|
|
@@ -103,7 +106,25 @@ async function startBrowserBounded(browser, sessionId) {
|
|
|
103
106
|
}
|
|
104
107
|
// ── pure helpers (exported for unit tests) ──
|
|
105
108
|
const norm = (s) => (s ?? "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
106
|
-
|
|
109
|
+
// Element ref = a STABLE-by-default handle: "@e:<identity>_<ordinal>". For a
|
|
110
|
+
// normal control `<identity>` is its generation-independent stableElementId, so
|
|
111
|
+
// the per-session observe delta can leave an unchanged element un-re-emitted and
|
|
112
|
+
// the ref the host already holds keeps resolving. The `@e:` sigil only
|
|
113
|
+
// disambiguates a ref from a free-text label target (a label may legitimately end
|
|
114
|
+
// in "_<digits>"). Staleness is guarded by IDENTITY, not a counter: a ref whose
|
|
115
|
+
// element is now gone finds no match in resolveTarget → returns null → the caller
|
|
116
|
+
// fails loudly ("no element matched") and the host re-observes.
|
|
117
|
+
//
|
|
118
|
+
// The exceptional identity form (issue #399) applies to same-base-identity
|
|
119
|
+
// siblings distinguished ONLY by positional selectors. Those "volatile" members
|
|
120
|
+
// get an identity prefixed with their sibling group's composition FINGERPRINT
|
|
121
|
+
// ("<fp>-<hash>", see volatilePositionalGroups + elementIdentity), so a ref is
|
|
122
|
+
// valid only while that fingerprint matches. A membership-count change re-mints
|
|
123
|
+
// the group and makes every old ref resolve to null, never to a survivor.
|
|
124
|
+
// Size-preserving changes among truly indistinguishable members are the bounded
|
|
125
|
+
// residual documented at volatilePositionalGroups. `<fp>-` stays within the id
|
|
126
|
+
// charset below, so no parsing changes are needed.
|
|
127
|
+
const PROVISION_REF_RE = /^@e:([a-z0-9_-]+)$/i;
|
|
107
128
|
const PROVISION_REF_ID_RE = /^(.+)_(\d+)$/;
|
|
108
129
|
// The label a host sees + targets by. Prefer the most human, stable signal.
|
|
109
130
|
export function elementRef(el) {
|
|
@@ -121,8 +142,8 @@ export function elementRef(el) {
|
|
|
121
142
|
function shortHash(s) {
|
|
122
143
|
return createHash("sha256").update(s).digest("base64url").slice(0, 12);
|
|
123
144
|
}
|
|
124
|
-
|
|
125
|
-
return
|
|
145
|
+
function baseIdentityFields(el) {
|
|
146
|
+
return [
|
|
126
147
|
el.screenPath ?? "",
|
|
127
148
|
el.testId ?? "",
|
|
128
149
|
el.container ?? "",
|
|
@@ -131,45 +152,159 @@ export function stableElementId(el) {
|
|
|
131
152
|
elementRef(el),
|
|
132
153
|
el.href ?? "",
|
|
133
154
|
el.type ?? "",
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
export function stableElementId(el) {
|
|
158
|
+
return shortHash([
|
|
159
|
+
...baseIdentityFields(el),
|
|
160
|
+
// The element's own selector — a per-element discriminator so two controls
|
|
161
|
+
// that are otherwise identical (same label/path/role, e.g. sibling "Remove"
|
|
162
|
+
// buttons in a list) get DISTINCT identities. Without it, a stable ref is a
|
|
163
|
+
// positional ordinal within a same-hash group: remove the first sibling and
|
|
164
|
+
// the old `_1` silently retargets the survivor. With a STABLE selector
|
|
165
|
+
// (id/data-attr) folded in, the removed element's identity is unique, so its
|
|
166
|
+
// old ref finds no match and resolveTarget returns null (the host
|
|
167
|
+
// re-observes) — no mis-click.
|
|
168
|
+
//
|
|
169
|
+
// Mutable state (`checked`, value length, topmost/occlusion) is deliberately
|
|
170
|
+
// excluded so fills, toggles, and visibility changes keep the same ref.
|
|
171
|
+
// A purely POSITIONAL selector (`:nth-of-type`/`:nth-child`/`>> nth=`)
|
|
172
|
+
// recycles on sibling removal, so this hash alone would let a survivor
|
|
173
|
+
// slide onto a departed node's identity. Closed one layer up (issue #399):
|
|
174
|
+
// volatilePositionalGroups fingerprints such sibling groups and
|
|
175
|
+
// elementIdentity prefixes their refs with that fingerprint, so a group
|
|
176
|
+
// size change makes every old positional ref resolve to null.
|
|
177
|
+
el.selector,
|
|
134
178
|
].join("\u001f"));
|
|
135
179
|
}
|
|
136
|
-
|
|
137
|
-
|
|
180
|
+
// The base identity WITHOUT the selector — the grouping key for same-label
|
|
181
|
+
// sibling detection.
|
|
182
|
+
function baseElementKey(el) {
|
|
183
|
+
return baseIdentityFields(el).join("\u001f");
|
|
184
|
+
}
|
|
185
|
+
// A selector that pins an element only by its POSITION among siblings
|
|
186
|
+
// (`:nth-of-type`/`:nth-child`, or Playwright's `>> nth=` index). Such selectors
|
|
187
|
+
// RECYCLE: remove an earlier sibling and a later one slides into the vacated
|
|
188
|
+
// position, so the identical selector string then designates a DIFFERENT node.
|
|
189
|
+
// Stable anchors (#id, [data-testid], [name=…]) never recycle this way. Quoted
|
|
190
|
+
// attribute VALUES (incl. backslash-escaped quotes) are blanked first so a stable
|
|
191
|
+
// `[data-key="x:nth-child(1)"]` — the value merely CONTAINS the syntax — is not
|
|
192
|
+
// misread as a positional combinator; only real structural syntax counts.
|
|
193
|
+
const POSITIONAL_SELECTOR_RE = /:nth-of-type\(|:nth-child\(|>>\s*nth=/i;
|
|
194
|
+
const QUOTED_VALUE_RE = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g;
|
|
195
|
+
function isPositionalSelector(selector) {
|
|
196
|
+
return POSITIONAL_SELECTOR_RE.test(selector.replace(QUOTED_VALUE_RE, '""'));
|
|
197
|
+
}
|
|
198
|
+
// A "volatile positional group": the ≥2 POSITIONAL members of a same-base-identity
|
|
199
|
+
// group (any stable-anchored siblings in the same base group keep their plain,
|
|
200
|
+
// non-volatile refs). Removing one shifts a survivor's positional selector onto a
|
|
201
|
+
// departed node's identity, so a purely structural ref would silently retarget
|
|
202
|
+
// the survivor (issue #399). Returns each such member mapped to a GROUP
|
|
203
|
+
// FINGERPRINT — a hash of the positional members' stableElementIds in extraction
|
|
204
|
+
// order. elementIdentity prefixes the member's ref with that fingerprint, so the
|
|
205
|
+
// ref is valid ONLY while the positional membership matches.
|
|
206
|
+
//
|
|
207
|
+
// Guarantees (the #399 invariant): after a member is REMOVED (group size N→N-1),
|
|
208
|
+
// the fingerprint changes, so the departed member's old ref appears in `removed`
|
|
209
|
+
// (or a full resync) and resolves to null — never a survivor — including WITHIN a
|
|
210
|
+
// turn (the act path re-extracts, so a mid-turn removal changes the fingerprint
|
|
211
|
+
// and forces a re-observe rather than mis-targeting a shifted sibling). Because
|
|
212
|
+
// the identity is composition-derived (not an observe counter), a static group's
|
|
213
|
+
// refs stay stable across observes (no wasted churn) and a toggled checkbox /
|
|
214
|
+
// filled field keeps its ref (mutable state is excluded from stableElementId).
|
|
215
|
+
//
|
|
216
|
+
// Bounded residual: the fingerprint is built from the members' own
|
|
217
|
+
// position-derived hashes, so a SIZE-PRESERVING shuffle of TRULY INDISTINGUISHABLE
|
|
218
|
+
// members — delete-one-and-insert-one, or a pure reorder, where the members carry
|
|
219
|
+
// ZERO distinguishing signal (identical label/aria/testid/text/screenPath, only
|
|
220
|
+
// the nth differs) — leaves the fingerprint unchanged and is not detected. This
|
|
221
|
+
// is information-theoretically unavoidable for a string-derived identity: such an
|
|
222
|
+
// observation is byte-identical to "nothing changed," so no ref scheme can flag
|
|
223
|
+
// it. Real per-row controls carry a distinguishing signal (row text / aria-label
|
|
224
|
+
// / a data-id), which lands them in DISTINCT base groups (non-volatile) where the
|
|
225
|
+
// #398 stable-selector identity already guards them. Fully closing the residual
|
|
226
|
+
// needs an extractor-stamped per-node id that survives DOM mutation — deferred
|
|
227
|
+
// because stamping every interactive node with a persistent attribute is
|
|
228
|
+
// anti-bot-detectable (a worse regression than the residual it removes).
|
|
229
|
+
function volatilePositionalGroups(elements) {
|
|
230
|
+
const groups = new Map();
|
|
231
|
+
for (const el of elements) {
|
|
232
|
+
const key = baseElementKey(el);
|
|
233
|
+
const group = groups.get(key);
|
|
234
|
+
if (group === undefined)
|
|
235
|
+
groups.set(key, [el]);
|
|
236
|
+
else
|
|
237
|
+
group.push(el);
|
|
238
|
+
}
|
|
239
|
+
const fingerprintOf = new Map();
|
|
240
|
+
for (const group of groups.values()) {
|
|
241
|
+
// ≥2 positional siblings sharing a base identity can recycle onto EACH
|
|
242
|
+
// OTHER; a lone positional member (or any stable-anchored member) cannot.
|
|
243
|
+
const positional = group.filter((el) => isPositionalSelector(el.selector));
|
|
244
|
+
if (positional.length < 2)
|
|
245
|
+
continue;
|
|
246
|
+
// Extraction-order fingerprint: sensitive to membership-count and selector-
|
|
247
|
+
// sequence changes, subject to the size-preserving residual above.
|
|
248
|
+
const fp = shortHash(positional.map((el) => stableElementId(el)).join(""));
|
|
249
|
+
for (const el of positional)
|
|
250
|
+
fingerprintOf.set(el, fp);
|
|
251
|
+
}
|
|
252
|
+
return fingerprintOf;
|
|
253
|
+
}
|
|
254
|
+
// The ref identity of one element. A volatile positional-group member is
|
|
255
|
+
// prefixed with its group fingerprint (`<fp>-<hash>`) so its ref survives only
|
|
256
|
+
// while the group's composition is unchanged; everything else uses its plain,
|
|
257
|
+
// composition-independent stableElementId (byte-identical to the pre-#399 ref).
|
|
258
|
+
function elementIdentity(el, fingerprintOf) {
|
|
259
|
+
const base = stableElementId(el);
|
|
260
|
+
const fp = fingerprintOf.get(el);
|
|
261
|
+
return fp === undefined ? base : `${fp}-${base}`;
|
|
262
|
+
}
|
|
263
|
+
export function provisionElementRef(el, ordinal = 1) {
|
|
264
|
+
return `@e:${stableElementId(el)}_${ordinal}`;
|
|
138
265
|
}
|
|
139
266
|
function parseProvisionRef(target) {
|
|
140
267
|
const m = target.trim().match(PROVISION_REF_RE);
|
|
141
268
|
if (m === null)
|
|
142
269
|
return null;
|
|
143
|
-
const rawId = m[
|
|
270
|
+
const rawId = m[1];
|
|
144
271
|
const idMatch = rawId.match(PROVISION_REF_ID_RE);
|
|
145
272
|
return {
|
|
146
|
-
generation: Number.parseInt(m[1], 10),
|
|
147
273
|
id: idMatch !== null ? idMatch[1] : rawId,
|
|
148
274
|
ordinal: idMatch !== null ? Number.parseInt(idMatch[2], 10) : null,
|
|
149
275
|
};
|
|
150
276
|
}
|
|
151
|
-
export function
|
|
277
|
+
export function parseLocatorTarget(target) {
|
|
278
|
+
const m = /^\s*(text|css)\s*=\s*([\s\S]+)$/i.exec(target);
|
|
279
|
+
if (m === null)
|
|
280
|
+
return null;
|
|
281
|
+
const mode = m[1].toLowerCase() === "css" ? "css" : "text";
|
|
282
|
+
let value = m[2].trim();
|
|
283
|
+
// Strip one matching pair of surrounding quotes so `text="Add To Cart"` and
|
|
284
|
+
// `text=Add To Cart` are equivalent (the quotes only help the host delimit
|
|
285
|
+
// trailing whitespace / punctuation).
|
|
286
|
+
if (value.length >= 2) {
|
|
287
|
+
const q = value[0];
|
|
288
|
+
if ((q === '"' || q === "'") && value[value.length - 1] === q) {
|
|
289
|
+
value = value.slice(1, -1);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (value.length === 0)
|
|
293
|
+
return null;
|
|
294
|
+
return { mode, value };
|
|
295
|
+
}
|
|
296
|
+
export function provisionElementRefs(elements) {
|
|
297
|
+
const fingerprintOf = volatilePositionalGroups(elements);
|
|
152
298
|
const seen = new Map();
|
|
153
299
|
const refs = new Map();
|
|
154
300
|
for (const el of elements) {
|
|
155
|
-
const id =
|
|
301
|
+
const id = elementIdentity(el, fingerprintOf);
|
|
156
302
|
const ordinal = (seen.get(id) ?? 0) + 1;
|
|
157
303
|
seen.set(id, ordinal);
|
|
158
|
-
refs.set(el,
|
|
304
|
+
refs.set(el, `@e:${id}_${ordinal}`);
|
|
159
305
|
}
|
|
160
306
|
return refs;
|
|
161
307
|
}
|
|
162
|
-
export class StaleProvisionRefError extends Error {
|
|
163
|
-
refGeneration;
|
|
164
|
-
currentGeneration;
|
|
165
|
-
code = "stale_ref";
|
|
166
|
-
constructor(refGeneration, currentGeneration) {
|
|
167
|
-
super(`stale_ref: target is from observation generation ${refGeneration}, ` +
|
|
168
|
-
`but current generation is ${currentGeneration}. Call operate_observe and retry with a fresh ref.`);
|
|
169
|
-
this.refGeneration = refGeneration;
|
|
170
|
-
this.currentGeneration = currentGeneration;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
308
|
export class AmbiguousProvisionTargetError extends Error {
|
|
174
309
|
target;
|
|
175
310
|
candidates;
|
|
@@ -191,13 +326,26 @@ function elementTargetKeys(el) {
|
|
|
191
326
|
// structured path, test id, or label text, scored exact > startsWith > contains.
|
|
192
327
|
// Returns null when nothing matches — the caller surfaces that rather than
|
|
193
328
|
// guessing.
|
|
194
|
-
export function resolveTarget(elements, target
|
|
329
|
+
export function resolveTarget(elements, target) {
|
|
195
330
|
const parsedRef = parseProvisionRef(target);
|
|
196
331
|
if (parsedRef !== null) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
332
|
+
// Staleness guard: a ref whose identity is absent among the LIVE elements
|
|
333
|
+
// returns null (the caller re-observes). Identity is recomputed here from the
|
|
334
|
+
// live set, so a volatile positional-group ref carries the group's fingerprint
|
|
335
|
+
// at mint time; if the live group has a different fingerprint, the stale ref
|
|
336
|
+
// resolves to null instead of retargeting a survivor (issue #399). This holds
|
|
337
|
+
// WITHIN a turn too: the act path re-extracts, so a membership-count change
|
|
338
|
+
// between observe and act changes the fingerprint and forces a re-observe.
|
|
339
|
+
//
|
|
340
|
+
// Ordinal caveat (same-hash duplicates): the `_<ordinal>` suffix positionally
|
|
341
|
+
// disambiguates elements that hash IDENTICALLY (same selector too — NOT the
|
|
342
|
+
// positional-sibling case, which the fingerprint covers). Mutable state is
|
|
343
|
+
// intentionally absent from that hash, so members need not have identical
|
|
344
|
+
// checked/value/visibility state. If one is removed, an ordinal can resolve to
|
|
345
|
+
// a survivor; the recycled ordinal is not invalidated by `removed`. An ordinal
|
|
346
|
+
// past the current group size still returns null.
|
|
347
|
+
const fingerprintOf = volatilePositionalGroups(elements);
|
|
348
|
+
const matches = elements.filter((el) => elementIdentity(el, fingerprintOf) === parsedRef.id);
|
|
201
349
|
if (parsedRef.ordinal !== null) {
|
|
202
350
|
const match = matches[parsedRef.ordinal - 1];
|
|
203
351
|
return match ?? null;
|
|
@@ -555,25 +703,41 @@ export function provisionPerceptionGuidance(pageText) {
|
|
|
555
703
|
}
|
|
556
704
|
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
557
705
|
}
|
|
558
|
-
|
|
559
|
-
if (!("target" in action))
|
|
560
|
-
return null;
|
|
706
|
+
function unsafeProvisionBlockReason(pageText, safetySignals, target) {
|
|
561
707
|
const appMarkers = authenticatedAppSurfaceMarkers(pageText);
|
|
562
|
-
if (appMarkers.length > 0 &&
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
708
|
+
if (appMarkers.length > 0 && safetySignals.accountSetup && hasAccountSetupOverlay(pageText)) {
|
|
709
|
+
if (target === null) {
|
|
710
|
+
return ("Perception guard: this control looks like an account/setup overlay action, " +
|
|
711
|
+
"but authenticated app markers are already visible. Do not retry OAuth or " +
|
|
712
|
+
"repeatedly press this overlay; use app navigation/direct same-origin URLs " +
|
|
713
|
+
"or complete only the minimal required setup.");
|
|
714
|
+
}
|
|
715
|
+
return (`Perception guard: "${target}" looks like an account/setup overlay action, ` +
|
|
566
716
|
`but authenticated app markers are already visible (${appMarkers.join(", ")}). ` +
|
|
567
717
|
`Do not retry OAuth or repeatedly press this overlay; use app navigation/direct ` +
|
|
568
718
|
`same-origin URLs or complete only the minimal required setup.`);
|
|
569
719
|
}
|
|
570
|
-
if (
|
|
571
|
-
|
|
572
|
-
|
|
720
|
+
if (safetySignals.billingObject && /\b(?:live|production)\s+mode\b/i.test(pageText)) {
|
|
721
|
+
if (target === null) {
|
|
722
|
+
return ("Mode safety guard: this control can create or save billing objects, " +
|
|
723
|
+
"but live/production mode is visible. Switch to the required test/sandbox mode before acting.");
|
|
724
|
+
}
|
|
725
|
+
return (`Mode safety guard: "${target}" can create or save billing objects, ` +
|
|
573
726
|
`but live/production mode is visible. Switch to the required test/sandbox mode before acting.`);
|
|
574
727
|
}
|
|
575
728
|
return null;
|
|
576
729
|
}
|
|
730
|
+
export function shouldBlockUnsafeProvisionSignals(pageText, safetySignals) {
|
|
731
|
+
return unsafeProvisionBlockReason(pageText, safetySignals, null);
|
|
732
|
+
}
|
|
733
|
+
export function shouldBlockUnsafeProvisionAction(pageText, action, options = {}) {
|
|
734
|
+
if (!("target" in action))
|
|
735
|
+
return null;
|
|
736
|
+
return unsafeProvisionBlockReason(pageText, {
|
|
737
|
+
accountSetup: isAccountSetupActionTarget(action.target),
|
|
738
|
+
billingObject: isBillingObjectActionTarget(action.target),
|
|
739
|
+
}, options.redactTarget === true ? null : action.target);
|
|
740
|
+
}
|
|
577
741
|
export function buildScreenOutline(elements, pageText, sealedFieldKeys = new Set()) {
|
|
578
742
|
if (elements.length === 0)
|
|
579
743
|
return undefined;
|
|
@@ -661,10 +825,10 @@ function presentLabel(el, sealed) {
|
|
|
661
825
|
return elementRef(el);
|
|
662
826
|
return elementRef({ ...el, value: null });
|
|
663
827
|
}
|
|
664
|
-
export function buildAccessibilitySnapshot(elements,
|
|
828
|
+
export function buildAccessibilitySnapshot(elements, limit = 12000, sealedFieldKeys = new Set()) {
|
|
665
829
|
if (elements.length === 0)
|
|
666
830
|
return undefined;
|
|
667
|
-
const refs = provisionElementRefs(elements
|
|
831
|
+
const refs = provisionElementRefs(elements);
|
|
668
832
|
const byRegion = new Map();
|
|
669
833
|
for (const el of elements) {
|
|
670
834
|
const region = el.container ?? "body:root";
|
|
@@ -689,7 +853,7 @@ export function buildAccessibilitySnapshot(elements, generation, limit = 12000,
|
|
|
689
853
|
el.href !== undefined && el.href !== null ? `href="${el.href.slice(0, 120)}"` : null,
|
|
690
854
|
el.topmost === false ? `occluded_by="${el.occludedBy ?? "unknown"}"` : null,
|
|
691
855
|
].filter((v) => v !== null);
|
|
692
|
-
lines.push(` ${role} "${label}" ref=${refs.get(el) ?? provisionElementRef(el
|
|
856
|
+
lines.push(` ${role} "${label}" ref=${refs.get(el) ?? provisionElementRef(el)}` +
|
|
693
857
|
(flags.length > 0 ? ` ${flags.join(" ")}` : ""));
|
|
694
858
|
}
|
|
695
859
|
}
|
|
@@ -835,8 +999,11 @@ export async function startProvisionSession(opts) {
|
|
|
835
999
|
secretSlots: new Map(),
|
|
836
1000
|
sealedFieldKeys: new Set(),
|
|
837
1001
|
lastElements: [],
|
|
1002
|
+
prevObserve: null,
|
|
1003
|
+
observeSnapshotFile: null,
|
|
838
1004
|
actionTrace: [],
|
|
839
1005
|
captureRounds: [],
|
|
1006
|
+
usedLocatorFallback: false,
|
|
840
1007
|
startedAt: Date.now(),
|
|
841
1008
|
hintServed: opts.hint !== undefined,
|
|
842
1009
|
startUrl: opts.serviceUrl,
|
|
@@ -983,15 +1150,39 @@ export function generatePassword(length = 24) {
|
|
|
983
1150
|
}
|
|
984
1151
|
return chars.join("");
|
|
985
1152
|
}
|
|
1153
|
+
// Type-elision (docs/DESIGN-observe-compact.md § Phase 4). `text` is always the
|
|
1154
|
+
// default input type; `button`/`submit` are redundant only when the tag or role
|
|
1155
|
+
// already identifies a button. Other types and unmarked input action controls
|
|
1156
|
+
// are load-bearing and kept. Applied only to the wire form, never the persisted
|
|
1157
|
+
// file.
|
|
1158
|
+
const ELIDED_TYPES = new Set(["button", "submit", "text"]);
|
|
1159
|
+
function shouldElideType(el) {
|
|
1160
|
+
const type = (el.type ?? "").toLowerCase();
|
|
1161
|
+
if (!ELIDED_TYPES.has(type))
|
|
1162
|
+
return false;
|
|
1163
|
+
if (type === "text")
|
|
1164
|
+
return true;
|
|
1165
|
+
return el.tag === "button" || (el.role ?? "").toLowerCase() === "button";
|
|
1166
|
+
}
|
|
986
1167
|
// One element, compacted: ref/label/tag always; every other field omitted when
|
|
987
1168
|
// empty. `value`→`value_len` (never the raw value — keeps the sealed-field moat);
|
|
988
1169
|
// `checked` kept for real checkables (true OR false), omitted when null;
|
|
989
1170
|
// `topmost` only when false (the informative case); `container` dropped.
|
|
990
|
-
export function toCompactElement(el, ref, sealed
|
|
1171
|
+
export function toCompactElement(el, ref, sealed,
|
|
1172
|
+
// `path` is the single most verbose field and agents act by ref, not path — so
|
|
1173
|
+
// it is DROPPED from the default host payload (78% → 85% of the measured cut).
|
|
1174
|
+
// It is retained ONLY in the persisted snapshot file (includePath=true), which
|
|
1175
|
+
// the host can re-expand or grep. It is also excluded from the delta identity,
|
|
1176
|
+
// so a layout-only path shift never forces a re-emit.
|
|
1177
|
+
includePath = false,
|
|
1178
|
+
// Apply type-elision (Phase 4) to the WIRE form. The persisted file
|
|
1179
|
+
// form keeps full fidelity for re-expansion, so callers that write the file
|
|
1180
|
+
// pass false.
|
|
1181
|
+
elide = false) {
|
|
991
1182
|
const out = { ref, label: presentLabel(el, sealed), tag: el.tag };
|
|
992
1183
|
if (el.role)
|
|
993
1184
|
out.role = el.role;
|
|
994
|
-
if (el.type)
|
|
1185
|
+
if (el.type && !(elide && shouldElideType(el)))
|
|
995
1186
|
out.type = el.type;
|
|
996
1187
|
// value_len is a LENGTH signal, not the value — report the REAL character count.
|
|
997
1188
|
// presentFieldValue masks a sealed field to "[sealed]" (8 chars), so using its
|
|
@@ -1007,7 +1198,7 @@ export function toCompactElement(el, ref, sealed) {
|
|
|
1007
1198
|
out.href = el.href;
|
|
1008
1199
|
if (el.testId)
|
|
1009
1200
|
out.testId = el.testId;
|
|
1010
|
-
if (el.screenPath)
|
|
1201
|
+
if (includePath && el.screenPath)
|
|
1011
1202
|
out.path = el.screenPath;
|
|
1012
1203
|
if (el.topmost === false)
|
|
1013
1204
|
out.topmost = false;
|
|
@@ -1015,6 +1206,351 @@ export function toCompactElement(el, ref, sealed) {
|
|
|
1015
1206
|
out.occluded_by = el.occludedBy;
|
|
1016
1207
|
return out;
|
|
1017
1208
|
}
|
|
1209
|
+
// Columnar wire encoding (docs/DESIGN-observe-compact.md § Phase 4). The compact
|
|
1210
|
+
// `elements` array repeated every field NAME on every element; a tab-delimited
|
|
1211
|
+
// table names each column ONCE in a header line, then one terse row per element.
|
|
1212
|
+
// Column order is CANONICAL (matches toCompactElement's field order) so a parsed
|
|
1213
|
+
// row reconstructs byte-identically. `ref`/`label`/`tag` are always present;
|
|
1214
|
+
// other columns appear only when at least one emitted element carries them.
|
|
1215
|
+
const ELEMENT_TABLE_COLUMNS = [
|
|
1216
|
+
"ref",
|
|
1217
|
+
"label",
|
|
1218
|
+
"tag",
|
|
1219
|
+
"role",
|
|
1220
|
+
"type",
|
|
1221
|
+
"value_len",
|
|
1222
|
+
"checked",
|
|
1223
|
+
"href",
|
|
1224
|
+
"testId",
|
|
1225
|
+
"topmost",
|
|
1226
|
+
"occluded_by",
|
|
1227
|
+
];
|
|
1228
|
+
// The string cell for one column of one element, or undefined when absent.
|
|
1229
|
+
// Booleans/numbers render as plain text; the parser coerces them back.
|
|
1230
|
+
function elementCell(e, col) {
|
|
1231
|
+
switch (col) {
|
|
1232
|
+
case "ref":
|
|
1233
|
+
return e.ref;
|
|
1234
|
+
case "label":
|
|
1235
|
+
return e.label;
|
|
1236
|
+
case "tag":
|
|
1237
|
+
return e.tag;
|
|
1238
|
+
case "role":
|
|
1239
|
+
return e.role ?? undefined;
|
|
1240
|
+
case "type":
|
|
1241
|
+
return e.type ?? undefined;
|
|
1242
|
+
case "value_len":
|
|
1243
|
+
return e.value_len !== undefined ? String(e.value_len) : undefined;
|
|
1244
|
+
case "checked":
|
|
1245
|
+
return e.checked === true ? "true" : e.checked === false ? "false" : undefined;
|
|
1246
|
+
case "href":
|
|
1247
|
+
return e.href ?? undefined;
|
|
1248
|
+
case "testId":
|
|
1249
|
+
return e.testId ?? undefined;
|
|
1250
|
+
case "topmost":
|
|
1251
|
+
return e.topmost === false ? "false" : undefined;
|
|
1252
|
+
case "occluded_by":
|
|
1253
|
+
return e.occluded_by ?? undefined;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
// Escape the only bytes that break the tab/newline framing. Backslash FIRST so
|
|
1257
|
+
// the decoder's single pass is unambiguous.
|
|
1258
|
+
function escapeCell(v) {
|
|
1259
|
+
return v.replace(/\\/g, "\\\\").replace(/\t/g, "\\t").replace(/\n/g, "\\n").replace(/\r/g, "\\r");
|
|
1260
|
+
}
|
|
1261
|
+
function unescapeCell(v) {
|
|
1262
|
+
return v.replace(/\\(.)/g, (_m, c) => c === "t" ? "\t" : c === "n" ? "\n" : c === "r" ? "\r" : c);
|
|
1263
|
+
}
|
|
1264
|
+
// Encode a set of compact elements as the tab table. Returns "" for an empty set
|
|
1265
|
+
// (the caller then omits `el_table` — an empty table costs a header for nothing).
|
|
1266
|
+
export function encodeElementsTable(els) {
|
|
1267
|
+
if (els.length === 0)
|
|
1268
|
+
return "";
|
|
1269
|
+
const columns = ELEMENT_TABLE_COLUMNS.filter((c) => c === "ref" ||
|
|
1270
|
+
c === "label" ||
|
|
1271
|
+
c === "tag" ||
|
|
1272
|
+
els.some((e) => elementCell(e, c) !== undefined));
|
|
1273
|
+
const header = columns.join("\t");
|
|
1274
|
+
const rows = els.map((e) => columns.map((c) => escapeCell(elementCell(e, c) ?? "")).join("\t"));
|
|
1275
|
+
return [header, ...rows].join("\n");
|
|
1276
|
+
}
|
|
1277
|
+
// Inverse of encodeElementsTable — reconstruct the compact elements from the wire
|
|
1278
|
+
// table. The delta stream's losslessness gate (INV-lossless-resync) round-trips
|
|
1279
|
+
// through this, and it documents the EXACT parse the host performs. An empty
|
|
1280
|
+
// cell (or a header column absent for a row) means the field is absent; only the
|
|
1281
|
+
// three mandatory columns are always assigned.
|
|
1282
|
+
export function parseElementsTable(table) {
|
|
1283
|
+
if (table.length === 0)
|
|
1284
|
+
return [];
|
|
1285
|
+
const lines = table.split("\n");
|
|
1286
|
+
const columns = (lines[0] ?? "").split("\t");
|
|
1287
|
+
const out = [];
|
|
1288
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1289
|
+
const cells = (lines[i] ?? "").split("\t").map(unescapeCell);
|
|
1290
|
+
const e = { ref: "", label: "", tag: "" };
|
|
1291
|
+
columns.forEach((col, idx) => {
|
|
1292
|
+
const raw = cells[idx] ?? "";
|
|
1293
|
+
if (col === "ref")
|
|
1294
|
+
e.ref = raw;
|
|
1295
|
+
else if (col === "label")
|
|
1296
|
+
e.label = raw;
|
|
1297
|
+
else if (col === "tag")
|
|
1298
|
+
e.tag = raw;
|
|
1299
|
+
else if (raw === "")
|
|
1300
|
+
return; // absent optional field
|
|
1301
|
+
else if (col === "role")
|
|
1302
|
+
e.role = raw;
|
|
1303
|
+
else if (col === "type")
|
|
1304
|
+
e.type = raw;
|
|
1305
|
+
else if (col === "value_len")
|
|
1306
|
+
e.value_len = Number(raw);
|
|
1307
|
+
else if (col === "checked")
|
|
1308
|
+
e.checked = raw === "true";
|
|
1309
|
+
else if (col === "href")
|
|
1310
|
+
e.href = raw;
|
|
1311
|
+
else if (col === "testId")
|
|
1312
|
+
e.testId = raw;
|
|
1313
|
+
else if (col === "topmost")
|
|
1314
|
+
e.topmost = false;
|
|
1315
|
+
else if (col === "occluded_by")
|
|
1316
|
+
e.occluded_by = raw;
|
|
1317
|
+
});
|
|
1318
|
+
out.push(e);
|
|
1319
|
+
}
|
|
1320
|
+
return out;
|
|
1321
|
+
}
|
|
1322
|
+
// The compact wire carries its element set as `el_table` (columnar); an empty set
|
|
1323
|
+
// omits the field entirely. FULL mode keeps `elements` (JSON). One helper so both
|
|
1324
|
+
// buildCompactObservation branches and the persist-fallback stay consistent.
|
|
1325
|
+
function emitElements(els, encode) {
|
|
1326
|
+
if (encode === "json")
|
|
1327
|
+
return { elements: [...els] };
|
|
1328
|
+
const table = encodeElementsTable(els);
|
|
1329
|
+
return table.length > 0 ? { el_table: table } : {};
|
|
1330
|
+
}
|
|
1331
|
+
// Session-scoped observe-snapshot persistence (docs/DESIGN-observe-compact.md).
|
|
1332
|
+
// Reuses the best-effort writeFileSync pattern of the corpus dump-hook:
|
|
1333
|
+
// a write failure must NEVER break an observe. Rolling one file per session (the
|
|
1334
|
+
// latest COMPLETE inventory) — that's what the host wants when it re-expands
|
|
1335
|
+
// after a context compaction or greps for an element the delta didn't re-show.
|
|
1336
|
+
function observeSnapshotDir(sessionId) {
|
|
1337
|
+
const override = (process.env.TRUSTY_SQUIRE_OBSERVE_DIR ?? "").trim();
|
|
1338
|
+
const parent = override.length > 0 ? override : join(tmpdir(), "trusty-squire-observe");
|
|
1339
|
+
return join(parent, sessionId);
|
|
1340
|
+
}
|
|
1341
|
+
function persistObserveSnapshot(session, generation, url, text, textTruncated, elements) {
|
|
1342
|
+
let temporaryFile = null;
|
|
1343
|
+
const dir = observeSnapshotDir(session.id);
|
|
1344
|
+
const file = join(dir, `observe-${session.id}.json`);
|
|
1345
|
+
try {
|
|
1346
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1347
|
+
chmodSync(dir, 0o700);
|
|
1348
|
+
temporaryFile = join(dir, `.observe-${session.id}-${generation}.tmp`);
|
|
1349
|
+
writeFileSync(temporaryFile, JSON.stringify({
|
|
1350
|
+
session_id: session.id,
|
|
1351
|
+
generation,
|
|
1352
|
+
url,
|
|
1353
|
+
elements_total: elements.length,
|
|
1354
|
+
text,
|
|
1355
|
+
text_truncated: textTruncated,
|
|
1356
|
+
elements,
|
|
1357
|
+
}, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
1358
|
+
renameSync(temporaryFile, file);
|
|
1359
|
+
session.observeSnapshotFile = file;
|
|
1360
|
+
return file;
|
|
1361
|
+
}
|
|
1362
|
+
catch {
|
|
1363
|
+
if (temporaryFile !== null) {
|
|
1364
|
+
try {
|
|
1365
|
+
unlinkSync(temporaryFile);
|
|
1366
|
+
}
|
|
1367
|
+
catch { }
|
|
1368
|
+
}
|
|
1369
|
+
for (const staleFile of new Set([session.observeSnapshotFile, file])) {
|
|
1370
|
+
if (staleFile === null)
|
|
1371
|
+
continue;
|
|
1372
|
+
try {
|
|
1373
|
+
unlinkSync(staleFile);
|
|
1374
|
+
}
|
|
1375
|
+
catch { }
|
|
1376
|
+
}
|
|
1377
|
+
session.observeSnapshotFile = null;
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
// An actionable control the chrome-link collapse must NEVER drop: any
|
|
1382
|
+
// button/input, or an element whose role is button/tab/checkbox/radio/menuitem,
|
|
1383
|
+
// or a type=submit. Button-shaped dismiss/consent/gate controls survive by
|
|
1384
|
+
// construction even in a chrome region; link-shaped variants are guarded
|
|
1385
|
+
// separately by isPlainChromeLink.
|
|
1386
|
+
export function isActionableControl(el) {
|
|
1387
|
+
const role = (el.role ?? "").toLowerCase();
|
|
1388
|
+
if (role === "button" ||
|
|
1389
|
+
role === "tab" ||
|
|
1390
|
+
role === "checkbox" ||
|
|
1391
|
+
role === "radio" ||
|
|
1392
|
+
role === "menuitem") {
|
|
1393
|
+
return true;
|
|
1394
|
+
}
|
|
1395
|
+
if (el.tag === "button" || el.tag === "input")
|
|
1396
|
+
return true;
|
|
1397
|
+
if ((el.type ?? "").toLowerCase() === "submit")
|
|
1398
|
+
return true;
|
|
1399
|
+
return false;
|
|
1400
|
+
}
|
|
1401
|
+
// "Chrome region" per docs/DESIGN-observe-compact.md: the element's path
|
|
1402
|
+
// root is a nav/footer/banner/aside-style landmark, OR a `section:` whose name is
|
|
1403
|
+
// a known boilerplate block (newsletter/copyright/social/…). Site-dependent
|
|
1404
|
+
// (measured 0% on flat DOMs, up to 57% on hoka) — a bonus, never the main win.
|
|
1405
|
+
export function isChromeRegionPath(el) {
|
|
1406
|
+
const path = el.screenPath ?? el.container ?? "";
|
|
1407
|
+
const root = (path.split(" > ")[0] ?? "").trim();
|
|
1408
|
+
const colon = root.indexOf(":");
|
|
1409
|
+
const role = (colon >= 0 ? root.slice(0, colon) : root).toLowerCase();
|
|
1410
|
+
const name = colon >= 0 ? root.slice(colon + 1).toLowerCase() : "";
|
|
1411
|
+
if (role === "navigation" ||
|
|
1412
|
+
role === "footer" ||
|
|
1413
|
+
role === "contentinfo" ||
|
|
1414
|
+
role === "banner" ||
|
|
1415
|
+
role === "complementary" ||
|
|
1416
|
+
role === "aside") {
|
|
1417
|
+
return true;
|
|
1418
|
+
}
|
|
1419
|
+
if (role === "section") {
|
|
1420
|
+
return /newsletter|copyright|trustpilot|accepted-payment|social|footer|shop-the-collection/.test(name);
|
|
1421
|
+
}
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
// A label that reads as a dismiss / consent / gate action — the collapse must
|
|
1425
|
+
// keep these even when they are shipped as a chrome-region <a>, and even when the
|
|
1426
|
+
// consent banner gives them a real fallback URL. Errs toward KEEPING (a false
|
|
1427
|
+
// positive keeps a nav link, which is safe; a false negative would drop a
|
|
1428
|
+
// dismiss control, which is not) — so it covers the accept/reject vocabulary AND
|
|
1429
|
+
// its opposites (decline/agree/allow) and the "preferences/opt out/got it" verbs.
|
|
1430
|
+
const DISMISS_CONSENT_LABEL_RE = /close|dismiss|skip|no thanks|accept|reject|decline|agree|allow|cookie|consent|preferences|opt.?out|not now|maybe later|got it/i;
|
|
1431
|
+
// A PLAIN chrome-region NAVIGATION link — the ONLY thing the collapse removes.
|
|
1432
|
+
// Buttons, inputs, and role-controls are never plain links (isActionableControl
|
|
1433
|
+
// short-circuits), so they are always kept regardless of region. Beyond that, a
|
|
1434
|
+
// link is treated as a NAVIGATION link (collapsible) ONLY when it clearly is one;
|
|
1435
|
+
// anything that could be a dismiss/consent control is kept:
|
|
1436
|
+
// - no href, a `#`-fragment href, or a `javascript:` href → an action-link, not
|
|
1437
|
+
// navigation (a "Close banner"/"Manage cookies" anchor) → KEEP.
|
|
1438
|
+
// - inConsentWidget → part of a cookie/consent banner → KEEP.
|
|
1439
|
+
// - label matches a dismiss/consent pattern (close/dismiss/accept/reject/cookie/
|
|
1440
|
+
// …) → KEEP even with a real fallback URL (consent banners often provide one).
|
|
1441
|
+
// This closes the "a dismiss control shipped as a bare/consent <a> gets dropped"
|
|
1442
|
+
// gap: only true, non-consent navigation links are collapsible.
|
|
1443
|
+
export function isPlainChromeLink(el) {
|
|
1444
|
+
if (isActionableControl(el))
|
|
1445
|
+
return false;
|
|
1446
|
+
const isLink = el.tag === "a" || (el.role ?? "").toLowerCase() === "link";
|
|
1447
|
+
if (!isLink)
|
|
1448
|
+
return false;
|
|
1449
|
+
if (!isChromeRegionPath(el))
|
|
1450
|
+
return false;
|
|
1451
|
+
if (el.inConsentWidget === true)
|
|
1452
|
+
return false;
|
|
1453
|
+
const href = (el.href ?? "").trim();
|
|
1454
|
+
if (href.length === 0 || href.startsWith("#") || href.toLowerCase().startsWith("javascript:")) {
|
|
1455
|
+
return false;
|
|
1456
|
+
}
|
|
1457
|
+
if (DISMISS_CONSENT_LABEL_RE.test(elementRef(el)))
|
|
1458
|
+
return false;
|
|
1459
|
+
return true;
|
|
1460
|
+
}
|
|
1461
|
+
// Fraction of the previous element set that changed (added/changed + removed).
|
|
1462
|
+
// Above this an observe emits a FULL snapshot instead of a delta — a big SPA
|
|
1463
|
+
// re-render is clearer whole, and a delta that touches most of the page is barely
|
|
1464
|
+
// smaller than the full set anyway.
|
|
1465
|
+
const OBSERVE_CHURN_FULL_THRESHOLD = 0.6;
|
|
1466
|
+
// Pure core of the compact/delta observe path — no browser, no filesystem — so
|
|
1467
|
+
// the delta invariants (lossless resync, actionable-never-dropped, token budget)
|
|
1468
|
+
// are unit-testable over synthetic element sequences. observeSession supplies the
|
|
1469
|
+
// live elements/text/url; this decides delta-vs-full, applies the chrome-link
|
|
1470
|
+
// collapse, and returns both the emit and the complete ground-truth set.
|
|
1471
|
+
export function buildCompactObservation(args) {
|
|
1472
|
+
const { sessionId, url, text, elements, prev } = args;
|
|
1473
|
+
const sealed = args.sealed ?? new Set();
|
|
1474
|
+
const encode = args.encode ?? "columnar";
|
|
1475
|
+
const elide = args.elide ?? true;
|
|
1476
|
+
const refs = provisionElementRefs(elements);
|
|
1477
|
+
const refOf = (el) => refs.get(el) ?? provisionElementRef(el);
|
|
1478
|
+
const fullByRef = new Map();
|
|
1479
|
+
const serializedByRef = new Map();
|
|
1480
|
+
const fileElements = [];
|
|
1481
|
+
for (const el of elements) {
|
|
1482
|
+
const ref = refOf(el);
|
|
1483
|
+
fullByRef.set(ref, toCompactElement(el, ref, sealed, false, elide));
|
|
1484
|
+
serializedByRef.set(ref, JSON.stringify(fullByRef.get(ref)));
|
|
1485
|
+
// The persisted file keeps FULL fidelity (path included, no elision) so a
|
|
1486
|
+
// re-expansion after a host compaction loses nothing.
|
|
1487
|
+
fileElements.push(toCompactElement(el, ref, sealed, true, false));
|
|
1488
|
+
}
|
|
1489
|
+
const nextState = { url, byRef: serializedByRef, text };
|
|
1490
|
+
const base = {
|
|
1491
|
+
session_id: sessionId,
|
|
1492
|
+
url,
|
|
1493
|
+
text,
|
|
1494
|
+
...(args.guidance !== undefined ? { guidance: args.guidance } : {}),
|
|
1495
|
+
elements_total: elements.length,
|
|
1496
|
+
...(args.textTruncated === true ? { text_truncated: true } : {}),
|
|
1497
|
+
};
|
|
1498
|
+
// Delta path: same URL as last observe, and churn under the threshold.
|
|
1499
|
+
if (prev !== null && prev.url === url) {
|
|
1500
|
+
const changed = [];
|
|
1501
|
+
let unchanged = 0;
|
|
1502
|
+
for (const [ref, ser] of serializedByRef) {
|
|
1503
|
+
if (prev.byRef.get(ref) === ser)
|
|
1504
|
+
unchanged += 1;
|
|
1505
|
+
else
|
|
1506
|
+
changed.push(fullByRef.get(ref));
|
|
1507
|
+
}
|
|
1508
|
+
const removed = [...prev.byRef.keys()].filter((ref) => !serializedByRef.has(ref));
|
|
1509
|
+
const churn = changed.length + removed.length;
|
|
1510
|
+
if (churn / Math.max(prev.byRef.size, 1) <= OBSERVE_CHURN_FULL_THRESHOLD) {
|
|
1511
|
+
// Text delta: emit the blob empty + a marker when it's byte-identical to
|
|
1512
|
+
// the previous observe (the host reuses the prior text; the full text is in
|
|
1513
|
+
// snapshot_file). Otherwise emit it in full.
|
|
1514
|
+
const textUnchanged = prev.text === text;
|
|
1515
|
+
return {
|
|
1516
|
+
observation: {
|
|
1517
|
+
...base,
|
|
1518
|
+
...(textUnchanged ? { text: "", text_unchanged: true } : {}),
|
|
1519
|
+
...emitElements(changed, encode),
|
|
1520
|
+
delta: true,
|
|
1521
|
+
unchanged,
|
|
1522
|
+
...(removed.length > 0 ? { removed } : {}),
|
|
1523
|
+
},
|
|
1524
|
+
fullByRef,
|
|
1525
|
+
fileElements,
|
|
1526
|
+
nextState,
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
// FULL compact snapshot — first observe / URL change / high churn. Only HERE do
|
|
1531
|
+
// we collapse plain chrome-region links (never a button/input/dismiss control);
|
|
1532
|
+
// the collapsed links stay in the persisted snapshot.
|
|
1533
|
+
const emitted = [];
|
|
1534
|
+
let chromeLinksCollapsed = 0;
|
|
1535
|
+
for (const el of elements) {
|
|
1536
|
+
if (isPlainChromeLink(el)) {
|
|
1537
|
+
chromeLinksCollapsed += 1;
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
emitted.push(fullByRef.get(refOf(el)));
|
|
1541
|
+
}
|
|
1542
|
+
return {
|
|
1543
|
+
observation: {
|
|
1544
|
+
...base,
|
|
1545
|
+
...emitElements(emitted, encode),
|
|
1546
|
+
delta: false,
|
|
1547
|
+
...(chromeLinksCollapsed > 0 ? { chrome_links_collapsed: chromeLinksCollapsed } : {}),
|
|
1548
|
+
},
|
|
1549
|
+
fullByRef,
|
|
1550
|
+
fileElements,
|
|
1551
|
+
nextState,
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1018
1554
|
async function observeSession(session, detail = "compact") {
|
|
1019
1555
|
session.browser.recoverActivePage();
|
|
1020
1556
|
widenAllowedHostsFromCurrentUrl(session);
|
|
@@ -1026,27 +1562,71 @@ async function observeSession(session, detail = "compact") {
|
|
|
1026
1562
|
const normalizedFull = text.replace(/\s+/g, " ").trim();
|
|
1027
1563
|
const normalizedText = normalizedFull.slice(0, 4000);
|
|
1028
1564
|
const guidance = provisionPerceptionGuidance(normalizedText);
|
|
1029
|
-
const
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
|
|
1565
|
+
const url = session.browser.currentUrl();
|
|
1566
|
+
const refs = provisionElementRefs(elements);
|
|
1567
|
+
const refOf = (el) => refs.get(el) ?? provisionElementRef(el);
|
|
1568
|
+
const textTruncated = normalizedFull.length > 4000;
|
|
1569
|
+
// Compact (default): the delta path, computed by the pure core.
|
|
1033
1570
|
if (detail !== "full") {
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
url
|
|
1571
|
+
const built = buildCompactObservation({
|
|
1572
|
+
sessionId: session.id,
|
|
1573
|
+
url,
|
|
1037
1574
|
text: normalizedText,
|
|
1575
|
+
textTruncated,
|
|
1038
1576
|
...(guidance !== undefined ? { guidance } : {}),
|
|
1039
|
-
elements
|
|
1040
|
-
|
|
1041
|
-
|
|
1577
|
+
elements,
|
|
1578
|
+
sealed: session.sealedFieldKeys,
|
|
1579
|
+
prev: session.prevObserve,
|
|
1580
|
+
});
|
|
1581
|
+
// Persist the COMPLETE snapshot (path INCLUDED) — the safety net that makes
|
|
1582
|
+
// delta safe: the host re-expands the full inventory from here.
|
|
1583
|
+
const snapshotFile = persistObserveSnapshot(session, generation, url, normalizedText, textTruncated, built.fileElements);
|
|
1584
|
+
if (snapshotFile === null) {
|
|
1585
|
+
// Persistence FAILED, so no recovery file exists. A delta (which omits
|
|
1586
|
+
// unchanged elements) or a collapsed full snapshot (which omits chrome
|
|
1587
|
+
// links) would be UNRECOVERABLE — the host would have no way to re-expand.
|
|
1588
|
+
// Fall back to a FULL, UNCOLLAPSED response (every element inline). And
|
|
1589
|
+
// INVALIDATE the delta baseline (null, not "leave it at the last good
|
|
1590
|
+
// state"): the host's reconstruction is now THIS full set, so the next
|
|
1591
|
+
// observe must emit a fresh FULL snapshot too, never a delta computed
|
|
1592
|
+
// against the last-persisted baseline — that stale-baseline delta would
|
|
1593
|
+
// desync a host that has already moved to this full state (a
|
|
1594
|
+
// remove-then-restore-across-a-failed-persist sequence would silently drop
|
|
1595
|
+
// the restored element otherwise).
|
|
1596
|
+
session.prevObserve = null;
|
|
1597
|
+
return {
|
|
1598
|
+
session_id: session.id,
|
|
1599
|
+
url,
|
|
1600
|
+
text: normalizedText,
|
|
1601
|
+
...(guidance !== undefined ? { guidance } : {}),
|
|
1602
|
+
// Still a COMPACT response — carry the (uncollapsed) set as the columnar
|
|
1603
|
+
// table so the host parses it the same way as any other compact observe.
|
|
1604
|
+
...emitElements([...built.fullByRef.values()], "columnar"),
|
|
1605
|
+
delta: false,
|
|
1606
|
+
elements_total: elements.length,
|
|
1607
|
+
...(textTruncated ? { text_truncated: true } : {}),
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
session.prevObserve = built.nextState;
|
|
1611
|
+
return {
|
|
1612
|
+
...built.observation,
|
|
1613
|
+
snapshot_file: snapshotFile,
|
|
1042
1614
|
};
|
|
1043
1615
|
}
|
|
1044
|
-
// Full path —
|
|
1616
|
+
// Full (legacy rich) path — the explicit escape hatch. Byte-identical to the
|
|
1617
|
+
// pre-delta full payload: every element with every field, screen, and
|
|
1618
|
+
// accessibility, never a delta and never a chrome collapse.
|
|
1619
|
+
session.prevObserve = null;
|
|
1620
|
+
// Refresh the persisted snapshot as a SIDE EFFECT so a re-expansion after a
|
|
1621
|
+
// full-only observe can't restore stale state (the previous compact snapshot).
|
|
1622
|
+
// Deliberately NOT surfaced in the payload — the full escape hatch stays
|
|
1623
|
+
// byte-equivalent to the legacy shape (no snapshot_file field added).
|
|
1624
|
+
persistObserveSnapshot(session, generation, url, normalizedText, textTruncated, elements.map((el) => toCompactElement(el, refOf(el), session.sealedFieldKeys, true)));
|
|
1045
1625
|
const screen = buildScreenOutline(elements, normalizedText, session.sealedFieldKeys);
|
|
1046
|
-
const accessibility = buildAccessibilitySnapshot(elements,
|
|
1626
|
+
const accessibility = buildAccessibilitySnapshot(elements, undefined, session.sealedFieldKeys);
|
|
1047
1627
|
return {
|
|
1048
1628
|
session_id: session.id,
|
|
1049
|
-
url
|
|
1629
|
+
url,
|
|
1050
1630
|
text: normalizedText,
|
|
1051
1631
|
...(guidance !== undefined ? { guidance } : {}),
|
|
1052
1632
|
...(screen !== undefined ? { screen } : {}),
|
|
@@ -1073,9 +1653,14 @@ export async function act(sessionId, action, detail = "compact") {
|
|
|
1073
1653
|
if (session === undefined)
|
|
1074
1654
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
1075
1655
|
const { browser } = session;
|
|
1656
|
+
const auditTarget = "target" in action && parseLocatorTarget(action.target) !== null
|
|
1657
|
+
? "<mode>=<redacted>"
|
|
1658
|
+
: "target" in action
|
|
1659
|
+
? action.target
|
|
1660
|
+
: undefined;
|
|
1076
1661
|
audit(sessionId, "act", {
|
|
1077
1662
|
kind: action.kind,
|
|
1078
|
-
...(
|
|
1663
|
+
...(auditTarget !== undefined ? { target: auditTarget } : {}),
|
|
1079
1664
|
...("url" in action ? { url: action.url } : {}),
|
|
1080
1665
|
});
|
|
1081
1666
|
// The URL the action is taken ON — captured BEFORE the action navigates. The
|
|
@@ -1147,7 +1732,10 @@ export async function act(sessionId, action, detail = "compact") {
|
|
|
1147
1732
|
}
|
|
1148
1733
|
const fresh = await browser.extractInteractiveElements();
|
|
1149
1734
|
session.lastElements = fresh;
|
|
1150
|
-
|
|
1735
|
+
// resolveTarget recomputes identities (incl. volatile positional-group
|
|
1736
|
+
// fingerprints) from these FRESH elements, so a ref whose group fingerprint
|
|
1737
|
+
// changed since the last observe resolves to null, not a survivor (#399).
|
|
1738
|
+
const el = resolveTarget(fresh, action.target);
|
|
1151
1739
|
if (el === null) {
|
|
1152
1740
|
throw new Error(`type_secret: no element matched target "${action.target}".`);
|
|
1153
1741
|
}
|
|
@@ -1166,18 +1754,102 @@ export async function act(sessionId, action, detail = "compact") {
|
|
|
1166
1754
|
});
|
|
1167
1755
|
break;
|
|
1168
1756
|
}
|
|
1757
|
+
case "select": {
|
|
1758
|
+
// Re-resolve against FRESH elements — the target may be the <select> or
|
|
1759
|
+
// its <label>; browser.selectOption walks label→control and handles the
|
|
1760
|
+
// native vs custom-listbox split. text is the fuzzy option matcher.
|
|
1761
|
+
const fresh = await browser.extractInteractiveElements();
|
|
1762
|
+
session.lastElements = fresh;
|
|
1763
|
+
const el = resolveTarget(fresh, action.target);
|
|
1764
|
+
if (el === null) {
|
|
1765
|
+
throw new Error(`select: no element matched target "${action.target}". Visible: ` +
|
|
1766
|
+
fresh
|
|
1767
|
+
.map((e) => `"${e.screenPath ?? elementRef(e)}"`)
|
|
1768
|
+
.slice(0, 20)
|
|
1769
|
+
.join(", "));
|
|
1770
|
+
}
|
|
1771
|
+
resolvedEl = el;
|
|
1772
|
+
await browser.selectOption(el.selector, action.text);
|
|
1773
|
+
await settleAfterStateChange(browser);
|
|
1774
|
+
break;
|
|
1775
|
+
}
|
|
1776
|
+
case "set_phone_country": {
|
|
1777
|
+
// No captured element — the bot finds the phone-local native <select>.
|
|
1778
|
+
// resolvedEl stays null; the step records without a captured-element
|
|
1779
|
+
// trace (the country is host-replannable, not a replay recipe).
|
|
1780
|
+
await browser.setPhoneCountry(action.country);
|
|
1781
|
+
await settleAfterStateChange(browser);
|
|
1782
|
+
break;
|
|
1783
|
+
}
|
|
1169
1784
|
case "click":
|
|
1170
1785
|
case "js_click":
|
|
1171
1786
|
case "type":
|
|
1172
1787
|
case "upload":
|
|
1173
1788
|
case "oauth_click": {
|
|
1174
|
-
const
|
|
1789
|
+
const pageText = await browser.extractVisibleText();
|
|
1790
|
+
const blockReason = shouldBlockUnsafeProvisionAction(pageText, action);
|
|
1175
1791
|
if (blockReason !== null)
|
|
1176
1792
|
throw new Error(blockReason);
|
|
1793
|
+
// Locator-form target (`text=…` / `css=…`): the host is pointing at a
|
|
1794
|
+
// control that has NO `@e:` ref because the inventory never emitted it (a
|
|
1795
|
+
// bare click-handler <div> with no role/label, e.g. a SPA "Add To Cart"
|
|
1796
|
+
// that falls past the card-scan cap). Resolve it directly against the live
|
|
1797
|
+
// page instead of the extracted-element list.
|
|
1798
|
+
const locator = parseLocatorTarget(action.target);
|
|
1799
|
+
if (locator !== null) {
|
|
1800
|
+
// text=/css= is a CLICK escape hatch only. `type` gates on click
|
|
1801
|
+
// affordance / non-editable text so it can't target a form input, and
|
|
1802
|
+
// upload/oauth_click have bespoke flows — reject them explicitly.
|
|
1803
|
+
if (action.kind !== "click" && action.kind !== "js_click") {
|
|
1804
|
+
throw new Error(`operate_act kind="${action.kind}" does not accept a text=/css= locator target; ` +
|
|
1805
|
+
`text=/css= is for clicking (click / js_click). Use an @e: ref from operate_observe.`);
|
|
1806
|
+
}
|
|
1807
|
+
const resolved = await browser.resolvePageTarget(locator.mode, locator.value);
|
|
1808
|
+
if (!resolved.ok) {
|
|
1809
|
+
if (resolved.reason === "none") {
|
|
1810
|
+
throw new Error(`no element matched locator "${action.target}". If the control is visible, ` +
|
|
1811
|
+
`try a shorter/exact text= label or a css=<selector>.`);
|
|
1812
|
+
}
|
|
1813
|
+
throw new AmbiguousProvisionTargetError(action.target, resolved.candidates);
|
|
1814
|
+
}
|
|
1815
|
+
// The unsafe-action guard above inspected the RAW target, so an opaque
|
|
1816
|
+
// `css=<selector>` (or any target whose string carries no verb/noun the
|
|
1817
|
+
// guard matches) could resolve to a destructive billing/setup control the
|
|
1818
|
+
// guard couldn't see through — clicking "Save product" in live mode via
|
|
1819
|
+
// css=#submit. Re-run it against compact safety signals computed from the
|
|
1820
|
+
// resolved control now that we know what the locator actually points at.
|
|
1821
|
+
// Mark the session non-promotable BEFORE the click: a locator click can't
|
|
1822
|
+
// be replayed from the inventory (the element was never in it), so a
|
|
1823
|
+
// skill synthesized from this run would silently omit the step. Setting
|
|
1824
|
+
// it up front means a click that lands but then throws still can't leave
|
|
1825
|
+
// the session promotable (see captureAndPromoteSession) (codex).
|
|
1826
|
+
try {
|
|
1827
|
+
const resolvedBlock = shouldBlockUnsafeProvisionSignals(pageText, resolved.safetySignals);
|
|
1828
|
+
if (resolvedBlock !== null)
|
|
1829
|
+
throw new Error(resolvedBlock);
|
|
1830
|
+
session.usedLocatorFallback = true;
|
|
1831
|
+
if (action.kind === "click")
|
|
1832
|
+
await browser.clickHandle(resolved.handle);
|
|
1833
|
+
else
|
|
1834
|
+
await browser.jsClickHandle(resolved.handle);
|
|
1835
|
+
}
|
|
1836
|
+
finally {
|
|
1837
|
+
await resolved.handle.dispose().catch(() => undefined);
|
|
1838
|
+
}
|
|
1839
|
+
audit(sessionId, action.kind, {
|
|
1840
|
+
locator_mode: locator.mode,
|
|
1841
|
+
host: registrableHost(browser.currentUrl()),
|
|
1842
|
+
});
|
|
1843
|
+
await settleAfterStateChange(browser);
|
|
1844
|
+
break;
|
|
1845
|
+
}
|
|
1177
1846
|
// Re-resolve against FRESH elements every act — never trust a stale index.
|
|
1178
1847
|
const fresh = await browser.extractInteractiveElements();
|
|
1179
1848
|
session.lastElements = fresh;
|
|
1180
|
-
|
|
1849
|
+
// resolveTarget recomputes identities (incl. volatile positional-group
|
|
1850
|
+
// fingerprints) from these FRESH elements, so a ref whose group fingerprint
|
|
1851
|
+
// changed since the last observe resolves to null, not a survivor (#399).
|
|
1852
|
+
const el = resolveTarget(fresh, action.target);
|
|
1181
1853
|
if (el === null) {
|
|
1182
1854
|
throw new Error(`no element matched target "${action.target}". Visible: ` +
|
|
1183
1855
|
fresh
|
|
@@ -1288,6 +1960,17 @@ function recordTrace(session, action, el) {
|
|
|
1288
1960
|
// shared recipe. Skip it here (the action is still in the audit trail).
|
|
1289
1961
|
if (action.kind === "upload")
|
|
1290
1962
|
return;
|
|
1963
|
+
// A native/custom-select pick isn't yet in the portable recipe vocabulary
|
|
1964
|
+
// (TraceAction has no `select` kind). Skip it from the trace like upload — the
|
|
1965
|
+
// action still runs and is audited. Selects were never traceable before this
|
|
1966
|
+
// kind existed, so nothing regresses; wiring select into the replay engine is
|
|
1967
|
+
// a follow-up for when a signup flow needs a replayable dropdown.
|
|
1968
|
+
if (action.kind === "select")
|
|
1969
|
+
return;
|
|
1970
|
+
// set_phone_country has no portable TraceAction kind either — the country is
|
|
1971
|
+
// host-replannable per run, not baked into a shared recipe. Skip like select.
|
|
1972
|
+
if (action.kind === "set_phone_country")
|
|
1973
|
+
return;
|
|
1291
1974
|
const rawText = traceTextFor(el);
|
|
1292
1975
|
const text = rawText !== undefined ? scrubKnownEmail(rawText, session.userEmail) : undefined;
|
|
1293
1976
|
const withText = text !== undefined ? { text_match: text } : {};
|
|
@@ -1424,6 +2107,12 @@ export async function captureAndPromoteSession(sessionId) {
|
|
|
1424
2107
|
const session = sessions.get(sessionId);
|
|
1425
2108
|
if (session === undefined)
|
|
1426
2109
|
return { kind: "skipped", reason: "unknown_session" };
|
|
2110
|
+
// A run that used the text=/css= locator click fallback hit a control with no
|
|
2111
|
+
// inventory ref; the synthesizer can't represent that step, so promoting would
|
|
2112
|
+
// ship a skill missing a click. Skip rather than emit a silently-broken skill.
|
|
2113
|
+
if (session.usedLocatorFallback) {
|
|
2114
|
+
return { kind: "skipped", reason: "locator_fallback_unrepresentable" };
|
|
2115
|
+
}
|
|
1427
2116
|
const dir = resolveCaptureDir();
|
|
1428
2117
|
if (dir === null)
|
|
1429
2118
|
return { kind: "skipped", reason: "capture_disabled" };
|
|
@@ -1498,6 +2187,9 @@ export async function rememberRecipe(sessionId, opts) {
|
|
|
1498
2187
|
const session = sessions.get(sessionId);
|
|
1499
2188
|
if (session === undefined)
|
|
1500
2189
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
2190
|
+
if (session.usedLocatorFallback) {
|
|
2191
|
+
throw new Error("operate_remember refused: this session used a text=/css= locator fallback that operator recipes cannot represent");
|
|
2192
|
+
}
|
|
1501
2193
|
const secrets = [...session.secretSlots.keys()].map((slot) => ({ slot, stored: false }));
|
|
1502
2194
|
const recipe = {
|
|
1503
2195
|
name: opts.name,
|
|
@@ -1525,13 +2217,14 @@ export async function rememberRecipe(sessionId, opts) {
|
|
|
1525
2217
|
async function snapshotForPostcondition(session) {
|
|
1526
2218
|
const obs = await observeSession(session);
|
|
1527
2219
|
// Read lengths off the RAW elements (session.lastElements, set by
|
|
1528
|
-
// observeSession)
|
|
1529
|
-
//
|
|
1530
|
-
// never expose the value, so this stays
|
|
2220
|
+
// observeSession). The compact wire carries only value_len and never the raw
|
|
2221
|
+
// value; deriving from the live elements preserves the real length for a
|
|
2222
|
+
// min_value_len success-signal. Lengths never expose the value, so this stays
|
|
2223
|
+
// leak-free.
|
|
1531
2224
|
const fields = session.lastElements
|
|
1532
2225
|
.filter((e) => typeof e.value === "string" && e.value.length > 0)
|
|
1533
2226
|
.map((e) => ({ label: elementRef(e), value_len: (e.value ?? "").length }));
|
|
1534
|
-
return { url: obs.url, text: obs.text, fields };
|
|
2227
|
+
return { url: obs.url, text: session.prevObserve?.text ?? obs.text, fields };
|
|
1535
2228
|
}
|
|
1536
2229
|
// Verify a recipe's postcondition against the live session — the anti-false-
|
|
1537
2230
|
// green gate for replay. execute_capability checks the current end-state;
|