@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.
- package/README.md +13 -5
- package/dist/bot/browser.d.ts +96 -1
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +420 -1
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/operator-recipe.d.ts +27 -975
- package/dist/bot/operator-recipe.d.ts.map +1 -1
- package/dist/bot/operator-recipe.js +88 -237
- package/dist/bot/operator-recipe.js.map +1 -1
- package/dist/bot/pay-operator.d.ts +2 -0
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +9 -5
- package/dist/bot/pay-operator.js.map +1 -1
- package/dist/bot/provision-session.d.ts +44 -2
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +383 -4
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/skill-registry-client.d.ts +57 -0
- package/dist/skill-registry-client.d.ts.map +1 -1
- package/dist/skill-registry-client.js +156 -7
- package/dist/skill-registry-client.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +73 -26
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +224 -10
- package/dist/tools/provision-drive.js.map +1 -1
- package/package.json +3 -1
- package/dist/eval/replay-harness/corpus.d.ts +0 -7
- package/dist/eval/replay-harness/corpus.d.ts.map +0 -1
- package/dist/eval/replay-harness/corpus.js +0 -183
- package/dist/eval/replay-harness/corpus.js.map +0 -1
- package/dist/eval/replay-harness/har-mutate.d.ts +0 -36
- package/dist/eval/replay-harness/har-mutate.d.ts.map +0 -1
- package/dist/eval/replay-harness/har-mutate.js +0 -119
- package/dist/eval/replay-harness/har-mutate.js.map +0 -1
- package/dist/eval/replay-harness/har-substrate.d.ts +0 -16
- package/dist/eval/replay-harness/har-substrate.d.ts.map +0 -1
- package/dist/eval/replay-harness/har-substrate.js +0 -99
- package/dist/eval/replay-harness/har-substrate.js.map +0 -1
- package/dist/eval/replay-harness/index.d.ts +0 -8
- package/dist/eval/replay-harness/index.d.ts.map +0 -1
- package/dist/eval/replay-harness/index.js +0 -8
- package/dist/eval/replay-harness/index.js.map +0 -1
- package/dist/eval/replay-harness/metrics.d.ts +0 -9
- package/dist/eval/replay-harness/metrics.d.ts.map +0 -1
- package/dist/eval/replay-harness/metrics.js +0 -117
- package/dist/eval/replay-harness/metrics.js.map +0 -1
- package/dist/eval/replay-harness/reporter.d.ts +0 -4
- package/dist/eval/replay-harness/reporter.d.ts.map +0 -1
- package/dist/eval/replay-harness/reporter.js +0 -62
- package/dist/eval/replay-harness/reporter.js.map +0 -1
- package/dist/eval/replay-harness/runner.d.ts +0 -36
- package/dist/eval/replay-harness/runner.d.ts.map +0 -1
- package/dist/eval/replay-harness/runner.js +0 -167
- package/dist/eval/replay-harness/runner.js.map +0 -1
- package/dist/eval/replay-harness/types.d.ts +0 -135
- package/dist/eval/replay-harness/types.d.ts.map +0 -1
- package/dist/eval/replay-harness/types.js +0 -2
- 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
|
-
|
|
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
|
-
...(
|
|
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);
|