@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.
Files changed (162) hide show
  1. package/README.md +136 -64
  2. package/assets/login/vnc-input.js +9 -0
  3. package/assets/login/vnc.html +214 -202
  4. package/assets/screencaps/connect-walkthrough.png +0 -0
  5. package/assets/screencaps/connect-walkthrough.svg +1 -1
  6. package/dist/api-client.d.ts +26 -3
  7. package/dist/api-client.d.ts.map +1 -1
  8. package/dist/api-client.js +19 -31
  9. package/dist/api-client.js.map +1 -1
  10. package/dist/bin.js +2 -3
  11. package/dist/bin.js.map +1 -1
  12. package/dist/bot/browser.d.ts +85 -80
  13. package/dist/bot/browser.d.ts.map +1 -1
  14. package/dist/bot/browser.js +1314 -1570
  15. package/dist/bot/browser.js.map +1 -1
  16. package/dist/bot/compact-observation-v2.d.ts +167 -0
  17. package/dist/bot/compact-observation-v2.d.ts.map +1 -0
  18. package/dist/bot/compact-observation-v2.js +1038 -0
  19. package/dist/bot/compact-observation-v2.js.map +1 -0
  20. package/dist/bot/credential-shape.d.ts +2 -0
  21. package/dist/bot/credential-shape.d.ts.map +1 -1
  22. package/dist/bot/credential-shape.js +21 -8
  23. package/dist/bot/credential-shape.js.map +1 -1
  24. package/dist/bot/element-fingerprint.d.ts +9 -0
  25. package/dist/bot/element-fingerprint.d.ts.map +1 -0
  26. package/dist/bot/element-fingerprint.js +147 -0
  27. package/dist/bot/element-fingerprint.js.map +1 -0
  28. package/dist/bot/email-verification.d.ts +7 -1
  29. package/dist/bot/email-verification.d.ts.map +1 -1
  30. package/dist/bot/email-verification.js +48 -14
  31. package/dist/bot/email-verification.js.map +1 -1
  32. package/dist/bot/google-login.d.ts +28 -52
  33. package/dist/bot/google-login.d.ts.map +1 -1
  34. package/dist/bot/google-login.js +246 -1119
  35. package/dist/bot/google-login.js.map +1 -1
  36. package/dist/bot/install-completion.d.ts +1 -2
  37. package/dist/bot/install-completion.d.ts.map +1 -1
  38. package/dist/bot/install-completion.js +1 -2
  39. package/dist/bot/install-completion.js.map +1 -1
  40. package/dist/bot/login-state.d.ts +13 -7
  41. package/dist/bot/login-state.d.ts.map +1 -1
  42. package/dist/bot/login-state.js +50 -114
  43. package/dist/bot/login-state.js.map +1 -1
  44. package/dist/bot/oauth-providers.d.ts +1 -1
  45. package/dist/bot/oauth-providers.d.ts.map +1 -1
  46. package/dist/bot/oauth-providers.js +3 -3
  47. package/dist/bot/oauth-providers.js.map +1 -1
  48. package/dist/bot/oauth-scope.d.ts +4 -0
  49. package/dist/bot/oauth-scope.d.ts.map +1 -0
  50. package/dist/bot/oauth-scope.js +58 -0
  51. package/dist/bot/oauth-scope.js.map +1 -0
  52. package/dist/bot/operator-browser-watchdog.d.ts +18 -6
  53. package/dist/bot/operator-browser-watchdog.d.ts.map +1 -1
  54. package/dist/bot/operator-browser-watchdog.js +91 -37
  55. package/dist/bot/operator-browser-watchdog.js.map +1 -1
  56. package/dist/bot/owner-process-reaper-worker.d.ts +2 -0
  57. package/dist/bot/owner-process-reaper-worker.d.ts.map +1 -0
  58. package/dist/bot/owner-process-reaper-worker.js +17 -0
  59. package/dist/bot/owner-process-reaper-worker.js.map +1 -0
  60. package/dist/bot/owner-process-reaper.d.ts +103 -0
  61. package/dist/bot/owner-process-reaper.d.ts.map +1 -0
  62. package/dist/bot/owner-process-reaper.js +1058 -0
  63. package/dist/bot/owner-process-reaper.js.map +1 -0
  64. package/dist/bot/pay-operator.d.ts +7 -1
  65. package/dist/bot/pay-operator.d.ts.map +1 -1
  66. package/dist/bot/pay-operator.js +269 -103
  67. package/dist/bot/pay-operator.js.map +1 -1
  68. package/dist/bot/profile.d.ts +10 -1
  69. package/dist/bot/profile.d.ts.map +1 -1
  70. package/dist/bot/profile.js +137 -11
  71. package/dist/bot/profile.js.map +1 -1
  72. package/dist/bot/promote-to-skill.js +1 -1
  73. package/dist/bot/promote-to-skill.js.map +1 -1
  74. package/dist/bot/provision-session.d.ts +57 -186
  75. package/dist/bot/provision-session.d.ts.map +1 -1
  76. package/dist/bot/provision-session.js +1976 -1812
  77. package/dist/bot/provision-session.js.map +1 -1
  78. package/dist/bot/remote-login-display.d.ts +61 -0
  79. package/dist/bot/remote-login-display.d.ts.map +1 -0
  80. package/dist/bot/remote-login-display.js +765 -0
  81. package/dist/bot/remote-login-display.js.map +1 -0
  82. package/dist/bot/session/hosts.d.ts +8 -0
  83. package/dist/bot/session/hosts.d.ts.map +1 -0
  84. package/dist/bot/session/hosts.js +36 -0
  85. package/dist/bot/session/hosts.js.map +1 -0
  86. package/dist/bot/session/lifecycle.d.ts +56 -0
  87. package/dist/bot/session/lifecycle.d.ts.map +1 -0
  88. package/dist/bot/session/lifecycle.js +962 -0
  89. package/dist/bot/session/lifecycle.js.map +1 -0
  90. package/dist/bot/session/model.d.ts +175 -0
  91. package/dist/bot/session/model.d.ts.map +1 -0
  92. package/dist/bot/session/model.js +77 -0
  93. package/dist/bot/session/model.js.map +1 -0
  94. package/dist/install/cli.d.ts +29 -9
  95. package/dist/install/cli.d.ts.map +1 -1
  96. package/dist/install/cli.js +243 -322
  97. package/dist/install/cli.js.map +1 -1
  98. package/dist/install/interactive.d.ts +1 -0
  99. package/dist/install/interactive.d.ts.map +1 -1
  100. package/dist/install/interactive.js +19 -5
  101. package/dist/install/interactive.js.map +1 -1
  102. package/dist/install/ui.d.ts.map +1 -1
  103. package/dist/install/ui.js +3 -15
  104. package/dist/install/ui.js.map +1 -1
  105. package/dist/operator-env.js +1 -1
  106. package/dist/operator-env.js.map +1 -1
  107. package/dist/server-instance-registry.d.ts +103 -0
  108. package/dist/server-instance-registry.d.ts.map +1 -0
  109. package/dist/server-instance-registry.js +392 -0
  110. package/dist/server-instance-registry.js.map +1 -0
  111. package/dist/server.d.ts +15 -1
  112. package/dist/server.d.ts.map +1 -1
  113. package/dist/server.js +164 -62
  114. package/dist/server.js.map +1 -1
  115. package/dist/session-guard.d.ts +28 -0
  116. package/dist/session-guard.d.ts.map +1 -0
  117. package/dist/session-guard.js +84 -0
  118. package/dist/session-guard.js.map +1 -0
  119. package/dist/session.d.ts +57 -13
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +246 -127
  122. package/dist/session.js.map +1 -1
  123. package/dist/tools/audit-log.d.ts +16 -2
  124. package/dist/tools/audit-log.d.ts.map +1 -1
  125. package/dist/tools/audit-log.js +223 -13
  126. package/dist/tools/audit-log.js.map +1 -1
  127. package/dist/tools/audit-rollup.d.ts +80 -0
  128. package/dist/tools/audit-rollup.d.ts.map +1 -0
  129. package/dist/tools/audit-rollup.js +0 -0
  130. package/dist/tools/audit-rollup.js.map +1 -0
  131. package/dist/tools/fetch-credential.d.ts +37 -0
  132. package/dist/tools/fetch-credential.d.ts.map +1 -0
  133. package/dist/tools/fetch-credential.js +153 -0
  134. package/dist/tools/fetch-credential.js.map +1 -0
  135. package/dist/tools/index.d.ts +3 -1
  136. package/dist/tools/index.d.ts.map +1 -1
  137. package/dist/tools/index.js +7 -3
  138. package/dist/tools/index.js.map +1 -1
  139. package/dist/tools/operate-pay.d.ts +4 -4
  140. package/dist/tools/operate-pay.d.ts.map +1 -1
  141. package/dist/tools/operate-pay.js +151 -96
  142. package/dist/tools/operate-pay.js.map +1 -1
  143. package/dist/tools/provision-drive.d.ts +47 -39
  144. package/dist/tools/provision-drive.d.ts.map +1 -1
  145. package/dist/tools/provision-drive.js +268 -164
  146. package/dist/tools/provision-drive.js.map +1 -1
  147. package/dist/tools/use-credential.d.ts.map +1 -1
  148. package/dist/tools/use-credential.js +3 -2
  149. package/dist/tools/use-credential.js.map +1 -1
  150. package/package.json +4 -6
  151. package/dist/bot/operator-direct-identity.d.ts +0 -26
  152. package/dist/bot/operator-direct-identity.d.ts.map +0 -1
  153. package/dist/bot/operator-direct-identity.js +0 -120
  154. package/dist/bot/operator-direct-identity.js.map +0 -1
  155. package/dist/bot/operator-profile-pool.d.ts +0 -97
  156. package/dist/bot/operator-profile-pool.d.ts.map +0 -1
  157. package/dist/bot/operator-profile-pool.js +0 -855
  158. package/dist/bot/operator-profile-pool.js.map +0 -1
  159. package/dist/bot/xvfb.d.ts +0 -13
  160. package/dist/bot/xvfb.d.ts.map +0 -1
  161. package/dist/bot/xvfb.js +0 -146
  162. 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, randomInt, randomUUID } from "node:crypto";
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 { BrowserController, CHECKOUT_SUBMIT_LABEL_RE, clickDispatchStatusForError, parseCheckoutAmount, } from "./browser.js";
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, looksLikeCredentialValue, isCredentialNoise, findCredentialTokens, keyFamilyPrefix, pickRelaxedNearCopyCredential, } from "./credential-shape.js";
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
- // Plain host list for the pieces that only need the names (goto gate, audit,
47
- // observed-hosts). The source metadata stays on the Session.
48
- function hostStrings(session) {
49
- return session.allowedHosts.map((e) => e.host);
50
- }
51
- // Hosts that may seed credential EGRESS (where a stored key is later sent by
52
- // the proxy): start + auto_widen, never mid_session task scope — a wide operate
53
- // scope must not silently over-grant a key's egress allow-list (Codex). The
54
- // vault unions these with the service-default + any agent-declared egress_hosts.
55
- function egressSeedHosts(session) {
56
- return session.allowedHosts.filter((e) => e.source !== "mid_session").map((e) => e.host);
57
- }
58
- function merchantSiblingSeedHosts(session) {
59
- return session.allowedHosts.filter((e) => e.source !== "mid_session").map((e) => e.host);
60
- }
61
- const sessions = new Map();
62
- // The pool and canonical-profile guard are authoritative for their respective
63
- // cross-process capacity. These maps only retain the local controller/lease
64
- // pairing needed for lifecycle cleanup; they deliberately do not impose a
65
- // process-global single-session gate.
66
- const leasedBrowsers = new Map();
67
- const startingBrowsers = new Set();
68
- const capacityWaiters = new Set();
69
- let shutdownGeneration = 0;
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
- async function releaseWarmBrowserPage(browser, reusable) {
251
- const slot = leasedBrowsers.get(browser);
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
- async function forceReleaseWarmBrowserPage(browser) {
268
- const slot = leasedBrowsers.get(browser);
269
- const closeState = await closeBrowserBounded(browser, false, "operator browser force-close timed out");
270
- if (slot === undefined)
271
- return;
272
- if (closeState === "closed")
273
- await slot.lease.destroy();
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 disposeSessionWatchdog(session) {
321
- session.watchdog?.dispose();
322
- session.watchdog = null;
323
- }
324
- const DEFAULT_SESSION_TERMINAL_DRAIN_TIMEOUT_MS = 5_000;
325
- const DEFAULT_PENDING_THREE_DS_FINALIZE_TIMEOUT_MS = 3_000;
326
- const DEFAULT_SESSION_TERMINAL_TRANSITION_TIMEOUT_MS = 30_000;
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 withTerminalTimeout(promise, timeoutMs, message) {
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
- promise,
337
- new Promise((_, reject) => {
338
- timer = setTimeout(() => reject(new Error(message)), timeoutMs);
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
- async function forceTerminateProvisionSession(session, event, detail, auditPendingThreeDs = true) {
348
- session.paymentDispatchClosed = true;
349
- const owner = session.terminalTeardownOwner ??
350
- (session.terminalTeardownOwner = {
351
- forced: false,
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 waitForPaymentCallsToDrain(session) {
511
- if (session.paymentCallCount === 0)
132
+ async function waitForOAuthLeaseCooldown(cooldownMs) {
133
+ if (cooldownMs <= 0)
512
134
  return;
513
135
  await new Promise((resolve) => {
514
- session.paymentCallDrainWaiters.add(resolve);
136
+ const timer = setTimeout(resolve, cooldownMs);
137
+ timer.unref();
515
138
  });
516
139
  }
517
- async function waitForSessionCallsToDrain(session) {
518
- if (session.callCount === 0)
519
- return true;
520
- const timeoutMs = positiveTimeout("TRUSTY_SQUIRE_OPERATOR_TERMINAL_DRAIN_TIMEOUT_MS", DEFAULT_SESSION_TERMINAL_DRAIN_TIMEOUT_MS);
521
- let timer;
522
- let wake;
523
- try {
524
- return await Promise.race([
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
- return await fn(session);
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
- release();
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
- finally {
564
- releasePaymentCall();
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
- const PENDING_CARD_AUTOCOMPLETE_FIELDS = new Set([
938
- "cc-number",
939
- "cc-exp",
940
- "cc-exp-month",
941
- "cc-exp-year",
942
- "cc-csc",
943
- "cc-name",
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
- if (autocomplete.some((token) => PENDING_CARD_AUTOCOMPLETE_FIELDS.has(token)))
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, the configured auth hosts, and *.firebaseapp.com /
1200
- // *.web.app auth handlers. Organic redirects are NOT routed through here.
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 Error("frame target lacks an origin");
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 Error(`${kind} refused: the target lives in an opaque (null-origin) frame — a sandboxed ` +
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 Error(`${kind} blocked by domain-scope: the target lives in a cross-domain frame ` +
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 Error("type_secret refused: the target lives in an opaque frame. Secrets may only be " +
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 Error(`type_secret refused: the target lives in a cross-domain frame ` +
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 Error(`operate_act kind="${kind}" does not yet support a target inside an <iframe> ` +
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, sealedFieldKeys = new Set(), paymentSealActive = false) {
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: presentPaymentSafeString(id, paymentSealActive),
1648
- role: presentPaymentSafeString(role, paymentSealActive),
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 = presentPaymentSafeString(el.occludedBy, paymentSealActive);
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
- ? presentPaymentSafeString(el.screenPath, paymentSealActive)
1666
- : presentLabel(el, sealedFieldKeys, paymentSealActive),
1667
- role: el.role === null ? null : presentPaymentSafeString(el.role, paymentSealActive),
1668
- text: presentLabel(el, sealedFieldKeys, paymentSealActive),
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
- : presentPaymentSafeString(el.href, paymentSealActive),
1238
+ : el.href,
1672
1239
  topmost: el.topmost ?? null,
1673
1240
  occluded_by: el.occludedBy === null || el.occludedBy === undefined
1674
1241
  ? null
1675
- : presentPaymentSafeString(el.occludedBy, paymentSealActive),
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
- // PR3 privacy — a host-facing observation reads field VALUES straight off the
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 "${presentPaymentSafeString(region, paymentSealActive)}"`);
1281
+ lines.push(` region "${region}"`);
1752
1282
  for (const el of group.slice(0, 16)) {
1753
- const label = presentLabel(el, sealedFieldKeys, paymentSealActive).replace(/"/g, '\\"');
1754
- const role = presentPaymentSafeString(roleForAccessibility(el), paymentSealActive);
1755
- const shownValue = presentFieldValue(el, sealedFieldKeys, paymentSealActive);
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="${presentPaymentSafeString(el.href, paymentSealActive).slice(0, 120)}"`
1292
+ ? `href="${el.href.slice(0, 120)}"`
1763
1293
  : null,
1764
1294
  el.topmost === false
1765
- ? `occluded_by="${presentPaymentSafeString(el.occludedBy ?? "unknown", paymentSealActive)}"`
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
- export function googleSessionGate(liveProviders) {
1848
- if (liveProviders.includes("google"))
1849
- return { ok: true };
1850
- return {
1851
- ok: false,
1852
- needs_user: {
1853
- wall: "google_session",
1854
- message: "No live Google session in the bot profile, so the operator cannot act " +
1855
- "as you yet. Refresh it with `npx @trusty-squire/mcp connect --force-relogin` " +
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
- const id = randomUUID();
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
- const id = randomUUID();
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: refuses an active card-fill
2083
- // lease, then delegates the capture-scoped sealed-value checks and pixel
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
- // A live card fill is never safe to inspect, even when the caller selects a
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
- return activeProvisionSession().browser;
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
- // [P0] Read-only: the outstanding approval a prior operate_pay call left
2200
- // waiting on the human, if any. Backs operate_payment_status, which does not
2201
- // touch session state and just reports on it.
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 observed = await observeSession(session);
2436
- if (observed.checkout_state === undefined)
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: observed.checkout_state.cart_url,
2442
- checkout_state: { ...observed.checkout_state, quantity },
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 observed = await observeSession(session);
2466
- if (observed.checkout_state === undefined)
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: observed.checkout_state.cart_url,
2472
- checkout_state: { ...observed.checkout_state, quantity: beforeQuantity },
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 observed = null;
1831
+ let actionResult = null;
2488
1832
  for (const target of addTargets) {
2489
1833
  try {
2490
- observed = await act(session.id, { kind: "click", target }, "compact", {
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 Error) || !error.message.startsWith("no element matched locator")) {
1846
+ if (!(error instanceof ProvisionTargetMissingError)) {
2503
1847
  throw error;
2504
1848
  }
2505
1849
  }
2506
1850
  }
2507
- if (addError !== undefined || observed === null)
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 = observed.checkout_state;
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, sealed,
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, paymentSealActive = false) {
2047
+ elide = false) {
2681
2048
  const out = {
2682
2049
  ref,
2683
- label: presentLabel(el, sealed, paymentSealActive),
2684
- tag: presentPaymentSafeString(el.tag, paymentSealActive),
2050
+ label: elementRef(el),
2051
+ tag: el.tag,
2685
2052
  };
2686
2053
  if (el.role)
2687
- out.role = presentPaymentSafeString(el.role, paymentSealActive);
2054
+ out.role = el.role;
2688
2055
  if (el.type && !(elide && shouldElideType(el))) {
2689
- out.type = presentPaymentSafeString(el.type, paymentSealActive);
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 = presentPaymentSafeString(el.href, paymentSealActive);
2065
+ out.href = el.href;
2703
2066
  if (el.testId)
2704
- out.testId = presentPaymentSafeString(el.testId, paymentSealActive);
2067
+ out.testId = el.testId;
2705
2068
  if (includePath && el.screenPath) {
2706
- out.path = presentPaymentSafeString(el.screenPath, paymentSealActive);
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 = presentPaymentSafeString(el.occludedBy, paymentSealActive);
2074
+ out.occluded_by = el.occludedBy;
2712
2075
  }
2713
2076
  if (el.frameOrigin) {
2714
- out.frame_origin = presentPaymentSafeString(el.frameOrigin, paymentSealActive);
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, sealed, false, elide, paymentSealActive));
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, sealed, true, false, paymentSealActive));
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
- let pathname = "";
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
- async function observeSession(session, detail = "compact") {
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 observation = {
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: "OAuth in progress: the provider detached or closed its page as expected. " +
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
- state: "in_progress",
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.lastElements = elements;
3371
- const sealedFieldKeys = observationSealedFieldKeys(session, elements);
3372
- const text = redactPaymentObservationText(await session.browser.extractVisibleText(), elements, session.paymentFieldSealActive);
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), sealedFieldKeys, true, false, session.paymentFieldSealActive)));
3441
- const screen = buildScreenOutline(elements, normalizedText, sealedFieldKeys, session.paymentFieldSealActive);
3442
- const accessibility = buildAccessibilitySnapshot(elements, undefined, sealedFieldKeys, session.paymentFieldSealActive);
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: presentLabel(el, sealedFieldKeys, session.paymentFieldSealActive),
3454
- tag: presentPaymentSafeString(el.tag, session.paymentFieldSealActive),
3315
+ label: elementRef(el),
3316
+ tag: el.tag,
3455
3317
  role: el.role === null
3456
3318
  ? null
3457
- : presentPaymentSafeString(el.role, session.paymentFieldSealActive),
3319
+ : el.role,
3458
3320
  type: el.type === null
3459
3321
  ? null
3460
- : presentPaymentSafeString(el.type, session.paymentFieldSealActive),
3461
- value: presentFieldValue(el, sealedFieldKeys, session.paymentFieldSealActive),
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
- : presentPaymentSafeString(el.href, session.paymentFieldSealActive),
3327
+ : el.href,
3466
3328
  testId: el.testId === null || el.testId === undefined
3467
3329
  ? null
3468
- : presentPaymentSafeString(el.testId, session.paymentFieldSealActive),
3330
+ : el.testId,
3469
3331
  path: el.screenPath === null || el.screenPath === undefined
3470
3332
  ? null
3471
- : presentPaymentSafeString(el.screenPath, session.paymentFieldSealActive),
3333
+ : el.screenPath,
3472
3334
  container: el.container === null || el.container === undefined
3473
3335
  ? null
3474
- : presentPaymentSafeString(el.container, session.paymentFieldSealActive),
3336
+ : el.container,
3475
3337
  topmost: el.topmost ?? null,
3476
3338
  occluded_by: el.occludedBy === null || el.occludedBy === undefined
3477
3339
  ? null
3478
- : presentPaymentSafeString(el.occludedBy, session.paymentFieldSealActive),
3340
+ : el.occludedBy,
3479
3341
  frame_origin: el.frameOrigin === null || el.frameOrigin === undefined
3480
3342
  ? null
3481
- : presentPaymentSafeString(el.frameOrigin, session.paymentFieldSealActive),
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
- const { browser } = session;
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
- const auditTarget = "target" in action && parseLocatorTarget(action.target) !== null
3627
- ? "<mode>=<redacted>"
3628
- : "target" in action
3629
- ? action.target
3630
- : undefined;
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 ? { url: action.url } : {}),
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 Error(`action refused: the browser is on Squire's own control plane (${curHost}). ` +
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
- switch (action.kind) {
3665
- case "goto": {
3666
- if (!hostAllowed(action.url, hostStrings(session))) {
3667
- throw new Error(`goto blocked by domain-scope: ${action.url} is outside the allowed hosts ` +
3668
- `[${hostStrings(session).join(", ")}] + auth providers. ` +
3669
- `Declare it first with an allow_host action if this task spans it.`);
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
- await browser.goto(action.url);
3672
- break;
3673
- }
3674
- case "allow_host": {
3675
- const checked = validateAllowHost(action.host);
3676
- if ("error" in checked) {
3677
- throw new Error(`allow_host rejected "${action.host}": ${checked.error}`);
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
- if (!session.allowedHosts.some((e) => e.host === checked.host)) {
3680
- session.allowedHosts.push({ host: checked.host, source: "mid_session" });
3681
- audit(sessionId, "allow_host", { host: checked.host, allowed_hosts: hostStrings(session) });
3608
+ case "press": {
3609
+ await browser.pressKey(action.key);
3610
+ break;
3682
3611
  }
3683
- break;
3684
- }
3685
- case "press": {
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
- sensitiveSource = {
3705
- traceIndex: -1,
3706
- hole: `credential.${action.slot}`,
3707
- literal: value,
3708
- };
3709
- const locator = parseLocatorTarget(action.target);
3710
- if (locator !== null) {
3711
- const resolved = await browser.resolvePageTarget(locator.mode, locator.value, "type");
3712
- if (!resolved.ok) {
3713
- if (resolved.reason === "none") {
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
- try {
3719
- if (resolved.frameTarget !== null) {
3720
- assertSecretFrameTargetAllowed(session, resolved.frameTarget);
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
- session.usedLocatorFallback = true;
3723
- const sealedFieldKeys = await browser.typeHandle(resolved.handle, value, true);
3724
- for (const key of sealedFieldKeys)
3725
- session.sealedFieldKeys.add(key);
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
- finally {
3728
- await resolved.handle.dispose().catch(() => undefined);
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
- locator_mode: locator.mode,
3692
+ target: auditTarget,
3733
3693
  host: registrableHost(browser.currentUrl()),
3734
3694
  });
3735
3695
  break;
3736
3696
  }
3737
- const fresh = await browser.extractInteractiveElements();
3738
- session.lastElements = fresh;
3739
- // resolveTarget recomputes identities (incl. volatile positional-group
3740
- // fingerprints) from these FRESH elements, so a ref whose group fingerprint
3741
- // changed since the last observe resolves to null, not a survivor (#399).
3742
- const el = resolveTarget(fresh, action.target);
3743
- if (el === null) {
3744
- const stale = staleTargetError(session, action.target, fresh);
3745
- if (stale !== null)
3746
- throw stale;
3747
- throw new Error(`type_secret: no element matched target "${action.target}".`);
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
- resolvedEl = el;
3750
- // Frame domain-lock (operator-frame-support) — never let a secret cross
3751
- // into a rogue/third-party (e.g. payment) iframe. See
3752
- // assertSecretFrameTargetAllowed; a main-frame or same-domain-frame
3753
- // target is unaffected.
3754
- assertSecretFrameTargetAllowed(session, el);
3755
- // Type the REAL value into the page. It crosses only browser↔page; the
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
- resolvedEl = el;
3788
- // Frame domain-lock (operator-frame-support) — the SAME gate a frame
3789
- // click/type passes; see frameTargetAllowed. A native <select> is not a
3790
- // secret field, so the stricter type_secret cross-origin rule does not
3791
- // apply, but the ordinary domain lock does.
3792
- assertFrameTargetAllowed(session, el, "select");
3793
- bindCartIdentity(isCartAffectingAction(action, el));
3794
- const selectFrame = frameTargetFor(el);
3795
- const committedText = selectFrame !== null
3796
- ? await browser.selectInFrame(selectFrame, el.selector, action.text)
3797
- : await browser.selectOption(el.selector, action.text);
3798
- session.committedSelectValues.set(el.selector, committedText);
3799
- completedAction = { ...action, text: committedText };
3800
- await settleAfterStateChange(browser);
3801
- break;
3802
- }
3803
- case "set_phone_country": {
3804
- // No captured element — the bot finds the phone-local native <select>.
3805
- // resolvedEl stays null; the step records without a captured-element
3806
- // trace (the country is host-replannable, not a replay recipe).
3807
- await browser.setPhoneCountry(action.country);
3808
- await settleAfterStateChange(browser);
3809
- break;
3810
- }
3811
- case "click":
3812
- case "js_click":
3813
- case "type":
3814
- case "upload":
3815
- case "oauth_click": {
3816
- const pageText = await browser.extractVisibleText();
3817
- const blockReason = shouldBlockUnsafeProvisionAction(pageText, action);
3818
- if (blockReason !== null)
3819
- throw new Error(blockReason);
3820
- // Locator-form target (`text=…` / `css=…`): the host is pointing at a
3821
- // control that has NO `@e:` ref because the inventory never emitted it (a
3822
- // bare click-handler <div> with no role/label, e.g. a SPA "Add To Cart"
3823
- // that falls past the card-scan cap). Resolve it directly against the live
3824
- // page instead of the extracted-element list.
3825
- const locator = parseLocatorTarget(action.target);
3826
- if (locator !== null) {
3827
- if (action.kind !== "click" && action.kind !== "js_click" && action.kind !== "type") {
3828
- throw new Error(`operate_act kind="${action.kind}" does not accept a text=/css= locator target; ` +
3829
- `use an @e: ref from operate_observe.`);
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
- const resolved = await browser.resolvePageTarget(locator.mode, locator.value, action.kind === "type" ? "type" : "click");
3832
- if (!resolved.ok) {
3833
- if (resolved.reason === "none") {
3834
- throw new Error(`no element matched locator "${action.target}". If the control is visible, ` +
3835
- `try a shorter/exact text= label or a css=<selector>.`);
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
- throw new AmbiguousProvisionTargetError(action.target, resolved.candidates);
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
- // The unsafe-action guard above inspected the RAW target, so an opaque
3840
- // `css=<selector>` (or any target whose string carries no verb/noun the
3841
- // guard matches) could resolve to a destructive billing/setup control the
3842
- // guard couldn't see through — clicking "Save product" in live mode via
3843
- // css=#submit. Re-run it against compact safety signals computed from the
3844
- // resolved control now that we know what the locator actually points at.
3845
- // Mark the session non-promotable BEFORE the action: a locator action can't
3846
- // be replayed from the inventory (the element was never in it), so a
3847
- // skill synthesized from this run would silently omit the step. Setting
3848
- // it up front means an action that lands but then throws still can't leave
3849
- // the session promotable (see captureAndPromoteSession) (codex).
3850
- try {
3851
- const resolvedBlock = shouldBlockUnsafeProvisionSignals(pageText, resolved.safetySignals);
3852
- if (resolvedBlock !== null)
3853
- throw new Error(resolvedBlock);
3854
- if (resolved.frameTarget !== null) {
3855
- assertFrameTargetAllowed(session, resolved.frameTarget, action.kind);
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
- bindCartIdentity(isCartAffectingAction(action, null, resolved.labels));
3858
- session.usedLocatorFallback = true;
3859
- const isPlaceOrderCandidate = isCheckoutSubmitLabeled([
3860
- resolved.text,
3861
- ...resolved.labels,
3862
- ]);
3863
- if (isPlaceOrderCandidate && (action.kind === "click" || action.kind === "js_click")) {
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
- finally {
3878
- await resolved.handle.dispose().catch(() => undefined);
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
- audit(sessionId, action.kind, {
3881
- locator_mode: locator.mode,
3882
- host: registrableHost(browser.currentUrl()),
3883
- });
3884
- if (action.kind !== "type")
3885
- await settleAfterStateChange(browser);
3886
- break;
3887
- }
3888
- // Re-resolve against FRESH elements every act — never trust a stale index.
3889
- const fresh = await browser.extractInteractiveElements();
3890
- session.lastElements = fresh;
3891
- // resolveTarget recomputes identities (incl. volatile positional-group
3892
- // fingerprints) from these FRESH elements, so a ref whose group fingerprint
3893
- // changed since the last observe resolves to null, not a survivor (#399).
3894
- const el = resolveTarget(fresh, action.target);
3895
- if (el === null) {
3896
- const stale = staleTargetError(session, action.target, fresh);
3897
- if (stale !== null)
3898
- throw stale;
3899
- throw new Error(`no element matched target "${action.target}". Visible: ` +
3900
- fresh
3901
- .map((e) => `"${e.screenPath ?? elementRef(e)}"`)
3902
- .slice(0, 20)
3903
- .join(", "));
3904
- }
3905
- resolvedEl = el;
3906
- // Frame domain-lock (operator-frame-support) — see frameTargetAllowed.
3907
- // A main-frame or same-domain-frame target is unaffected.
3908
- assertFrameTargetAllowed(session, el, action.kind);
3909
- bindCartIdentity(isCartAffectingAction(action, el));
3910
- if (action.kind === "click" || action.kind === "js_click") {
3911
- const target = frameTargetFor(el);
3912
- if (isPlaceOrderClickCandidate(el)) {
3913
- await runClickWithPlaceOrderGuard(session, (shouldTrack) => browser.clickWithDispatchTracking(target !== null
3914
- ? { kind: "frame", frame: target, selector: el.selector, method: action.kind }
3915
- : { kind: "selector", selector: el.selector, method: action.kind }, shouldTrack));
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 === "click") {
3918
- if (target !== null)
3919
- await browser.clickInFrame(target, el.selector);
3920
- else
3921
- await browser.click(el.selector);
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
- if (target !== null)
3925
- await browser.clickViaJsInFrame(target, el.selector);
3926
- else
3927
- await browser.clickViaJs(el.selector);
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
- else if (action.kind === "type" && frameTargetFor(el) !== null) {
3931
- // Frame targets skip the autocomplete-popup-commit machinery below —
3932
- // it operates on the main page's DOM only (markPreexistingType
3933
- // SuggestionPopups / detectTypeSuggestionPopup / commitTypeSuggestion
3934
- // are page-scoped). Out of scope for the checkout-option case frame
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
- else {
3950
- // 3.1 — a Google-Places-style address field or a react-select/cmdk/
3951
- // Radix combobox can open a suggestion popup as a side effect of
3952
- // typing, not just of an explicit `select`. Snapshot pre-existing
3953
- // popups BEFORE typing (it can open mid-keystroke), then detect what
3954
- // opened afterward.
3955
- await browser.markPreexistingTypeSuggestionPopups();
3956
- await browser.type(el.selector, action.text);
3957
- // Cleanup (clear our tracking markers, and dismiss with Escape
3958
- // ONLY when a detected popup is plausibly still open) must run no
3959
- // matter how this resolves — no popup, an ambiguous stop, a
3960
- // failed commit, or success — mirroring selectFromCombobox's own
3961
- // try/finally. Scoping the try to only the
3962
- // suggestionTexts.length > 0 branch left the "preexisting"
3963
- // markers set by markPreexistingTypeSuggestionPopups uncleared on
3964
- // the no-popup path; markComboboxPreexistingElements only ADDS
3965
- // markers, so a stale one could exclude a genuine popup from
3966
- // detection on a LATER type/select into the same element. Escape
3967
- // fires on the ambiguous-stop and failed-commit paths (popup
3968
- // never interacted with / click may not have registered — still
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
- case "oauth_login": {
4050
- const pageText = await browser.extractVisibleText();
4051
- const blockReason = shouldBlockUnsafeProvisionAction(pageText, action);
4052
- if (blockReason !== null)
4053
- throw new Error(blockReason);
4054
- // Atomic OAuth deliberately accepts only the observed stable ref. A raw
4055
- // locator would lose the same stale-reference guarantees as every other
4056
- // action before the provider transition begins.
4057
- const fresh = await browser.extractInteractiveElements();
4058
- session.lastElements = fresh;
4059
- const el = resolveTarget(fresh, action.target);
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
- resolvedEl = el;
4064
- assertNoFrameTarget(el, "oauth_login");
4065
- await browser.loginWithOAuth(el.selector);
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
- recordTrace(session, completedAction, resolvedEl, sensitiveSource);
4076
- recordCaptureRound(session, completedAction, resolvedEl, urlBeforeAction);
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 completedAction.kind === "select"
4099
- ? { ...observation, selected_option: completedAction.text }
4100
- : observation;
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
- // Keep variant changes in one host call. Each successful select is followed
4105
- // by a real observe before the next target resolves: variant widgets commonly
4106
- // replace all dependent selects, so continuing from an old inventory is worse
4107
- // than reporting a partial result.
4108
- for (const [label, option] of Object.entries(selections)) {
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 actionResult = await act(sessionId, { kind: "select", target: label, text: option }, "none");
4111
- const selectedOption = actionResult.selected_option;
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
- label,
4120
- option,
4155
+ ...publicSelection,
4121
4156
  status: "selected",
4122
- selected_option: selectedOption,
4157
+ ...(publicSelectedOption === undefined ? {} : { selected_option: publicSelectedOption }),
4123
4158
  });
4124
4159
  }
4125
4160
  catch (err) {
4126
- if (err instanceof TargetStaleError) {
4161
+ if (session.compactV2Active) {
4127
4162
  fields.push({
4128
- label,
4129
- option,
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
- label,
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 ? { css: scrub(el.selector) } : {}),
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 observed = captureObserved(action, el);
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: action.kind === "oauth_click" || action.kind === "oauth_login",
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: { url: urlAtObservation, title: "", html: "", screenshot: "" },
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
- try {
4540
- html = (await session.browser.getState()).html;
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
- catch {
4543
- /* best-effort — the copy-button/inventory extract path still works */
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: { url: session.browser.currentUrl(), title: "", html, screenshot: "" },
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
- process.stderr.write(`${JSON.stringify({ marker: "provision-measurement", ...m })}\n`);
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
- await settle(450);
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
- // Read lengths off the RAW elements (session.lastElements, set by
4893
- // observeSession). The compact wire carries only value_len and never the raw
4894
- // value; deriving from the live elements preserves the real length for a
4895
- // min_value_len success-signal. Lengths never expose the value, so this stays
4896
- // leak-free.
4897
- const fields = session.lastElements
4898
- .filter((e) => typeof e.value === "string" && e.value.length > 0)
4899
- .map((e) => ({ label: elementRef(e), value_len: (e.value ?? "").length }));
4900
- return { url: obs.url, text: session.prevObserve?.text ?? obs.text, fields };
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 = sessions.get(sessionId);
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 = sessions.get(sessionId);
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, `replay refused: "${host}" is outside the recipe's own domain "${recipeDomain}"`);
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.committedSelectValues.delete(resolution.element.selector);
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.committedSelectValues.delete(resolution.element.selector);
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.committedSelectValues.delete(resolution.element.selector);
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.lastElements = fresh;
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.lastElements = fresh;
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.lastElements = fresh;
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 = sessions.get(sessionId);
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: recipeDomain,
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.lastElements = fresh;
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
- action = { kind: "oauth_click", target: ref };
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 act(sessionId, action, "none");
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, error instanceof Error ? error.message : String(error));
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 (skip still-masked values and
5710
- // env-var NAME displays — "LANGWATCH_API_KEY=" is the SDK-snippet prefix, not
5711
- // a credential).
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 || c.isMasked)
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
- // Report-back so the agent keeps going instead of treating an empty result as
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
- const OTP_KEYWORD_RE = /(?:code|verification|verify|otp|passcode|one[- ]time)\D{0,40}?(\d{4,8})|(\d{4,8})\D{0,8}?(?:code|verification|verify|otp|passcode)/i;
6000
- export function parseVerification(text, links) {
6001
- const link = pickVerificationLink([...links]);
6002
- const kw = OTP_KEYWORD_RE.exec(text);
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
- // PR2 — consent refusal. The user has not consented to the operator reading
6043
- // their inbox, so we do NOT read it. The session stays live (resumable): the
6044
- // host asks the user for the code and types it, or the user grants inbox consent
6045
- // and retries. Distinct message from buildVerificationResult so the host can tell
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 not consented, so the operator did not read any mail. Ask " +
6052
- "the user, in context: may the operator read your inbox to fetch the code for " +
6053
- 'this signup? If YES, retry operate_act { kind: "await_verification" } with ' +
6054
- "grant_inbox_consent:true (grants it for the rest of this session). If NO, " +
6055
- "ask them for the code and type it with operate_act — the session is still " +
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
- const { browser } = session;
6082
- // PR3b — JIT consent grant: the host passes grantConsent ONLY after the user
6083
- // agreed in-context. Grant inbox-read for the rest of this session (remembered
6084
- // so we don't re-prompt each await); does not touch the standing install flag.
6085
- if (opts.grantConsent === true && !session.consentInboxRead) {
6086
- session.consentInboxRead = true;
6087
- audit(sessionId, "inbox_consent_granted", { scope: "session" });
6088
- }
6089
- // PR2 fail-closed gate: without inbox-read consent, do NOT read the user's
6090
- // mail. Hand the code request back to the user instead (resumable). The old
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
- const query = buildVerificationSearchQuery(opts.sender);
6098
- const searchUrl = `https://mail.google.com/mail/u/0/#search/${encodeURIComponent(query)}`;
6099
- const hrefsOf = (els) => els.map((e) => e.href).filter((h) => typeof h === "string" && h.length > 0);
6100
- // A verification email commonly lands 10–30s AFTER the trigger, so a single
6101
- // search misses it and hands back "not found" the agent has to re-issue. Re-run
6102
- // the search up to 3× with a short wait between attempts within this one call.
6103
- let code = null;
6104
- let link = null;
6105
- let sourceFrom = null;
6106
- for (let attempt = 0; attempt < 3 && code === null && link === null; attempt++) {
6107
- sourceFrom = null;
6108
- if (attempt > 0)
6109
- await browser.waitForCaptchaChallengeToSettle(4000, 0).catch(() => false);
6110
- // Internal navigation (not an agent goto) — sanctioned read of the user's mail.
6111
- await browser.goto(searchUrl);
6112
- // Poll briefly for the result list to render.
6113
- let listText = "";
6114
- for (let i = 0; i < 6; i++) {
6115
- listText = await browser.extractVisibleText();
6116
- if (listText.length > 200)
6117
- break;
6118
- await browser.waitForCaptchaChallengeToSettle(1200, 0).catch(() => false);
6119
- }
6120
- const listLinks = hrefsOf(await browser.extractInteractiveElements());
6121
- const opened = await browser.openFirstMailResult().catch(() => false);
6122
- if (opened) {
6123
- // Parse the OPENED email as the AUTHORITATIVE source. Merging the
6124
- // results-list snippets let an UNRELATED sender's code (a bank OTP shown in
6125
- // the list) override the target email's link — a real wrong-code grab
6126
- // (Brave signup 2026-07-04: a GO2bank "verification code is 580210" beat
6127
- // Brave's verify LINK). The list snippets stay a fallback only, for when the
6128
- // mail won't open (an OTP-in-snippet still works then).
6129
- const openedText = await browser.extractVisibleText();
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