@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.
- package/README.md +34 -16
- package/dist/api-client.d.ts +11 -3
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +14 -4
- package/dist/api-client.js.map +1 -1
- package/dist/bot/browser.d.ts +19 -1
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +228 -30
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/near-text-hint.d.ts +7 -3
- package/dist/bot/near-text-hint.d.ts.map +1 -1
- package/dist/bot/near-text-hint.js +1 -2
- package/dist/bot/near-text-hint.js.map +1 -1
- package/dist/bot/operator-recipe.d.ts +694 -36
- package/dist/bot/operator-recipe.d.ts.map +1 -1
- package/dist/bot/operator-recipe.js +565 -43
- package/dist/bot/operator-recipe.js.map +1 -1
- package/dist/bot/pay-operator.d.ts +4 -2
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +173 -67
- package/dist/bot/pay-operator.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +1 -0
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +20 -30
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/provision-session.d.ts +44 -1
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +1300 -104
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/eval/replay-harness/corpus.d.ts +7 -0
- package/dist/eval/replay-harness/corpus.d.ts.map +1 -0
- package/dist/eval/replay-harness/corpus.js +183 -0
- package/dist/eval/replay-harness/corpus.js.map +1 -0
- package/dist/eval/replay-harness/har-mutate.d.ts +36 -0
- package/dist/eval/replay-harness/har-mutate.d.ts.map +1 -0
- package/dist/eval/replay-harness/har-mutate.js +119 -0
- package/dist/eval/replay-harness/har-mutate.js.map +1 -0
- package/dist/eval/replay-harness/har-substrate.d.ts +16 -0
- package/dist/eval/replay-harness/har-substrate.d.ts.map +1 -0
- package/dist/eval/replay-harness/har-substrate.js +99 -0
- package/dist/eval/replay-harness/har-substrate.js.map +1 -0
- package/dist/eval/replay-harness/index.d.ts +8 -0
- package/dist/eval/replay-harness/index.d.ts.map +1 -0
- package/dist/eval/replay-harness/index.js +8 -0
- package/dist/eval/replay-harness/index.js.map +1 -0
- package/dist/eval/replay-harness/metrics.d.ts +9 -0
- package/dist/eval/replay-harness/metrics.d.ts.map +1 -0
- package/dist/eval/replay-harness/metrics.js +117 -0
- package/dist/eval/replay-harness/metrics.js.map +1 -0
- package/dist/eval/replay-harness/reporter.d.ts +4 -0
- package/dist/eval/replay-harness/reporter.d.ts.map +1 -0
- package/dist/eval/replay-harness/reporter.js +62 -0
- package/dist/eval/replay-harness/reporter.js.map +1 -0
- package/dist/eval/replay-harness/runner.d.ts +36 -0
- package/dist/eval/replay-harness/runner.d.ts.map +1 -0
- package/dist/eval/replay-harness/runner.js +167 -0
- package/dist/eval/replay-harness/runner.js.map +1 -0
- package/dist/eval/replay-harness/types.d.ts +135 -0
- package/dist/eval/replay-harness/types.d.ts.map +1 -0
- package/dist/eval/replay-harness/types.js +2 -0
- package/dist/eval/replay-harness/types.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/grant-app-access.js +1 -1
- package/dist/tools/grant-app-access.js.map +1 -1
- package/dist/tools/operate-pay.d.ts +10 -10
- package/dist/tools/operate-pay.d.ts.map +1 -1
- package/dist/tools/operate-pay.js +42 -16
- package/dist/tools/operate-pay.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +165 -8
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +211 -40
- package/dist/tools/provision-drive.js.map +1 -1
- 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(
|
|
952
|
-
const
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
1887
|
-
recordCaptureRound(session,
|
|
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
|
|
2214
|
+
if (userEmail === null || userEmail.length === 0)
|
|
1929
2215
|
return s;
|
|
1930
|
-
|
|
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
|
-
|
|
1933
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
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 = {
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
2722
|
+
trace,
|
|
2203
2723
|
secrets,
|
|
2204
|
-
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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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;
|