@trusty-squire/mcp 1.1.13-rc.9 → 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 +136 -64
- 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 +85 -80
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +1314 -1570
- 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 +18 -6
- package/dist/bot/operator-browser-watchdog.d.ts.map +1 -1
- package/dist/bot/operator-browser-watchdog.js +91 -37
- package/dist/bot/operator-browser-watchdog.js.map +1 -1
- 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 +7 -1
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +269 -103
- 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 +57 -186
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +1976 -1812
- 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 +151 -96
- package/dist/tools/operate-pay.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +47 -39
- 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,25 +15,22 @@
|
|
|
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
|
-
import { OperatorBrowserWatchdog, } from "./operator-browser-watchdog.js";
|
|
37
34
|
// Identity-provider + auth-handler hosts a signup legitimately bounces
|
|
38
35
|
// through. Used to widen domain-scope so an OAuth `goto` (rare) isn't blocked.
|
|
39
36
|
// Organic redirects are already exempt (scope only gates explicit goto).
|
|
@@ -43,299 +40,81 @@ const DEFAULT_AUTH_HOSTS = [
|
|
|
43
40
|
"login.microsoftonline.com",
|
|
44
41
|
"appleid.apple.com",
|
|
45
42
|
];
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const START_CAPACITY_WAIT_MS = 30_000;
|
|
71
|
-
const START_CAPACITY_RETRY_MS = 50;
|
|
72
|
-
function isOperatorCapacityError(error) {
|
|
73
|
-
return error instanceof Error && error.message.startsWith("operate_start capacity reached:");
|
|
74
|
-
}
|
|
75
|
-
async function acquireOperatorProfileBounded(opts, sessionId, generation) {
|
|
76
|
-
let wake = () => undefined;
|
|
77
|
-
let resolveSettled = () => undefined;
|
|
78
|
-
const waiter = {
|
|
79
|
-
generation,
|
|
80
|
-
cancelRequested: false,
|
|
81
|
-
abortController: new AbortController(),
|
|
82
|
-
wake: () => wake(),
|
|
83
|
-
settled: new Promise((resolve) => {
|
|
84
|
-
resolveSettled = resolve;
|
|
85
|
-
}),
|
|
86
|
-
resolveSettled: () => resolveSettled(),
|
|
87
|
-
};
|
|
88
|
-
capacityWaiters.add(waiter);
|
|
89
|
-
const deadline = Date.now() + START_CAPACITY_WAIT_MS;
|
|
90
|
-
try {
|
|
91
|
-
for (;;) {
|
|
92
|
-
if (waiter.cancelRequested || waiter.generation !== shutdownGeneration) {
|
|
93
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
94
|
-
}
|
|
95
|
-
try {
|
|
96
|
-
const lease = await acquireOperatorProfile(sessionId, {
|
|
97
|
-
...(opts.profileDir !== undefined ? { sourceProfileDir: opts.profileDir } : {}),
|
|
98
|
-
deadline,
|
|
99
|
-
signal: waiter.abortController.signal,
|
|
100
|
-
});
|
|
101
|
-
if (waiter.cancelRequested || waiter.generation !== shutdownGeneration) {
|
|
102
|
-
await lease.destroy();
|
|
103
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
104
|
-
}
|
|
105
|
-
return lease;
|
|
106
|
-
}
|
|
107
|
-
catch (error) {
|
|
108
|
-
if (waiter.cancelRequested || waiter.generation !== shutdownGeneration) {
|
|
109
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
110
|
-
}
|
|
111
|
-
if (error instanceof OperatorProfileAcquisitionInterruptedError &&
|
|
112
|
-
error.reason === "cancelled") {
|
|
113
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
114
|
-
}
|
|
115
|
-
if (error instanceof OperatorProfileAcquisitionInterruptedError &&
|
|
116
|
-
error.reason === "timeout") {
|
|
117
|
-
if (error.phase === "seed_lock") {
|
|
118
|
-
throw new Error("operate_start failed: start deadline exceeded while waiting to acquire the shared seed lock");
|
|
119
|
-
}
|
|
120
|
-
throw new Error("operate_start failed: start deadline exceeded while acquiring an isolated operator profile");
|
|
121
|
-
}
|
|
122
|
-
if (isOperatorCapacityError(error) && Date.now() >= deadline) {
|
|
123
|
-
throw new Error("operate_start capacity wait timed out: 2 operator sessions are active; finish one and retry");
|
|
124
|
-
}
|
|
125
|
-
if (!isOperatorCapacityError(error)) {
|
|
126
|
-
throw error;
|
|
127
|
-
}
|
|
128
|
-
await new Promise((resolve) => {
|
|
129
|
-
wake = resolve;
|
|
130
|
-
const timer = setTimeout(resolve, START_CAPACITY_RETRY_MS);
|
|
131
|
-
waiter.wake = () => {
|
|
132
|
-
clearTimeout(timer);
|
|
133
|
-
resolve();
|
|
134
|
-
};
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
finally {
|
|
140
|
-
capacityWaiters.delete(waiter);
|
|
141
|
-
waiter.resolveSettled();
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
// The Google-authenticated counterpart of acquireOperatorProfileBounded — see
|
|
145
|
-
// operator-direct-identity.ts. Registers the same CapacityWaiter shape so
|
|
146
|
-
// closeAllProvisionSessions cancels an in-flight acquisition uniformly,
|
|
147
|
-
// but the underlying resource is the single canonical profile, not a
|
|
148
|
-
// two-slot pool: acquireDirectIdentityProfile already polls/blocks
|
|
149
|
-
// internally, so there is no outer capacity-retry loop to drive here.
|
|
150
|
-
async function acquireDirectIdentityProfileBounded(opts, generation) {
|
|
151
|
-
const waiter = {
|
|
152
|
-
generation,
|
|
153
|
-
cancelRequested: false,
|
|
154
|
-
abortController: new AbortController(),
|
|
155
|
-
wake: () => undefined,
|
|
156
|
-
settled: Promise.resolve(),
|
|
157
|
-
resolveSettled: () => undefined,
|
|
158
|
-
};
|
|
159
|
-
let resolveSettled = () => undefined;
|
|
160
|
-
waiter.settled = new Promise((resolve) => {
|
|
161
|
-
resolveSettled = resolve;
|
|
162
|
-
});
|
|
163
|
-
waiter.resolveSettled = () => resolveSettled();
|
|
164
|
-
capacityWaiters.add(waiter);
|
|
165
|
-
try {
|
|
166
|
-
if (waiter.cancelRequested || waiter.generation !== shutdownGeneration) {
|
|
167
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
168
|
-
}
|
|
169
|
-
try {
|
|
170
|
-
return await acquireDirectIdentityProfile({
|
|
171
|
-
...(opts.profileDir !== undefined ? { profileDir: opts.profileDir } : {}),
|
|
172
|
-
deadline: Date.now() + START_CAPACITY_WAIT_MS,
|
|
173
|
-
signal: waiter.abortController.signal,
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
catch (error) {
|
|
177
|
-
if (waiter.cancelRequested || waiter.generation !== shutdownGeneration) {
|
|
178
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
179
|
-
}
|
|
180
|
-
if (error instanceof OperatorDirectIdentityAcquisitionInterruptedError) {
|
|
181
|
-
throw error.reason === "cancelled"
|
|
182
|
-
? new Error("operate_start cancelled: operator server is shutting down")
|
|
183
|
-
: new Error("operate_start capacity reached: the Google-identity operator profile is in " +
|
|
184
|
-
"use by another session; finish it and retry");
|
|
185
|
-
}
|
|
186
|
-
throw error;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
finally {
|
|
190
|
-
capacityWaiters.delete(waiter);
|
|
191
|
-
waiter.resolveSettled();
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
async function acquireWarmBrowser(opts, sessionId) {
|
|
195
|
-
if ((process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0) {
|
|
196
|
-
throw new Error("operate_start does not support remote CDP with isolated profile leases");
|
|
197
|
-
}
|
|
198
|
-
const generation = shutdownGeneration;
|
|
199
|
-
const lease = opts.requireLiveIdentity === true
|
|
200
|
-
? await acquireDirectIdentityProfileBounded(opts, generation)
|
|
201
|
-
: await acquireOperatorProfileBounded(opts, sessionId, generation);
|
|
202
|
-
if (generation !== shutdownGeneration) {
|
|
203
|
-
await lease.destroy();
|
|
204
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
205
|
-
}
|
|
206
|
-
const controller = new BrowserController({
|
|
207
|
-
profileDir: lease.profileDir,
|
|
208
|
-
...("takeProfileOperationLease" in lease
|
|
209
|
-
? { profileOperationLease: lease.takeProfileOperationLease() }
|
|
210
|
-
: {}),
|
|
211
|
-
...(opts.proxyUrl !== undefined ? { proxyUrl: opts.proxyUrl } : {}),
|
|
212
|
-
});
|
|
213
|
-
const pending = {
|
|
214
|
-
controller,
|
|
215
|
-
lease,
|
|
216
|
-
launch: Promise.resolve(),
|
|
217
|
-
cancelRequested: false,
|
|
218
|
-
cleanupPromise: null,
|
|
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(),
|
|
219
67
|
};
|
|
220
|
-
pending.launch = startBrowserBounded(controller, sessionId, async () => {
|
|
221
|
-
await cancelStartingBrowser(pending);
|
|
222
|
-
});
|
|
223
|
-
startingBrowsers.add(pending);
|
|
224
|
-
try {
|
|
225
|
-
await pending.launch;
|
|
226
|
-
if (pending.cancelRequested) {
|
|
227
|
-
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
228
|
-
}
|
|
229
|
-
// Several unit harnesses replace BrowserController with the narrow legacy
|
|
230
|
-
// shape. Production controllers always expose identity; a harness without
|
|
231
|
-
// a real process simply leaves the descriptor worker-less.
|
|
232
|
-
if (typeof controller.operatorWorkerIdentity === "function") {
|
|
233
|
-
const worker = controller.operatorWorkerIdentity();
|
|
234
|
-
if (worker !== null)
|
|
235
|
-
lease.bindWorker(worker);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
catch (err) {
|
|
239
|
-
if (!pending.cancelRequested) {
|
|
240
|
-
await closeLeasedBrowser(controller, lease, false);
|
|
241
|
-
}
|
|
242
|
-
throw err;
|
|
243
|
-
}
|
|
244
|
-
finally {
|
|
245
|
-
startingBrowsers.delete(pending);
|
|
246
|
-
}
|
|
247
|
-
leasedBrowsers.set(controller, { controller, lease });
|
|
248
|
-
return { controller, profileDir: lease.profileDir };
|
|
249
68
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (slot === undefined) {
|
|
253
|
-
await browser.close().catch(() => undefined);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
try {
|
|
257
|
-
if (reusable)
|
|
258
|
-
await browser.resetPageForReuse();
|
|
259
|
-
await closeLeasedBrowser(browser, slot.lease, reusable);
|
|
260
|
-
}
|
|
261
|
-
catch {
|
|
262
|
-
await closeLeasedBrowser(browser, slot.lease, false);
|
|
263
|
-
}
|
|
264
|
-
if (leasedBrowsers.get(browser) === slot)
|
|
265
|
-
leasedBrowsers.delete(browser);
|
|
69
|
+
function oauthActionRemainingMs(deadline) {
|
|
70
|
+
return Math.max(0, deadline.expiresAt - Date.now());
|
|
266
71
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
else
|
|
275
|
-
await slot.lease.retain(true);
|
|
276
|
-
if (leasedBrowsers.get(browser) === slot)
|
|
277
|
-
leasedBrowsers.delete(browser);
|
|
278
|
-
}
|
|
279
|
-
async function closeBrowserBounded(browser, cancelStart, timeoutMessage) {
|
|
280
|
-
const forceClose = browser.forceCloseOwnedProcessTree;
|
|
281
|
-
const ordinaryClose = browser
|
|
282
|
-
.close(cancelStart ? { cancelStart: true } : undefined)
|
|
283
|
-
.catch(() => "unknown");
|
|
284
|
-
const forcedClose = forceClose === undefined
|
|
285
|
-
? ordinaryClose
|
|
286
|
-
: forceClose.call(browser).catch(() => "unknown");
|
|
287
|
-
const closed = Promise.race([
|
|
288
|
-
ordinaryClose.then((state) => (state === "closed" ? state : forcedClose)),
|
|
289
|
-
forcedClose.then((state) => (state === "closed" ? state : ordinaryClose)),
|
|
290
|
-
]);
|
|
291
|
-
return await withTerminalTimeout(closed, positiveTimeout("TRUSTY_SQUIRE_OPERATOR_FORCE_CLOSE_TIMEOUT_MS", DEFAULT_OPERATOR_FORCE_CLOSE_TIMEOUT_MS), timeoutMessage).catch(() => "unknown");
|
|
292
|
-
}
|
|
293
|
-
async function cancelStartingBrowser(pending) {
|
|
294
|
-
pending.cancelRequested = true;
|
|
295
|
-
if (pending.cleanupPromise !== null)
|
|
296
|
-
return await pending.cleanupPromise;
|
|
297
|
-
pending.cleanupPromise = (async () => {
|
|
298
|
-
const closeState = await closeBrowserBounded(pending.controller, true, "operator browser startup cancellation timed out");
|
|
299
|
-
if (closeState === "closed")
|
|
300
|
-
await pending.lease.destroy();
|
|
301
|
-
else
|
|
302
|
-
await pending.lease.retain(true);
|
|
303
|
-
})();
|
|
304
|
-
return await pending.cleanupPromise;
|
|
305
|
-
}
|
|
306
|
-
async function closeLeasedBrowser(browser, lease, reusable) {
|
|
307
|
-
const closeState = await browser.close().catch(() => "unknown");
|
|
308
|
-
if (closeState !== "closed") {
|
|
309
|
-
await lease.retain(!reusable);
|
|
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)
|
|
310
79
|
return;
|
|
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;
|
|
90
|
+
}
|
|
91
|
+
await Promise.allSettled(pending);
|
|
311
92
|
}
|
|
312
|
-
if (reusable)
|
|
313
|
-
await lease.returnWarm(closeState);
|
|
314
|
-
else
|
|
315
|
-
await lease.destroy();
|
|
316
|
-
}
|
|
317
|
-
function stopSessionWatchdog(session) {
|
|
318
|
-
session.watchdog?.stop();
|
|
319
93
|
}
|
|
320
|
-
function
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const DEFAULT_OPERATOR_FORCE_CLOSE_TIMEOUT_MS = 3_000;
|
|
328
|
-
function positiveTimeout(name, fallback) {
|
|
329
|
-
const value = Number(process.env[name]);
|
|
330
|
-
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
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`);
|
|
331
101
|
}
|
|
332
|
-
async function
|
|
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
|
+
}
|
|
333
109
|
let timer;
|
|
334
110
|
try {
|
|
335
111
|
return await Promise.race([
|
|
336
|
-
|
|
337
|
-
new Promise((
|
|
338
|
-
timer = setTimeout(() =>
|
|
112
|
+
tracked,
|
|
113
|
+
new Promise((_resolve, reject) => {
|
|
114
|
+
timer = setTimeout(() => {
|
|
115
|
+
expireOAuthAction(deadline);
|
|
116
|
+
reject(oauthActionDeadlineError(deadline));
|
|
117
|
+
}, remainingMs);
|
|
339
118
|
}),
|
|
340
119
|
]);
|
|
341
120
|
}
|
|
@@ -344,277 +123,103 @@ async function withTerminalTimeout(promise, timeoutMs, message) {
|
|
|
344
123
|
clearTimeout(timer);
|
|
345
124
|
}
|
|
346
125
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
(
|
|
351
|
-
|
|
352
|
-
forcePromise: null,
|
|
353
|
-
routinePromise: null,
|
|
354
|
-
});
|
|
355
|
-
if (owner.forcePromise !== null)
|
|
356
|
-
return await owner.forcePromise;
|
|
357
|
-
owner.forced = true;
|
|
358
|
-
owner.forcePromise = forceTerminateProvisionSessionOwned(session, event, detail, auditPendingThreeDs);
|
|
359
|
-
return await owner.forcePromise;
|
|
360
|
-
}
|
|
361
|
-
async function forceTerminateProvisionSessionOwned(session, event, detail, auditPendingThreeDs) {
|
|
362
|
-
session.closing = true;
|
|
363
|
-
stopSessionWatchdog(session);
|
|
364
|
-
audit(session.id, event, detail);
|
|
365
|
-
const handoff = session.paymentDispatchHandoff;
|
|
366
|
-
if (handoff !== null) {
|
|
367
|
-
handoff.terminalizing = true;
|
|
368
|
-
const timeoutMs = positiveTimeout("TRUSTY_SQUIRE_OPERATOR_PENDING_3DS_FINALIZE_TIMEOUT_MS", DEFAULT_PENDING_THREE_DS_FINALIZE_TIMEOUT_MS);
|
|
369
|
-
await withTerminalTimeout(handoff.settled, timeoutMs, `payment dispatch handoff exceeded ${timeoutMs}ms`).catch(() => undefined);
|
|
370
|
-
}
|
|
371
|
-
if (sessions.get(session.id) === session)
|
|
372
|
-
sessions.delete(session.id);
|
|
373
|
-
let terminalError;
|
|
374
|
-
if (auditPendingThreeDs && session.pendingThreeDs !== null) {
|
|
375
|
-
try {
|
|
376
|
-
await auditPendingThreeDsForSessionCloseBounded(session);
|
|
377
|
-
}
|
|
378
|
-
catch (error) {
|
|
379
|
-
terminalError = error;
|
|
380
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
381
|
-
process.stderr.write(`[operator] terminal 3DS audit failed session=${session.id}: ${message}\n`);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
if (handoff !== null) {
|
|
385
|
-
handoff.terminalComplete = true;
|
|
386
|
-
if (handoff.released && session.paymentDispatchHandoff === handoff) {
|
|
387
|
-
session.paymentDispatchHandoff = null;
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
session.activePayment = null;
|
|
391
|
-
session.paymentFieldSealActive = false;
|
|
392
|
-
session.pendingThreeDs = null;
|
|
393
|
-
await forceReleaseWarmBrowserPage(session.browser).catch((error) => {
|
|
394
|
-
if (terminalError === undefined)
|
|
395
|
-
terminalError = error;
|
|
396
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
397
|
-
process.stderr.write(`[operator] terminal browser close failed session=${session.id}: ${message}\n`);
|
|
398
|
-
});
|
|
399
|
-
disposeSessionWatchdog(session);
|
|
400
|
-
return terminalError;
|
|
401
|
-
}
|
|
402
|
-
async function terminateExpiredProvisionSession(session, reason) {
|
|
403
|
-
const owner = session.terminalTeardownOwner ??
|
|
404
|
-
(session.terminalTeardownOwner = {
|
|
405
|
-
forced: false,
|
|
406
|
-
forcePromise: null,
|
|
407
|
-
routinePromise: null,
|
|
408
|
-
});
|
|
409
|
-
if (owner.routinePromise !== null)
|
|
410
|
-
return await owner.routinePromise;
|
|
411
|
-
session.closing = true;
|
|
412
|
-
stopSessionWatchdog(session);
|
|
413
|
-
owner.routinePromise = (async () => {
|
|
414
|
-
if (reason.kind !== "idle_timeout" && session.paymentCallCount > 0) {
|
|
415
|
-
const timeoutMs = positiveTimeout("TRUSTY_SQUIRE_OPERATOR_TERMINAL_TRANSITION_TIMEOUT_MS", DEFAULT_SESSION_TERMINAL_TRANSITION_TIMEOUT_MS);
|
|
416
|
-
await withTerminalTimeout(waitForPaymentCallsToDrain(session), timeoutMs, `payment call drain exceeded ${timeoutMs}ms`).catch(() => undefined);
|
|
417
|
-
}
|
|
418
|
-
if (owner.forcePromise !== null) {
|
|
419
|
-
await owner.forcePromise;
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
await forceTerminateProvisionSession(session, "browser_watchdog_terminate", { ...reason });
|
|
423
|
-
})();
|
|
424
|
-
await owner.routinePromise;
|
|
425
|
-
}
|
|
426
|
-
function startSessionWatchdog(session) {
|
|
427
|
-
if (session.watchdog !== null) {
|
|
428
|
-
session.watchdog.start();
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
const browser = session.browser;
|
|
432
|
-
const watchdog = new OperatorBrowserWatchdog({
|
|
433
|
-
startedAt: session.startedAt,
|
|
434
|
-
lastActivityAt: () => session.lastActivityAt,
|
|
435
|
-
hasActiveCall: () => session.callCount > 0,
|
|
436
|
-
processMarker: () => browser.operatorBrowserMarker?.() ?? null,
|
|
437
|
-
onTerminate: async (reason) => await terminateExpiredProvisionSession(session, reason),
|
|
438
|
-
});
|
|
439
|
-
session.watchdog = watchdog;
|
|
440
|
-
watchdog.start();
|
|
441
|
-
}
|
|
442
|
-
function sessionForCall(sessionId) {
|
|
443
|
-
return sessions.get(sessionId);
|
|
444
|
-
}
|
|
445
|
-
// Money rule (simplified 2026-08-16): the fence is the live human biometric
|
|
446
|
-
// approval per charge, not a software re-check of replay field values. The
|
|
447
|
-
// only surviving invariant — a card-charging trace step is never blind-
|
|
448
|
-
// replayed — is enforced unconditionally where operate_pay steps are
|
|
449
|
-
// encountered during replay (see replayOperatorRecipe), not here.
|
|
450
|
-
function assertPaymentSessionAllowed(session) {
|
|
451
|
-
if (session.closing) {
|
|
452
|
-
throw new Error(`provision session ${session.id} is closing`);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
// Resolve the compatibility omission once, at tool entry. In particular, do
|
|
456
|
-
// not repeat this lookup in completion callbacks: after an await, a different
|
|
457
|
-
// session could otherwise become the sole process-local session.
|
|
458
|
-
export function paymentSession(sessionId) {
|
|
459
|
-
let session;
|
|
460
|
-
if (sessionId !== undefined) {
|
|
461
|
-
session = sessionForCall(sessionId);
|
|
462
|
-
if (session === undefined)
|
|
463
|
-
throw new Error(`unknown provision session ${sessionId}`);
|
|
464
|
-
}
|
|
465
|
-
else {
|
|
466
|
-
if (sessions.size !== 1) {
|
|
467
|
-
throw new Error(sessions.size === 0
|
|
468
|
-
? "operate_pay requires one active operate_start browser session"
|
|
469
|
-
: "operate_pay requires session_id when multiple operator sessions are active");
|
|
470
|
-
}
|
|
471
|
-
session = sessions.values().next().value;
|
|
472
|
-
}
|
|
473
|
-
assertPaymentSessionAllowed(session);
|
|
474
|
-
return session;
|
|
475
|
-
}
|
|
476
|
-
function acquireSessionCallLease(session) {
|
|
477
|
-
if (session.closing)
|
|
478
|
-
throw new Error(`provision session ${session.id} is closing`);
|
|
479
|
-
session.lastActivityAt = Date.now();
|
|
480
|
-
session.callCount += 1;
|
|
481
|
-
let released = false;
|
|
482
|
-
return () => {
|
|
483
|
-
if (released)
|
|
484
|
-
return;
|
|
485
|
-
released = true;
|
|
486
|
-
session.callCount -= 1;
|
|
487
|
-
if (session.callCount === 0) {
|
|
488
|
-
session.lastActivityAt = Date.now();
|
|
489
|
-
for (const wake of session.callDrainWaiters)
|
|
490
|
-
wake();
|
|
491
|
-
session.callDrainWaiters.clear();
|
|
492
|
-
}
|
|
493
|
-
};
|
|
494
|
-
}
|
|
495
|
-
function acquirePaymentCallLease(session) {
|
|
496
|
-
session.paymentCallCount += 1;
|
|
497
|
-
let released = false;
|
|
498
|
-
return () => {
|
|
499
|
-
if (released)
|
|
500
|
-
return;
|
|
501
|
-
released = true;
|
|
502
|
-
session.paymentCallCount -= 1;
|
|
503
|
-
if (session.paymentCallCount === 0) {
|
|
504
|
-
for (const wake of session.paymentCallDrainWaiters)
|
|
505
|
-
wake();
|
|
506
|
-
session.paymentCallDrainWaiters.clear();
|
|
507
|
-
}
|
|
508
|
-
};
|
|
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;
|
|
509
131
|
}
|
|
510
|
-
async function
|
|
511
|
-
if (
|
|
132
|
+
async function waitForOAuthLeaseCooldown(cooldownMs) {
|
|
133
|
+
if (cooldownMs <= 0)
|
|
512
134
|
return;
|
|
513
135
|
await new Promise((resolve) => {
|
|
514
|
-
|
|
136
|
+
const timer = setTimeout(resolve, cooldownMs);
|
|
137
|
+
timer.unref();
|
|
515
138
|
});
|
|
516
139
|
}
|
|
517
|
-
async function
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
new Promise((resolve) => {
|
|
526
|
-
wake = () => resolve(true);
|
|
527
|
-
session.callDrainWaiters.add(wake);
|
|
528
|
-
}),
|
|
529
|
-
new Promise((resolve) => {
|
|
530
|
-
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
531
|
-
}),
|
|
532
|
-
]);
|
|
533
|
-
}
|
|
534
|
-
finally {
|
|
535
|
-
if (timer !== undefined)
|
|
536
|
-
clearTimeout(timer);
|
|
537
|
-
if (wake !== undefined)
|
|
538
|
-
session.callDrainWaiters.delete(wake);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
async function withSelectedProvisionSessionCall(session, fn) {
|
|
542
|
-
const release = acquireSessionCallLease(session);
|
|
140
|
+
async function withOAuthActionLease(deadline, run, releaseCooldownMs = 0) {
|
|
141
|
+
let release;
|
|
142
|
+
const previous = oauthActionLeaseTail;
|
|
143
|
+
const turn = new Promise((resolve) => {
|
|
144
|
+
release = resolve;
|
|
145
|
+
});
|
|
146
|
+
oauthActionLeaseTail = previous.then(() => turn);
|
|
147
|
+
let acquired = false;
|
|
543
148
|
try {
|
|
544
|
-
|
|
149
|
+
if (deadline === undefined)
|
|
150
|
+
await previous;
|
|
151
|
+
else
|
|
152
|
+
await withinOAuthActionDeadline(previous, deadline);
|
|
153
|
+
acquired = true;
|
|
154
|
+
return await run();
|
|
545
155
|
}
|
|
546
156
|
finally {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
550
|
-
export async function withProvisionSessionCall(sessionId, fn) {
|
|
551
|
-
const session = sessionForCall(sessionId);
|
|
552
|
-
if (session === undefined)
|
|
553
|
-
throw new Error(`unknown provision session ${sessionId}`);
|
|
554
|
-
return await withSelectedProvisionSessionCall(session, fn);
|
|
555
|
-
}
|
|
556
|
-
export async function withPaymentSessionCall(sessionId, fn) {
|
|
557
|
-
const session = paymentSession(sessionId);
|
|
558
|
-
return await withSelectedProvisionSessionCall(session, async (selectedSession) => {
|
|
559
|
-
const releasePaymentCall = acquirePaymentCallLease(selectedSession);
|
|
560
|
-
try {
|
|
561
|
-
return await fn(selectedSession);
|
|
157
|
+
if (deadline === undefined) {
|
|
158
|
+
release();
|
|
562
159
|
}
|
|
563
|
-
|
|
564
|
-
|
|
160
|
+
else if (!acquired) {
|
|
161
|
+
void previous.then(release, release);
|
|
565
162
|
}
|
|
566
|
-
|
|
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;
|
|
567
221
|
}
|
|
568
222
|
const settle = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
569
|
-
// Audit trail (security posture): every session action emits one structured
|
|
570
|
-
// stderr line the host's MCP log captures. The `provision-audit` marker makes
|
|
571
|
-
// the trail greppable. No credential VALUES are ever logged — only the action
|
|
572
|
-
// shape + url.
|
|
573
|
-
function audit(sessionId, event, detail = {}) {
|
|
574
|
-
process.stderr.write(`${JSON.stringify({ marker: "provision-audit", surface: "operate", session_id: sessionId, event, ...detail })}\n`);
|
|
575
|
-
}
|
|
576
|
-
// operate_start's browser launch is the one UNBOUNDED step in the session
|
|
577
|
-
// bootstrap: on a fresh box the first launch downloads Chromium and spins up a
|
|
578
|
-
// virtual display (Xvfb), and a wedged profile lock or missing browser deps can
|
|
579
|
-
// otherwise hang it indefinitely — a real dogfood run sat on a silent ~30-min
|
|
580
|
-
// hang here with zero feedback (the worst first-run failure: the user assumes
|
|
581
|
-
// it's broken and never comes back). Cap it so a stuck launch fails LOUDLY with
|
|
582
|
-
// an actionable message. The default is generous (a cold Chromium download is
|
|
583
|
-
// legitimately multi-minute — better to wait than false-fail a slow-but-working
|
|
584
|
-
// launch); tune with BOT_START_TIMEOUT_MS. Timeout uses the independent bounded
|
|
585
|
-
// cancellation boundary: it releases or quarantines profile custody without
|
|
586
|
-
// awaiting the unresolved launch, and late settlement cleans up only this
|
|
587
|
-
// controller's marked process.
|
|
588
|
-
async function startBrowserBounded(browser, sessionId, cancel) {
|
|
589
|
-
const timeoutMs = Number(process.env.BOT_START_TIMEOUT_MS) || 600_000;
|
|
590
|
-
audit(sessionId, "browser_launch", {
|
|
591
|
-
note: "first launch may download Chromium + start Xvfb; slow but one-time",
|
|
592
|
-
timeout_ms: timeoutMs,
|
|
593
|
-
});
|
|
594
|
-
let timer;
|
|
595
|
-
const timeout = new Promise((_resolve, reject) => {
|
|
596
|
-
timer = setTimeout(() => reject(new Error("__browser_start_timeout__")), timeoutMs);
|
|
597
|
-
});
|
|
598
|
-
try {
|
|
599
|
-
await Promise.race([browser.start(), timeout]);
|
|
600
|
-
}
|
|
601
|
-
catch (err) {
|
|
602
|
-
if (err instanceof Error && err.message === "__browser_start_timeout__") {
|
|
603
|
-
await cancel().catch(() => undefined);
|
|
604
|
-
throw new Error(`operate_start: browser did not launch within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
605
|
-
"On a fresh machine the first launch downloads Chromium and starts a virtual display " +
|
|
606
|
-
"(Xvfb) — slow but one-time. A hang this long usually means the browser binaries or Xvfb " +
|
|
607
|
-
"are missing on this box. Retry once (a partial download resumes and later launches reuse " +
|
|
608
|
-
"the cache); if it recurs, run `npx @trusty-squire/mcp connect` here to install the browser " +
|
|
609
|
-
"deps, or raise BOT_START_TIMEOUT_MS to wait longer.");
|
|
610
|
-
}
|
|
611
|
-
throw err;
|
|
612
|
-
}
|
|
613
|
-
finally {
|
|
614
|
-
if (timer !== undefined)
|
|
615
|
-
clearTimeout(timer);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
223
|
// ── pure helpers (exported for unit tests) ──
|
|
619
224
|
const norm = (s) => (s ?? "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
620
225
|
// Element ref = a STABLE-by-default handle: "@e:<identity>_<ordinal>". For a
|
|
@@ -660,7 +265,7 @@ function baseIdentityFields(el) {
|
|
|
660
265
|
el.container ?? "",
|
|
661
266
|
el.role ?? "",
|
|
662
267
|
el.tag,
|
|
663
|
-
elementRef(el),
|
|
268
|
+
elementRef({ ...el, value: null }),
|
|
664
269
|
el.href ?? "",
|
|
665
270
|
el.type ?? "",
|
|
666
271
|
// Frame origin — WITHOUT this, an element's `selector` (folded into
|
|
@@ -889,6 +494,21 @@ export class TargetStaleError extends Error {
|
|
|
889
494
|
this.result = result;
|
|
890
495
|
}
|
|
891
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
|
+
}
|
|
892
512
|
function replacementCandidates(elements) {
|
|
893
513
|
const refs = provisionElementRefs(elements);
|
|
894
514
|
const candidates = {};
|
|
@@ -934,174 +554,18 @@ function elementTargetKeys(el) {
|
|
|
934
554
|
return v.length > 0 ? [v] : [];
|
|
935
555
|
});
|
|
936
556
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
]);
|
|
945
|
-
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;
|
|
946
564
|
const autocomplete = (el.autocomplete ?? "")
|
|
947
565
|
.toLowerCase()
|
|
948
566
|
.split(/\s+/)
|
|
949
567
|
.filter((token) => token.length > 0);
|
|
950
|
-
|
|
951
|
-
return true;
|
|
952
|
-
if (autocomplete.includes("billing") &&
|
|
953
|
-
autocomplete.some((token) => [
|
|
954
|
-
"address-line1",
|
|
955
|
-
"address-line2",
|
|
956
|
-
"address-level1",
|
|
957
|
-
"address-level2",
|
|
958
|
-
"postal-code",
|
|
959
|
-
"country",
|
|
960
|
-
].includes(token))) {
|
|
961
|
-
return true;
|
|
962
|
-
}
|
|
963
|
-
const name = (el.name ?? "").toLowerCase();
|
|
964
|
-
const id = (el.id ?? "").toLowerCase();
|
|
965
|
-
const ariaLabel = (el.ariaLabel ?? "").toLowerCase();
|
|
966
|
-
const placeholder = (el.placeholder ?? "").toLowerCase();
|
|
967
|
-
return (name.includes("cardnumber") ||
|
|
968
|
-
id.includes("card-number") ||
|
|
969
|
-
id.includes("cardnumber") ||
|
|
970
|
-
name.includes("cardholder") ||
|
|
971
|
-
name.includes("card-name") ||
|
|
972
|
-
id.includes("cardholder") ||
|
|
973
|
-
name.includes("cvv") ||
|
|
974
|
-
name.includes("cvc") ||
|
|
975
|
-
name.includes("security-code") ||
|
|
976
|
-
id.includes("cvv") ||
|
|
977
|
-
id.includes("cvc") ||
|
|
978
|
-
((name.includes("exp") || id.includes("exp")) &&
|
|
979
|
-
(name.includes("month") ||
|
|
980
|
-
name.includes("year") ||
|
|
981
|
-
name.includes("date") ||
|
|
982
|
-
name.includes("expir") ||
|
|
983
|
-
id.includes("month") ||
|
|
984
|
-
id.includes("year") ||
|
|
985
|
-
id.includes("date") ||
|
|
986
|
-
id.includes("expir") ||
|
|
987
|
-
name === "exp" ||
|
|
988
|
-
id === "exp")) ||
|
|
989
|
-
placeholder.replace(/\s+/g, "") === "mm/yy" ||
|
|
990
|
-
ariaLabel.replace(/\s+/g, "") === "mm/yy" ||
|
|
991
|
-
((name.includes("billing") || id.includes("billing")) &&
|
|
992
|
-
/address|line1|line2|city|locality|state|region|postal|zip|country/.test(`${name} ${id}`)));
|
|
993
|
-
}
|
|
994
|
-
function pendingCardSecretKind(el) {
|
|
995
|
-
const autocomplete = (el.autocomplete ?? "").toLowerCase().split(/\s+/);
|
|
996
|
-
const signal = `${el.name ?? ""} ${el.id ?? ""}`.toLowerCase();
|
|
997
|
-
if (autocomplete.includes("cc-number") ||
|
|
998
|
-
signal.includes("cardnumber") ||
|
|
999
|
-
signal.includes("card-number")) {
|
|
1000
|
-
return "pan";
|
|
1001
|
-
}
|
|
1002
|
-
if (autocomplete.includes("cc-csc") ||
|
|
1003
|
-
signal.includes("cvv") ||
|
|
1004
|
-
signal.includes("cvc") ||
|
|
1005
|
-
signal.includes("security-code")) {
|
|
1006
|
-
return "cvv";
|
|
1007
|
-
}
|
|
1008
|
-
return null;
|
|
1009
|
-
}
|
|
1010
|
-
function redactPaymentObservationText(text, elements, active) {
|
|
1011
|
-
if (!active)
|
|
1012
|
-
return text;
|
|
1013
|
-
let redacted = text;
|
|
1014
|
-
for (const element of elements) {
|
|
1015
|
-
const kind = pendingCardSecretKind(element);
|
|
1016
|
-
const value = element.value?.trim() ?? "";
|
|
1017
|
-
if (kind === null || value.length === 0)
|
|
1018
|
-
continue;
|
|
1019
|
-
if (kind === "pan") {
|
|
1020
|
-
redacted = redactExactDigitSequence(redacted, value.replace(/\D/g, ""));
|
|
1021
|
-
}
|
|
1022
|
-
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1023
|
-
redacted = redacted.replace(kind === "cvv" ? new RegExp(`\\b${escaped}\\b`, "g") : new RegExp(escaped, "g"), "[sealed payment]");
|
|
1024
|
-
}
|
|
1025
|
-
redacted = redactLuhnPanSpans(redacted);
|
|
1026
|
-
return redacted.replace(/\b(cvv|cvc|security\s+code)\s*[:#-]?\s*\d{3,4}\b/gi, "$1 [sealed payment]");
|
|
1027
|
-
}
|
|
1028
|
-
function presentPaymentSafeString(value, paymentSealActive) {
|
|
1029
|
-
return paymentSealActive ? redactPaymentObservationText(value, [], true) : value;
|
|
1030
|
-
}
|
|
1031
|
-
const PAYMENT_PAN_MAX_SPAN_CHARS = 96;
|
|
1032
|
-
function redactExactDigitSequence(text, expectedDigits) {
|
|
1033
|
-
if (expectedDigits.length < 13)
|
|
1034
|
-
return text;
|
|
1035
|
-
const digitMatches = Array.from(text.matchAll(/\d/g));
|
|
1036
|
-
const replacements = [];
|
|
1037
|
-
for (let start = 0; start + expectedDigits.length <= digitMatches.length; start += 1) {
|
|
1038
|
-
const matches = digitMatches.slice(start, start + expectedDigits.length);
|
|
1039
|
-
if (matches.map((match) => match[0]).join("") !== expectedDigits)
|
|
1040
|
-
continue;
|
|
1041
|
-
if (matches[matches.length - 1].index - matches[0].index + 1 > PAYMENT_PAN_MAX_SPAN_CHARS) {
|
|
1042
|
-
continue;
|
|
1043
|
-
}
|
|
1044
|
-
replacements.push({
|
|
1045
|
-
start: matches[0].index,
|
|
1046
|
-
end: matches[matches.length - 1].index + 1,
|
|
1047
|
-
});
|
|
1048
|
-
start += expectedDigits.length - 1;
|
|
1049
|
-
}
|
|
1050
|
-
if (replacements.length === 0)
|
|
1051
|
-
return text;
|
|
1052
|
-
let cursor = 0;
|
|
1053
|
-
let result = "";
|
|
1054
|
-
for (const replacement of replacements) {
|
|
1055
|
-
result += `${text.slice(cursor, replacement.start)}[sealed payment]`;
|
|
1056
|
-
cursor = replacement.end;
|
|
1057
|
-
}
|
|
1058
|
-
return result + text.slice(cursor);
|
|
1059
|
-
}
|
|
1060
|
-
function redactLuhnPanSpans(text) {
|
|
1061
|
-
const digitPositions = Array.from(text.matchAll(/\d/g), (match) => match.index);
|
|
1062
|
-
const replacements = [];
|
|
1063
|
-
let startDigit = 0;
|
|
1064
|
-
while (startDigit + 13 <= digitPositions.length) {
|
|
1065
|
-
let matchedDigits = 0;
|
|
1066
|
-
const maxDigits = Math.min(19, digitPositions.length - startDigit);
|
|
1067
|
-
for (let length = maxDigits; length >= 13; length -= 1) {
|
|
1068
|
-
const positions = digitPositions.slice(startDigit, startDigit + length);
|
|
1069
|
-
if (positions[positions.length - 1] - positions[0] + 1 > PAYMENT_PAN_MAX_SPAN_CHARS) {
|
|
1070
|
-
continue;
|
|
1071
|
-
}
|
|
1072
|
-
const digits = positions.map((position) => text[position]).join("");
|
|
1073
|
-
if (passesLuhn(digits)) {
|
|
1074
|
-
matchedDigits = length;
|
|
1075
|
-
replacements.push({
|
|
1076
|
-
start: positions[0],
|
|
1077
|
-
end: positions[positions.length - 1] + 1,
|
|
1078
|
-
});
|
|
1079
|
-
break;
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
startDigit += matchedDigits || 1;
|
|
1083
|
-
}
|
|
1084
|
-
if (replacements.length === 0)
|
|
1085
|
-
return text;
|
|
1086
|
-
let cursor = 0;
|
|
1087
|
-
let result = "";
|
|
1088
|
-
for (const replacement of replacements) {
|
|
1089
|
-
result += `${text.slice(cursor, replacement.start)}[sealed payment]`;
|
|
1090
|
-
cursor = replacement.end;
|
|
1091
|
-
}
|
|
1092
|
-
return result + text.slice(cursor);
|
|
1093
|
-
}
|
|
1094
|
-
function observationSealedFieldKeys(session, elements) {
|
|
1095
|
-
if (!session.paymentFieldSealActive)
|
|
1096
|
-
return session.sealedFieldKeys;
|
|
1097
|
-
const sealed = new Set(session.sealedFieldKeys);
|
|
1098
|
-
for (const el of elements) {
|
|
1099
|
-
if (!isPendingCardFilledField(el))
|
|
1100
|
-
continue;
|
|
1101
|
-
for (const key of elementTargetKeys(el))
|
|
1102
|
-
sealed.add(key);
|
|
1103
|
-
}
|
|
1104
|
-
return sealed;
|
|
568
|
+
return autocomplete.includes("shipping") && autocomplete.includes("address-line1");
|
|
1105
569
|
}
|
|
1106
570
|
// Resolve a host-supplied target string to one live element. Matching is by
|
|
1107
571
|
// structured path, test id, or label text, scored exact > startsWith > contains.
|
|
@@ -1175,6 +639,104 @@ export function resolveTarget(elements, target) {
|
|
|
1175
639
|
}
|
|
1176
640
|
return best?.el ?? null;
|
|
1177
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
|
+
}
|
|
1178
740
|
// Squire's OWN control plane. The operator browser runs in the connect-seeded
|
|
1179
741
|
// profile, so it is authenticated as the user (a live Google session). It must
|
|
1180
742
|
// therefore NEVER be allowed to reach Squire's own web app / API: otherwise a
|
|
@@ -1196,8 +758,9 @@ export function isSquireControlPlaneHost(host) {
|
|
|
1196
758
|
return SQUIRE_CONTROL_PLANE_HOSTS.some((d) => h === d || h.endsWith(`.${d}`));
|
|
1197
759
|
}
|
|
1198
760
|
// Domain-scope check for an agent-initiated goto. Allows the target host, any
|
|
1199
|
-
// subdomain of it,
|
|
1200
|
-
// *.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.
|
|
1201
764
|
export function hostAllowed(url, allowedHosts) {
|
|
1202
765
|
let host;
|
|
1203
766
|
try {
|
|
@@ -1213,6 +776,8 @@ export function hostAllowed(url, allowedHosts) {
|
|
|
1213
776
|
const ok = (allowed) => host === allowed || host.endsWith(`.${allowed}`);
|
|
1214
777
|
if (allowedHosts.some(ok))
|
|
1215
778
|
return true;
|
|
779
|
+
if (serviceLoginRouteHosts(allowedHosts).includes(host))
|
|
780
|
+
return true;
|
|
1216
781
|
if (DEFAULT_AUTH_HOSTS.some(ok))
|
|
1217
782
|
return true;
|
|
1218
783
|
if (host.endsWith(".firebaseapp.com") || host.endsWith(".web.app"))
|
|
@@ -1223,7 +788,7 @@ function frameTargetFor(el) {
|
|
|
1223
788
|
if (el.framePath === undefined || el.framePath === null)
|
|
1224
789
|
return null;
|
|
1225
790
|
if (el.frameOrigin === undefined || el.frameOrigin === null) {
|
|
1226
|
-
throw new
|
|
791
|
+
throw new ProvisionTargetNotAllowedError("frame target lacks an origin");
|
|
1227
792
|
}
|
|
1228
793
|
return {
|
|
1229
794
|
framePath: el.framePath,
|
|
@@ -1250,12 +815,12 @@ function assertFrameTargetAllowed(session, el, kind) {
|
|
|
1250
815
|
// message below suggests allow_host, which can never succeed for a null
|
|
1251
816
|
// origin — a remedy the model would loop on forever.
|
|
1252
817
|
if (el.frameOpaque === true || el.frameOrigin === "null") {
|
|
1253
|
-
throw new
|
|
818
|
+
throw new ProvisionTargetNotAllowedError(`${kind} refused: the target lives in an opaque (null-origin) frame — a sandboxed ` +
|
|
1254
819
|
`iframe without allow-same-origin, or an unconfirmed about:blank/srcdoc document. ` +
|
|
1255
820
|
`No host declaration can ever permit a null origin; this control is not reachable ` +
|
|
1256
821
|
`through operate_act. Drive the page's own controls instead.`);
|
|
1257
822
|
}
|
|
1258
|
-
throw new
|
|
823
|
+
throw new ProvisionTargetNotAllowedError(`${kind} blocked by domain-scope: the target lives in a cross-domain frame ` +
|
|
1259
824
|
`(${el.frameOrigin ?? el.frameUrl}) outside the allowed hosts ` +
|
|
1260
825
|
`[${hostStrings(session).join(", ")}] + auth providers. ` +
|
|
1261
826
|
`Declare it first with an allow_host action if this task spans it.`);
|
|
@@ -1271,13 +836,13 @@ function assertSecretFrameTargetAllowed(session, el) {
|
|
|
1271
836
|
if (target === null)
|
|
1272
837
|
return;
|
|
1273
838
|
if (target.frameOpaque === true) {
|
|
1274
|
-
throw new
|
|
839
|
+
throw new ProvisionTargetNotAllowedError("type_secret refused: the target lives in an opaque frame. Secrets may only be " +
|
|
1275
840
|
"typed into the main frame or a frame on the page's own domain.");
|
|
1276
841
|
}
|
|
1277
842
|
const pageUrl = session.browser.currentUrl();
|
|
1278
843
|
if (isSameRecipeDomain(target.frameOrigin, pageUrl))
|
|
1279
844
|
return;
|
|
1280
|
-
throw new
|
|
845
|
+
throw new ProvisionTargetNotAllowedError(`type_secret refused: the target lives in a cross-domain frame ` +
|
|
1281
846
|
`(${target.frameOrigin}), not the page's own domain. Secrets may only be ` +
|
|
1282
847
|
`typed into the main frame or a frame on the page's own domain.`);
|
|
1283
848
|
}
|
|
@@ -1292,7 +857,7 @@ function assertSecretFrameTargetAllowed(session, el) {
|
|
|
1292
857
|
function assertNoFrameTarget(el, kind) {
|
|
1293
858
|
if (el.framePath === undefined || el.framePath === null)
|
|
1294
859
|
return;
|
|
1295
|
-
throw new
|
|
860
|
+
throw new ProvisionTargetNotAllowedError(`operate_act kind="${kind}" does not yet support a target inside an <iframe> ` +
|
|
1296
861
|
`(frame ${el.frameOrigin ?? "unknown"}). Use click/js_click/type/type_secret/select ` +
|
|
1297
862
|
`for frame targets.`);
|
|
1298
863
|
}
|
|
@@ -1635,7 +1200,7 @@ export function manualCardEntryBlockReason(text) {
|
|
|
1635
1200
|
}
|
|
1636
1201
|
return null;
|
|
1637
1202
|
}
|
|
1638
|
-
export function buildScreenOutline(elements, pageText
|
|
1203
|
+
export function buildScreenOutline(elements, pageText) {
|
|
1639
1204
|
if (elements.length === 0)
|
|
1640
1205
|
return undefined;
|
|
1641
1206
|
const byRegion = new Map();
|
|
@@ -1644,8 +1209,8 @@ export function buildScreenOutline(elements, pageText, sealedFieldKeys = new Set
|
|
|
1644
1209
|
const role = id.split(":")[0] ?? "region";
|
|
1645
1210
|
const existing = byRegion.get(id);
|
|
1646
1211
|
const region = existing ?? {
|
|
1647
|
-
id:
|
|
1648
|
-
role:
|
|
1212
|
+
id: id,
|
|
1213
|
+
role: role,
|
|
1649
1214
|
topmost: false,
|
|
1650
1215
|
occluded_by: null,
|
|
1651
1216
|
children: [],
|
|
@@ -1657,22 +1222,24 @@ export function buildScreenOutline(elements, pageText, sealedFieldKeys = new Set
|
|
|
1657
1222
|
else if (region.occluded_by === null &&
|
|
1658
1223
|
el.occludedBy !== null &&
|
|
1659
1224
|
el.occludedBy !== undefined) {
|
|
1660
|
-
region.occluded_by =
|
|
1225
|
+
region.occluded_by = el.occludedBy;
|
|
1661
1226
|
}
|
|
1662
1227
|
if (region.children.length < 10) {
|
|
1663
1228
|
region.children.push({
|
|
1664
1229
|
ref: el.screenPath !== null && el.screenPath !== undefined
|
|
1665
|
-
?
|
|
1666
|
-
:
|
|
1667
|
-
role: el.role === null
|
|
1668
|
-
|
|
1230
|
+
? el.screenPath
|
|
1231
|
+
: elementRef(el),
|
|
1232
|
+
role: el.role === null
|
|
1233
|
+
? null
|
|
1234
|
+
: el.role,
|
|
1235
|
+
text: elementRef(el),
|
|
1669
1236
|
href: el.href === null || el.href === undefined
|
|
1670
1237
|
? null
|
|
1671
|
-
:
|
|
1238
|
+
: el.href,
|
|
1672
1239
|
topmost: el.topmost ?? null,
|
|
1673
1240
|
occluded_by: el.occludedBy === null || el.occludedBy === undefined
|
|
1674
1241
|
? null
|
|
1675
|
-
:
|
|
1242
|
+
: el.occludedBy,
|
|
1676
1243
|
});
|
|
1677
1244
|
}
|
|
1678
1245
|
byRegion.set(id, region);
|
|
@@ -1696,44 +1263,7 @@ function roleForAccessibility(el) {
|
|
|
1696
1263
|
return el.type ?? "textbox";
|
|
1697
1264
|
return el.tag;
|
|
1698
1265
|
}
|
|
1699
|
-
|
|
1700
|
-
// live DOM, so after a type_secret the cleartext password (or any sealed slot)
|
|
1701
|
-
// would surface in the observation/accessibility tree the planner sees and logs.
|
|
1702
|
-
// The whole point of prepare_login/extract is that the host only ever holds a
|
|
1703
|
-
// MASKED handle. Mask the presented copy here; internal callers (form-fill,
|
|
1704
|
-
// replay, postcondition length checks) read the raw InteractiveElement and are
|
|
1705
|
-
// unaffected. A field is sealed if it's a password input or a target a secret
|
|
1706
|
-
// slot was typed into (tracked per-session in sealedFieldKeys).
|
|
1707
|
-
const SEALED_FIELD_PLACEHOLDER = "[sealed]";
|
|
1708
|
-
function isSealedFieldValue(el, sealed) {
|
|
1709
|
-
if (el.sealed === true)
|
|
1710
|
-
return true;
|
|
1711
|
-
if ((el.type ?? "").toLowerCase() === "password")
|
|
1712
|
-
return true;
|
|
1713
|
-
if ((el.sealedIdentityKeys ?? []).some((key) => sealed.has(key)))
|
|
1714
|
-
return true;
|
|
1715
|
-
return elementTargetKeys(el).some((k) => sealed.has(k));
|
|
1716
|
-
}
|
|
1717
|
-
function presentFieldValue(el, sealed, paymentSealActive = false) {
|
|
1718
|
-
const v = el.value ?? null;
|
|
1719
|
-
if (v === null || v.length === 0)
|
|
1720
|
-
return v;
|
|
1721
|
-
return isSealedFieldValue(el, sealed)
|
|
1722
|
-
? SEALED_FIELD_PLACEHOLDER
|
|
1723
|
-
: presentPaymentSafeString(v, paymentSealActive);
|
|
1724
|
-
}
|
|
1725
|
-
// The host-facing LABEL. elementRef falls back to a field's VALUE when it has no
|
|
1726
|
-
// other label text — which would leak a sealed secret as the element's name. For
|
|
1727
|
-
// a sealed field, re-derive the label with the value stripped so it lands on the
|
|
1728
|
-
// next signal (placeholder/name) or `tag#index`, never the secret. Ref-keying
|
|
1729
|
-
// and targeting still use the raw elementRef, so resolution is unaffected.
|
|
1730
|
-
function presentLabel(el, sealed, paymentSealActive = false) {
|
|
1731
|
-
const label = isSealedFieldValue(el, sealed)
|
|
1732
|
-
? elementRef({ ...el, value: null })
|
|
1733
|
-
: elementRef(el);
|
|
1734
|
-
return presentPaymentSafeString(label, paymentSealActive);
|
|
1735
|
-
}
|
|
1736
|
-
export function buildAccessibilitySnapshot(elements, limit = 12000, sealedFieldKeys = new Set(), paymentSealActive = false) {
|
|
1266
|
+
export function buildAccessibilitySnapshot(elements, limit = 12000) {
|
|
1737
1267
|
if (elements.length === 0)
|
|
1738
1268
|
return undefined;
|
|
1739
1269
|
const refs = provisionElementRefs(elements);
|
|
@@ -1748,21 +1278,21 @@ export function buildAccessibilitySnapshot(elements, limit = 12000, sealedFieldK
|
|
|
1748
1278
|
const structurallyTruncated = entries.length > 24 || entries.some(([, group]) => group.length > 16);
|
|
1749
1279
|
const lines = ["RootWebArea"];
|
|
1750
1280
|
for (const [region, group] of entries.slice(0, 24)) {
|
|
1751
|
-
lines.push(` region "${
|
|
1281
|
+
lines.push(` region "${region}"`);
|
|
1752
1282
|
for (const el of group.slice(0, 16)) {
|
|
1753
|
-
const label =
|
|
1754
|
-
const role =
|
|
1755
|
-
const shownValue =
|
|
1283
|
+
const label = elementRef(el).replace(/"/g, '\\"');
|
|
1284
|
+
const role = roleForAccessibility(el);
|
|
1285
|
+
const shownValue = (el.value ?? null);
|
|
1756
1286
|
const flags = [
|
|
1757
1287
|
el.value !== undefined && el.value !== null
|
|
1758
1288
|
? `value="${(shownValue ?? "").slice(0, 60)}"`
|
|
1759
1289
|
: null,
|
|
1760
1290
|
el.checked !== undefined && el.checked !== null ? `checked=${el.checked}` : null,
|
|
1761
1291
|
el.href !== undefined && el.href !== null
|
|
1762
|
-
? `href="${
|
|
1292
|
+
? `href="${el.href.slice(0, 120)}"`
|
|
1763
1293
|
: null,
|
|
1764
1294
|
el.topmost === false
|
|
1765
|
-
? `occluded_by="${
|
|
1295
|
+
? `occluded_by="${el.occludedBy ?? "unknown"}"`
|
|
1766
1296
|
: null,
|
|
1767
1297
|
].filter((v) => v !== null);
|
|
1768
1298
|
lines.push(` ${role} "${label}" ref=${refs.get(el) ?? provisionElementRef(el)}` +
|
|
@@ -1792,14 +1322,6 @@ export function buildAccessibilitySnapshot(elements, limit = 12000, sealedFieldK
|
|
|
1792
1322
|
source: "interactive_dom",
|
|
1793
1323
|
};
|
|
1794
1324
|
}
|
|
1795
|
-
function registrableHost(url) {
|
|
1796
|
-
try {
|
|
1797
|
-
return new URL(url).hostname.toLowerCase();
|
|
1798
|
-
}
|
|
1799
|
-
catch {
|
|
1800
|
-
return null;
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
1325
|
function baseDomain(host) {
|
|
1804
1326
|
const parts = host.toLowerCase().split(".").filter(Boolean);
|
|
1805
1327
|
if (parts.length <= 2)
|
|
@@ -1844,234 +1366,21 @@ function widenAllowedHostsFromCurrentUrl(session) {
|
|
|
1844
1366
|
});
|
|
1845
1367
|
}
|
|
1846
1368
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
"— plain `connect` may report 'already connected' from a cached marker and " +
|
|
1857
|
-
"skip the sign-in, so it will NOT fix a stale/expired session. Then retry " +
|
|
1858
|
-
"— the task has NOT started and nothing was changed.",
|
|
1859
|
-
resume: "connect",
|
|
1860
|
-
},
|
|
1861
|
-
};
|
|
1862
|
-
}
|
|
1863
|
-
async function ensureProvisionPrimaryProviderSession(browser) {
|
|
1864
|
-
if (typeof browser.detectSessionProviders !== "function")
|
|
1865
|
-
return [];
|
|
1866
|
-
return await browser.detectSessionProviders().catch(() => []);
|
|
1867
|
-
}
|
|
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
|
+
};
|
|
1868
1378
|
export async function startProvisionSession(opts) {
|
|
1869
|
-
|
|
1870
|
-
let browser;
|
|
1871
|
-
let liveProviders;
|
|
1872
|
-
let workerEmail;
|
|
1873
|
-
const acquired = await acquireWarmBrowser(opts, id);
|
|
1874
|
-
browser = acquired.controller;
|
|
1875
|
-
// Probe the selected browser: ordinary sessions use a claimed/cloned worker,
|
|
1876
|
-
// while requireLiveIdentity uses the canonical login-authoring profile directly.
|
|
1877
|
-
// The immutable seed is never opened by Chrome during an operator start.
|
|
1878
|
-
liveProviders = await ensureProvisionPrimaryProviderSession(browser);
|
|
1879
|
-
workerEmail =
|
|
1880
|
-
typeof browser.detectGoogleAccountEmail === "function"
|
|
1881
|
-
? await browser.detectGoogleAccountEmail().catch(() => null)
|
|
1882
|
-
: null;
|
|
1883
|
-
// Change 5 — fail-closed identity gate BEFORE driving. If an operate task
|
|
1884
|
-
// needs to act as the user and there's no live Google session, hand back now;
|
|
1885
|
-
// do not start the browser or the task. No autonomous login is attempted.
|
|
1886
|
-
if (opts.requireLiveIdentity === true) {
|
|
1887
|
-
const gate = googleSessionGate(liveProviders);
|
|
1888
|
-
if (!gate.ok) {
|
|
1889
|
-
audit(id, "connect_gate", { ok: false, wall: "google_session" });
|
|
1890
|
-
await releaseWarmBrowserPage(browser, true);
|
|
1891
|
-
return { session_id: id, url: "", text: "", elements: [], needs_user: gate.needs_user };
|
|
1892
|
-
}
|
|
1893
|
-
}
|
|
1894
|
-
const targetHost = registrableHost(opts.serviceUrl);
|
|
1895
|
-
const seedHosts = [
|
|
1896
|
-
...(targetHost !== null ? [targetHost] : []),
|
|
1897
|
-
...(opts.extraAllowedHosts ?? []),
|
|
1898
|
-
];
|
|
1899
|
-
// All start-declared hosts are sourced "start" — auto-widen chains off these,
|
|
1900
|
-
// and credential egress may seed from these (but never from mid_session).
|
|
1901
|
-
const allowedHosts = [...new Set(seedHosts)].map((host) => ({
|
|
1902
|
-
host,
|
|
1903
|
-
source: "start",
|
|
1904
|
-
}));
|
|
1905
|
-
const session = {
|
|
1906
|
-
id,
|
|
1907
|
-
browser,
|
|
1908
|
-
allowedHosts,
|
|
1909
|
-
generation: 0,
|
|
1910
|
-
secretSlots: new Map(),
|
|
1911
|
-
sealedFieldKeys: new Set(),
|
|
1912
|
-
lastElements: [],
|
|
1913
|
-
prevObserve: null,
|
|
1914
|
-
observeSnapshotFile: null,
|
|
1915
|
-
actionTrace: [],
|
|
1916
|
-
recordedValues: [],
|
|
1917
|
-
committedSelectValues: new Map(),
|
|
1918
|
-
captureRounds: [],
|
|
1919
|
-
usedLocatorFallback: false,
|
|
1920
|
-
recipeRejectionReason: null,
|
|
1921
|
-
replayState: null,
|
|
1922
|
-
activePayment: null,
|
|
1923
|
-
paymentFieldSealActive: false,
|
|
1924
|
-
pendingThreeDs: null,
|
|
1925
|
-
paymentDispatchHandoff: null,
|
|
1926
|
-
placeOrderApproval: null,
|
|
1927
|
-
placeOrderAttempted: false,
|
|
1928
|
-
lastCartCheckout: null,
|
|
1929
|
-
cartAdds: new Map(),
|
|
1930
|
-
cartAddsByIdempotencyKey: new Map(),
|
|
1931
|
-
cartUrls: new Map(),
|
|
1932
|
-
lastCartMutation: null,
|
|
1933
|
-
closing: false,
|
|
1934
|
-
callCount: 0,
|
|
1935
|
-
callDrainWaiters: new Set(),
|
|
1936
|
-
paymentCallCount: 0,
|
|
1937
|
-
paymentCallDrainWaiters: new Set(),
|
|
1938
|
-
paymentDispatchClosed: false,
|
|
1939
|
-
startedAt: Date.now(),
|
|
1940
|
-
lastActivityAt: Date.now(),
|
|
1941
|
-
watchdog: null,
|
|
1942
|
-
terminalTeardownOwner: null,
|
|
1943
|
-
hintServed: opts.hint !== undefined,
|
|
1944
|
-
startUrl: opts.serviceUrl,
|
|
1945
|
-
consentInboxRead: opts.consentInboxRead === true,
|
|
1946
|
-
userEmail: workerEmail,
|
|
1947
|
-
...(opts.api !== undefined ? { api: opts.api } : {}),
|
|
1948
|
-
};
|
|
1949
|
-
sessions.set(id, session);
|
|
1950
|
-
startSessionWatchdog(session);
|
|
1951
|
-
try {
|
|
1952
|
-
if (typeof browser.setHostScopeAllowedHosts === "function") {
|
|
1953
|
-
await browser.setHostScopeAllowedHosts(() => hostStrings(session), () => merchantSiblingSeedHosts(session));
|
|
1954
|
-
}
|
|
1955
|
-
audit(id, "start", {
|
|
1956
|
-
service_url: opts.serviceUrl,
|
|
1957
|
-
allowed_hosts: hostStrings(session),
|
|
1958
|
-
has_hint: opts.hint !== undefined,
|
|
1959
|
-
});
|
|
1960
|
-
await browser.goto(opts.serviceUrl);
|
|
1961
|
-
// A cookie/consent overlay (Usercentrics/OneTrust/…) renders after load and its
|
|
1962
|
-
// backdrop occludes the ENTIRE form — the agent then sees every element
|
|
1963
|
-
// occluded_by a div and gives up, or falls back to the only thing that looks
|
|
1964
|
-
// clickable (e.g. a "Connect wallet" CTA on the Robinhood faucet). Dismiss it
|
|
1965
|
-
// BEFORE the first observation so the real actionable form is operable.
|
|
1966
|
-
// dismissConsentBanner() existed but had NO call sites (dead code); it only
|
|
1967
|
-
// clicks banner-specific CTAs (accept/reject all), so a false click is unlikely.
|
|
1968
|
-
// Best-effort + one retry, since the widget lazy-loads a beat after the goto.
|
|
1969
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1970
|
-
const cta = await browser.dismissConsentBanner().catch(() => null);
|
|
1971
|
-
if (cta !== null) {
|
|
1972
|
-
audit(id, "consent_dismissed", { cta });
|
|
1973
|
-
break;
|
|
1974
|
-
}
|
|
1975
|
-
if (attempt === 0)
|
|
1976
|
-
await browser.waitForCaptchaChallengeToSettle(800, 0).catch(() => false);
|
|
1977
|
-
}
|
|
1978
|
-
const observation = await observeSession(session);
|
|
1979
|
-
// Tell the agent which provider the user actually has a live session for
|
|
1980
|
-
// (Google-preferred) — the bot knows from the profile cookies, so the agent
|
|
1981
|
-
// doesn't have to guess. Composed with the skill route hint (if any).
|
|
1982
|
-
const hintParts = [
|
|
1983
|
-
loginSessionGuidance(liveProviders),
|
|
1984
|
-
...(opts.hint !== undefined ? [opts.hint] : []),
|
|
1985
|
-
];
|
|
1986
|
-
return {
|
|
1987
|
-
...observation,
|
|
1988
|
-
hint: hintParts.join("\n"),
|
|
1989
|
-
...(session.userEmail !== null ? { user_email: session.userEmail } : {}),
|
|
1990
|
-
};
|
|
1991
|
-
}
|
|
1992
|
-
catch (err) {
|
|
1993
|
-
sessions.delete(id);
|
|
1994
|
-
disposeSessionWatchdog(session);
|
|
1995
|
-
await releaseWarmBrowserPage(browser, false);
|
|
1996
|
-
throw err;
|
|
1997
|
-
}
|
|
1379
|
+
return await startProvisionSessionInternal(opts, sessionStartPorts);
|
|
1998
1380
|
}
|
|
1999
1381
|
/** Start a normal guarded session on a caller-owned harness page. */
|
|
2000
1382
|
export async function startHarnessProvisionSession(opts) {
|
|
2001
|
-
|
|
2002
|
-
if (opts.requireLiveIdentity === true) {
|
|
2003
|
-
throw new Error("harness sessions cannot request live identity");
|
|
2004
|
-
}
|
|
2005
|
-
const targetHost = registrableHost(opts.serviceUrl);
|
|
2006
|
-
const allowedHosts = [
|
|
2007
|
-
...(targetHost === null ? [] : [targetHost]),
|
|
2008
|
-
...(opts.extraAllowedHosts ?? []),
|
|
2009
|
-
]
|
|
2010
|
-
.filter((host, index, hosts) => hosts.indexOf(host) === index)
|
|
2011
|
-
.map((host) => ({
|
|
2012
|
-
host,
|
|
2013
|
-
source: "start",
|
|
2014
|
-
}));
|
|
2015
|
-
const session = {
|
|
2016
|
-
id,
|
|
2017
|
-
browser: opts.browser,
|
|
2018
|
-
allowedHosts,
|
|
2019
|
-
generation: 0,
|
|
2020
|
-
secretSlots: new Map(),
|
|
2021
|
-
sealedFieldKeys: new Set(),
|
|
2022
|
-
lastElements: [],
|
|
2023
|
-
prevObserve: null,
|
|
2024
|
-
observeSnapshotFile: null,
|
|
2025
|
-
actionTrace: [],
|
|
2026
|
-
recordedValues: [],
|
|
2027
|
-
committedSelectValues: new Map(),
|
|
2028
|
-
captureRounds: [],
|
|
2029
|
-
usedLocatorFallback: false,
|
|
2030
|
-
recipeRejectionReason: null,
|
|
2031
|
-
replayState: null,
|
|
2032
|
-
activePayment: null,
|
|
2033
|
-
paymentFieldSealActive: false,
|
|
2034
|
-
pendingThreeDs: null,
|
|
2035
|
-
paymentDispatchHandoff: null,
|
|
2036
|
-
placeOrderApproval: null,
|
|
2037
|
-
placeOrderAttempted: false,
|
|
2038
|
-
lastCartCheckout: null,
|
|
2039
|
-
cartAdds: new Map(),
|
|
2040
|
-
cartAddsByIdempotencyKey: new Map(),
|
|
2041
|
-
cartUrls: new Map(),
|
|
2042
|
-
lastCartMutation: null,
|
|
2043
|
-
closing: false,
|
|
2044
|
-
callCount: 0,
|
|
2045
|
-
callDrainWaiters: new Set(),
|
|
2046
|
-
paymentCallCount: 0,
|
|
2047
|
-
paymentCallDrainWaiters: new Set(),
|
|
2048
|
-
paymentDispatchClosed: false,
|
|
2049
|
-
startedAt: Date.now(),
|
|
2050
|
-
lastActivityAt: Date.now(),
|
|
2051
|
-
watchdog: null,
|
|
2052
|
-
terminalTeardownOwner: null,
|
|
2053
|
-
hintServed: opts.hint !== undefined,
|
|
2054
|
-
startUrl: opts.serviceUrl,
|
|
2055
|
-
consentInboxRead: false,
|
|
2056
|
-
userEmail: null,
|
|
2057
|
-
...(opts.api === undefined ? {} : { api: opts.api }),
|
|
2058
|
-
};
|
|
2059
|
-
sessions.set(id, session);
|
|
2060
|
-
startSessionWatchdog(session);
|
|
2061
|
-
try {
|
|
2062
|
-
audit(id, "start_harness", {
|
|
2063
|
-
service_url: opts.serviceUrl,
|
|
2064
|
-
allowed_hosts: hostStrings(session),
|
|
2065
|
-
});
|
|
2066
|
-
await opts.browser.goto(opts.serviceUrl);
|
|
2067
|
-
return { ...(await observeSession(session)), hint: opts.hint ?? "" };
|
|
2068
|
-
}
|
|
2069
|
-
catch (error) {
|
|
2070
|
-
sessions.delete(id);
|
|
2071
|
-
disposeSessionWatchdog(session);
|
|
2072
|
-
await opts.browser.close().catch(() => undefined);
|
|
2073
|
-
throw error;
|
|
2074
|
-
}
|
|
1383
|
+
return await startHarnessProvisionSessionInternal(opts, sessionStartPorts);
|
|
2075
1384
|
}
|
|
2076
1385
|
export async function observe(sessionId, detail = "compact") {
|
|
2077
1386
|
const session = sessionForCall(sessionId);
|
|
@@ -2079,28 +1388,18 @@ export async function observe(sessionId, detail = "compact") {
|
|
|
2079
1388
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
2080
1389
|
return await observeSession(session, detail);
|
|
2081
1390
|
}
|
|
2082
|
-
// operate_screenshot's session-level entry point
|
|
2083
|
-
//
|
|
2084
|
-
// 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.
|
|
2085
1393
|
export async function captureScreenshot(sessionId, opts = {}) {
|
|
2086
1394
|
const session = sessionForCall(sessionId);
|
|
2087
1395
|
if (session === undefined)
|
|
2088
1396
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
2089
|
-
|
|
2090
|
-
// child frame. Historical seals are different: sealedFieldKeys is cumulative,
|
|
2091
|
-
// so the browser must inspect only the frames this capture would include.
|
|
2092
|
-
if (session.paymentFieldSealActive || session.activePayment?.status === "operating") {
|
|
2093
|
-
throw new Error("screenshot_unavailable_sealed_context");
|
|
2094
|
-
}
|
|
2095
|
-
const captured = await session.browser.captureOperatorScreenshot(opts, [
|
|
2096
|
-
...session.sealedFieldKeys,
|
|
2097
|
-
]);
|
|
1397
|
+
const captured = await session.browser.captureOperatorScreenshot(opts);
|
|
2098
1398
|
return {
|
|
2099
1399
|
session_id: sessionId,
|
|
2100
1400
|
url: session.browser.currentUrl(),
|
|
2101
1401
|
frame_url: captured.frameUrl,
|
|
2102
1402
|
frame_count: captured.frameCount,
|
|
2103
|
-
redacted_count: captured.redactedCount,
|
|
2104
1403
|
image: { mime_type: "image/jpeg", data_base64: captured.base64 },
|
|
2105
1404
|
};
|
|
2106
1405
|
}
|
|
@@ -2157,14 +1456,20 @@ export function currentProvisionUrl(sessionId) {
|
|
|
2157
1456
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
2158
1457
|
return session.browser.currentUrl();
|
|
2159
1458
|
}
|
|
1459
|
+
export function isCompactV2ProvisionSession(sessionId) {
|
|
1460
|
+
return sessionForCall(sessionId)?.compactV2Mode === "on";
|
|
1461
|
+
}
|
|
2160
1462
|
function activeProvisionSession() {
|
|
2161
1463
|
return paymentSession();
|
|
2162
1464
|
}
|
|
2163
1465
|
export function activeProvisionBrowser() {
|
|
2164
|
-
|
|
1466
|
+
const session = activeProvisionSession();
|
|
1467
|
+
invalidateCompactV2Snapshot(session);
|
|
1468
|
+
return session.browser;
|
|
2165
1469
|
}
|
|
2166
1470
|
export async function activeProvisionBrowserForPayment(selectedSession) {
|
|
2167
1471
|
const session = selectedSession ?? activeProvisionSession();
|
|
1472
|
+
invalidateCompactV2Snapshot(session);
|
|
2168
1473
|
return session.browser;
|
|
2169
1474
|
}
|
|
2170
1475
|
function placeOrderApprovalFromPendingFill(pending) {
|
|
@@ -2196,13 +1501,37 @@ export function getActivePendingCardFill(selectedSession) {
|
|
|
2196
1501
|
const state = (selectedSession ?? activeProvisionSession()).activePayment;
|
|
2197
1502
|
return state?.status === "pending" ? state.pending : null;
|
|
2198
1503
|
}
|
|
2199
|
-
//
|
|
2200
|
-
//
|
|
2201
|
-
//
|
|
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.
|
|
2202
1507
|
export function getActivePendingApproval(selectedSession) {
|
|
2203
1508
|
const state = (selectedSession ?? activeProvisionSession()).activePayment;
|
|
2204
1509
|
return state?.status === "awaiting_approval" ? state.state : null;
|
|
2205
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
|
+
}
|
|
2206
1535
|
export function getActivePendingThreeDs(selectedSession) {
|
|
2207
1536
|
return (selectedSession ?? activeProvisionSession()).pendingThreeDs ?? null;
|
|
2208
1537
|
}
|
|
@@ -2278,6 +1607,13 @@ export function claimActivePaymentForOperatePay(phase, selectedSession) {
|
|
|
2278
1607
|
if (state?.status === "sealed") {
|
|
2279
1608
|
throw new Error("operate_pay refused: payment field cleanup remains unverified");
|
|
2280
1609
|
}
|
|
1610
|
+
if (state?.status === "terminal_approval") {
|
|
1611
|
+
return {
|
|
1612
|
+
kind: "terminal",
|
|
1613
|
+
state: state.state,
|
|
1614
|
+
terminalStatus: state.terminalStatus,
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
2281
1617
|
if (state?.status === "pending") {
|
|
2282
1618
|
if (phase !== "confirm") {
|
|
2283
1619
|
throw new Error('operate_pay refused: a vaulted card fill is pending; phase="confirm" is required next');
|
|
@@ -2413,6 +1749,14 @@ function alreadyInCartResult(result) {
|
|
|
2413
1749
|
checkout_state: { ...result.checkout_state },
|
|
2414
1750
|
};
|
|
2415
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
|
+
}
|
|
2416
1760
|
async function reconcileReservedCartAdd(session, record) {
|
|
2417
1761
|
if (record.result !== null)
|
|
2418
1762
|
return alreadyInCartResult(record.result);
|
|
@@ -2432,14 +1776,14 @@ async function reconcileReservedCartAdd(session, record) {
|
|
|
2432
1776
|
cartDelta: "0",
|
|
2433
1777
|
origin: originForUrl(session.browser.currentUrl()) ?? "",
|
|
2434
1778
|
};
|
|
2435
|
-
const
|
|
2436
|
-
if (
|
|
1779
|
+
const checkoutState = await capturePrivateCheckoutState(session);
|
|
1780
|
+
if (checkoutState === undefined)
|
|
2437
1781
|
throw error;
|
|
2438
1782
|
const result = {
|
|
2439
1783
|
status: "already_in_cart",
|
|
2440
1784
|
cart_delta: "0",
|
|
2441
|
-
cart_url:
|
|
2442
|
-
checkout_state: { ...
|
|
1785
|
+
cart_url: checkoutState.cart_url,
|
|
1786
|
+
checkout_state: { ...checkoutState, quantity },
|
|
2443
1787
|
postcondition: {
|
|
2444
1788
|
product_identity: record.productIdentity,
|
|
2445
1789
|
options_hash: record.optionsHash,
|
|
@@ -2462,14 +1806,14 @@ async function performCartAdd(session, record) {
|
|
|
2462
1806
|
cartDelta: "0",
|
|
2463
1807
|
origin: originForUrl(session.browser.currentUrl()) ?? "",
|
|
2464
1808
|
};
|
|
2465
|
-
const
|
|
2466
|
-
if (
|
|
1809
|
+
const checkoutState = await capturePrivateCheckoutState(session);
|
|
1810
|
+
if (checkoutState === undefined)
|
|
2467
1811
|
throw new Error("cart state was not observable");
|
|
2468
1812
|
return {
|
|
2469
1813
|
status: "already_in_cart",
|
|
2470
1814
|
cart_delta: "0",
|
|
2471
|
-
cart_url:
|
|
2472
|
-
checkout_state: { ...
|
|
1815
|
+
cart_url: checkoutState.cart_url,
|
|
1816
|
+
checkout_state: { ...checkoutState, quantity: beforeQuantity },
|
|
2473
1817
|
postcondition: {
|
|
2474
1818
|
product_identity: record.productIdentity,
|
|
2475
1819
|
options_hash: record.optionsHash,
|
|
@@ -2484,33 +1828,33 @@ async function performCartAdd(session, record) {
|
|
|
2484
1828
|
'text="カートに追加"',
|
|
2485
1829
|
];
|
|
2486
1830
|
let addError;
|
|
2487
|
-
let
|
|
1831
|
+
let actionResult = null;
|
|
2488
1832
|
for (const target of addTargets) {
|
|
2489
1833
|
try {
|
|
2490
|
-
|
|
1834
|
+
actionResult = await actInternally(session.id, { kind: "click", target }, "compact", {
|
|
2491
1835
|
productIdentity: record.productIdentity,
|
|
2492
1836
|
optionsHash: record.optionsHash,
|
|
2493
1837
|
onActionReady: () => {
|
|
2494
1838
|
record.phase = "click_started";
|
|
2495
1839
|
},
|
|
2496
|
-
});
|
|
1840
|
+
}, true);
|
|
2497
1841
|
addError = undefined;
|
|
2498
1842
|
break;
|
|
2499
1843
|
}
|
|
2500
1844
|
catch (error) {
|
|
2501
1845
|
addError = error;
|
|
2502
|
-
if (!(error instanceof
|
|
1846
|
+
if (!(error instanceof ProvisionTargetMissingError)) {
|
|
2503
1847
|
throw error;
|
|
2504
1848
|
}
|
|
2505
1849
|
}
|
|
2506
1850
|
}
|
|
2507
|
-
if (addError !== undefined ||
|
|
1851
|
+
if (addError !== undefined || actionResult === null)
|
|
2508
1852
|
throw addError;
|
|
2509
1853
|
const afterQuantity = await cartLineQuantity(session, record.productIdentity, record.optionsHash);
|
|
2510
1854
|
if (afterQuantity === null || afterQuantity <= 0) {
|
|
2511
1855
|
throw new Error("requested product/variant line was not observable after add");
|
|
2512
1856
|
}
|
|
2513
|
-
const checkoutState =
|
|
1857
|
+
const checkoutState = actionResult.outcome.checkoutState;
|
|
2514
1858
|
if (checkoutState === undefined)
|
|
2515
1859
|
throw new Error("cart state was not observable after add");
|
|
2516
1860
|
const cartDelta = beforeQuantity === null
|
|
@@ -2538,6 +1882,29 @@ async function performCartAdd(session, record) {
|
|
|
2538
1882
|
},
|
|
2539
1883
|
};
|
|
2540
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
|
+
}
|
|
2541
1908
|
export async function cartAdd(sessionId, productIdentity, optionsHash, idempotencyKey) {
|
|
2542
1909
|
const session = sessionForCall(sessionId);
|
|
2543
1910
|
if (session === undefined)
|
|
@@ -2667,7 +2034,7 @@ function annotatePaymentControl(out, el) {
|
|
|
2667
2034
|
// empty. `value`→`value_len` (never the raw value — keeps the sealed-field moat);
|
|
2668
2035
|
// `checked` kept for real checkables (true OR false), omitted when null;
|
|
2669
2036
|
// `topmost` only when false (the informative case); `container` dropped.
|
|
2670
|
-
export function toCompactElement(el, ref,
|
|
2037
|
+
export function toCompactElement(el, ref,
|
|
2671
2038
|
// `path` is the single most verbose field and agents act by ref, not path — so
|
|
2672
2039
|
// it is DROPPED from the default host payload (78% → 85% of the measured cut).
|
|
2673
2040
|
// It is retained ONLY in the persisted snapshot file (includePath=true), which
|
|
@@ -2677,41 +2044,37 @@ includePath = false,
|
|
|
2677
2044
|
// Apply type-elision (Phase 4) to the WIRE form. The persisted file
|
|
2678
2045
|
// form keeps full fidelity for re-expansion, so callers that write the file
|
|
2679
2046
|
// pass false.
|
|
2680
|
-
elide = false
|
|
2047
|
+
elide = false) {
|
|
2681
2048
|
const out = {
|
|
2682
2049
|
ref,
|
|
2683
|
-
label:
|
|
2684
|
-
tag:
|
|
2050
|
+
label: elementRef(el),
|
|
2051
|
+
tag: el.tag,
|
|
2685
2052
|
};
|
|
2686
2053
|
if (el.role)
|
|
2687
|
-
out.role =
|
|
2054
|
+
out.role = el.role;
|
|
2688
2055
|
if (el.type && !(elide && shouldElideType(el))) {
|
|
2689
|
-
out.type =
|
|
2056
|
+
out.type = el.type;
|
|
2690
2057
|
}
|
|
2691
2058
|
// value_len is a LENGTH signal, not the value — report the REAL character count.
|
|
2692
|
-
// presentFieldValue masks a sealed field to "[sealed]" (8 chars), so using its
|
|
2693
|
-
// length made a correctly-filled 19-char email read as value_len:8 and misled
|
|
2694
|
-
// the agent into thinking its fill truncated. The length isn't the secret (the
|
|
2695
|
-
// min_value_len postcondition already uses the real length for the same reason).
|
|
2696
2059
|
const realLen = (el.value ?? "").length;
|
|
2697
2060
|
if (realLen > 0)
|
|
2698
2061
|
out.value_len = realLen;
|
|
2699
2062
|
if (el.checked !== null && el.checked !== undefined)
|
|
2700
2063
|
out.checked = el.checked;
|
|
2701
2064
|
if (el.href)
|
|
2702
|
-
out.href =
|
|
2065
|
+
out.href = el.href;
|
|
2703
2066
|
if (el.testId)
|
|
2704
|
-
out.testId =
|
|
2067
|
+
out.testId = el.testId;
|
|
2705
2068
|
if (includePath && el.screenPath) {
|
|
2706
|
-
out.path =
|
|
2069
|
+
out.path = el.screenPath;
|
|
2707
2070
|
}
|
|
2708
2071
|
if (el.topmost === false)
|
|
2709
2072
|
out.topmost = false;
|
|
2710
2073
|
if (el.occludedBy) {
|
|
2711
|
-
out.occluded_by =
|
|
2074
|
+
out.occluded_by = el.occludedBy;
|
|
2712
2075
|
}
|
|
2713
2076
|
if (el.frameOrigin) {
|
|
2714
|
-
out.frame_origin =
|
|
2077
|
+
out.frame_origin = el.frameOrigin;
|
|
2715
2078
|
}
|
|
2716
2079
|
annotatePaymentControl(out, el);
|
|
2717
2080
|
return out;
|
|
@@ -2881,11 +2244,6 @@ function emitElements(els, encode) {
|
|
|
2881
2244
|
// a write failure must NEVER break an observe. Rolling one file per session (the
|
|
2882
2245
|
// latest COMPLETE inventory) — that's what the host wants when it re-expands
|
|
2883
2246
|
// after a context compaction or greps for an element the delta didn't re-show.
|
|
2884
|
-
function observeSnapshotDir(sessionId) {
|
|
2885
|
-
const override = (process.env.TRUSTY_SQUIRE_OBSERVE_DIR ?? "").trim();
|
|
2886
|
-
const parent = override.length > 0 ? override : join(tmpdir(), "trusty-squire-observe");
|
|
2887
|
-
return join(parent, sessionId);
|
|
2888
|
-
}
|
|
2889
2247
|
function persistObserveSnapshot(session, generation, url, text, textTruncated, elements) {
|
|
2890
2248
|
let temporaryFile = null;
|
|
2891
2249
|
const dir = observeSnapshotDir(session.id);
|
|
@@ -3018,10 +2376,8 @@ const OBSERVE_CHURN_FULL_THRESHOLD = 0.6;
|
|
|
3018
2376
|
// collapse, and returns both the emit and the complete ground-truth set.
|
|
3019
2377
|
export function buildCompactObservation(args) {
|
|
3020
2378
|
const { sessionId, url, text, elements, prev } = args;
|
|
3021
|
-
const sealed = args.sealed ?? new Set();
|
|
3022
2379
|
const encode = args.encode ?? "columnar";
|
|
3023
2380
|
const elide = args.elide ?? true;
|
|
3024
|
-
const paymentSealActive = args.paymentSealActive ?? false;
|
|
3025
2381
|
const refs = provisionElementRefs(elements);
|
|
3026
2382
|
const refOf = (el) => refs.get(el) ?? provisionElementRef(el);
|
|
3027
2383
|
const fullByRef = new Map();
|
|
@@ -3029,11 +2385,11 @@ export function buildCompactObservation(args) {
|
|
|
3029
2385
|
const fileElements = [];
|
|
3030
2386
|
for (const el of elements) {
|
|
3031
2387
|
const ref = refOf(el);
|
|
3032
|
-
fullByRef.set(ref, toCompactElement(el, ref,
|
|
2388
|
+
fullByRef.set(ref, toCompactElement(el, ref, false, elide));
|
|
3033
2389
|
serializedByRef.set(ref, JSON.stringify(fullByRef.get(ref)));
|
|
3034
2390
|
// The persisted file keeps FULL fidelity (path included, no elision) so a
|
|
3035
2391
|
// re-expansion after a host compaction loses nothing.
|
|
3036
|
-
fileElements.push(toCompactElement(el, ref,
|
|
2392
|
+
fileElements.push(toCompactElement(el, ref, true, false));
|
|
3037
2393
|
}
|
|
3038
2394
|
const nextState = { url, byRef: serializedByRef, text };
|
|
3039
2395
|
// A dialog/modal region with at least one topmost (unoccluded) element is
|
|
@@ -3135,19 +2491,7 @@ async function captureCartCheckoutForFillCardFallback(session, url) {
|
|
|
3135
2491
|
return null;
|
|
3136
2492
|
}
|
|
3137
2493
|
function checkoutStageFromUrl(url) {
|
|
3138
|
-
|
|
3139
|
-
try {
|
|
3140
|
-
pathname = decodeURIComponent(new URL(url).pathname).toLowerCase();
|
|
3141
|
-
}
|
|
3142
|
-
catch {
|
|
3143
|
-
return null;
|
|
3144
|
-
}
|
|
3145
|
-
if (/(?:^|\/)(?:checkout|secure[-_]?checkout|payment|order[-_]?review|order\/review)(?:\.(?:php|html?))?(?:\/|$)/i.test(pathname)) {
|
|
3146
|
-
return "checkout";
|
|
3147
|
-
}
|
|
3148
|
-
if (/(?:^|\/)(?:cart|shopping[-_]?cart|view[-_]?cart|basket|bag)(?:\.(?:php|html?))?(?:\/|$)/i.test(pathname))
|
|
3149
|
-
return "cart";
|
|
3150
|
-
return null;
|
|
2494
|
+
return checkoutStageFromUrlV2(url);
|
|
3151
2495
|
}
|
|
3152
2496
|
function checkoutStage(url, elements) {
|
|
3153
2497
|
const routeStage = checkoutStageFromUrl(url);
|
|
@@ -3335,25 +2679,527 @@ function isCartAffectingAction(action, el, extraLabels = []) {
|
|
|
3335
2679
|
return ((action.kind === "type" || action.kind === "select") &&
|
|
3336
2680
|
/(?:\b(?:quantity|qty)\b|数量|個数)/i.test(target));
|
|
3337
2681
|
}
|
|
3338
|
-
|
|
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) {
|
|
3339
3183
|
const oauthInProgress = () => {
|
|
3340
3184
|
session.prevObserve = null;
|
|
3185
|
+
invalidateCompactV2Snapshot(session);
|
|
3341
3186
|
const oauth = session.browser.oauthTransitionStatus?.();
|
|
3342
|
-
const
|
|
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, () => ({
|
|
3343
3196
|
session_id: session.id,
|
|
3344
3197
|
url: oauth?.productUrl ?? session.startUrl,
|
|
3345
3198
|
text: "",
|
|
3346
|
-
guidance
|
|
3347
|
-
"Do not switch login methods or close the session; call operate_observe again to read the retained product page.",
|
|
3199
|
+
guidance,
|
|
3348
3200
|
elements: [],
|
|
3349
|
-
oauth:
|
|
3350
|
-
|
|
3351
|
-
provider_page: "closed_or_detached",
|
|
3352
|
-
next_action: "operate_observe",
|
|
3353
|
-
},
|
|
3354
|
-
};
|
|
3355
|
-
session.browser.completeOAuthTransitionRecovery();
|
|
3356
|
-
return observation;
|
|
3201
|
+
oauth: state,
|
|
3202
|
+
}), { stage: "auth", guidance, oauth: state });
|
|
3357
3203
|
};
|
|
3358
3204
|
try {
|
|
3359
3205
|
session.browser.recoverActivePage();
|
|
@@ -3367,9 +3213,27 @@ async function observeSession(session, detail = "compact") {
|
|
|
3367
3213
|
session.generation += 1;
|
|
3368
3214
|
const generation = session.generation;
|
|
3369
3215
|
const elements = await session.browser.extractInteractiveElements();
|
|
3370
|
-
session
|
|
3371
|
-
|
|
3372
|
-
|
|
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();
|
|
3373
3237
|
const normalizedFull = text.replace(/\s+/g, " ").trim();
|
|
3374
3238
|
const normalizedText = normalizedFull.slice(0, 4000);
|
|
3375
3239
|
const guidance = provisionPerceptionGuidance(normalizedText);
|
|
@@ -3389,8 +3253,6 @@ async function observeSession(session, detail = "compact") {
|
|
|
3389
3253
|
textTruncated,
|
|
3390
3254
|
...(guidance !== undefined ? { guidance } : {}),
|
|
3391
3255
|
elements,
|
|
3392
|
-
sealed: sealedFieldKeys,
|
|
3393
|
-
paymentSealActive: session.paymentFieldSealActive,
|
|
3394
3256
|
prev: session.prevObserve,
|
|
3395
3257
|
});
|
|
3396
3258
|
// Persist the COMPLETE snapshot (path INCLUDED) — the safety net that makes
|
|
@@ -3437,9 +3299,9 @@ async function observeSession(session, detail = "compact") {
|
|
|
3437
3299
|
// full-only observe can't restore stale state (the previous compact snapshot).
|
|
3438
3300
|
// Deliberately NOT surfaced in the payload — the full escape hatch stays
|
|
3439
3301
|
// byte-equivalent to the legacy shape (no snapshot_file field added).
|
|
3440
|
-
persistObserveSnapshot(session, generation, url, normalizedText, textTruncated, elements.map((el) => toCompactElement(el, refOf(el),
|
|
3441
|
-
const screen = buildScreenOutline(elements, normalizedText
|
|
3442
|
-
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);
|
|
3443
3305
|
return withCheckoutState({
|
|
3444
3306
|
session_id: session.id,
|
|
3445
3307
|
url,
|
|
@@ -3450,35 +3312,35 @@ async function observeSession(session, detail = "compact") {
|
|
|
3450
3312
|
elements: elements.map((el) => {
|
|
3451
3313
|
const observed = {
|
|
3452
3314
|
ref: refOf(el),
|
|
3453
|
-
label:
|
|
3454
|
-
tag:
|
|
3315
|
+
label: elementRef(el),
|
|
3316
|
+
tag: el.tag,
|
|
3455
3317
|
role: el.role === null
|
|
3456
3318
|
? null
|
|
3457
|
-
:
|
|
3319
|
+
: el.role,
|
|
3458
3320
|
type: el.type === null
|
|
3459
3321
|
? null
|
|
3460
|
-
:
|
|
3461
|
-
value:
|
|
3322
|
+
: el.type,
|
|
3323
|
+
value: (el.value ?? null),
|
|
3462
3324
|
checked: el.checked ?? null,
|
|
3463
3325
|
href: el.href === null || el.href === undefined
|
|
3464
3326
|
? null
|
|
3465
|
-
:
|
|
3327
|
+
: el.href,
|
|
3466
3328
|
testId: el.testId === null || el.testId === undefined
|
|
3467
3329
|
? null
|
|
3468
|
-
:
|
|
3330
|
+
: el.testId,
|
|
3469
3331
|
path: el.screenPath === null || el.screenPath === undefined
|
|
3470
3332
|
? null
|
|
3471
|
-
:
|
|
3333
|
+
: el.screenPath,
|
|
3472
3334
|
container: el.container === null || el.container === undefined
|
|
3473
3335
|
? null
|
|
3474
|
-
:
|
|
3336
|
+
: el.container,
|
|
3475
3337
|
topmost: el.topmost ?? null,
|
|
3476
3338
|
occluded_by: el.occludedBy === null || el.occludedBy === undefined
|
|
3477
3339
|
? null
|
|
3478
|
-
:
|
|
3340
|
+
: el.occludedBy,
|
|
3479
3341
|
frame_origin: el.frameOrigin === null || el.frameOrigin === undefined
|
|
3480
3342
|
? null
|
|
3481
|
-
:
|
|
3343
|
+
: el.frameOrigin,
|
|
3482
3344
|
};
|
|
3483
3345
|
annotatePaymentControl(observed, el);
|
|
3484
3346
|
return observed;
|
|
@@ -3594,7 +3456,42 @@ async function runClickWithPlaceOrderGuard(session, click) {
|
|
|
3594
3456
|
}
|
|
3595
3457
|
await recordPlaceOrderAttemptAudit(session, approval);
|
|
3596
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
|
+
}
|
|
3597
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) {
|
|
3598
3495
|
const session = sessionForCall(sessionId);
|
|
3599
3496
|
if (session === undefined)
|
|
3600
3497
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
@@ -3610,7 +3507,7 @@ export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
|
3610
3507
|
if (cardBlock !== null)
|
|
3611
3508
|
throw new ManualCardEntryBlockedError(cardBlock);
|
|
3612
3509
|
}
|
|
3613
|
-
|
|
3510
|
+
let browser = session.browser;
|
|
3614
3511
|
let completedAction = action;
|
|
3615
3512
|
let sensitiveSource;
|
|
3616
3513
|
let cartAffecting = false;
|
|
@@ -3623,15 +3520,37 @@ export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
|
3623
3520
|
cartAffecting = true;
|
|
3624
3521
|
cartIdentity.onActionReady?.();
|
|
3625
3522
|
};
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
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
|
+
}
|
|
3631
3548
|
audit(sessionId, "act", {
|
|
3632
3549
|
kind: action.kind,
|
|
3633
3550
|
...(auditTarget !== undefined ? { target: auditTarget } : {}),
|
|
3634
|
-
...("url" in action
|
|
3551
|
+
...("url" in action
|
|
3552
|
+
? { url: session.compactV2Active ? compactV2AuditUrl(action.url) : action.url }
|
|
3553
|
+
: {}),
|
|
3635
3554
|
});
|
|
3636
3555
|
// The URL the action is taken ON — captured BEFORE the action navigates. The
|
|
3637
3556
|
// capture round pairs this with the pre-action inventory + observed; using
|
|
@@ -3653,7 +3572,7 @@ export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
|
3653
3572
|
curHost = null;
|
|
3654
3573
|
}
|
|
3655
3574
|
if (curHost !== null && isSquireControlPlaneHost(curHost)) {
|
|
3656
|
-
throw new
|
|
3575
|
+
throw new ProvisionTargetNotAllowedError(`action refused: the browser is on Squire's own control plane (${curHost}). ` +
|
|
3657
3576
|
`The operator may not act on the Trusty Squire vault/app — navigate away with goto.`);
|
|
3658
3577
|
}
|
|
3659
3578
|
}
|
|
@@ -3661,410 +3580,486 @@ export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
|
3661
3580
|
// Captured for the operator-recipe trace: the element a target action
|
|
3662
3581
|
// resolved to, so we record the VISIBLE text it acted on (not the ref).
|
|
3663
3582
|
let resolvedEl = null;
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
`
|
|
3669
|
-
|
|
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;
|
|
3670
3593
|
}
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
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;
|
|
3678
3607
|
}
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3608
|
+
case "press": {
|
|
3609
|
+
await browser.pressKey(action.key);
|
|
3610
|
+
break;
|
|
3682
3611
|
}
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
await browser.pressKey(action.key);
|
|
3687
|
-
break;
|
|
3688
|
-
}
|
|
3689
|
-
case "oauth_settle": {
|
|
3690
|
-
await browser.settleAfterOAuth();
|
|
3691
|
-
break;
|
|
3692
|
-
}
|
|
3693
|
-
case "scroll": {
|
|
3694
|
-
await browser.scrollViewport(action.direction ?? "down");
|
|
3695
|
-
break;
|
|
3696
|
-
}
|
|
3697
|
-
case "type_secret": {
|
|
3698
|
-
const value = session.secretSlots.get(action.slot);
|
|
3699
|
-
if (value === undefined) {
|
|
3700
|
-
throw new Error(`type_secret: no sealed slot named "${action.slot}". Capture it first with ` +
|
|
3701
|
-
`operate_act { kind: "extract", into_slot: "${action.slot}" }. Known slots: ` +
|
|
3702
|
-
`[${[...session.secretSlots.keys()].join(", ")}]`);
|
|
3612
|
+
case "oauth_settle": {
|
|
3613
|
+
await browser.settleAfterOAuth();
|
|
3614
|
+
break;
|
|
3703
3615
|
}
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
throw new Error(`type_secret: no element matched locator "${action.target}".`);
|
|
3715
|
-
}
|
|
3716
|
-
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(", ")}]`);
|
|
3717
3626
|
}
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
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);
|
|
3721
3640
|
}
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
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);
|
|
3650
|
+
}
|
|
3651
|
+
audit(sessionId, "type_secret", {
|
|
3652
|
+
slot: action.slot,
|
|
3653
|
+
locator_mode: locator.mode,
|
|
3654
|
+
host: registrableHost(browser.currentUrl()),
|
|
3655
|
+
});
|
|
3656
|
+
break;
|
|
3726
3657
|
}
|
|
3727
|
-
|
|
3728
|
-
|
|
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}".`);
|
|
3729
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);
|
|
3730
3690
|
audit(sessionId, "type_secret", {
|
|
3731
3691
|
slot: action.slot,
|
|
3732
|
-
|
|
3692
|
+
target: auditTarget,
|
|
3733
3693
|
host: registrableHost(browser.currentUrl()),
|
|
3734
3694
|
});
|
|
3735
3695
|
break;
|
|
3736
3696
|
}
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
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;
|
|
3748
3736
|
}
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
// value is never returned to the host and never logged.
|
|
3757
|
-
const target = frameTargetFor(el);
|
|
3758
|
-
const sealedFieldKeys = target !== null
|
|
3759
|
-
? await browser.typeInFrame(target, el.selector, value, true)
|
|
3760
|
-
: await browser.type(el.selector, value, true);
|
|
3761
|
-
for (const key of sealedFieldKeys)
|
|
3762
|
-
session.sealedFieldKeys.add(key);
|
|
3763
|
-
audit(sessionId, "type_secret", {
|
|
3764
|
-
slot: action.slot,
|
|
3765
|
-
target: action.target,
|
|
3766
|
-
host: registrableHost(browser.currentUrl()),
|
|
3767
|
-
});
|
|
3768
|
-
break;
|
|
3769
|
-
}
|
|
3770
|
-
case "select": {
|
|
3771
|
-
// Re-resolve against FRESH elements — the target may be the <select> or
|
|
3772
|
-
// its <label>. Main-frame execution uses selectOption; frame execution
|
|
3773
|
-
// uses selectInFrame. text is the fuzzy option matcher in both paths.
|
|
3774
|
-
const fresh = await browser.extractInteractiveElements();
|
|
3775
|
-
session.lastElements = fresh;
|
|
3776
|
-
const el = resolveTarget(fresh, action.target);
|
|
3777
|
-
if (el === null) {
|
|
3778
|
-
const stale = staleTargetError(session, action.target, fresh);
|
|
3779
|
-
if (stale !== null)
|
|
3780
|
-
throw stale;
|
|
3781
|
-
throw new Error(`select: no element matched target "${action.target}". Visible: ` +
|
|
3782
|
-
fresh
|
|
3783
|
-
.map((e) => `"${e.screenPath ?? elementRef(e)}"`)
|
|
3784
|
-
.slice(0, 20)
|
|
3785
|
-
.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;
|
|
3786
3744
|
}
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
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;
|
|
3830
3833
|
}
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
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`);
|
|
3836
3848
|
}
|
|
3837
|
-
|
|
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(", "));
|
|
3838
3857
|
}
|
|
3839
|
-
|
|
3840
|
-
//
|
|
3841
|
-
//
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
if (
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
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
|
+
});
|
|
3856
3878
|
}
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
await runClickWithPlaceOrderGuard(session, (shouldTrack) => browser.clickWithDispatchTracking({
|
|
3865
|
-
kind: "handle",
|
|
3866
|
-
handle: resolved.handle,
|
|
3867
|
-
method: action.kind,
|
|
3868
|
-
}, 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
|
+
});
|
|
3869
3886
|
}
|
|
3870
|
-
else if (action.kind === "click")
|
|
3871
|
-
await browser.clickHandle(resolved.handle);
|
|
3872
|
-
else if (action.kind === "js_click")
|
|
3873
|
-
await browser.jsClickHandle(resolved.handle);
|
|
3874
|
-
else
|
|
3875
|
-
await browser.typeHandle(resolved.handle, action.text);
|
|
3876
3887
|
}
|
|
3877
|
-
|
|
3878
|
-
|
|
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);
|
|
3879
3896
|
}
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
.
|
|
3903
|
-
.
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
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
|
+
}
|
|
3916
3992
|
}
|
|
3917
|
-
else if (action.kind === "
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
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
|
+
});
|
|
3922
4001
|
}
|
|
3923
4002
|
else {
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
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);
|
|
3928
4008
|
}
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
//
|
|
3932
|
-
//
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
// support exists for; a plain frame-scoped fill covers it.
|
|
3936
|
-
session.committedSelectValues.delete(el.selector);
|
|
3937
|
-
await browser.typeInFrame(frameTargetFor(el), el.selector, action.text);
|
|
3938
|
-
}
|
|
3939
|
-
else if (action.kind === "type") {
|
|
3940
|
-
session.committedSelectValues.delete(el.selector);
|
|
3941
|
-
if (!isAutocompleteScopedTypeField(action.provenance, el)) {
|
|
3942
|
-
// Free text only — e.g. a site-search/catalog-search box, which
|
|
3943
|
-
// can legitimately open its own suggestion listbox too. 3.1 only
|
|
3944
|
-
// applies to a form/recipe field where a committed value is
|
|
3945
|
-
// actually required; forcing every incidental popup into
|
|
3946
|
-
// commit-or-stop would break ordinary search typing.
|
|
3947
|
-
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);
|
|
3948
4015
|
}
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
// open either way) but NEVER after a confirmed commit: the widget
|
|
3970
|
-
// already closed its popup on selection, so Escape would land on
|
|
3971
|
-
// nothing and bubble to close an enclosing modal/dialog instead.
|
|
3972
|
-
let dismissPopupWithEscape = false;
|
|
3973
|
-
try {
|
|
3974
|
-
const suggestionTexts = await browser.detectTypeSuggestionPopup(el.selector);
|
|
3975
|
-
if (suggestionTexts.length > 0) {
|
|
3976
|
-
dismissPopupWithEscape = true;
|
|
3977
|
-
const candidates = matchAutocompleteSuggestions(action.text, suggestionTexts);
|
|
3978
|
-
if (candidates.length !== 1) {
|
|
3979
|
-
// Pass the MATCHED subset, not the full popup — passing every
|
|
3980
|
-
// suggestion made candidates.length effectively the popup
|
|
3981
|
-
// size (always > 0 here), so the constructor's zero-match
|
|
3982
|
-
// branch was dead code and the multi-match message reported
|
|
3983
|
-
// the wrong count.
|
|
3984
|
-
throw new AutocompleteCommitRequiredError(action.text, candidates.map((i) => suggestionTexts[i]));
|
|
3985
|
-
}
|
|
3986
|
-
const pickedText = suggestionTexts[candidates[0]];
|
|
3987
|
-
await browser.commitTypeSuggestion(candidates[0]);
|
|
3988
|
-
// Never trust that a click "looked right" — POSITIVELY confirm
|
|
3989
|
-
// the commit took (same hard constraint as the field-role
|
|
3990
|
-
// guard, PR #447: a miss is a stop, never a silent
|
|
3991
|
-
// pass-through). Checking only the typed-into selector's own
|
|
3992
|
-
// `.value` false-fails on react-select/cmdk-style widgets,
|
|
3993
|
-
// which clear their search input on selection and render the
|
|
3994
|
-
// committed choice in a nearby element instead —
|
|
3995
|
-
// confirmAutocompleteCommitted checks that too, bounded to the
|
|
3996
|
-
// field's own neighborhood, and returns false (never a guess)
|
|
3997
|
-
// when nothing confirms it.
|
|
3998
|
-
const committed = await browser.confirmAutocompleteCommitted(el.selector, pickedText);
|
|
3999
|
-
if (!committed) {
|
|
4000
|
-
throw new Error(`autocomplete commit for "${action.text}" did not take — nothing on the page ` +
|
|
4001
|
-
`confirms the field now holds "${pickedText}" after selecting it.`);
|
|
4002
|
-
}
|
|
4003
|
-
dismissPopupWithEscape = false;
|
|
4004
|
-
// Rewrite the completed action to the field's LIVE post-commit
|
|
4005
|
-
// value (not the raw typed draft) before it reaches
|
|
4006
|
-
// recordTrace/recordedValues, so the recorded trace reflects
|
|
4007
|
-
// what actually ended up on the page. Recording pickedText
|
|
4008
|
-
// would diverge from the live value exactly when the commit
|
|
4009
|
-
// was confirmed via a NEARBY element (react-select/cmdk clear
|
|
4010
|
-
// their search input on selection), and the cold-path
|
|
4011
|
-
// transition attestation (attestRecordedFieldsBeforeTransition
|
|
4012
|
-
// → verifyFilledFieldValues) re-reads the live value — a
|
|
4013
|
-
// pickedText literal would flag every such commit as a
|
|
4014
|
-
// mismatch and disqualify recipe recording. Reading the same
|
|
4015
|
-
// live value here is attestation-consistent by construction.
|
|
4016
|
-
// Known limitation: after a nearby-signal-only commit the
|
|
4017
|
-
// field itself can be empty, so the recorded literal is "" —
|
|
4018
|
-
// that field won't cleanly template into a saved recipe, but
|
|
4019
|
-
// the live run is unaffected.
|
|
4020
|
-
const refreshed = await browser.extractInteractiveElements();
|
|
4021
|
-
session.lastElements = refreshed;
|
|
4022
|
-
const liveField = refreshed.find((field) => field.selector === el.selector);
|
|
4023
|
-
const liveValue = typeof liveField?.value === "string" ? liveField.value : pickedText;
|
|
4024
|
-
completedAction = { ...action, text: liveValue };
|
|
4025
|
-
}
|
|
4026
|
-
}
|
|
4027
|
-
finally {
|
|
4028
|
-
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");
|
|
4029
4036
|
}
|
|
4037
|
+
throw new Error(`oauth_login: no element matched target "${action.target}". Re-observe and use the OAuth button ref.`);
|
|
4030
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;
|
|
4031
4046
|
}
|
|
4032
|
-
else if (action.kind === "upload") {
|
|
4033
|
-
assertNoFrameTarget(el, "upload");
|
|
4034
|
-
await browser.uploadFile(el.selector, action.path);
|
|
4035
|
-
audit(sessionId, "upload", {
|
|
4036
|
-
target: action.target,
|
|
4037
|
-
path: action.path,
|
|
4038
|
-
host: registrableHost(browser.currentUrl()),
|
|
4039
|
-
});
|
|
4040
|
-
}
|
|
4041
|
-
else {
|
|
4042
|
-
assertNoFrameTarget(el, "oauth_click");
|
|
4043
|
-
await browser.startOAuth(el.selector);
|
|
4044
|
-
}
|
|
4045
|
-
if (action.kind !== "type")
|
|
4046
|
-
await settleAfterStateChange(browser);
|
|
4047
|
-
break;
|
|
4048
4047
|
}
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
if (el === null) {
|
|
4061
|
-
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);
|
|
4062
4059
|
}
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
await settleAfterStateChange(browser);
|
|
4067
|
-
break;
|
|
4060
|
+
catch { }
|
|
4061
|
+
if (!stillObservedDocument)
|
|
4062
|
+
invalidateCompactV2Snapshot(session);
|
|
4068
4063
|
}
|
|
4069
4064
|
}
|
|
4070
4065
|
await verifyRecordedFieldsAfterTransition(session, action, recordingTransitionFields);
|
|
@@ -4072,8 +4067,9 @@ export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
|
4072
4067
|
// INBOX_READ_HOSTS): replay re-reads the code via awaitVerification, and a
|
|
4073
4068
|
// recorded inbox click would bake the email's subject into a shared recipe.
|
|
4074
4069
|
if (!isInboxReadHost(browser.currentUrl())) {
|
|
4075
|
-
|
|
4076
|
-
|
|
4070
|
+
const replayElement = replaySafeElementForSession(session, resolvedEl);
|
|
4071
|
+
recordTrace(session, completedAction, replayElement, sensitiveSource);
|
|
4072
|
+
recordCaptureRound(session, completedAction, replayElement, urlBeforeAction);
|
|
4077
4073
|
}
|
|
4078
4074
|
if (cartAffecting) {
|
|
4079
4075
|
session.lastCartMutation = {
|
|
@@ -4086,47 +4082,92 @@ export async function act(sessionId, action, detail = "compact", cartIdentity) {
|
|
|
4086
4082
|
// `detail:"none"` returns a minimal ack (the action ran; no perception emitted)
|
|
4087
4083
|
// so multi-field fills don't each echo the page. The host must call
|
|
4088
4084
|
// operate_observe before its next ref-targeted act (refs aren't refreshed here).
|
|
4085
|
+
const checkoutState = internalAccess && collectCheckoutState ? await capturePrivateCheckoutState(session) : undefined;
|
|
4089
4086
|
const observation = detail === "none" && !cartAffecting && action.kind !== "oauth_login"
|
|
4090
|
-
? {
|
|
4087
|
+
? compactV2PublicObservation(session, () => ({
|
|
4091
4088
|
session_id: session.id,
|
|
4092
4089
|
url: browser.currentUrl(),
|
|
4093
4090
|
text: "",
|
|
4094
4091
|
elements: [],
|
|
4095
4092
|
observed: "none",
|
|
4096
|
-
}
|
|
4093
|
+
}), {
|
|
4094
|
+
stage: safeStageV2(browser.currentUrl(), session.lastElements),
|
|
4095
|
+
observed: "none",
|
|
4096
|
+
})
|
|
4097
4097
|
: await observeSession(session, detail === "none" ? "compact" : detail);
|
|
4098
|
-
return
|
|
4099
|
-
|
|
4100
|
-
|
|
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
|
+
};
|
|
4101
4107
|
}
|
|
4102
4108
|
export async function formSelectMany(sessionId, selections) {
|
|
4103
4109
|
const fields = [];
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
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
|
+
}
|
|
4109
4141
|
try {
|
|
4110
|
-
const
|
|
4111
|
-
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;
|
|
4112
4145
|
if (selectedOption === undefined) {
|
|
4113
4146
|
throw new Error("select: successful action omitted the selected option");
|
|
4114
4147
|
}
|
|
4115
4148
|
// `detail:none` is intentionally a minimal ack. The explicit observe here
|
|
4116
4149
|
// refreshes the DOM generation between every potentially mutating select.
|
|
4117
4150
|
await observe(sessionId, "compact");
|
|
4151
|
+
const publicSelectedOption = session.compactV2Active
|
|
4152
|
+
? safeDescriptionV2(selectedOption)
|
|
4153
|
+
: selectedOption;
|
|
4118
4154
|
fields.push({
|
|
4119
|
-
|
|
4120
|
-
option,
|
|
4155
|
+
...publicSelection,
|
|
4121
4156
|
status: "selected",
|
|
4122
|
-
selected_option:
|
|
4157
|
+
...(publicSelectedOption === undefined ? {} : { selected_option: publicSelectedOption }),
|
|
4123
4158
|
});
|
|
4124
4159
|
}
|
|
4125
4160
|
catch (err) {
|
|
4126
|
-
if (
|
|
4161
|
+
if (session.compactV2Active) {
|
|
4127
4162
|
fields.push({
|
|
4128
|
-
|
|
4129
|
-
|
|
4163
|
+
...publicSelection,
|
|
4164
|
+
status: "failed",
|
|
4165
|
+
reason: compactV2SelectionFailureReason(err),
|
|
4166
|
+
});
|
|
4167
|
+
}
|
|
4168
|
+
else if (err instanceof TargetStaleError) {
|
|
4169
|
+
fields.push({
|
|
4170
|
+
...publicSelection,
|
|
4130
4171
|
status: "failed",
|
|
4131
4172
|
reason: err.message,
|
|
4132
4173
|
repair: err.result,
|
|
@@ -4134,12 +4175,14 @@ export async function formSelectMany(sessionId, selections) {
|
|
|
4134
4175
|
}
|
|
4135
4176
|
else {
|
|
4136
4177
|
fields.push({
|
|
4137
|
-
|
|
4138
|
-
option,
|
|
4178
|
+
...publicSelection,
|
|
4139
4179
|
status: "failed",
|
|
4140
4180
|
reason: err instanceof Error ? err.message : String(err),
|
|
4141
4181
|
});
|
|
4142
4182
|
}
|
|
4183
|
+
if (session.compactV2Active && index + 1 < selectionEntries.length) {
|
|
4184
|
+
await observe(sessionId, "compact");
|
|
4185
|
+
}
|
|
4143
4186
|
}
|
|
4144
4187
|
}
|
|
4145
4188
|
return {
|
|
@@ -4148,6 +4191,24 @@ export async function formSelectMany(sessionId, selections) {
|
|
|
4148
4191
|
observation: await observe(sessionId, "compact"),
|
|
4149
4192
|
};
|
|
4150
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
|
+
}
|
|
4151
4212
|
// PR3 privacy: in the operator model the host fills the USER's real email into
|
|
4152
4213
|
// signup forms (no Squire alias anymore). The recipe trace must NOT persist that
|
|
4153
4214
|
// literal address — it would land in operator recipes and any skill synthesized
|
|
@@ -4225,6 +4286,113 @@ const REPLAY_VERIFIED_HOLE = /^(?:address|contact)(?:\.|$)|^quantity$/;
|
|
|
4225
4286
|
function looksLikeEmailValue(v) {
|
|
4226
4287
|
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.trim());
|
|
4227
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
|
+
}
|
|
4228
4396
|
// Exported for unit tests.
|
|
4229
4397
|
export function redactEmailForTrace(value) {
|
|
4230
4398
|
return looksLikeEmailValue(value) ? EMAIL_SLOT_TEMPLATE : value;
|
|
@@ -4328,7 +4496,9 @@ export function recipeTargetFor(el, inventory = [], userEmail = null) {
|
|
|
4328
4496
|
: {}),
|
|
4329
4497
|
...(nearText !== null ? { near_text_hint: scrub(nearText) } : {}),
|
|
4330
4498
|
...(hrefHint !== null && !isSingleUseUrl(el.href ?? "") ? { href_hint: scrub(hrefHint) } : {}),
|
|
4331
|
-
...(el.selector.length > 0
|
|
4499
|
+
...(el.selector.length > 0 && !el.selector.startsWith("@c:")
|
|
4500
|
+
? { css: scrub(el.selector) }
|
|
4501
|
+
: {}),
|
|
4332
4502
|
...(el.visibleText !== null && el.visibleText.length > 0
|
|
4333
4503
|
? { visible_text: scrub(el.visibleText) }
|
|
4334
4504
|
: {}),
|
|
@@ -4340,6 +4510,10 @@ export function recipeTargetFor(el, inventory = [], userEmail = null) {
|
|
|
4340
4510
|
};
|
|
4341
4511
|
}
|
|
4342
4512
|
function recordTrace(session, action, el, sensitiveSource) {
|
|
4513
|
+
const recordedAction = compactV2RecordedAction(session, action);
|
|
4514
|
+
if (recordedAction === null)
|
|
4515
|
+
return;
|
|
4516
|
+
action = recordedAction;
|
|
4343
4517
|
// Never freeze a single-use link (email-verify / magic / reset token) into
|
|
4344
4518
|
// the recipe — it's dead on the next replay. The host agent re-plans the
|
|
4345
4519
|
// verification step live (operate_act { kind: "await_verification" } fetches a FRESH link)
|
|
@@ -4518,16 +4692,29 @@ export function captureObserved(action, el) {
|
|
|
4518
4692
|
}
|
|
4519
4693
|
// Accumulate one MEDIUM round: inventory + action + url, no html/screenshot.
|
|
4520
4694
|
function recordCaptureRound(session, action, el, urlAtObservation) {
|
|
4521
|
-
const
|
|
4695
|
+
const recordedAction = compactV2RecordedAction(session, action);
|
|
4696
|
+
if (recordedAction === null)
|
|
4697
|
+
return;
|
|
4698
|
+
const observed = captureObserved(recordedAction, el);
|
|
4522
4699
|
if (observed === null)
|
|
4523
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
|
+
}
|
|
4524
4706
|
session.captureRounds.push({
|
|
4525
4707
|
service: captureService(session),
|
|
4526
4708
|
round: session.captureRounds.length,
|
|
4527
|
-
oauth:
|
|
4709
|
+
oauth: recordedAction.kind === "oauth_click" || recordedAction.kind === "oauth_login",
|
|
4528
4710
|
// The URL the inventory + action belong to (pre-action), NOT the post-
|
|
4529
4711
|
// navigation URL — see urlBeforeAction in act().
|
|
4530
|
-
state: {
|
|
4712
|
+
state: {
|
|
4713
|
+
url: stateUrl,
|
|
4714
|
+
title: "",
|
|
4715
|
+
html: "",
|
|
4716
|
+
screenshot: "",
|
|
4717
|
+
},
|
|
4531
4718
|
inventory: session.lastElements,
|
|
4532
4719
|
observed,
|
|
4533
4720
|
});
|
|
@@ -4536,20 +4723,35 @@ function recordCaptureRound(session, action, el, urlAtObservation) {
|
|
|
4536
4723
|
// step is synthesized from the page where the credential is shown.
|
|
4537
4724
|
async function recordExtractRound(session) {
|
|
4538
4725
|
let html = "";
|
|
4539
|
-
|
|
4540
|
-
|
|
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
|
+
}
|
|
4541
4733
|
}
|
|
4542
|
-
|
|
4543
|
-
|
|
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;
|
|
4544
4740
|
}
|
|
4545
4741
|
session.captureRounds.push({
|
|
4546
4742
|
service: captureService(session),
|
|
4547
4743
|
round: session.captureRounds.length,
|
|
4548
4744
|
oauth: false,
|
|
4549
|
-
state: {
|
|
4745
|
+
state: {
|
|
4746
|
+
url: stateUrl,
|
|
4747
|
+
title: "",
|
|
4748
|
+
html,
|
|
4749
|
+
screenshot: "",
|
|
4750
|
+
},
|
|
4550
4751
|
inventory: session.lastElements,
|
|
4551
4752
|
observed: { kind: "extract", reason: "extract the credential shown on the page" },
|
|
4552
4753
|
});
|
|
4754
|
+
return true;
|
|
4553
4755
|
}
|
|
4554
4756
|
// Record the extract round from the live page, then write the accumulated medium
|
|
4555
4757
|
// rounds through the real capture path (integrity chain) and synthesize a skill.
|
|
@@ -4565,12 +4767,18 @@ export async function captureAndPromoteSession(sessionId) {
|
|
|
4565
4767
|
if (session.usedLocatorFallback) {
|
|
4566
4768
|
return { kind: "skipped", reason: "locator_fallback_unrepresentable" };
|
|
4567
4769
|
}
|
|
4770
|
+
if (session.recipeRejectionReason !== null) {
|
|
4771
|
+
return { kind: "skipped", reason: session.recipeRejectionReason };
|
|
4772
|
+
}
|
|
4568
4773
|
const dir = resolveCaptureDir();
|
|
4569
4774
|
if (dir === null)
|
|
4570
4775
|
return { kind: "skipped", reason: "capture_disabled" };
|
|
4571
4776
|
if (!session.captureRounds.some((r) => r.observed.kind === "extract")) {
|
|
4572
4777
|
await recordExtractRound(session);
|
|
4573
4778
|
}
|
|
4779
|
+
if (session.recipeRejectionReason !== null) {
|
|
4780
|
+
return { kind: "skipped", reason: session.recipeRejectionReason };
|
|
4781
|
+
}
|
|
4574
4782
|
const hasExtract = session.captureRounds.some((r) => r.observed.kind === "extract");
|
|
4575
4783
|
if (!hasExtract || session.captureRounds.length < 2) {
|
|
4576
4784
|
return { kind: "skipped", reason: "too_few_rounds" };
|
|
@@ -4617,18 +4825,63 @@ export function emitProvisionMeasurement(sessionId, outcome) {
|
|
|
4617
4825
|
now: Date.now(),
|
|
4618
4826
|
turns: session.actionTrace.length,
|
|
4619
4827
|
});
|
|
4620
|
-
|
|
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`);
|
|
4621
4832
|
return m;
|
|
4622
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
|
+
}
|
|
4623
4879
|
async function settleAfterStateChange(browser) {
|
|
4624
|
-
|
|
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.
|
|
4625
4884
|
await browser.waitForInteractiveDom(1, 2_000).catch(() => undefined);
|
|
4626
|
-
for (let i = 0; i < 4; i += 1) {
|
|
4627
|
-
const text = await browser.extractVisibleText().catch(() => "");
|
|
4628
|
-
if (text.replace(/\s+/g, " ").trim().length > 0)
|
|
4629
|
-
return;
|
|
4630
|
-
await settle(300);
|
|
4631
|
-
}
|
|
4632
4885
|
}
|
|
4633
4886
|
// ── operator-recipe: remember a successful run, verify a postcondition ──
|
|
4634
4887
|
// Persist the session's action trace as a keyed, replayable operator-recipe.
|
|
@@ -4888,16 +5141,42 @@ async function rememberCheckoutLeg(session, verb, trace) {
|
|
|
4888
5141
|
// Read a single page snapshot for postcondition checking. Field VALUES are
|
|
4889
5142
|
// reduced to lengths here so a token/secret success-signal can't leak.
|
|
4890
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;
|
|
4891
5165
|
const obs = await observeSession(session);
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
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
|
+
};
|
|
4901
5180
|
}
|
|
4902
5181
|
// Verify a recipe's postcondition against the live session — the anti-false-
|
|
4903
5182
|
// green gate for replay. execute_capability checks the current end-state;
|
|
@@ -4911,6 +5190,7 @@ export async function verifyPostcondition(sessionId, postcondition) {
|
|
|
4911
5190
|
if (host !== null && !session.allowedHosts.some((e) => e.host === host)) {
|
|
4912
5191
|
session.allowedHosts.push({ host, source: "mid_session" });
|
|
4913
5192
|
}
|
|
5193
|
+
invalidateCompactV2Snapshot(session);
|
|
4914
5194
|
await session.browser.goto(postcondition.probe_url);
|
|
4915
5195
|
await settle(1500);
|
|
4916
5196
|
}
|
|
@@ -4924,7 +5204,7 @@ export async function verifyPostcondition(sessionId, postcondition) {
|
|
|
4924
5204
|
return result;
|
|
4925
5205
|
}
|
|
4926
5206
|
export async function verifySavedRecipePostcondition(sessionId, recipe) {
|
|
4927
|
-
const session =
|
|
5207
|
+
const session = sessionForCall(sessionId);
|
|
4928
5208
|
if (session === undefined)
|
|
4929
5209
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
4930
5210
|
const state = session.replayState;
|
|
@@ -4941,7 +5221,7 @@ export async function verifySavedRecipePostcondition(sessionId, recipe) {
|
|
|
4941
5221
|
return await verifyPostcondition(sessionId, postcondition);
|
|
4942
5222
|
}
|
|
4943
5223
|
export async function verifyActiveRecipePostcondition(sessionId, recipeName) {
|
|
4944
|
-
const session =
|
|
5224
|
+
const session = sessionForCall(sessionId);
|
|
4945
5225
|
if (session === undefined)
|
|
4946
5226
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
4947
5227
|
const state = session.replayState;
|
|
@@ -5120,7 +5400,7 @@ function replayTargetWithinRecipeDomain(url, recipeDomain) {
|
|
|
5120
5400
|
return isSameRecipeDomain(url, recipeDomain);
|
|
5121
5401
|
}
|
|
5122
5402
|
function markReplayDomainLockViolation(session, host, recipeDomain) {
|
|
5123
|
-
rejectRecipeRecording(session,
|
|
5403
|
+
rejectRecipeRecording(session, "replay_domain_lock_violation");
|
|
5124
5404
|
audit(session.id, "replay_domain_lock_violation", { host, recipe_domain: recipeDomain });
|
|
5125
5405
|
}
|
|
5126
5406
|
function verifyReplayFieldInElements(session, expected, elements, allowCommittedSelect = false) {
|
|
@@ -5140,18 +5420,18 @@ function verifyReplayFieldInElements(session, expected, elements, allowCommitted
|
|
|
5140
5420
|
]);
|
|
5141
5421
|
if (guard.ok) {
|
|
5142
5422
|
if (expected.kind === "select") {
|
|
5143
|
-
session
|
|
5423
|
+
clearCommittedSelectValue(session, resolution.element.selector);
|
|
5144
5424
|
}
|
|
5145
5425
|
return { ok: true };
|
|
5146
5426
|
}
|
|
5147
5427
|
if (allowCommittedSelect &&
|
|
5148
5428
|
expected.kind === "select" &&
|
|
5149
|
-
session.committedSelectValues.get(resolution.element.selector) === expected.expected) {
|
|
5150
|
-
session
|
|
5429
|
+
session.committedSelectValues.get(compactV2CommittedSelectKey(session, resolution.element.selector)) === compactV2CommittedSelectValue(session, expected.expected)) {
|
|
5430
|
+
clearCommittedSelectValue(session, resolution.element.selector);
|
|
5151
5431
|
return { ok: true };
|
|
5152
5432
|
}
|
|
5153
5433
|
if (expected.kind === "select") {
|
|
5154
|
-
session
|
|
5434
|
+
clearCommittedSelectValue(session, resolution.element.selector);
|
|
5155
5435
|
}
|
|
5156
5436
|
return { ok: false, reason: guard.reason };
|
|
5157
5437
|
}
|
|
@@ -5165,7 +5445,7 @@ async function verifyReplayField(session, expected, allowCommittedSelect = false
|
|
|
5165
5445
|
if (target === null)
|
|
5166
5446
|
return { ok: false, reason: "field_missing" };
|
|
5167
5447
|
const fresh = await session.browser.extractInteractiveElements();
|
|
5168
|
-
session
|
|
5448
|
+
retainSessionElements(session, fresh);
|
|
5169
5449
|
return verifyReplayFieldInElements(session, expected, fresh, allowCommittedSelect);
|
|
5170
5450
|
}
|
|
5171
5451
|
async function verifyReplayFieldWithElements(session, expected, elements, allowCommittedSelect = false) {
|
|
@@ -5227,7 +5507,7 @@ async function attestRecordedFieldsBeforeTransition(session, action) {
|
|
|
5227
5507
|
if (fields.length === 0)
|
|
5228
5508
|
return fields;
|
|
5229
5509
|
const fresh = await session.browser.extractInteractiveElements();
|
|
5230
|
-
session
|
|
5510
|
+
retainSessionElements(session, fresh);
|
|
5231
5511
|
for (const expected of fields) {
|
|
5232
5512
|
const guard = await verifyReplayFieldWithElements(session, expected, fresh);
|
|
5233
5513
|
if (!guard.ok) {
|
|
@@ -5242,7 +5522,7 @@ async function verifyRecordedFieldsAfterTransition(session, action, fields) {
|
|
|
5242
5522
|
return;
|
|
5243
5523
|
}
|
|
5244
5524
|
const fresh = await session.browser.extractInteractiveElements();
|
|
5245
|
-
session
|
|
5525
|
+
retainSessionElements(session, fresh);
|
|
5246
5526
|
for (const expected of fields) {
|
|
5247
5527
|
if (!(await isReplayFieldMounted(session, expected, fresh))) {
|
|
5248
5528
|
rejectRecipeRecording(session, `checkout transition could not be attested (${expected.hole}: field_missing)`);
|
|
@@ -5261,7 +5541,7 @@ async function verifyRecordedFieldsAfterTransition(session, action, fields) {
|
|
|
5261
5541
|
* repairs that one step, calling again with `fromIndex=next_index` continues.
|
|
5262
5542
|
*/
|
|
5263
5543
|
export async function replayOperatorRecipe(sessionId, recipe, bindings, fromIndex = 0, options = {}) {
|
|
5264
|
-
const session =
|
|
5544
|
+
const session = sessionForCall(sessionId);
|
|
5265
5545
|
if (session === undefined)
|
|
5266
5546
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
5267
5547
|
const recipeHash = replayDigest(recipe);
|
|
@@ -5360,12 +5640,16 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
|
|
|
5360
5640
|
const recipeDomain = recipe.domain;
|
|
5361
5641
|
const domainLockViolation = async (stepIndex, host) => {
|
|
5362
5642
|
markReplayDomainLockViolation(session, host, recipeDomain);
|
|
5643
|
+
const publicHost = session.compactV2Active ? compactV2AuditHost(host) : host;
|
|
5644
|
+
const publicRecipeDomain = session.compactV2Active
|
|
5645
|
+
? compactV2AuditHost(recipeDomain)
|
|
5646
|
+
: recipeDomain;
|
|
5363
5647
|
return {
|
|
5364
5648
|
status: "domain_lock_violation",
|
|
5365
5649
|
observation: await observe(sessionId),
|
|
5366
5650
|
step_index: stepIndex,
|
|
5367
|
-
host,
|
|
5368
|
-
recipe_domain:
|
|
5651
|
+
host: publicHost,
|
|
5652
|
+
recipe_domain: publicRecipeDomain,
|
|
5369
5653
|
};
|
|
5370
5654
|
};
|
|
5371
5655
|
for (let i = fromIndex; i < recipe.trace.length; i += 1) {
|
|
@@ -5467,7 +5751,7 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
|
|
|
5467
5751
|
// Structural pre-check: resolve against the live inventory before every
|
|
5468
5752
|
// deterministic act. This is especially load-bearing on money paths.
|
|
5469
5753
|
const fresh = await session.browser.extractInteractiveElements();
|
|
5470
|
-
session
|
|
5754
|
+
retainSessionElements(session, fresh);
|
|
5471
5755
|
const expectedForStep = state.expectedFields.get(i);
|
|
5472
5756
|
const resolution = expectedForStep === undefined
|
|
5473
5757
|
? resolveRecipeTarget(fresh, target)
|
|
@@ -5518,12 +5802,19 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
|
|
|
5518
5802
|
action = { kind: "js_click", target: ref };
|
|
5519
5803
|
}
|
|
5520
5804
|
else {
|
|
5521
|
-
|
|
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
|
+
};
|
|
5522
5813
|
}
|
|
5523
5814
|
}
|
|
5524
5815
|
try {
|
|
5525
5816
|
await options.beforeAction?.({ step_index: i, action });
|
|
5526
|
-
await
|
|
5817
|
+
await actInternally(sessionId, action, "none");
|
|
5527
5818
|
replayed += 1;
|
|
5528
5819
|
const expected = state.expectedFields.get(i);
|
|
5529
5820
|
if (expected !== undefined) {
|
|
@@ -5536,7 +5827,11 @@ export async function replayOperatorRecipe(sessionId, recipe, bindings, fromInde
|
|
|
5536
5827
|
catch (error) {
|
|
5537
5828
|
if (error instanceof ManualCardEntryBlockedError)
|
|
5538
5829
|
throw error;
|
|
5539
|
-
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));
|
|
5540
5835
|
}
|
|
5541
5836
|
}
|
|
5542
5837
|
return {
|
|
@@ -5615,10 +5910,11 @@ export function sanitizeExtractedCredentials(credentials, url, haystack = Object
|
|
|
5615
5910
|
const k = normLabelKey(key);
|
|
5616
5911
|
if (k === "refcode" || k === "referral_code")
|
|
5617
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.
|
|
5618
5916
|
if (isCredentialNoise(value))
|
|
5619
5917
|
continue;
|
|
5620
|
-
if ((k === "key" || k === "api_key") && !looksLikeCredentialValue(value))
|
|
5621
|
-
continue;
|
|
5622
5918
|
if (host === "api.together.ai" && /^key_[A-Za-z0-9]{16,}$/i.test(value.trim()))
|
|
5623
5919
|
continue;
|
|
5624
5920
|
normalized[key] = value;
|
|
@@ -5652,6 +5948,7 @@ export async function extractCredentials(sessionId) {
|
|
|
5652
5948
|
if (session === undefined)
|
|
5653
5949
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
5654
5950
|
const { browser } = session;
|
|
5951
|
+
invalidateCompactV2Snapshot(session);
|
|
5655
5952
|
// The masked-display trap: click reveal/show toggles before reading.
|
|
5656
5953
|
await browser.revealMaskedCredentials();
|
|
5657
5954
|
const labeled = await browser.extractLabeledCredentialCandidates();
|
|
@@ -5706,16 +6003,13 @@ export async function extractCredentials(sessionId) {
|
|
|
5706
6003
|
: { kind: "full", value: key };
|
|
5707
6004
|
state = accumulateCandidate(state, cls);
|
|
5708
6005
|
}
|
|
5709
|
-
// Named credentials for multi-cred services
|
|
5710
|
-
//
|
|
5711
|
-
//
|
|
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.
|
|
5712
6010
|
const named = {};
|
|
5713
6011
|
for (const c of labeled) {
|
|
5714
|
-
if (c.label === null
|
|
5715
|
-
continue;
|
|
5716
|
-
if (isCredentialNoise(c.value))
|
|
5717
|
-
continue;
|
|
5718
|
-
if (looksLikeCodeIdentifier(c.value))
|
|
6012
|
+
if (c.label === null)
|
|
5719
6013
|
continue;
|
|
5720
6014
|
const k = normLabelKey(c.label);
|
|
5721
6015
|
if (k.length > 0 && !(k in named))
|
|
@@ -5766,28 +6060,12 @@ export async function extractCredentials(sessionId) {
|
|
|
5766
6060
|
}
|
|
5767
6061
|
const sanitized = sanitizeExtractedCredentials(credentials, browser.currentUrl(), haystack);
|
|
5768
6062
|
const found = Object.keys(sanitized).length > 0;
|
|
5769
|
-
|
|
5770
|
-
// done: if the page HAD labeled candidates but none survived as a real
|
|
5771
|
-
// credential, they were page noise (a date/email/greeting) or a still-masked
|
|
5772
|
-
// display — i.e. this isn't the keys page or the key needs revealing. Tell the
|
|
5773
|
-
// agent that so it navigates/reveals and extracts again, rather than storing junk.
|
|
5774
|
-
const notLegit = !found && labeled.length > 0
|
|
5775
|
-
? "no_legit_credential: the page had candidate values but none looked like a " +
|
|
5776
|
-
"real key (they were page text — a date/email/label — or a still-masked " +
|
|
5777
|
-
"display). You are likely NOT on the API-keys page, or the key is masked. " +
|
|
5778
|
-
"Navigate to the keys/settings page (or click reveal/show/copy), then extract again."
|
|
5779
|
-
: null;
|
|
5780
|
-
audit(sessionId, "extract", {
|
|
5781
|
-
found,
|
|
5782
|
-
candidate_count: labeled.length,
|
|
5783
|
-
not_legit: notLegit !== null,
|
|
5784
|
-
});
|
|
6063
|
+
audit(sessionId, "extract", { found, candidate_count: labeled.length });
|
|
5785
6064
|
return {
|
|
5786
6065
|
session_id: sessionId,
|
|
5787
6066
|
url: browser.currentUrl(),
|
|
5788
6067
|
credentials: sanitized,
|
|
5789
6068
|
candidate_count: labeled.length,
|
|
5790
|
-
...(notLegit !== null ? { blocked_reason: notLegit } : {}),
|
|
5791
6069
|
};
|
|
5792
6070
|
}
|
|
5793
6071
|
// A TwoCaptchaVaultProxy backed by the MCP api-client: every 2Captcha call is
|
|
@@ -5904,6 +6182,7 @@ export async function captchaGate(sessionId) {
|
|
|
5904
6182
|
const session = sessionForCall(sessionId);
|
|
5905
6183
|
if (session === undefined)
|
|
5906
6184
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
6185
|
+
invalidateCompactV2Snapshot(session);
|
|
5907
6186
|
const det = await session.browser.detectCaptchaVariant();
|
|
5908
6187
|
const found = det.variant !== "unknown" || det.challengeRendered;
|
|
5909
6188
|
if (!found) {
|
|
@@ -5996,15 +6275,10 @@ export async function captchaGate(sessionId) {
|
|
|
5996
6275
|
// number elsewhere in the mail doesn't win; falls back to the first standalone
|
|
5997
6276
|
// 4-8 digit run. The link uses the bot's pickVerificationLink heuristic.
|
|
5998
6277
|
const OTP_ANY_RE = /(?:^|[^0-9])(\d{4,8})(?:[^0-9]|$)/g;
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6003
|
-
let code = null;
|
|
6004
|
-
if (kw !== null) {
|
|
6005
|
-
code = kw[1] ?? kw[2] ?? null;
|
|
6006
|
-
}
|
|
6007
|
-
else {
|
|
6278
|
+
export function parseVerification(text, links, expectedDomains) {
|
|
6279
|
+
const link = pickVerificationLink([...links], expectedDomains);
|
|
6280
|
+
let code = findOtpCredential(text);
|
|
6281
|
+
if (code === null) {
|
|
6008
6282
|
const m = OTP_ANY_RE.exec(text);
|
|
6009
6283
|
code = m !== null ? (m[1] ?? null) : null;
|
|
6010
6284
|
}
|
|
@@ -6019,6 +6293,17 @@ export function extractSenderEmail(text) {
|
|
|
6019
6293
|
const m = /<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/.exec(text);
|
|
6020
6294
|
return m !== null ? m[1].toLowerCase() : null;
|
|
6021
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
|
+
}
|
|
6022
6307
|
// Pure: assemble the verification result. When neither a code nor a link was
|
|
6023
6308
|
// found, the thick session is still live, so this is a RESUMABLE hand-back
|
|
6024
6309
|
// (Flow A) — the host asks the user for the code and types it — not a give-up.
|
|
@@ -6039,21 +6324,19 @@ export function buildVerificationResult(sessionId, code, link, sourceFrom = null
|
|
|
6039
6324
|
};
|
|
6040
6325
|
return { session_id: sessionId, found, code, link, needs_user, ...src };
|
|
6041
6326
|
}
|
|
6042
|
-
//
|
|
6043
|
-
//
|
|
6044
|
-
//
|
|
6045
|
-
//
|
|
6046
|
-
// "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".
|
|
6047
6331
|
// Exported for unit tests.
|
|
6048
6332
|
export function buildConsentRefusal(sessionId) {
|
|
6049
6333
|
const needs_user = {
|
|
6050
6334
|
wall: "verification_code",
|
|
6051
|
-
message: "Inbox reading is
|
|
6052
|
-
"the user
|
|
6053
|
-
'
|
|
6054
|
-
"
|
|
6055
|
-
"
|
|
6056
|
-
"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.",
|
|
6057
6340
|
resume: "code",
|
|
6058
6341
|
};
|
|
6059
6342
|
return { session_id: sessionId, found: false, code: null, link: null, needs_user };
|
|
@@ -6069,72 +6352,112 @@ export function buildVerificationSearchQuery(sender) {
|
|
|
6069
6352
|
return [
|
|
6070
6353
|
sender !== undefined && sender.length > 0 ? `from:${sender}` : "",
|
|
6071
6354
|
"newer_than:1d",
|
|
6072
|
-
'(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)',
|
|
6073
6356
|
]
|
|
6074
6357
|
.filter((s) => s.length > 0)
|
|
6075
6358
|
.join(" ");
|
|
6076
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
|
+
}
|
|
6077
6410
|
export async function awaitVerification(sessionId, opts = {}) {
|
|
6078
6411
|
const session = sessionForCall(sessionId);
|
|
6079
6412
|
if (session === undefined)
|
|
6080
6413
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
6081
|
-
|
|
6082
|
-
//
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
}
|
|
6089
|
-
//
|
|
6090
|
-
//
|
|
6091
|
-
// behavior read mail.google.com unconditionally, silently breaking the
|
|
6092
|
-
// 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).
|
|
6093
6424
|
if (!session.consentInboxRead) {
|
|
6094
6425
|
audit(sessionId, "await_verification", { refused: "no_inbox_consent" });
|
|
6095
6426
|
return buildConsentRefusal(sessionId);
|
|
6096
6427
|
}
|
|
6097
|
-
|
|
6098
|
-
const
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
|
|
6129
|
-
|
|
6130
|
-
const openedLinks = hrefsOf(await browser.extractInteractiveElements());
|
|
6131
|
-
sourceFrom = extractSenderEmail(openedText);
|
|
6132
|
-
({ code, link } = parseVerification(openedText, [...openedLinks, ...listLinks]));
|
|
6133
|
-
}
|
|
6134
|
-
else {
|
|
6135
|
-
({ code, link } = parseVerification(listText, listLinks));
|
|
6136
|
-
}
|
|
6137
|
-
}
|
|
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;
|
|
6138
6461
|
const found = code !== null || link !== null;
|
|
6139
6462
|
audit(sessionId, "await_verification", {
|
|
6140
6463
|
sender: opts.sender ?? null,
|
|
@@ -6160,163 +6483,4 @@ export async function awaitVerification(sessionId, opts = {}) {
|
|
|
6160
6483
|
}
|
|
6161
6484
|
return buildVerificationResult(sessionId, code, link, sourceFrom);
|
|
6162
6485
|
}
|
|
6163
|
-
function profileRequiresDestroy(session) {
|
|
6164
|
-
return (session.activePayment !== null ||
|
|
6165
|
-
session.paymentFieldSealActive ||
|
|
6166
|
-
session.pendingThreeDs !== null);
|
|
6167
|
-
}
|
|
6168
|
-
function pendingThreeDsAuditStatus(resolution) {
|
|
6169
|
-
if (resolution === "succeeded")
|
|
6170
|
-
return "payment_submitted";
|
|
6171
|
-
if (resolution === "failed")
|
|
6172
|
-
return "payment_declined";
|
|
6173
|
-
return "payment_3ds_unresolved";
|
|
6174
|
-
}
|
|
6175
|
-
async function auditPendingThreeDsForSessionClose(session) {
|
|
6176
|
-
const pending = session.pendingThreeDs;
|
|
6177
|
-
if (pending === null)
|
|
6178
|
-
return;
|
|
6179
|
-
if (session.api === undefined) {
|
|
6180
|
-
throw new Error("operate_finish refused: pending 3-D Secure outcome cannot be audited without an active API session");
|
|
6181
|
-
}
|
|
6182
|
-
const resolution = await session.browser.waitForThreeDsResolution(0);
|
|
6183
|
-
const recordAudit = async () => {
|
|
6184
|
-
await session.api.auditPayment({
|
|
6185
|
-
...pending.checkout,
|
|
6186
|
-
last4: pending.last4,
|
|
6187
|
-
status: pendingThreeDsAuditStatus(resolution),
|
|
6188
|
-
approval_id: pending.approval_id,
|
|
6189
|
-
...(pending.mandate_id !== undefined ? { mandate_id: pending.mandate_id } : {}),
|
|
6190
|
-
});
|
|
6191
|
-
};
|
|
6192
|
-
const handoff = session.paymentDispatchHandoff;
|
|
6193
|
-
if (handoff?.state === pending) {
|
|
6194
|
-
handoff.auditPromise ??= recordAudit();
|
|
6195
|
-
await handoff.auditPromise;
|
|
6196
|
-
return;
|
|
6197
|
-
}
|
|
6198
|
-
await recordAudit();
|
|
6199
|
-
}
|
|
6200
|
-
async function auditPendingThreeDsForSessionCloseBounded(session) {
|
|
6201
|
-
const timeoutMs = positiveTimeout("TRUSTY_SQUIRE_OPERATOR_PENDING_3DS_FINALIZE_TIMEOUT_MS", DEFAULT_PENDING_THREE_DS_FINALIZE_TIMEOUT_MS);
|
|
6202
|
-
await withTerminalTimeout(auditPendingThreeDsForSessionClose(session), timeoutMs, `pending 3-D Secure finalization exceeded ${timeoutMs}ms`);
|
|
6203
|
-
}
|
|
6204
|
-
async function closeFinishingProvisionSession(session, destroyProfile) {
|
|
6205
|
-
const sessionId = session.id;
|
|
6206
|
-
await auditPendingThreeDsForSessionCloseBounded(session);
|
|
6207
|
-
const url = session.browser.currentUrl();
|
|
6208
|
-
audit(sessionId, "finish", { url });
|
|
6209
|
-
session.activePayment = null;
|
|
6210
|
-
session.paymentFieldSealActive = false;
|
|
6211
|
-
session.pendingThreeDs = null;
|
|
6212
|
-
sessions.delete(sessionId);
|
|
6213
|
-
stopSessionWatchdog(session);
|
|
6214
|
-
await releaseWarmBrowserPage(session.browser, !destroyProfile);
|
|
6215
|
-
disposeSessionWatchdog(session);
|
|
6216
|
-
return { session_id: sessionId, url, closed: true };
|
|
6217
|
-
}
|
|
6218
|
-
export async function finishProvisionSessionWithPreparation(sessionId, prepare) {
|
|
6219
|
-
const session = sessionForCall(sessionId);
|
|
6220
|
-
if (session === undefined)
|
|
6221
|
-
throw new Error(`unknown provision session ${sessionId}`);
|
|
6222
|
-
if (session.closing)
|
|
6223
|
-
throw new Error(`provision session ${sessionId} is already closing`);
|
|
6224
|
-
const owner = {
|
|
6225
|
-
forced: false,
|
|
6226
|
-
forcePromise: null,
|
|
6227
|
-
routinePromise: null,
|
|
6228
|
-
};
|
|
6229
|
-
session.terminalTeardownOwner = owner;
|
|
6230
|
-
session.closing = true;
|
|
6231
|
-
stopSessionWatchdog(session);
|
|
6232
|
-
const timeoutMs = positiveTimeout("TRUSTY_SQUIRE_OPERATOR_TERMINAL_TRANSITION_TIMEOUT_MS", DEFAULT_SESSION_TERMINAL_TRANSITION_TIMEOUT_MS);
|
|
6233
|
-
let timer;
|
|
6234
|
-
const transition = (async () => {
|
|
6235
|
-
const drained = await waitForSessionCallsToDrain(session);
|
|
6236
|
-
if (!drained) {
|
|
6237
|
-
await forceTerminateProvisionSession(session, "finish_forced_terminate", {
|
|
6238
|
-
reason: "call_drain_timeout",
|
|
6239
|
-
});
|
|
6240
|
-
throw new Error(`provision session ${sessionId} call drain timed out; browser terminated`);
|
|
6241
|
-
}
|
|
6242
|
-
const prepared = await prepare();
|
|
6243
|
-
if (owner.forced || sessions.get(sessionId) !== session) {
|
|
6244
|
-
throw new Error(`provision session ${sessionId} terminal transition was forced`);
|
|
6245
|
-
}
|
|
6246
|
-
const destroyProfile = profileRequiresDestroy(session);
|
|
6247
|
-
try {
|
|
6248
|
-
const finish = await closeFinishingProvisionSession(session, destroyProfile);
|
|
6249
|
-
return { finish, prepared };
|
|
6250
|
-
}
|
|
6251
|
-
catch (error) {
|
|
6252
|
-
await forceTerminateProvisionSession(session, "finish_forced_terminate", { reason: "terminal_close_failed" }, false);
|
|
6253
|
-
throw error;
|
|
6254
|
-
}
|
|
6255
|
-
})();
|
|
6256
|
-
try {
|
|
6257
|
-
return await Promise.race([
|
|
6258
|
-
transition,
|
|
6259
|
-
new Promise((_, reject) => {
|
|
6260
|
-
timer = setTimeout(() => {
|
|
6261
|
-
void forceTerminateProvisionSession(session, "finish_forced_terminate", {
|
|
6262
|
-
reason: "terminal_transition_timeout",
|
|
6263
|
-
timeout_ms: timeoutMs,
|
|
6264
|
-
}).then(() => reject(new Error(`provision session ${sessionId} terminal transition timed out; browser terminated`)), reject);
|
|
6265
|
-
}, timeoutMs);
|
|
6266
|
-
timer.unref();
|
|
6267
|
-
}),
|
|
6268
|
-
]);
|
|
6269
|
-
}
|
|
6270
|
-
catch (error) {
|
|
6271
|
-
if (!owner.forced && sessions.get(sessionId) === session) {
|
|
6272
|
-
session.closing = false;
|
|
6273
|
-
session.terminalTeardownOwner = null;
|
|
6274
|
-
startSessionWatchdog(session);
|
|
6275
|
-
}
|
|
6276
|
-
throw error;
|
|
6277
|
-
}
|
|
6278
|
-
finally {
|
|
6279
|
-
if (timer !== undefined)
|
|
6280
|
-
clearTimeout(timer);
|
|
6281
|
-
}
|
|
6282
|
-
}
|
|
6283
|
-
export async function finishProvisionSession(sessionId) {
|
|
6284
|
-
return (await finishProvisionSessionWithPreparation(sessionId, async () => undefined)).finish;
|
|
6285
|
-
}
|
|
6286
|
-
// Test/teardown helper — close every live session (used by the dev shim on exit).
|
|
6287
|
-
export async function closeAllProvisionSessions() {
|
|
6288
|
-
shutdownGeneration += 1;
|
|
6289
|
-
const waiters = [...capacityWaiters];
|
|
6290
|
-
for (const waiter of waiters) {
|
|
6291
|
-
waiter.cancelRequested = true;
|
|
6292
|
-
waiter.abortController.abort();
|
|
6293
|
-
waiter.wake();
|
|
6294
|
-
}
|
|
6295
|
-
await Promise.all(waiters.map((waiter) => waiter.settled));
|
|
6296
|
-
for (const pending of [...startingBrowsers]) {
|
|
6297
|
-
await cancelStartingBrowser(pending).catch(() => undefined);
|
|
6298
|
-
}
|
|
6299
|
-
const closingSessions = [...sessions.values()];
|
|
6300
|
-
for (const session of closingSessions) {
|
|
6301
|
-
session.closing = true;
|
|
6302
|
-
stopSessionWatchdog(session);
|
|
6303
|
-
}
|
|
6304
|
-
let closeError;
|
|
6305
|
-
for (const session of closingSessions) {
|
|
6306
|
-
const drained = await waitForSessionCallsToDrain(session);
|
|
6307
|
-
const error = await forceTerminateProvisionSession(session, "shutdown_terminate", {
|
|
6308
|
-
reason: drained ? "transport_disconnect" : "call_drain_timeout",
|
|
6309
|
-
});
|
|
6310
|
-
if (closeError === undefined && error !== undefined)
|
|
6311
|
-
closeError = error;
|
|
6312
|
-
}
|
|
6313
|
-
for (const slot of [...leasedBrowsers.values()]) {
|
|
6314
|
-
await forceReleaseWarmBrowserPage(slot.controller).catch(() => undefined);
|
|
6315
|
-
}
|
|
6316
|
-
if (closeError !== undefined)
|
|
6317
|
-
throw closeError;
|
|
6318
|
-
}
|
|
6319
|
-
export function activeSessionCount() {
|
|
6320
|
-
return sessions.size;
|
|
6321
|
-
}
|
|
6322
6486
|
//# sourceMappingURL=provision-session.js.map
|