@trusty-squire/mcp 1.1.6 → 1.1.7

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 (75) hide show
  1. package/README.md +34 -16
  2. package/dist/api-client.d.ts +11 -3
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +14 -4
  5. package/dist/api-client.js.map +1 -1
  6. package/dist/bot/browser.d.ts +19 -1
  7. package/dist/bot/browser.d.ts.map +1 -1
  8. package/dist/bot/browser.js +228 -30
  9. package/dist/bot/browser.js.map +1 -1
  10. package/dist/bot/near-text-hint.d.ts +7 -3
  11. package/dist/bot/near-text-hint.d.ts.map +1 -1
  12. package/dist/bot/near-text-hint.js +1 -2
  13. package/dist/bot/near-text-hint.js.map +1 -1
  14. package/dist/bot/operator-recipe.d.ts +694 -36
  15. package/dist/bot/operator-recipe.d.ts.map +1 -1
  16. package/dist/bot/operator-recipe.js +565 -43
  17. package/dist/bot/operator-recipe.js.map +1 -1
  18. package/dist/bot/pay-operator.d.ts +4 -2
  19. package/dist/bot/pay-operator.d.ts.map +1 -1
  20. package/dist/bot/pay-operator.js +173 -67
  21. package/dist/bot/pay-operator.js.map +1 -1
  22. package/dist/bot/promote-to-skill.d.ts +1 -0
  23. package/dist/bot/promote-to-skill.d.ts.map +1 -1
  24. package/dist/bot/promote-to-skill.js +20 -30
  25. package/dist/bot/promote-to-skill.js.map +1 -1
  26. package/dist/bot/provision-session.d.ts +44 -1
  27. package/dist/bot/provision-session.d.ts.map +1 -1
  28. package/dist/bot/provision-session.js +1300 -104
  29. package/dist/bot/provision-session.js.map +1 -1
  30. package/dist/eval/replay-harness/corpus.d.ts +7 -0
  31. package/dist/eval/replay-harness/corpus.d.ts.map +1 -0
  32. package/dist/eval/replay-harness/corpus.js +183 -0
  33. package/dist/eval/replay-harness/corpus.js.map +1 -0
  34. package/dist/eval/replay-harness/har-mutate.d.ts +36 -0
  35. package/dist/eval/replay-harness/har-mutate.d.ts.map +1 -0
  36. package/dist/eval/replay-harness/har-mutate.js +119 -0
  37. package/dist/eval/replay-harness/har-mutate.js.map +1 -0
  38. package/dist/eval/replay-harness/har-substrate.d.ts +16 -0
  39. package/dist/eval/replay-harness/har-substrate.d.ts.map +1 -0
  40. package/dist/eval/replay-harness/har-substrate.js +99 -0
  41. package/dist/eval/replay-harness/har-substrate.js.map +1 -0
  42. package/dist/eval/replay-harness/index.d.ts +8 -0
  43. package/dist/eval/replay-harness/index.d.ts.map +1 -0
  44. package/dist/eval/replay-harness/index.js +8 -0
  45. package/dist/eval/replay-harness/index.js.map +1 -0
  46. package/dist/eval/replay-harness/metrics.d.ts +9 -0
  47. package/dist/eval/replay-harness/metrics.d.ts.map +1 -0
  48. package/dist/eval/replay-harness/metrics.js +117 -0
  49. package/dist/eval/replay-harness/metrics.js.map +1 -0
  50. package/dist/eval/replay-harness/reporter.d.ts +4 -0
  51. package/dist/eval/replay-harness/reporter.d.ts.map +1 -0
  52. package/dist/eval/replay-harness/reporter.js +62 -0
  53. package/dist/eval/replay-harness/reporter.js.map +1 -0
  54. package/dist/eval/replay-harness/runner.d.ts +36 -0
  55. package/dist/eval/replay-harness/runner.d.ts.map +1 -0
  56. package/dist/eval/replay-harness/runner.js +167 -0
  57. package/dist/eval/replay-harness/runner.js.map +1 -0
  58. package/dist/eval/replay-harness/types.d.ts +135 -0
  59. package/dist/eval/replay-harness/types.d.ts.map +1 -0
  60. package/dist/eval/replay-harness/types.js +2 -0
  61. package/dist/eval/replay-harness/types.js.map +1 -0
  62. package/dist/server.d.ts.map +1 -1
  63. package/dist/server.js +1 -0
  64. package/dist/server.js.map +1 -1
  65. package/dist/tools/grant-app-access.js +1 -1
  66. package/dist/tools/grant-app-access.js.map +1 -1
  67. package/dist/tools/operate-pay.d.ts +10 -10
  68. package/dist/tools/operate-pay.d.ts.map +1 -1
  69. package/dist/tools/operate-pay.js +42 -16
  70. package/dist/tools/operate-pay.js.map +1 -1
  71. package/dist/tools/provision-drive.d.ts +165 -8
  72. package/dist/tools/provision-drive.d.ts.map +1 -1
  73. package/dist/tools/provision-drive.js +211 -40
  74. package/dist/tools/provision-drive.js.map +1 -1
  75. package/package.json +2 -1
@@ -16,6 +16,7 @@
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 { Buffer } from "node:buffer";
19
20
  import { chmodSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
20
21
  import { tmpdir } from "node:os";
21
22
  import { join } from "node:path";
@@ -26,9 +27,9 @@ import { pickVerificationLink } from "./email-verification.js";
26
27
  import { detectActiveProviderSessions, ensureOAuthSession } from "./google-login.js";
27
28
  import { loggedInEmail } from "./login-state.js";
28
29
  import { loginSessionGuidance } from "./skill-hint.js";
29
- import { checkSuccessSignal, isSingleUseUrl, writeRecipe, } from "./operator-recipe.js";
30
+ import { checkSuccessSignal, bindKnownEmailTemplate, bindRecipePostcondition, bindRecipeTarget, bindRecipeValue, cssEscapeRecipeValue, fillTemplate, hasRecipeTargetCandidate, isSingleUseUrl, knownRecipeInputValue, operatorRecipeDomain, resolveRecipeFieldTarget, resolveRecipeRepairTarget, resolveRecipeTarget, verifyFilledFieldValues, writeRecipe, } from "./operator-recipe.js";
30
31
  import { captureOnboardingRound, currentRunId, resetCaptureChain, resolveCaptureDir, } from "./onboarding-capture.js";
31
- import { promoteToSkill } from "./promote-to-skill.js";
32
+ import { promoteToSkill, pickRowDisambiguator, pickStableDomHint, pickHrefHint, } from "./promote-to-skill.js";
32
33
  import { serviceSlugFromHost } from "@trusty-squire/skill-schema";
33
34
  import { looksLikeCodeIdentifier, looksLikeCredentialValue, isCredentialNoise, findCredentialTokens, keyFamilyPrefix, pickRelaxedNearCopyCredential, } from "./credential-shape.js";
34
35
  import { initialExtractionState, accumulateCandidate, hasFullHit, resolveExtraction, } from "./extraction.js";
@@ -54,6 +55,119 @@ function egressSeedHosts(session) {
54
55
  return session.allowedHosts.filter((e) => e.source !== "mid_session").map((e) => e.host);
55
56
  }
56
57
  const sessions = new Map();
58
+ function positiveEnvNumber(name, fallback) {
59
+ const value = Number(process.env[name]);
60
+ return Number.isFinite(value) && value > 0 ? value : fallback;
61
+ }
62
+ const WARM_BROWSER_IDLE_TTL_MS = positiveEnvNumber("BOT_WARM_BROWSER_IDLE_TTL_MS", 6 * 60 * 60 * 1_000);
63
+ const WARM_BROWSER_MAX_REUSES = positiveEnvNumber("BOT_WARM_BROWSER_MAX_REUSES", 50);
64
+ const WARM_BROWSER_MAX_AGE_MS = positiveEnvNumber("BOT_WARM_BROWSER_MAX_AGE_MS", 24 * 60 * 60 * 1_000);
65
+ let warmBrowser = null;
66
+ let warmBrowserIdleTimer = null;
67
+ // Sequential single-page model: one start may be booting OR one task may hold
68
+ // the browser. This is also the lifecycle/payment safety lease — every reaper
69
+ // checks it before closing shared Chrome.
70
+ let starting = false;
71
+ let inFlight = false;
72
+ function clearWarmBrowserIdleTimer() {
73
+ if (warmBrowserIdleTimer === null)
74
+ return;
75
+ clearTimeout(warmBrowserIdleTimer);
76
+ warmBrowserIdleTimer = null;
77
+ }
78
+ async function closeWarmBrowserIfIdle(reason) {
79
+ if (warmBrowser === null)
80
+ return false;
81
+ if (starting || inFlight) {
82
+ armWarmBrowserIdleTimer();
83
+ return false;
84
+ }
85
+ const browser = warmBrowser.controller;
86
+ warmBrowser = null;
87
+ clearWarmBrowserIdleTimer();
88
+ console.error(`[operator] recycling warm browser reason=${reason}`);
89
+ await browser.close().catch(() => undefined);
90
+ return true;
91
+ }
92
+ function armWarmBrowserIdleTimer() {
93
+ clearWarmBrowserIdleTimer();
94
+ if (warmBrowser === null)
95
+ return;
96
+ warmBrowserIdleTimer = setTimeout(() => {
97
+ warmBrowserIdleTimer = null;
98
+ void closeWarmBrowserIfIdle("idle_ttl");
99
+ }, WARM_BROWSER_IDLE_TTL_MS);
100
+ warmBrowserIdleTimer.unref();
101
+ }
102
+ // Every operate call reaches this module through start or a session lookup.
103
+ // Resetting the one unref'd timer here measures genuine operator quiet.
104
+ function touchWarmBrowser() {
105
+ armWarmBrowserIdleTimer();
106
+ }
107
+ function warmBrowserExpired(slot) {
108
+ if (slot.reuseCount >= WARM_BROWSER_MAX_REUSES)
109
+ return "max_reuses";
110
+ if (Date.now() - slot.createdAt >= WARM_BROWSER_MAX_AGE_MS)
111
+ return "max_age";
112
+ return null;
113
+ }
114
+ async function acquireWarmBrowser(opts, sessionId) {
115
+ const slot = warmBrowser;
116
+ if (slot !== null) {
117
+ const expired = warmBrowserExpired(slot);
118
+ const eligible = slot.controller.matchesLaunchOptions(opts);
119
+ const healthy = slot.controller.isConnected();
120
+ if (expired !== null || !eligible || !healthy) {
121
+ warmBrowser = null;
122
+ clearWarmBrowserIdleTimer();
123
+ await slot.controller.close().catch(() => undefined);
124
+ audit(sessionId, "browser_recycle", {
125
+ reason: expired ?? (!eligible ? "launch_config_mismatch" : "disconnected"),
126
+ });
127
+ }
128
+ else {
129
+ try {
130
+ await slot.controller.resetPageForReuse();
131
+ slot.reuseCount += 1;
132
+ touchWarmBrowser();
133
+ audit(sessionId, "browser_reuse", { reuse_count: slot.reuseCount });
134
+ return slot.controller;
135
+ }
136
+ catch {
137
+ warmBrowser = null;
138
+ clearWarmBrowserIdleTimer();
139
+ await slot.controller.close().catch(() => undefined);
140
+ audit(sessionId, "browser_recycle", { reason: "page_reset_failed" });
141
+ }
142
+ }
143
+ }
144
+ const controller = new BrowserController({
145
+ ...(opts.profileDir !== undefined ? { profileDir: opts.profileDir } : {}),
146
+ ...(opts.proxyUrl !== undefined ? { proxyUrl: opts.proxyUrl } : {}),
147
+ });
148
+ await startBrowserBounded(controller, sessionId);
149
+ warmBrowser = { controller, createdAt: Date.now(), reuseCount: 0 };
150
+ touchWarmBrowser();
151
+ return controller;
152
+ }
153
+ async function releaseWarmBrowserPage(browser) {
154
+ if (warmBrowser?.controller !== browser) {
155
+ await browser.close().catch(() => undefined);
156
+ return;
157
+ }
158
+ try {
159
+ await browser.resetPageForReuse();
160
+ }
161
+ catch {
162
+ warmBrowser = null;
163
+ clearWarmBrowserIdleTimer();
164
+ await browser.close().catch(() => undefined);
165
+ }
166
+ }
167
+ function sessionForCall(sessionId) {
168
+ touchWarmBrowser();
169
+ return sessions.get(sessionId);
170
+ }
57
171
  const settle = (ms) => new Promise((r) => setTimeout(r, ms));
58
172
  // Audit trail (security posture): every session action emits one structured
59
173
  // stderr line the host's MCP log captures. The `provision-audit` marker makes
@@ -948,38 +1062,70 @@ export function googleSessionGate(liveProviders) {
948
1062
  },
949
1063
  };
