@trusty-squire/mcp 1.1.8 → 1.1.9-rc.2

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.
Files changed (58) hide show
  1. package/README.md +13 -5
  2. package/dist/bot/browser.d.ts +96 -1
  3. package/dist/bot/browser.d.ts.map +1 -1
  4. package/dist/bot/browser.js +420 -1
  5. package/dist/bot/browser.js.map +1 -1
  6. package/dist/bot/operator-recipe.d.ts +27 -975
  7. package/dist/bot/operator-recipe.d.ts.map +1 -1
  8. package/dist/bot/operator-recipe.js +88 -237
  9. package/dist/bot/operator-recipe.js.map +1 -1
  10. package/dist/bot/pay-operator.d.ts +2 -0
  11. package/dist/bot/pay-operator.d.ts.map +1 -1
  12. package/dist/bot/pay-operator.js +9 -5
  13. package/dist/bot/pay-operator.js.map +1 -1
  14. package/dist/bot/provision-session.d.ts +44 -2
  15. package/dist/bot/provision-session.d.ts.map +1 -1
  16. package/dist/bot/provision-session.js +383 -4
  17. package/dist/bot/provision-session.js.map +1 -1
  18. package/dist/skill-registry-client.d.ts +57 -0
  19. package/dist/skill-registry-client.d.ts.map +1 -1
  20. package/dist/skill-registry-client.js +156 -7
  21. package/dist/skill-registry-client.js.map +1 -1
  22. package/dist/tools/provision-drive.d.ts +73 -26
  23. package/dist/tools/provision-drive.d.ts.map +1 -1
  24. package/dist/tools/provision-drive.js +224 -10
  25. package/dist/tools/provision-drive.js.map +1 -1
  26. package/package.json +3 -1
  27. package/dist/eval/replay-harness/corpus.d.ts +0 -7
  28. package/dist/eval/replay-harness/corpus.d.ts.map +0 -1
  29. package/dist/eval/replay-harness/corpus.js +0 -183
  30. package/dist/eval/replay-harness/corpus.js.map +0 -1
  31. package/dist/eval/replay-harness/har-mutate.d.ts +0 -36
  32. package/dist/eval/replay-harness/har-mutate.d.ts.map +0 -1
  33. package/dist/eval/replay-harness/har-mutate.js +0 -119
  34. package/dist/eval/replay-harness/har-mutate.js.map +0 -1
  35. package/dist/eval/replay-harness/har-substrate.d.ts +0 -16
  36. package/dist/eval/replay-harness/har-substrate.d.ts.map +0 -1
  37. package/dist/eval/replay-harness/har-substrate.js +0 -99
  38. package/dist/eval/replay-harness/har-substrate.js.map +0 -1
  39. package/dist/eval/replay-harness/index.d.ts +0 -8
  40. package/dist/eval/replay-harness/index.d.ts.map +0 -1
  41. package/dist/eval/replay-harness/index.js +0 -8
  42. package/dist/eval/replay-harness/index.js.map +0 -1
  43. package/dist/eval/replay-harness/metrics.d.ts +0 -9
  44. package/dist/eval/replay-harness/metrics.d.ts.map +0 -1
  45. package/dist/eval/replay-harness/metrics.js +0 -117
  46. package/dist/eval/replay-harness/metrics.js.map +0 -1
  47. package/dist/eval/replay-harness/reporter.d.ts +0 -4
  48. package/dist/eval/replay-harness/reporter.d.ts.map +0 -1
  49. package/dist/eval/replay-harness/reporter.js +0 -62
  50. package/dist/eval/replay-harness/reporter.js.map +0 -1
  51. package/dist/eval/replay-harness/runner.d.ts +0 -36
  52. package/dist/eval/replay-harness/runner.d.ts.map +0 -1
  53. package/dist/eval/replay-harness/runner.js +0 -167
  54. package/dist/eval/replay-harness/runner.js.map +0 -1
  55. package/dist/eval/replay-harness/types.d.ts +0 -135
  56. package/dist/eval/replay-harness/types.d.ts.map +0 -1
  57. package/dist/eval/replay-harness/types.js +0 -2
  58. package/dist/eval/replay-harness/types.js.map +0 -1
@@ -27,7 +27,7 @@ import { pickVerificationLink } from "./email-verification.js";
27
27
  import { detectActiveProviderSessions, ensureOAuthSession } from "./google-login.js";
28
28
  import { loggedInEmail } from "./login-state.js";
29
29
  import { loginSessionGuidance } from "./skill-hint.js";
