@trusty-squire/mcp 1.1.13-rc.8 → 1.1.13

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