@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.
@@ -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 { BrowserController } from "./browser.js";
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
- const PROVISION_REF_RE = /^@?g(\d+):([a-z0-9_-]+)$/i;
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
- export function stableElementId(el) {
125
- return shortHash([
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
- export function provisionElementRef(el, generation, ordinal = 1) {
137
- return `@g${generation}:${stableElementId(el)}_${ordinal}`;
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[2];
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 provisionElementRefs(elements, generation) {
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 = stableElementId(el);
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, provisionElementRef(el, generation, ordinal));
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, currentGeneration) {
329
+ export function resolveTarget(elements, target) {
195
330
  const parsedRef = parseProvisionRef(target);
196
331
  if (parsedRef !== null) {
197
- if (currentGeneration !== undefined && parsedRef.generation !== currentGeneration) {
198
- throw new StaleProvisionRefError(parsedRef.generation, currentGeneration);
199
- }
200
- const matches = elements.filter((el) => stableElementId(el) === parsedRef.id);
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
- export function shouldBlockUnsafeProvisionAction(pageText, action) {
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
- isAccountSetupActionTarget(action.target) &&
564
- hasAccountSetupOverlay(pageText)) {
565
- return (`Perception guard: "${action.target}" looks like an account/setup overlay action, ` +
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 (isBillingObjectActionTarget(action.target) &&
571
- /\b(?:live|production)\s+mode\b/i.test(pageText)) {
572
- return (`Mode safety guard: "${action.target}" can create or save billing objects, ` +
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, generation, limit = 12000, sealedFieldKeys = new Set()) {
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, generation);
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, generation)}` +
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 refs = provisionElementRefs(elements, generation);
1030
- const refOf = (el) => refs.get(el) ?? provisionElementRef(el, generation);
1031
- // Compact (default): text + actionable elements only. No screen/accessibility
1032
- // (the two re-encodings of the same nodes); empty fields omitted. ~50% smaller.
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
- return {
1035
- session_id: session.id,
1036
- url: session.browser.currentUrl(),
1571
+ const built = buildCompactObservation({
1572
+ sessionId: session.id,
1573
+ url,
1037
1574
  text: normalizedText,
1575
+ textTruncated,
1038
1576
  ...(guidance !== undefined ? { guidance } : {}),
1039
- elements: elements.map((el) => toCompactElement(el, refOf(el), session.sealedFieldKeys)),
1040
- elements_total: elements.length,
1041
- ...(normalizedFull.length > 4000 ? { text_truncated: true } : {}),
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 — byte-identical to the pre-compact payload.
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, generation, undefined, session.sealedFieldKeys);
1626
+ const accessibility = buildAccessibilitySnapshot(elements, undefined, session.sealedFieldKeys);
1047
1627
  return {
1048
1628
  session_id: session.id,
1049
- url: session.browser.currentUrl(),
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
- ...("target" in action ? { target: action.target } : {}),
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
- const el = resolveTarget(fresh, action.target, session.generation);
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 blockReason = shouldBlockUnsafeProvisionAction(await browser.extractVisibleText(), action);
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
- const el = resolveTarget(fresh, action.target, session.generation);
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) obs.elements masks sealed/password values to a fixed
1529
- // placeholder, which would corrupt a min_value_len success-signal. Lengths
1530
- // never expose the value, so this stays leak-free.
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;