30
- import { checkSuccessSignal, bindKnownEmailTemplate, bindRecipePostcondition, bindRecipeTarget, bindRecipeValue, cssEscapeRecipeValue, fillTemplate, hasRecipeTargetCandidate, isSingleUseUrl, knownRecipeInputValue, operatorRecipeDomain, resolveRecipeFieldTarget, resolveRecipeRepairTarget, resolveRecipeTarget, verifyFilledFieldValues, writeRecipe, } from "./operator-recipe.js";
30
+ import { checkSuccessSignal, bindKnownEmailTemplate, bindRecipePostcondition, bindRecipeTarget, bindRecipeValue, cssEscapeRecipeValue, fillTemplate, hasRecipeTargetCandidate, isSingleUseUrl, isCheckoutShapeKey, isSameRecipeDomain, knownRecipeInputValue, localeStableFieldRole, operatorRecipeDomain, checkoutFieldSetSignature, checkoutShapeKey, resolveRecipeFieldTarget, resolveRecipeRepairTarget, resolveRecipeTarget, verifyFilledFieldValues, writeRecipe, } from "./operator-recipe.js";
31
31
  import { captureOnboardingRound, currentRunId, resetCaptureChain, resolveCaptureDir, } from "./onboarding-capture.js";
32
32
  import { promoteToSkill, pickRowDisambiguator, pickStableDomHint, pickHrefHint, } from "./promote-to-skill.js";
33
33
  import { serviceSlugFromHost } from "@trusty-squire/skill-schema";
@@ -430,6 +430,49 @@ export class AmbiguousProvisionTargetError extends Error {
430
430
  this.candidates = candidates;
431
431
  }
432
432
  }