950
1064
  }
951
- async function ensureProvisionPrimaryProviderSession(profileDir) {
952
- const initial = await detectActiveProviderSessions(profileDir).catch(() => []);
1065
+ async function ensureProvisionPrimaryProviderSession(opts, sessionId) {
1066
+ const slot = warmBrowser;
1067
+ if (slot !== null) {
1068
+ const expired = warmBrowserExpired(slot);
1069
+ const eligible = slot.controller.matchesLaunchOptions(opts);
1070
+ const healthy = slot.controller.isConnected();
1071
+ if (expired === null && eligible && healthy) {
1072
+ const warmProviders = await slot.controller.detectSessionProviders();
1073
+ if (warmProviders.includes("google")) {
1074
+ touchWarmBrowser();
1075
+ return warmProviders;
1076
+ }
1077
+ }
1078
+ warmBrowser = null;
1079
+ clearWarmBrowserIdleTimer();
1080
+ await slot.controller.close().catch(() => undefined);
1081
+ audit(sessionId, "browser_recycle", {
1082
+ reason: expired ??
1083
+ (!eligible
1084
+ ? "launch_config_mismatch"
1085
+ : !healthy
1086
+ ? "disconnected"
1087
+ : "identity_probe_requires_cold_boot"),
1088
+ });
1089
+ }
1090
+ const initial = await detectActiveProviderSessions(opts.profileDir).catch(() => []);
953
1091
  if (initial.includes("google"))
954
1092
  return initial;
955
1093
  const result = await ensureOAuthSession({
956
1094
  provider: "google",
957
- ...(profileDir !== undefined ? { profileDir } : {}),
1095
+ ...(opts.profileDir !== undefined ? { profileDir: opts.profileDir } : {}),
958
1096
  });
959
1097
  if (result.status !== "already_valid" && result.status !== "logged_in") {
960
1098
  return initial;
961
1099
  }
962
- const after = await detectActiveProviderSessions(profileDir).catch(() => []);
1100
+ const after = await detectActiveProviderSessions(opts.profileDir).catch(() => []);
963
1101
  return after.includes("google") ? after : ["google", ...after];
964
1102
  }
965
1103
  export async function startProvisionSession(opts) {
966
1104
  const id = randomUUID();
967
- const liveProviders = await ensureProvisionPrimaryProviderSession(opts.profileDir);
968
- // Change 5 fail-closed identity gate BEFORE driving. If an operate task
969
- // needs to act as the user and there's no live Google session, hand back now;
970
- // do not start the browser or the task. No autonomous login is attempted.
971
- if (opts.requireLiveIdentity === true) {
972
- const gate = googleSessionGate(liveProviders);
973
- if (!gate.ok) {
974
- audit(id, "connect_gate", { ok: false, wall: "google_session" });
975
- return { session_id: id, url: "", text: "", elements: [], needs_user: gate.needs_user };
976
- }
977
- }
978
- const browser = new BrowserController({
979
- ...(opts.profileDir !== undefined ? { profileDir: opts.profileDir } : {}),
980
- ...(opts.proxyUrl !== undefined ? { proxyUrl: opts.proxyUrl } : {}),
981
- });
982
- await startBrowserBounded(browser, id);
1105
+ touchWarmBrowser();
1106
+ if (starting || inFlight) {
1107
+ throw new Error("operate_start refused: another operator session is already in flight");
1108
+ }
1109
+ starting = true;
1110
+ let browser;
1111
+ let liveProviders;
1112
+ try {
1113
+ liveProviders = await ensureProvisionPrimaryProviderSession(opts, id);
1114
+ // Change 5 — fail-closed identity gate BEFORE driving. If an operate task
1115
+ // needs to act as the user and there's no live Google session, hand back now;
1116
+ // do not start the browser or the task. No autonomous login is attempted.
1117
+ if (opts.requireLiveIdentity === true) {
1118
+ const gate = googleSessionGate(liveProviders);
1119
+ if (!gate.ok) {
1120
+ audit(id, "connect_gate", { ok: false, wall: "google_session" });
1121
+ return { session_id: id, url: "", text: "", elements: [], needs_user: gate.needs_user };
1122
+ }
1123
+ }
1124
+ browser = await acquireWarmBrowser(opts, id);
1125
+ }
1126
+ finally {
1127
+ starting = false;
1128
+ }
983
1129
  const targetHost = registrableHost(opts.serviceUrl);
984
1130
  const seedHosts = [
985
1131
  ...(targetHost !== null ? [targetHost] : []),
@@ -1002,8 +1148,12 @@ export async function startProvisionSession(opts) {
1002
1148
  prevObserve: null,
1003
1149
  observeSnapshotFile: null,
1004
1150
  actionTrace: [],
1151
+ recordedValues: [],
1152
+ committedSelectValues: new Map(),
1005
1153
  captureRounds: [],
1006
1154
  usedLocatorFallback: false,
1155
+ recipeRejectionReason: null,
1156
+ replayState: null,
1007
1157
  startedAt: Date.now(),
1008
1158
  hintServed: opts.hint !== undefined,
1009
1159
  startUrl: opts.serviceUrl,
@@ -1012,45 +1162,55 @@ export async function startProvisionSession(opts) {
1012
1162
  ...(opts.api !== undefined ? { api: opts.api } : {}),
1013
1163
  };
1014
1164
  sessions.set(id, session);
1015
- audit(id, "start", {
1016
- service_url: opts.serviceUrl,
1017
- allowed_hosts: hostStrings(session),
1018
- has_hint: opts.hint !== undefined,
1019
- });
1020
- await browser.goto(opts.serviceUrl);
1021
- // A cookie/consent overlay (Usercentrics/OneTrust/…) renders after load and its
1022
- // backdrop occludes the ENTIRE form — the agent then sees every element
1023
- // occluded_by a div and gives up, or falls back to the only thing that looks
1024
- // clickable (e.g. a "Connect wallet" CTA on the Robinhood faucet). Dismiss it
1025
- // BEFORE the first observation so the real actionable form is operable.
1026
- // dismissConsentBanner() existed but had NO call sites (dead code); it only
1027
- // clicks banner-specific CTAs (accept/reject all), so a false click is unlikely.
1028
- // Best-effort + one retry, since the widget lazy-loads a beat after the goto.
1029
- for (let attempt = 0; attempt < 2; attempt++) {
1030
- const cta = await browser.dismissConsentBanner().catch(() => null);
1031
- if (cta !== null) {
1032
- audit(id, "consent_dismissed", { cta });
1033
- break;
1165
+ inFlight = true;
1166
+ try {
1167
+ audit(id, "start", {
1168
+ service_url: opts.serviceUrl,
1169
+ allowed_hosts: hostStrings(session),
1170
+ has_hint: opts.hint !== undefined,
1171
+ });
1172
+ await browser.goto(opts.serviceUrl);
1173
+ // A cookie/consent overlay (Usercentrics/OneTrust/…) renders after load and its
1174
+ // backdrop occludes the ENTIRE form the agent then sees every element
1175
+ // occluded_by a div and gives up, or falls back to the only thing that looks
1176
+ // clickable (e.g. a "Connect wallet" CTA on the Robinhood faucet). Dismiss it
1177
+ // BEFORE the first observation so the real actionable form is operable.
1178
+ // dismissConsentBanner() existed but had NO call sites (dead code); it only
1179
+ // clicks banner-specific CTAs (accept/reject all), so a false click is unlikely.
1180
+ // Best-effort + one retry, since the widget lazy-loads a beat after the goto.
1181
+ for (let attempt = 0; attempt < 2; attempt++) {
1182
+ const cta = await browser.dismissConsentBanner().catch(() => null);
1183
+ if (cta !== null) {
1184
+ audit(id, "consent_dismissed", { cta });
1185
+ break;
1186
+ }
1187
+ if (attempt === 0)
1188
+ await browser.waitForCaptchaChallengeToSettle(800, 0).catch(() => false);
1034
1189
  }
1035
- if (attempt === 0)
1036
- await browser.waitForCaptchaChallengeToSettle(800, 0).catch(() => false);
1190
+ const observation = await observeSession(session);
1191
+ // Tell the agent which provider the user actually has a live session for
1192
+ // (Google-preferred) — the bot knows from the profile cookies, so the agent
1193
+ // doesn't have to guess. Composed with the skill route hint (if any).
1194
+ const hintParts = [
1195
+ loginSessionGuidance(liveProviders),
1196
+ ...(opts.hint !== undefined ? [opts.hint] : []),
1197
+ ];
1198
+ return {
1199
+ ...observation,
1200
+ hint: hintParts.join("\n"),
1201
+ ...(session.userEmail !== null ? { user_email: session.userEmail } : {}),
1202
+ };
1203
+ }
1204
+ catch (err) {
1205
+ sessions.delete(id);
1206
+ await releaseWarmBrowserPage(browser);
1207
+ inFlight = false;
1208
+ touchWarmBrowser();
1209
+ throw err;
1037
1210
  }
1038
- const observation = await observeSession(session);
1039
- // Tell the agent which provider the user actually has a live session for
1040
- // (Google-preferred) — the bot knows from the profile cookies, so the agent
1041
- // doesn't have to guess. Composed with the skill route hint (if any).
1042
- const hintParts = [
1043
- loginSessionGuidance(liveProviders),
1044
- ...(opts.hint !== undefined ? [opts.hint] : []),
1045
- ];
1046
- return {
1047
- ...observation,
1048
- hint: hintParts.join("\n"),
1049
- ...(session.userEmail !== null ? { user_email: session.userEmail } : {}),
1050
- };
1051
1211
  }
1052
1212
  export async function observe(sessionId, detail = "compact") {
1053
- const session = sessions.get(sessionId);
1213
+ const session = sessionForCall(sessionId);
1054
1214
  if (session === undefined)
1055
1215
  throw new Error(`unknown provision session ${sessionId}`);
1056
1216
  return await observeSession(session, detail);
@@ -1059,7 +1219,7 @@ export async function observe(sessionId, detail = "compact") {
1059
1219
  // session: start + auto_widen, NEVER mid_session task scope (a wide multi-app
1060
1220
  // operate scope must not silently over-grant a key's egress allow-list).
1061
1221
  export function observedHostsForSession(sessionId) {
1062
- const session = sessions.get(sessionId);
1222
+ const session = sessionForCall(sessionId);
1063
1223
  if (session === undefined)
1064
1224
  throw new Error(`unknown provision session ${sessionId}`);
1065
1225
  widenAllowedHostsFromCurrentUrl(session);
@@ -1080,7 +1240,7 @@ export function maskSecretValue(value) {
1080
1240
  // host — a later type_secret enters it into another site's form. Extends the
1081
1241
  // write-only-vault moat to in-session credential transfer.
1082
1242
  export function stashSecretSlot(sessionId, slot, value) {
1083
- const session = sessions.get(sessionId);
1243
+ const session = sessionForCall(sessionId);
1084
1244
  if (session === undefined)
1085
1245
  throw new Error(`unknown provision session ${sessionId}`);
1086
1246
  session.secretSlots.set(slot, value);
@@ -1094,7 +1254,7 @@ export function stashSecretSlot(sessionId, slot, value) {
1094
1254
  // signup password to the vault after the service account is created. Never
1095
1255
  // expose this value in a tool response or recipe trace.
1096
1256
  export function readSecretSlotValue(sessionId, slot) {
1097
- const session = sessions.get(sessionId);
1257
+ const session = sessionForCall(sessionId);
1098
1258
  if (session === undefined)
1099
1259
  throw new Error(`unknown provision session ${sessionId}`);
1100
1260
  const value = session.secretSlots.get(slot);
@@ -1103,7 +1263,7 @@ export function readSecretSlotValue(sessionId, slot) {
1103
1263
  return value;
1104
1264
  }
1105
1265
  export function currentProvisionUrl(sessionId) {
1106
- const session = sessions.get(sessionId);
1266
+ const session = sessionForCall(sessionId);
1107
1267
  if (session === undefined)
1108
1268
  throw new Error(`unknown provision session ${sessionId}`);
1109
1269
  return session.browser.currentUrl();
@@ -1111,19 +1271,60 @@ export function currentProvisionUrl(sessionId) {
1111
1271
  // operate_pay has a deliberately small input contract with no session id: it
1112
1272
  // acts on the one live operator checkout. Fail closed rather than guessing if
1113
1273
  // zero or multiple browser sessions exist.
1114
- export function activeProvisionBrowser() {
1274
+ function activeProvisionSession() {
1275
+ touchWarmBrowser();
1115
1276
  if (sessions.size !== 1) {
1116
1277
  throw new Error(sessions.size === 0
1117
1278
  ? "operate_pay requires one active operate_start browser session"
1118
1279
  : "operate_pay refused: multiple active browser sessions");
1119
1280
  }
1120
- return sessions.values().next().value.browser;
1281
+ const session = sessions.values().next().value;
1282
+ if (session.replayState?.moneyPath === true && session.replayState.paymentGuard !== "verified") {
1283
+ throw new Error("operate_pay refused: replay address/contact/quantity verification is not satisfied");
1284
+ }
1285
+ return session;
1286
+ }
1287
+ export function activeProvisionBrowser() {
1288
+ return activeProvisionSession().browser;
1289
+ }
1290
+ export async function activeProvisionBrowserForPayment() {
1291
+ const session = activeProvisionSession();
1292
+ const state = session.replayState;
1293
+ if (state?.moneyPath !== true)
1294
+ return session.browser;
1295
+ const fresh = await session.browser.extractInteractiveElements();
1296
+ session.lastElements = fresh;
1297
+ for (const expected of state.expectedFields.values()) {
1298
+ if (!state.verifiedFields.has(expected.stepIndex))
1299
+ continue;
1300
+ if (!(await isReplayFieldMounted(session, expected, fresh))) {
1301
+ markReplayFailure(session, "field_missing", expected.hole);
1302
+ throw new Error("operate_pay refused: replay address/contact/quantity verification is not satisfied");
1303
+ }
1304
+ const guard = await verifyReplayFieldWithElements(session, expected, fresh);
1305
+ if (!guard.ok) {
1306
+ markReplayFailure(session, guard.reason, expected.hole);
1307
+ throw new Error("operate_pay refused: replay address/contact/quantity verification is not satisfied");
1308
+ }
1309
+ }
1310
+ return session.browser;
1311
+ }
1312
+ export function recordActivePaymentProvenance(cardRef) {
1313
+ if (sessions.size !== 1)
1314
+ return;
1315
+ const session = sessions.values().next().value;
1316
+ const last = session.actionTrace.at(-1)?.action;
1317
+ if (last?.kind === "operate_pay")
1318
+ return;
1319
+ const traceIndex = session.actionTrace.length;
1320
+ session.actionTrace.push({ action: { kind: "operate_pay", value: { hole: "card" } } });
1321
+ session.recordedValues.push({ traceIndex, hole: "card", literal: cardRef });
1121
1322
  }
1122
1323
  // PR3c — the user's own email captured at login (the authoritative signup
1123
1324
  // address), or null when none was captured. The tool layer reads this to fill
1124
1325
  // username/password signups so the account is user-owned.
1125
1326
  export function getSessionUserEmail(sessionId) {
1126
- const session = sessions.get(sessionId);
1327
+ const session = sessionForCall(sessionId);
1127
1328
  if (session === undefined)
1128
1329
  throw new Error(`unknown provision session ${sessionId}`);
1129
1330
  return session.userEmail;
@@ -1649,10 +1850,17 @@ async function observeSession(session, detail = "compact") {
1649
1850
  };
1650
1851
  }
1651
1852
  export async function act(sessionId, action, detail = "compact") {
1652
- const session = sessions.get(sessionId);
1853
+ const session = sessionForCall(sessionId);
1653
1854
  if (session === undefined)
1654
1855
  throw new Error(`unknown provision session ${sessionId}`);
1856
+ if (action.kind === "type_secret" &&
1857
+ action.provenance !== undefined &&
1858
+ action.provenance.hole !== `credential.${action.slot}`) {
1859
+ throw new Error(`type_secret provenance must match the authoritative slot credential.${action.slot}`);
1860
+ }
1655
1861
  const { browser } = session;
1862
+ let completedAction = action;
1863
+ let sensitiveSource;
1656
1864
  const auditTarget = "target" in action && parseLocatorTarget(action.target) !== null
1657
1865
  ? "<mode>=<redacted>"
1658
1866
  : "target" in action
@@ -1687,6 +1895,7 @@ export async function act(sessionId, action, detail = "compact") {
1687
1895
  `The operator may not act on the Trusty Squire vault/app — navigate away with goto.`);
1688
1896
  }
1689
1897
  }
1898
+ const recordingTransitionFields = await attestRecordedFieldsBeforeTransition(session, action);
1690
1899
  // Captured for the operator-recipe trace: the element a target action
1691
1900
  // resolved to, so we record the VISIBLE text it acted on (not the ref).
1692
1901
  let resolvedEl = null;
@@ -1730,6 +1939,11 @@ export async function act(sessionId, action, detail = "compact") {
1730
1939
  `operate_extract { into_slot: "${action.slot}" }. Known slots: ` +
1731
1940
  `[${[...session.secretSlots.keys()].join(", ")}]`);
1732
1941
  }
1942
+ sensitiveSource = {
1943
+ traceIndex: -1,
1944
+ hole: `credential.${action.slot}`,
1945
+ literal: value,
1946
+ };
1733
1947
  const fresh = await browser.extractInteractiveElements();
1734
1948
  session.lastElements = fresh;
1735
1949
  // resolveTarget recomputes identities (incl. volatile positional-group
@@ -1769,7 +1983,9 @@ export async function act(sessionId, action, detail = "compact") {
1769
1983
  .join(", "));
1770
1984
  }
1771
1985
  resolvedEl = el;
1772
- await browser.selectOption(el.selector, action.text);
1986
+ const committedText = await browser.selectOption(el.selector, action.text);
1987
+ session.committedSelectValues.set(el.selector, committedText);
1988
+ completedAction = { ...action, text: committedText };
1773
1989
  await settleAfterStateChange(browser);
1774
1990
  break;
1775
1991
  }
@@ -1862,8 +2078,10 @@ export async function act(sessionId, action, detail = "compact") {
1862
2078
  await browser.click(el.selector);
1863
2079
  else if (action.kind === "js_click")
1864
2080
  await browser.clickViaJs(el.selector);
1865
- else if (action.kind === "type")
2081
+ else if (action.kind === "type") {
2082
+ session.committedSelectValues.delete(el.selector);
1866
2083
  await browser.type(el.selector, action.text);
2084
+ }
1867
2085
  else if (action.kind === "upload") {
1868
2086
  await browser.uploadFile(el.selector, action.path);
1869
2087
  audit(sessionId, "upload", {
@@ -1879,12 +2097,15 @@ export async function act(sessionId, action, detail = "compact") {
1879
2097
  break;
1880
2098
  }
1881
2099
  }
2100
+ await verifyRecordedFieldsAfterTransition(session, action, recordingTransitionFields);
2101
+ await captureReplayRepairVerification(session, completedAction, resolvedEl);
2102
+ await refreshReplayVerificationAfterAction(session, completedAction, resolvedEl);
1882
2103
  // Don't fold inbox-provider steps into the replayable recipe (see
1883
2104
  // INBOX_READ_HOSTS): replay re-reads the code via awaitVerification, and a
1884
2105
  // recorded inbox click would bake the email's subject into a shared recipe.
1885
2106
  if (!isInboxReadHost(browser.currentUrl())) {
1886
- recordTrace(session, action, resolvedEl);
1887
- recordCaptureRound(session, action, resolvedEl, urlBeforeAction);
2107
+ recordTrace(session, completedAction, resolvedEl, sensitiveSource);
2108
+ recordCaptureRound(session, completedAction, resolvedEl, urlBeforeAction);
1888
2109
  }
1889
2110
  // `detail:"none"` returns a minimal ack (the action ran; no perception emitted)
1890
2111
  // so multi-field fills don't each echo the page. The host must call
@@ -1909,6 +2130,71 @@ export async function act(sessionId, action, detail = "compact") {
1909
2130
  // name keeps its legacy form for corpus compatibility (validateReplayGraph and
1910
2131
  // published skills key off it); it now means "the email to fill", not a Squire alias.
1911
2132
  const EMAIL_SLOT_TEMPLATE = "${EMAIL_ALIAS}";
2133
+ function regexEscape(value) {
2134
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2135
+ }
2136
+ function nestedPercentByte(byte) {
2137
+ return `%(?:25)*${byte.toString(16).padStart(2, "0")}`;
2138
+ }
2139
+ function encodedCharacterPattern(char) {
2140
+ const raw = regexEscape(char);
2141
+ const percent = [...Buffer.from(char)].map(nestedPercentByte).join("");
2142
+ const encoded = `(?:${raw}|${percent})`;
2143
+ const slash = `(?:\\\\|${nestedPercentByte(0x5c)})`;
2144
+ const cssSimple = /[a-zA-Z0-9_-]/.test(char) ? null : `${slash}${encoded}`;
2145
+ const cssHex = `${slash}${[...char.codePointAt(0).toString(16)]
2146
+ .map((digit) => `(?:${digit}|${nestedPercentByte(digit.charCodeAt(0))})`)
2147
+ .join("")}(?:(?:\\s|${nestedPercentByte(0x20)}))?`;
2148
+ return `(?:${[encoded, cssSimple, cssHex].filter(Boolean).join("|")})`;
2149
+ }
2150
+ function decodeUriLayer(value) {
2151
+ try {
2152
+ return decodeURIComponent(value);
2153
+ }
2154
+ catch {
2155
+ return value;
2156
+ }
2157
+ }
2158
+ function decodeCssLayer(value) {
2159
+ return value.replace(/\\([0-9a-f]{1,6})(?:\r\n|[\t\n\f\r ])?|\\([^\r\n\f])/gi, (_match, hex, escaped) => hex === undefined ? (escaped ?? "") : String.fromCodePoint(Number.parseInt(hex, 16)));
2160
+ }
2161
+ function applyEmailEncoding(value, operations) {
2162
+ return operations.reduce((encoded, operation) => operation === "URI" ? encodeURIComponent(encoded) : cssEscapeRecipeValue(encoded), value);
2163
+ }
2164
+ function emailTemplateForRepresentation(representation, email) {
2165
+ const queue = [
2166
+ { value: representation, inverse: [] },
2167
+ ];
2168
+ const seen = new Set();
2169
+ let fallback = null;
2170
+ while (queue.length > 0 && seen.size <= 4096) {
2171
+ const current = queue.shift();
2172
+ const key = `${current.value}\0${current.inverse.join("_")}`;
2173
+ if (seen.has(key))
2174
+ continue;
2175
+ seen.add(key);
2176
+ if (current.value.toLowerCase() === email.toLowerCase()) {
2177
+ const operations = [...current.inverse].reverse();
2178
+ if (applyEmailEncoding(email, operations).toLowerCase() === representation.toLowerCase()) {
2179
+ return `\${EMAIL_ALIAS${operations.map((operation) => `_${operation}`).join("")}}`;
2180
+ }
2181
+ fallback ??= operations;
2182
+ continue;
2183
+ }
2184
+ const uriDecoded = decodeUriLayer(current.value);
2185
+ if (uriDecoded !== current.value) {
2186
+ queue.push({ value: uriDecoded, inverse: [...current.inverse, "URI"] });
2187
+ }
2188
+ const cssDecoded = decodeCssLayer(current.value);
2189
+ if (cssDecoded !== current.value) {
2190
+ queue.push({ value: cssDecoded, inverse: [...current.inverse, "CSS"] });
2191
+ }
2192
+ }
2193
+ return fallback === null
2194
+ ? null
2195
+ : `\${EMAIL_ALIAS${fallback.map((operation) => `_${operation}`).join("")}}`;
2196
+ }
2197
+ const REPLAY_VERIFIED_HOLE = /^(?:address|contact)(?:\.|$)|^quantity$/;
1912
2198
  function looksLikeEmailValue(v) {
1913
2199
  return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.trim());
1914
2200
  }
@@ -1925,12 +2211,25 @@ export function redactEmailForTrace(value) {
1925
2211
  // NOT a vector here — that path belonged to the retired autonomous bot and is
1926
2212
  // not wired into operate_*.) Exported for unit tests.
1927
2213
  export function scrubKnownEmail(s, userEmail) {
1928
- if (userEmail === null || userEmail.length === 0 || !s.includes(userEmail))
2214
+ if (userEmail === null || userEmail.length === 0)
1929
2215
  return s;
1930
- return s.split(userEmail).join(EMAIL_SLOT_TEMPLATE);
2216
+ const pattern = [...userEmail].map(encodedCharacterPattern).join("");
2217
+ return s.replace(new RegExp(pattern, "gi"), (matched) => {
2218
+ const template = emailTemplateForRepresentation(matched, userEmail);
2219
+ if (template === null) {
2220
+ throw new Error("known email encoding could not be provenance-templated");
2221
+ }
2222
+ return template;
2223
+ });
1931
2224
  }
1932
- // Append a TEXT-targeted entry to the session's operator-recipe trace. Stores
1933
- // the visible text the action hit (never a ref/coordinate) + non-secret params.
2225
+ function assertRecipeEmailScrubbed(recipe, userEmail) {
2226
+ const serialized = JSON.stringify(recipe);
2227
+ if (scrubKnownEmail(serialized, userEmail) !== serialized) {
2228
+ throw new Error("operate_remember refused: known email remains in serialized recipe data");
2229
+ }
2230
+ }
2231
+ // Append a stable-attribute-targeted entry to the session's operator-recipe
2232
+ // trace. Stores no ref/coordinate; visible text remains a unique-only fallback.
1934
2233
  // `extract` (the seal) is recorded separately in stashSecretSlot.
1935
2234
  function traceTextFor(el) {
1936
2235
  if (el === null)
@@ -1939,7 +2238,71 @@ function traceTextFor(el) {
1939
2238
  const first = keys[0];
1940
2239
  return typeof first === "string" && first.length > 0 ? first.slice(0, 120) : undefined;
1941
2240
  }
1942
- function recordTrace(session, action, el) {
2241
+ // Stable attributes already present in the inventory and consumed by the
2242
+ // skill synthesizer. Keep all of them; replay chooses in a fixed order.
2243
+ export function recipeTargetFor(el, inventory = [], userEmail = null) {
2244
+ if (el === null)
2245
+ return undefined;
2246
+ const role = el.role ??
2247
+ (el.tag === "button"
2248
+ ? "button"
2249
+ : el.tag === "a"
2250
+ ? "link"
2251
+ : el.tag === "input" || el.tag === "textarea"
2252
+ ? "textbox"
2253
+ : undefined);
2254
+ const accessibleName = el.ariaLabel ??
2255
+ el.labelText ??
2256
+ el.visibleText ??
2257
+ el.iconLabel ??
2258
+ el.placeholder ??
2259
+ el.title ??
2260
+ el.name ??
2261
+ undefined;
2262
+ const siblings = inventory.filter((candidate) => {
2263
+ if (candidate === el || candidate.selector === el.selector)
2264
+ return false;
2265
+ const candidateName = candidate.ariaLabel ??
2266
+ candidate.labelText ??
2267
+ candidate.visibleText ??
2268
+ candidate.iconLabel ??
2269
+ candidate.placeholder ??
2270
+ candidate.title ??
2271
+ candidate.name ??
2272
+ undefined;
2273
+ return ((el.testId !== null && el.testId !== undefined && candidate.testId === el.testId) ||
2274
+ (el.id !== null && candidate.id === el.id) ||
2275
+ (el.name !== null && candidate.name === el.name) ||
2276
+ (accessibleName !== undefined && candidateName === accessibleName) ||
2277
+ (el.href !== null && el.href !== undefined && candidate.href === el.href));
2278
+ });
2279
+ const nearText = siblings.length > 0 ? pickRowDisambiguator(el, siblings, inventory) : null;
2280
+ const domHint = pickStableDomHint(el);
2281
+ const hrefHint = pickHrefHint(el);
2282
+ const scrub = (value) => scrubKnownEmail(value, userEmail);
2283
+ return {
2284
+ ...(domHint !== undefined
2285
+ ? {
2286
+ dom_hint: {
2287
+ ...(domHint.testid !== undefined ? { testid: scrub(domHint.testid) } : {}),
2288
+ ...(domHint.id !== undefined ? { id: scrub(domHint.id) } : {}),
2289
+ ...(domHint.name !== undefined ? { name: scrub(domHint.name) } : {}),
2290
+ },
2291
+ }
2292
+ : {}),
2293
+ ...(role !== undefined && role.length > 0 ? { role_hint: scrub(role) } : {}),
2294
+ ...(accessibleName !== undefined && accessibleName.length > 0
2295
+ ? { accessible_name: scrub(accessibleName) }
2296
+ : {}),
2297
+ ...(nearText !== null ? { near_text_hint: scrub(nearText) } : {}),
2298
+ ...(hrefHint !== null && !isSingleUseUrl(el.href ?? "") ? { href_hint: scrub(hrefHint) } : {}),
2299
+ ...(el.selector.length > 0 ? { css: scrub(el.selector) } : {}),
2300
+ ...(el.visibleText !== null && el.visibleText.length > 0
2301
+ ? { visible_text: scrub(el.visibleText) }
2302
+ : {}),
2303
+ };
2304
+ }
2305
+ function recordTrace(session, action, el, sensitiveSource) {
1943
2306
  // Never freeze a single-use link (email-verify / magic / reset token) into
1944
2307
  // the recipe — it's dead on the next replay. The host agent re-plans the
1945
2308
  // verification step live (operate_await_verification fetches a FRESH link)
@@ -1960,24 +2323,24 @@ function recordTrace(session, action, el) {
1960
2323
  // shared recipe. Skip it here (the action is still in the audit trail).
1961
2324
  if (action.kind === "upload")
1962
2325
  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;
2326
+ const knownEmailHole = action.kind === "type" &&
2327
+ action.provenance === undefined &&
2328
+ session.userEmail !== null &&
2329
+ action.text === session.userEmail
2330
+ ? "contact.email"
2331
+ : undefined;
2332
+ const actionHole = action.kind === "type" || action.kind === "select" || action.kind === "set_phone_country"
2333
+ ? (action.provenance?.hole ?? knownEmailHole)
2334
+ : undefined;
1974
2335
  const rawText = traceTextFor(el);
1975
2336
  const text = rawText !== undefined ? scrubKnownEmail(rawText, session.userEmail) : undefined;
1976
2337
  const withText = text !== undefined ? { text_match: text } : {};
2338
+ const target = recipeTargetFor(el, session.lastElements, session.userEmail);
2339
+ const withTarget = target !== undefined ? { target } : {};
1977
2340
  let a;
1978
2341
  switch (action.kind) {
1979
2342
  case "goto":
1980
- a = { kind: "goto", url_template: action.url };
2343
+ a = { kind: "goto", url_template: scrubKnownEmail(action.url, session.userEmail) };
1981
2344
  break;
1982
2345
  case "allow_host":
1983
2346
  a = { kind: "allow_host", host: action.host };
@@ -1998,23 +2361,58 @@ function recordTrace(session, action, el) {
1998
2361
  a = {
1999
2362
  kind: "type",
2000
2363
  ...withText,
2364
+ ...withTarget,
2001
2365
  value: scrubKnownEmail(redactEmailForTrace(action.text), session.userEmail),
2002
2366
  };
2003
2367
  break;
2004
2368
  case "type_secret":
2005
- a = { kind: "type_secret", slot: action.slot, ...withText };
2369
+ a = {
2370
+ kind: "type_secret",
2371
+ slot: action.slot,
2372
+ value: { hole: `credential.${action.slot}` },
2373
+ ...withText,
2374
+ ...withTarget,
2375
+ };
2376
+ break;
2377
+ case "select":
2378
+ a = {
2379
+ kind: "select",
2380
+ value: action.text,
2381
+ ...withText,
2382
+ ...withTarget,
2383
+ };
2384
+ break;
2385
+ case "set_phone_country":
2386
+ a = {
2387
+ kind: "set_phone_country",
2388
+ value: action.country,
2389
+ };
2006
2390
  break;
2007
2391
  case "click":
2008
- a = { kind: "click", ...withText };
2392
+ a = { kind: "click", ...withText, ...withTarget };
2009
2393
  break;
2010
2394
  case "js_click":
2011
- a = { kind: "js_click", ...withText };
2395
+ a = { kind: "js_click", ...withText, ...withTarget };
2012
2396
  break;
2013
2397
  case "oauth_click":
2014
- a = { kind: "oauth_click", ...withText };
2398
+ a = { kind: "oauth_click", ...withText, ...withTarget };
2015
2399
  break;
2016
2400
  }
2401
+ const traceIndex = session.actionTrace.length;
2017
2402
  session.actionTrace.push({ action: a });
2403
+ if (action.kind === "type" || action.kind === "select" || action.kind === "set_phone_country") {
2404
+ session.recordedValues.push({
2405
+ traceIndex,
2406
+ ...(actionHole !== undefined ? { hole: actionHole } : {}),
2407
+ literal: action.kind === "set_phone_country" ? action.country : action.text,
2408
+ });
2409
+ }
2410
+ else if (action.kind === "type_secret") {
2411
+ if (sensitiveSource === undefined) {
2412
+ throw new Error(`type_secret ${action.slot} lacks an action-time source attestation`);
2413
+ }
2414
+ session.recordedValues.push({ ...sensitiveSource, traceIndex });
2415
+ }
2018
2416
  }
2019
2417
  // ── Medium capture → skill (docs/DESIGN-operator-hints.md) ──────────────────
2020
2418
  // The service SLUG for the capture. It MUST be produced the same way
@@ -2104,7 +2502,7 @@ async function recordExtractRound(session) {
2104
2502
  // Best-effort: any failure returns a skip and never disrupts the parent
2105
2503
  // provision. The caller publishes the returned skill.
2106
2504
  export async function captureAndPromoteSession(sessionId) {
2107
- const session = sessions.get(sessionId);
2505
+ const session = sessionForCall(sessionId);
2108
2506
  if (session === undefined)
2109
2507
  return { kind: "skipped", reason: "unknown_session" };
2110
2508
  // A run that used the text=/css= locator click fallback hit a control with no
@@ -2154,7 +2552,7 @@ export function buildProvisionMeasurement(args) {
2154
2552
  // stderr JSON so it aggregates like the other provision-audit lines. This is the
2155
2553
  // raw signal deliverable #1 buckets into success-rate + time by hint_present.
2156
2554
  export function emitProvisionMeasurement(sessionId, outcome) {
2157
- const session = sessions.get(sessionId);
2555
+ const session = sessionForCall(sessionId);
2158
2556
  if (session === undefined)
2159
2557
  return null;
2160
2558
  const m = buildProvisionMeasurement({
@@ -2179,30 +2577,157 @@ async function settleAfterStateChange(browser) {
2179
2577
  }
2180
2578
  }
2181
2579
  // ── operator-recipe: remember a successful run, verify a postcondition ──
2182
- // Persist the session's action trace as a named, replayable operator-recipe.
2580
+ // Persist the session's action trace as a keyed, replayable operator-recipe.
2183
2581
  // Sealed secrets become SLOT references (stored:false) — never values. The
2184
2582
  // recipe's scope = start + auto_widen hosts (mid_session crossings replay via
2185
2583
  // the trace's own allow_host steps).
2584
+ function verifiedEmailSources(session, inputs) {
2585
+ return session.recordedValues.filter((source) => source.hole !== undefined &&
2586
+ session.userEmail !== null &&
2587
+ source.literal === session.userEmail &&
2588
+ looksLikeEmailValue(source.literal) &&
2589
+ knownRecipeInputValue(inputs, source.hole) === source.literal);
2590
+ }
2591
+ function traceWithVerifiedProvenance(session, inputs) {
2592
+ const trace = session.actionTrace.map((entry) => ({
2593
+ ...entry,
2594
+ action: { ...entry.action },
2595
+ }));
2596
+ const recordedIndexes = new Set();
2597
+ for (const source of session.recordedValues) {
2598
+ if (recordedIndexes.has(source.traceIndex)) {
2599
+ throw new Error(`duplicate value source for trace step ${source.traceIndex}`);
2600
+ }
2601
+ recordedIndexes.add(source.traceIndex);
2602
+ if (source.hole === undefined) {
2603
+ throw new Error(`value at trace step ${source.traceIndex} lacks explicit provenance`);
2604
+ }
2605
+ const authoritative = knownRecipeInputValue(inputs, source.hole);
2606
+ if (authoritative === undefined) {
2607
+ throw new Error(`provenance ${source.hole} has no authoritative operate_remember input`);
2608
+ }
2609
+ if (authoritative !== source.literal) {
2610
+ throw new Error(`provenance ${source.hole} does not match the injected value`);
2611
+ }
2612
+ const entry = trace[source.traceIndex];
2613
+ if (entry === undefined) {
2614
+ throw new Error(`provenance ${source.hole} is not bound to a recorded value action`);
2615
+ }
2616
+ if ((entry.action.kind === "type" ||
2617
+ entry.action.kind === "select" ||
2618
+ entry.action.kind === "set_phone_country") &&
2619
+ typeof entry.action.value === "string") {
2620
+ entry.action.value = { hole: source.hole };
2621
+ continue;
2622
+ }
2623
+ if ((entry.action.kind === "type_secret" || entry.action.kind === "operate_pay") &&
2624
+ typeof entry.action.value !== "string" &&
2625
+ entry.action.value?.hole === source.hole) {
2626
+ continue;
2627
+ }
2628
+ throw new Error(`provenance ${source.hole} is not bound to a recorded value action`);
2629
+ }
2630
+ const emailSources = verifiedEmailSources(session, inputs);
2631
+ const emailHoles = new Set(emailSources.map((source) => source.hole));
2632
+ for (const [traceIndex, entry] of trace.entries()) {
2633
+ const targetText = JSON.stringify({
2634
+ text_match: entry.action.text_match,
2635
+ target: entry.action.target,
2636
+ url_template: entry.action.url_template,
2637
+ });
2638
+ if (!targetText.includes("${EMAIL_ALIAS"))
2639
+ continue;
2640
+ const directEmailHole = emailSources.find((source) => source.traceIndex === traceIndex)?.hole;
2641
+ if (directEmailHole === undefined && emailHoles.size !== 1) {
2642
+ throw new Error("known-email target lacks one unambiguous action-time source hole");
2643
+ }
2644
+ const emailHole = directEmailHole ?? [...emailHoles][0];
2645
+ entry.action.email_hole = emailHole;
2646
+ }
2647
+ for (const [traceIndex, entry] of trace.entries()) {
2648
+ const value = entry.action.value;
2649
+ if (value === undefined || typeof value === "string" || recordedIndexes.has(traceIndex)) {
2650
+ continue;
2651
+ }
2652
+ throw new Error(`provenance ${value.hole} lacks an action-time source attestation`);
2653
+ }
2654
+ return trace;
2655
+ }
2656
+ function scrubRecipePostcondition(session, postcondition, inputs) {
2657
+ const probeUrl = postcondition.probe_url === undefined
2658
+ ? undefined
2659
+ : scrubKnownEmail(postcondition.probe_url, session.userEmail);
2660
+ const signal = postcondition.success_signal;
2661
+ const successSignal = "url_contains" in signal
2662
+ ? { url_contains: scrubKnownEmail(signal.url_contains, session.userEmail) }
2663
+ : signal;
2664
+ const hasTemplate = probeUrl?.includes("${EMAIL_ALIAS") === true ||
2665
+ ("url_contains" in successSignal && successSignal.url_contains.includes("${EMAIL_ALIAS"));
2666
+ const base = { kind: postcondition.kind, describe: postcondition.describe };
2667
+ if (!hasTemplate) {
2668
+ return {
2669
+ ...base,
2670
+ ...(probeUrl !== undefined ? { probe_url: probeUrl } : {}),
2671
+ success_signal: successSignal,
2672
+ };
2673
+ }
2674
+ const holes = [...new Set(verifiedEmailSources(session, inputs).map((source) => source.hole))];
2675
+ if (holes.length !== 1) {
2676
+ throw new Error("known-email postcondition lacks one unambiguous action-time source hole");
2677
+ }
2678
+ return {
2679
+ ...base,
2680
+ ...(probeUrl !== undefined ? { probe_url: probeUrl } : {}),
2681
+ success_signal: successSignal,
2682
+ email_hole: holes[0],
2683
+ };
2684
+ }
2186
2685
  export async function rememberRecipe(sessionId, opts) {
2187
- const session = sessions.get(sessionId);
2686
+ const session = sessionForCall(sessionId);
2188
2687
  if (session === undefined)
2189
2688
  throw new Error(`unknown provision session ${sessionId}`);
2190
2689
  if (session.usedLocatorFallback) {
2191
2690
  throw new Error("operate_remember refused: this session used a text=/css= locator fallback that operator recipes cannot represent");
2192
2691
  }
2692
+ if (session.recipeRejectionReason !== null) {
2693
+ throw new Error(`operate_remember refused: ${session.recipeRejectionReason}`);
2694
+ }
2695
+ if (opts.inputs === undefined) {
2696
+ throw new Error("operate_remember refused: complete provenance inputs are required");
2697
+ }
2698
+ // Record only through the existing machine-checkable success gate. Previously
2699
+ // operate_remember wrote first and operate_finish_task verified later, leaving
2700
+ // an unverified recipe on disk when the postcondition failed.
2701
+ const verified = await verifyPostcondition(sessionId, opts.postcondition);
2702
+ if (!verified.confirmed) {
2703
+ throw new Error(`operate_remember refused: postcondition not confirmed (${verified.reason})`);
2704
+ }
2193
2705
  const secrets = [...session.secretSlots.keys()].map((slot) => ({ slot, stored: false }));
2706
+ const scrubbedStartUrl = scrubKnownEmail(session.startUrl, session.userEmail);
2707
+ const trace = traceWithVerifiedProvenance(session, opts.inputs);
2708
+ const postcondition = scrubRecipePostcondition(session, opts.postcondition, opts.inputs);
2194
2709
  const recipe = {
2195
2710
  name: opts.name,
2196
2711
  schema_version: 1,
2197
2712
  goal: opts.goal,
2713
+ ...(opts.verb !== undefined
2714
+ ? { verb: opts.verb, domain: operatorRecipeDomain(session.startUrl) }
2715
+ : {}),
2198
2716
  // Canonical, stable replay entry — the page the session started at, never a
2199
2717
  // mid-flow single-use link inferred from the trace.
2200
- entry_url: session.startUrl,
2718
+ ...(isSingleUseUrl(session.startUrl) || scrubbedStartUrl !== session.startUrl
2719
+ ? { entry_mode: "runtime_service_url" }
2720
+ : { entry_url: session.startUrl }),
2201
2721
  allowed_hosts: [...new Set(egressSeedHosts(session))],
2202
- trace: session.actionTrace,
2722
+ trace,
2203
2723
  secrets,
2204
- postcondition: opts.postcondition,
2724
+ postcondition,
2205
2725
  };
2726
+ assertRecipeEmailScrubbed(recipe, session.userEmail);
2727
+ const unprovenancedMoneyField = findUnprovenancedMoneyField(recipe);
2728
+ if (unprovenancedMoneyField !== null) {
2729
+ throw new Error(`operate_remember refused: money field lacks provenance (${unprovenancedMoneyField})`);
2730
+ }
2206
2731
  const file = await writeRecipe(recipe);
2207
2732
  audit(sessionId, "remember_recipe", {
2208
2733
  name: opts.name,
@@ -2210,7 +2735,13 @@ export async function rememberRecipe(sessionId, opts) {
2210
2735
  secrets: secrets.length,
2211
2736
  file,
2212
2737
  });
2213
- return { file, name: opts.name, steps: recipe.trace.length, secrets: secrets.map((s) => s.slot) };
2738
+ return {
2739
+ file,
2740
+ name: opts.name,
2741
+ steps: recipe.trace.length,
2742
+ secrets: secrets.map((s) => s.slot),
2743
+ verified,
2744
+ };
2214
2745
  }
2215
2746
  // Read a single page snapshot for postcondition checking. Field VALUES are
2216
2747
  // reduced to lengths here so a token/secret success-signal can't leak.
@@ -2230,7 +2761,7 @@ async function snapshotForPostcondition(session) {
2230
2761
  // green gate for replay. execute_capability checks the current end-state;
2231
2762
  // observe_artifact navigates to the probe surface first (Phase B paces this).
2232
2763
  export async function verifyPostcondition(sessionId, postcondition) {
2233
- const session = sessions.get(sessionId);
2764
+ const session = sessionForCall(sessionId);
2234
2765
  if (session === undefined)
2235
2766
  throw new Error(`unknown provision session ${sessionId}`);
2236
2767
  if (postcondition.kind === "observe_artifact" && postcondition.probe_url !== undefined) {
@@ -2250,6 +2781,660 @@ export async function verifyPostcondition(sessionId, postcondition) {
2250
2781
  });
2251
2782
  return result;
2252
2783
  }
2784
+ export async function verifySavedRecipePostcondition(sessionId, recipe) {
2785
+ const session = sessions.get(sessionId);
2786
+ if (session === undefined)
2787
+ throw new Error(`unknown provision session ${sessionId}`);
2788
+ const state = session.replayState;
2789
+ if (state !== null && state.recipeHash === replayDigest(recipe)) {
2790
+ return await verifyPostcondition(sessionId, state.boundPostcondition);
2791
+ }
2792
+ let postcondition;
2793
+ try {
2794
+ postcondition = bindRecipePostcondition(recipe.postcondition, {});
2795
+ }
2796
+ catch {
2797
+ throw new Error("saved recipe postcondition requires bindings from its active replay");
2798
+ }
2799
+ return await verifyPostcondition(sessionId, postcondition);
2800
+ }
2801
+ export async function verifyActiveRecipePostcondition(sessionId, recipeName) {
2802
+ const session = sessions.get(sessionId);
2803
+ if (session === undefined)
2804
+ throw new Error(`unknown provision session ${sessionId}`);
2805
+ const state = session.replayState;
2806
+ if (state === null)
2807
+ return null;
2808
+ if (state.recipeName !== recipeName) {
2809
+ throw new Error(`verify_recipe ${recipeName} does not match active replay ${state.recipeName}`);
2810
+ }
2811
+ return await verifyPostcondition(sessionId, state.boundPostcondition);
2812
+ }
2813
+ const MONEY_REPLAY_VERBS = new Set([
2814
+ "purchase",
2815
+ "subscribe",
2816
+ "checkout",
2817
+ "renew",
2818
+ "upgrade",
2819
+ "book",
2820
+ "reserve",
2821
+ ]);
2822
+ function replayTarget(action) {
2823
+ return action.target !== undefined
2824
+ ? action.target
2825
+ : action.text_match !== undefined
2826
+ ? { visible_text: action.text_match }
2827
+ : null;
2828
+ }
2829
+ function boundReplayTarget(action, bindings) {
2830
+ const target = action.target !== undefined
2831
+ ? action.target
2832
+ : action.text_match !== undefined
2833
+ ? { visible_text: action.text_match }
2834
+ : null;
2835
+ return target === null ? null : bindRecipeTarget(target, bindings, action.email_hole);
2836
+ }
2837
+ 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;
2838
+ function moneyFieldName(action) {
2839
+ if (action.kind === "set_phone_country")
2840
+ return "phone_country";
2841
+ if (action.kind !== "type" && action.kind !== "select")
2842
+ return null;
2843
+ const target = replayTarget(action);
2844
+ if (target === null)
2845
+ return null;
2846
+ const label = [
2847
+ target.dom_hint?.testid,
2848
+ target.dom_hint?.id,
2849
+ target.dom_hint?.name,
2850
+ target.accessible_name,
2851
+ target.visible_text,
2852
+ ]
2853
+ .filter((value) => value !== undefined)
2854
+ .join(" ")
2855
+ .replace(/([a-z])([A-Z])/g, "$1 $2");
2856
+ return MONEY_FIELD_TARGET.test(label) ? label || "field" : null;
2857
+ }
2858
+ function findUnprovenancedMoneyField(recipe) {
2859
+ if (recipe.verb === undefined || !MONEY_REPLAY_VERBS.has(recipe.verb))
2860
+ return null;
2861
+ for (const { action } of recipe.trace) {
2862
+ const field = moneyFieldName(action);
2863
+ if (field !== null && typeof action.value === "string")
2864
+ return field;
2865
+ }
2866
+ return null;
2867
+ }
2868
+ function replayDigest(value) {
2869
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
2870
+ }
2871
+ function bindingDigest(bindings) {
2872
+ return replayDigest(Object.entries(bindings)
2873
+ .sort(([left], [right]) => left.localeCompare(right))
2874
+ .map(([key, value]) => [key, value]));
2875
+ }
2876
+ function expectedReplayFields(recipe, bindings) {
2877
+ const fields = new Map();
2878
+ for (let stepIndex = 0; stepIndex < recipe.trace.length; stepIndex += 1) {
2879
+ const action = recipe.trace[stepIndex].action;
2880
+ const unprovenanced = moneyFieldName(action);
2881
+ if (unprovenanced !== null && typeof action.value === "string") {
2882
+ return { fields, missing: unprovenanced };
2883
+ }
2884
+ if ((action.kind !== "type" && action.kind !== "select" && action.kind !== "set_phone_country") ||
2885
+ action.value === undefined ||
2886
+ typeof action.value === "string" ||
2887
+ !REPLAY_VERIFIED_HOLE.test(action.value.hole)) {
2888
+ continue;
2889
+ }
2890
+ const expected = bindings[action.value.hole];
2891
+ if (expected === undefined)
2892
+ return { fields, missing: action.value.hole };
2893
+ let target;
2894
+ try {
2895
+ target = boundReplayTarget(action, bindings);
2896
+ }
2897
+ catch {
2898
+ return { fields, missing: action.email_hole ?? "email_target" };
2899
+ }
2900
+ fields.set(stepIndex, {
2901
+ stepIndex,
2902
+ hole: action.value.hole,
2903
+ expected,
2904
+ target,
2905
+ kind: action.kind,
2906
+ });
2907
+ }
2908
+ return { fields, missing: null };
2909
+ }
2910
+ function markReplayFailure(session, reason, field) {
2911
+ rejectRecipeRecording(session, `replay transition failed (${field}: ${reason})`);
2912
+ if (session.replayState === null)
2913
+ return;
2914
+ session.replayState.paymentGuard = "failed";
2915
+ session.replayState.failure = { reason, field };
2916
+ audit(session.id, "replay_field_value_guard", { ok: false, reason, field });
2917
+ }
2918
+ function verifyReplayFieldInElements(session, expected, elements, allowCommittedSelect = false) {
2919
+ if (expected.kind === "set_phone_country" || expected.target === null) {
2920
+ return { ok: false, reason: "field_missing" };
2921
+ }
2922
+ const resolution = resolveRecipeFieldTarget(elements, expected.target);
2923
+ if (resolution === null)
2924
+ return { ok: false, reason: "field_missing" };
2925
+ const guard = verifyFilledFieldValues(elements, [
2926
+ {
2927
+ target: expected.target,
2928
+ expected: expected.expected,
2929
+ hole: expected.hole,
2930
+ kind: expected.kind === "select" ? "select" : "type",
2931
+ },
2932
+ ]);
2933
+ if (guard.ok) {
2934
+ if (expected.kind === "select") {
2935
+ session.committedSelectValues.delete(resolution.element.selector);
2936
+ }
2937
+ return { ok: true };
2938
+ }
2939
+ if (allowCommittedSelect &&
2940
+ expected.kind === "select" &&
2941
+ session.committedSelectValues.get(resolution.element.selector) === expected.expected) {
2942
+ session.committedSelectValues.delete(resolution.element.selector);
2943
+ return { ok: true };
2944
+ }
2945
+ if (expected.kind === "select") {
2946
+ session.committedSelectValues.delete(resolution.element.selector);
2947
+ }
2948
+ return { ok: false, reason: guard.reason };
2949
+ }
2950
+ async function verifyReplayField(session, expected, allowCommittedSelect = false) {
2951
+ if (expected.kind === "set_phone_country") {
2952
+ return (await session.browser.verifyPhoneCountry(expected.expected))
2953
+ ? { ok: true }
2954
+ : { ok: false, reason: "field_value_mismatch" };
2955
+ }
2956
+ const target = expected.target;
2957
+ if (target === null)
2958
+ return { ok: false, reason: "field_missing" };
2959
+ const fresh = await session.browser.extractInteractiveElements();
2960
+ session.lastElements = fresh;
2961
+ return verifyReplayFieldInElements(session, expected, fresh, allowCommittedSelect);
2962
+ }
2963
+ async function verifyReplayFieldWithElements(session, expected, elements, allowCommittedSelect = false) {
2964
+ if (expected.kind === "set_phone_country") {
2965
+ return (await session.browser.verifyPhoneCountry(expected.expected))
2966
+ ? { ok: true }
2967
+ : { ok: false, reason: "field_value_mismatch" };
2968
+ }
2969
+ return verifyReplayFieldInElements(session, expected, elements, allowCommittedSelect);
2970
+ }
2971
+ async function isReplayFieldMounted(session, expected, elements) {
2972
+ if (expected.kind === "set_phone_country") {
2973
+ return await session.browser.hasPhoneCountryControl();
2974
+ }
2975
+ return expected.target !== null && hasRecipeTargetCandidate(elements, expected.target);
2976
+ }
2977
+ async function captureReplayRepairVerification(session, action, resolvedEl) {
2978
+ const state = session.replayState;
2979
+ if (state === null || state.nextIndex === null || !state.moneyPath)
2980
+ return;
2981
+ const stepIndex = state.nextIndex - 1;
2982
+ const expected = state.expectedFields.get(stepIndex);
2983
+ if (expected === undefined)
2984
+ return;
2985
+ if (action.kind !== "type" && action.kind !== "select" && action.kind !== "set_phone_country") {
2986
+ return;
2987
+ }
2988
+ if (action.replayRepair === undefined ||
2989
+ action.replayRepair.stepIndex !== stepIndex ||
2990
+ action.replayRepair.hole !== expected.hole) {
2991
+ throw new Error(`replay repair must bind to step ${stepIndex} and ${expected.hole}`);
2992
+ }
2993
+ const supplied = action.kind === "type" || action.kind === "select"
2994
+ ? action.text
2995
+ : action.kind === "set_phone_country"
2996
+ ? action.country
2997
+ : null;
2998
+ if (supplied === null)
2999
+ return;
3000
+ if (supplied !== expected.expected) {
3001
+ markReplayFailure(session, "field_value_mismatch", expected.hole);
3002
+ throw new Error(`replay repair value mismatch for ${expected.hole}`);
3003
+ }
3004
+ if (action.kind === "set_phone_country") {
3005
+ const guard = await verifyReplayField(session, expected);
3006
+ if (!guard.ok) {
3007
+ markReplayFailure(session, guard.reason, expected.hole);
3008
+ throw new Error(`replay repair verification failed for ${expected.hole}: ${guard.reason}`);
3009
+ }
3010
+ state.verifiedFields.add(stepIndex);
3011
+ return;
3012
+ }
3013
+ const recordedResolution = expected.target === null
3014
+ ? null
3015
+ : resolveRecipeFieldTarget(session.lastElements, expected.target);
3016
+ if (resolvedEl === null) {
3017
+ throw new Error(`replay repair target mismatch for ${expected.hole}`);
3018
+ }
3019
+ const recordedTargetMatches = recordedResolution?.element.selector === resolvedEl.selector;
3020
+ const semanticResolution = expected.target === null
3021
+ ? null
3022
+ : resolveRecipeRepairTarget(session.lastElements, expected.target);
3023
+ const replacementSemanticsMatch = semanticResolution?.selector === resolvedEl.selector;
3024
+ if (!recordedTargetMatches && !replacementSemanticsMatch) {
3025
+ throw new Error(`replay repair target mismatch for ${expected.hole}`);
3026
+ }
3027
+ const replacementTarget = recipeTargetFor(resolvedEl, session.lastElements, session.userEmail);
3028
+ if (replacementTarget === undefined) {
3029
+ throw new Error(`replay repair target could not be attested for ${expected.hole}`);
3030
+ }
3031
+ expected.target = replacementTarget;
3032
+ const guard = await verifyReplayField(session, expected, action.kind === "select");
3033
+ if (!guard.ok) {
3034
+ markReplayFailure(session, guard.reason, expected.hole);
3035
+ throw new Error(`replay repair verification failed for ${expected.hole}: ${guard.reason}`);
3036
+ }
3037
+ state.verifiedFields.add(stepIndex);
3038
+ }
3039
+ async function refreshReplayVerificationAfterAction(session, action, resolvedEl) {
3040
+ const state = session.replayState;
3041
+ if (state === null ||
3042
+ !state.moneyPath ||
3043
+ state.nextIndex !== null ||
3044
+ state.paymentGuard !== "verified" ||
3045
+ (action.kind !== "type" && action.kind !== "select" && action.kind !== "set_phone_country")) {
3046
+ return;
3047
+ }
3048
+ const affected = [...state.expectedFields.values()].filter((expected) => {
3049
+ if (action.kind === "set_phone_country")
3050
+ return expected.kind === "set_phone_country";
3051
+ if (expected.target === null || resolvedEl === null)
3052
+ return false;
3053
+ return (resolveRecipeFieldTarget(session.lastElements, expected.target)?.element.selector ===
3054
+ resolvedEl.selector);
3055
+ });
3056
+ for (const expected of affected) {
3057
+ state.verifiedFields.delete(expected.stepIndex);
3058
+ state.paymentGuard = "pending";
3059
+ const supplied = action.kind === "set_phone_country" ? action.country : action.text;
3060
+ if (supplied !== expected.expected) {
3061
+ markReplayFailure(session, "field_value_mismatch", expected.hole);
3062
+ throw new Error(`replay field value mismatch for ${expected.hole}`);
3063
+ }
3064
+ const guard = await verifyReplayField(session, expected, action.kind === "select");
3065
+ if (!guard.ok) {
3066
+ markReplayFailure(session, guard.reason, expected.hole);
3067
+ throw new Error(`replay field verification failed for ${expected.hole}: ${guard.reason}`);
3068
+ }
3069
+ state.verifiedFields.add(expected.stepIndex);
3070
+ }
3071
+ if (state.verifiedFields.size === state.expectedFields.size)
3072
+ state.paymentGuard = "verified";
3073
+ }
3074
+ function isReplayTransitionAction(action) {
3075
+ return (action.kind !== "type" &&
3076
+ action.kind !== "select" &&
3077
+ action.kind !== "set_phone_country" &&
3078
+ action.kind !== "allow_host");
3079
+ }
3080
+ function rejectRecipeRecording(session, reason) {
3081
+ session.recipeRejectionReason ??= reason;
3082
+ }
3083
+ function recordedMoneyFields(session) {
3084
+ const fields = [];
3085
+ for (const source of session.recordedValues) {
3086
+ if (source.hole === undefined || !REPLAY_VERIFIED_HOLE.test(source.hole))
3087
+ continue;
3088
+ const action = session.actionTrace[source.traceIndex]?.action;
3089
+ if (action === undefined ||
3090
+ (action.kind !== "type" && action.kind !== "select" && action.kind !== "set_phone_country")) {
3091
+ continue;
3092
+ }
3093
+ let target = null;
3094
+ if (action.kind !== "set_phone_country") {
3095
+ try {
3096
+ target = boundReplayTarget({ ...action, email_hole: source.hole }, { [source.hole]: source.literal });
3097
+ }
3098
+ catch {
3099
+ target = null;
3100
+ }
3101
+ }
3102
+ fields.push({
3103
+ stepIndex: source.traceIndex,
3104
+ hole: source.hole,
3105
+ expected: source.literal,
3106
+ target,
3107
+ kind: action.kind,
3108
+ });
3109
+ }
3110
+ return fields;
3111
+ }
3112
+ async function attestRecordedFieldsBeforeTransition(session, action) {
3113
+ if (session.replayState !== null || !isReplayTransitionAction(action))
3114
+ return [];
3115
+ const fields = recordedMoneyFields(session);
3116
+ if (fields.length === 0)
3117
+ return fields;
3118
+ const fresh = await session.browser.extractInteractiveElements();
3119
+ session.lastElements = fresh;
3120
+ for (const expected of fields) {
3121
+ const guard = await verifyReplayFieldWithElements(session, expected, fresh);
3122
+ if (!guard.ok) {
3123
+ rejectRecipeRecording(session, `checkout transition could not be attested (${expected.hole}: ${guard.reason})`);
3124
+ return [];
3125
+ }
3126
+ }
3127
+ return fields;
3128
+ }
3129
+ async function verifyRecordedFieldsAfterTransition(session, action, fields) {
3130
+ if (session.replayState !== null || !isReplayTransitionAction(action) || fields.length === 0) {
3131
+ return;
3132
+ }
3133
+ const fresh = await session.browser.extractInteractiveElements();
3134
+ session.lastElements = fresh;
3135
+ for (const expected of fields) {
3136
+ if (!(await isReplayFieldMounted(session, expected, fresh))) {
3137
+ rejectRecipeRecording(session, `checkout transition could not be attested (${expected.hole}: field_missing)`);
3138
+ return;
3139
+ }
3140
+ const guard = await verifyReplayFieldWithElements(session, expected, fresh);
3141
+ if (!guard.ok) {
3142
+ rejectRecipeRecording(session, `checkout transition could not be attested (${expected.hole}: ${guard.reason})`);
3143
+ return;
3144
+ }
3145
+ }
3146
+ }
3147
+ async function attestReplayFieldsBeforeTransition(session, action) {
3148
+ const state = session.replayState;
3149
+ const fields = new Set();
3150
+ if (state === null || !state.moneyPath || !isReplayTransitionAction(action)) {
3151
+ return { ok: true, fields };
3152
+ }
3153
+ const fresh = await session.browser.extractInteractiveElements();
3154
+ session.lastElements = fresh;
3155
+ for (const expected of state.expectedFields.values()) {
3156
+ if (!state.verifiedFields.has(expected.stepIndex) ||
3157
+ (expected.target === null && expected.kind !== "set_phone_country")) {
3158
+ continue;
3159
+ }
3160
+ const guard = await verifyReplayFieldWithElements(session, expected, fresh);
3161
+ if (!guard.ok) {
3162
+ return { ok: false, reason: guard.reason, field: expected.hole };
3163
+ }
3164
+ fields.add(expected.stepIndex);
3165
+ }
3166
+ return { ok: true, fields };
3167
+ }
3168
+ async function verifyReplayFieldsAfterTransition(session, action, attestedFields) {
3169
+ const state = session.replayState;
3170
+ if (state === null || !state.moneyPath || !isReplayTransitionAction(action)) {
3171
+ return { ok: true };
3172
+ }
3173
+ const fresh = await session.browser.extractInteractiveElements();
3174
+ session.lastElements = fresh;
3175
+ for (const stepIndex of attestedFields) {
3176
+ const expected = state.expectedFields.get(stepIndex);
3177
+ if (expected === undefined)
3178
+ continue;
3179
+ if (!(await isReplayFieldMounted(session, expected, fresh))) {
3180
+ return { ok: false, reason: "field_missing", field: expected.hole };
3181
+ }
3182
+ const guard = await verifyReplayFieldWithElements(session, expected, fresh);
3183
+ if (!guard.ok) {
3184
+ return { ok: false, reason: guard.reason, field: expected.hole };
3185
+ }
3186
+ }
3187
+ return { ok: true };
3188
+ }
3189
+ /**
3190
+ * Execute deterministic recipe steps until completion or one local miss.
3191
+ * A miss is returned to the host with a continuation index; after the host
3192
+ * repairs that one step, calling again with `fromIndex=next_index` continues.
3193
+ */
3194
+ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromIndex = 0) {
3195
+ const session = sessions.get(sessionId);
3196
+ if (session === undefined)
3197
+ throw new Error(`unknown provision session ${sessionId}`);
3198
+ const recipeHash = replayDigest(recipe);
3199
+ const bindingsHash = bindingDigest(bindings);
3200
+ const boundPostcondition = bindRecipePostcondition(recipe.postcondition, bindings);
3201
+ if (recipe.verb === undefined || recipe.domain === undefined) {
3202
+ throw new Error("legacy named recipes are hint-only and cannot replay deterministically");
3203
+ }
3204
+ const isMoneyPath = MONEY_REPLAY_VERBS.has(recipe.verb);
3205
+ let state;
3206
+ const humanRequired = async (reason, field) => {
3207
+ markReplayFailure(session, reason, field);
3208
+ return { status: "human_required", observation: await observe(sessionId), reason, field };
3209
+ };
3210
+ if (fromIndex === 0) {
3211
+ if (session.replayState !== null) {
3212
+ throw new Error("replay already started in this session; use the issued continuation");
3213
+ }
3214
+ const expected = expectedReplayFields(recipe, bindings);
3215
+ state = {
3216
+ recipeName: recipe.name,
3217
+ recipeHash,
3218
+ bindingsHash,
3219
+ boundPostcondition,
3220
+ moneyPath: isMoneyPath,
3221
+ nextIndex: null,
3222
+ expectedFields: expected.fields,
3223
+ verifiedFields: new Set(),
3224
+ paymentGuard: isMoneyPath ? "pending" : "verified",
3225
+ };
3226
+ session.replayState = state;
3227
+ if (expected.missing !== null)
3228
+ return await humanRequired("field_missing", expected.missing);
3229
+ }
3230
+ else {
3231
+ state = session.replayState;
3232
+ if (state === null ||
3233
+ state.recipeHash !== recipeHash ||
3234
+ state.bindingsHash !== bindingsHash ||
3235
+ state.nextIndex !== fromIndex) {
3236
+ throw new Error("invalid replay continuation: resume_from was not issued for this session");
3237
+ }
3238
+ if (state.failure !== undefined) {
3239
+ return await humanRequired(state.failure.reason, state.failure.field);
3240
+ }
3241
+ const repairedField = state.expectedFields.get(fromIndex - 1);
3242
+ if (repairedField !== undefined && !state.verifiedFields.has(fromIndex - 1)) {
3243
+ const guard = await verifyReplayField(session, repairedField);
3244
+ if (!guard.ok)
3245
+ return await humanRequired(guard.reason, repairedField.hole);
3246
+ state.verifiedFields.add(fromIndex - 1);
3247
+ }
3248
+ state.nextIndex = null;
3249
+ }
3250
+ let replayed = 0;
3251
+ const fallback = async (step, stepIndex, reason) => {
3252
+ state.nextIndex = stepIndex + 1;
3253
+ if (step.action.kind === "operate_pay" &&
3254
+ state.verifiedFields.size === state.expectedFields.size) {
3255
+ state.paymentGuard = "verified";
3256
+ }
3257
+ return {
3258
+ status: "fallback_required",
3259
+ observation: await observe(sessionId),
3260
+ step_index: stepIndex,
3261
+ next_index: stepIndex + 1,
3262
+ step,
3263
+ reason,
3264
+ };
3265
+ };
3266
+ for (let i = fromIndex; i < recipe.trace.length; i += 1) {
3267
+ const step = recipe.trace[i];
3268
+ const recorded = step.action;
3269
+ let action;
3270
+ if (recorded.kind === "extract") {
3271
+ return await fallback(step, i, "credential extraction requires host planning on the live page");
3272
+ }
3273
+ if (recorded.kind === "operate_pay") {
3274
+ return await fallback(step, i, "payment requires the existing operate_pay approval flow");
3275
+ }
3276
+ if (recorded.kind === "goto") {
3277
+ if (recorded.url_template === undefined) {
3278
+ return await fallback(step, i, "goto step has no URL");
3279
+ }
3280
+ let urlTemplate;
3281
+ try {
3282
+ urlTemplate = bindKnownEmailTemplate(recorded.url_template, bindings, recorded.email_hole);
3283
+ }
3284
+ catch (error) {
3285
+ return await fallback(step, i, error instanceof Error ? error.message : String(error));
3286
+ }
3287
+ const filled = fillTemplate(urlTemplate, bindings);
3288
+ if (filled.missing.length > 0) {
3289
+ return await fallback(step, i, `missing bindings: ${filled.missing.join(", ")}`);
3290
+ }
3291
+ action = { kind: "goto", url: filled.url };
3292
+ }
3293
+ else if (recorded.kind === "allow_host") {
3294
+ if (recorded.host === undefined) {
3295
+ return await fallback(step, i, "allow_host step has no host");
3296
+ }
3297
+ action = { kind: "allow_host", host: recorded.host };
3298
+ }
3299
+ else if (recorded.kind === "press") {
3300
+ if (recorded.key === undefined) {
3301
+ return await fallback(step, i, "press step has no key");
3302
+ }
3303
+ action = { kind: "press", key: recorded.key };
3304
+ }
3305
+ else if (recorded.kind === "oauth_settle") {
3306
+ action = { kind: "oauth_settle" };
3307
+ }
3308
+ else if (recorded.kind === "scroll") {
3309
+ action = {
3310
+ kind: "scroll",
3311
+ ...(recorded.direction !== undefined ? { direction: recorded.direction } : {}),
3312
+ };
3313
+ }
3314
+ else if (recorded.kind === "set_phone_country") {
3315
+ if (recorded.value === undefined) {
3316
+ return await fallback(step, i, "set_phone_country step has no value");
3317
+ }
3318
+ try {
3319
+ action = {
3320
+ kind: "set_phone_country",
3321
+ country: bindRecipeValue(recorded.value, bindings),
3322
+ ...(typeof recorded.value === "string" ? {} : { provenance: recorded.value }),
3323
+ };
3324
+ }
3325
+ catch (error) {
3326
+ return await fallback(step, i, error instanceof Error ? error.message : String(error));
3327
+ }
3328
+ }
3329
+ else {
3330
+ let target;
3331
+ try {
3332
+ target = boundReplayTarget(recorded, bindings);
3333
+ }
3334
+ catch (error) {
3335
+ return await fallback(step, i, error instanceof Error ? error.message : String(error));
3336
+ }
3337
+ if (target === null) {
3338
+ return await fallback(step, i, "step has no replay target");
3339
+ }
3340
+ // Structural pre-check: resolve against the live inventory before every
3341
+ // deterministic act. This is especially load-bearing on money paths.
3342
+ const fresh = await session.browser.extractInteractiveElements();
3343
+ session.lastElements = fresh;
3344
+ const expectedForStep = state.expectedFields.get(i);
3345
+ const resolution = expectedForStep === undefined
3346
+ ? resolveRecipeTarget(fresh, target)
3347
+ : resolveRecipeFieldTarget(fresh, target);
3348
+ if (resolution === null) {
3349
+ return await fallback(step, i, "ordered target resolver missed");
3350
+ }
3351
+ const ref = provisionElementRefs(fresh).get(resolution.element);
3352
+ if (ref === undefined) {
3353
+ return await fallback(step, i, "resolved target has no live ref");
3354
+ }
3355
+ if (recorded.kind === "type" || recorded.kind === "select") {
3356
+ if (recorded.value === undefined) {
3357
+ return await fallback(step, i, `${recorded.kind} step has no value`);
3358
+ }
3359
+ let text;
3360
+ try {
3361
+ text = bindRecipeValue(recorded.value, bindings);
3362
+ }
3363
+ catch (error) {
3364
+ return await fallback(step, i, error instanceof Error ? error.message : String(error));
3365
+ }
3366
+ action =
3367
+ recorded.kind === "type"
3368
+ ? {
3369
+ kind: "type",
3370
+ target: ref,
3371
+ text,
3372
+ ...(typeof recorded.value === "string" ? {} : { provenance: recorded.value }),
3373
+ }
3374
+ : {
3375
+ kind: "select",
3376
+ target: ref,
3377
+ text,
3378
+ ...(typeof recorded.value === "string" ? {} : { provenance: recorded.value }),
3379
+ };
3380
+ }
3381
+ else if (recorded.kind === "type_secret") {
3382
+ if (recorded.slot === undefined) {
3383
+ return await fallback(step, i, "type_secret step has no slot");
3384
+ }
3385
+ action = { kind: "type_secret", target: ref, slot: recorded.slot };
3386
+ }
3387
+ else if (recorded.kind === "click") {
3388
+ action = { kind: "click", target: ref };
3389
+ }
3390
+ else if (recorded.kind === "js_click") {
3391
+ action = { kind: "js_click", target: ref };
3392
+ }
3393
+ else {
3394
+ action = { kind: "oauth_click", target: ref };
3395
+ }
3396
+ }
3397
+ try {
3398
+ const transitionAttestation = await attestReplayFieldsBeforeTransition(session, action);
3399
+ if (!transitionAttestation.ok) {
3400
+ return await humanRequired(transitionAttestation.reason, transitionAttestation.field);
3401
+ }
3402
+ await act(sessionId, action, "none");
3403
+ replayed += 1;
3404
+ const expected = state.expectedFields.get(i);
3405
+ if (expected !== undefined) {
3406
+ const guard = await verifyReplayField(session, expected, expected.kind === "select");
3407
+ if (!guard.ok)
3408
+ return await humanRequired(guard.reason, expected.hole);
3409
+ state.verifiedFields.add(i);
3410
+ }
3411
+ const transitionGuard = await verifyReplayFieldsAfterTransition(session, action, transitionAttestation.fields);
3412
+ if (!transitionGuard.ok) {
3413
+ return await humanRequired(transitionGuard.reason, transitionGuard.field);
3414
+ }
3415
+ }
3416
+ catch (error) {
3417
+ return await fallback(step, i, error instanceof Error ? error.message : String(error));
3418
+ }
3419
+ }
3420
+ if (isMoneyPath) {
3421
+ const unverified = [...state.expectedFields.values()].find((expected) => !state.verifiedFields.has(expected.stepIndex));
3422
+ if (unverified !== undefined) {
3423
+ return await humanRequired("field_missing", unverified.hole);
3424
+ }
3425
+ state.paymentGuard = "verified";
3426
+ audit(sessionId, "replay_field_value_guard", {
3427
+ ok: true,
3428
+ fields: state.expectedFields.size,
3429
+ });
3430
+ }
3431
+ return {
3432
+ status: "complete",
3433
+ observation: await observe(sessionId),
3434
+ replayed_steps: replayed,
3435
+ field_values_verified: isMoneyPath,
3436
+ };
3437
+ }
2253
3438
  const normLabelKey = (label) => label
2254
3439
  .replace(/\s+/g, "_")
2255
3440
  .replace(/[^a-z0-9_]/gi, "")
@@ -2352,7 +3537,7 @@ export function classifyVouchflowCredentials(text) {
2352
3537
  // isTruncatedCapture + extraction.ts accumulation). Reuses the substrate —
2353
3538
  // no new credential regexes.
2354
3539
  export async function extractCredentials(sessionId) {
2355
- const session = sessions.get(sessionId);
3540
+ const session = sessionForCall(sessionId);
2356
3541
  if (session === undefined)
2357
3542
  throw new Error(`unknown provision session ${sessionId}`);
2358
3543
  const { browser } = session;
@@ -2605,7 +3790,7 @@ async function solveCaptchaWithTokenSolver(solver, browser, variant) {
2605
3790
  // response token is the success signal; challenge disappearance alone is not
2606
3791
  // enough because a reCAPTCHA v2 checkbox can be idle with an empty token.
2607
3792
  export async function captchaGate(sessionId) {
2608
- const session = sessions.get(sessionId);
3793
+ const session = sessionForCall(sessionId);
2609
3794
  if (session === undefined)
2610
3795
  throw new Error(`unknown provision session ${sessionId}`);
2611
3796
  const det = await session.browser.detectCaptchaVariant();
@@ -2780,7 +3965,7 @@ export function buildVerificationSearchQuery(sender) {
2780
3965
  .join(" ");
2781
3966
  }
2782
3967
  export async function awaitVerification(sessionId, opts = {}) {
2783
- const session = sessions.get(sessionId);
3968
+ const session = sessionForCall(sessionId);
2784
3969
  if (session === undefined)
2785
3970
  throw new Error(`unknown provision session ${sessionId}`);
2786
3971
  const { browser } = session;
@@ -2866,13 +4051,23 @@ export async function awaitVerification(sessionId, opts = {}) {
2866
4051
  return buildVerificationResult(sessionId, code, link, sourceFrom);
2867
4052
  }
2868
4053
  export async function finishProvisionSession(sessionId) {
2869
- const session = sessions.get(sessionId);
4054
+ const session = sessionForCall(sessionId);
2870
4055
  if (session === undefined)
2871
4056
  throw new Error(`unknown provision session ${sessionId}`);
2872
4057
  const url = session.browser.currentUrl();
2873
4058
  audit(sessionId, "finish", { url });
2874
- await session.browser.close();
2875
4059
  sessions.delete(sessionId);
4060
+ try {
4061
+ await releaseWarmBrowserPage(session.browser);
4062
+ }
4063
+ finally {
4064
+ inFlight = false;
4065
+ }
4066
+ const expired = warmBrowser !== null ? warmBrowserExpired(warmBrowser) : null;
4067
+ if (expired !== null)
4068
+ await closeWarmBrowserIfIdle(expired);
4069
+ else
4070
+ touchWarmBrowser();
2876
4071
  return { session_id: sessionId, url, closed: true };
2877
4072
  }
2878
4073
  // Test/teardown helper — close every live session (used by the dev shim on exit).
@@ -2885,6 +4080,7 @@ export async function closeAllProvisionSessions() {
2885
4080
  /* best-effort */
2886
4081
  }
2887
4082
  }
4083
+ await closeWarmBrowserIfIdle("shutdown");
2888
4084
  }
2889
4085
  export function activeSessionCount() {
2890
4086
  return sessions.size;