@trusty-squire/mcp 1.1.13-rc.8 → 1.1.13
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 +139 -62
- package/assets/login/vnc-input.js +9 -0
- package/assets/login/vnc.html +214 -202
- package/assets/screencaps/connect-walkthrough.png +0 -0
- package/assets/screencaps/connect-walkthrough.svg +1 -1
- package/dist/api-client.d.ts +26 -3
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +19 -31
- package/dist/api-client.js.map +1 -1
- package/dist/bin.js +2 -3
- package/dist/bin.js.map +1 -1
- package/dist/bot/browser.d.ts +134 -77
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +1788 -1543
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/compact-observation-v2.d.ts +167 -0
- package/dist/bot/compact-observation-v2.d.ts.map +1 -0
- package/dist/bot/compact-observation-v2.js +1038 -0
- package/dist/bot/compact-observation-v2.js.map +1 -0
- package/dist/bot/credential-shape.d.ts +2 -0
- package/dist/bot/credential-shape.d.ts.map +1 -1
- package/dist/bot/credential-shape.js +21 -8
- package/dist/bot/credential-shape.js.map +1 -1
- package/dist/bot/element-fingerprint.d.ts +9 -0
- package/dist/bot/element-fingerprint.d.ts.map +1 -0
- package/dist/bot/element-fingerprint.js +147 -0
- package/dist/bot/element-fingerprint.js.map +1 -0
- package/dist/bot/email-verification.d.ts +7 -1
- package/dist/bot/email-verification.d.ts.map +1 -1
- package/dist/bot/email-verification.js +48 -14
- package/dist/bot/email-verification.js.map +1 -1
- package/dist/bot/google-login.d.ts +28 -52
- package/dist/bot/google-login.d.ts.map +1 -1
- package/dist/bot/google-login.js +246 -1119
- package/dist/bot/google-login.js.map +1 -1
- package/dist/bot/install-completion.d.ts +1 -2
- package/dist/bot/install-completion.d.ts.map +1 -1
- package/dist/bot/install-completion.js +1 -2
- package/dist/bot/install-completion.js.map +1 -1
- package/dist/bot/login-state.d.ts +13 -7
- package/dist/bot/login-state.d.ts.map +1 -1
- package/dist/bot/login-state.js +50 -114
- package/dist/bot/login-state.js.map +1 -1
- package/dist/bot/oauth-providers.d.ts +1 -1
- package/dist/bot/oauth-providers.d.ts.map +1 -1
- package/dist/bot/oauth-providers.js +3 -3
- package/dist/bot/oauth-providers.js.map +1 -1
- package/dist/bot/oauth-scope.d.ts +4 -0
- package/dist/bot/oauth-scope.d.ts.map +1 -0
- package/dist/bot/oauth-scope.js +58 -0
- package/dist/bot/oauth-scope.js.map +1 -0
- package/dist/bot/operator-browser-watchdog.d.ts +127 -0
- package/dist/bot/operator-browser-watchdog.d.ts.map +1 -0
- package/dist/bot/operator-browser-watchdog.js +438 -0
- package/dist/bot/operator-browser-watchdog.js.map +1 -0
- package/dist/bot/owner-process-reaper-worker.d.ts +2 -0
- package/dist/bot/owner-process-reaper-worker.d.ts.map +1 -0
- package/dist/bot/owner-process-reaper-worker.js +17 -0
- package/dist/bot/owner-process-reaper-worker.js.map +1 -0
- package/dist/bot/owner-process-reaper.d.ts +103 -0
- package/dist/bot/owner-process-reaper.d.ts.map +1 -0
- package/dist/bot/owner-process-reaper.js +1058 -0
- package/dist/bot/owner-process-reaper.js.map +1 -0
- package/dist/bot/pay-operator.d.ts +13 -2
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +319 -123
- package/dist/bot/pay-operator.js.map +1 -1
- package/dist/bot/profile.d.ts +10 -1
- package/dist/bot/profile.d.ts.map +1 -1
- package/dist/bot/profile.js +137 -11
- package/dist/bot/profile.js.map +1 -1
- package/dist/bot/promote-to-skill.js +1 -1
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/provision-session.d.ts +60 -164
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +2049 -1546
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/bot/remote-login-display.d.ts +61 -0
- package/dist/bot/remote-login-display.d.ts.map +1 -0
- package/dist/bot/remote-login-display.js +765 -0
- package/dist/bot/remote-login-display.js.map +1 -0
- package/dist/bot/session/hosts.d.ts +8 -0
- package/dist/bot/session/hosts.d.ts.map +1 -0
- package/dist/bot/session/hosts.js +36 -0
- package/dist/bot/session/hosts.js.map +1 -0
- package/dist/bot/session/lifecycle.d.ts +56 -0
- package/dist/bot/session/lifecycle.d.ts.map +1 -0
- package/dist/bot/session/lifecycle.js +962 -0
- package/dist/bot/session/lifecycle.js.map +1 -0
- package/dist/bot/session/model.d.ts +175 -0
- package/dist/bot/session/model.d.ts.map +1 -0
- package/dist/bot/session/model.js +77 -0
- package/dist/bot/session/model.js.map +1 -0
- package/dist/install/cli.d.ts +29 -9
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +243 -322
- package/dist/install/cli.js.map +1 -1
- package/dist/install/interactive.d.ts +1 -0
- package/dist/install/interactive.d.ts.map +1 -1
- package/dist/install/interactive.js +19 -5
- package/dist/install/interactive.js.map +1 -1
- package/dist/install/ui.d.ts.map +1 -1
- package/dist/install/ui.js +3 -15
- package/dist/install/ui.js.map +1 -1
- package/dist/operator-env.js +1 -1
- package/dist/operator-env.js.map +1 -1
- package/dist/server-instance-registry.d.ts +103 -0
- package/dist/server-instance-registry.d.ts.map +1 -0
- package/dist/server-instance-registry.js +392 -0
- package/dist/server-instance-registry.js.map +1 -0
- package/dist/server.d.ts +15 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +164 -62
- package/dist/server.js.map +1 -1
- package/dist/session-guard.d.ts +28 -0
- package/dist/session-guard.d.ts.map +1 -0
- package/dist/session-guard.js +84 -0
- package/dist/session-guard.js.map +1 -0
- package/dist/session.d.ts +57 -13
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +246 -127
- package/dist/session.js.map +1 -1
- package/dist/tools/audit-log.d.ts +16 -2
- package/dist/tools/audit-log.d.ts.map +1 -1
- package/dist/tools/audit-log.js +223 -13
- package/dist/tools/audit-log.js.map +1 -1
- package/dist/tools/audit-rollup.d.ts +80 -0
- package/dist/tools/audit-rollup.d.ts.map +1 -0
- package/dist/tools/audit-rollup.js +0 -0
- package/dist/tools/audit-rollup.js.map +1 -0
- package/dist/tools/fetch-credential.d.ts +37 -0
- package/dist/tools/fetch-credential.d.ts.map +1 -0
- package/dist/tools/fetch-credential.js +153 -0
- package/dist/tools/fetch-credential.js.map +1 -0
- package/dist/tools/index.d.ts +3 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +7 -3
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/operate-pay.d.ts +4 -4
- package/dist/tools/operate-pay.d.ts.map +1 -1
- package/dist/tools/operate-pay.js +163 -96
- package/dist/tools/operate-pay.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +43 -35
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +268 -164
- package/dist/tools/provision-drive.js.map +1 -1
- package/dist/tools/use-credential.d.ts.map +1 -1
- package/dist/tools/use-credential.js +3 -2
- package/dist/tools/use-credential.js.map +1 -1
- package/package.json +4 -6
- package/dist/bot/operator-direct-identity.d.ts +0 -26
- package/dist/bot/operator-direct-identity.d.ts.map +0 -1
- package/dist/bot/operator-direct-identity.js +0 -120
- package/dist/bot/operator-direct-identity.js.map +0 -1
- package/dist/bot/operator-profile-pool.d.ts +0 -97
- package/dist/bot/operator-profile-pool.d.ts.map +0 -1
- package/dist/bot/operator-profile-pool.js +0 -855
- package/dist/bot/operator-profile-pool.js.map +0 -1
- package/dist/bot/xvfb.d.ts +0 -13
- package/dist/bot/xvfb.d.ts.map +0 -1
- package/dist/bot/xvfb.js +0 -146
- package/dist/bot/xvfb.js.map +0 -1
|
@@ -15,23 +15,21 @@
|
|
|
15
15
|
// not navigation the agent chose, so they are never blocked.
|
|
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
|
-
import { createHash,
|
|
18
|
+
import { createHash, createHmac, randomInt } from "node:crypto";
|
|
19
19
|
import { Buffer } from "node:buffer";
|
|
20
20
|
import { chmodSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
21
|
-
import { tmpdir } from "node:os";
|
|
22
21
|
import { join } from "node:path";
|
|
23
|
-
import {
|
|
22
|
+
import { CHECKOUT_SUBMIT_LABEL_RE, clickDispatchStatusForError, parseCheckoutAmount, } from "./browser.js";
|
|
24
23
|
import { TwoCaptchaSolver } from "./captcha-solver-2captcha.js";
|
|
24
|
+
import { buildSafeControlsV2, checkoutStageFromUrlV2, compactV2LegacyRefForHandle, compactV2PayloadWithinBudget, COMPACT_V2_HANDLE_LENGTH, isCompactV2Handle, isCompactV2Label, controlMatchesPrivateQueryV2, diffSafeControlsV2, equalSafePageSemanticsV2, encodeV2Delta, encodeV2Page, compactV2AuditHost, compactV2AuditUrl, compactV2AuditValue, recordableTokenV2, safeDescriptionV2, safeOriginV2, safePageSemanticsV2, sealRetainedInteractiveElementsV2, safeStageV2, } from "./compact-observation-v2.js";
|
|
25
|
+
import { elementFingerprints } from "./element-fingerprint.js";
|
|
25
26
|
import { extractApiKeyFromText, isTruncatedCapture } from "./credential-text.js";
|
|
26
27
|
import { pickVerificationLink } from "./email-verification.js";
|
|
27
|
-
import { acquireOperatorProfile, OperatorProfileAcquisitionInterruptedError, } from "./operator-profile-pool.js";
|
|
28
|
-
import { acquireDirectIdentityProfile, OperatorDirectIdentityAcquisitionInterruptedError, } from "./operator-direct-identity.js";
|
|
29
|
-
import { loginSessionGuidance } from "./skill-hint.js";
|
|
30
28
|
import { checkSuccessSignal, bindKnownEmailTemplate, bindRecipePostcondition, bindRecipeTarget, bindRecipeValue, cssEscapeRecipeValue, fillTemplate, hasRecipeTargetCandidate, isSingleUseUrl, isCheckoutShapeKey, isSameRecipeDomain, knownRecipeInputValue, localeStableFieldRole, operatorRecipeDomain, operatorRecipeKeyForDomain, canonicalVerb, extractActionPath, checkoutFieldSetSignature, checkoutShapeKey, readRecipe, resolveRecipeFieldTarget, resolveRecipeTarget, verifyFilledFieldValues, writeRecipe, } from "./operator-recipe.js";
|
|
31
29
|
import { captureOnboardingRound, currentRunId, resetCaptureChain, resolveCaptureDir, } from "./onboarding-capture.js";
|
|
32
30
|
import { promoteToSkill, pickRowDisambiguator, pickStableDomHint, pickHrefHint, } from "./promote-to-skill.js";
|
|
33
31
|
import { serviceSlugFromHost } from "@trusty-squire/skill-schema";
|
|
34
|
-
import { looksLikeCodeIdentifier,
|
|
32
|
+
import { looksLikeCodeIdentifier, isCredentialNoise, findCredentialTokens, findOtpCredential, keyFamilyPrefix, pickRelaxedNearCopyCredential, } from "./credential-shape.js";
|
|
35
33
|
import { initialExtractionState, accumulateCandidate, hasFullHit, resolveExtraction, } from "./extraction.js";
|
|
36
34
|
// Identity-provider + auth-handler hosts a signup legitimately bounces
|
|
37
35
|
// through. Used to widen domain-scope so an OAuth `goto` (rare) isn't blocked.
|
|
@@ -42,358 +40,186 @@ const DEFAULT_AUTH_HOSTS = [
|
|
|
42
40
|
"login.microsoftonline.com",
|
|
43
41
|
"appleid.apple.com",
|
|
44
42
|
];
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const START_CAPACITY_WAIT_MS = 30_000;
|
|
70
|
-
const START_CAPACITY_RETRY_MS = 50;
|
|
71
|
-
function isOperatorCapacityError(error) {
|
|
72
|
-
return error instanceof Error && error.message.startsWith("operate_start capacity reached:");
|
|
73
|
-
}
|
|
74
|
-
async function acquireOperatorProfileBounded(opts, sessionId, generation) {
|
|
75
|
-
let wake = () => undefined;
|
|
76
|
-
let resolveSettled = () => undefined;
|
|
77
|
-
const waiter = {
|
|
78
|
-
generation,
|
|
79
|
-
cancelRequested: false,
|
|
80
|
-
abortController: new AbortController(),
|
|
81
|
-
wake: () => wake(),
|
|
82
|
-
settled: new Promise((resolve) => {
|
|
83
|
-
resolveSettled = resolve;
|
|
84
|
-
}),
|
|
85
|
-
resolveSettled: () => resolveSettled(),
|
|
86
|
-
};
|
|
87
|
-
capacityWaiters.add(waiter);
|
|
88
|
-
const deadline = Date.now() + START_CAPACITY_WAIT_MS;
|
|
89
|
-
try {
|
|
90
|
-
for (;;) {
|
|
91
|
-
if (waiter.cancelRequested || waiter.generation !== shutdownGeneration) {
|
|
92
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
93
|
-
}
|
|
94
|
-
try {
|
|
95
|
-
const lease = await acquireOperatorProfile(sessionId, {
|
|
96
|
-
...(opts.profileDir !== undefined ? { sourceProfileDir: opts.profileDir } : {}),
|
|
97
|
-
deadline,
|
|
98
|
-
signal: waiter.abortController.signal,
|
|
99
|
-
});
|
|
100
|
-
if (waiter.cancelRequested || waiter.generation !== shutdownGeneration) {
|
|
101
|
-
await lease.destroy();
|
|
102
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
103
|
-
}
|
|
104
|
-
return lease;
|
|
105
|
-
}
|
|
106
|
-
catch (error) {
|
|
107
|
-
if (waiter.cancelRequested || waiter.generation !== shutdownGeneration) {
|
|
108
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
109
|
-
}
|
|
110
|
-
if (error instanceof OperatorProfileAcquisitionInterruptedError &&
|
|
111
|
-
error.reason === "cancelled") {
|
|
112
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
113
|
-
}
|
|
114
|
-
if (error instanceof OperatorProfileAcquisitionInterruptedError &&
|
|
115
|
-
error.reason === "timeout") {
|
|
116
|
-
if (error.phase === "seed_lock") {
|
|
117
|
-
throw new Error("operate_start failed: start deadline exceeded while waiting to acquire the shared seed lock");
|
|
118
|
-
}
|
|
119
|
-
throw new Error("operate_start failed: start deadline exceeded while acquiring an isolated operator profile");
|
|
120
|
-
}
|
|
121
|
-
if (isOperatorCapacityError(error) && Date.now() >= deadline) {
|
|
122
|
-
throw new Error("operate_start capacity wait timed out: 2 operator sessions are active; finish one and retry");
|
|
123
|
-
}
|
|
124
|
-
if (!isOperatorCapacityError(error)) {
|
|
125
|
-
throw error;
|
|
126
|
-
}
|
|
127
|
-
await new Promise((resolve) => {
|
|
128
|
-
wake = resolve;
|
|
129
|
-
const timer = setTimeout(resolve, START_CAPACITY_RETRY_MS);
|
|
130
|
-
waiter.wake = () => {
|
|
131
|
-
clearTimeout(timer);
|
|
132
|
-
resolve();
|
|
133
|
-
};
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
finally {
|
|
139
|
-
capacityWaiters.delete(waiter);
|
|
140
|
-
waiter.resolveSettled();
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
// The Google-authenticated counterpart of acquireOperatorProfileBounded — see
|
|
144
|
-
// operator-direct-identity.ts. Registers the same CapacityWaiter shape so
|
|
145
|
-
// closeAllProvisionSessions cancels an in-flight acquisition uniformly,
|
|
146
|
-
// but the underlying resource is the single canonical profile, not a
|
|
147
|
-
// two-slot pool: acquireDirectIdentityProfile already polls/blocks
|
|
148
|
-
// internally, so there is no outer capacity-retry loop to drive here.
|
|
149
|
-
async function acquireDirectIdentityProfileBounded(opts, generation) {
|
|
150
|
-
const waiter = {
|
|
151
|
-
generation,
|
|
152
|
-
cancelRequested: false,
|
|
153
|
-
abortController: new AbortController(),
|
|
154
|
-
wake: () => undefined,
|
|
155
|
-
settled: Promise.resolve(),
|
|
156
|
-
resolveSettled: () => undefined,
|
|
157
|
-
};
|
|
158
|
-
let resolveSettled = () => undefined;
|
|
159
|
-
waiter.settled = new Promise((resolve) => {
|
|
160
|
-
resolveSettled = resolve;
|
|
161
|
-
});
|
|
162
|
-
waiter.resolveSettled = () => resolveSettled();
|
|
163
|
-
capacityWaiters.add(waiter);
|
|
164
|
-
try {
|
|
165
|
-
if (waiter.cancelRequested || waiter.generation !== shutdownGeneration) {
|
|
166
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
167
|
-
}
|
|
168
|
-
try {
|
|
169
|
-
return await acquireDirectIdentityProfile({
|
|
170
|
-
...(opts.profileDir !== undefined ? { profileDir: opts.profileDir } : {}),
|
|
171
|
-
deadline: Date.now() + START_CAPACITY_WAIT_MS,
|
|
172
|
-
signal: waiter.abortController.signal,
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
catch (error) {
|
|
176
|
-
if (waiter.cancelRequested || waiter.generation !== shutdownGeneration) {
|
|
177
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
178
|
-
}
|
|
179
|
-
if (error instanceof OperatorDirectIdentityAcquisitionInterruptedError) {
|
|
180
|
-
throw error.reason === "cancelled"
|
|
181
|
-
? new Error("operate_start cancelled: operator server is shutting down")
|
|
182
|
-
: new Error("operate_start capacity reached: the Google-identity operator profile is in " +
|
|
183
|
-
"use by another session; finish it and retry");
|
|
184
|
-
}
|
|
185
|
-
throw error;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
finally {
|
|
189
|
-
capacityWaiters.delete(waiter);
|
|
190
|
-
waiter.resolveSettled();
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
async function acquireWarmBrowser(opts, sessionId) {
|
|
194
|
-
if ((process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0) {
|
|
195
|
-
throw new Error("operate_start does not support remote CDP with isolated profile leases");
|
|
196
|
-
}
|
|
197
|
-
const generation = shutdownGeneration;
|
|
198
|
-
const lease = opts.requireLiveIdentity === true
|
|
199
|
-
? await acquireDirectIdentityProfileBounded(opts, generation)
|
|
200
|
-
: await acquireOperatorProfileBounded(opts, sessionId, generation);
|
|
201
|
-
if (generation !== shutdownGeneration) {
|
|
202
|
-
await lease.destroy();
|
|
203
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
204
|
-
}
|
|
205
|
-
const controller = new BrowserController({
|
|
206
|
-
profileDir: lease.profileDir,
|
|
207
|
-
...("takeProfileOperationLease" in lease
|
|
208
|
-
? { profileOperationLease: lease.takeProfileOperationLease() }
|
|
209
|
-
: {}),
|
|
210
|
-
...(opts.proxyUrl !== undefined ? { proxyUrl: opts.proxyUrl } : {}),
|
|
211
|
-
});
|
|
212
|
-
const pending = {
|
|
213
|
-
controller,
|
|
214
|
-
lease,
|
|
215
|
-
launch: startBrowserBounded(controller, sessionId),
|
|
216
|
-
cancelRequested: false,
|
|
43
|
+
import { egressSeedHosts, hostStrings, registrableHost, serviceLoginRouteHosts, } from "./session/hosts.js";
|
|
44
|
+
// Phase 2 — the lifecycle registry transaction moved to session/lifecycle.ts as
|
|
45
|
+
// one unit (registry, real-profile lease, call leases and drains, watchdog,
|
|
46
|
+
// bounded close, terminal owner, artifact cleanup, start/finish/shutdown).
|
|
47
|
+
// Everything it owns is re-exported below, so no caller import changed.
|
|
48
|
+
import { activeSessionCount, audit, closeAllProvisionSessions, finishProvisionSession, finishProvisionSessionWithPreparation, googleSessionGate, observeSnapshotDir, paymentSession, sessionForCall, startProvisionSession as startProvisionSessionInternal, startHarnessProvisionSession as startHarnessProvisionSessionInternal, withPaymentSessionCall, withProvisionSessionCall, } from "./session/lifecycle.js";
|
|
49
|
+
export { activeSessionCount, closeAllProvisionSessions, finishProvisionSession, finishProvisionSessionWithPreparation, googleSessionGate, paymentSession, withPaymentSessionCall, withProvisionSessionCall, };
|
|
50
|
+
let oauthActionLeaseTail = Promise.resolve();
|
|
51
|
+
const DEFAULT_OAUTH_LOGIN_LEASE_COOLDOWN_MS = 3_000;
|
|
52
|
+
const DEFAULT_OAUTH_ACTION_TIMEOUT_MS = 30_000;
|
|
53
|
+
function oauthActionTimeoutMs() {
|
|
54
|
+
const configured = Number(process.env.TRUSTY_SQUIRE_OAUTH_ACTION_TIMEOUT_MS);
|
|
55
|
+
return Number.isFinite(configured) && configured > 0
|
|
56
|
+
? Math.min(configured, DEFAULT_OAUTH_ACTION_TIMEOUT_MS)
|
|
57
|
+
: DEFAULT_OAUTH_ACTION_TIMEOUT_MS;
|
|
58
|
+
}
|
|
59
|
+
function oauthActionDeadline(provider) {
|
|
60
|
+
const timeoutMs = oauthActionTimeoutMs();
|
|
61
|
+
return {
|
|
62
|
+
expiresAt: Date.now() + timeoutMs,
|
|
63
|
+
timeoutMs,
|
|
64
|
+
provider,
|
|
65
|
+
timedOut: false,
|
|
66
|
+
inFlight: new Set(),
|
|
217
67
|
};
|
|
218
|
-
startingBrowsers.add(pending);
|
|
219
|
-
try {
|
|
220
|
-
await pending.launch;
|
|
221
|
-
if (pending.cancelRequested) {
|
|
222
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
223
|
-
}
|
|
224
|
-
// Several unit harnesses replace BrowserController with the narrow legacy
|
|
225
|
-
// shape. Production controllers always expose identity; a harness without
|
|
226
|
-
// a real process simply leaves the descriptor worker-less.
|
|
227
|
-
if (typeof controller.operatorWorkerIdentity === "function") {
|
|
228
|
-
const worker = controller.operatorWorkerIdentity();
|
|
229
|
-
if (worker !== null)
|
|
230
|
-
lease.bindWorker(worker);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
catch (err) {
|
|
234
|
-
if (!pending.cancelRequested) {
|
|
235
|
-
await closeLeasedBrowser(controller, lease, false);
|
|
236
|
-
}
|
|
237
|
-
throw err;
|
|
238
|
-
}
|
|
239
|
-
finally {
|
|
240
|
-
startingBrowsers.delete(pending);
|
|
241
|
-
}
|
|
242
|
-
leasedBrowsers.set(controller, { controller, lease });
|
|
243
|
-
return { controller, profileDir: lease.profileDir };
|
|
244
68
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (slot === undefined) {
|
|
248
|
-
await browser.close().catch(() => undefined);
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
leasedBrowsers.delete(browser);
|
|
252
|
-
try {
|
|
253
|
-
if (reusable)
|
|
254
|
-
await browser.resetPageForReuse();
|
|
255
|
-
await closeLeasedBrowser(browser, slot.lease, reusable);
|
|
256
|
-
}
|
|
257
|
-
catch {
|
|
258
|
-
await closeLeasedBrowser(browser, slot.lease, false);
|
|
259
|
-
}
|
|
69
|
+
function oauthActionRemainingMs(deadline) {
|
|
70
|
+
return Math.max(0, deadline.expiresAt - Date.now());
|
|
260
71
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
72
|
+
function trackOAuthActionPromise(deadline, promise) {
|
|
73
|
+
deadline.inFlight.add(promise);
|
|
74
|
+
void promise.then(() => deadline.inFlight.delete(promise), () => deadline.inFlight.delete(promise));
|
|
75
|
+
return promise;
|
|
76
|
+
}
|
|
77
|
+
function expireOAuthAction(deadline) {
|
|
78
|
+
if (deadline.timedOut)
|
|
265
79
|
return;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
// approval per charge, not a software re-check of replay field values. The
|
|
277
|
-
// only surviving invariant — a card-charging trace step is never blind-
|
|
278
|
-
// replayed — is enforced unconditionally where operate_pay steps are
|
|
279
|
-
// encountered during replay (see replayOperatorRecipe), not here.
|
|
280
|
-
function assertPaymentSessionAllowed(session) {
|
|
281
|
-
if (session.closing) {
|
|
282
|
-
throw new Error(`provision session ${session.id} is closing`);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
// Resolve the compatibility omission once, at tool entry. In particular, do
|
|
286
|
-
// not repeat this lookup in completion callbacks: after an await, a different
|
|
287
|
-
// session could otherwise become the sole process-local session.
|
|
288
|
-
export function paymentSession(sessionId) {
|
|
289
|
-
let session;
|
|
290
|
-
if (sessionId !== undefined) {
|
|
291
|
-
session = sessionForCall(sessionId);
|
|
292
|
-
if (session === undefined)
|
|
293
|
-
throw new Error(`unknown provision session ${sessionId}`);
|
|
294
|
-
}
|
|
295
|
-
else {
|
|
296
|
-
if (sessions.size !== 1) {
|
|
297
|
-
throw new Error(sessions.size === 0
|
|
298
|
-
? "operate_pay requires one active operate_start browser session"
|
|
299
|
-
: "operate_pay requires session_id when multiple operator sessions are active");
|
|
300
|
-
}
|
|
301
|
-
session = sessions.values().next().value;
|
|
302
|
-
}
|
|
303
|
-
assertPaymentSessionAllowed(session);
|
|
304
|
-
return session;
|
|
305
|
-
}
|
|
306
|
-
function acquireSessionCallLease(session) {
|
|
307
|
-
if (session.closing)
|
|
308
|
-
throw new Error(`provision session ${session.id} is closing`);
|
|
309
|
-
session.callCount += 1;
|
|
310
|
-
let released = false;
|
|
311
|
-
return () => {
|
|
312
|
-
if (released)
|
|
313
|
-
return;
|
|
314
|
-
released = true;
|
|
315
|
-
session.callCount -= 1;
|
|
316
|
-
if (session.callCount === 0) {
|
|
317
|
-
for (const wake of session.callDrainWaiters)
|
|
318
|
-
wake();
|
|
319
|
-
session.callDrainWaiters.clear();
|
|
80
|
+
deadline.timedOut = true;
|
|
81
|
+
}
|
|
82
|
+
async function waitForOAuthActionQuiescence(deadline) {
|
|
83
|
+
for (;;) {
|
|
84
|
+
const pending = [...deadline.inFlight];
|
|
85
|
+
if (pending.length === 0) {
|
|
86
|
+
await Promise.resolve();
|
|
87
|
+
if (deadline.inFlight.size === 0)
|
|
88
|
+
return;
|
|
89
|
+
continue;
|
|
320
90
|
}
|
|
321
|
-
|
|
91
|
+
await Promise.allSettled(pending);
|
|
92
|
+
}
|
|
322
93
|
}
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
return;
|
|
326
|
-
|
|
94
|
+
function oauthActionDeadlineError(deadline) {
|
|
95
|
+
if (deadline.provider === undefined || deadline.provider === "google") {
|
|
96
|
+
return Object.assign(new Error(`google_session: OAuth did not complete within ${Math.ceil(deadline.timeoutMs / 1000)} seconds; ` +
|
|
97
|
+
"the saved session may have expired, so reconnect with " +
|
|
98
|
+
"`npx @trusty-squire/mcp connect --force-relogin=google` before retrying"), { code: "google_session" });
|
|
99
|
+
}
|
|
100
|
+
return new Error(`OAuth action did not complete within ${Math.ceil(deadline.timeoutMs / 1000)} seconds`);
|
|
327
101
|
}
|
|
328
|
-
async function
|
|
329
|
-
const
|
|
102
|
+
async function withinOAuthActionDeadline(promise, deadline) {
|
|
103
|
+
const tracked = trackOAuthActionPromise(deadline, promise);
|
|
104
|
+
const remainingMs = oauthActionRemainingMs(deadline);
|
|
105
|
+
if (remainingMs <= 0 || deadline.timedOut) {
|
|
106
|
+
expireOAuthAction(deadline);
|
|
107
|
+
throw oauthActionDeadlineError(deadline);
|
|
108
|
+
}
|
|
109
|
+
let timer;
|
|
330
110
|
try {
|
|
331
|
-
return await
|
|
111
|
+
return await Promise.race([
|
|
112
|
+
tracked,
|
|
113
|
+
new Promise((_resolve, reject) => {
|
|
114
|
+
timer = setTimeout(() => {
|
|
115
|
+
expireOAuthAction(deadline);
|
|
116
|
+
reject(oauthActionDeadlineError(deadline));
|
|
117
|
+
}, remainingMs);
|
|
118
|
+
}),
|
|
119
|
+
]);
|
|
332
120
|
}
|
|
333
121
|
finally {
|
|
334
|
-
|
|
122
|
+
if (timer !== undefined)
|
|
123
|
+
clearTimeout(timer);
|
|
335
124
|
}
|
|
336
125
|
}
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
export async function withPaymentSessionCall(sessionId, fn) {
|
|
344
|
-
const session = paymentSession(sessionId);
|
|
345
|
-
return await withSelectedProvisionSessionCall(session, fn);
|
|
126
|
+
function oauthLoginLeaseCooldownMs() {
|
|
127
|
+
const configured = Number(process.env.TRUSTY_SQUIRE_OAUTH_LOGIN_COOLDOWN_MS);
|
|
128
|
+
return Number.isFinite(configured) && configured >= 0
|
|
129
|
+
? Math.min(configured, 60_000)
|
|
130
|
+
: DEFAULT_OAUTH_LOGIN_LEASE_COOLDOWN_MS;
|
|
346
131
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
process.stderr.write(`${JSON.stringify({ marker: "provision-audit", surface: "operate", session_id: sessionId, event, ...detail })}\n`);
|
|
354
|
-
}
|
|
355
|
-
// operate_start's browser launch is the one UNBOUNDED step in the session
|
|
356
|
-
// bootstrap: on a fresh box the first launch downloads Chromium and spins up a
|
|
357
|
-
// virtual display (Xvfb), and a wedged profile lock or missing browser deps can
|
|
358
|
-
// otherwise hang it indefinitely — a real dogfood run sat on a silent ~30-min
|
|
359
|
-
// hang here with zero feedback (the worst first-run failure: the user assumes
|
|
360
|
-
// it's broken and never comes back). Cap it so a stuck launch fails LOUDLY with
|
|
361
|
-
// an actionable message. The default is generous (a cold Chromium download is
|
|
362
|
-
// legitimately multi-minute — better to wait than false-fail a slow-but-working
|
|
363
|
-
// launch); tune with BOT_START_TIMEOUT_MS. On timeout we close() the
|
|
364
|
-
// half-launched browser so a wedged Chrome can't keep the profile lock and brick
|
|
365
|
-
// the next attempt.
|
|
366
|
-
const START_TIMEOUT_MS = Number(process.env.BOT_START_TIMEOUT_MS) || 600_000;
|
|
367
|
-
async function startBrowserBounded(browser, sessionId) {
|
|
368
|
-
audit(sessionId, "browser_launch", {
|
|
369
|
-
note: "first launch may download Chromium + start Xvfb; slow but one-time",
|
|
370
|
-
timeout_ms: START_TIMEOUT_MS,
|
|
132
|
+
async function waitForOAuthLeaseCooldown(cooldownMs) {
|
|
133
|
+
if (cooldownMs <= 0)
|
|
134
|
+
return;
|
|
135
|
+
await new Promise((resolve) => {
|
|
136
|
+
const timer = setTimeout(resolve, cooldownMs);
|
|
137
|
+
timer.unref();
|
|
371
138
|
});
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
139
|
+
}
|
|
140
|
+
async function withOAuthActionLease(deadline, run, releaseCooldownMs = 0) {
|
|
141
|
+
let release;
|
|
142
|
+
const previous = oauthActionLeaseTail;
|
|
143
|
+
const turn = new Promise((resolve) => {
|
|
144
|
+
release = resolve;
|
|
375
145
|
});
|
|
146
|
+
oauthActionLeaseTail = previous.then(() => turn);
|
|
147
|
+
let acquired = false;
|
|
376
148
|
try {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
throw new Error(`operate_start: browser did not launch within ${Math.round(START_TIMEOUT_MS / 1000)}s. ` +
|
|
384
|
-
"On a fresh machine the first launch downloads Chromium and starts a virtual display " +
|
|
385
|
-
"(Xvfb) — slow but one-time. A hang this long usually means the browser binaries or Xvfb " +
|
|
386
|
-
"are missing on this box. Retry once (a partial download resumes and later launches reuse " +
|
|
387
|
-
"the cache); if it recurs, run `npx @trusty-squire/mcp connect` here to install the browser " +
|
|
388
|
-
"deps, or raise BOT_START_TIMEOUT_MS to wait longer.");
|
|
389
|
-
}
|
|
390
|
-
throw err;
|
|
149
|
+
if (deadline === undefined)
|
|
150
|
+
await previous;
|
|
151
|
+
else
|
|
152
|
+
await withinOAuthActionDeadline(previous, deadline);
|
|
153
|
+
acquired = true;
|
|
154
|
+
return await run();
|
|
391
155
|
}
|
|
392
156
|
finally {
|
|
393
|
-
if (
|
|
394
|
-
|
|
395
|
-
|
|
157
|
+
if (deadline === undefined) {
|
|
158
|
+
release();
|
|
159
|
+
}
|
|
160
|
+
else if (!acquired) {
|
|
161
|
+
void previous.then(release, release);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
void waitForOAuthActionQuiescence(deadline)
|
|
165
|
+
.then(() => waitForOAuthLeaseCooldown(releaseCooldownMs))
|
|
166
|
+
.then(release, release);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function withOAuthActionBoundary(session, provider, run) {
|
|
171
|
+
const deadline = oauthActionDeadline(provider);
|
|
172
|
+
const releaseCooldownMs = oauthLoginLeaseCooldownMs();
|
|
173
|
+
// A deadline expiry must NOT terminalize the session: the in-flight OAuth
|
|
174
|
+
// operation is budget-bounded (loginWithOAuth races its own completion
|
|
175
|
+
// window), so unwinding needs no teardown. Force-closing here used to
|
|
176
|
+
// deregister a healthy session mid-handoff, leaving the pending
|
|
177
|
+
// chooser/consent screen unreachable — observe/screenshot/oauth_settle all
|
|
178
|
+
// returned "unknown provision session" and the only recovery was a fresh
|
|
179
|
+
// session that lost all progress. The timeout surfaces as a recoverable
|
|
180
|
+
// error while the session, browser, and page stay valid for inspection and
|
|
181
|
+
// a settle/retry. Serialization custody is unchanged: the lease below still
|
|
182
|
+
// drains the in-flight work (plus the cooldown) before the next OAuth
|
|
183
|
+
// action acquires it, and a genuinely dead browser still surfaces as
|
|
184
|
+
// unavailable through its own close paths.
|
|
185
|
+
return await withOAuthActionLease(deadline, async () => {
|
|
186
|
+
return await withinOAuthActionDeadline(run(deadline), deadline);
|
|
187
|
+
}, releaseCooldownMs);
|
|
188
|
+
}
|
|
189
|
+
async function runSerializedGoogleIdentityOperation(session, operation, options = {}) {
|
|
190
|
+
const browser = session.browser;
|
|
191
|
+
const result = options.deadline === undefined
|
|
192
|
+
? await operation(browser)
|
|
193
|
+
: await withinOAuthActionDeadline(operation(browser), options.deadline);
|
|
194
|
+
return { browser, result };
|
|
195
|
+
}
|
|
196
|
+
async function runDetachedGoogleIdentityOperation(session, operation) {
|
|
197
|
+
return await withOAuthActionLease(undefined, async () => (await runSerializedGoogleIdentityOperation(session, operation)).result);
|
|
198
|
+
}
|
|
199
|
+
async function runSerializedOAuthBoundary(session, authorizedElement, authorizedElements, provider, deadline) {
|
|
200
|
+
const authorizedRef = provisionElementRefs(authorizedElements).get(authorizedElement);
|
|
201
|
+
if (authorizedRef === undefined) {
|
|
202
|
+
throw new Error("OAuth action target was not present in the authorized action map");
|
|
203
|
+
}
|
|
204
|
+
const expectedGoogleAccountEmail = session.userEmail ?? undefined;
|
|
205
|
+
const completed = await runSerializedGoogleIdentityOperation(session, async (browser) => {
|
|
206
|
+
// The action was authorized against the session's isolated browser before
|
|
207
|
+
// the canonical profile was opened. Re-extract inside the same serialized
|
|
208
|
+
// operation and require the identical structural element identity before
|
|
209
|
+
// clicking; this keeps stale-handle protection without letting a prepared
|
|
210
|
+
// canonical browser escape the boundary on reobserve_required.
|
|
211
|
+
const fresh = await browser.extractInteractiveElements();
|
|
212
|
+
retainSessionElements(session, fresh);
|
|
213
|
+
const resolved = resolveTarget(fresh, authorizedRef);
|
|
214
|
+
if (resolved === null) {
|
|
215
|
+
throw new Error("OAuth action target changed during the identity handoff; re-observe before retrying");
|
|
216
|
+
}
|
|
217
|
+
await browser.loginWithOAuth(resolved.selector, oauthActionRemainingMs(deadline), provider, provider === "github" ? undefined : expectedGoogleAccountEmail);
|
|
218
|
+
await settleAfterStateChange(browser);
|
|
219
|
+
}, { deadline });
|
|
220
|
+
return completed.browser;
|
|
396
221
|
}
|
|
222
|
+
const settle = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
397
223
|
// ── pure helpers (exported for unit tests) ──
|
|
398
224
|
const norm = (s) => (s ?? "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
399
225
|
// Element ref = a STABLE-by-default handle: "@e:<identity>_<ordinal>". For a
|
|
@@ -439,7 +265,7 @@ function baseIdentityFields(el) {
|
|
|
439
265
|
el.container ?? "",
|
|
440
266
|
el.role ?? "",
|
|
441
267
|
el.tag,
|
|
442
|
-
elementRef(el),
|
|
268
|
+
elementRef({ ...el, value: null }),
|
|
443
269
|
el.href ?? "",
|
|
444
270
|
el.type ?? "",
|
|
445
271
|
// Frame origin — WITHOUT this, an element's `selector` (folded into
|
|
@@ -668,6 +494,21 @@ export class TargetStaleError extends Error {
|
|
|
668
494
|
this.result = result;
|
|
669
495
|
}
|
|
670
496
|
}
|
|
497
|
+
class CompactV2StaleRefError extends Error {
|
|
498
|
+
}
|
|
499
|
+
class ProvisionTargetNotAllowedError extends Error {
|
|
500
|
+
}
|
|
501
|
+
class ProvisionTargetMissingError extends Error {
|
|
502
|
+
}
|
|
503
|
+
class CompactV2ActionFailureError extends Error {
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* A `@label` that names more than one observed control. Extends the
|
|
507
|
+
* already-sealed failure channel so its message survives V2's opaque error
|
|
508
|
+
* mapping: it carries ONLY refs the agent already holds, never page text.
|
|
509
|
+
*/
|
|
510
|
+
class CompactV2AmbiguousLabelError extends CompactV2ActionFailureError {
|
|
511
|
+
}
|
|
671
512
|
function replacementCandidates(elements) {
|
|
672
513
|
const refs = provisionElementRefs(elements);
|
|
673
514
|
const candidates = {};
|
|
@@ -713,174 +554,18 @@ function elementTargetKeys(el) {
|
|
|
713
554
|
return v.length > 0 ? [v] : [];
|
|
714
555
|
});
|
|
715
556
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
]);
|
|
724
|
-
function isPendingCardFilledField(el) {
|
|
557
|
+
// Shopify defers address geocoding (and therefore delivery-rate loading) until
|
|
558
|
+
// its required shipping street field is committed. Keep this deliberately
|
|
559
|
+
// narrow: ordinary text fields and even other autocomplete controls retain
|
|
560
|
+
// their existing type-only behavior.
|
|
561
|
+
function isRequiredShippingAddressLine1(el) {
|
|
562
|
+
if (!el.required)
|
|
563
|
+
return false;
|
|
725
564
|
const autocomplete = (el.autocomplete ?? "")
|
|
726
565
|
.toLowerCase()
|
|
727
566
|
.split(/\s+/)
|
|
728
567
|
.filter((token) => token.length > 0);
|
|
729
|
-
|
|
730
|
-
return true;
|
|
731
|
-
if (autocomplete.includes("billing") &&
|
|
732
|
-
autocomplete.some((token) => [
|
|
733
|
-
"address-line1",
|
|
734
|
-
"address-line2",
|
|
735
|
-
"address-level1",
|
|
736
|
-
"address-level2",
|
|
737
|
-
"postal-code",
|
|
738
|
-
"country",
|
|
739
|
-
].includes(token))) {
|
|
740
|
-
return true;
|
|
741
|
-
}
|
|
742
|
-
const name = (el.name ?? "").toLowerCase();
|
|
743
|
-
const id = (el.id ?? "").toLowerCase();
|
|
744
|
-
const ariaLabel = (el.ariaLabel ?? "").toLowerCase();
|
|
745
|
-
const placeholder = (el.placeholder ?? "").toLowerCase();
|
|
746
|
-
return (name.includes("cardnumber") ||
|
|
747
|
-
id.includes("card-number") ||
|
|
748
|
-
id.includes("cardnumber") ||
|
|
749
|
-
name.includes("cardholder") ||
|
|
750
|
-
name.includes("card-name") ||
|
|
751
|
-
id.includes("cardholder") ||
|
|
752
|
-
name.includes("cvv") ||
|
|
753
|
-
name.includes("cvc") ||
|
|
754
|
-
name.includes("security-code") ||
|
|
755
|
-
id.includes("cvv") ||
|
|
756
|
-
id.includes("cvc") ||
|
|
757
|
-
((name.includes("exp") || id.includes("exp")) &&
|
|
758
|
-
(name.includes("month") ||
|
|
759
|
-
name.includes("year") ||
|
|
760
|
-
name.includes("date") ||
|
|
761
|
-
name.includes("expir") ||
|
|
762
|
-
id.includes("month") ||
|
|
763
|
-
id.includes("year") ||
|
|
764
|
-
id.includes("date") ||
|
|
765
|
-
id.includes("expir") ||
|
|
766
|
-
name === "exp" ||
|
|
767
|
-
id === "exp")) ||
|
|
768
|
-
placeholder.replace(/\s+/g, "") === "mm/yy" ||
|
|
769
|
-
ariaLabel.replace(/\s+/g, "") === "mm/yy" ||
|
|
770
|
-
((name.includes("billing") || id.includes("billing")) &&
|
|
771
|
-
/address|line1|line2|city|locality|state|region|postal|zip|country/.test(`${name} ${id}`)));
|
|
772
|
-
}
|
|
773
|
-
function pendingCardSecretKind(el) {
|
|
774
|
-
const autocomplete = (el.autocomplete ?? "").toLowerCase().split(/\s+/);
|
|
775
|
-
const signal = `${el.name ?? ""} ${el.id ?? ""}`.toLowerCase();
|
|
776
|
-
if (autocomplete.includes("cc-number") ||
|
|
777
|
-
signal.includes("cardnumber") ||
|
|
778
|
-
signal.includes("card-number")) {
|
|
779
|
-
return "pan";
|
|
780
|
-
}
|
|
781
|
-
if (autocomplete.includes("cc-csc") ||
|
|
782
|
-
signal.includes("cvv") ||
|
|
783
|
-
signal.includes("cvc") ||
|
|
784
|
-
signal.includes("security-code")) {
|
|
785
|
-
return "cvv";
|
|
786
|
-
}
|
|
787
|
-
return null;
|
|
788
|
-
}
|
|
789
|
-
function redactPaymentObservationText(text, elements, active) {
|
|
790
|
-
if (!active)
|
|
791
|
-
return text;
|
|
792
|
-
let redacted = text;
|
|
793
|
-
for (const element of elements) {
|
|
794
|
-
const kind = pendingCardSecretKind(element);
|
|
795
|
-
const value = element.value?.trim() ?? "";
|
|
796
|
-
if (kind === null || value.length === 0)
|
|
797
|
-
continue;
|
|
798
|
-
if (kind === "pan") {
|
|
799
|
-
redacted = redactExactDigitSequence(redacted, value.replace(/\D/g, ""));
|
|
800
|
-
}
|
|
801
|
-
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
802
|
-
redacted = redacted.replace(kind === "cvv" ? new RegExp(`\\b${escaped}\\b`, "g") : new RegExp(escaped, "g"), "[sealed payment]");
|
|
803
|
-
}
|
|
804
|
-
redacted = redactLuhnPanSpans(redacted);
|
|
805
|
-
return redacted.replace(/\b(cvv|cvc|security\s+code)\s*[:#-]?\s*\d{3,4}\b/gi, "$1 [sealed payment]");
|
|
806
|
-
}
|
|
807
|
-
function presentPaymentSafeString(value, paymentSealActive) {
|
|
808
|
-
return paymentSealActive ? redactPaymentObservationText(value, [], true) : value;
|
|
809
|
-
}
|
|
810
|
-
const PAYMENT_PAN_MAX_SPAN_CHARS = 96;
|
|
811
|
-
function redactExactDigitSequence(text, expectedDigits) {
|
|
812
|
-
if (expectedDigits.length < 13)
|
|
813
|
-
return text;
|
|
814
|
-
const digitMatches = Array.from(text.matchAll(/\d/g));
|
|
815
|
-
const replacements = [];
|
|
816
|
-
for (let start = 0; start + expectedDigits.length <= digitMatches.length; start += 1) {
|
|
817
|
-
const matches = digitMatches.slice(start, start + expectedDigits.length);
|
|
818
|
-
if (matches.map((match) => match[0]).join("") !== expectedDigits)
|
|
819
|
-
continue;
|
|
820
|
-
if (matches[matches.length - 1].index - matches[0].index + 1 > PAYMENT_PAN_MAX_SPAN_CHARS) {
|
|
821
|
-
continue;
|
|
822
|
-
}
|
|
823
|
-
replacements.push({
|
|
824
|
-
start: matches[0].index,
|
|
825
|
-
end: matches[matches.length - 1].index + 1,
|
|
826
|
-
});
|
|
827
|
-
start += expectedDigits.length - 1;
|
|
828
|
-
}
|
|
829
|
-
if (replacements.length === 0)
|
|
830
|
-
return text;
|
|
831
|
-
let cursor = 0;
|
|
832
|
-
let result = "";
|
|
833
|
-
for (const replacement of replacements) {
|
|
834
|
-
result += `${text.slice(cursor, replacement.start)}[sealed payment]`;
|
|
835
|
-
cursor = replacement.end;
|
|
836
|
-
}
|
|
837
|
-
return result + text.slice(cursor);
|
|
838
|
-
}
|
|
839
|
-
function redactLuhnPanSpans(text) {
|
|
840
|
-
const digitPositions = Array.from(text.matchAll(/\d/g), (match) => match.index);
|
|
841
|
-
const replacements = [];
|
|
842
|
-
let startDigit = 0;
|
|
843
|
-
while (startDigit + 13 <= digitPositions.length) {
|
|
844
|
-
let matchedDigits = 0;
|
|
845
|
-
const maxDigits = Math.min(19, digitPositions.length - startDigit);
|
|
846
|
-
for (let length = maxDigits; length >= 13; length -= 1) {
|
|
847
|
-
const positions = digitPositions.slice(startDigit, startDigit + length);
|
|
848
|
-
if (positions[positions.length - 1] - positions[0] + 1 > PAYMENT_PAN_MAX_SPAN_CHARS) {
|
|
849
|
-
continue;
|
|
850
|
-
}
|
|
851
|
-
const digits = positions.map((position) => text[position]).join("");
|
|
852
|
-
if (passesLuhn(digits)) {
|
|
853
|
-
matchedDigits = length;
|
|
854
|
-
replacements.push({
|
|
855
|
-
start: positions[0],
|
|
856
|
-
end: positions[positions.length - 1] + 1,
|
|
857
|
-
});
|
|
858
|
-
break;
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
startDigit += matchedDigits || 1;
|
|
862
|
-
}
|
|
863
|
-
if (replacements.length === 0)
|
|
864
|
-
return text;
|
|
865
|
-
let cursor = 0;
|
|
866
|
-
let result = "";
|
|
867
|
-
for (const replacement of replacements) {
|
|
868
|
-
result += `${text.slice(cursor, replacement.start)}[sealed payment]`;
|
|
869
|
-
cursor = replacement.end;
|
|
870
|
-
}
|
|
871
|
-
return result + text.slice(cursor);
|
|
872
|
-
}
|
|
873
|
-
function observationSealedFieldKeys(session, elements) {
|
|
874
|
-
if (!session.paymentFieldSealActive)
|
|
875
|
-
return session.sealedFieldKeys;
|
|
876
|
-
const sealed = new Set(session.sealedFieldKeys);
|
|
877
|
-
for (const el of elements) {
|
|
878
|
-
if (!isPendingCardFilledField(el))
|
|
879
|
-
continue;
|
|
880
|
-
for (const key of elementTargetKeys(el))
|
|
881
|
-
sealed.add(key);
|
|
882
|
-
}
|
|
883
|
-
return sealed;
|
|
568
|
+
return autocomplete.includes("shipping") && autocomplete.includes("address-line1");
|
|
884
569
|
}
|
|
885
570
|
// Resolve a host-supplied target string to one live element. Matching is by
|
|
886
571
|
// structured path, test id, or label text, scored exact > startsWith > contains.
|
|
@@ -954,6 +639,104 @@ export function resolveTarget(elements, target) {
|
|
|
954
639
|
}
|
|
955
640
|
return best?.el ?? null;
|
|
956
641
|
}
|
|
642
|
+
function invalidateCompactV2Snapshot(session) {
|
|
643
|
+
session.compactV2Refs = new Map();
|
|
644
|
+
session.compactV2Index = null;
|
|
645
|
+
session.compactV2Previous = null;
|
|
646
|
+
}
|
|
647
|
+
function throwCompactV2StaleRef() {
|
|
648
|
+
// Deliberately opaque: stale V2 errors must not construct V1 replacement
|
|
649
|
+
// candidates or reveal raw labels/legacy identities outside the safe view.
|
|
650
|
+
throw new CompactV2StaleRefError("stale_ref");
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* The parts of an observed row that carry what the agent CHOSE the control for.
|
|
654
|
+
* `state`, `visibility` and `choice` legitimately churn on a live form and are
|
|
655
|
+
* excluded, so a ref stays actionable across a re-render; role, intent, field
|
|
656
|
+
* class, frame and label are not — a control whose label went from "Continue"
|
|
657
|
+
* to "Delete account" is no longer the control the agent reasoned about, even
|
|
658
|
+
* though its fingerprint (its DOM id) never moved.
|
|
659
|
+
*/
|
|
660
|
+
function sameCompactV2Intent(left, right) {
|
|
661
|
+
return (left.role === right.role &&
|
|
662
|
+
left.action === right.action &&
|
|
663
|
+
left.field === right.field &&
|
|
664
|
+
left.frame === right.frame &&
|
|
665
|
+
left.label === right.label);
|
|
666
|
+
}
|
|
667
|
+
/** Row equality over the sealed representation only — never raw page data. */
|
|
668
|
+
function sameCompactV2Control(left, right) {
|
|
669
|
+
return (left.role === right.role &&
|
|
670
|
+
left.state === right.state &&
|
|
671
|
+
left.visibility === right.visibility &&
|
|
672
|
+
left.action === right.action &&
|
|
673
|
+
left.field === right.field &&
|
|
674
|
+
left.label === right.label &&
|
|
675
|
+
left.choice === right.choice &&
|
|
676
|
+
left.frame === right.frame);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Authorize an agent-supplied target against the observed skeleton. Two forms:
|
|
680
|
+
* a `@e:` handle (the durable fingerprint) or a `@label` alias, which resolves
|
|
681
|
+
* to exactly one observed handle or fails — never a guess. Only the epoch's
|
|
682
|
+
* `doc` gates here; a benign re-render since the observation is expected and is
|
|
683
|
+
* settled at act time against live elements.
|
|
684
|
+
*/
|
|
685
|
+
function compactV2AuthorizationForTarget(session, target) {
|
|
686
|
+
const index = session.compactV2Index;
|
|
687
|
+
if (index === null)
|
|
688
|
+
throwCompactV2StaleRef();
|
|
689
|
+
if (index.expiresAt < Date.now() || index.epoch.doc !== compactV2EpochDoc(session)) {
|
|
690
|
+
invalidateCompactV2Snapshot(session);
|
|
691
|
+
throwCompactV2StaleRef();
|
|
692
|
+
}
|
|
693
|
+
const row = isCompactV2Label(target)
|
|
694
|
+
? resolveCompactV2Label(index.rows, target)
|
|
695
|
+
: index.rows.find((candidate) => candidate.ref === target);
|
|
696
|
+
if (row === undefined)
|
|
697
|
+
throwCompactV2StaleRef();
|
|
698
|
+
const legacy = compactV2LegacyRefForHandle(session.compactV2Refs, row.ref);
|
|
699
|
+
if (legacy === null)
|
|
700
|
+
throwCompactV2StaleRef();
|
|
701
|
+
return { legacyRef: legacy, row };
|
|
702
|
+
}
|
|
703
|
+
/** A label acts only when it names exactly one observed control. */
|
|
704
|
+
function resolveCompactV2Label(rows, label) {
|
|
705
|
+
const matches = rows.filter((row) => row.label === label);
|
|
706
|
+
if (matches.length > 1) {
|
|
707
|
+
throw new CompactV2AmbiguousLabelError(`ambiguous_target: "${label}" names ${matches.length} controls. ` +
|
|
708
|
+
`Retry with one exact ref: ${matches.map((row) => row.ref).join(", ")}`);
|
|
709
|
+
}
|
|
710
|
+
return matches[0];
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Re-resolve an authorized ref against LIVE elements. The handle is minted from
|
|
714
|
+
* the element's durable fingerprint under the current document epoch, so this
|
|
715
|
+
* survives a re-render between two acts (the whole point of the identity model)
|
|
716
|
+
* while a replaced document, a removed control, or a control that changed role
|
|
717
|
+
* under the same fingerprint all fail closed.
|
|
718
|
+
*/
|
|
719
|
+
function resolveAuthorizedCompactV2Target(session, elements, authorization) {
|
|
720
|
+
const index = session.compactV2Index;
|
|
721
|
+
if (index === null || index.epoch.doc !== compactV2EpochDoc(session)) {
|
|
722
|
+
invalidateCompactV2Snapshot(session);
|
|
723
|
+
throwCompactV2StaleRef();
|
|
724
|
+
}
|
|
725
|
+
const live = compactV2LiveControls(session, elements);
|
|
726
|
+
const matches = live.rows.filter((row) => row.ref === authorization.row.ref);
|
|
727
|
+
// Fingerprints are unique within an inventory by construction, so >1 means a
|
|
728
|
+
// broken invariant rather than an addressable ambiguity: refuse either way.
|
|
729
|
+
if (matches.length !== 1)
|
|
730
|
+
throwCompactV2StaleRef();
|
|
731
|
+
const liveRow = matches[0];
|
|
732
|
+
if (!sameCompactV2Intent(liveRow, authorization.row))
|
|
733
|
+
throwCompactV2StaleRef();
|
|
734
|
+
const legacy = live.byRef.get(liveRow.ref);
|
|
735
|
+
const resolved = legacy === undefined ? null : resolveTarget(elements, legacy);
|
|
736
|
+
if (resolved === null)
|
|
737
|
+
throwCompactV2StaleRef();
|
|
738
|
+
return resolved;
|
|
739
|
+
}
|
|
957
740
|
// Squire's OWN control plane. The operator browser runs in the connect-seeded
|
|
958
741
|
// profile, so it is authenticated as the user (a live Google session). It must
|
|
959
742
|
// therefore NEVER be allowed to reach Squire's own web app / API: otherwise a
|
|
@@ -975,8 +758,9 @@ export function isSquireControlPlaneHost(host) {
|
|
|
975
758
|
return SQUIRE_CONTROL_PLANE_HOSTS.some((d) => h === d || h.endsWith(`.${d}`));
|
|
976
759
|
}
|
|
977
760
|
// Domain-scope check for an agent-initiated goto. Allows the target host, any
|
|
978
|
-
// subdomain of it,
|
|
979
|
-
// *.web.app auth handlers. Organic redirects
|
|
761
|
+
// subdomain of it, exact service-specific login routes, the configured auth
|
|
762
|
+
// hosts, and *.firebaseapp.com / *.web.app auth handlers. Organic redirects
|
|
763
|
+
// are NOT routed through here.
|
|
980
764
|
export function hostAllowed(url, allowedHosts) {
|
|
981
765
|
let host;
|
|
982
766
|
try {
|
|
@@ -992,6 +776,8 @@ export function hostAllowed(url, allowedHosts) {
|
|
|
992
776
|
const ok = (allowed) => host === allowed || host.endsWith(`.${allowed}`);
|
|
993
777
|
if (allowedHosts.some(ok))
|
|
994
778
|
return true;
|
|
779
|
+
if (serviceLoginRouteHosts(allowedHosts).includes(host))
|
|
780
|
+
return true;
|
|
995
781
|
if (DEFAULT_AUTH_HOSTS.some(ok))
|
|
996
782
|
return true;
|
|
997
783
|
if (host.endsWith(".firebaseapp.com") || host.endsWith(".web.app"))
|
|
@@ -1002,7 +788,7 @@ function frameTargetFor(el) {
|
|
|
1002
788
|
if (el.framePath === undefined || el.framePath === null)
|
|
1003
789
|
return null;
|
|
1004
790
|
if (el.frameOrigin === undefined || el.frameOrigin === null) {
|
|
1005
|
-
throw new
|
|
791
|
+
throw new ProvisionTargetNotAllowedError("frame target lacks an origin");
|
|
1006
792
|
}
|
|
1007
793
|
return {
|
|
1008
794
|
framePath: el.framePath,
|
|
@@ -1029,12 +815,12 @@ function assertFrameTargetAllowed(session, el, kind) {
|
|
|
1029
815
|
// message below suggests allow_host, which can never succeed for a null
|
|
1030
816
|
// origin — a remedy the model would loop on forever.
|
|
1031
817
|
if (el.frameOpaque === true || el.frameOrigin === "null") {
|
|
1032
|
-
throw new
|
|
818
|
+
throw new ProvisionTargetNotAllowedError(`${kind} refused: the target lives in an opaque (null-origin) frame — a sandboxed ` +
|
|
1033
819
|
`iframe without allow-same-origin, or an unconfirmed about:blank/srcdoc document. ` +
|
|
1034
820
|
`No host declaration can ever permit a null origin; this control is not reachable ` +
|
|
1035
821
|
`through operate_act. Drive the page's own controls instead.`);
|
|
1036
822
|
}
|
|
1037
|
-
throw new
|
|
823
|
+
throw new ProvisionTargetNotAllowedError(`${kind} blocked by domain-scope: the target lives in a cross-domain frame ` +
|
|
1038
824
|
`(${el.frameOrigin ?? el.frameUrl}) outside the allowed hosts ` +
|
|
1039
825
|
`[${hostStrings(session).join(", ")}] + auth providers. ` +
|
|
1040
826
|
`Declare it first with an allow_host action if this task spans it.`);
|
|
@@ -1050,13 +836,13 @@ function assertSecretFrameTargetAllowed(session, el) {
|
|
|
1050
836
|
if (target === null)
|
|
1051
837
|
return;
|
|
1052
838
|
if (target.frameOpaque === true) {
|
|
1053
|
-
throw new
|
|
839
|
+
throw new ProvisionTargetNotAllowedError("type_secret refused: the target lives in an opaque frame. Secrets may only be " +
|
|
1054
840
|
"typed into the main frame or a frame on the page's own domain.");
|
|
1055
841
|
}
|
|
1056
842
|
const pageUrl = session.browser.currentUrl();
|
|
1057
843
|
if (isSameRecipeDomain(target.frameOrigin, pageUrl))
|
|
1058
844
|
return;
|
|
1059
|
-
throw new
|
|
845
|
+
throw new ProvisionTargetNotAllowedError(`type_secret refused: the target lives in a cross-domain frame ` +
|
|
1060
846
|
`(${target.frameOrigin}), not the page's own domain. Secrets may only be ` +
|
|
1061
847
|
`typed into the main frame or a frame on the page's own domain.`);
|
|
1062
848
|
}
|
|
@@ -1071,7 +857,7 @@ function assertSecretFrameTargetAllowed(session, el) {
|
|
|
1071
857
|
function assertNoFrameTarget(el, kind) {
|
|
1072
858
|
if (el.framePath === undefined || el.framePath === null)
|
|
1073
859
|
return;
|
|
1074
|
-
throw new
|
|
860
|
+
throw new ProvisionTargetNotAllowedError(`operate_act kind="${kind}" does not yet support a target inside an <iframe> ` +
|
|
1075
861
|
`(frame ${el.frameOrigin ?? "unknown"}). Use click/js_click/type/type_secret/select ` +
|
|
1076
862
|
`for frame targets.`);
|
|
1077
863
|
}
|
|
@@ -1414,7 +1200,7 @@ export function manualCardEntryBlockReason(text) {
|
|
|
1414
1200
|
}
|
|
1415
1201
|
return null;
|
|
1416
1202
|
}
|
|
1417
|
-
export function buildScreenOutline(elements, pageText
|
|
1203
|
+
export function buildScreenOutline(elements, pageText) {
|
|
1418
1204
|
if (elements.length === 0)
|
|
1419
1205
|
return undefined;
|
|
1420
1206
|
const byRegion = new Map();
|
|
@@ -1423,8 +1209,8 @@ export function buildScreenOutline(elements, pageText, sealedFieldKeys = new Set
|
|
|
1423
1209
|
const role = id.split(":")[0] ?? "region";
|
|
1424
1210
|
const existing = byRegion.get(id);
|
|
1425
1211
|
const region = existing ?? {
|
|
1426
|
-
id:
|
|
1427
|
-
role:
|
|
1212
|
+
id: id,
|
|
1213
|
+
role: role,
|
|
1428
1214
|
topmost: false,
|
|
1429
1215
|
occluded_by: null,
|
|
1430
1216
|
children: [],
|
|
@@ -1436,22 +1222,24 @@ export function buildScreenOutline(elements, pageText, sealedFieldKeys = new Set
|
|
|
1436
1222
|
else if (region.occluded_by === null &&
|
|
1437
1223
|
el.occludedBy !== null &&
|
|
1438
1224
|
el.occludedBy !== undefined) {
|
|
1439
|
-
region.occluded_by =
|
|
1225
|
+
region.occluded_by = el.occludedBy;
|
|
1440
1226
|
}
|
|
1441
1227
|
if (region.children.length < 10) {
|
|
1442
1228
|
region.children.push({
|
|
1443
1229
|
ref: el.screenPath !== null && el.screenPath !== undefined
|
|
1444
|
-
?
|
|
1445
|
-
:
|
|
1446
|
-
role: el.role === null
|
|
1447
|
-
|
|
1230
|
+
? el.screenPath
|
|
1231
|
+
: elementRef(el),
|
|
1232
|
+
role: el.role === null
|
|
1233
|
+
? null
|
|
1234
|
+
: el.role,
|
|
1235
|
+
text: elementRef(el),
|
|
1448
1236
|
href: el.href === null || el.href === undefined
|
|
1449
1237
|
? null
|
|
1450
|
-
:
|
|
1238
|
+
: el.href,
|
|
1451
1239
|
topmost: el.topmost ?? null,
|
|
1452
1240
|
occluded_by: el.occludedBy === null || el.occludedBy === undefined
|
|
1453
1241
|
? null
|
|
1454
|
-
:
|
|
1242
|
+
: el.occludedBy,
|
|
1455
1243
|
});
|
|
1456
1244
|
}
|
|
1457
1245
|
byRegion.set(id, region);
|
|
@@ -1475,44 +1263,7 @@ function roleForAccessibility(el) {
|
|
|
1475
1263
|
return el.type ?? "textbox";
|
|
1476
1264
|
return el.tag;
|
|
1477
1265
|
}
|
|
1478
|
-
|
|
1479
|
-
// live DOM, so after a type_secret the cleartext password (or any sealed slot)
|
|
1480
|
-
// would surface in the observation/accessibility tree the planner sees and logs.
|
|
1481
|
-
// The whole point of prepare_login/extract is that the host only ever holds a
|
|
1482
|
-
// MASKED handle. Mask the presented copy here; internal callers (form-fill,
|
|
1483
|
-
// replay, postcondition length checks) read the raw InteractiveElement and are
|
|
1484
|
-
// unaffected. A field is sealed if it's a password input or a target a secret
|
|
1485
|
-
// slot was typed into (tracked per-session in sealedFieldKeys).
|
|
1486
|
-
const SEALED_FIELD_PLACEHOLDER = "[sealed]";
|
|
1487
|
-
function isSealedFieldValue(el, sealed) {
|
|
1488
|
-
if (el.sealed === true)
|
|
1489
|
-
return true;
|
|
1490
|
-
if ((el.type ?? "").toLowerCase() === "password")
|
|
1491
|
-
return true;
|
|
1492
|
-
if ((el.sealedIdentityKeys ?? []).some((key) => sealed.has(key)))
|
|
1493
|
-
return true;
|
|
1494
|
-
return elementTargetKeys(el).some((k) => sealed.has(k));
|
|
1495
|
-
}
|
|
1496
|
-
function presentFieldValue(el, sealed, paymentSealActive = false) {
|
|
1497
|
-
const v = el.value ?? null;
|
|
1498
|
-
if (v === null || v.length === 0)
|
|
1499
|
-
return v;
|
|
1500
|
-
return isSealedFieldValue(el, sealed)
|
|
1501
|
-
? SEALED_FIELD_PLACEHOLDER
|
|
1502
|
-
: presentPaymentSafeString(v, paymentSealActive);
|
|
1503
|
-
}
|
|
1504
|
-
// The host-facing LABEL. elementRef falls back to a field's VALUE when it has no
|
|
1505
|
-
// other label text — which would leak a sealed secret as the element's name. For
|
|
1506
|
-
// a sealed field, re-derive the label with the value stripped so it lands on the
|
|
1507
|
-
// next signal (placeholder/name) or `tag#index`, never the secret. Ref-keying
|
|
1508
|
-
// and targeting still use the raw elementRef, so resolution is unaffected.
|
|
1509
|
-
function presentLabel(el, sealed, paymentSealActive = false) {
|
|
1510
|
-
const label = isSealedFieldValue(el, sealed)
|
|
1511
|
-
? elementRef({ ...el, value: null })
|
|
1512
|
-
: elementRef(el);
|
|
1513
|
-
return presentPaymentSafeString(label, paymentSealActive);
|
|
1514
|
-
}
|
|
1515
|
-
export function buildAccessibilitySnapshot(elements, limit = 12000, sealedFieldKeys = new Set(), paymentSealActive = false) {
|
|
1266
|
+
export function buildAccessibilitySnapshot(elements, limit = 12000) {
|
|
1516
1267
|
if (elements.length === 0)
|
|
1517
1268
|
return undefined;
|
|
1518
1269
|
const refs = provisionElementRefs(elements);
|
|
@@ -1527,21 +1278,21 @@ export function buildAccessibilitySnapshot(elements, limit = 12000, sealedFieldK
|
|
|
1527
1278
|
const structurallyTruncated = entries.length > 24 || entries.some(([, group]) => group.length > 16);
|
|
1528
1279
|
const lines = ["RootWebArea"];
|
|
1529
1280
|
for (const [region, group] of entries.slice(0, 24)) {
|
|
1530
|
-
lines.push(` region "${
|
|
1281
|
+
lines.push(` region "${region}"`);
|
|
1531
1282
|
for (const el of group.slice(0, 16)) {
|
|
1532
|
-
const label =
|
|
1533
|
-
const role =
|
|
1534
|
-
const shownValue =
|
|
1283
|
+
const label = elementRef(el).replace(/"/g, '\\"');
|
|
1284
|
+
const role = roleForAccessibility(el);
|
|
1285
|
+
const shownValue = (el.value ?? null);
|
|
1535
1286
|
const flags = [
|
|
1536
1287
|
el.value !== undefined && el.value !== null
|
|
1537
1288
|
? `value="${(shownValue ?? "").slice(0, 60)}"`
|
|
1538
1289
|
: null,
|
|
1539
1290
|
el.checked !== undefined && el.checked !== null ? `checked=${el.checked}` : null,
|
|
1540
1291
|
el.href !== undefined && el.href !== null
|
|
1541
|
-
? `href="${
|
|
1292
|
+
? `href="${el.href.slice(0, 120)}"`
|
|
1542
1293
|
: null,
|
|
1543
1294
|
el.topmost === false
|
|
1544
|
-
? `occluded_by="${
|
|
1295
|
+
? `occluded_by="${el.occludedBy ?? "unknown"}"`
|
|
1545
1296
|
: null,
|
|
1546
1297
|
].filter((v) => v !== null);
|
|
1547
1298
|
lines.push(` ${role} "${label}" ref=${refs.get(el) ?? provisionElementRef(el)}` +
|
|
@@ -1571,14 +1322,6 @@ export function buildAccessibilitySnapshot(elements, limit = 12000, sealedFieldK
|
|
|
1571
1322
|
source: "interactive_dom",
|
|
1572
1323
|
};
|
|
1573
1324
|
}
|
|
1574
|
-
function registrableHost(url) {
|
|
1575
|
-
try {
|
|
1576
|
-
return new URL(url).hostname.toLowerCase();
|
|
1577
|
-
}
|
|
1578
|
-
catch {
|
|
1579
|
-
return null;
|
|
1580
|
-
}
|
|
1581
|
-
}
|
|
1582
1325
|
function baseDomain(host) {
|
|
1583
1326
|
const parts = host.toLowerCase().split(".").filter(Boolean);
|
|
1584
1327
|
if (parts.length <= 2)
|
|
@@ -1623,216 +1366,21 @@ function widenAllowedHostsFromCurrentUrl(session) {
|
|
|
1623
1366
|
});
|
|
1624
1367
|
}
|
|
1625
1368
|
}
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
"— plain `connect` may report 'already connected' from a cached marker and " +
|
|
1636
|
-
"skip the sign-in, so it will NOT fix a stale/expired session. Then retry " +
|
|
1637
|
-
"— the task has NOT started and nothing was changed.",
|
|
1638
|
-
resume: "connect",
|
|
1639
|
-
},
|
|
1640
|
-
};
|
|
1641
|
-
}
|
|
1642
|
-
async function ensureProvisionPrimaryProviderSession(browser) {
|
|
1643
|
-
if (typeof browser.detectSessionProviders !== "function")
|
|
1644
|
-
return [];
|
|
1645
|
-
return await browser.detectSessionProviders().catch(() => []);
|
|
1646
|
-
}
|
|
1369
|
+
// ── session lifecycle ──
|
|
1370
|
+
//
|
|
1371
|
+
// The transaction itself lives in session/lifecycle.ts. These two wrappers are
|
|
1372
|
+
// the facade: they bind the perception collaborators the start paths need, so
|
|
1373
|
+
// the lifecycle module keeps a one-way dependency on this file (types only).
|
|
1374
|
+
const sessionStartPorts = {
|
|
1375
|
+
observeSession: async (session, detail, startMetadata) => await observeSession(session, detail, startMetadata),
|
|
1376
|
+
compactV2StartMetadata: (registryHint, loginHint, userEmail) => compactV2StartMetadata(registryHint, loginHint, userEmail),
|
|
1377
|
+
};
|
|
1647
1378
|
export async function startProvisionSession(opts) {
|
|
1648
|
-
|
|
1649
|
-
let browser;
|
|
1650
|
-
let liveProviders;
|
|
1651
|
-
let workerEmail;
|
|
1652
|
-
const acquired = await acquireWarmBrowser(opts, id);
|
|
1653
|
-
browser = acquired.controller;
|
|
1654
|
-
// Probe the selected browser: ordinary sessions use a claimed/cloned worker,
|
|
1655
|
-
// while requireLiveIdentity uses the canonical login-authoring profile directly.
|
|
1656
|
-
// The immutable seed is never opened by Chrome during an operator start.
|
|
1657
|
-
liveProviders = await ensureProvisionPrimaryProviderSession(browser);
|
|
1658
|
-
workerEmail =
|
|
1659
|
-
typeof browser.detectGoogleAccountEmail === "function"
|
|
1660
|
-
? await browser.detectGoogleAccountEmail().catch(() => null)
|
|
1661
|
-
: null;
|
|
1662
|
-
// Change 5 — fail-closed identity gate BEFORE driving. If an operate task
|
|
1663
|
-
// needs to act as the user and there's no live Google session, hand back now;
|
|
1664
|
-
// do not start the browser or the task. No autonomous login is attempted.
|
|
1665
|
-
if (opts.requireLiveIdentity === true) {
|
|
1666
|
-
const gate = googleSessionGate(liveProviders);
|
|
1667
|
-
if (!gate.ok) {
|
|
1668
|
-
audit(id, "connect_gate", { ok: false, wall: "google_session" });
|
|
1669
|
-
await releaseWarmBrowserPage(browser, true);
|
|
1670
|
-
return { session_id: id, url: "", text: "", elements: [], needs_user: gate.needs_user };
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
const targetHost = registrableHost(opts.serviceUrl);
|
|
1674
|
-
const seedHosts = [
|
|
1675
|
-
...(targetHost !== null ? [targetHost] : []),
|
|
1676
|
-
...(opts.extraAllowedHosts ?? []),
|
|
1677
|
-
];
|
|
1678
|
-
// All start-declared hosts are sourced "start" — auto-widen chains off these,
|
|
1679
|
-
// and credential egress may seed from these (but never from mid_session).
|
|
1680
|
-
const allowedHosts = [...new Set(seedHosts)].map((host) => ({
|
|
1681
|
-
host,
|
|
1682
|
-
source: "start",
|
|
1683
|
-
}));
|
|
1684
|
-
const session = {
|
|
1685
|
-
id,
|
|
1686
|
-
browser,
|
|
1687
|
-
allowedHosts,
|
|
1688
|
-
generation: 0,
|
|
1689
|
-
secretSlots: new Map(),
|
|
1690
|
-
sealedFieldKeys: new Set(),
|
|
1691
|
-
lastElements: [],
|
|
1692
|
-
prevObserve: null,
|
|
1693
|
-
observeSnapshotFile: null,
|
|
1694
|
-
actionTrace: [],
|
|
1695
|
-
recordedValues: [],
|
|
1696
|
-
committedSelectValues: new Map(),
|
|
1697
|
-
captureRounds: [],
|
|
1698
|
-
usedLocatorFallback: false,
|
|
1699
|
-
recipeRejectionReason: null,
|
|
1700
|
-
replayState: null,
|
|
1701
|
-
activePayment: null,
|
|
1702
|
-
paymentFieldSealActive: false,
|
|
1703
|
-
pendingThreeDs: null,
|
|
1704
|
-
placeOrderApproval: null,
|
|
1705
|
-
placeOrderAttempted: false,
|
|
1706
|
-
lastCartCheckout: null,
|
|
1707
|
-
cartAdds: new Map(),
|
|
1708
|
-
cartAddsByIdempotencyKey: new Map(),
|
|
1709
|
-
cartUrls: new Map(),
|
|
1710
|
-
lastCartMutation: null,
|
|
1711
|
-
closing: false,
|
|
1712
|
-
callCount: 0,
|
|
1713
|
-
callDrainWaiters: new Set(),
|
|
1714
|
-
startedAt: Date.now(),
|
|
1715
|
-
hintServed: opts.hint !== undefined,
|
|
1716
|
-
startUrl: opts.serviceUrl,
|
|
1717
|
-
consentInboxRead: opts.consentInboxRead === true,
|
|
1718
|
-
userEmail: workerEmail,
|
|
1719
|
-
...(opts.api !== undefined ? { api: opts.api } : {}),
|
|
1720
|
-
};
|
|
1721
|
-
sessions.set(id, session);
|
|
1722
|
-
try {
|
|
1723
|
-
if (typeof browser.setHostScopeAllowedHosts === "function") {
|
|
1724
|
-
await browser.setHostScopeAllowedHosts(() => hostStrings(session), () => merchantSiblingSeedHosts(session));
|
|
1725
|
-
}
|
|
1726
|
-
audit(id, "start", {
|
|
1727
|
-
service_url: opts.serviceUrl,
|
|
1728
|
-
allowed_hosts: hostStrings(session),
|
|
1729
|
-
has_hint: opts.hint !== undefined,
|
|
1730
|
-
});
|
|
1731
|
-
await browser.goto(opts.serviceUrl);
|
|
1732
|
-
// A cookie/consent overlay (Usercentrics/OneTrust/…) renders after load and its
|
|
1733
|
-
// backdrop occludes the ENTIRE form — the agent then sees every element
|
|
1734
|
-
// occluded_by a div and gives up, or falls back to the only thing that looks
|
|
1735
|
-
// clickable (e.g. a "Connect wallet" CTA on the Robinhood faucet). Dismiss it
|
|
1736
|
-
// BEFORE the first observation so the real actionable form is operable.
|
|
1737
|
-
// dismissConsentBanner() existed but had NO call sites (dead code); it only
|
|
1738
|
-
// clicks banner-specific CTAs (accept/reject all), so a false click is unlikely.
|
|
1739
|
-
// Best-effort + one retry, since the widget lazy-loads a beat after the goto.
|
|
1740
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1741
|
-
const cta = await browser.dismissConsentBanner().catch(() => null);
|
|
1742
|
-
if (cta !== null) {
|
|
1743
|
-
audit(id, "consent_dismissed", { cta });
|
|
1744
|
-
break;
|
|
1745
|
-
}
|
|
1746
|
-
if (attempt === 0)
|
|
1747
|
-
await browser.waitForCaptchaChallengeToSettle(800, 0).catch(() => false);
|
|
1748
|
-
}
|
|
1749
|
-
const observation = await observeSession(session);
|
|
1750
|
-
// Tell the agent which provider the user actually has a live session for
|
|
1751
|
-
// (Google-preferred) — the bot knows from the profile cookies, so the agent
|
|
1752
|
-
// doesn't have to guess. Composed with the skill route hint (if any).
|
|
1753
|
-
const hintParts = [
|
|
1754
|
-
loginSessionGuidance(liveProviders),
|
|
1755
|
-
...(opts.hint !== undefined ? [opts.hint] : []),
|
|
1756
|
-
];
|
|
1757
|
-
return {
|
|
1758
|
-
...observation,
|
|
1759
|
-
hint: hintParts.join("\n"),
|
|
1760
|
-
...(session.userEmail !== null ? { user_email: session.userEmail } : {}),
|
|
1761
|
-
};
|
|
1762
|
-
}
|
|
1763
|
-
catch (err) {
|
|
1764
|
-
sessions.delete(id);
|
|
1765
|
-
await releaseWarmBrowserPage(browser, false);
|
|
1766
|
-
throw err;
|
|
1767
|
-
}
|
|
1379
|
+
return await startProvisionSessionInternal(opts, sessionStartPorts);
|
|
1768
1380
|
}
|
|
1769
1381
|
/** Start a normal guarded session on a caller-owned harness page. */
|
|
1770
1382
|
export async function startHarnessProvisionSession(opts) {
|
|
1771
|
-
|
|
1772
|
-
if (opts.requireLiveIdentity === true) {
|
|
1773
|
-
throw new Error("harness sessions cannot request live identity");
|
|
1774
|
-
}
|
|
1775
|
-
const targetHost = registrableHost(opts.serviceUrl);
|
|
1776
|
-
const allowedHosts = [
|
|
1777
|
-
...(targetHost === null ? [] : [targetHost]),
|
|
1778
|
-
...(opts.extraAllowedHosts ?? []),
|
|
1779
|
-
]
|
|
1780
|
-
.filter((host, index, hosts) => hosts.indexOf(host) === index)
|
|
1781
|
-
.map((host) => ({
|
|
1782
|
-
host,
|
|
1783
|
-
source: "start",
|
|
1784
|
-
}));
|
|
1785
|
-
const session = {
|
|
1786
|
-
id,
|
|
1787
|
-
browser: opts.browser,
|
|
1788
|
-
allowedHosts,
|
|
1789
|
-
generation: 0,
|
|
1790
|
-
secretSlots: new Map(),
|
|
1791
|
-
sealedFieldKeys: new Set(),
|
|
1792
|
-
lastElements: [],
|
|
1793
|
-
prevObserve: null,
|
|
1794
|
-
observeSnapshotFile: null,
|
|
1795
|
-
actionTrace: [],
|
|
1796
|
-
recordedValues: [],
|
|
1797
|
-
committedSelectValues: new Map(),
|
|
1798
|
-
captureRounds: [],
|
|
1799
|
-
usedLocatorFallback: false,
|
|
1800
|
-
recipeRejectionReason: null,
|
|
1801
|
-
replayState: null,
|
|
1802
|
-
activePayment: null,
|
|
1803
|
-
paymentFieldSealActive: false,
|
|
1804
|
-
pendingThreeDs: null,
|
|
1805
|
-
placeOrderApproval: null,
|
|
1806
|
-
placeOrderAttempted: false,
|
|
1807
|
-
lastCartCheckout: null,
|
|
1808
|
-
cartAdds: new Map(),
|
|
1809
|
-
cartAddsByIdempotencyKey: new Map(),
|
|
1810
|
-
cartUrls: new Map(),
|
|
1811
|
-
lastCartMutation: null,
|
|
1812
|
-
closing: false,
|
|
1813
|
-
callCount: 0,
|
|
1814
|
-
callDrainWaiters: new Set(),
|
|
1815
|
-
startedAt: Date.now(),
|
|
1816
|
-
hintServed: opts.hint !== undefined,
|
|
1817
|
-
startUrl: opts.serviceUrl,
|
|
1818
|
-
consentInboxRead: false,
|
|
1819
|
-
userEmail: null,
|
|
1820
|
-
...(opts.api === undefined ? {} : { api: opts.api }),
|
|
1821
|
-
};
|
|
1822
|
-
sessions.set(id, session);
|
|
1823
|
-
try {
|
|
1824
|
-
audit(id, "start_harness", {
|
|
1825
|
-
service_url: opts.serviceUrl,
|
|
1826
|
-
allowed_hosts: hostStrings(session),
|
|
1827
|
-
});
|
|
1828
|
-
await opts.browser.goto(opts.serviceUrl);
|
|
1829
|
-
return { ...(await observeSession(session)), hint: opts.hint ?? "" };
|
|
1830
|
-
}
|
|
1831
|
-
catch (error) {
|
|
1832
|
-
sessions.delete(id);
|
|
1833
|
-
await opts.browser.close().catch(() => undefined);
|
|
1834
|
-
throw error;
|
|
1835
|
-
}
|
|
1383
|
+
return await startHarnessProvisionSessionInternal(opts, sessionStartPorts);
|
|
1836
1384
|
}
|
|
1837
1385
|
export async function observe(sessionId, detail = "compact") {
|
|
1838
1386
|
const session = sessionForCall(sessionId);
|
|
@@ -1840,28 +1388,18 @@ export async function observe(sessionId, detail = "compact") {
|
|
|
1840
1388
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
1841
1389
|
return await observeSession(session, detail);
|
|
1842
1390
|
}
|
|
1843
|
-
// operate_screenshot's session-level entry point
|
|
1844
|
-
//
|
|
1845
|
-
// redaction to BrowserController.captureOperatorScreenshot (browser.ts).
|
|
1391
|
+
// operate_screenshot's session-level entry point. The capture is the page's real
|
|
1392
|
+
// pixels — there is no redaction pass and no sealed-context refusal.
|
|
1846
1393
|
export async function captureScreenshot(sessionId, opts = {}) {
|
|
1847
1394
|
const session = sessionForCall(sessionId);
|
|
1848
1395
|
if (session === undefined)
|
|
1849
1396
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
1850
|
-
|
|
1851
|
-
// child frame. Historical seals are different: sealedFieldKeys is cumulative,
|
|
1852
|
-
// so the browser must inspect only the frames this capture would include.
|
|
1853
|
-
if (session.paymentFieldSealActive || session.activePayment?.status === "operating") {
|
|
1854
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
1855
|
-
}
|
|
1856
|
-
const captured = await session.browser.captureOperatorScreenshot(opts, [
|
|
1857
|
-
...session.sealedFieldKeys,
|
|
1858
|
-
]);
|
|
1397
|
+
const captured = await session.browser.captureOperatorScreenshot(opts);
|
|
1859
1398
|
return {
|
|
1860
1399
|
session_id: sessionId,
|
|
1861
1400
|
url: session.browser.currentUrl(),
|
|
1862
1401
|
frame_url: captured.frameUrl,
|
|
1863
1402
|
frame_count: captured.frameCount,
|
|
1864
|
-
redacted_count: captured.redactedCount,
|
|
1865
1403
|
image: { mime_type: "image/jpeg", data_base64: captured.base64 },
|
|
1866
1404
|
};
|
|
1867
1405
|
}
|
|
@@ -1918,14 +1456,20 @@ export function currentProvisionUrl(sessionId) {
|
|
|
1918
1456
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
1919
1457
|
return session.browser.currentUrl();
|
|
1920
1458
|
}
|
|
1459
|
+
export function isCompactV2ProvisionSession(sessionId) {
|
|
1460
|
+
return sessionForCall(sessionId)?.compactV2Mode === "on";
|
|
1461
|
+
}
|
|
1921
1462
|
function activeProvisionSession() {
|
|
1922
1463
|
return paymentSession();
|
|
1923
1464
|
}
|
|
1924
1465
|
export function activeProvisionBrowser() {
|
|
1925
|
-
|
|
1466
|
+
const session = activeProvisionSession();
|
|
1467
|
+
invalidateCompactV2Snapshot(session);
|
|
1468
|
+
return session.browser;
|
|
1926
1469
|
}
|
|
1927
1470
|
export async function activeProvisionBrowserForPayment(selectedSession) {
|
|
1928
1471
|
const session = selectedSession ?? activeProvisionSession();
|
|
1472
|
+
invalidateCompactV2Snapshot(session);
|
|
1929
1473
|
return session.browser;
|
|
1930
1474
|
}
|
|
1931
1475
|
function placeOrderApprovalFromPendingFill(pending) {
|
|
@@ -1957,18 +1501,88 @@ export function getActivePendingCardFill(selectedSession) {
|
|
|
1957
1501
|
const state = (selectedSession ?? activeProvisionSession()).activePayment;
|
|
1958
1502
|
return state?.status === "pending" ? state.pending : null;
|
|
1959
1503
|
}
|
|
1960
|
-
//
|
|
1961
|
-
//
|
|
1962
|
-
//
|
|
1504
|
+
// The outstanding approval a prior bounded operate_pay call left waiting on
|
|
1505
|
+
// the human, if any. A status read may transition this exact state to terminal
|
|
1506
|
+
// denial/expiry and scrub its private operator key.
|
|
1963
1507
|
export function getActivePendingApproval(selectedSession) {
|
|
1964
1508
|
const state = (selectedSession ?? activeProvisionSession()).activePayment;
|
|
1965
1509
|
return state?.status === "awaiting_approval" ? state.state : null;
|
|
1966
1510
|
}
|
|
1511
|
+
export function getTerminalPaymentApproval(selectedSession) {
|
|
1512
|
+
const activePayment = (selectedSession ?? activeProvisionSession()).activePayment;
|
|
1513
|
+
return activePayment?.status === "terminal_approval"
|
|
1514
|
+
? { state: activePayment.state, terminalStatus: activePayment.terminalStatus }
|
|
1515
|
+
: null;
|
|
1516
|
+
}
|
|
1517
|
+
export function completeActivePendingApprovalWithTerminalStatus(state, terminalStatus, selectedSession) {
|
|
1518
|
+
const session = selectedSession ?? activeProvisionSession();
|
|
1519
|
+
const activePayment = session.activePayment;
|
|
1520
|
+
if (activePayment?.status !== "awaiting_approval" || activePayment.state !== state)
|
|
1521
|
+
return false;
|
|
1522
|
+
state.keypair.privateKey = "";
|
|
1523
|
+
session.activePayment = { status: "terminal_approval", state, terminalStatus };
|
|
1524
|
+
return true;
|
|
1525
|
+
}
|
|
1526
|
+
export function completeActivePaymentLeaseWithTerminalApproval(lease, state, terminalStatus, selectedSession) {
|
|
1527
|
+
const session = selectedSession ?? activeProvisionSession();
|
|
1528
|
+
const activePayment = session.activePayment;
|
|
1529
|
+
if (activePayment?.status !== "operating" || activePayment.lease !== lease) {
|
|
1530
|
+
throw new Error("operate_pay terminal approval completed without ownership of the active payment lease");
|
|
1531
|
+
}
|
|
1532
|
+
state.keypair.privateKey = "";
|
|
1533
|
+
session.activePayment = { status: "terminal_approval", state, terminalStatus };
|
|
1534
|
+
}
|
|
1967
1535
|
export function getActivePendingThreeDs(selectedSession) {
|
|
1968
1536
|
return (selectedSession ?? activeProvisionSession()).pendingThreeDs ?? null;
|
|
1969
1537
|
}
|
|
1538
|
+
export function armPaymentDispatchHandoff(state, selectedSession) {
|
|
1539
|
+
const session = selectedSession ?? activeProvisionSession();
|
|
1540
|
+
if (session.paymentDispatchClosed || (session.closing && session.paymentCallCount === 0)) {
|
|
1541
|
+
throw new Error(`provision session ${session.id} closed before payment dispatch`);
|
|
1542
|
+
}
|
|
1543
|
+
let resolveSettled = () => undefined;
|
|
1544
|
+
const settled = new Promise((resolve) => {
|
|
1545
|
+
resolveSettled = resolve;
|
|
1546
|
+
});
|
|
1547
|
+
session.paymentDispatchHandoff = {
|
|
1548
|
+
state,
|
|
1549
|
+
settled,
|
|
1550
|
+
resolveSettled,
|
|
1551
|
+
terminalizing: false,
|
|
1552
|
+
terminalComplete: false,
|
|
1553
|
+
released: false,
|
|
1554
|
+
auditPromise: null,
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
export function finishPaymentDispatchHandoff(state, selectedSession) {
|
|
1558
|
+
const session = selectedSession ?? activeProvisionSession();
|
|
1559
|
+
const handoff = session.paymentDispatchHandoff;
|
|
1560
|
+
if (handoff?.state !== state)
|
|
1561
|
+
return;
|
|
1562
|
+
handoff.released = true;
|
|
1563
|
+
handoff.resolveSettled();
|
|
1564
|
+
if (!handoff.terminalizing || handoff.terminalComplete) {
|
|
1565
|
+
session.paymentDispatchHandoff = null;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
export async function coordinatePaymentDispatchAudit(state, recordAudit, selectedSession) {
|
|
1569
|
+
const session = selectedSession ?? activeProvisionSession();
|
|
1570
|
+
const handoff = session.paymentDispatchHandoff;
|
|
1571
|
+
if (handoff?.state === state) {
|
|
1572
|
+
handoff.auditPromise ??= recordAudit();
|
|
1573
|
+
await handoff.auditPromise;
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
await recordAudit();
|
|
1577
|
+
}
|
|
1970
1578
|
export function setActivePendingThreeDs(state, selectedSession) {
|
|
1971
|
-
|
|
1579
|
+
const session = selectedSession ?? activeProvisionSession();
|
|
1580
|
+
const handoff = session.paymentDispatchHandoff;
|
|
1581
|
+
if (session.closing && handoff?.state !== state)
|
|
1582
|
+
return;
|
|
1583
|
+
session.pendingThreeDs = state;
|
|
1584
|
+
if (handoff?.state === state)
|
|
1585
|
+
handoff.resolveSettled();
|
|
1972
1586
|
}
|
|
1973
1587
|
export function clearActivePendingThreeDsIfCurrent(state, selectedSession) {
|
|
1974
1588
|
const session = selectedSession ?? activeProvisionSession();
|
|
@@ -1993,6 +1607,13 @@ export function claimActivePaymentForOperatePay(phase, selectedSession) {
|
|
|
1993
1607
|
if (state?.status === "sealed") {
|
|
1994
1608
|
throw new Error("operate_pay refused: payment field cleanup remains unverified");
|
|
1995
1609
|
}
|
|
1610
|
+
if (state?.status === "terminal_approval") {
|
|
1611
|
+
return {
|
|
1612
|
+
kind: "terminal",
|
|
1613
|
+
state: state.state,
|
|
1614
|
+
terminalStatus: state.terminalStatus,
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1996
1617
|
if (state?.status === "pending") {
|
|
1997
1618
|
if (phase !== "confirm") {
|
|
1998
1619
|
throw new Error('operate_pay refused: a vaulted card fill is pending; phase="confirm" is required next');
|
|
@@ -2128,6 +1749,14 @@ function alreadyInCartResult(result) {
|
|
|
2128
1749
|
checkout_state: { ...result.checkout_state },
|
|
2129
1750
|
};
|
|
2130
1751
|
}
|
|
1752
|
+
async function capturePrivateCheckoutState(session) {
|
|
1753
|
+
const elements = await session.browser.extractInteractiveElements();
|
|
1754
|
+
retainSessionElements(session, elements);
|
|
1755
|
+
const url = session.browser.currentUrl();
|
|
1756
|
+
const text = await session.browser.extractVisibleText();
|
|
1757
|
+
const liveCheckout = await captureCartCheckoutForFillCardFallback(session, url);
|
|
1758
|
+
return checkoutStateForObservation(session, url, text.slice(0, 12_000), elements, liveCheckout);
|
|
1759
|
+
}
|
|
2131
1760
|
async function reconcileReservedCartAdd(session, record) {
|
|
2132
1761
|
if (record.result !== null)
|
|
2133
1762
|
return alreadyInCartResult(record.result);
|
|
@@ -2147,14 +1776,14 @@ async function reconcileReservedCartAdd(session, record) {
|
|
|
2147
1776
|
cartDelta: "0",
|
|
2148
1777
|
origin: originForUrl(session.browser.currentUrl()) ?? "",
|
|
2149
1778
|
};
|
|
2150
|
-
const
|
|
2151
|
-
if (
|
|
1779
|
+
const checkoutState = await capturePrivateCheckoutState(session);
|
|
1780
|
+
if (checkoutState === undefined)
|
|
2152
1781
|
throw error;
|
|
2153
1782
|
const result = {
|
|
2154
1783
|
status: "already_in_cart",
|
|
2155
1784
|
cart_delta: "0",
|
|
2156
|
-
cart_url:
|
|
2157
|
-
checkout_state: { ...
|
|
1785
|
+
cart_url: checkoutState.cart_url,
|
|
1786
|
+
checkout_state: { ...checkoutState, quantity },
|
|
2158
1787
|
postcondition: {
|
|
2159
1788
|
product_identity: record.productIdentity,
|
|
2160
1789
|
options_hash: record.optionsHash,
|
|
@@ -2177,14 +1806,14 @@ async function performCartAdd(session, record) {
|
|
|
2177
1806
|
cartDelta: "0",
|
|
2178
1807
|
origin: originForUrl(session.browser.currentUrl()) ?? "",
|
|
2179
1808
|
};
|
|
2180
|
-
const
|
|
2181
|
-
if (
|
|
1809
|
+
const checkoutState = await capturePrivateCheckoutState(session);
|
|
1810
|
+
if (checkoutState === undefined)
|
|
2182
1811
|
throw new Error("cart state was not observable");
|
|
2183
1812
|
return {
|
|
2184
1813
|
status: "already_in_cart",
|
|
2185
1814
|
cart_delta: "0",
|
|
2186
|
-
cart_url:
|
|
2187
|
-
checkout_state: { ...
|
|
1815
|
+
cart_url: checkoutState.cart_url,
|
|
1816
|
+
checkout_state: { ...checkoutState, quantity: beforeQuantity },
|
|
2188
1817
|
postcondition: {
|
|
2189
1818
|
product_identity: record.productIdentity,
|
|
2190
1819
|
options_hash: record.optionsHash,
|
|
@@ -2199,33 +1828,33 @@ async function performCartAdd(session, record) {
|
|
|
2199
1828
|
'text="カートに追加"',
|
|
2200
1829
|
];
|
|
2201
1830
|
let addError;
|
|
2202
|
-
let
|
|
1831
|
+
let actionResult = null;
|
|
2203
1832
|
for (const target of addTargets) {
|
|
2204
1833
|
try {
|
|
2205
|
-
|
|
1834
|
+
actionResult = await actInternally(session.id, { kind: "click", target }, "compact", {
|
|
2206
1835
|
productIdentity: record.productIdentity,
|
|
2207
1836
|
optionsHash: record.optionsHash,
|
|
2208
1837
|
onActionReady: () => {
|
|
2209
1838
|
record.phase = "click_started";
|
|
2210
1839
|
},
|
|
2211
|
-
});
|
|
1840
|
+
}, true);
|
|
2212
1841
|
addError = undefined;
|
|
2213
1842
|
break;
|
|
2214
1843
|
}
|
|
2215
1844
|
catch (error) {
|
|
2216
1845
|
addError = error;
|
|
2217
|
-
if (!(error instanceof
|
|
1846
|
+
if (!(error instanceof ProvisionTargetMissingError)) {
|
|
2218
1847
|
throw error;
|
|
2219
1848
|
}
|
|
2220
1849
|
}
|
|
2221
1850
|
}
|
|
2222
|
-
if (addError !== undefined ||
|
|
1851
|
+
if (addError !== undefined || actionResult === null)
|
|
2223
1852
|
throw addError;
|
|
2224
1853
|
const afterQuantity = await cartLineQuantity(session, record.productIdentity, record.optionsHash);
|
|
2225
1854
|
if (afterQuantity === null || afterQuantity <= 0) {
|
|
2226
1855
|
throw new Error("requested product/variant line was not observable after add");
|
|
2227
1856
|
}
|
|
2228
|
-
const checkoutState =
|
|
1857
|
+
const checkoutState = actionResult.outcome.checkoutState;
|
|
2229
1858
|
if (checkoutState === undefined)
|
|
2230
1859
|
throw new Error("cart state was not observable after add");
|
|
2231
1860
|
const cartDelta = beforeQuantity === null
|
|
@@ -2253,6 +1882,29 @@ async function performCartAdd(session, record) {
|
|
|
2253
1882
|
},
|
|
2254
1883
|
};
|
|
2255
1884
|
}
|
|
1885
|
+
// Deterministic cart-normalize affordance: empty the cart before a cart_add so
|
|
1886
|
+
// a run reaches a KNOWN quantity regardless of what accumulated in the shared
|
|
1887
|
+
// persistent-profile cart across earlier operate_start sessions. Drops the
|
|
1888
|
+
// local idempotency reservations too — a stale "already_in_cart" for a line
|
|
1889
|
+
// that no longer exists must not suppress a real add after the clear.
|
|
1890
|
+
export async function cartClear(sessionId) {
|
|
1891
|
+
const session = sessionForCall(sessionId);
|
|
1892
|
+
if (session === undefined)
|
|
1893
|
+
throw new Error(`unknown provision session ${sessionId}`);
|
|
1894
|
+
const cleared = await session.browser.clearCart();
|
|
1895
|
+
if (!cleared)
|
|
1896
|
+
throw new Error("cart_clear failed to reach the cart-clear endpoint");
|
|
1897
|
+
session.cartAdds.clear();
|
|
1898
|
+
session.cartAddsByIdempotencyKey.clear();
|
|
1899
|
+
session.lastCartMutation = null;
|
|
1900
|
+
session.lastCartCheckout = null;
|
|
1901
|
+
const observed = await observeSession(session);
|
|
1902
|
+
return {
|
|
1903
|
+
status: "cleared",
|
|
1904
|
+
cart_url: observed.checkout_state?.cart_url ?? null,
|
|
1905
|
+
...(observed.checkout_state !== undefined ? { checkout_state: observed.checkout_state } : {}),
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
2256
1908
|
export async function cartAdd(sessionId, productIdentity, optionsHash, idempotencyKey) {
|
|
2257
1909
|
const session = sessionForCall(sessionId);
|
|
2258
1910
|
if (session === undefined)
|
|
@@ -2382,7 +2034,7 @@ function annotatePaymentControl(out, el) {
|
|
|
2382
2034
|
// empty. `value`→`value_len` (never the raw value — keeps the sealed-field moat);
|
|
2383
2035
|
// `checked` kept for real checkables (true OR false), omitted when null;
|
|
2384
2036
|
// `topmost` only when false (the informative case); `container` dropped.
|
|
2385
|
-
export function toCompactElement(el, ref,
|
|
2037
|
+
export function toCompactElement(el, ref,
|
|
2386
2038
|
// `path` is the single most verbose field and agents act by ref, not path — so
|
|
2387
2039
|
// it is DROPPED from the default host payload (78% → 85% of the measured cut).
|
|
2388
2040
|
// It is retained ONLY in the persisted snapshot file (includePath=true), which
|
|
@@ -2392,41 +2044,37 @@ includePath = false,
|
|
|
2392
2044
|
// Apply type-elision (Phase 4) to the WIRE form. The persisted file
|
|
2393
2045
|
// form keeps full fidelity for re-expansion, so callers that write the file
|
|
2394
2046
|
// pass false.
|
|
2395
|
-
elide = false
|
|
2047
|
+
elide = false) {
|
|
2396
2048
|
const out = {
|
|
2397
2049
|
ref,
|
|
2398
|
-
label:
|
|
2399
|
-
tag:
|
|
2050
|
+
label: elementRef(el),
|
|
2051
|
+
tag: el.tag,
|
|
2400
2052
|
};
|
|
2401
2053
|
if (el.role)
|
|
2402
|
-
out.role =
|
|
2054
|
+
out.role = el.role;
|
|
2403
2055
|
if (el.type && !(elide && shouldElideType(el))) {
|
|
2404
|
-
out.type =
|
|
2056
|
+
out.type = el.type;
|
|
2405
2057
|
}
|
|
2406
2058
|
// value_len is a LENGTH signal, not the value — report the REAL character count.
|
|
2407
|
-
// presentFieldValue masks a sealed field to "[sealed]" (8 chars), so using its
|
|
2408
|
-
// length made a correctly-filled 19-char email read as value_len:8 and misled
|
|
2409
|
-
// the agent into thinking its fill truncated. The length isn't the secret (the
|
|
2410
|
-
// min_value_len postcondition already uses the real length for the same reason).
|
|
2411
2059
|
const realLen = (el.value ?? "").length;
|
|
2412
2060
|
if (realLen > 0)
|
|
2413
2061
|
out.value_len = realLen;
|
|
2414
2062
|
if (el.checked !== null && el.checked !== undefined)
|
|
2415
2063
|
out.checked = el.checked;
|
|
2416
2064
|
if (el.href)
|
|
2417
|
-
out.href =
|
|
2065
|
+
out.href = el.href;
|
|
2418
2066
|
if (el.testId)
|
|
2419
|
-
out.testId =
|
|
2067
|
+
out.testId = el.testId;
|
|
2420
2068
|
if (includePath && el.screenPath) {
|
|
2421
|
-
out.path =
|
|
2069
|
+
out.path = el.screenPath;
|
|
2422
2070
|
}
|
|
2423
2071
|
if (el.topmost === false)
|
|
2424
2072
|
out.topmost = false;
|
|
2425
2073
|
if (el.occludedBy) {
|
|
2426
|
-
out.occluded_by =
|
|
2074
|
+
out.occluded_by = el.occludedBy;
|
|
2427
2075
|
}
|
|
2428
2076
|
if (el.frameOrigin) {
|
|
2429
|
-
out.frame_origin =
|
|
2077
|
+
out.frame_origin = el.frameOrigin;
|
|
2430
2078
|
}
|
|
2431
2079
|
annotatePaymentControl(out, el);
|
|
2432
2080
|
return out;
|
|
@@ -2596,11 +2244,6 @@ function emitElements(els, encode) {
|
|
|
2596
2244
|
// a write failure must NEVER break an observe. Rolling one file per session (the
|
|
2597
2245
|
// latest COMPLETE inventory) — that's what the host wants when it re-expands
|
|
2598
2246
|
// after a context compaction or greps for an element the delta didn't re-show.
|
|
2599
|
-
function observeSnapshotDir(sessionId) {
|
|
2600
|
-
const override = (process.env.TRUSTY_SQUIRE_OBSERVE_DIR ?? "").trim();
|
|
2601
|
-
const parent = override.length > 0 ? override : join(tmpdir(), "trusty-squire-observe");
|
|
2602
|
-
return join(parent, sessionId);
|
|
2603
|
-
}
|
|
2604
2247
|
function persistObserveSnapshot(session, generation, url, text, textTruncated, elements) {
|
|
2605
2248
|
let temporaryFile = null;
|
|
2606
2249
|
const dir = observeSnapshotDir(session.id);
|
|
@@ -2733,10 +2376,8 @@ const OBSERVE_CHURN_FULL_THRESHOLD = 0.6;
|
|
|
2733
2376
|
// collapse, and returns both the emit and the complete ground-truth set.
|
|
2734
2377
|
export function buildCompactObservation(args) {
|
|
2735
2378
|
const { sessionId, url, text, elements, prev } = args;
|
|
2736
|
-
const sealed = args.sealed ?? new Set();
|
|
2737
2379
|
const encode = args.encode ?? "columnar";
|
|
2738
2380
|
const elide = args.elide ?? true;
|
|
2739
|
-
const paymentSealActive = args.paymentSealActive ?? false;
|
|
2740
2381
|
const refs = provisionElementRefs(elements);
|
|
2741
2382
|
const refOf = (el) => refs.get(el) ?? provisionElementRef(el);
|
|
2742
2383
|
const fullByRef = new Map();
|
|
@@ -2744,11 +2385,11 @@ export function buildCompactObservation(args) {
|
|
|
2744
2385
|
const fileElements = [];
|
|
2745
2386
|
for (const el of elements) {
|
|
2746
2387
|
const ref = refOf(el);
|
|
2747
|
-
fullByRef.set(ref, toCompactElement(el, ref,
|
|
2388
|
+
fullByRef.set(ref, toCompactElement(el, ref, false, elide));
|
|
2748
2389
|
serializedByRef.set(ref, JSON.stringify(fullByRef.get(ref)));
|
|
2749
2390
|
// The persisted file keeps FULL fidelity (path included, no elision) so a
|
|
2750
2391
|
// re-expansion after a host compaction loses nothing.
|
|
2751
|
-
fileElements.push(toCompactElement(el, ref,
|
|
2392
|
+
fileElements.push(toCompactElement(el, ref, true, false));
|
|
2752
2393
|
}
|
|
2753
2394
|
const nextState = { url, byRef: serializedByRef, text };
|
|
2754
2395
|
// A dialog/modal region with at least one topmost (unoccluded) element is
|
|
@@ -2850,19 +2491,7 @@ async function captureCartCheckoutForFillCardFallback(session, url) {
|
|
|
2850
2491
|
return null;
|
|
2851
2492
|
}
|
|
2852
2493
|
function checkoutStageFromUrl(url) {
|
|
2853
|
-
|
|
2854
|
-
try {
|
|
2855
|
-
pathname = decodeURIComponent(new URL(url).pathname).toLowerCase();
|
|
2856
|
-
}
|
|
2857
|
-
catch {
|
|
2858
|
-
return null;
|
|
2859
|
-
}
|
|
2860
|
-
if (/(?:^|\/)(?:checkout|secure[-_]?checkout|payment|order[-_]?review|order\/review)(?:\.(?:php|html?))?(?:\/|$)/i.test(pathname)) {
|
|
2861
|
-
return "checkout";
|
|
2862
|
-
}
|
|
2863
|
-
if (/(?:^|\/)(?:cart|shopping[-_]?cart|view[-_]?cart|basket|bag)(?:\.(?:php|html?))?(?:\/|$)/i.test(pathname))
|
|
2864
|
-
return "cart";
|
|
2865
|
-
return null;
|
|
2494
|
+
return checkoutStageFromUrlV2(url);
|
|
2866
2495
|
}
|
|
2867
2496
|
function checkoutStage(url, elements) {
|
|
2868
2497
|
const routeStage = checkoutStageFromUrl(url);
|
|
@@ -3050,41 +2679,561 @@ function isCartAffectingAction(action, el, extraLabels = []) {
|
|
|
3050
2679
|
return ((action.kind === "type" || action.kind === "select") &&
|
|
3051
2680
|
/(?:\b(?:quantity|qty)\b|数量|個数)/i.test(target));
|
|
3052
2681
|
}
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
session.
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
2682
|
+
function retainSessionElements(session, elements) {
|
|
2683
|
+
session.lastElements =
|
|
2684
|
+
session.compactV2Mode === "on"
|
|
2685
|
+
? sealRetainedInteractiveElementsV2(elements, (element) => compactV2CorrelationSelector(session, element))
|
|
2686
|
+
: elements;
|
|
2687
|
+
}
|
|
2688
|
+
function compactV2CommittedSelectKey(session, selector) {
|
|
2689
|
+
if (session.compactV2Mode !== "on")
|
|
2690
|
+
return selector;
|
|
2691
|
+
return createHmac("sha256", session.compactV2Secret)
|
|
2692
|
+
.update(`select-key\0${selector}`)
|
|
2693
|
+
.digest("base64url");
|
|
2694
|
+
}
|
|
2695
|
+
function compactV2CommittedSelectValue(session, value) {
|
|
2696
|
+
if (session.compactV2Mode !== "on")
|
|
2697
|
+
return value;
|
|
2698
|
+
return createHmac("sha256", session.compactV2Secret)
|
|
2699
|
+
.update(`select-value\0${value}`)
|
|
2700
|
+
.digest("base64url");
|
|
2701
|
+
}
|
|
2702
|
+
function clearCommittedSelectValue(session, selector) {
|
|
2703
|
+
session.committedSelectValues.delete(compactV2CommittedSelectKey(session, selector));
|
|
2704
|
+
}
|
|
2705
|
+
function compactV2CorrelationSelector(session, element) {
|
|
2706
|
+
const binding = JSON.stringify([
|
|
2707
|
+
element.frameOrigin ?? null,
|
|
2708
|
+
element.framePath ?? null,
|
|
2709
|
+
element.selector,
|
|
2710
|
+
]);
|
|
2711
|
+
return `@c:${createHmac("sha256", session.compactV2Secret)
|
|
2712
|
+
.update(binding)
|
|
2713
|
+
.digest("base64url")
|
|
2714
|
+
.slice(0, 22)}`;
|
|
2715
|
+
}
|
|
2716
|
+
function replaySafeElementForSession(session, element) {
|
|
2717
|
+
if (element === null || session.compactV2Mode !== "on")
|
|
2718
|
+
return element;
|
|
2719
|
+
if (element.frameOrigin !== null && element.frameOrigin !== undefined) {
|
|
2720
|
+
if (safeOriginV2(element.frameOrigin) === null) {
|
|
2721
|
+
rejectRecipeRecording(session, "compact_v2_unrepresentable_frame_origin");
|
|
2722
|
+
return null;
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
return (sealRetainedInteractiveElementsV2([element], (candidate) => compactV2CorrelationSelector(session, candidate))[0] ?? null);
|
|
2726
|
+
}
|
|
2727
|
+
function splitUtf8Pages(value, maxBytes) {
|
|
2728
|
+
if (value.length === 0)
|
|
2729
|
+
return [];
|
|
2730
|
+
const pages = [];
|
|
2731
|
+
let page = "";
|
|
2732
|
+
let bytes = 0;
|
|
2733
|
+
for (const character of value) {
|
|
2734
|
+
const characterBytes = Buffer.byteLength(character, "utf8");
|
|
2735
|
+
if (bytes + characterBytes > maxBytes && page.length > 0) {
|
|
2736
|
+
pages.push(page);
|
|
2737
|
+
page = "";
|
|
2738
|
+
bytes = 0;
|
|
2739
|
+
}
|
|
2740
|
+
page += character;
|
|
2741
|
+
bytes += characterBytes;
|
|
2742
|
+
}
|
|
2743
|
+
if (page.length > 0)
|
|
2744
|
+
pages.push(page);
|
|
2745
|
+
return pages;
|
|
2746
|
+
}
|
|
2747
|
+
function compactV2StartMetadata(registryHint, loginHint, userEmail) {
|
|
2748
|
+
const hint = [loginHint, registryHint]
|
|
2749
|
+
.filter((part) => part !== undefined && part.length > 0)
|
|
2750
|
+
.join("\n");
|
|
2751
|
+
const validEmail = userEmail !== null &&
|
|
2752
|
+
Buffer.byteLength(userEmail, "utf8") <= 254 &&
|
|
2753
|
+
/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(userEmail)
|
|
2754
|
+
? userEmail
|
|
2755
|
+
: undefined;
|
|
2756
|
+
return {
|
|
2757
|
+
...(hint.length === 0 ? {} : { hintPages: splitUtf8Pages(hint, 384) }),
|
|
2758
|
+
...(validEmail === undefined ? {} : { userEmail: validEmail }),
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
function compactV2QueryCursorScope(session, query, role) {
|
|
2762
|
+
return createHmac("sha256", session.compactV2Secret)
|
|
2763
|
+
.update(JSON.stringify([query, role ?? null]))
|
|
2764
|
+
.digest("base64url")
|
|
2765
|
+
.slice(0, 10);
|
|
2766
|
+
}
|
|
2767
|
+
function compactV2HintCursorScope(session) {
|
|
2768
|
+
return createHmac("sha256", session.compactV2Secret)
|
|
2769
|
+
.update("start-metadata")
|
|
2770
|
+
.digest("base64url")
|
|
2771
|
+
.slice(0, 10);
|
|
2772
|
+
}
|
|
2773
|
+
function compactV2Cursor(session, rev, offset, scope) {
|
|
2774
|
+
// The session-held index owns the five-minute expiry, so the cursor need not
|
|
2775
|
+
// repeat a UUID/timestamp on every dense-page observation. A session-secret
|
|
2776
|
+
// HMAC makes this compact in-MCP token unforgeable across sessions. Binding
|
|
2777
|
+
// the epoch revision is what kills a page offset across a re-serialization.
|
|
2778
|
+
const body = `${rev.toString(36)}:${offset.toString(36)}:${scope}`;
|
|
2779
|
+
const signature = createHmac("sha256", session.compactV2Secret)
|
|
2780
|
+
.update(body)
|
|
2781
|
+
.digest("base64url")
|
|
2782
|
+
.slice(0, 12);
|
|
2783
|
+
return `${body}.${signature}`;
|
|
2784
|
+
}
|
|
2785
|
+
// A live checkout mints a per-checkout token INTO ITS PATH and re-writes it as
|
|
2786
|
+
// the checkout SPA re-renders a step. The token names the checkout, never the
|
|
2787
|
+
// document, so folding it into `doc` retires every ref between two fills of one
|
|
2788
|
+
// address block. This is the path-side twin of the query/fragment exclusion
|
|
2789
|
+
// PR #624 landed; it is deliberately a CLOSED list of known checkout shapes —
|
|
2790
|
+
// every other path keeps its full identity, so an SPA route change to a
|
|
2791
|
+
// different logical page still retires refs.
|
|
2792
|
+
const VOLATILE_CHECKOUT_PATH_RULES = [
|
|
2793
|
+
// Shopify hosted checkout, current (`…/checkouts/cn/<token>[/<step>]`) and
|
|
2794
|
+
// legacy (`…/checkouts/c|co/<token>[/<step>]`), under any locale/shop prefix.
|
|
2795
|
+
// The marker must be a whole segment, so a token that merely starts with "c"
|
|
2796
|
+
// cannot be split across the capture.
|
|
2797
|
+
/^(.*\/checkouts\/c[no]?)\/([^/]+)(?:\/.*)?$/i,
|
|
2798
|
+
// Older Shopify: `/<shop-id>/checkouts/<token>[/<step>]`.
|
|
2799
|
+
/^(.*\/checkouts)\/([^/]+)(?:\/.*)?$/i,
|
|
2800
|
+
];
|
|
2801
|
+
/**
|
|
2802
|
+
* Generated, not authored. An authored path slug under `/checkouts/` (a docs
|
|
2803
|
+
* page, a marketing route) must keep its own identity, so the token has to look
|
|
2804
|
+
* minted: long, in the URL-safe token alphabet, and carrying a digit.
|
|
2805
|
+
*/
|
|
2806
|
+
function looksLikeVolatileCheckoutToken(segment) {
|
|
2807
|
+
return segment.length >= 16 && /^[A-Za-z0-9_-]+$/.test(segment) && /\d/.test(segment);
|
|
2808
|
+
}
|
|
2809
|
+
/**
|
|
2810
|
+
* Collapse a known-volatile checkout path onto one logical-page key. The step
|
|
2811
|
+
* suffix collapses with the token: inside a single checkout the steps are
|
|
2812
|
+
* same-document SPA routing, and a move to a DIFFERENT checkout replaces the
|
|
2813
|
+
* document, which the primary document-identity signal catches.
|
|
2814
|
+
*/
|
|
2815
|
+
function normalizeVolatileCheckoutPath(pathname) {
|
|
2816
|
+
for (const rule of VOLATILE_CHECKOUT_PATH_RULES) {
|
|
2817
|
+
const match = rule.exec(pathname);
|
|
2818
|
+
if (match !== null && looksLikeVolatileCheckoutToken(match[2])) {
|
|
2819
|
+
return `${match[1]}/:checkout`;
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
return pathname;
|
|
2823
|
+
}
|
|
2824
|
+
// The `doc` half of the observation epoch (docs/observation-model.md §4.1):
|
|
2825
|
+
// the browser's stable main-document identity, not the URL. The full URL is too
|
|
2826
|
+
// volatile to key on — live checkouts (e.g. Shopify) rotate a token in the
|
|
2827
|
+
// query string AND in the path on every step re-render, which would invalidate
|
|
2828
|
+
// every ref between two acts. A NORMALIZED origin+pathname is folded in as a
|
|
2829
|
+
// fail-closed backstop for a host whose document identity does not move on a
|
|
2830
|
+
// logical page change; query-string, fragment, and known-volatile checkout
|
|
2831
|
+
// token churn on the same logical page must not invalidate.
|
|
2832
|
+
function compactV2EpochDoc(session) {
|
|
2833
|
+
let location = session.browser.currentUrl();
|
|
2834
|
+
try {
|
|
2835
|
+
const parsed = new URL(location);
|
|
2836
|
+
if (parsed.origin !== "null" && parsed.origin !== "")
|
|
2837
|
+
location = `${parsed.origin}${normalizeVolatileCheckoutPath(parsed.pathname)}`;
|
|
2838
|
+
}
|
|
2839
|
+
catch { }
|
|
2840
|
+
return createHmac("sha256", session.compactV2Secret)
|
|
2841
|
+
.update(`${session.browser.mainDocumentIdentity()}\u0000${location}`)
|
|
2842
|
+
.digest("base64url");
|
|
2843
|
+
}
|
|
2844
|
+
/**
|
|
2845
|
+
* Mint the durable, document-scoped handle for every element in one inventory.
|
|
2846
|
+
* The session HMAC makes a handle unforgeable and unlinkable across sessions;
|
|
2847
|
+
* folding the epoch's `doc` into it is what makes a ref die on a real
|
|
2848
|
+
* navigation without any snapshot bookkeeping — a handle simply stops being
|
|
2849
|
+
* producible by any live element once the document is replaced.
|
|
2850
|
+
*/
|
|
2851
|
+
function compactV2Handles(session, elements) {
|
|
2852
|
+
const doc = compactV2EpochDoc(session);
|
|
2853
|
+
const handles = new Map();
|
|
2854
|
+
for (const [element, fingerprint] of elementFingerprints(elements)) {
|
|
2855
|
+
const digest = createHmac("sha256", session.compactV2Secret)
|
|
2856
|
+
.update(`${doc}\u001f${fingerprint}`)
|
|
2857
|
+
.digest("base64url")
|
|
2858
|
+
.slice(0, COMPACT_V2_HANDLE_LENGTH);
|
|
2859
|
+
handles.set(element, `@e:${digest}`);
|
|
2860
|
+
}
|
|
2861
|
+
return handles;
|
|
2862
|
+
}
|
|
2863
|
+
/** The live skeleton for an element inventory, under the session's epoch. */
|
|
2864
|
+
function compactV2LiveControls(session, elements) {
|
|
2865
|
+
let pageOrigin = "";
|
|
2866
|
+
try {
|
|
2867
|
+
pageOrigin = new URL(session.browser.currentUrl()).origin;
|
|
2868
|
+
}
|
|
2869
|
+
catch { }
|
|
2870
|
+
return buildSafeControlsV2({
|
|
2871
|
+
elements,
|
|
2872
|
+
legacyRefs: provisionElementRefs(elements),
|
|
2873
|
+
handles: compactV2Handles(session, elements),
|
|
2874
|
+
pageOrigin,
|
|
2875
|
+
pageUrl: session.browser.currentUrl(),
|
|
2876
|
+
});
|
|
2877
|
+
}
|
|
2878
|
+
function parseCompactV2Cursor(session, cursor, expectedScope) {
|
|
2879
|
+
const [body, signature, extra] = cursor.split(".");
|
|
2880
|
+
if (body === undefined || signature === undefined || extra !== undefined)
|
|
2881
|
+
throw new Error("invalid_cursor");
|
|
2882
|
+
const expected = createHmac("sha256", session.compactV2Secret)
|
|
2883
|
+
.update(body)
|
|
2884
|
+
.digest("base64url")
|
|
2885
|
+
.slice(0, 12);
|
|
2886
|
+
if (signature !== expected)
|
|
2887
|
+
throw new Error("invalid_cursor");
|
|
2888
|
+
const [revRaw, offsetRaw, scope, extraPart] = body.split(":");
|
|
2889
|
+
if (revRaw === undefined ||
|
|
2890
|
+
offsetRaw === undefined ||
|
|
2891
|
+
scope === undefined ||
|
|
2892
|
+
extraPart !== undefined ||
|
|
2893
|
+
scope !== expectedScope) {
|
|
2894
|
+
throw new Error("invalid_cursor");
|
|
2895
|
+
}
|
|
2896
|
+
const rev = Number.parseInt(revRaw, 36);
|
|
2897
|
+
const offset = Number.parseInt(offsetRaw, 36);
|
|
2898
|
+
if (!Number.isSafeInteger(rev) ||
|
|
2899
|
+
!Number.isSafeInteger(offset) ||
|
|
2900
|
+
offset < 0 ||
|
|
2901
|
+
rev.toString(36) !== revRaw ||
|
|
2902
|
+
offset.toString(36) !== offsetRaw) {
|
|
2903
|
+
throw new Error("stale_cursor");
|
|
2904
|
+
}
|
|
2905
|
+
return { rev, offset };
|
|
2906
|
+
}
|
|
2907
|
+
function compactV2HintPage(session, index, offset) {
|
|
2908
|
+
const hint = session.compactV2HintPages[offset];
|
|
2909
|
+
if (hint === undefined)
|
|
2910
|
+
throw new Error("invalid_cursor");
|
|
2911
|
+
const nextOffset = offset + 1;
|
|
2912
|
+
const remaining = session.compactV2HintPages.length - nextOffset;
|
|
2913
|
+
const payload = {
|
|
2914
|
+
format: "compact-v2",
|
|
2915
|
+
url: "",
|
|
2916
|
+
text: "",
|
|
2917
|
+
session_id: session.id,
|
|
2918
|
+
stage: index.stage,
|
|
2919
|
+
hint,
|
|
2920
|
+
...(remaining > 0
|
|
2921
|
+
? {
|
|
2922
|
+
hint_overflow: {
|
|
2923
|
+
remaining,
|
|
2924
|
+
next_cursor: compactV2Cursor(session, index.epoch.rev, nextOffset, compactV2HintCursorScope(session)),
|
|
2925
|
+
},
|
|
2926
|
+
}
|
|
2927
|
+
: {}),
|
|
2928
|
+
};
|
|
2929
|
+
if (!compactV2PayloadWithinBudget(payload)) {
|
|
2930
|
+
throw new Error("compact-v2 budget metadata exceeded");
|
|
2931
|
+
}
|
|
2932
|
+
return payload;
|
|
2933
|
+
}
|
|
2934
|
+
function compactV2PublicObservation(session, legacy, fields) {
|
|
2935
|
+
if (session.compactV2Mode !== "on")
|
|
2936
|
+
return legacy();
|
|
2937
|
+
session.compactV2Active = true;
|
|
2938
|
+
const payload = {
|
|
2939
|
+
format: "compact-v2",
|
|
2940
|
+
session_id: session.id,
|
|
2941
|
+
url: session.browser.currentUrl(),
|
|
2942
|
+
text: "",
|
|
2943
|
+
stage: fields.stage,
|
|
2944
|
+
...(fields.guidance === undefined ? {} : { guidance: fields.guidance }),
|
|
2945
|
+
...(fields.oauth === undefined ? {} : { oauth: fields.oauth }),
|
|
2946
|
+
...(fields.observed === undefined ? {} : { observed: fields.observed }),
|
|
2947
|
+
};
|
|
2948
|
+
if (!compactV2PayloadWithinBudget(payload)) {
|
|
2949
|
+
throw new Error("compact-v2 budget metadata exceeded");
|
|
2950
|
+
}
|
|
2951
|
+
return payload;
|
|
2952
|
+
}
|
|
2953
|
+
function compactV2Observation(session, generation, elements, semanticSource, startMetadata) {
|
|
2954
|
+
if (startMetadata?.hintPages !== undefined) {
|
|
2955
|
+
session.compactV2HintPages = [...startMetadata.hintPages];
|
|
2956
|
+
}
|
|
2957
|
+
const stage = safeStageV2(session.browser.currentUrl(), elements);
|
|
2958
|
+
const semantics = safePageSemanticsV2(semanticSource);
|
|
2959
|
+
const epochDoc = compactV2EpochDoc(session);
|
|
2960
|
+
const previous = session.compactV2Previous;
|
|
2961
|
+
const sameDocument = previous !== null && previous.epoch.doc === epochDoc;
|
|
2962
|
+
const safe = compactV2LiveControls(session, elements);
|
|
2963
|
+
let delta = sameDocument ? diffSafeControlsV2(previous, stage, safe.rows) : null;
|
|
2964
|
+
const privateBindingsChanged = sameDocument &&
|
|
2965
|
+
(session.compactV2Refs.size !== safe.byRef.size ||
|
|
2966
|
+
[...safe.byRef].some(([ref, legacy]) => session.compactV2Refs.get(ref) !== legacy));
|
|
2967
|
+
const requiresResync = !sameDocument ||
|
|
2968
|
+
delta === null ||
|
|
2969
|
+
delta.stageChanged ||
|
|
2970
|
+
privateBindingsChanged ||
|
|
2971
|
+
delta.added.length > 0 ||
|
|
2972
|
+
delta.changed.length > 0 ||
|
|
2973
|
+
delta.removed.length > 0;
|
|
2974
|
+
if (requiresResync)
|
|
2975
|
+
delta = null;
|
|
2976
|
+
// The epoch's DOM-mutation counter advances only when the skeleton actually
|
|
2977
|
+
// changed. Refs are fingerprint-derived and survive it; paging cursors are
|
|
2978
|
+
// positional and bound to it, so a cursor minted before a re-render dies.
|
|
2979
|
+
const epoch = {
|
|
2980
|
+
doc: epochDoc,
|
|
2981
|
+
rev: sameDocument && !requiresResync ? previous.epoch.rev : generation,
|
|
2982
|
+
};
|
|
2983
|
+
const index = {
|
|
2984
|
+
epoch,
|
|
2985
|
+
stage,
|
|
2986
|
+
semantics,
|
|
2987
|
+
rows: safe.rows,
|
|
2988
|
+
byRef: safe.byRef,
|
|
2989
|
+
expiresAt: Date.now() + 5 * 60_000,
|
|
2990
|
+
};
|
|
2991
|
+
// Raw DOM values fall out of scope here.
|
|
2992
|
+
// Only this enum-only index survives to delta/query/action resolution.
|
|
2993
|
+
session.compactV2Active = true;
|
|
2994
|
+
session.compactV2Index = index;
|
|
2995
|
+
session.compactV2Refs = safe.byRef;
|
|
2996
|
+
session.compactV2Previous = {
|
|
2997
|
+
epoch,
|
|
2998
|
+
stage,
|
|
2999
|
+
semantics,
|
|
3000
|
+
byRef: new Map(safe.rows.map((row) => [row.ref, row])),
|
|
3001
|
+
};
|
|
3002
|
+
session.prevObserve = null;
|
|
3003
|
+
if (previous !== null && !requiresResync && delta !== null) {
|
|
3004
|
+
const encodedDelta = encodeV2Delta({
|
|
3005
|
+
sessionId: session.id,
|
|
3006
|
+
stage,
|
|
3007
|
+
pageUrl: session.browser.currentUrl(),
|
|
3008
|
+
// The first V2 page establishes semantic essentials. On a delta they
|
|
3009
|
+
// are sticky, so resend only a sealed semantic change rather than the
|
|
3010
|
+
// same title/heading on every harmless re-observe.
|
|
3011
|
+
semantics: equalSafePageSemanticsV2(previous.semantics, semantics) ? undefined : semantics,
|
|
3012
|
+
delta,
|
|
3013
|
+
});
|
|
3014
|
+
// A high-churn delta is less useful than a fresh paged map. This also
|
|
3015
|
+
// guarantees any overflow remains in the MCP cursor protocol.
|
|
3016
|
+
if (encodedDelta !== null)
|
|
3017
|
+
return encodedDelta;
|
|
3018
|
+
}
|
|
3019
|
+
const page = encodeV2Page({
|
|
3020
|
+
sessionId: session.id,
|
|
3021
|
+
stage: index.stage,
|
|
3022
|
+
pageUrl: session.browser.currentUrl(),
|
|
3023
|
+
semantics,
|
|
3024
|
+
rows: index.rows,
|
|
3025
|
+
cursorFor: (offset) => compactV2Cursor(session, epoch.rev, offset, compactV2QueryCursorScope(session, "", undefined)),
|
|
3026
|
+
...(startMetadata === undefined
|
|
3027
|
+
? {}
|
|
3028
|
+
: {
|
|
3029
|
+
startMetadata: {
|
|
3030
|
+
...(session.compactV2HintPages[0] === undefined
|
|
3031
|
+
? {}
|
|
3032
|
+
: { hint: session.compactV2HintPages[0] }),
|
|
3033
|
+
...(startMetadata.userEmail === undefined
|
|
3034
|
+
? {}
|
|
3035
|
+
: { userEmail: startMetadata.userEmail }),
|
|
3036
|
+
...(session.compactV2HintPages.length <= 1
|
|
3037
|
+
? {}
|
|
3038
|
+
: {
|
|
3039
|
+
hintOverflow: {
|
|
3040
|
+
remaining: session.compactV2HintPages.length - 1,
|
|
3041
|
+
next_cursor: compactV2Cursor(session, epoch.rev, 1, compactV2HintCursorScope(session)),
|
|
3042
|
+
},
|
|
3043
|
+
}),
|
|
3044
|
+
},
|
|
3045
|
+
}),
|
|
3046
|
+
});
|
|
3047
|
+
return page.payload;
|
|
3048
|
+
}
|
|
3049
|
+
function exerciseCompactV2Shadow(session, generation, elements, semanticSource) {
|
|
3050
|
+
const saved = {
|
|
3051
|
+
compactV2Active: session.compactV2Active,
|
|
3052
|
+
compactV2Index: session.compactV2Index,
|
|
3053
|
+
compactV2Refs: session.compactV2Refs,
|
|
3054
|
+
compactV2Previous: session.compactV2Previous,
|
|
3055
|
+
prevObserve: session.prevObserve,
|
|
3056
|
+
};
|
|
3057
|
+
try {
|
|
3058
|
+
compactV2Observation(session, generation, elements, semanticSource);
|
|
3059
|
+
}
|
|
3060
|
+
catch {
|
|
3061
|
+
}
|
|
3062
|
+
finally {
|
|
3063
|
+
session.compactV2Active = saved.compactV2Active;
|
|
3064
|
+
session.compactV2Index = saved.compactV2Index;
|
|
3065
|
+
session.compactV2Refs = saved.compactV2Refs;
|
|
3066
|
+
session.compactV2Previous = saved.compactV2Previous;
|
|
3067
|
+
session.prevObserve = saved.prevObserve;
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
export async function observeQuery(sessionId, query, role, cursor) {
|
|
3071
|
+
const session = sessionForCall(sessionId);
|
|
3072
|
+
if (session === undefined)
|
|
3073
|
+
throw new Error(`unknown provision session ${sessionId}`);
|
|
3074
|
+
const index = session.compactV2Index;
|
|
3075
|
+
if (index === null || index.expiresAt < Date.now())
|
|
3076
|
+
throw new Error("stale_cursor");
|
|
3077
|
+
// Query/paging is part of the same sealed action-map protocol: never return
|
|
3078
|
+
// rows from a page whose private binding no longer matches the live page.
|
|
3079
|
+
if (index.epoch.doc !== compactV2EpochDoc(session)) {
|
|
3080
|
+
invalidateCompactV2Snapshot(session);
|
|
3081
|
+
throw new Error("stale_cursor");
|
|
3082
|
+
}
|
|
3083
|
+
const needle = norm(query);
|
|
3084
|
+
const cursorScope = compactV2QueryCursorScope(session, needle, role);
|
|
3085
|
+
let offset = 0;
|
|
3086
|
+
if (cursor !== undefined) {
|
|
3087
|
+
if (needle.length === 0 && role === undefined && session.compactV2HintPages.length > 0) {
|
|
3088
|
+
try {
|
|
3089
|
+
const parsed = parseCompactV2Cursor(session, cursor, compactV2HintCursorScope(session));
|
|
3090
|
+
if (parsed.rev !== index.epoch.rev)
|
|
3091
|
+
throw new Error("stale_cursor");
|
|
3092
|
+
return compactV2HintPage(session, index, parsed.offset);
|
|
3093
|
+
}
|
|
3094
|
+
catch (error) {
|
|
3095
|
+
if (!(error instanceof Error) || error.message !== "invalid_cursor")
|
|
3096
|
+
throw error;
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
const parsed = parseCompactV2Cursor(session, cursor, cursorScope);
|
|
3100
|
+
if (parsed.rev !== index.epoch.rev)
|
|
3101
|
+
throw new Error("stale_cursor");
|
|
3102
|
+
offset = parsed.offset;
|
|
3103
|
+
}
|
|
3104
|
+
const liveElements = await session.browser.extractInteractiveElements();
|
|
3105
|
+
const liveSafe = compactV2LiveControls(session, liveElements);
|
|
3106
|
+
const liveUnchanged = liveSafe.rows.length === index.rows.length &&
|
|
3107
|
+
liveSafe.byRef.size === session.compactV2Refs.size &&
|
|
3108
|
+
liveSafe.rows.every((row, rowIndex) => sameCompactV2Control(row, index.rows[rowIndex])) &&
|
|
3109
|
+
[...liveSafe.byRef].every(([ref, legacy]) => session.compactV2Refs.get(ref) === legacy);
|
|
3110
|
+
let snapshotRows = index.rows;
|
|
3111
|
+
let pagingStage = index.stage;
|
|
3112
|
+
let pagingRev = index.epoch.rev;
|
|
3113
|
+
if (!liveUnchanged) {
|
|
3114
|
+
// Benign live re-render (validation states appearing, dynamic fields
|
|
3115
|
+
// toggling, rotating checkout query tokens): re-serialize the same
|
|
3116
|
+
// document instead of failing the page. Refs are unaffected — they are
|
|
3117
|
+
// fingerprint-derived — but the positional page offsets are not, so the
|
|
3118
|
+
// epoch's revision advances and cursors minted here bind to the fresh map.
|
|
3119
|
+
session.generation += 1;
|
|
3120
|
+
pagingRev = session.generation;
|
|
3121
|
+
pagingStage = safeStageV2(session.browser.currentUrl(), liveElements);
|
|
3122
|
+
snapshotRows = liveSafe.rows;
|
|
3123
|
+
const epoch = { doc: index.epoch.doc, rev: pagingRev };
|
|
3124
|
+
session.compactV2Index = {
|
|
3125
|
+
epoch,
|
|
3126
|
+
stage: pagingStage,
|
|
3127
|
+
semantics: index.semantics,
|
|
3128
|
+
rows: liveSafe.rows,
|
|
3129
|
+
byRef: liveSafe.byRef,
|
|
3130
|
+
expiresAt: Date.now() + 5 * 60_000,
|
|
3131
|
+
};
|
|
3132
|
+
session.compactV2Refs = liveSafe.byRef;
|
|
3133
|
+
session.compactV2Previous = {
|
|
3134
|
+
epoch,
|
|
3135
|
+
stage: pagingStage,
|
|
3136
|
+
semantics: index.semantics,
|
|
3137
|
+
byRef: new Map(liveSafe.rows.map((row) => [row.ref, row])),
|
|
3138
|
+
};
|
|
3139
|
+
}
|
|
3140
|
+
const liveByLegacy = new Map();
|
|
3141
|
+
for (const [element, legacy] of provisionElementRefs(liveElements)) {
|
|
3142
|
+
liveByLegacy.set(legacy, element);
|
|
3143
|
+
}
|
|
3144
|
+
const privateMatches = new Set();
|
|
3145
|
+
if (needle.length > 0) {
|
|
3146
|
+
for (const [ref, legacy] of session.compactV2Refs) {
|
|
3147
|
+
const element = liveByLegacy.get(legacy);
|
|
3148
|
+
if (element !== undefined && controlMatchesPrivateQueryV2(element, query)) {
|
|
3149
|
+
privateMatches.add(ref);
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
const rows = snapshotRows.filter((row) => {
|
|
3154
|
+
const searchable = [
|
|
3155
|
+
row.label,
|
|
3156
|
+
row.role,
|
|
3157
|
+
row.state,
|
|
3158
|
+
row.visibility,
|
|
3159
|
+
row.action,
|
|
3160
|
+
row.field,
|
|
3161
|
+
row.choice,
|
|
3162
|
+
row.frame,
|
|
3163
|
+
]
|
|
3164
|
+
.filter((value) => value !== undefined)
|
|
3165
|
+
.map(norm);
|
|
3166
|
+
return ((needle.length === 0 ||
|
|
3167
|
+
searchable.some((value) => value.includes(needle)) ||
|
|
3168
|
+
privateMatches.has(row.ref)) &&
|
|
3169
|
+
(role === undefined || row.role === role));
|
|
3170
|
+
});
|
|
3171
|
+
const page = encodeV2Page({
|
|
3172
|
+
sessionId: session.id,
|
|
3173
|
+
stage: pagingStage,
|
|
3174
|
+
pageUrl: session.browser.currentUrl(),
|
|
3175
|
+
semantics: index.semantics,
|
|
3176
|
+
rows,
|
|
3177
|
+
offset,
|
|
3178
|
+
cursorFor: (next) => compactV2Cursor(session, pagingRev, next, cursorScope),
|
|
3179
|
+
});
|
|
3180
|
+
return page.payload;
|
|
3181
|
+
}
|
|
3182
|
+
async function observeSession(session, detail = "compact", startMetadata) {
|
|
3183
|
+
const oauthInProgress = () => {
|
|
3184
|
+
session.prevObserve = null;
|
|
3185
|
+
invalidateCompactV2Snapshot(session);
|
|
3186
|
+
const oauth = session.browser.oauthTransitionStatus?.();
|
|
3187
|
+
const guidance = "OAuth in progress: the provider detached or closed its page as expected. " +
|
|
3188
|
+
"Do not switch login methods or close the session; call operate_observe again to read the retained product page.";
|
|
3189
|
+
const state = {
|
|
3190
|
+
state: "in_progress",
|
|
3191
|
+
provider_page: "closed_or_detached",
|
|
3192
|
+
next_action: "operate_observe",
|
|
3193
|
+
};
|
|
3194
|
+
session.browser.completeOAuthTransitionRecovery();
|
|
3195
|
+
return compactV2PublicObservation(session, () => ({
|
|
3196
|
+
session_id: session.id,
|
|
3197
|
+
url: oauth?.productUrl ?? session.startUrl,
|
|
3198
|
+
text: "",
|
|
3199
|
+
guidance,
|
|
3200
|
+
elements: [],
|
|
3201
|
+
oauth: state,
|
|
3202
|
+
}), { stage: "auth", guidance, oauth: state });
|
|
3203
|
+
};
|
|
3204
|
+
try {
|
|
3205
|
+
session.browser.recoverActivePage();
|
|
3206
|
+
const transition = session.browser.oauthTransitionStatus?.();
|
|
3207
|
+
if (transition?.providerPageClosed === true &&
|
|
3208
|
+
transition.productPageViable &&
|
|
3209
|
+
transition.browserConnected) {
|
|
3210
|
+
return oauthInProgress();
|
|
3080
3211
|
}
|
|
3081
3212
|
widenAllowedHostsFromCurrentUrl(session);
|
|
3082
3213
|
session.generation += 1;
|
|
3083
3214
|
const generation = session.generation;
|
|
3084
3215
|
const elements = await session.browser.extractInteractiveElements();
|
|
3085
|
-
session
|
|
3086
|
-
|
|
3087
|
-
|
|
3216
|
+
retainSessionElements(session, elements);
|
|
3217
|
+
let semanticSource = { title: "", headings: [] };
|
|
3218
|
+
try {
|
|
3219
|
+
semanticSource = await session.browser.extractObservationSemantics();
|
|
3220
|
+
}
|
|
3221
|
+
catch {
|
|
3222
|
+
// Semantic context is optional availability-wise; it is independently
|
|
3223
|
+
// sealed below and never changes action-map safety.
|
|
3224
|
+
}
|
|
3225
|
+
// Native TypeScript compact serializer over TS's own CDP-derived DOM
|
|
3226
|
+
// inventory. Its allowlist seal runs before any retained/emitted view; no
|
|
3227
|
+
// Python subprocess or externally provisioned runtime participates.
|
|
3228
|
+
const v2Mode = session.compactV2Mode;
|
|
3229
|
+
if (v2Mode === "on") {
|
|
3230
|
+
return compactV2Observation(session, generation, elements, semanticSource, startMetadata);
|
|
3231
|
+
}
|
|
3232
|
+
if (v2Mode === "shadow")
|
|
3233
|
+
exerciseCompactV2Shadow(session, generation, elements, semanticSource);
|
|
3234
|
+
session.compactV2Active = false;
|
|
3235
|
+
invalidateCompactV2Snapshot(session);
|
|
3236
|
+
const text = await session.browser.extractVisibleText();
|
|
3088
3237
|
const normalizedFull = text.replace(/\s+/g, " ").trim();
|
|
3089
3238
|
const normalizedText = normalizedFull.slice(0, 4000);
|
|
3090
3239
|
const guidance = provisionPerceptionGuidance(normalizedText);
|
|
@@ -3104,8 +3253,6 @@ async function observeSession(session, detail = "compact") {
|
|
|
3104
3253
|
textTruncated,
|
|
3105
3254
|
...(guidance !== undefined ? { guidance } : {}),
|
|
3106
3255
|
elements,
|
|
3107
|
-
sealed: sealedFieldKeys,
|
|
3108
|
-
paymentSealActive: session.paymentFieldSealActive,
|
|
3109
3256
|
prev: session.prevObserve,
|
|
3110
3257
|
});
|
|
3111
3258
|
// Persist the COMPLETE snapshot (path INCLUDED) — the safety net that makes
|
|
@@ -3152,9 +3299,9 @@ async function observeSession(session, detail = "compact") {
|
|
|
3152
3299
|
// full-only observe can't restore stale state (the previous compact snapshot).
|
|
3153
3300
|
// Deliberately NOT surfaced in the payload — the full escape hatch stays
|
|
3154
3301
|
// byte-equivalent to the legacy shape (no snapshot_file field added).
|
|
3155
|
-
persistObserveSnapshot(session, generation, url, normalizedText, textTruncated, elements.map((el) => toCompactElement(el, refOf(el),
|
|
3156
|
-
const screen = buildScreenOutline(elements, normalizedText
|
|
3157
|
-
const accessibility = buildAccessibilitySnapshot(elements
|
|
3302
|
+
persistObserveSnapshot(session, generation, url, normalizedText, textTruncated, elements.map((el) => toCompactElement(el, refOf(el), true, false)));
|
|
3303
|
+
const screen = buildScreenOutline(elements, normalizedText);
|
|
3304
|
+
const accessibility = buildAccessibilitySnapshot(elements);
|
|
3158
3305
|
return withCheckoutState({
|
|
3159
3306
|
session_id: session.id,
|
|
3160
3307
|
url,
|
|
@@ -3165,35 +3312,35 @@ async function observeSession(session, detail = "compact") {
|
|
|
3165
3312
|
elements: elements.map((el) => {
|
|
3166
3313
|
const observed = {
|
|
3167
3314
|
ref: refOf(el),
|
|
3168
|
-
label:
|
|
3169
|
-
tag:
|
|
3315
|
+
label: elementRef(el),
|
|
3316
|
+
tag: el.tag,
|
|
3170
3317
|
role: el.role === null
|
|
3171
3318
|
? null
|
|
3172
|
-
:
|
|
3319
|
+
: el.role,
|
|
3173
3320
|
type: el.type === null
|
|
3174
3321
|
? null
|
|
3175
|
-
:
|
|
3176
|
-
value:
|
|
3322
|
+
: el.type,
|
|
3323
|
+
value: (el.value ?? null),
|
|
3177
3324
|
checked: el.checked ?? null,
|
|
3178
3325
|
href: el.href === null || el.href === undefined
|
|
3179
3326
|
? null
|
|
3180
|
-
:
|
|
3327
|
+
: el.href,
|
|
3181
3328
|
testId: el.testId === null || el.testId === undefined
|
|
3182
3329
|
? null
|
|
3183
|
-
:
|
|
3330
|
+
: el.testId,
|
|
3184
3331
|
path: el.screenPath === null || el.screenPath === undefined
|
|
3185
3332
|
? null
|
|
3186
|
-
:
|
|
3333
|
+
: el.screenPath,
|
|
3187
3334
|
container: el.container === null || el.container === undefined
|
|
3188
3335
|
? null
|
|
3189
|
-
:
|
|
3336
|
+
: el.container,
|
|
3190
3337
|
topmost: el.topmost ?? null,
|
|
3191
3338
|
occluded_by: el.occludedBy === null || el.occludedBy === undefined
|
|
3192
3339
|
? null
|
|
3193
|
-
:
|
|
3340
|
+
: el.occludedBy,
|
|
3194
3341
|
frame_origin: el.frameOrigin === null || el.frameOrigin === undefined
|
|
3195
3342
|
? null
|
|
3196
|
-
:
|
|
3343
|
+
: el.frameOrigin,
|
|
3197
3344
|
};
|
|
3198
3345
|
annotatePaymentControl(observed, el);
|
|
3199
3346
|
return observed;
|
|
@@ -3309,7 +3456,42 @@ async function runClickWithPlaceOrderGuard(session, click) {
|
|
|
3309
3456
|
}
|
|
3310
3457
|
await recordPlaceOrderAttemptAudit(session, approval);
|
|
3311
3458
|
}
|
|
3459
|
+
async function actInternally(sessionId, action, detail = "compact", cartIdentity, collectCheckoutState = false, compactV2Authorization) {
|
|
3460
|
+
const session = sessionForCall(sessionId);
|
|
3461
|
+
const oauthProvider = action.kind === "oauth_login" || action.kind === "oauth_click" ? action.provider : undefined;
|
|
3462
|
+
try {
|
|
3463
|
+
const execute = async (deadline) => await executeAct(sessionId, action, detail, cartIdentity, true, collectCheckoutState, compactV2Authorization, deadline);
|
|
3464
|
+
return session !== undefined && (action.kind === "oauth_login" || action.kind === "oauth_click")
|
|
3465
|
+
? await withOAuthActionBoundary(session, oauthProvider, execute)
|
|
3466
|
+
: await execute(undefined);
|
|
3467
|
+
}
|
|
3468
|
+
catch (error) {
|
|
3469
|
+
if (session?.compactV2Active === true &&
|
|
3470
|
+
!(error instanceof ManualCardEntryBlockedError) &&
|
|
3471
|
+
!(error instanceof ProvisionTargetMissingError)) {
|
|
3472
|
+
throw new CompactV2ActionFailureError(compactV2ActionFailureReason(error, action.kind));
|
|
3473
|
+
}
|
|
3474
|
+
throw error;
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3312
3477
|
export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
3478
|
+
const session = sessionForCall(sessionId);
|
|
3479
|
+
const oauthProvider = action.kind === "oauth_login" || action.kind === "oauth_click" ? action.provider : undefined;
|
|
3480
|
+
try {
|
|
3481
|
+
const execute = async (deadline) => await executeAct(sessionId, action, detail, cartIdentity, false, false, undefined, deadline);
|
|
3482
|
+
const result = session !== undefined && (action.kind === "oauth_login" || action.kind === "oauth_click")
|
|
3483
|
+
? await withOAuthActionBoundary(session, oauthProvider, execute)
|
|
3484
|
+
: await execute(undefined);
|
|
3485
|
+
return result.observation;
|
|
3486
|
+
}
|
|
3487
|
+
catch (error) {
|
|
3488
|
+
if (session?.compactV2Active === true) {
|
|
3489
|
+
throw new Error(compactV2ActionFailureReason(error, action.kind));
|
|
3490
|
+
}
|
|
3491
|
+
throw error;
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
async function executeAct(sessionId, action, detail, cartIdentity, internalAccess, collectCheckoutState, internalAuthorization, oauthDeadline) {
|
|
3313
3495
|
const session = sessionForCall(sessionId);
|
|
3314
3496
|
if (session === undefined)
|
|
3315
3497
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
@@ -3325,7 +3507,7 @@ export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
|
3325
3507
|
if (cardBlock !== null)
|
|
3326
3508
|
throw new ManualCardEntryBlockedError(cardBlock);
|
|
3327
3509
|
}
|
|
3328
|
-
|
|
3510
|
+
let browser = session.browser;
|
|
3329
3511
|
let completedAction = action;
|
|
3330
3512
|
let sensitiveSource;
|
|
3331
3513
|
let cartAffecting = false;
|
|
@@ -3338,15 +3520,37 @@ export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
|
3338
3520
|
cartAffecting = true;
|
|
3339
3521
|
cartIdentity.onActionReady?.();
|
|
3340
3522
|
};
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3523
|
+
let resolutionTarget;
|
|
3524
|
+
let auditTarget;
|
|
3525
|
+
let compactV2Authorization = internalAuthorization;
|
|
3526
|
+
if ("target" in action) {
|
|
3527
|
+
if (session.compactV2Active && !internalAccess) {
|
|
3528
|
+
try {
|
|
3529
|
+
compactV2Authorization = compactV2AuthorizationForTarget(session, action.target);
|
|
3530
|
+
resolutionTarget = compactV2Authorization.legacyRef;
|
|
3531
|
+
auditTarget = action.target;
|
|
3532
|
+
}
|
|
3533
|
+
catch (error) {
|
|
3534
|
+
audit(sessionId, "act", { kind: action.kind, target: "<rejected-v2-target>" });
|
|
3535
|
+
throw error;
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
else {
|
|
3539
|
+
resolutionTarget = action.target;
|
|
3540
|
+
auditTarget =
|
|
3541
|
+
session.compactV2Active && internalAccess
|
|
3542
|
+
? "<internal-target>"
|
|
3543
|
+
: parseLocatorTarget(action.target) !== null
|
|
3544
|
+
? "<mode>=<redacted>"
|
|
3545
|
+
: action.target;
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3346
3548
|
audit(sessionId, "act", {
|
|
3347
3549
|
kind: action.kind,
|
|
3348
3550
|
...(auditTarget !== undefined ? { target: auditTarget } : {}),
|
|
3349
|
-
...("url" in action
|
|
3551
|
+
...("url" in action
|
|
3552
|
+
? { url: session.compactV2Active ? compactV2AuditUrl(action.url) : action.url }
|
|
3553
|
+
: {}),
|
|
3350
3554
|
});
|
|
3351
3555
|
// The URL the action is taken ON — captured BEFORE the action navigates. The
|
|
3352
3556
|
// capture round pairs this with the pre-action inventory + observed; using
|
|
@@ -3368,7 +3572,7 @@ export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
|
3368
3572
|
curHost = null;
|
|
3369
3573
|
}
|
|
3370
3574
|
if (curHost !== null && isSquireControlPlaneHost(curHost)) {
|
|
3371
|
-
throw new
|
|
3575
|
+
throw new ProvisionTargetNotAllowedError(`action refused: the browser is on Squire's own control plane (${curHost}). ` +
|
|
3372
3576
|
`The operator may not act on the Trusty Squire vault/app — navigate away with goto.`);
|
|
3373
3577
|
}
|
|
3374
3578
|
}
|
|
@@ -3376,410 +3580,486 @@ export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
|
3376
3580
|
// Captured for the operator-recipe trace: the element a target action
|
|
3377
3581
|
// resolved to, so we record the VISIBLE text it acted on (not the ref).
|
|
3378
3582
|
let resolvedEl = null;
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
`
|
|
3384
|
-
|
|
3583
|
+
try {
|
|
3584
|
+
switch (action.kind) {
|
|
3585
|
+
case "goto": {
|
|
3586
|
+
if (!hostAllowed(action.url, hostStrings(session))) {
|
|
3587
|
+
throw new ProvisionTargetNotAllowedError(`goto blocked by domain-scope: ${action.url} is outside the allowed hosts ` +
|
|
3588
|
+
`[${hostStrings(session).join(", ")}] + auth providers. ` +
|
|
3589
|
+
`Declare it first with an allow_host action if this task spans it.`);
|
|
3590
|
+
}
|
|
3591
|
+
await browser.goto(action.url);
|
|
3592
|
+
break;
|
|
3385
3593
|
}
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3594
|
+
case "allow_host": {
|
|
3595
|
+
const checked = validateAllowHost(action.host);
|
|
3596
|
+
if ("error" in checked) {
|
|
3597
|
+
throw new ProvisionTargetNotAllowedError(`allow_host rejected "${action.host}": ${checked.error}`);
|
|
3598
|
+
}
|
|
3599
|
+
if (!session.allowedHosts.some((e) => e.host === checked.host)) {
|
|
3600
|
+
session.allowedHosts.push({ host: checked.host, source: "mid_session" });
|
|
3601
|
+
audit(sessionId, "allow_host", {
|
|
3602
|
+
host: checked.host,
|
|
3603
|
+
allowed_hosts: hostStrings(session),
|
|
3604
|
+
});
|
|
3605
|
+
}
|
|
3606
|
+
break;
|
|
3393
3607
|
}
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3608
|
+
case "press": {
|
|
3609
|
+
await browser.pressKey(action.key);
|
|
3610
|
+
break;
|
|
3397
3611
|
}
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
await browser.pressKey(action.key);
|
|
3402
|
-
break;
|
|
3403
|
-
}
|
|
3404
|
-
case "oauth_settle": {
|
|
3405
|
-
await browser.settleAfterOAuth();
|
|
3406
|
-
break;
|
|
3407
|
-
}
|
|
3408
|
-
case "scroll": {
|
|
3409
|
-
await browser.scrollViewport(action.direction ?? "down");
|
|
3410
|
-
break;
|
|
3411
|
-
}
|
|
3412
|
-
case "type_secret": {
|
|
3413
|
-
const value = session.secretSlots.get(action.slot);
|
|
3414
|
-
if (value === undefined) {
|
|
3415
|
-
throw new Error(`type_secret: no sealed slot named "${action.slot}". Capture it first with ` +
|
|
3416
|
-
`operate_act { kind: "extract", into_slot: "${action.slot}" }. Known slots: ` +
|
|
3417
|
-
`[${[...session.secretSlots.keys()].join(", ")}]`);
|
|
3612
|
+
case "oauth_settle": {
|
|
3613
|
+
await browser.settleAfterOAuth();
|
|
3614
|
+
break;
|
|
3418
3615
|
}
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
throw new Error(`type_secret: no element matched locator "${action.target}".`);
|
|
3430
|
-
}
|
|
3431
|
-
throw new AmbiguousProvisionTargetError(action.target, resolved.candidates);
|
|
3616
|
+
case "scroll": {
|
|
3617
|
+
await browser.scrollViewport(action.direction ?? "down");
|
|
3618
|
+
break;
|
|
3619
|
+
}
|
|
3620
|
+
case "type_secret": {
|
|
3621
|
+
const value = session.secretSlots.get(action.slot);
|
|
3622
|
+
if (value === undefined) {
|
|
3623
|
+
throw new Error(`type_secret: no sealed slot named "${action.slot}". Capture it first with ` +
|
|
3624
|
+
`operate_act { kind: "extract", into_slot: "${action.slot}" }. Known slots: ` +
|
|
3625
|
+
`[${[...session.secretSlots.keys()].join(", ")}]`);
|
|
3432
3626
|
}
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3627
|
+
sensitiveSource = {
|
|
3628
|
+
traceIndex: -1,
|
|
3629
|
+
hole: `credential.${action.slot}`,
|
|
3630
|
+
literal: value,
|
|
3631
|
+
};
|
|
3632
|
+
const locator = parseLocatorTarget(resolutionTarget);
|
|
3633
|
+
if (locator !== null) {
|
|
3634
|
+
const resolved = await browser.resolvePageTarget(locator.mode, locator.value, "type");
|
|
3635
|
+
if (!resolved.ok) {
|
|
3636
|
+
if (resolved.reason === "none") {
|
|
3637
|
+
throw new Error(`type_secret: no element matched locator "${action.target}".`);
|
|
3638
|
+
}
|
|
3639
|
+
throw new AmbiguousProvisionTargetError(action.target, resolved.candidates);
|
|
3640
|
+
}
|
|
3641
|
+
try {
|
|
3642
|
+
if (resolved.frameTarget !== null) {
|
|
3643
|
+
assertSecretFrameTargetAllowed(session, resolved.frameTarget);
|
|
3644
|
+
}
|
|
3645
|
+
session.usedLocatorFallback = true;
|
|
3646
|
+
await browser.typeHandle(resolved.handle, value, true);
|
|
3647
|
+
}
|
|
3648
|
+
finally {
|
|
3649
|
+
await resolved.handle.dispose().catch(() => undefined);
|
|
3436
3650
|
}
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3651
|
+
audit(sessionId, "type_secret", {
|
|
3652
|
+
slot: action.slot,
|
|
3653
|
+
locator_mode: locator.mode,
|
|
3654
|
+
host: registrableHost(browser.currentUrl()),
|
|
3655
|
+
});
|
|
3656
|
+
break;
|
|
3441
3657
|
}
|
|
3442
|
-
|
|
3443
|
-
|
|
3658
|
+
const fresh = await browser.extractInteractiveElements();
|
|
3659
|
+
retainSessionElements(session, fresh);
|
|
3660
|
+
// resolveTarget recomputes identities (incl. volatile positional-group
|
|
3661
|
+
// fingerprints) from these FRESH elements, so a ref whose group fingerprint
|
|
3662
|
+
// changed since the last observe resolves to null, not a survivor (#399).
|
|
3663
|
+
const el = compactV2Authorization === undefined
|
|
3664
|
+
? resolveTarget(fresh, resolutionTarget)
|
|
3665
|
+
: resolveAuthorizedCompactV2Target(session, fresh, compactV2Authorization);
|
|
3666
|
+
if (el === null) {
|
|
3667
|
+
if (session.compactV2Active) {
|
|
3668
|
+
if (!internalAccess)
|
|
3669
|
+
throwCompactV2StaleRef();
|
|
3670
|
+
throw new Error("type_secret: internal live target changed");
|
|
3671
|
+
}
|
|
3672
|
+
const stale = staleTargetError(session, action.target, fresh);
|
|
3673
|
+
if (stale !== null)
|
|
3674
|
+
throw stale;
|
|
3675
|
+
throw new Error(`type_secret: no element matched target "${action.target}".`);
|
|
3444
3676
|
}
|
|
3677
|
+
resolvedEl = el;
|
|
3678
|
+
// Frame domain-lock (operator-frame-support) — never let a secret cross
|
|
3679
|
+
// into a rogue/third-party (e.g. payment) iframe. See
|
|
3680
|
+
// assertSecretFrameTargetAllowed; a main-frame or same-domain-frame
|
|
3681
|
+
// target is unaffected.
|
|
3682
|
+
assertSecretFrameTargetAllowed(session, el);
|
|
3683
|
+
// Type the REAL value into the page. It crosses only browser↔page; the
|
|
3684
|
+
// value is never returned to the host and never logged.
|
|
3685
|
+
const target = frameTargetFor(el);
|
|
3686
|
+
if (target !== null)
|
|
3687
|
+
await browser.typeInFrame(target, el.selector, value, true);
|
|
3688
|
+
else
|
|
3689
|
+
await browser.type(el.selector, value, true);
|
|
3445
3690
|
audit(sessionId, "type_secret", {
|
|
3446
3691
|
slot: action.slot,
|
|
3447
|
-
|
|
3692
|
+
target: auditTarget,
|
|
3448
3693
|
host: registrableHost(browser.currentUrl()),
|
|
3449
3694
|
});
|
|
3450
3695
|
break;
|
|
3451
3696
|
}
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3697
|
+
case "select": {
|
|
3698
|
+
// Re-resolve against FRESH elements — the target may be the <select> or
|
|
3699
|
+
// its <label>. Main-frame execution uses selectOption; frame execution
|
|
3700
|
+
// uses selectInFrame. text is the fuzzy option matcher in both paths.
|
|
3701
|
+
const fresh = await browser.extractInteractiveElements();
|
|
3702
|
+
retainSessionElements(session, fresh);
|
|
3703
|
+
const el = compactV2Authorization === undefined
|
|
3704
|
+
? resolveTarget(fresh, resolutionTarget)
|
|
3705
|
+
: resolveAuthorizedCompactV2Target(session, fresh, compactV2Authorization);
|
|
3706
|
+
if (el === null) {
|
|
3707
|
+
if (session.compactV2Active) {
|
|
3708
|
+
if (!internalAccess)
|
|
3709
|
+
throwCompactV2StaleRef();
|
|
3710
|
+
throw new Error("select: internal live target changed");
|
|
3711
|
+
}
|
|
3712
|
+
const stale = staleTargetError(session, action.target, fresh);
|
|
3713
|
+
if (stale !== null)
|
|
3714
|
+
throw stale;
|
|
3715
|
+
throw new Error(`select: no element matched target "${action.target}". Visible: ` +
|
|
3716
|
+
fresh
|
|
3717
|
+
.map((e) => `"${e.screenPath ?? elementRef(e)}"`)
|
|
3718
|
+
.slice(0, 20)
|
|
3719
|
+
.join(", "));
|
|
3720
|
+
}
|
|
3721
|
+
resolvedEl = el;
|
|
3722
|
+
// Frame domain-lock (operator-frame-support) — the SAME gate a frame
|
|
3723
|
+
// click/type passes; see frameTargetAllowed. A native <select> is not a
|
|
3724
|
+
// secret field, so the stricter type_secret cross-origin rule does not
|
|
3725
|
+
// apply, but the ordinary domain lock does.
|
|
3726
|
+
assertFrameTargetAllowed(session, el, "select");
|
|
3727
|
+
bindCartIdentity(isCartAffectingAction(action, el));
|
|
3728
|
+
const selectFrame = frameTargetFor(el);
|
|
3729
|
+
const committedText = selectFrame !== null
|
|
3730
|
+
? await browser.selectInFrame(selectFrame, el.selector, action.text)
|
|
3731
|
+
: await browser.selectOption(el.selector, action.text);
|
|
3732
|
+
session.committedSelectValues.set(compactV2CommittedSelectKey(session, el.selector), compactV2CommittedSelectValue(session, committedText));
|
|
3733
|
+
completedAction = { ...action, text: committedText };
|
|
3734
|
+
await settleAfterStateChange(browser);
|
|
3735
|
+
break;
|
|
3463
3736
|
}
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
// value is never returned to the host and never logged.
|
|
3472
|
-
const target = frameTargetFor(el);
|
|
3473
|
-
const sealedFieldKeys = target !== null
|
|
3474
|
-
? await browser.typeInFrame(target, el.selector, value, true)
|
|
3475
|
-
: await browser.type(el.selector, value, true);
|
|
3476
|
-
for (const key of sealedFieldKeys)
|
|
3477
|
-
session.sealedFieldKeys.add(key);
|
|
3478
|
-
audit(sessionId, "type_secret", {
|
|
3479
|
-
slot: action.slot,
|
|
3480
|
-
target: action.target,
|
|
3481
|
-
host: registrableHost(browser.currentUrl()),
|
|
3482
|
-
});
|
|
3483
|
-
break;
|
|
3484
|
-
}
|
|
3485
|
-
case "select": {
|
|
3486
|
-
// Re-resolve against FRESH elements — the target may be the <select> or
|
|
3487
|
-
// its <label>. Main-frame execution uses selectOption; frame execution
|
|
3488
|
-
// uses selectInFrame. text is the fuzzy option matcher in both paths.
|
|
3489
|
-
const fresh = await browser.extractInteractiveElements();
|
|
3490
|
-
session.lastElements = fresh;
|
|
3491
|
-
const el = resolveTarget(fresh, action.target);
|
|
3492
|
-
if (el === null) {
|
|
3493
|
-
const stale = staleTargetError(session, action.target, fresh);
|
|
3494
|
-
if (stale !== null)
|
|
3495
|
-
throw stale;
|
|
3496
|
-
throw new Error(`select: no element matched target "${action.target}". Visible: ` +
|
|
3497
|
-
fresh
|
|
3498
|
-
.map((e) => `"${e.screenPath ?? elementRef(e)}"`)
|
|
3499
|
-
.slice(0, 20)
|
|
3500
|
-
.join(", "));
|
|
3737
|
+
case "set_phone_country": {
|
|
3738
|
+
// No captured element — the bot finds the phone-local native <select>.
|
|
3739
|
+
// resolvedEl stays null; the step records without a captured-element
|
|
3740
|
+
// trace (the country is host-replannable, not a replay recipe).
|
|
3741
|
+
await browser.setPhoneCountry(action.country);
|
|
3742
|
+
await settleAfterStateChange(browser);
|
|
3743
|
+
break;
|
|
3501
3744
|
}
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3745
|
+
case "click":
|
|
3746
|
+
case "js_click":
|
|
3747
|
+
case "type":
|
|
3748
|
+
case "upload":
|
|
3749
|
+
case "oauth_click": {
|
|
3750
|
+
const pageText = await browser.extractVisibleText();
|
|
3751
|
+
const blockReason = shouldBlockUnsafeProvisionAction(pageText, action);
|
|
3752
|
+
if (blockReason !== null)
|
|
3753
|
+
throw new Error(blockReason);
|
|
3754
|
+
// Locator-form target (`text=…` / `css=…`): the host is pointing at a
|
|
3755
|
+
// control that has NO `@e:` ref because the inventory never emitted it (a
|
|
3756
|
+
// bare click-handler <div> with no role/label, e.g. a SPA "Add To Cart"
|
|
3757
|
+
// that falls past the card-scan cap). Resolve it directly against the live
|
|
3758
|
+
// page instead of the extracted-element list.
|
|
3759
|
+
const locator = parseLocatorTarget(resolutionTarget);
|
|
3760
|
+
if (locator !== null) {
|
|
3761
|
+
if (action.kind !== "click" && action.kind !== "js_click" && action.kind !== "type") {
|
|
3762
|
+
throw new Error(`operate_act kind="${action.kind}" does not accept a text=/css= locator target; ` +
|
|
3763
|
+
`use an @e: ref from operate_observe.`);
|
|
3764
|
+
}
|
|
3765
|
+
const resolved = await browser.resolvePageTarget(locator.mode, locator.value, action.kind === "type" ? "type" : "click");
|
|
3766
|
+
if (!resolved.ok) {
|
|
3767
|
+
if (resolved.reason === "none") {
|
|
3768
|
+
throw new ProvisionTargetMissingError(`no element matched locator "${action.target}". If the control is visible, ` +
|
|
3769
|
+
`try a shorter/exact text= label or a css=<selector>.`);
|
|
3770
|
+
}
|
|
3771
|
+
throw new AmbiguousProvisionTargetError(action.target, resolved.candidates);
|
|
3772
|
+
}
|
|
3773
|
+
// The unsafe-action guard above inspected the RAW target, so an opaque
|
|
3774
|
+
// `css=<selector>` (or any target whose string carries no verb/noun the
|
|
3775
|
+
// guard matches) could resolve to a destructive billing/setup control the
|
|
3776
|
+
// guard couldn't see through — clicking "Save product" in live mode via
|
|
3777
|
+
// css=#submit. Re-run it against compact safety signals computed from the
|
|
3778
|
+
// resolved control now that we know what the locator actually points at.
|
|
3779
|
+
// Mark the session non-promotable BEFORE the action: a locator action can't
|
|
3780
|
+
// be replayed from the inventory (the element was never in it), so a
|
|
3781
|
+
// skill synthesized from this run would silently omit the step. Setting
|
|
3782
|
+
// it up front means an action that lands but then throws still can't leave
|
|
3783
|
+
// the session promotable (see captureAndPromoteSession) (codex).
|
|
3784
|
+
try {
|
|
3785
|
+
const resolvedBlock = shouldBlockUnsafeProvisionSignals(pageText, resolved.safetySignals);
|
|
3786
|
+
if (resolvedBlock !== null)
|
|
3787
|
+
throw new Error(resolvedBlock);
|
|
3788
|
+
if (resolved.frameTarget !== null) {
|
|
3789
|
+
assertFrameTargetAllowed(session, resolved.frameTarget, action.kind);
|
|
3790
|
+
}
|
|
3791
|
+
bindCartIdentity(isCartAffectingAction(action, null, resolved.labels));
|
|
3792
|
+
session.usedLocatorFallback = true;
|
|
3793
|
+
const isPlaceOrderCandidate = isCheckoutSubmitLabeled([
|
|
3794
|
+
resolved.text,
|
|
3795
|
+
...resolved.labels,
|
|
3796
|
+
]);
|
|
3797
|
+
if ((session.placeOrderApproval !== null || getActivePendingThreeDs(session) !== null) &&
|
|
3798
|
+
isPlaceOrderCandidate &&
|
|
3799
|
+
(action.kind === "click" || action.kind === "js_click")) {
|
|
3800
|
+
await runClickWithPlaceOrderGuard(session, (shouldTrack) => browser.clickWithDispatchTracking({
|
|
3801
|
+
kind: "handle",
|
|
3802
|
+
handle: resolved.handle,
|
|
3803
|
+
method: action.kind,
|
|
3804
|
+
}, shouldTrack));
|
|
3805
|
+
}
|
|
3806
|
+
else if (action.kind === "click" || action.kind === "js_click") {
|
|
3807
|
+
const method = action.kind;
|
|
3808
|
+
await adoptTabOpenedByClick(session, browser, async () => {
|
|
3809
|
+
if (method === "click")
|
|
3810
|
+
await browser.clickHandle(resolved.handle);
|
|
3811
|
+
else
|
|
3812
|
+
await browser.jsClickHandle(resolved.handle);
|
|
3813
|
+
});
|
|
3814
|
+
}
|
|
3815
|
+
else
|
|
3816
|
+
await browser.typeHandle(resolved.handle, action.text);
|
|
3817
|
+
}
|
|
3818
|
+
finally {
|
|
3819
|
+
await resolved.handle.dispose().catch(() => undefined);
|
|
3820
|
+
}
|
|
3821
|
+
audit(sessionId, action.kind, {
|
|
3822
|
+
locator_mode: locator.mode,
|
|
3823
|
+
host: registrableHost(browser.currentUrl()),
|
|
3824
|
+
});
|
|
3825
|
+
if (action.kind !== "type") {
|
|
3826
|
+
await settleAfterStateChange(browser);
|
|
3827
|
+
// A tab opened by JS a tick after the click lands during the
|
|
3828
|
+
// settle above, not inside the click's own grace window. Drain it
|
|
3829
|
+
// here — the queue is already populated, so this costs nothing.
|
|
3830
|
+
await adoptOpenedTab(session, browser, 0);
|
|
3831
|
+
}
|
|
3832
|
+
break;
|
|
3545
3833
|
}
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3834
|
+
// Re-resolve against FRESH elements every act — never trust a stale index.
|
|
3835
|
+
const fresh = await browser.extractInteractiveElements();
|
|
3836
|
+
retainSessionElements(session, fresh);
|
|
3837
|
+
// resolveTarget recomputes identities (incl. volatile positional-group
|
|
3838
|
+
// fingerprints) from these FRESH elements, so a ref whose group fingerprint
|
|
3839
|
+
// changed since the last observe resolves to null, not a survivor (#399).
|
|
3840
|
+
const el = compactV2Authorization === undefined
|
|
3841
|
+
? resolveTarget(fresh, resolutionTarget)
|
|
3842
|
+
: resolveAuthorizedCompactV2Target(session, fresh, compactV2Authorization);
|
|
3843
|
+
if (el === null) {
|
|
3844
|
+
if (session.compactV2Active) {
|
|
3845
|
+
if (!internalAccess)
|
|
3846
|
+
throwCompactV2StaleRef();
|
|
3847
|
+
throw new Error(`${action.kind}: internal live target changed`);
|
|
3551
3848
|
}
|
|
3552
|
-
|
|
3849
|
+
const stale = staleTargetError(session, action.target, fresh);
|
|
3850
|
+
if (stale !== null)
|
|
3851
|
+
throw stale;
|
|
3852
|
+
throw new Error(`no element matched target "${action.target}". Visible: ` +
|
|
3853
|
+
fresh
|
|
3854
|
+
.map((e) => `"${e.screenPath ?? elementRef(e)}"`)
|
|
3855
|
+
.slice(0, 20)
|
|
3856
|
+
.join(", "));
|
|
3553
3857
|
}
|
|
3554
|
-
|
|
3555
|
-
//
|
|
3556
|
-
//
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
if (
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3858
|
+
resolvedEl = el;
|
|
3859
|
+
// Frame domain-lock (operator-frame-support) — see frameTargetAllowed.
|
|
3860
|
+
// A main-frame or same-domain-frame target is unaffected.
|
|
3861
|
+
assertFrameTargetAllowed(session, el, action.kind);
|
|
3862
|
+
bindCartIdentity(isCartAffectingAction(action, el));
|
|
3863
|
+
if (action.kind === "click" || action.kind === "js_click") {
|
|
3864
|
+
const target = frameTargetFor(el);
|
|
3865
|
+
if ((session.placeOrderApproval !== null || getActivePendingThreeDs(session) !== null) &&
|
|
3866
|
+
isPlaceOrderClickCandidate(el)) {
|
|
3867
|
+
await runClickWithPlaceOrderGuard(session, (shouldTrack) => browser.clickWithDispatchTracking(target !== null
|
|
3868
|
+
? { kind: "frame", frame: target, selector: el.selector, method: action.kind }
|
|
3869
|
+
: { kind: "selector", selector: el.selector, method: action.kind }, shouldTrack));
|
|
3870
|
+
}
|
|
3871
|
+
else if (action.kind === "click") {
|
|
3872
|
+
await adoptTabOpenedByClick(session, browser, async () => {
|
|
3873
|
+
if (target !== null)
|
|
3874
|
+
await browser.clickInFrame(target, el.selector);
|
|
3875
|
+
else
|
|
3876
|
+
await browser.click(el.selector);
|
|
3877
|
+
});
|
|
3571
3878
|
}
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
await runClickWithPlaceOrderGuard(session, (shouldTrack) => browser.clickWithDispatchTracking({
|
|
3580
|
-
kind: "handle",
|
|
3581
|
-
handle: resolved.handle,
|
|
3582
|
-
method: action.kind,
|
|
3583
|
-
}, shouldTrack));
|
|
3879
|
+
else {
|
|
3880
|
+
await adoptTabOpenedByClick(session, browser, async () => {
|
|
3881
|
+
if (target !== null)
|
|
3882
|
+
await browser.clickViaJsInFrame(target, el.selector);
|
|
3883
|
+
else
|
|
3884
|
+
await browser.clickViaJs(el.selector);
|
|
3885
|
+
});
|
|
3584
3886
|
}
|
|
3585
|
-
else if (action.kind === "click")
|
|
3586
|
-
await browser.clickHandle(resolved.handle);
|
|
3587
|
-
else if (action.kind === "js_click")
|
|
3588
|
-
await browser.jsClickHandle(resolved.handle);
|
|
3589
|
-
else
|
|
3590
|
-
await browser.typeHandle(resolved.handle, action.text);
|
|
3591
3887
|
}
|
|
3592
|
-
|
|
3593
|
-
|
|
3888
|
+
else if (action.kind === "type" && frameTargetFor(el) !== null) {
|
|
3889
|
+
// Frame targets skip the autocomplete-popup-commit machinery below —
|
|
3890
|
+
// it operates on the main page's DOM only (markPreexistingType
|
|
3891
|
+
// SuggestionPopups / detectTypeSuggestionPopup / commitTypeSuggestion
|
|
3892
|
+
// are page-scoped). Out of scope for the checkout-option case frame
|
|
3893
|
+
// support exists for; a plain frame-scoped fill covers it.
|
|
3894
|
+
clearCommittedSelectValue(session, el.selector);
|
|
3895
|
+
await browser.typeInFrame(frameTargetFor(el), el.selector, action.text);
|
|
3594
3896
|
}
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
.
|
|
3618
|
-
.
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3897
|
+
else if (action.kind === "type") {
|
|
3898
|
+
clearCommittedSelectValue(session, el.selector);
|
|
3899
|
+
if (!isAutocompleteScopedTypeField(action.provenance, el)) {
|
|
3900
|
+
// Free text only — e.g. a site-search/catalog-search box, which
|
|
3901
|
+
// can legitimately open its own suggestion listbox too. 3.1 only
|
|
3902
|
+
// applies to a form/recipe field where a committed value is
|
|
3903
|
+
// actually required; forcing every incidental popup into
|
|
3904
|
+
// commit-or-stop would break ordinary search typing.
|
|
3905
|
+
await browser.type(el.selector, action.text);
|
|
3906
|
+
}
|
|
3907
|
+
else {
|
|
3908
|
+
// 3.1 — a Google-Places-style address field or a react-select/cmdk/
|
|
3909
|
+
// Radix combobox can open a suggestion popup as a side effect of
|
|
3910
|
+
// typing, not just of an explicit `select`. Snapshot pre-existing
|
|
3911
|
+
// popups BEFORE typing (it can open mid-keystroke), then detect what
|
|
3912
|
+
// opened afterward.
|
|
3913
|
+
await browser.markPreexistingTypeSuggestionPopups();
|
|
3914
|
+
await browser.type(el.selector, action.text);
|
|
3915
|
+
// Cleanup (clear our tracking markers, and dismiss with Escape
|
|
3916
|
+
// ONLY when a detected popup is plausibly still open) must run no
|
|
3917
|
+
// matter how this resolves — no popup, an ambiguous stop, a
|
|
3918
|
+
// failed commit, or success — mirroring selectFromCombobox's own
|
|
3919
|
+
// try/finally. Scoping the try to only the
|
|
3920
|
+
// suggestionTexts.length > 0 branch left the "preexisting"
|
|
3921
|
+
// markers set by markPreexistingTypeSuggestionPopups uncleared on
|
|
3922
|
+
// the no-popup path; markComboboxPreexistingElements only ADDS
|
|
3923
|
+
// markers, so a stale one could exclude a genuine popup from
|
|
3924
|
+
// detection on a LATER type/select into the same element. Escape
|
|
3925
|
+
// fires on the ambiguous-stop and failed-commit paths (popup
|
|
3926
|
+
// never interacted with / click may not have registered — still
|
|
3927
|
+
// open either way) but NEVER after a confirmed commit: the widget
|
|
3928
|
+
// already closed its popup on selection, so Escape would land on
|
|
3929
|
+
// nothing and bubble to close an enclosing modal/dialog instead.
|
|
3930
|
+
let dismissPopupWithEscape = false;
|
|
3931
|
+
try {
|
|
3932
|
+
const suggestionTexts = await browser.detectTypeSuggestionPopup(el.selector);
|
|
3933
|
+
if (suggestionTexts.length > 0) {
|
|
3934
|
+
dismissPopupWithEscape = true;
|
|
3935
|
+
const candidates = matchAutocompleteSuggestions(action.text, suggestionTexts);
|
|
3936
|
+
if (candidates.length !== 1) {
|
|
3937
|
+
// Pass the MATCHED subset, not the full popup — passing every
|
|
3938
|
+
// suggestion made candidates.length effectively the popup
|
|
3939
|
+
// size (always > 0 here), so the constructor's zero-match
|
|
3940
|
+
// branch was dead code and the multi-match message reported
|
|
3941
|
+
// the wrong count.
|
|
3942
|
+
throw new AutocompleteCommitRequiredError(action.text, candidates.map((i) => suggestionTexts[i]));
|
|
3943
|
+
}
|
|
3944
|
+
const pickedText = suggestionTexts[candidates[0]];
|
|
3945
|
+
await browser.commitTypeSuggestion(candidates[0]);
|
|
3946
|
+
// Never trust that a click "looked right" — POSITIVELY confirm
|
|
3947
|
+
// the commit took (same hard constraint as the field-role
|
|
3948
|
+
// guard, PR #447: a miss is a stop, never a silent
|
|
3949
|
+
// pass-through). Checking only the typed-into selector's own
|
|
3950
|
+
// `.value` false-fails on react-select/cmdk-style widgets,
|
|
3951
|
+
// which clear their search input on selection and render the
|
|
3952
|
+
// committed choice in a nearby element instead —
|
|
3953
|
+
// confirmAutocompleteCommitted checks that too, bounded to the
|
|
3954
|
+
// field's own neighborhood, and returns false (never a guess)
|
|
3955
|
+
// when nothing confirms it.
|
|
3956
|
+
const committed = await browser.confirmAutocompleteCommitted(el.selector, pickedText);
|
|
3957
|
+
if (!committed) {
|
|
3958
|
+
throw new Error(`autocomplete commit for "${action.text}" did not take — nothing on the page ` +
|
|
3959
|
+
`confirms the field now holds "${pickedText}" after selecting it.`);
|
|
3960
|
+
}
|
|
3961
|
+
dismissPopupWithEscape = false;
|
|
3962
|
+
// Rewrite the completed action to the field's LIVE post-commit
|
|
3963
|
+
// value (not the raw typed draft) before it reaches
|
|
3964
|
+
// recordTrace/recordedValues, so the recorded trace reflects
|
|
3965
|
+
// what actually ended up on the page. Recording pickedText
|
|
3966
|
+
// would diverge from the live value exactly when the commit
|
|
3967
|
+
// was confirmed via a NEARBY element (react-select/cmdk clear
|
|
3968
|
+
// their search input on selection), and the cold-path
|
|
3969
|
+
// transition attestation (attestRecordedFieldsBeforeTransition
|
|
3970
|
+
// → verifyFilledFieldValues) re-reads the live value — a
|
|
3971
|
+
// pickedText literal would flag every such commit as a
|
|
3972
|
+
// mismatch and disqualify recipe recording. Reading the same
|
|
3973
|
+
// live value here is attestation-consistent by construction.
|
|
3974
|
+
// Known limitation: after a nearby-signal-only commit the
|
|
3975
|
+
// field itself can be empty, so the recorded literal is "" —
|
|
3976
|
+
// that field won't cleanly template into a saved recipe, but
|
|
3977
|
+
// the live run is unaffected.
|
|
3978
|
+
const refreshed = await browser.extractInteractiveElements();
|
|
3979
|
+
retainSessionElements(session, refreshed);
|
|
3980
|
+
const liveField = refreshed.find((field) => field.selector === el.selector);
|
|
3981
|
+
const liveValue = typeof liveField?.value === "string" ? liveField.value : pickedText;
|
|
3982
|
+
completedAction = { ...action, text: liveValue };
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
finally {
|
|
3986
|
+
await browser.discardTypeSuggestionPopup(dismissPopupWithEscape);
|
|
3987
|
+
}
|
|
3988
|
+
if (isRequiredShippingAddressLine1(el)) {
|
|
3989
|
+
await browser.commitRequiredShippingAddressLine1(el.selector);
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3631
3992
|
}
|
|
3632
|
-
else if (action.kind === "
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3993
|
+
else if (action.kind === "upload") {
|
|
3994
|
+
assertNoFrameTarget(el, "upload");
|
|
3995
|
+
await browser.uploadFile(el.selector, action.path);
|
|
3996
|
+
audit(sessionId, "upload", {
|
|
3997
|
+
target: auditTarget,
|
|
3998
|
+
path: session.compactV2Active ? "<local-file>" : action.path,
|
|
3999
|
+
host: registrableHost(browser.currentUrl()),
|
|
4000
|
+
});
|
|
3637
4001
|
}
|
|
3638
4002
|
else {
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
4003
|
+
assertNoFrameTarget(el, "oauth_click");
|
|
4004
|
+
if (oauthDeadline === undefined) {
|
|
4005
|
+
throw new Error("OAuth action deadline was not established");
|
|
4006
|
+
}
|
|
4007
|
+
browser = await runSerializedOAuthBoundary(session, el, fresh, action.provider, oauthDeadline);
|
|
3643
4008
|
}
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
//
|
|
3647
|
-
//
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
// support exists for; a plain frame-scoped fill covers it.
|
|
3651
|
-
session.committedSelectValues.delete(el.selector);
|
|
3652
|
-
await browser.typeInFrame(frameTargetFor(el), el.selector, action.text);
|
|
3653
|
-
}
|
|
3654
|
-
else if (action.kind === "type") {
|
|
3655
|
-
session.committedSelectValues.delete(el.selector);
|
|
3656
|
-
if (!isAutocompleteScopedTypeField(action.provenance, el)) {
|
|
3657
|
-
// Free text only — e.g. a site-search/catalog-search box, which
|
|
3658
|
-
// can legitimately open its own suggestion listbox too. 3.1 only
|
|
3659
|
-
// applies to a form/recipe field where a committed value is
|
|
3660
|
-
// actually required; forcing every incidental popup into
|
|
3661
|
-
// commit-or-stop would break ordinary search typing.
|
|
3662
|
-
await browser.type(el.selector, action.text);
|
|
4009
|
+
if (action.kind !== "type")
|
|
4010
|
+
await settleAfterStateChange(browser);
|
|
4011
|
+
// Only a plain click follows a tab it opened. oauth_click owns its own
|
|
4012
|
+
// provider-page lifecycle and upload never opens one.
|
|
4013
|
+
if (action.kind === "click" || action.kind === "js_click") {
|
|
4014
|
+
await adoptOpenedTab(session, browser, 0);
|
|
3663
4015
|
}
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
// open either way) but NEVER after a confirmed commit: the widget
|
|
3685
|
-
// already closed its popup on selection, so Escape would land on
|
|
3686
|
-
// nothing and bubble to close an enclosing modal/dialog instead.
|
|
3687
|
-
let dismissPopupWithEscape = false;
|
|
3688
|
-
try {
|
|
3689
|
-
const suggestionTexts = await browser.detectTypeSuggestionPopup(el.selector);
|
|
3690
|
-
if (suggestionTexts.length > 0) {
|
|
3691
|
-
dismissPopupWithEscape = true;
|
|
3692
|
-
const candidates = matchAutocompleteSuggestions(action.text, suggestionTexts);
|
|
3693
|
-
if (candidates.length !== 1) {
|
|
3694
|
-
// Pass the MATCHED subset, not the full popup — passing every
|
|
3695
|
-
// suggestion made candidates.length effectively the popup
|
|
3696
|
-
// size (always > 0 here), so the constructor's zero-match
|
|
3697
|
-
// branch was dead code and the multi-match message reported
|
|
3698
|
-
// the wrong count.
|
|
3699
|
-
throw new AutocompleteCommitRequiredError(action.text, candidates.map((i) => suggestionTexts[i]));
|
|
3700
|
-
}
|
|
3701
|
-
const pickedText = suggestionTexts[candidates[0]];
|
|
3702
|
-
await browser.commitTypeSuggestion(candidates[0]);
|
|
3703
|
-
// Never trust that a click "looked right" — POSITIVELY confirm
|
|
3704
|
-
// the commit took (same hard constraint as the field-role
|
|
3705
|
-
// guard, PR #447: a miss is a stop, never a silent
|
|
3706
|
-
// pass-through). Checking only the typed-into selector's own
|
|
3707
|
-
// `.value` false-fails on react-select/cmdk-style widgets,
|
|
3708
|
-
// which clear their search input on selection and render the
|
|
3709
|
-
// committed choice in a nearby element instead —
|
|
3710
|
-
// confirmAutocompleteCommitted checks that too, bounded to the
|
|
3711
|
-
// field's own neighborhood, and returns false (never a guess)
|
|
3712
|
-
// when nothing confirms it.
|
|
3713
|
-
const committed = await browser.confirmAutocompleteCommitted(el.selector, pickedText);
|
|
3714
|
-
if (!committed) {
|
|
3715
|
-
throw new Error(`autocomplete commit for "${action.text}" did not take — nothing on the page ` +
|
|
3716
|
-
`confirms the field now holds "${pickedText}" after selecting it.`);
|
|
3717
|
-
}
|
|
3718
|
-
dismissPopupWithEscape = false;
|
|
3719
|
-
// Rewrite the completed action to the field's LIVE post-commit
|
|
3720
|
-
// value (not the raw typed draft) before it reaches
|
|
3721
|
-
// recordTrace/recordedValues, so the recorded trace reflects
|
|
3722
|
-
// what actually ended up on the page. Recording pickedText
|
|
3723
|
-
// would diverge from the live value exactly when the commit
|
|
3724
|
-
// was confirmed via a NEARBY element (react-select/cmdk clear
|
|
3725
|
-
// their search input on selection), and the cold-path
|
|
3726
|
-
// transition attestation (attestRecordedFieldsBeforeTransition
|
|
3727
|
-
// → verifyFilledFieldValues) re-reads the live value — a
|
|
3728
|
-
// pickedText literal would flag every such commit as a
|
|
3729
|
-
// mismatch and disqualify recipe recording. Reading the same
|
|
3730
|
-
// live value here is attestation-consistent by construction.
|
|
3731
|
-
// Known limitation: after a nearby-signal-only commit the
|
|
3732
|
-
// field itself can be empty, so the recorded literal is "" —
|
|
3733
|
-
// that field won't cleanly template into a saved recipe, but
|
|
3734
|
-
// the live run is unaffected.
|
|
3735
|
-
const refreshed = await browser.extractInteractiveElements();
|
|
3736
|
-
session.lastElements = refreshed;
|
|
3737
|
-
const liveField = refreshed.find((field) => field.selector === el.selector);
|
|
3738
|
-
const liveValue = typeof liveField?.value === "string" ? liveField.value : pickedText;
|
|
3739
|
-
completedAction = { ...action, text: liveValue };
|
|
3740
|
-
}
|
|
3741
|
-
}
|
|
3742
|
-
finally {
|
|
3743
|
-
await browser.discardTypeSuggestionPopup(dismissPopupWithEscape);
|
|
4016
|
+
break;
|
|
4017
|
+
}
|
|
4018
|
+
case "oauth_login": {
|
|
4019
|
+
const pageText = await browser.extractVisibleText();
|
|
4020
|
+
const blockReason = shouldBlockUnsafeProvisionAction(pageText, action);
|
|
4021
|
+
if (blockReason !== null)
|
|
4022
|
+
throw new Error(blockReason);
|
|
4023
|
+
// Atomic OAuth deliberately accepts only the observed stable ref. A raw
|
|
4024
|
+
// locator would lose the same stale-reference guarantees as every other
|
|
4025
|
+
// action before the provider transition begins.
|
|
4026
|
+
const fresh = await browser.extractInteractiveElements();
|
|
4027
|
+
retainSessionElements(session, fresh);
|
|
4028
|
+
const el = compactV2Authorization === undefined
|
|
4029
|
+
? resolveTarget(fresh, resolutionTarget)
|
|
4030
|
+
: resolveAuthorizedCompactV2Target(session, fresh, compactV2Authorization);
|
|
4031
|
+
if (el === null) {
|
|
4032
|
+
if (session.compactV2Active) {
|
|
4033
|
+
if (!internalAccess)
|
|
4034
|
+
throwCompactV2StaleRef();
|
|
4035
|
+
throw new Error("oauth_login: internal live target changed");
|
|
3744
4036
|
}
|
|
4037
|
+
throw new Error(`oauth_login: no element matched target "${action.target}". Re-observe and use the OAuth button ref.`);
|
|
3745
4038
|
}
|
|
4039
|
+
resolvedEl = el;
|
|
4040
|
+
assertNoFrameTarget(el, "oauth_login");
|
|
4041
|
+
if (oauthDeadline === undefined) {
|
|
4042
|
+
throw new Error("OAuth action deadline was not established");
|
|
4043
|
+
}
|
|
4044
|
+
browser = await runSerializedOAuthBoundary(session, el, fresh, action.provider, oauthDeadline);
|
|
4045
|
+
break;
|
|
3746
4046
|
}
|
|
3747
|
-
else if (action.kind === "upload") {
|
|
3748
|
-
assertNoFrameTarget(el, "upload");
|
|
3749
|
-
await browser.uploadFile(el.selector, action.path);
|
|
3750
|
-
audit(sessionId, "upload", {
|
|
3751
|
-
target: action.target,
|
|
3752
|
-
path: action.path,
|
|
3753
|
-
host: registrableHost(browser.currentUrl()),
|
|
3754
|
-
});
|
|
3755
|
-
}
|
|
3756
|
-
else {
|
|
3757
|
-
assertNoFrameTarget(el, "oauth_click");
|
|
3758
|
-
await browser.startOAuth(el.selector);
|
|
3759
|
-
}
|
|
3760
|
-
if (action.kind !== "type")
|
|
3761
|
-
await settleAfterStateChange(browser);
|
|
3762
|
-
break;
|
|
3763
4047
|
}
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
if (el === null) {
|
|
3776
|
-
throw new Error(`oauth_login: no element matched target "${action.target}". Re-observe and use the OAuth button ref.`);
|
|
4048
|
+
}
|
|
4049
|
+
finally {
|
|
4050
|
+
// An act no longer retires the action map. Refs are fingerprint-derived and
|
|
4051
|
+
// re-resolved live against the observed epoch on every act, so the agent can
|
|
4052
|
+
// fill a whole form from one observation. Only LEAVING the observed document
|
|
4053
|
+
// retires them — the same condition the authorization check enforces — so
|
|
4054
|
+
// drop the snapshot exactly then, and fail closed if the epoch is unreadable.
|
|
4055
|
+
if (action.kind !== "allow_host" && session.compactV2Index !== null) {
|
|
4056
|
+
let stillObservedDocument = false;
|
|
4057
|
+
try {
|
|
4058
|
+
stillObservedDocument = session.compactV2Index.epoch.doc === compactV2EpochDoc(session);
|
|
3777
4059
|
}
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
await settleAfterStateChange(browser);
|
|
3782
|
-
break;
|
|
4060
|
+
catch { }
|
|
4061
|
+
if (!stillObservedDocument)
|
|
4062
|
+
invalidateCompactV2Snapshot(session);
|
|
3783
4063
|
}
|
|
3784
4064
|
}
|
|
3785
4065
|
await verifyRecordedFieldsAfterTransition(session, action, recordingTransitionFields);
|
|
@@ -3787,8 +4067,9 @@ export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
|
3787
4067
|
// INBOX_READ_HOSTS): replay re-reads the code via awaitVerification, and a
|
|
3788
4068
|
// recorded inbox click would bake the email's subject into a shared recipe.
|
|
3789
4069
|
if (!isInboxReadHost(browser.currentUrl())) {
|
|
3790
|
-
|
|
3791
|
-
|
|
4070
|
+
const replayElement = replaySafeElementForSession(session, resolvedEl);
|
|
4071
|
+
recordTrace(session, completedAction, replayElement, sensitiveSource);
|
|
4072
|
+
recordCaptureRound(session, completedAction, replayElement, urlBeforeAction);
|
|
3792
4073
|
}
|
|
3793
4074
|
if (cartAffecting) {
|
|
3794
4075
|
session.lastCartMutation = {
|
|
@@ -3801,47 +4082,92 @@ export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
|
3801
4082
|
// `detail:"none"` returns a minimal ack (the action ran; no perception emitted)
|
|
3802
4083
|
// so multi-field fills don't each echo the page. The host must call
|
|
3803
4084
|
// operate_observe before its next ref-targeted act (refs aren't refreshed here).
|
|
4085
|
+
const checkoutState = internalAccess && collectCheckoutState ? await capturePrivateCheckoutState(session) : undefined;
|
|
3804
4086
|
const observation = detail === "none" && !cartAffecting && action.kind !== "oauth_login"
|
|
3805
|
-
? {
|
|
4087
|
+
? compactV2PublicObservation(session, () => ({
|
|
3806
4088
|
session_id: session.id,
|
|
3807
4089
|
url: browser.currentUrl(),
|
|
3808
4090
|
text: "",
|
|
3809
4091
|
elements: [],
|
|
3810
4092
|
observed: "none",
|
|
3811
|
-
}
|
|
4093
|
+
}), {
|
|
4094
|
+
stage: safeStageV2(browser.currentUrl(), session.lastElements),
|
|
4095
|
+
observed: "none",
|
|
4096
|
+
})
|
|
3812
4097
|
: await observeSession(session, detail === "none" ? "compact" : detail);
|
|
3813
|
-
return
|
|
3814
|
-
|
|
3815
|
-
|
|
4098
|
+
return {
|
|
4099
|
+
observation: completedAction.kind === "select" && observation.format !== "compact-v2"
|
|
4100
|
+
? { ...observation, selected_option: completedAction.text }
|
|
4101
|
+
: observation,
|
|
4102
|
+
outcome: {
|
|
4103
|
+
...(completedAction.kind === "select" ? { selectedOption: completedAction.text } : {}),
|
|
4104
|
+
...(checkoutState === undefined ? {} : { checkoutState }),
|
|
4105
|
+
},
|
|
4106
|
+
};
|
|
3816
4107
|
}
|
|
3817
4108
|
export async function formSelectMany(sessionId, selections) {
|
|
3818
4109
|
const fields = [];
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
for (
|
|
4110
|
+
const session = sessionForCall(sessionId);
|
|
4111
|
+
if (session === undefined)
|
|
4112
|
+
throw new Error(`unknown provision session ${sessionId}`);
|
|
4113
|
+
const selectionEntries = Object.entries(selections);
|
|
4114
|
+
for (let index = 0; index < selectionEntries.length; index += 1) {
|
|
4115
|
+
const [label, option] = selectionEntries[index];
|
|
4116
|
+
const publicLabel = session.compactV2Active && !isCompactV2Handle(label) && !isCompactV2Label(label)
|
|
4117
|
+
? "<rejected-v2-target>"
|
|
4118
|
+
: label;
|
|
4119
|
+
const publicSelection = session.compactV2Active
|
|
4120
|
+
? { label: publicLabel }
|
|
4121
|
+
: { label: publicLabel, option };
|
|
4122
|
+
let authorization;
|
|
4123
|
+
if (session.compactV2Active) {
|
|
4124
|
+
try {
|
|
4125
|
+
authorization = compactV2AuthorizationForTarget(session, label);
|
|
4126
|
+
}
|
|
4127
|
+
catch (error) {
|
|
4128
|
+
audit(sessionId, "act", { kind: "select_many", target: "<rejected-v2-target>" });
|
|
4129
|
+
if (index === 0)
|
|
4130
|
+
throw error;
|
|
4131
|
+
fields.push({
|
|
4132
|
+
...publicSelection,
|
|
4133
|
+
status: "failed",
|
|
4134
|
+
reason: compactV2SelectionFailureReason(error),
|
|
4135
|
+
});
|
|
4136
|
+
if (index + 1 < selectionEntries.length)
|
|
4137
|
+
await observe(sessionId, "compact");
|
|
4138
|
+
continue;
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
3824
4141
|
try {
|
|
3825
|
-
const
|
|
3826
|
-
const
|
|
4142
|
+
const target = authorization?.legacyRef ?? label;
|
|
4143
|
+
const actionResult = await actInternally(sessionId, { kind: "select", target, text: option }, "none", undefined, false, authorization);
|
|
4144
|
+
const selectedOption = actionResult.outcome.selectedOption;
|
|
3827
4145
|
if (selectedOption === undefined) {
|
|
3828
4146
|
throw new Error("select: successful action omitted the selected option");
|
|
3829
4147
|
}
|
|
3830
4148
|
// `detail:none` is intentionally a minimal ack. The explicit observe here
|
|
3831
4149
|
// refreshes the DOM generation between every potentially mutating select.
|
|
3832
4150
|
await observe(sessionId, "compact");
|
|
4151
|
+
const publicSelectedOption = session.compactV2Active
|
|
4152
|
+
? safeDescriptionV2(selectedOption)
|
|
4153
|
+
: selectedOption;
|
|
3833
4154
|
fields.push({
|
|
3834
|
-
|
|
3835
|
-
option,
|
|
4155
|
+
...publicSelection,
|
|
3836
4156
|
status: "selected",
|
|
3837
|
-
selected_option:
|
|
4157
|
+
...(publicSelectedOption === undefined ? {} : { selected_option: publicSelectedOption }),
|
|
3838
4158
|
});
|
|
3839
4159
|
}
|
|
3840
4160
|
catch (err) {
|
|
3841
|
-
if (
|
|
4161
|
+
if (session.compactV2Active) {
|
|
4162
|
+
fields.push({
|
|
4163
|
+
...publicSelection,
|
|
4164
|
+
status: "failed",
|
|
4165
|
+
reason: compactV2SelectionFailureReason(err),
|
|
4166
|
+
});
|
|
4167
|
+
}
|
|
4168
|
+
else if (err instanceof TargetStaleError) {
|
|
3842
4169
|
fields.push({
|
|
3843
|
-
|
|
3844
|
-
option,
|
|
4170
|
+
...publicSelection,
|
|
3845
4171
|
status: "failed",
|
|
3846
4172
|
reason: err.message,
|
|
3847
4173
|
repair: err.result,
|
|
@@ -3849,12 +4175,14 @@ export async function formSelectMany(sessionId, selections) {
|
|
|
3849
4175
|
}
|
|
3850
4176
|
else {
|
|
3851
4177
|
fields.push({
|
|
3852
|
-
|
|
3853
|
-
option,
|
|
4178
|
+
...publicSelection,
|
|
3854
4179
|
status: "failed",
|
|
3855
4180
|
reason: err instanceof Error ? err.message : String(err),
|
|
3856
4181
|
});
|
|
3857
4182
|
}
|
|
4183
|
+
if (session.compactV2Active && index + 1 < selectionEntries.length) {
|
|
4184
|
+
await observe(sessionId, "compact");
|
|
4185
|
+
}
|
|
3858
4186
|
}
|
|
3859
4187
|
}
|
|
3860
4188
|
return {
|
|
@@ -3863,6 +4191,24 @@ export async function formSelectMany(sessionId, selections) {
|
|
|
3863
4191
|
observation: await observe(sessionId, "compact"),
|
|
3864
4192
|
};
|
|
3865
4193
|
}
|
|
4194
|
+
function compactV2SelectionFailureReason(error) {
|
|
4195
|
+
return compactV2ActionFailureReason(error, "select");
|
|
4196
|
+
}
|
|
4197
|
+
function compactV2ActionFailureReason(error, kind) {
|
|
4198
|
+
if (error instanceof CompactV2ActionFailureError)
|
|
4199
|
+
return error.message;
|
|
4200
|
+
if (error instanceof Error && error.code === "google_session") {
|
|
4201
|
+
return error.message;
|
|
4202
|
+
}
|
|
4203
|
+
if (error instanceof CompactV2StaleRefError)
|
|
4204
|
+
return "stale_ref";
|
|
4205
|
+
if (error instanceof TargetStaleError)
|
|
4206
|
+
return "reobserve_required";
|
|
4207
|
+
if (error instanceof ProvisionTargetNotAllowedError) {
|
|
4208
|
+
return "target_not_allowed";
|
|
4209
|
+
}
|
|
4210
|
+
return kind === "select" ? "selection_failed" : "action_failed";
|
|
4211
|
+
}
|
|
3866
4212
|
// PR3 privacy: in the operator model the host fills the USER's real email into
|
|
3867
4213
|
// signup forms (no Squire alias anymore). The recipe trace must NOT persist that
|
|
3868
4214
|
// literal address — it would land in operator recipes and any skill synthesized
|
|
@@ -3940,6 +4286,113 @@ const REPLAY_VERIFIED_HOLE = /^(?:address|contact)(?:\.|$)|^quantity$/;
|
|
|
3940
4286
|
function looksLikeEmailValue(v) {
|
|
3941
4287
|
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.trim());
|
|
3942
4288
|
}
|
|
4289
|
+
const COMPACT_V2_REPLAY_ROUTE_SEGMENTS = new Set([
|
|
4290
|
+
"account",
|
|
4291
|
+
"accounts",
|
|
4292
|
+
"api",
|
|
4293
|
+
"app",
|
|
4294
|
+
"apps",
|
|
4295
|
+
"auth",
|
|
4296
|
+
"basket",
|
|
4297
|
+
"billing",
|
|
4298
|
+
"callback",
|
|
4299
|
+
"cart",
|
|
4300
|
+
"checkout",
|
|
4301
|
+
"complete",
|
|
4302
|
+
"console",
|
|
4303
|
+
"create",
|
|
4304
|
+
"credential",
|
|
4305
|
+
"credentials",
|
|
4306
|
+
"dashboard",
|
|
4307
|
+
"developer",
|
|
4308
|
+
"developers",
|
|
4309
|
+
"key",
|
|
4310
|
+
"keys",
|
|
4311
|
+
"login",
|
|
4312
|
+
"new",
|
|
4313
|
+
"oauth",
|
|
4314
|
+
"payment",
|
|
4315
|
+
"profile",
|
|
4316
|
+
"project",
|
|
4317
|
+
"projects",
|
|
4318
|
+
"register",
|
|
4319
|
+
"security",
|
|
4320
|
+
"settings",
|
|
4321
|
+
"sign-in",
|
|
4322
|
+
"sign-up",
|
|
4323
|
+
"signin",
|
|
4324
|
+
"signup",
|
|
4325
|
+
"success",
|
|
4326
|
+
"token",
|
|
4327
|
+
"tokens",
|
|
4328
|
+
"verification",
|
|
4329
|
+
"verify",
|
|
4330
|
+
"workspace",
|
|
4331
|
+
"workspaces",
|
|
4332
|
+
]);
|
|
4333
|
+
function compactV2ReplaySafeUrl(rawUrl) {
|
|
4334
|
+
try {
|
|
4335
|
+
const parsed = new URL(rawUrl);
|
|
4336
|
+
if (safeOriginV2(parsed.origin) === null)
|
|
4337
|
+
return null;
|
|
4338
|
+
if (parsed.username.length > 0 ||
|
|
4339
|
+
parsed.password.length > 0 ||
|
|
4340
|
+
parsed.search.length > 0 ||
|
|
4341
|
+
parsed.hash.length > 0 ||
|
|
4342
|
+
isSingleUseUrl(rawUrl)) {
|
|
4343
|
+
return null;
|
|
4344
|
+
}
|
|
4345
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
4346
|
+
if (segments.some((segment) => segment !== segment.toLowerCase() || !COMPACT_V2_REPLAY_ROUTE_SEGMENTS.has(segment))) {
|
|
4347
|
+
return null;
|
|
4348
|
+
}
|
|
4349
|
+
return parsed.toString();
|
|
4350
|
+
}
|
|
4351
|
+
catch {
|
|
4352
|
+
return null;
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4355
|
+
function compactV2RecordedAction(session, action) {
|
|
4356
|
+
if (session.compactV2Mode !== "on")
|
|
4357
|
+
return action;
|
|
4358
|
+
if (action.kind === "goto") {
|
|
4359
|
+
const url = compactV2ReplaySafeUrl(action.url);
|
|
4360
|
+
if (url !== null)
|
|
4361
|
+
return { ...action, url };
|
|
4362
|
+
rejectRecipeRecording(session, "compact_v2_unrepresentable_goto");
|
|
4363
|
+
return null;
|
|
4364
|
+
}
|
|
4365
|
+
if (action.kind === "allow_host") {
|
|
4366
|
+
const origin = safeOriginV2(`https://${action.host}`);
|
|
4367
|
+
if (origin !== null)
|
|
4368
|
+
return { ...action, host: new URL(origin).hostname };
|
|
4369
|
+
rejectRecipeRecording(session, "compact_v2_unrepresentable_host");
|
|
4370
|
+
return null;
|
|
4371
|
+
}
|
|
4372
|
+
if (action.kind === "type") {
|
|
4373
|
+
if (looksLikeEmailValue(action.text))
|
|
4374
|
+
return { ...action, text: EMAIL_SLOT_TEMPLATE };
|
|
4375
|
+
rejectRecipeRecording(session, "compact_v2_unrepresentable_value_action");
|
|
4376
|
+
return null;
|
|
4377
|
+
}
|
|
4378
|
+
if (action.kind === "select") {
|
|
4379
|
+
// Registry-bound recipe value, not an observation: it must be recognizable
|
|
4380
|
+
// code-owned vocabulary before it is published to the shared registry.
|
|
4381
|
+
const text = recordableTokenV2(action.text);
|
|
4382
|
+
if (text !== undefined)
|
|
4383
|
+
return { ...action, text };
|
|
4384
|
+
rejectRecipeRecording(session, "compact_v2_unrepresentable_value_action");
|
|
4385
|
+
return null;
|
|
4386
|
+
}
|
|
4387
|
+
if (action.kind === "set_phone_country") {
|
|
4388
|
+
if (/^[a-z]{2}$/i.test(action.country)) {
|
|
4389
|
+
return { ...action, country: action.country.toUpperCase() };
|
|
4390
|
+
}
|
|
4391
|
+
rejectRecipeRecording(session, "compact_v2_unrepresentable_value_action");
|
|
4392
|
+
return null;
|
|
4393
|
+
}
|
|
4394
|
+
return action;
|
|
4395
|
+
}
|
|
3943
4396
|
// Exported for unit tests.
|
|
3944
4397
|
export function redactEmailForTrace(value) {
|
|
3945
4398
|
return looksLikeEmailValue(value) ? EMAIL_SLOT_TEMPLATE : value;
|
|
@@ -4043,7 +4496,9 @@ export function recipeTargetFor(el, inventory = [], userEmail = null) {
|
|
|
4043
4496
|
: {}),
|
|
4044
4497
|
...(nearText !== null ? { near_text_hint: scrub(nearText) } : {}),
|
|
4045
4498
|
...(hrefHint !== null && !isSingleUseUrl(el.href ?? "") ? { href_hint: scrub(hrefHint) } : {}),
|
|
4046
|
-
...(el.selector.length > 0
|
|
4499
|
+
...(el.selector.length > 0 && !el.selector.startsWith("@c:")
|
|
4500
|
+
? { css: scrub(el.selector) }
|
|
4501
|
+
: {}),
|
|
4047
4502
|
...(el.visibleText !== null && el.visibleText.length > 0
|
|
4048
4503
|
? { visible_text: scrub(el.visibleText) }
|
|
4049
4504
|
: {}),
|
|
@@ -4055,6 +4510,10 @@ export function recipeTargetFor(el, inventory = [], userEmail = null) {
|
|
|
4055
4510
|
};
|
|
4056
4511
|
}
|
|
4057
4512
|
function recordTrace(session, action, el, sensitiveSource) {
|
|
4513
|
+
const recordedAction = compactV2RecordedAction(session, action);
|
|
4514
|
+
if (recordedAction === null)
|
|
4515
|
+
return;
|
|
4516
|
+
action = recordedAction;
|
|
4058
4517
|
// Never freeze a single-use link (email-verify / magic / reset token) into
|
|
4059
4518
|
// the recipe — it's dead on the next replay. The host agent re-plans the
|
|
4060
4519
|
// verification step live (operate_act { kind: "await_verification" } fetches a FRESH link)
|
|
@@ -4233,16 +4692,29 @@ export function captureObserved(action, el) {
|
|
|
4233
4692
|
}
|
|
4234
4693
|
// Accumulate one MEDIUM round: inventory + action + url, no html/screenshot.
|
|
4235
4694
|
function recordCaptureRound(session, action, el, urlAtObservation) {
|
|
4236
|
-
const
|
|
4695
|
+
const recordedAction = compactV2RecordedAction(session, action);
|
|
4696
|
+
if (recordedAction === null)
|
|
4697
|
+
return;
|
|
4698
|
+
const observed = captureObserved(recordedAction, el);
|
|
4237
4699
|
if (observed === null)
|
|
4238
4700
|
return;
|
|
4701
|
+
const stateUrl = session.compactV2Mode === "on" ? compactV2ReplaySafeUrl(urlAtObservation) : urlAtObservation;
|
|
4702
|
+
if (stateUrl === null) {
|
|
4703
|
+
rejectRecipeRecording(session, "compact_v2_unrepresentable_page_url");
|
|
4704
|
+
return;
|
|
4705
|
+
}
|
|
4239
4706
|
session.captureRounds.push({
|
|
4240
4707
|
service: captureService(session),
|
|
4241
4708
|
round: session.captureRounds.length,
|
|
4242
|
-
oauth:
|
|
4709
|
+
oauth: recordedAction.kind === "oauth_click" || recordedAction.kind === "oauth_login",
|
|
4243
4710
|
// The URL the inventory + action belong to (pre-action), NOT the post-
|
|
4244
4711
|
// navigation URL — see urlBeforeAction in act().
|
|
4245
|
-
state: {
|
|
4712
|
+
state: {
|
|
4713
|
+
url: stateUrl,
|
|
4714
|
+
title: "",
|
|
4715
|
+
html: "",
|
|
4716
|
+
screenshot: "",
|
|
4717
|
+
},
|
|
4246
4718
|
inventory: session.lastElements,
|
|
4247
4719
|
observed,
|
|
4248
4720
|
});
|
|
@@ -4251,20 +4723,35 @@ function recordCaptureRound(session, action, el, urlAtObservation) {
|
|
|
4251
4723
|
// step is synthesized from the page where the credential is shown.
|
|
4252
4724
|
async function recordExtractRound(session) {
|
|
4253
4725
|
let html = "";
|
|
4254
|
-
|
|
4255
|
-
|
|
4726
|
+
if (session.compactV2Mode !== "on") {
|
|
4727
|
+
try {
|
|
4728
|
+
html = (await session.browser.getState()).html;
|
|
4729
|
+
}
|
|
4730
|
+
catch {
|
|
4731
|
+
/* best-effort — the copy-button/inventory extract path still works */
|
|
4732
|
+
}
|
|
4256
4733
|
}
|
|
4257
|
-
|
|
4258
|
-
|
|
4734
|
+
const stateUrl = session.compactV2Mode === "on"
|
|
4735
|
+
? compactV2ReplaySafeUrl(session.browser.currentUrl())
|
|
4736
|
+
: session.browser.currentUrl();
|
|
4737
|
+
if (stateUrl === null) {
|
|
4738
|
+
rejectRecipeRecording(session, "compact_v2_unrepresentable_page_url");
|
|
4739
|
+
return false;
|
|
4259
4740
|
}
|
|
4260
4741
|
session.captureRounds.push({
|
|
4261
4742
|
service: captureService(session),
|
|
4262
4743
|
round: session.captureRounds.length,
|
|
4263
4744
|
oauth: false,
|
|
4264
|
-
state: {
|
|
4745
|
+
state: {
|
|
4746
|
+
url: stateUrl,
|
|
4747
|
+
title: "",
|
|
4748
|
+
html,
|
|
4749
|
+
screenshot: "",
|
|
4750
|
+
},
|
|
4265
4751
|
inventory: session.lastElements,
|
|
4266
4752
|
observed: { kind: "extract", reason: "extract the credential shown on the page" },
|
|
4267
4753
|
});
|
|
4754
|
+
return true;
|
|
4268
4755
|
}
|
|
4269
4756
|
// Record the extract round from the live page, then write the accumulated medium
|
|
4270
4757
|
// rounds through the real capture path (integrity chain) and synthesize a skill.
|
|
@@ -4280,12 +4767,18 @@ export async function captureAndPromoteSession(sessionId) {
|
|
|
4280
4767
|
if (session.usedLocatorFallback) {
|
|
4281
4768
|
return { kind: "skipped", reason: "locator_fallback_unrepresentable" };
|
|
4282
4769
|
}
|
|
4770
|
+
if (session.recipeRejectionReason !== null) {
|
|
4771
|
+
return { kind: "skipped", reason: session.recipeRejectionReason };
|
|
4772
|
+
}
|
|
4283
4773
|
const dir = resolveCaptureDir();
|
|
4284
4774
|
if (dir === null)
|
|
4285
4775
|
return { kind: "skipped", reason: "capture_disabled" };
|
|
4286
4776
|
if (!session.captureRounds.some((r) => r.observed.kind === "extract")) {
|
|
4287
4777
|
await recordExtractRound(session);
|
|
4288
4778
|
}
|
|
4779
|
+
if (session.recipeRejectionReason !== null) {
|
|
4780
|
+
return { kind: "skipped", reason: session.recipeRejectionReason };
|
|
4781
|
+
}
|
|
4289
4782
|
const hasExtract = session.captureRounds.some((r) => r.observed.kind === "extract");
|
|
4290
4783
|
if (!hasExtract || session.captureRounds.length < 2) {
|
|
4291
4784
|
return { kind: "skipped", reason: "too_few_rounds" };
|
|
@@ -4332,18 +4825,63 @@ export function emitProvisionMeasurement(sessionId, outcome) {
|
|
|
4332
4825
|
now: Date.now(),
|
|
4333
4826
|
turns: session.actionTrace.length,
|
|
4334
4827
|
});
|
|
4335
|
-
|
|
4828
|
+
const emitted = session.compactV2Mode === "on"
|
|
4829
|
+
? { ...m, service: compactV2AuditValue("service", m.service) }
|
|
4830
|
+
: m;
|
|
4831
|
+
process.stderr.write(`${JSON.stringify({ marker: "provision-measurement", ...emitted })}\n`);
|
|
4336
4832
|
return m;
|
|
4337
4833
|
}
|
|
4834
|
+
// ── new-tab adoption ──
|
|
4835
|
+
//
|
|
4836
|
+
// A click on a `target=_blank` link or a `window.open` control opens a NEW TAB,
|
|
4837
|
+
// and a real user lands on it. The operator has to do the same: an email
|
|
4838
|
+
// magic-link button in Gmail opens its login tab that way, and following the
|
|
4839
|
+
// tab is the ONLY safe way to reach it — the link carries a single-use login
|
|
4840
|
+
// token, so `extract` seals the href and must never hand it to the host as
|
|
4841
|
+
// text. Navigating the browser exposes nothing.
|
|
4842
|
+
//
|
|
4843
|
+
// Grace between the click returning and Playwright delivering the context
|
|
4844
|
+
// "page" event for the tab it opened. It cannot be zero: MEASURED 2026-09-03
|
|
4845
|
+
// (new-tab-adoption.test.ts, 3 runs) — at 0 the event has not landed yet and
|
|
4846
|
+
// every follow case fails, including the post-settle drain below. It is kept
|
|
4847
|
+
// short because a click that opens NO tab pays it in full, and a tab that
|
|
4848
|
+
// arrives later than this is still caught by that drain.
|
|
4849
|
+
const OPENED_TAB_GRACE_MS = 300;
|
|
4850
|
+
// Payment is deliberately out of scope. A sealed card fill and a live
|
|
4851
|
+
// place-order/3DS approval both own their page identity, so leave the operator
|
|
4852
|
+
// anchored where those flows put it rather than following a tab into them.
|
|
4853
|
+
function newTabAdoptionAllowed(session) {
|
|
4854
|
+
return (!session.paymentFieldSealActive &&
|
|
4855
|
+
session.placeOrderApproval === null &&
|
|
4856
|
+
getActivePendingThreeDs(session) === null);
|
|
4857
|
+
}
|
|
4858
|
+
async function adoptOpenedTab(session, browser, graceMs) {
|
|
4859
|
+
if (!newTabAdoptionAllowed(session))
|
|
4860
|
+
return;
|
|
4861
|
+
const url = await browser.adoptOpenedTab(graceMs).catch(() => null);
|
|
4862
|
+
if (url === null)
|
|
4863
|
+
return;
|
|
4864
|
+
audit(session.id, "new_tab_adopted", { host: registrableHost(url) });
|
|
4865
|
+
}
|
|
4866
|
+
async function adoptTabOpenedByClick(session, browser, click) {
|
|
4867
|
+
if (!newTabAdoptionAllowed(session)) {
|
|
4868
|
+
await click();
|
|
4869
|
+
return;
|
|
4870
|
+
}
|
|
4871
|
+
browser.armOpenedTabAdoption();
|
|
4872
|
+
try {
|
|
4873
|
+
await click();
|
|
4874
|
+
}
|
|
4875
|
+
finally {
|
|
4876
|
+
await adoptOpenedTab(session, browser, OPENED_TAB_GRACE_MS);
|
|
4877
|
+
}
|
|
4878
|
+
}
|
|
4338
4879
|
async function settleAfterStateChange(browser) {
|
|
4339
|
-
|
|
4880
|
+
// A fixed dwell here used to consume the OAuth action's completion window
|
|
4881
|
+
// after the provider had already returned. Wait for the page's actual
|
|
4882
|
+
// interactive state instead; it resolves immediately when the redirect has
|
|
4883
|
+
// rendered and remains bounded for slow SPAs.
|
|
4340
4884
|
await browser.waitForInteractiveDom(1, 2_000).catch(() => undefined);
|
|
4341
|
-
for (let i = 0; i < 4; i += 1) {
|
|
4342
|
-
const text = await browser.extractVisibleText().catch(() => "");
|
|
4343
|
-
if (text.replace(/\s+/g, " ").trim().length > 0)
|
|
4344
|
-
return;
|
|
4345
|
-
await settle(300);
|
|
4346
|
-
}
|
|
4347
4885
|
}
|
|
4348
4886
|
// ── operator-recipe: remember a successful run, verify a postcondition ──
|
|
4349
4887
|
// Persist the session's action trace as a keyed, replayable operator-recipe.
|
|
@@ -4603,16 +5141,42 @@ async function rememberCheckoutLeg(session, verb, trace) {
|
|
|
4603
5141
|
// Read a single page snapshot for postcondition checking. Field VALUES are
|
|
4604
5142
|
// reduced to lengths here so a token/secret success-signal can't leak.
|
|
4605
5143
|
async function snapshotForPostcondition(session) {
|
|
5144
|
+
const privateFields = session.compactV2Mode === "on"
|
|
5145
|
+
? (await session.browser.extractInteractiveElements())
|
|
5146
|
+
.filter((element) => typeof element.value === "string" && element.value.length > 0)
|
|
5147
|
+
.map((element) => {
|
|
5148
|
+
const sealed = sealRetainedInteractiveElementsV2([element])[0];
|
|
5149
|
+
const description = safeDescriptionV2(sealed.labelText ??
|
|
5150
|
+
sealed.ariaLabel ??
|
|
5151
|
+
sealed.placeholder ??
|
|
5152
|
+
sealed.title ??
|
|
5153
|
+
sealed.name ??
|
|
5154
|
+
sealed.id);
|
|
5155
|
+
const fieldRole = localeStableFieldRole(sealed);
|
|
5156
|
+
return {
|
|
5157
|
+
label: [description, fieldRole]
|
|
5158
|
+
.filter((value) => value !== undefined && value !== null)
|
|
5159
|
+
.join(" "),
|
|
5160
|
+
value_len: element.value.length,
|
|
5161
|
+
};
|
|
5162
|
+
})
|
|
5163
|
+
.filter((field) => field.label.length > 0)
|
|
5164
|
+
: null;
|
|
4606
5165
|
const obs = await observeSession(session);
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
5166
|
+
const fields = privateFields ??
|
|
5167
|
+
session.lastElements
|
|
5168
|
+
.filter((element) => typeof element.value === "string" && element.value.length > 0)
|
|
5169
|
+
.map((element) => ({
|
|
5170
|
+
label: elementRef(element),
|
|
5171
|
+
value_len: element.value.length,
|
|
5172
|
+
}));
|
|
5173
|
+
return {
|
|
5174
|
+
url: obs.format === "compact-v2" ? session.browser.currentUrl() : obs.url,
|
|
5175
|
+
text: obs.format === "compact-v2"
|
|
5176
|
+
? await session.browser.extractVisibleText()
|
|
5177
|
+
: (session.prevObserve?.text ?? obs.text),
|
|
5178
|
+
fields,
|
|
5179
|
+
};
|
|
4616
5180
|
}
|
|
4617
5181
|
// Verify a recipe's postcondition against the live session — the anti-false-
|
|
4618
5182
|
// green gate for replay. execute_capability checks the current end-state;
|
|
@@ -4626,6 +5190,7 @@ export async function verifyPostcondition(sessionId, postcondition) {
|
|
|
4626
5190
|
if (host !== null && !session.allowedHosts.some((e) => e.host === host)) {
|
|
4627
5191
|
session.allowedHosts.push({ host, source: "mid_session" });
|
|
4628
5192
|
}
|
|
5193
|
+
invalidateCompactV2Snapshot(session);
|
|
4629
5194
|
await session.browser.goto(postcondition.probe_url);
|
|
4630
5195
|
await settle(1500);
|
|
4631
5196
|
}
|
|
@@ -4639,7 +5204,7 @@ export async function verifyPostcondition(sessionId, postcondition) {
|
|
|
4639
5204
|
return result;
|
|
4640
5205
|
}
|
|
4641
5206
|
export async function verifySavedRecipePostcondition(sessionId, recipe) {
|
|
4642
|
-
const session =
|
|
5207
|
+
const session = sessionForCall(sessionId);
|
|
4643
5208
|
if (session === undefined)
|
|
4644
5209
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
4645
5210
|
const state = session.replayState;
|
|
@@ -4656,7 +5221,7 @@ export async function verifySavedRecipePostcondition(sessionId, recipe) {
|
|
|
4656
5221
|
return await verifyPostcondition(sessionId, postcondition);
|
|
4657
5222
|
}
|
|
4658
5223
|
export async function verifyActiveRecipePostcondition(sessionId, recipeName) {
|
|
4659
|
-
const session =
|
|
5224
|
+
const session = sessionForCall(sessionId);
|
|
4660
5225
|
if (session === undefined)
|
|
4661
5226
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
4662
5227
|
const state = session.replayState;
|
|
@@ -4835,7 +5400,7 @@ function replayTargetWithinRecipeDomain(url, recipeDomain) {
|
|
|
4835
5400
|
return isSameRecipeDomain(url, recipeDomain);
|
|
4836
5401
|
}
|
|
4837
5402
|
function markReplayDomainLockViolation(session, host, recipeDomain) {
|
|
4838
|
-
rejectRecipeRecording(session,
|
|
5403
|
+
rejectRecipeRecording(session, "replay_domain_lock_violation");
|
|
4839
5404
|
audit(session.id, "replay_domain_lock_violation", { host, recipe_domain: recipeDomain });
|
|
4840
5405
|
}
|
|
4841
5406
|
function verifyReplayFieldInElements(session, expected, elements, allowCommittedSelect = false) {
|
|
@@ -4855,18 +5420,18 @@ function verifyReplayFieldInElements(session, expected, elements, allowCommitted
|
|
|
4855
5420
|
]);
|
|
4856
5421
|
if (guard.ok) {
|
|
4857
5422
|
if (expected.kind === "select") {
|
|
4858
|
-
session
|
|
5423
|
+
clearCommittedSelectValue(session, resolution.element.selector);
|
|
4859
5424
|
}
|
|
4860
5425
|
return { ok: true };
|
|
4861
5426
|
}
|
|
4862
5427
|
if (allowCommittedSelect &&
|
|
4863
5428
|
expected.kind === "select" &&
|
|
4864
|
-
session.committedSelectValues.get(resolution.element.selector) === expected.expected) {
|
|
4865
|
-
session
|
|
5429
|
+
session.committedSelectValues.get(compactV2CommittedSelectKey(session, resolution.element.selector)) === compactV2CommittedSelectValue(session, expected.expected)) {
|
|
5430
|
+
clearCommittedSelectValue(session, resolution.element.selector);
|
|
4866
5431
|
return { ok: true };
|
|
4867
5432
|
}
|
|
4868
5433
|
if (expected.kind === "select") {
|
|
4869
|
-
session
|
|
5434
|
+
clearCommittedSelectValue(session, resolution.element.selector);
|
|
4870
5435
|
}
|
|
4871
5436
|
return { ok: false, reason: guard.reason };
|
|
4872
5437
|
}
|
|
@@ -4880,7 +5445,7 @@ async function verifyReplayField(session, expected, allowCommittedSelect = false
|
|
|
4880
5445
|
if (target === null)
|
|
4881
5446
|
return { ok: false, reason: "field_missing" };
|
|
4882
5447
|
const fresh = await session.browser.extractInteractiveElements();
|
|
4883
|
-
session
|
|
5448
|
+
retainSessionElements(session, fresh);
|
|
4884
5449
|
return verifyReplayFieldInElements(session, expected, fresh, allowCommittedSelect);
|
|
4885
5450
|
}
|
|
4886
5451
|
async function verifyReplayFieldWithElements(session, expected, elements, allowCommittedSelect = false) {
|
|
@@ -4942,7 +5507,7 @@ async function attestRecordedFieldsBeforeTransition(session, action) {
|
|
|
4942
5507
|
if (fields.length === 0)
|
|
4943
5508
|
return fields;
|
|
4944
5509
|
const fresh = await session.browser.extractInteractiveElements();
|
|
4945
|
-
session
|
|
5510
|
+
retainSessionElements(session, fresh);
|
|
4946
5511
|
for (const expected of fields) {
|
|
4947
5512
|
const guard = await verifyReplayFieldWithElements(session, expected, fresh);
|
|
4948
5513
|
if (!guard.ok) {
|
|
@@ -4957,7 +5522,7 @@ async function verifyRecordedFieldsAfterTransition(session, action, fields) {
|
|
|
4957
5522
|
return;
|
|
4958
5523
|
}
|
|
4959
5524
|
const fresh = await session.browser.extractInteractiveElements();
|
|
4960
|
-
session
|
|
5525
|
+
retainSessionElements(session, fresh);
|
|
4961
5526
|
for (const expected of fields) {
|
|
4962
5527
|
if (!(await isReplayFieldMounted(session, expected, fresh))) {
|
|
4963
5528
|
rejectRecipeRecording(session, `checkout transition could not be attested (${expected.hole}: field_missing)`);
|
|
@@ -4976,7 +5541,7 @@ async function verifyRecordedFieldsAfterTransition(session, action, fields) {
|
|
|
4976
5541
|
* repairs that one step, calling again with `fromIndex=next_index` continues.
|
|
4977
5542
|
*/
|
|
4978
5543
|
export async function replayOperatorRecipe(sessionId, recipe, bindings, fromIndex = 0, options = {}) {
|
|
4979
|
-
const session =
|
|
5544
|
+
const session = sessionForCall(sessionId);
|
|
4980
5545
|
if (session === undefined)
|
|
4981
5546
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
4982
5547
|
const recipeHash = replayDigest(recipe);
|
|
@@ -5075,12 +5640,16 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
|
|
|
5075
5640
|
const recipeDomain = recipe.domain;
|
|
5076
5641
|
const domainLockViolation = async (stepIndex, host) => {
|
|
5077
5642
|
markReplayDomainLockViolation(session, host, recipeDomain);
|
|
5643
|
+
const publicHost = session.compactV2Active ? compactV2AuditHost(host) : host;
|
|
5644
|
+
const publicRecipeDomain = session.compactV2Active
|
|
5645
|
+
? compactV2AuditHost(recipeDomain)
|
|
5646
|
+
: recipeDomain;
|
|
5078
5647
|
return {
|
|
5079
5648
|
status: "domain_lock_violation",
|
|
5080
5649
|
observation: await observe(sessionId),
|
|
5081
5650
|
step_index: stepIndex,
|
|
5082
|
-
host,
|
|
5083
|
-
recipe_domain:
|
|
5651
|
+
host: publicHost,
|
|
5652
|
+
recipe_domain: publicRecipeDomain,
|
|
5084
5653
|
};
|
|
5085
5654
|
};
|
|
5086
5655
|
for (let i = fromIndex; i < recipe.trace.length; i += 1) {
|
|
@@ -5182,7 +5751,7 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
|
|
|
5182
5751
|
// Structural pre-check: resolve against the live inventory before every
|
|
5183
5752
|
// deterministic act. This is especially load-bearing on money paths.
|
|
5184
5753
|
const fresh = await session.browser.extractInteractiveElements();
|
|
5185
|
-
session
|
|
5754
|
+
retainSessionElements(session, fresh);
|
|
5186
5755
|
const expectedForStep = state.expectedFields.get(i);
|
|
5187
5756
|
const resolution = expectedForStep === undefined
|
|
5188
5757
|
? resolveRecipeTarget(fresh, target)
|
|
@@ -5233,12 +5802,19 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
|
|
|
5233
5802
|
action = { kind: "js_click", target: ref };
|
|
5234
5803
|
}
|
|
5235
5804
|
else {
|
|
5236
|
-
|
|
5805
|
+
const recipeProvider = recipe.oauth_provider;
|
|
5806
|
+
action = {
|
|
5807
|
+
kind: "oauth_click",
|
|
5808
|
+
target: ref,
|
|
5809
|
+
...(recipeProvider === "google" || recipeProvider === "github"
|
|
5810
|
+
? { provider: recipeProvider }
|
|
5811
|
+
: {}),
|
|
5812
|
+
};
|
|
5237
5813
|
}
|
|
5238
5814
|
}
|
|
5239
5815
|
try {
|
|
5240
5816
|
await options.beforeAction?.({ step_index: i, action });
|
|
5241
|
-
await
|
|
5817
|
+
await actInternally(sessionId, action, "none");
|
|
5242
5818
|
replayed += 1;
|
|
5243
5819
|
const expected = state.expectedFields.get(i);
|
|
5244
5820
|
if (expected !== undefined) {
|
|
@@ -5251,7 +5827,11 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
|
|
|
5251
5827
|
catch (error) {
|
|
5252
5828
|
if (error instanceof ManualCardEntryBlockedError)
|
|
5253
5829
|
throw error;
|
|
5254
|
-
return await fallback(step, i,
|
|
5830
|
+
return await fallback(step, i, session.compactV2Active
|
|
5831
|
+
? compactV2ActionFailureReason(error, action.kind)
|
|
5832
|
+
: error instanceof Error
|
|
5833
|
+
? error.message
|
|
5834
|
+
: String(error));
|
|
5255
5835
|
}
|
|
5256
5836
|
}
|
|
5257
5837
|
return {
|
|
@@ -5330,10 +5910,11 @@ export function sanitizeExtractedCredentials(credentials, url, haystack = Object
|
|
|
5330
5910
|
const k = normLabelKey(key);
|
|
5331
5911
|
if (k === "refcode" || k === "referral_code")
|
|
5332
5912
|
continue;
|
|
5913
|
+
// Page PROSE is still dropped — a greeting or a date under a "Key" heading
|
|
5914
|
+
// is not a value the page is presenting as a credential. A masked-looking
|
|
5915
|
+
// display is NOT prose: it is what the page renders, so it is returned.
|
|
5333
5916
|
if (isCredentialNoise(value))
|
|
5334
5917
|
continue;
|
|
5335
|
-
if ((k === "key" || k === "api_key") && !looksLikeCredentialValue(value))
|
|
5336
|
-
continue;
|
|
5337
5918
|
if (host === "api.together.ai" && /^key_[A-Za-z0-9]{16,}$/i.test(value.trim()))
|
|
5338
5919
|
continue;
|
|
5339
5920
|
normalized[key] = value;
|
|
@@ -5367,6 +5948,7 @@ export async function extractCredentials(sessionId) {
|
|
|
5367
5948
|
if (session === undefined)
|
|
5368
5949
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
5369
5950
|
const { browser } = session;
|
|
5951
|
+
invalidateCompactV2Snapshot(session);
|
|
5370
5952
|
// The masked-display trap: click reveal/show toggles before reading.
|
|
5371
5953
|
await browser.revealMaskedCredentials();
|
|
5372
5954
|
const labeled = await browser.extractLabeledCredentialCandidates();
|
|
@@ -5421,16 +6003,13 @@ export async function extractCredentials(sessionId) {
|
|
|
5421
6003
|
: { kind: "full", value: key };
|
|
5422
6004
|
state = accumulateCandidate(state, cls);
|
|
5423
6005
|
}
|
|
5424
|
-
// Named credentials for multi-cred services
|
|
5425
|
-
//
|
|
5426
|
-
//
|
|
6006
|
+
// Named credentials for multi-cred services. Every labeled value the page
|
|
6007
|
+
// renders is returned under its label — including one that still LOOKS masked.
|
|
6008
|
+
// A value on the page is the agent's to read; refusing it (the old
|
|
6009
|
+
// `isMasked` skip) boxed the operator out of keys the page was displaying.
|
|
5427
6010
|
const named = {};
|
|
5428
6011
|
for (const c of labeled) {
|
|
5429
|
-
if (c.label === null
|
|
5430
|
-
continue;
|
|
5431
|
-
if (isCredentialNoise(c.value))
|
|
5432
|
-
continue;
|
|
5433
|
-
if (looksLikeCodeIdentifier(c.value))
|
|
6012
|
+
if (c.label === null)
|
|
5434
6013
|
continue;
|
|
5435
6014
|
const k = normLabelKey(c.label);
|
|
5436
6015
|
if (k.length > 0 && !(k in named))
|
|
@@ -5481,28 +6060,12 @@ export async function extractCredentials(sessionId) {
|
|
|
5481
6060
|
}
|
|
5482
6061
|
const sanitized = sanitizeExtractedCredentials(credentials, browser.currentUrl(), haystack);
|
|
5483
6062
|
const found = Object.keys(sanitized).length > 0;
|
|
5484
|
-
|
|
5485
|
-
// done: if the page HAD labeled candidates but none survived as a real
|
|
5486
|
-
// credential, they were page noise (a date/email/greeting) or a still-masked
|
|
5487
|
-
// display — i.e. this isn't the keys page or the key needs revealing. Tell the
|
|
5488
|
-
// agent that so it navigates/reveals and extracts again, rather than storing junk.
|
|
5489
|
-
const notLegit = !found && labeled.length > 0
|
|
5490
|
-
? "no_legit_credential: the page had candidate values but none looked like a " +
|
|
5491
|
-
"real key (they were page text — a date/email/label — or a still-masked " +
|
|
5492
|
-
"display). You are likely NOT on the API-keys page, or the key is masked. " +
|
|
5493
|
-
"Navigate to the keys/settings page (or click reveal/show/copy), then extract again."
|
|
5494
|
-
: null;
|
|
5495
|
-
audit(sessionId, "extract", {
|
|
5496
|
-
found,
|
|
5497
|
-
candidate_count: labeled.length,
|
|
5498
|
-
not_legit: notLegit !== null,
|
|
5499
|
-
});
|
|
6063
|
+
audit(sessionId, "extract", { found, candidate_count: labeled.length });
|
|
5500
6064
|
return {
|
|
5501
6065
|
session_id: sessionId,
|
|
5502
6066
|
url: browser.currentUrl(),
|
|
5503
6067
|
credentials: sanitized,
|
|
5504
6068
|
candidate_count: labeled.length,
|
|
5505
|
-
...(notLegit !== null ? { blocked_reason: notLegit } : {}),
|
|
5506
6069
|
};
|
|
5507
6070
|
}
|
|
5508
6071
|
// A TwoCaptchaVaultProxy backed by the MCP api-client: every 2Captcha call is
|
|
@@ -5619,6 +6182,7 @@ export async function captchaGate(sessionId) {
|
|
|
5619
6182
|
const session = sessionForCall(sessionId);
|
|
5620
6183
|
if (session === undefined)
|
|
5621
6184
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
6185
|
+
invalidateCompactV2Snapshot(session);
|
|
5622
6186
|
const det = await session.browser.detectCaptchaVariant();
|
|
5623
6187
|
const found = det.variant !== "unknown" || det.challengeRendered;
|
|
5624
6188
|
if (!found) {
|
|
@@ -5711,15 +6275,10 @@ export async function captchaGate(sessionId) {
|
|
|
5711
6275
|
// number elsewhere in the mail doesn't win; falls back to the first standalone
|
|
5712
6276
|
// 4-8 digit run. The link uses the bot's pickVerificationLink heuristic.
|
|
5713
6277
|
const OTP_ANY_RE = /(?:^|[^0-9])(\d{4,8})(?:[^0-9]|$)/g;
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
let code = null;
|
|
5719
|
-
if (kw !== null) {
|
|
5720
|
-
code = kw[1] ?? kw[2] ?? null;
|
|
5721
|
-
}
|
|
5722
|
-
else {
|
|
6278
|
+
export function parseVerification(text, links, expectedDomains) {
|
|
6279
|
+
const link = pickVerificationLink([...links], expectedDomains);
|
|
6280
|
+
let code = findOtpCredential(text);
|
|
6281
|
+
if (code === null) {
|
|
5723
6282
|
const m = OTP_ANY_RE.exec(text);
|
|
5724
6283
|
code = m !== null ? (m[1] ?? null) : null;
|
|
5725
6284
|
}
|
|
@@ -5734,6 +6293,17 @@ export function extractSenderEmail(text) {
|
|
|
5734
6293
|
const m = /<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/.exec(text);
|
|
5735
6294
|
return m !== null ? m[1].toLowerCase() : null;
|
|
5736
6295
|
}
|
|
6296
|
+
// Derives the domain(s) pickVerificationLink should prefer: the caller's
|
|
6297
|
+
// `sender` search hint (an email address or a bare domain) and the sender
|
|
6298
|
+
// address read off the opened message, deduped and lowercased. Exported for
|
|
6299
|
+
// unit tests.
|
|
6300
|
+
export function expectedVerificationDomains(sender, sourceFrom) {
|
|
6301
|
+
const domainOf = (s) => (s.includes("@") ? s.split("@").pop() : s).toLowerCase();
|
|
6302
|
+
const domains = [sender, sourceFrom ?? undefined]
|
|
6303
|
+
.filter((s) => s !== undefined && s.length > 0)
|
|
6304
|
+
.map(domainOf);
|
|
6305
|
+
return [...new Set(domains)];
|
|
6306
|
+
}
|
|
5737
6307
|
// Pure: assemble the verification result. When neither a code nor a link was
|
|
5738
6308
|
// found, the thick session is still live, so this is a RESUMABLE hand-back
|
|
5739
6309
|
// (Flow A) — the host asks the user for the code and types it — not a give-up.
|
|
@@ -5754,21 +6324,19 @@ export function buildVerificationResult(sessionId, code, link, sourceFrom = null
|
|
|
5754
6324
|
};
|
|
5755
6325
|
return { session_id: sessionId, found, code, link, needs_user, ...src };
|
|
5756
6326
|
}
|
|
5757
|
-
//
|
|
5758
|
-
//
|
|
5759
|
-
//
|
|
5760
|
-
//
|
|
5761
|
-
// "consent withheld" apart from "code not found in an inbox we DID read".
|
|
6327
|
+
// Inbox-read opt-out refusal. The session stays live (resumable): the host asks
|
|
6328
|
+
// the user for the code and types it, or restores inbox access and retries.
|
|
6329
|
+
// Distinct from buildVerificationResult so the host can tell "access disabled"
|
|
6330
|
+
// apart from "code not found in an inbox we DID read".
|
|
5762
6331
|
// Exported for unit tests.
|
|
5763
6332
|
export function buildConsentRefusal(sessionId) {
|
|
5764
6333
|
const needs_user = {
|
|
5765
6334
|
wall: "verification_code",
|
|
5766
|
-
message: "Inbox reading is
|
|
5767
|
-
"the user
|
|
5768
|
-
'
|
|
5769
|
-
"
|
|
5770
|
-
"
|
|
5771
|
-
"live either way. (To grant it permanently, re-run `connect` and allow inbox access.)",
|
|
6335
|
+
message: "Inbox reading is disabled, so the operator did not read any mail. Ask " +
|
|
6336
|
+
"the user for the code and type it with operate_act, or retry " +
|
|
6337
|
+
'operate_act { kind: "await_verification" } with grant_inbox_consent:true ' +
|
|
6338
|
+
"to restore inbox reading for this session. The session stays live either way. " +
|
|
6339
|
+
"To change the default permanently, re-run `connect` and update advanced settings.",
|
|
5772
6340
|
resume: "code",
|
|
5773
6341
|
};
|
|
5774
6342
|
return { session_id: sessionId, found: false, code: null, link: null, needs_user };
|
|
@@ -5784,72 +6352,112 @@ export function buildVerificationSearchQuery(sender) {
|
|
|
5784
6352
|
return [
|
|
5785
6353
|
sender !== undefined && sender.length > 0 ? `from:${sender}` : "",
|
|
5786
6354
|
"newer_than:1d",
|
|
5787
|
-
'(verify OR verification OR confirm OR confirmation OR code OR otp OR passcode OR password OR login OR "log in" OR "sign in" OR "sign-in" OR signin OR "magic link" OR activate OR activation OR welcome)',
|
|
6355
|
+
'(verify OR verification OR confirm OR confirmation OR code OR otp OR passcode OR password OR login OR "log in" OR "sign in" OR "sign-in" OR signin OR "magic link" OR activate OR activation OR welcome OR "link account" OR "link your" OR continue)',
|
|
5788
6356
|
]
|
|
5789
6357
|
.filter((s) => s.length > 0)
|
|
5790
6358
|
.join(" ");
|
|
5791
6359
|
}
|
|
6360
|
+
// Gmail's own search backend intermittently throws a transient error —
|
|
6361
|
+
// "Oops... the system encountered a problem (#2014) - Retrying in Ns" —
|
|
6362
|
+
// and while that banner is up a search like `from:xata.io` can spuriously
|
|
6363
|
+
// render "No messages matched your search" even though the message is
|
|
6364
|
+
// really there. Exported for unit tests.
|
|
6365
|
+
export function isGmailTransientErrorText(text) {
|
|
6366
|
+
return /#2014|encountered a problem|retrying in\s*\d+/i.test(text);
|
|
6367
|
+
}
|
|
6368
|
+
// Exported for unit tests.
|
|
6369
|
+
export function isEmptyGmailResultText(text) {
|
|
6370
|
+
return /no messages matched your search/i.test(text);
|
|
6371
|
+
}
|
|
6372
|
+
// Backoff schedule for the transient-error retry, in ms: 800, 1600, 3200,
|
|
6373
|
+
// capped at 4000. Exported for unit tests.
|
|
6374
|
+
export function gmailTransientBackoffMs(retryIndex) {
|
|
6375
|
+
return Math.min(800 * 2 ** retryIndex, 4000);
|
|
6376
|
+
}
|
|
6377
|
+
// Bounded — a genuinely empty inbox must still resolve to not-found in
|
|
6378
|
+
// finite time, not hang retrying forever.
|
|
6379
|
+
const GMAIL_TRANSIENT_MAX_RETRIES = 3;
|
|
6380
|
+
// Reads the Gmail search results list, retrying through Gmail's own transient
|
|
6381
|
+
// backend error with backoff before accepting a result as final. Detects
|
|
6382
|
+
// EITHER the error banner itself, or an empty-looking "No messages matched"
|
|
6383
|
+
// render with no result links (the shape the banner's spurious empty state
|
|
6384
|
+
// takes) — and only gives up on the bounded retries running out, never on
|
|
6385
|
+
// the first read. Exported for unit tests via the pure detectors above; this
|
|
6386
|
+
// wrapper needs a live browser so it isn't itself unit tested directly.
|
|
6387
|
+
async function readGmailSearchResultsResilient(browser, searchUrl, linkCandidatesOf) {
|
|
6388
|
+
let text = "";
|
|
6389
|
+
let links = [];
|
|
6390
|
+
for (let retry = 0; retry <= GMAIL_TRANSIENT_MAX_RETRIES; retry++) {
|
|
6391
|
+
if (retry > 0) {
|
|
6392
|
+
await browser
|
|
6393
|
+
.waitForCaptchaChallengeToSettle(gmailTransientBackoffMs(retry - 1), 0)
|
|
6394
|
+
.catch(() => false);
|
|
6395
|
+
await browser.goto(searchUrl);
|
|
6396
|
+
}
|
|
6397
|
+
for (let i = 0; i < 6; i++) {
|
|
6398
|
+
text = await browser.extractVisibleText();
|
|
6399
|
+
if (text.length > 200)
|
|
6400
|
+
break;
|
|
6401
|
+
await browser.waitForCaptchaChallengeToSettle(1200, 0).catch(() => false);
|
|
6402
|
+
}
|
|
6403
|
+
links = linkCandidatesOf(await browser.extractInteractiveElements());
|
|
6404
|
+
const transientOrEmpty = isGmailTransientErrorText(text) || (isEmptyGmailResultText(text) && links.length === 0);
|
|
6405
|
+
if (!transientOrEmpty || retry === GMAIL_TRANSIENT_MAX_RETRIES)
|
|
6406
|
+
break;
|
|
6407
|
+
}
|
|
6408
|
+
return { text, links };
|
|
6409
|
+
}
|
|
5792
6410
|
export async function awaitVerification(sessionId, opts = {}) {
|
|
5793
6411
|
const session = sessionForCall(sessionId);
|
|
5794
6412
|
if (session === undefined)
|
|
5795
6413
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
5796
|
-
|
|
5797
|
-
//
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
}
|
|
5804
|
-
//
|
|
5805
|
-
//
|
|
5806
|
-
// behavior read mail.google.com unconditionally, silently breaking the
|
|
5807
|
-
// default-off consent promise.
|
|
6414
|
+
// A caller can override the default for this session without changing the
|
|
6415
|
+
// saved advanced preference. In particular, false must win over default-on.
|
|
6416
|
+
if (opts.grantConsent !== undefined && opts.grantConsent !== session.consentInboxRead) {
|
|
6417
|
+
session.consentInboxRead = opts.grantConsent;
|
|
6418
|
+
audit(sessionId, opts.grantConsent ? "inbox_consent_granted" : "inbox_consent_revoked", {
|
|
6419
|
+
scope: "session",
|
|
6420
|
+
});
|
|
6421
|
+
}
|
|
6422
|
+
// Explicit opt-out gate: do NOT read mail while disabled. Hand the code
|
|
6423
|
+
// request back to the user instead (resumable).
|
|
5808
6424
|
if (!session.consentInboxRead) {
|
|
5809
6425
|
audit(sessionId, "await_verification", { refused: "no_inbox_consent" });
|
|
5810
6426
|
return buildConsentRefusal(sessionId);
|
|
5811
6427
|
}
|
|
5812
|
-
|
|
5813
|
-
const
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
const openedLinks = hrefsOf(await browser.extractInteractiveElements());
|
|
5846
|
-
sourceFrom = extractSenderEmail(openedText);
|
|
5847
|
-
({ code, link } = parseVerification(openedText, [...openedLinks, ...listLinks]));
|
|
5848
|
-
}
|
|
5849
|
-
else {
|
|
5850
|
-
({ code, link } = parseVerification(listText, listLinks));
|
|
5851
|
-
}
|
|
5852
|
-
}
|
|
6428
|
+
invalidateCompactV2Snapshot(session);
|
|
6429
|
+
const verification = await runDetachedGoogleIdentityOperation(session, async (browser) => {
|
|
6430
|
+
return await browser.withTemporaryHostScopeAllowedHosts(["mail.google.com"], async () => {
|
|
6431
|
+
const query = buildVerificationSearchQuery(opts.sender);
|
|
6432
|
+
const searchUrl = `https://mail.google.com/mail/u/0/#search/${encodeURIComponent(query)}`;
|
|
6433
|
+
const linkCandidatesOf = (els) => els
|
|
6434
|
+
.filter((e) => typeof e.href === "string" && e.href.length > 0)
|
|
6435
|
+
.map((e) => ({ url: e.href, text: e.visibleText ?? e.labelText ?? e.ariaLabel ?? null }));
|
|
6436
|
+
let code = null;
|
|
6437
|
+
let link = null;
|
|
6438
|
+
let sourceFrom = null;
|
|
6439
|
+
for (let attempt = 0; attempt < 3 && code === null && link === null; attempt++) {
|
|
6440
|
+
sourceFrom = null;
|
|
6441
|
+
if (attempt > 0)
|
|
6442
|
+
await browser.waitForCaptchaChallengeToSettle(4000, 0).catch(() => false);
|
|
6443
|
+
await browser.goto(searchUrl);
|
|
6444
|
+
const { text: listText, links: listLinks } = await readGmailSearchResultsResilient(browser, searchUrl, linkCandidatesOf);
|
|
6445
|
+
const opened = await browser.openFirstMailResult().catch(() => false);
|
|
6446
|
+
if (opened) {
|
|
6447
|
+
const openedText = await browser.extractVisibleText();
|
|
6448
|
+
const openedLinks = linkCandidatesOf(await browser.extractInteractiveElements());
|
|
6449
|
+
sourceFrom = extractSenderEmail(openedText);
|
|
6450
|
+
const expectedDomains = expectedVerificationDomains(opts.sender, sourceFrom);
|
|
6451
|
+
({ code, link } = parseVerification(openedText, [...openedLinks, ...listLinks], expectedDomains));
|
|
6452
|
+
}
|
|
6453
|
+
else {
|
|
6454
|
+
({ code, link } = parseVerification(listText, listLinks, expectedVerificationDomains(opts.sender, null)));
|
|
6455
|
+
}
|
|
6456
|
+
}
|
|
6457
|
+
return { code, link, sourceFrom };
|
|
6458
|
+
});
|
|
6459
|
+
});
|
|
6460
|
+
const { code, link, sourceFrom } = verification;
|
|
5853
6461
|
const found = code !== null || link !== null;
|
|
5854
6462
|
audit(sessionId, "await_verification", {
|
|
5855
6463
|
sender: opts.sender ?? null,
|
|
@@ -5875,109 +6483,4 @@ export async function awaitVerification(sessionId, opts = {}) {
|
|
|
5875
6483
|
}
|
|
5876
6484
|
return buildVerificationResult(sessionId, code, link, sourceFrom);
|
|
5877
6485
|
}
|
|
5878
|
-
function profileRequiresDestroy(session) {
|
|
5879
|
-
return (session.activePayment !== null ||
|
|
5880
|
-
session.paymentFieldSealActive ||
|
|
5881
|
-
session.pendingThreeDs !== null);
|
|
5882
|
-
}
|
|
5883
|
-
function pendingThreeDsAuditStatus(resolution) {
|
|
5884
|
-
if (resolution === "succeeded")
|
|
5885
|
-
return "payment_submitted";
|
|
5886
|
-
if (resolution === "failed")
|
|
5887
|
-
return "payment_declined";
|
|
5888
|
-
return "payment_3ds_unresolved";
|
|
5889
|
-
}
|
|
5890
|
-
async function auditPendingThreeDsForSessionClose(session) {
|
|
5891
|
-
const pending = session.pendingThreeDs;
|
|
5892
|
-
if (pending === null)
|
|
5893
|
-
return;
|
|
5894
|
-
if (session.api === undefined) {
|
|
5895
|
-
throw new Error("operate_finish refused: pending 3-D Secure outcome cannot be audited without an active API session");
|
|
5896
|
-
}
|
|
5897
|
-
const resolution = await session.browser.waitForThreeDsResolution(0);
|
|
5898
|
-
await session.api.auditPayment({
|
|
5899
|
-
...pending.checkout,
|
|
5900
|
-
last4: pending.last4,
|
|
5901
|
-
status: pendingThreeDsAuditStatus(resolution),
|
|
5902
|
-
approval_id: pending.approval_id,
|
|
5903
|
-
...(pending.mandate_id !== undefined ? { mandate_id: pending.mandate_id } : {}),
|
|
5904
|
-
});
|
|
5905
|
-
}
|
|
5906
|
-
async function closeFinishingProvisionSession(session, destroyProfile) {
|
|
5907
|
-
const sessionId = session.id;
|
|
5908
|
-
await auditPendingThreeDsForSessionClose(session);
|
|
5909
|
-
const url = session.browser.currentUrl();
|
|
5910
|
-
audit(sessionId, "finish", { url });
|
|
5911
|
-
session.activePayment = null;
|
|
5912
|
-
session.paymentFieldSealActive = false;
|
|
5913
|
-
session.pendingThreeDs = null;
|
|
5914
|
-
sessions.delete(sessionId);
|
|
5915
|
-
await releaseWarmBrowserPage(session.browser, !destroyProfile);
|
|
5916
|
-
return { session_id: sessionId, url, closed: true };
|
|
5917
|
-
}
|
|
5918
|
-
export async function finishProvisionSessionWithPreparation(sessionId, prepare) {
|
|
5919
|
-
const session = sessionForCall(sessionId);
|
|
5920
|
-
if (session === undefined)
|
|
5921
|
-
throw new Error(`unknown provision session ${sessionId}`);
|
|
5922
|
-
if (session.closing)
|
|
5923
|
-
throw new Error(`provision session ${sessionId} is already closing`);
|
|
5924
|
-
session.closing = true;
|
|
5925
|
-
try {
|
|
5926
|
-
await waitForSessionCallsToDrain(session);
|
|
5927
|
-
const destroyProfile = profileRequiresDestroy(session);
|
|
5928
|
-
const prepared = await prepare();
|
|
5929
|
-
const finish = await closeFinishingProvisionSession(session, destroyProfile);
|
|
5930
|
-
return { finish, prepared };
|
|
5931
|
-
}
|
|
5932
|
-
catch (error) {
|
|
5933
|
-
if (sessions.get(sessionId) === session)
|
|
5934
|
-
session.closing = false;
|
|
5935
|
-
throw error;
|
|
5936
|
-
}
|
|
5937
|
-
}
|
|
5938
|
-
export async function finishProvisionSession(sessionId) {
|
|
5939
|
-
return (await finishProvisionSessionWithPreparation(sessionId, async () => undefined)).finish;
|
|
5940
|
-
}
|
|
5941
|
-
// Test/teardown helper — close every live session (used by the dev shim on exit).
|
|
5942
|
-
export async function closeAllProvisionSessions() {
|
|
5943
|
-
shutdownGeneration += 1;
|
|
5944
|
-
const waiters = [...capacityWaiters];
|
|
5945
|
-
for (const waiter of waiters) {
|
|
5946
|
-
waiter.cancelRequested = true;
|
|
5947
|
-
waiter.abortController.abort();
|
|
5948
|
-
waiter.wake();
|
|
5949
|
-
}
|
|
5950
|
-
await Promise.all(waiters.map((waiter) => waiter.settled));
|
|
5951
|
-
for (const pending of [...startingBrowsers]) {
|
|
5952
|
-
pending.cancelRequested = true;
|
|
5953
|
-
await pending.controller.close({ cancelStart: true }).catch(() => undefined);
|
|
5954
|
-
await pending.launch.catch(() => undefined);
|
|
5955
|
-
await closeLeasedBrowser(pending.controller, pending.lease, false).catch(() => undefined);
|
|
5956
|
-
}
|
|
5957
|
-
const closingSessions = [...sessions.values()];
|
|
5958
|
-
for (const session of closingSessions)
|
|
5959
|
-
session.closing = true;
|
|
5960
|
-
let closeError;
|
|
5961
|
-
for (const session of closingSessions) {
|
|
5962
|
-
try {
|
|
5963
|
-
await waitForSessionCallsToDrain(session);
|
|
5964
|
-
await closeFinishingProvisionSession(session, true);
|
|
5965
|
-
}
|
|
5966
|
-
catch (error) {
|
|
5967
|
-
if (sessions.get(session.id) === session)
|
|
5968
|
-
session.closing = false;
|
|
5969
|
-
if (closeError === undefined)
|
|
5970
|
-
closeError = error;
|
|
5971
|
-
}
|
|
5972
|
-
}
|
|
5973
|
-
if (closeError !== undefined)
|
|
5974
|
-
throw closeError;
|
|
5975
|
-
for (const slot of [...leasedBrowsers.values()]) {
|
|
5976
|
-
leasedBrowsers.delete(slot.controller);
|
|
5977
|
-
await closeLeasedBrowser(slot.controller, slot.lease, false).catch(() => undefined);
|
|
5978
|
-
}
|
|
5979
|
-
}
|
|
5980
|
-
export function activeSessionCount() {
|
|
5981
|
-
return sessions.size;
|
|
5982
|
-
}
|
|
5983
6486
|
//# sourceMappingURL=provision-session.js.map
|