433
+ // A `type` into an autocomplete field (Google-Places-style address picker,
434
+ // react-select/cmdk/Radix combobox) opened a suggestion popup, but the typed
435
+ // text didn't resolve to exactly one option — same "N candidates matched,
436
+ // stop and ask, never guess" shape as AmbiguousProvisionTargetError. Zero
437
+ // candidates and >1 candidates are both a stop, never a confident wrong
438
+ // commit (see matchAutocompleteSuggestions).
439
+ export class AutocompleteCommitRequiredError extends Error {
440
+ typedText;
441
+ candidates;
442
+ code = "autocomplete_commit_required";
443
+ constructor(typedText, candidates) {
444
+ super(candidates.length === 0
445
+ ? `autocomplete_commit_required: typing "${typedText}" opened a suggestion list but no ` +
446
+ `visible option started with the typed text. Retry with text that matches a suggestion, ` +
447
+ `or issue an explicit select/click on the option you want.`
448
+ : `autocomplete_commit_required: typing "${typedText}" matched ${candidates.length} ` +
449
+ `suggestions, not one. Narrow the typed text or issue an explicit select/click: ` +
450
+ `${candidates.slice(0, 8).join(", ")}`);
451
+ this.typedText = typedText;
452
+ this.candidates = candidates;
453
+ }
454
+ }
455
+ function normalizeAutocompleteText(value) {
456
+ return value.toLowerCase().replace(/\s+/g, " ").trim();
457
+ }
458
+ // Match-or-stop rule for 3.1 — pure and unit-testable without a browser. A
459
+ // suggestion is a candidate iff the typed text (normalized) is a prefix of
460
+ // the option's normalized text (e.g. "350 5th Ave" → "350 5th Ave, New York,
461
+ // NY 10118, USA"). Deliberately NOT a bare substring match anywhere in the
462
+ // string — too loose, invites the label-swap-class wrong-fill the field-role
463
+ // guard (PR #447) exists to prevent. Returns the indices of matching options;
464
+ // the caller commits only when there is exactly one.
465
+ export function matchAutocompleteSuggestions(typedText, optionTexts) {
466
+ const typed = normalizeAutocompleteText(typedText);
467
+ if (typed.length === 0)
468
+ return [];
469
+ const indices = [];
470
+ optionTexts.forEach((text, index) => {
471
+ if (normalizeAutocompleteText(text).startsWith(typed))
472
+ indices.push(index);
473
+ });
474
+ return indices;
475
+ }
433
476
  function elementTargetKeys(el) {
434
477
  return [el.screenPath ?? null, el.testId ?? null, elementRef(el)].flatMap((s) => {
435
478
  const v = (s ?? "").replace(/\s+/g, " ").trim();
@@ -1209,6 +1252,66 @@ export async function startProvisionSession(opts) {
1209
1252
  throw err;
1210
1253
  }
1211
1254
  }
1255
+ /** Start a normal guarded session on a caller-owned harness page. */
1256
+ export async function startHarnessProvisionSession(opts) {
1257
+ const id = randomUUID();
1258
+ if (starting || inFlight) {
1259
+ throw new Error("operate_start refused: another operator session is already in flight");
1260
+ }
1261
+ if (opts.requireLiveIdentity === true) {
1262
+ throw new Error("harness sessions cannot request live identity");
1263
+ }
1264
+ const targetHost = registrableHost(opts.serviceUrl);
1265
+ const allowedHosts = [
1266
+ ...(targetHost === null ? [] : [targetHost]),
1267
+ ...(opts.extraAllowedHosts ?? []),
1268
+ ]
1269
+ .filter((host, index, hosts) => hosts.indexOf(host) === index)
1270
+ .map((host) => ({
1271
+ host,
1272
+ source: "start",
1273
+ }));
1274
+ const session = {
1275
+ id,
1276
+ browser: opts.browser,
1277
+ allowedHosts,
1278
+ generation: 0,
1279
+ secretSlots: new Map(),
1280
+ sealedFieldKeys: new Set(),
1281
+ lastElements: [],
1282
+ prevObserve: null,
1283
+ observeSnapshotFile: null,
1284
+ actionTrace: [],
1285
+ recordedValues: [],
1286
+ committedSelectValues: new Map(),
1287
+ captureRounds: [],
1288
+ usedLocatorFallback: false,
1289
+ recipeRejectionReason: null,
1290
+ replayState: null,
1291
+ startedAt: Date.now(),
1292
+ hintServed: opts.hint !== undefined,
1293
+ startUrl: opts.serviceUrl,
1294
+ consentInboxRead: false,
1295
+ userEmail: null,
1296
+ ...(opts.api === undefined ? {} : { api: opts.api }),
1297
+ };
1298
+ sessions.set(id, session);
1299
+ inFlight = true;
1300
+ try {
1301
+ audit(id, "start_harness", {
1302
+ service_url: opts.serviceUrl,
1303
+ allowed_hosts: hostStrings(session),
1304
+ });
1305
+ await opts.browser.goto(opts.serviceUrl);
1306
+ return { ...(await observeSession(session)), hint: opts.hint ?? "" };
1307
+ }
1308
+ catch (error) {
1309
+ sessions.delete(id);
1310
+ await opts.browser.close().catch(() => undefined);
1311
+ inFlight = false;
1312
+ throw error;
1313
+ }
1314
+ }
1212
1315
  export async function observe(sessionId, detail = "compact") {
1213
1316
  const session = sessionForCall(sessionId);
1214
1317
  if (session === undefined)
@@ -2080,7 +2183,96 @@ export async function act(sessionId, action, detail = "compact") {
2080
2183
  await browser.clickViaJs(el.selector);
2081
2184
  else if (action.kind === "type") {
2082
2185
  session.committedSelectValues.delete(el.selector);
2083
- await browser.type(el.selector, action.text);
2186
+ if (!isAutocompleteScopedTypeField(action.provenance, el)) {
2187
+ // Free text only — e.g. a site-search/catalog-search box, which
2188
+ // can legitimately open its own suggestion listbox too. 3.1 only
2189
+ // applies to a form/recipe field where a committed value is
2190
+ // actually required; forcing every incidental popup into
2191
+ // commit-or-stop would break ordinary search typing.
2192
+ await browser.type(el.selector, action.text);
2193
+ }
2194
+ else {
2195
+ // 3.1 — a Google-Places-style address field or a react-select/cmdk/
2196
+ // Radix combobox can open a suggestion popup as a side effect of
2197
+ // typing, not just of an explicit `select`. Snapshot pre-existing
2198
+ // popups BEFORE typing (it can open mid-keystroke), then detect what
2199
+ // opened afterward.
2200
+ await browser.markPreexistingTypeSuggestionPopups();
2201
+ await browser.type(el.selector, action.text);
2202
+ // Cleanup (clear our tracking markers, and dismiss with Escape
2203
+ // ONLY when a detected popup is plausibly still open) must run no
2204
+ // matter how this resolves — no popup, an ambiguous stop, a
2205
+ // failed commit, or success — mirroring selectFromCombobox's own
2206
+ // try/finally. Scoping the try to only the
2207
+ // suggestionTexts.length > 0 branch left the "preexisting"
2208
+ // markers set by markPreexistingTypeSuggestionPopups uncleared on
2209
+ // the no-popup path; markComboboxPreexistingElements only ADDS
2210
+ // markers, so a stale one could exclude a genuine popup from
2211
+ // detection on a LATER type/select into the same element. Escape
2212
+ // fires on the ambiguous-stop and failed-commit paths (popup
2213
+ // never interacted with / click may not have registered — still
2214
+ // open either way) but NEVER after a confirmed commit: the widget
2215
+ // already closed its popup on selection, so Escape would land on
2216
+ // nothing and bubble to close an enclosing modal/dialog instead.
2217
+ let dismissPopupWithEscape = false;
2218
+ try {
2219
+ const suggestionTexts = await browser.detectTypeSuggestionPopup(el.selector);
2220
+ if (suggestionTexts.length > 0) {
2221
+ dismissPopupWithEscape = true;
2222
+ const candidates = matchAutocompleteSuggestions(action.text, suggestionTexts);
2223
+ if (candidates.length !== 1) {
2224
+ // Pass the MATCHED subset, not the full popup — passing every
2225
+ // suggestion made candidates.length effectively the popup
2226
+ // size (always > 0 here), so the constructor's zero-match
2227
+ // branch was dead code and the multi-match message reported
2228
+ // the wrong count.
2229
+ throw new AutocompleteCommitRequiredError(action.text, candidates.map((i) => suggestionTexts[i]));
2230
+ }
2231
+ const pickedText = suggestionTexts[candidates[0]];
2232
+ await browser.commitTypeSuggestion(candidates[0]);
2233
+ // Never trust that a click "looked right" — POSITIVELY confirm
2234
+ // the commit took (same hard constraint as the field-role
2235
+ // guard, PR #447: a miss is a stop, never a silent
2236
+ // pass-through). Checking only the typed-into selector's own
2237
+ // `.value` false-fails on react-select/cmdk-style widgets,
2238
+ // which clear their search input on selection and render the
2239
+ // committed choice in a nearby element instead —
2240
+ // confirmAutocompleteCommitted checks that too, bounded to the
2241
+ // field's own neighborhood, and returns false (never a guess)
2242
+ // when nothing confirms it.
2243
+ const committed = await browser.confirmAutocompleteCommitted(el.selector, pickedText);
2244
+ if (!committed) {
2245
+ throw new Error(`autocomplete commit for "${action.text}" did not take — nothing on the page ` +
2246
+ `confirms the field now holds "${pickedText}" after selecting it.`);
2247
+ }
2248
+ dismissPopupWithEscape = false;
2249
+ // Rewrite the completed action to the field's LIVE post-commit
2250
+ // value (not the raw typed draft) before it reaches
2251
+ // recordTrace/recordedValues, so the recorded trace reflects
2252
+ // what actually ended up on the page. Recording pickedText
2253
+ // would diverge from the live value exactly when the commit
2254
+ // was confirmed via a NEARBY element (react-select/cmdk clear
2255
+ // their search input on selection), and the cold-path
2256
+ // transition attestation (attestRecordedFieldsBeforeTransition
2257
+ // → verifyFilledFieldValues) re-reads the live value — a
2258
+ // pickedText literal would flag every such commit as a
2259
+ // mismatch and disqualify recipe recording. Reading the same
2260
+ // live value here is attestation-consistent by construction.
2261
+ // Known limitation: after a nearby-signal-only commit the
2262
+ // field itself can be empty, so the recorded literal is "" —
2263
+ // that field won't cleanly template into a saved recipe, but
2264
+ // the live run is unaffected.
2265
+ const refreshed = await browser.extractInteractiveElements();
2266
+ session.lastElements = refreshed;
2267
+ const liveField = refreshed.find((field) => field.selector === el.selector);
2268
+ const liveValue = typeof liveField?.value === "string" ? liveField.value : pickedText;
2269
+ completedAction = { ...action, text: liveValue };
2270
+ }
2271
+ }
2272
+ finally {
2273
+ await browser.discardTypeSuggestionPopup(dismissPopupWithEscape);
2274
+ }
2275
+ }
2084
2276
  }
2085
2277
  else if (action.kind === "upload") {
2086
2278
  await browser.uploadFile(el.selector, action.path);
@@ -2280,6 +2472,9 @@ export function recipeTargetFor(el, inventory = [], userEmail = null) {
2280
2472
  const domHint = pickStableDomHint(el);
2281
2473
  const hrefHint = pickHrefHint(el);
2282
2474
  const scrub = (value) => scrubKnownEmail(value, userEmail);
2475
+ // Locale-stable role for money-path fill safety (autocomplete > data-role >
2476
+ // distinguishing input type). Never label text — labels flip under i18n.
2477
+ const fieldRole = localeStableFieldRole(el);
2283
2478
  return {
2284
2479
  ...(domHint !== undefined
2285
2480
  ? {
@@ -2300,6 +2495,7 @@ export function recipeTargetFor(el, inventory = [], userEmail = null) {
2300
2495
  ...(el.visibleText !== null && el.visibleText.length > 0
2301
2496
  ? { visible_text: scrub(el.visibleText) }
2302
2497
  : {}),
2498
+ ...(fieldRole !== null ? { field_role: fieldRole } : {}),
2303
2499
  };
2304
2500
  }
2305
2501
  function recordTrace(session, action, el, sensitiveSource) {
@@ -2706,6 +2902,9 @@ export async function rememberRecipe(sessionId, opts) {
2706
2902
  const scrubbedStartUrl = scrubKnownEmail(session.startUrl, session.userEmail);
2707
2903
  const trace = traceWithVerifiedProvenance(session, opts.inputs);
2708
2904
  const postcondition = scrubRecipePostcondition(session, opts.postcondition, opts.inputs);
2905
+ if (opts.entry_mode !== undefined && opts.verb === undefined) {
2906
+ throw new Error("runtime recipe entry requires a keyed verb");
2907
+ }
2709
2908
  const recipe = {
2710
2909
  name: opts.name,
2711
2910
  schema_version: 1,
@@ -2715,7 +2914,9 @@ export async function rememberRecipe(sessionId, opts) {
2715
2914
  : {}),
2716
2915
  // Canonical, stable replay entry — the page the session started at, never a
2717
2916
  // mid-flow single-use link inferred from the trace.
2718
- ...(isSingleUseUrl(session.startUrl) || scrubbedStartUrl !== session.startUrl
2917
+ ...(opts.entry_mode === "runtime_service_url" ||
2918
+ isSingleUseUrl(session.startUrl) ||
2919
+ scrubbedStartUrl !== session.startUrl
2719
2920
  ? { entry_mode: "runtime_service_url" }
2720
2921
  : { entry_url: session.startUrl }),
2721
2922
  allowed_hosts: [...new Set(egressSeedHosts(session))],
@@ -2735,13 +2936,50 @@ export async function rememberRecipe(sessionId, opts) {
2735
2936
  secrets: secrets.length,
2736
2937
  file,
2737
2938
  });
2939
+ const checkoutLegFile = await rememberCheckoutLeg(session, opts.verb, trace).catch(() => null);
2738
2940
  return {
2739
2941
  file,
2740
2942
  name: opts.name,
2741
2943
  steps: recipe.trace.length,
2742
2944
  secrets: secrets.map((s) => s.slot),
2743
2945
  verified,
2946
+ ...(checkoutLegFile !== null ? { checkout_leg_file: checkoutLegFile } : {}),
2947
+ };
2948
+ }
2949
+ // replay-per-leg-signature — split off and save the checkout portion of a
2950
+ // just-recorded money-path trace as its OWN recipe, keyed by the checkout
2951
+ // page's live field-name-set signature instead of domain. Best-effort and
2952
+ // silent on any reason it can't produce one (non-money verb, no money field
2953
+ // captured, empty live field set) — the whole-task recipe above is already
2954
+ // saved and stands on its own either way; this only ever ADDS a second,
2955
+ // narrower, cross-domain-reusable recipe alongside it.
2956
+ async function rememberCheckoutLeg(session, verb, trace) {
2957
+ if (verb === undefined || !MONEY_REPLAY_VERBS.has(verb))
2958
+ return null;
2959
+ const legStart = checkoutLegStartIndex(trace);
2960
+ if (legStart === null)
2961
+ return null;
2962
+ const legTrace = trace.slice(legStart);
2963
+ const fieldNames = await session.browser.extractCheckoutFieldNames();
2964
+ const signature = checkoutFieldSetSignature(fieldNames);
2965
+ if (signature === null)
2966
+ return null;
2967
+ const legSlots = new Set(legTrace
2968
+ .filter((entry) => entry.action.kind === "type_secret")
2969
+ .map((entry) => entry.action.slot)
2970
+ .filter((slot) => slot !== undefined));
2971
+ const checkoutRecipe = {
2972
+ name: `checkout-leg--${signature.slice(0, 12)}`,
2973
+ schema_version: 1,
2974
+ goal: "Fill the checkout leg's fields",
2975
+ verb,
2976
+ domain: checkoutShapeKey(signature),
2977
+ allowed_hosts: [],
2978
+ trace: legTrace,
2979
+ secrets: [...legSlots].map((slot) => ({ slot, stored: false })),
2980
+ postcondition: checkoutLegPostcondition(legTrace),
2744
2981
  };
2982
+ return await writeRecipe(checkoutRecipe);
2745
2983
  }
2746
2984
  // Read a single page snapshot for postcondition checking. Field VALUES are
2747
2985
  // reduced to lengths here so a token/secret success-signal can't leak.
@@ -2810,6 +3048,21 @@ export async function verifyActiveRecipePostcondition(sessionId, recipeName) {
2810
3048
  }
2811
3049
  return await verifyPostcondition(sessionId, state.boundPostcondition);
2812
3050
  }
3051
+ // replay-per-leg-signature — the checkout leg's registry/local-store key,
3052
+ // computed from the CURRENT live page. Callers use this to resolve (and,
3053
+ // on a hit, replay via replayOperatorRecipe) a checkout-leg recipe
3054
+ // independently of whatever (or whether any) whole-task recipe applies to
3055
+ // this session's domain — the mechanism that lets a checkout plan recorded
3056
+ // on one store resolve on a different, unrelated store of the same
3057
+ // checkout platform. Returns null when the live page has no field-name-set
3058
+ // to key by (nothing to resolve yet).
3059
+ export async function checkoutShapeSignatureForSession(sessionId) {
3060
+ const session = sessionForCall(sessionId);
3061
+ if (session === undefined)
3062
+ throw new Error(`unknown provision session ${sessionId}`);
3063
+ const fieldNames = await session.browser.extractCheckoutFieldNames();
3064
+ return checkoutFieldSetSignature(fieldNames);
3065
+ }
2813
3066
  const MONEY_REPLAY_VERBS = new Set([
2814
3067
  "purchase",
2815
3068
  "subscribe",
@@ -2835,6 +3088,25 @@ function boundReplayTarget(action, bindings) {
2835
3088
  return target === null ? null : bindRecipeTarget(target, bindings, action.email_hole);
2836
3089
  }
2837
3090
  const MONEY_FIELD_TARGET = /(?:^|[\s._-])(?:address|street|line\s*[12]|city|state|province|postal|zip|country|e-?mail|phone|first\s*name|last\s*name|full\s*name|quantity|qty)(?:$|[\s._-])/i;
3091
+ // 3.1 must only engage for a form/recipe field where a committed value is
3092
+ // actually required (a checkout form field, or one the host tagged with a
3093
+ // recipe hole) — not arbitrary typing. The popup-shape detection
3094
+ // (role=listbox/menu/dialog, etc.) also matches an incidental suggestion
3095
+ // popup on an ordinary site-search/catalog-search box; without this scope,
3096
+ // typing a search query either auto-clicks a suggestion (navigating as a
3097
+ // side effect of "type") or throws, with no way to keep free text. Reuses
3098
+ // the existing MONEY_FIELD_TARGET shape check (moneyFieldName's sibling,
3099
+ // just read off the live element instead of a recorded TraceAction) rather
3100
+ // than inventing a new heuristic.
3101
+ function isAutocompleteScopedTypeField(provenance, el) {
3102
+ if (provenance !== undefined)
3103
+ return true;
3104
+ const label = [el.testId, el.id, el.name, el.ariaLabel, el.labelText, el.placeholder]
3105
+ .filter((value) => typeof value === "string" && value.length > 0)
3106
+ .join(" ")
3107
+ .replace(/([a-z])([A-Z])/g, "$1 $2");
3108
+ return MONEY_FIELD_TARGET.test(label);
3109
+ }
2838
3110
  function moneyFieldName(action) {
2839
3111
  if (action.kind === "set_phone_country")
2840
3112
  return "phone_country";
@@ -2855,6 +3127,37 @@ function moneyFieldName(action) {
2855
3127
  .replace(/([a-z])([A-Z])/g, "$1 $2");
2856
3128
  return MONEY_FIELD_TARGET.test(label) ? label || "field" : null;
2857
3129
  }
3130
+ // replay-per-leg-signature — the checkout leg is whatever portion of a
3131
+ // money-path trace touches money fields (address/contact/card-adjacent —
3132
+ // the exact same classifier moneyFieldName already uses for the fill
3133
+ // guard). Reusing it here means the leg boundary needs no new heuristic
3134
+ // (no URL/path pattern-matching, no platform name): the first step the
3135
+ // existing guard already treats as a money field IS where checkout starts.
3136
+ // Returns null when the trace has no money field at all (verb classified
3137
+ // money-path but nothing was actually captured, or a non-money recipe).
3138
+ function checkoutLegStartIndex(trace) {
3139
+ const index = trace.findIndex((entry) => moneyFieldName(entry.action) !== null);
3140
+ return index === -1 ? null : index;
3141
+ }
3142
+ // A checkout-leg-only recipe still needs a postcondition (the schema
3143
+ // requires one), but it isn't the whole task's "order placed" signal — it
3144
+ // only covers the leg it replays. Anchor it to the LAST money field in the
3145
+ // leg holding a non-empty value, mirroring the field_text/min_value_len
3146
+ // pattern already used elsewhere (e.g. the OAuth Playground token check):
3147
+ // checks a length, never a value, so it can't leak what it proves.
3148
+ // legTrace is guaranteed non-empty and to start on a money field by
3149
+ // construction (checkoutLegStartIndex found it), so a label always exists.
3150
+ function checkoutLegPostcondition(legTrace) {
3151
+ const lastLabel = [...legTrace]
3152
+ .reverse()
3153
+ .map((entry) => moneyFieldName(entry.action))
3154
+ .find((label) => label !== null);
3155
+ return {
3156
+ kind: "execute_capability",
3157
+ describe: "checkout leg fields filled and re-verified",
3158
+ success_signal: { field_text: lastLabel, min_value_len: 1 },
3159
+ };
3160
+ }
2858
3161
  function findUnprovenancedMoneyField(recipe) {
2859
3162
  if (recipe.verb === undefined || !MONEY_REPLAY_VERBS.has(recipe.verb))
2860
3163
  return null;
@@ -2915,6 +3218,20 @@ function markReplayFailure(session, reason, field) {
2915
3218
  session.replayState.failure = { reason, field };
2916
3219
  audit(session.id, "replay_field_value_guard", { ok: false, reason, field });
2917
3220
  }
3221
+ // replay-serve-live-domainlock — checks a replay-bound goto/allow_host
3222
+ // target against the recipe's own eTLD+1. Unlike the ordinary session goto
3223
+ // gate, recipe replay has no auth-host exceptions. Checkout-shape recipes
3224
+ // cannot execute goto/allow_host actions because they have no site domain.
3225
+ function replayTargetWithinRecipeDomain(url, recipeDomain) {
3226
+ return isSameRecipeDomain(url, recipeDomain);
3227
+ }
3228
+ function markReplayDomainLockViolation(session, host, recipeDomain) {
3229
+ rejectRecipeRecording(session, `replay refused: "${host}" is outside the recipe's own domain "${recipeDomain}"`);
3230
+ if (session.replayState === null)
3231
+ return;
3232
+ session.replayState.paymentGuard = "failed";
3233
+ audit(session.id, "replay_domain_lock_violation", { host, recipe_domain: recipeDomain });
3234
+ }
2918
3235
  function verifyReplayFieldInElements(session, expected, elements, allowCommittedSelect = false) {
2919
3236
  if (expected.kind === "set_phone_country" || expected.target === null) {
2920
3237
  return { ok: false, reason: "field_missing" };
@@ -3191,7 +3508,7 @@ async function verifyReplayFieldsAfterTransition(session, action, attestedFields
3191
3508
  * A miss is returned to the host with a continuation index; after the host
3192
3509
  * repairs that one step, calling again with `fromIndex=next_index` continues.
3193
3510
  */
3194
- export async function replayOperatorRecipe(sessionId, recipe, bindings, fromIndex = 0) {
3511
+ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromIndex = 0, options = {}) {
3195
3512
  const session = sessions.get(sessionId);
3196
3513
  if (session === undefined)
3197
3514
  throw new Error(`unknown provision session ${sessionId}`);
@@ -3204,6 +3521,33 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
3204
3521
  const isMoneyPath = MONEY_REPLAY_VERBS.has(recipe.verb);
3205
3522
  let state;
3206
3523
  const humanRequired = async (reason, field) => {
3524
+ // replay-per-leg-signature — a genuine catalog/storefront prefix exists
3525
+ // ahead of the checkout leg (legStartIndex > 0): degrade to a leg-scoped
3526
+ // fallback instead of aborting the whole replay. A recipe with no such
3527
+ // prefix (legStartIndex is 0 or null — a simple/single-leg money-path
3528
+ // recipe) has nothing narrower to fall back to, so it keeps today's
3529
+ // behavior exactly: hard-stop at human_required.
3530
+ if (state.moneyPath && state.legStartIndex !== null && state.legStartIndex > 0) {
3531
+ const fromStepIndex = state.legStartIndex;
3532
+ // Same fail-closed payment gate as human_required (markReplayFailure
3533
+ // sets paymentGuard:"failed" and rejects recipe re-recording) — only
3534
+ // the RETURNED status differs. Deliberately does NOT null out
3535
+ // session.replayState: activeProvisionSession's operate_pay gate
3536
+ // reads session.replayState.paymentGuard, so clearing it would
3537
+ // silently reopen the payment gate instead of keeping it shut.
3538
+ // nextIndex is left unset (not stepIndex+1), so a resume_from
3539
+ // attempt still hits "invalid replay continuation" below — this is
3540
+ // not a resumable fallback the way fallback_required is.
3541
+ markReplayFailure(session, reason, field);
3542
+ audit(sessionId, "replay_leg_fallback", { reason, field, from_step_index: fromStepIndex });
3543
+ return {
3544
+ status: "leg_fallback_required",
3545
+ observation: await observe(sessionId),
3546
+ leg: "checkout",
3547
+ from_step_index: fromStepIndex,
3548
+ reason: `${reason}: ${field}`,
3549
+ };
3550
+ }
3207
3551
  markReplayFailure(session, reason, field);
3208
3552
  return { status: "human_required", observation: await observe(sessionId), reason, field };
3209
3553
  };
@@ -3222,6 +3566,7 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
3222
3566
  expectedFields: expected.fields,
3223
3567
  verifiedFields: new Set(),
3224
3568
  paymentGuard: isMoneyPath ? "pending" : "verified",
3569
+ legStartIndex: isMoneyPath ? checkoutLegStartIndex(recipe.trace) : null,
3225
3570
  };
3226
3571
  session.replayState = state;
3227
3572
  if (expected.missing !== null)
@@ -3263,9 +3608,21 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
3263
3608
  reason,
3264
3609
  };
3265
3610
  };
3611
+ const recipeDomain = recipe.domain;
3612
+ const domainLockViolation = async (stepIndex, host) => {
3613
+ markReplayDomainLockViolation(session, host, recipeDomain);
3614
+ return {
3615
+ status: "domain_lock_violation",
3616
+ observation: await observe(sessionId),
3617
+ step_index: stepIndex,
3618
+ host,
3619
+ recipe_domain: recipeDomain,
3620
+ };
3621
+ };
3266
3622
  for (let i = fromIndex; i < recipe.trace.length; i += 1) {
3267
3623
  const step = recipe.trace[i];
3268
3624
  const recorded = step.action;
3625
+ await options.beforeStep?.({ step_index: i, action: recorded });
3269
3626
  let action;
3270
3627
  if (recorded.kind === "extract") {
3271
3628
  return await fallback(step, i, "credential extraction requires host planning on the live page");
@@ -3274,6 +3631,11 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
3274
3631
  return await fallback(step, i, "payment requires the existing operate_pay approval flow");
3275
3632
  }
3276
3633
  if (recorded.kind === "goto") {
3634
+ // This lock covers explicit goto/allow_host steps only. Organic redirects
3635
+ // and OAuth popups remain governed by the existing session navigation model.
3636
+ if (isCheckoutShapeKey(recipeDomain)) {
3637
+ return await domainLockViolation(i, recorded.url_template ?? "<goto>");
3638
+ }
3277
3639
  if (recorded.url_template === undefined) {
3278
3640
  return await fallback(step, i, "goto step has no URL");
3279
3641
  }
@@ -3288,12 +3650,28 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
3288
3650
  if (filled.missing.length > 0) {
3289
3651
  return await fallback(step, i, `missing bindings: ${filled.missing.join(", ")}`);
3290
3652
  }
3653
+ if (!replayTargetWithinRecipeDomain(filled.url, recipeDomain)) {
3654
+ let host;
3655
+ try {
3656
+ host = new URL(filled.url).hostname;
3657
+ }
3658
+ catch {
3659
+ host = filled.url;
3660
+ }
3661
+ return await domainLockViolation(i, host);
3662
+ }
3291
3663
  action = { kind: "goto", url: filled.url };
3292
3664
  }
3293
3665
  else if (recorded.kind === "allow_host") {
3666
+ if (isCheckoutShapeKey(recipeDomain)) {
3667
+ return await domainLockViolation(i, recorded.host ?? "<allow_host>");
3668
+ }
3294
3669
  if (recorded.host === undefined) {
3295
3670
  return await fallback(step, i, "allow_host step has no host");
3296
3671
  }
3672
+ if (!replayTargetWithinRecipeDomain(`https://${recorded.host}`, recipeDomain)) {
3673
+ return await domainLockViolation(i, recorded.host);
3674
+ }
3297
3675
  action = { kind: "allow_host", host: recorded.host };
3298
3676
  }
3299
3677
  else if (recorded.kind === "press") {
@@ -3399,6 +3777,7 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
3399
3777
  if (!transitionAttestation.ok) {
3400
3778
  return await humanRequired(transitionAttestation.reason, transitionAttestation.field);
3401
3779
  }
3780
+ await options.beforeAction?.({ step_index: i, action });
3402
3781
  await act(sessionId, action, "none");
3403
3782
  replayed += 1;
3404
3783
  const expected = state.expectedFields.get(i);