@trusty-squire/mcp 1.1.13-rc.9 → 1.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -64
- package/assets/login/vnc-input.js +9 -0
- package/assets/login/vnc.html +214 -202
- package/assets/screencaps/connect-walkthrough.png +0 -0
- package/assets/screencaps/connect-walkthrough.svg +1 -1
- package/dist/api-client.d.ts +26 -3
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +19 -31
- package/dist/api-client.js.map +1 -1
- package/dist/bin.js +2 -3
- package/dist/bin.js.map +1 -1
- package/dist/bot/browser.d.ts +85 -80
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +1314 -1570
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/compact-observation-v2.d.ts +167 -0
- package/dist/bot/compact-observation-v2.d.ts.map +1 -0
- package/dist/bot/compact-observation-v2.js +1038 -0
- package/dist/bot/compact-observation-v2.js.map +1 -0
- package/dist/bot/credential-shape.d.ts +2 -0
- package/dist/bot/credential-shape.d.ts.map +1 -1
- package/dist/bot/credential-shape.js +21 -8
- package/dist/bot/credential-shape.js.map +1 -1
- package/dist/bot/element-fingerprint.d.ts +9 -0
- package/dist/bot/element-fingerprint.d.ts.map +1 -0
- package/dist/bot/element-fingerprint.js +147 -0
- package/dist/bot/element-fingerprint.js.map +1 -0
- package/dist/bot/email-verification.d.ts +7 -1
- package/dist/bot/email-verification.d.ts.map +1 -1
- package/dist/bot/email-verification.js +48 -14
- package/dist/bot/email-verification.js.map +1 -1
- package/dist/bot/google-login.d.ts +28 -52
- package/dist/bot/google-login.d.ts.map +1 -1
- package/dist/bot/google-login.js +246 -1119
- package/dist/bot/google-login.js.map +1 -1
- package/dist/bot/install-completion.d.ts +1 -2
- package/dist/bot/install-completion.d.ts.map +1 -1
- package/dist/bot/install-completion.js +1 -2
- package/dist/bot/install-completion.js.map +1 -1
- package/dist/bot/login-state.d.ts +13 -7
- package/dist/bot/login-state.d.ts.map +1 -1
- package/dist/bot/login-state.js +50 -114
- package/dist/bot/login-state.js.map +1 -1
- package/dist/bot/oauth-providers.d.ts +1 -1
- package/dist/bot/oauth-providers.d.ts.map +1 -1
- package/dist/bot/oauth-providers.js +3 -3
- package/dist/bot/oauth-providers.js.map +1 -1
- package/dist/bot/oauth-scope.d.ts +4 -0
- package/dist/bot/oauth-scope.d.ts.map +1 -0
- package/dist/bot/oauth-scope.js +58 -0
- package/dist/bot/oauth-scope.js.map +1 -0
- package/dist/bot/operator-browser-watchdog.d.ts +18 -6
- package/dist/bot/operator-browser-watchdog.d.ts.map +1 -1
- package/dist/bot/operator-browser-watchdog.js +91 -37
- package/dist/bot/operator-browser-watchdog.js.map +1 -1
- package/dist/bot/owner-process-reaper-worker.d.ts +2 -0
- package/dist/bot/owner-process-reaper-worker.d.ts.map +1 -0
- package/dist/bot/owner-process-reaper-worker.js +17 -0
- package/dist/bot/owner-process-reaper-worker.js.map +1 -0
- package/dist/bot/owner-process-reaper.d.ts +103 -0
- package/dist/bot/owner-process-reaper.d.ts.map +1 -0
- package/dist/bot/owner-process-reaper.js +1058 -0
- package/dist/bot/owner-process-reaper.js.map +1 -0
- package/dist/bot/pay-operator.d.ts +7 -1
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +269 -103
- package/dist/bot/pay-operator.js.map +1 -1
- package/dist/bot/profile.d.ts +10 -1
- package/dist/bot/profile.d.ts.map +1 -1
- package/dist/bot/profile.js +137 -11
- package/dist/bot/profile.js.map +1 -1
- package/dist/bot/promote-to-skill.js +1 -1
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/provision-session.d.ts +57 -186
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +1976 -1812
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/bot/remote-login-display.d.ts +61 -0
- package/dist/bot/remote-login-display.d.ts.map +1 -0
- package/dist/bot/remote-login-display.js +765 -0
- package/dist/bot/remote-login-display.js.map +1 -0
- package/dist/bot/session/hosts.d.ts +8 -0
- package/dist/bot/session/hosts.d.ts.map +1 -0
- package/dist/bot/session/hosts.js +36 -0
- package/dist/bot/session/hosts.js.map +1 -0
- package/dist/bot/session/lifecycle.d.ts +56 -0
- package/dist/bot/session/lifecycle.d.ts.map +1 -0
- package/dist/bot/session/lifecycle.js +962 -0
- package/dist/bot/session/lifecycle.js.map +1 -0
- package/dist/bot/session/model.d.ts +175 -0
- package/dist/bot/session/model.d.ts.map +1 -0
- package/dist/bot/session/model.js +77 -0
- package/dist/bot/session/model.js.map +1 -0
- package/dist/install/cli.d.ts +29 -9
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +243 -322
- package/dist/install/cli.js.map +1 -1
- package/dist/install/interactive.d.ts +1 -0
- package/dist/install/interactive.d.ts.map +1 -1
- package/dist/install/interactive.js +19 -5
- package/dist/install/interactive.js.map +1 -1
- package/dist/install/ui.d.ts.map +1 -1
- package/dist/install/ui.js +3 -15
- package/dist/install/ui.js.map +1 -1
- package/dist/operator-env.js +1 -1
- package/dist/operator-env.js.map +1 -1
- package/dist/server-instance-registry.d.ts +103 -0
- package/dist/server-instance-registry.d.ts.map +1 -0
- package/dist/server-instance-registry.js +392 -0
- package/dist/server-instance-registry.js.map +1 -0
- package/dist/server.d.ts +15 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +164 -62
- package/dist/server.js.map +1 -1
- package/dist/session-guard.d.ts +28 -0
- package/dist/session-guard.d.ts.map +1 -0
- package/dist/session-guard.js +84 -0
- package/dist/session-guard.js.map +1 -0
- package/dist/session.d.ts +57 -13
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +246 -127
- package/dist/session.js.map +1 -1
- package/dist/tools/audit-log.d.ts +16 -2
- package/dist/tools/audit-log.d.ts.map +1 -1
- package/dist/tools/audit-log.js +223 -13
- package/dist/tools/audit-log.js.map +1 -1
- package/dist/tools/audit-rollup.d.ts +80 -0
- package/dist/tools/audit-rollup.d.ts.map +1 -0
- package/dist/tools/audit-rollup.js +0 -0
- package/dist/tools/audit-rollup.js.map +1 -0
- package/dist/tools/fetch-credential.d.ts +37 -0
- package/dist/tools/fetch-credential.d.ts.map +1 -0
- package/dist/tools/fetch-credential.js +153 -0
- package/dist/tools/fetch-credential.js.map +1 -0
- package/dist/tools/index.d.ts +3 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +7 -3
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/operate-pay.d.ts +4 -4
- package/dist/tools/operate-pay.d.ts.map +1 -1
- package/dist/tools/operate-pay.js +151 -96
- package/dist/tools/operate-pay.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +47 -39
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +268 -164
- package/dist/tools/provision-drive.js.map +1 -1
- package/dist/tools/use-credential.d.ts.map +1 -1
- package/dist/tools/use-credential.js +3 -2
- package/dist/tools/use-credential.js.map +1 -1
- package/package.json +4 -6
- package/dist/bot/operator-direct-identity.d.ts +0 -26
- package/dist/bot/operator-direct-identity.d.ts.map +0 -1
- package/dist/bot/operator-direct-identity.js +0 -120
- package/dist/bot/operator-direct-identity.js.map +0 -1
- package/dist/bot/operator-profile-pool.d.ts +0 -97
- package/dist/bot/operator-profile-pool.d.ts.map +0 -1
- package/dist/bot/operator-profile-pool.js +0 -855
- package/dist/bot/operator-profile-pool.js.map +0 -1
- package/dist/bot/xvfb.d.ts +0 -13
- package/dist/bot/xvfb.d.ts.map +0 -1
- package/dist/bot/xvfb.js +0 -146
- package/dist/bot/xvfb.js.map +0 -1
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
// Phase 2 of the operator session-management restructure: the session
|
|
2
|
+
// lifecycle, moved out of provision-session.ts as ONE transaction.
|
|
3
|
+
//
|
|
4
|
+
// This module owns the live-session registry and everything whose ORDER is
|
|
5
|
+
// load-bearing around it: the real-profile lease and its browser acquisition,
|
|
6
|
+
// the ordinary and payment call leases and their drains, the idle/lifetime
|
|
7
|
+
// watchdog, the bounded close, the single terminal-teardown owner, the
|
|
8
|
+
// session artifact cleanup, and start/finish/shutdown themselves. It is one
|
|
9
|
+
// module because those are one transaction — a terminal transition drains
|
|
10
|
+
// leases, runs finish preparation, audits a pending 3-D Secure outcome,
|
|
11
|
+
// closes the browser, clears artifacts, and only then deletes the EXACT
|
|
12
|
+
// session object from the map. Splitting that ordering across modules is how
|
|
13
|
+
// it silently regresses.
|
|
14
|
+
//
|
|
15
|
+
// provision-session.ts re-exports every public name here, so no caller import
|
|
16
|
+
// changed. Payment state transitions deliberately did NOT move: they still
|
|
17
|
+
// live in the facade, and this module only observes their fields at teardown.
|
|
18
|
+
//
|
|
19
|
+
// The one collaborator this module does not own is perception: the two start
|
|
20
|
+
// paths take their first observation through `SessionStartPorts`, supplied by
|
|
21
|
+
// the facade. That keeps the dependency one-way (facade → lifecycle) with no
|
|
22
|
+
// runtime import cycle, exactly as session/model.ts does with its type-only
|
|
23
|
+
// back-reference.
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { lstatSync, rmSync } from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { BrowserController } from "../browser.js";
|
|
29
|
+
import { compactV2AuditValue } from "../compact-observation-v2.js";
|
|
30
|
+
import { acquireProfileOperationGuard, CHROME_PROFILE_DIR, ProfileBusyError, waitForProfileFree, } from "../profile.js";
|
|
31
|
+
import { loginSessionGuidance } from "../skill-hint.js";
|
|
32
|
+
import { OperatorBrowserWatchdog, } from "../operator-browser-watchdog.js";
|
|
33
|
+
import { createSession } from "./model.js";
|
|
34
|
+
import { hostStrings, merchantSiblingSeedHosts, registrableHost, requestScopeHostStrings, } from "./hosts.js";
|
|
35
|
+
const sessions = new Map();
|
|
36
|
+
// A Google-gated start returns an ID so the caller can correlate its handoff,
|
|
37
|
+
// but it never creates a browser session. Its terminal acknowledgement is a
|
|
38
|
+
// no-op rather than an "unknown session" error.
|
|
39
|
+
const refusedStartSessionIds = new Set();
|
|
40
|
+
const leasedBrowsers = new Map();
|
|
41
|
+
const startingBrowsers = new Set();
|
|
42
|
+
let shutdownGeneration = 0;
|
|
43
|
+
let shutdownInProgress = 0;
|
|
44
|
+
// Audit trail (security posture): every session action emits one structured
|
|
45
|
+
// stderr line the host's MCP log captures. The `provision-audit` marker makes
|
|
46
|
+
// the trail greppable. No credential VALUES are ever logged — only the action
|
|
47
|
+
// shape + url.
|
|
48
|
+
export function audit(sessionId, event, detail = {}) {
|
|
49
|
+
const session = sessions.get(sessionId);
|
|
50
|
+
const sealedDetail = session?.compactV2Mode === "on"
|
|
51
|
+
? Object.fromEntries(Object.entries(detail).map(([key, value]) => [key, compactV2AuditValue(key, value)]))
|
|
52
|
+
: detail;
|
|
53
|
+
process.stderr.write(`${JSON.stringify({ marker: "provision-audit", surface: "operate", session_id: sessionId, event, ...sealedDetail })}\n`);
|
|
54
|
+
}
|
|
55
|
+
// operate_start's browser launch is the one UNBOUNDED step in the session
|
|
56
|
+
// bootstrap: on a fresh box the first launch downloads Chromium, and a wedged
|
|
57
|
+
// profile lock or missing browser deps can
|
|
58
|
+
// otherwise hang it indefinitely — a real dogfood run sat on a silent ~30-min
|
|
59
|
+
// hang here with zero feedback (the worst first-run failure: the user assumes
|
|
60
|
+
// it's broken and never comes back). Cap it so a stuck launch fails LOUDLY with
|
|
61
|
+
// an actionable message. The default is generous (a cold Chromium download is
|
|
62
|
+
// legitimately multi-minute — better to wait than false-fail a slow-but-working
|
|
63
|
+
// launch); tune with BOT_START_TIMEOUT_MS. Timeout uses the independent bounded
|
|
64
|
+
// cancellation boundary: it releases or quarantines profile custody without
|
|
65
|
+
// awaiting the unresolved launch, and late settlement cleans up only this
|
|
66
|
+
// controller's marked process.
|
|
67
|
+
async function startBrowserBounded(browser, sessionId, cancel, maximumTimeoutMs) {
|
|
68
|
+
const configuredTimeoutMs = Number(process.env.BOT_START_TIMEOUT_MS) || 600_000;
|
|
69
|
+
const timeoutMs = maximumTimeoutMs === undefined
|
|
70
|
+
? configuredTimeoutMs
|
|
71
|
+
: Math.max(1, Math.min(configuredTimeoutMs, maximumTimeoutMs));
|
|
72
|
+
audit(sessionId, "browser_launch", {
|
|
73
|
+
note: "first launch may download Chromium; slow but one-time",
|
|
74
|
+
timeout_ms: timeoutMs,
|
|
75
|
+
});
|
|
76
|
+
let timer;
|
|
77
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
78
|
+
timer = setTimeout(() => reject(new Error("__browser_start_timeout__")), timeoutMs);
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
await Promise.race([browser.start(), timeout]);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err instanceof Error && err.message === "__browser_start_timeout__") {
|
|
85
|
+
const cancellation = cancel().catch(() => undefined);
|
|
86
|
+
if (maximumTimeoutMs === undefined)
|
|
87
|
+
await cancellation;
|
|
88
|
+
throw new Error(`operate_start: browser did not launch within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
89
|
+
"On a fresh machine the first launch downloads Chromium — slow but one-time. A hang this long " +
|
|
90
|
+
"usually means browser binaries are missing on this box. Retry once (a partial download resumes and later launches reuse " +
|
|
91
|
+
"the cache); if it recurs, run `npx @trusty-squire/mcp connect` here to install the browser " +
|
|
92
|
+
"deps, or raise BOT_START_TIMEOUT_MS to wait longer.");
|
|
93
|
+
}
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
if (timer !== undefined)
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function provisionStartGeneration() {
|
|
102
|
+
if (shutdownInProgress > 0) {
|
|
103
|
+
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
104
|
+
}
|
|
105
|
+
return shutdownGeneration;
|
|
106
|
+
}
|
|
107
|
+
function assertProvisionStartAdmitted(generation) {
|
|
108
|
+
if (shutdownInProgress > 0 || generation !== shutdownGeneration) {
|
|
109
|
+
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function acquireWarmBrowser(opts, sessionId) {
|
|
113
|
+
const generation = provisionStartGeneration();
|
|
114
|
+
if ((process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0) {
|
|
115
|
+
throw new Error("operate_start does not support remote CDP with the local Chrome profile");
|
|
116
|
+
}
|
|
117
|
+
const profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
|
|
118
|
+
// This lease is held for the complete operate session. Its on-disk owner
|
|
119
|
+
// records host + pid + process birth time, so a crashed holder is reclaimed
|
|
120
|
+
// while a live or indeterminate holder is never stolen.
|
|
121
|
+
const lease = acquireProfileOperationGuard(profileDir);
|
|
122
|
+
if (!(await waitForProfileFree(profileDir, { deadlineMs: 0 }))) {
|
|
123
|
+
lease.release();
|
|
124
|
+
throw new ProfileBusyError("another Trusty Squire session is already using the browser — close it first");
|
|
125
|
+
}
|
|
126
|
+
const pending = {
|
|
127
|
+
controller: null,
|
|
128
|
+
profileDir,
|
|
129
|
+
launch: Promise.resolve(),
|
|
130
|
+
cancelRequested: false,
|
|
131
|
+
cleanupPromise: null,
|
|
132
|
+
quiescencePromise: null,
|
|
133
|
+
retainProfileUntilQuiescent: false,
|
|
134
|
+
};
|
|
135
|
+
startingBrowsers.add(pending);
|
|
136
|
+
let controller = null;
|
|
137
|
+
try {
|
|
138
|
+
if (pending.cancelRequested) {
|
|
139
|
+
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
140
|
+
}
|
|
141
|
+
controller = new BrowserController({
|
|
142
|
+
profileDir,
|
|
143
|
+
...(opts.proxyUrl !== undefined ? { proxyUrl: opts.proxyUrl } : {}),
|
|
144
|
+
});
|
|
145
|
+
pending.controller = controller;
|
|
146
|
+
pending.launch = startBrowserBounded(controller, sessionId, async () => {
|
|
147
|
+
await cancelStartingBrowser(pending);
|
|
148
|
+
});
|
|
149
|
+
await pending.launch;
|
|
150
|
+
if (pending.cancelRequested) {
|
|
151
|
+
throw new Error("operate_start cancelled: operator server is shutting down");
|
|
152
|
+
}
|
|
153
|
+
assertProvisionStartAdmitted(generation);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
if (controller !== null)
|
|
157
|
+
await controller.close().catch(() => undefined);
|
|
158
|
+
lease.release();
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
startingBrowsers.delete(pending);
|
|
163
|
+
}
|
|
164
|
+
if (controller === null) {
|
|
165
|
+
throw new Error("operate_start cancelled before browser initialization");
|
|
166
|
+
}
|
|
167
|
+
leasedBrowsers.set(controller, {
|
|
168
|
+
controller,
|
|
169
|
+
profileDir,
|
|
170
|
+
lease,
|
|
171
|
+
shutdownGeneration: generation,
|
|
172
|
+
...(opts.proxyUrl === undefined ? {} : { proxyUrl: opts.proxyUrl }),
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
controller,
|
|
176
|
+
profileDir,
|
|
177
|
+
shutdownGeneration: generation,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
async function releaseWarmBrowserPage(browser, _persistState, owner) {
|
|
181
|
+
const leased = leasedBrowsers.get(browser);
|
|
182
|
+
try {
|
|
183
|
+
if (owner?.forced)
|
|
184
|
+
throw new Error("operator browser terminal teardown was forced");
|
|
185
|
+
await browser.close();
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
leasedBrowsers.delete(browser);
|
|
189
|
+
leased?.lease.release();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function forceReleaseWarmBrowserPage(browser, owner) {
|
|
193
|
+
const leased = leasedBrowsers.get(browser);
|
|
194
|
+
await closeBrowserUntilProven(browser, false, "operator browser force-close timed out", () => owner?.requireProvenBrowserClose === true);
|
|
195
|
+
if (leased === undefined)
|
|
196
|
+
return;
|
|
197
|
+
leased.lease.release();
|
|
198
|
+
leasedBrowsers.delete(browser);
|
|
199
|
+
}
|
|
200
|
+
async function closeBrowserBounded(browser, cancelStart, timeoutMessage, maximumTimeoutMs) {
|
|
201
|
+
const forceClose = browser.forceCloseOwnedProcessTree;
|
|
202
|
+
const ordinaryClose = browser
|
|
203
|
+
.close(cancelStart ? { cancelStart: true } : undefined)
|
|
204
|
+
.catch(() => "unknown");
|
|
205
|
+
const forcedClose = forceClose === undefined
|
|
206
|
+
? ordinaryClose
|
|
207
|
+
: forceClose.call(browser).catch(() => "unknown");
|
|
208
|
+
const closed = Promise.race([
|
|
209
|
+
ordinaryClose.then((state) => (state === "closed" ? state : forcedClose)),
|
|
210
|
+
forcedClose.then((state) => (state === "closed" ? state : ordinaryClose)),
|
|
211
|
+
]);
|
|
212
|
+
const configuredTimeoutMs = positiveTimeout("TRUSTY_SQUIRE_OPERATOR_FORCE_CLOSE_TIMEOUT_MS", DEFAULT_OPERATOR_FORCE_CLOSE_TIMEOUT_MS);
|
|
213
|
+
const timeoutMs = maximumTimeoutMs === undefined
|
|
214
|
+
? configuredTimeoutMs
|
|
215
|
+
: Math.max(1, Math.min(configuredTimeoutMs, maximumTimeoutMs));
|
|
216
|
+
return await withTerminalTimeout(closed, timeoutMs, timeoutMessage).catch(() => "unknown");
|
|
217
|
+
}
|
|
218
|
+
async function closeBrowserUntilProven(browser, cancelStart, timeoutMessage, requireProof) {
|
|
219
|
+
let closeState = await closeBrowserBounded(browser, cancelStart, timeoutMessage);
|
|
220
|
+
while (closeState !== "closed" && requireProof()) {
|
|
221
|
+
await new Promise((resolve) => {
|
|
222
|
+
setTimeout(resolve, 25);
|
|
223
|
+
});
|
|
224
|
+
closeState = await closeBrowserBounded(browser, true, timeoutMessage);
|
|
225
|
+
}
|
|
226
|
+
return closeState;
|
|
227
|
+
}
|
|
228
|
+
async function cancelStartingBrowser(pending, maximumTimeoutMs) {
|
|
229
|
+
pending.cancelRequested = true;
|
|
230
|
+
if (pending.cleanupPromise !== null)
|
|
231
|
+
return await pending.cleanupPromise;
|
|
232
|
+
pending.cleanupPromise = (async () => {
|
|
233
|
+
if (pending.controller === null) {
|
|
234
|
+
return "closed";
|
|
235
|
+
}
|
|
236
|
+
const closeState = await closeBrowserBounded(pending.controller, true, "operator browser startup cancellation timed out", maximumTimeoutMs);
|
|
237
|
+
return closeState;
|
|
238
|
+
})();
|
|
239
|
+
return await pending.cleanupPromise;
|
|
240
|
+
}
|
|
241
|
+
async function quiesceStartingBrowser(pending, requireProvenClose = false) {
|
|
242
|
+
pending.retainProfileUntilQuiescent = true;
|
|
243
|
+
if (pending.quiescencePromise === null) {
|
|
244
|
+
pending.quiescencePromise = (async () => {
|
|
245
|
+
let closeState = await cancelStartingBrowser(pending);
|
|
246
|
+
if (pending.controller === null)
|
|
247
|
+
return closeState;
|
|
248
|
+
await pending.controller.waitForCancelledStartQuiescence();
|
|
249
|
+
if (closeState !== "closed") {
|
|
250
|
+
closeState = await closeBrowserBounded(pending.controller, true, "operator browser startup cancellation did not quiesce");
|
|
251
|
+
}
|
|
252
|
+
return closeState;
|
|
253
|
+
})();
|
|
254
|
+
void pending.quiescencePromise.then((closeState) => {
|
|
255
|
+
if (closeState === "closed")
|
|
256
|
+
startingBrowsers.delete(pending);
|
|
257
|
+
}, () => undefined);
|
|
258
|
+
}
|
|
259
|
+
let closeState = await pending.quiescencePromise;
|
|
260
|
+
while (closeState !== "closed" && requireProvenClose) {
|
|
261
|
+
await new Promise((resolve) => {
|
|
262
|
+
setTimeout(resolve, 25);
|
|
263
|
+
});
|
|
264
|
+
closeState = await closeBrowserBounded(pending.controller, true, "operator browser startup cancellation did not quiesce");
|
|
265
|
+
}
|
|
266
|
+
if (closeState === "closed")
|
|
267
|
+
startingBrowsers.delete(pending);
|
|
268
|
+
return closeState;
|
|
269
|
+
}
|
|
270
|
+
function stopSessionWatchdog(session) {
|
|
271
|
+
session.watchdog?.stop();
|
|
272
|
+
}
|
|
273
|
+
function disposeSessionWatchdog(session) {
|
|
274
|
+
session.watchdog?.dispose();
|
|
275
|
+
session.watchdog = null;
|
|
276
|
+
}
|
|
277
|
+
const DEFAULT_PENDING_THREE_DS_FINALIZE_TIMEOUT_MS = 3_000;
|
|
278
|
+
const DEFAULT_SESSION_TERMINAL_TRANSITION_TIMEOUT_MS = 30_000;
|
|
279
|
+
const DEFAULT_OPERATOR_FORCE_CLOSE_TIMEOUT_MS = 3_000;
|
|
280
|
+
function positiveTimeout(name, fallback) {
|
|
281
|
+
const value = Number(process.env[name]);
|
|
282
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
283
|
+
}
|
|
284
|
+
async function withTerminalTimeout(promise, timeoutMs, message) {
|
|
285
|
+
let timer;
|
|
286
|
+
try {
|
|
287
|
+
return await Promise.race([
|
|
288
|
+
promise,
|
|
289
|
+
new Promise((_, reject) => {
|
|
290
|
+
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
291
|
+
}),
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
if (timer !== undefined)
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async function forceTerminateProvisionSession(session, event, detail, auditPendingThreeDs = true, requireProvenBrowserClose = false) {
|
|
300
|
+
session.paymentDispatchClosed = true;
|
|
301
|
+
const owner = session.terminalTeardownOwner ??
|
|
302
|
+
(session.terminalTeardownOwner = {
|
|
303
|
+
forced: false,
|
|
304
|
+
forcePromise: null,
|
|
305
|
+
routinePromise: null,
|
|
306
|
+
requireProvenBrowserClose: false,
|
|
307
|
+
});
|
|
308
|
+
if (requireProvenBrowserClose)
|
|
309
|
+
owner.requireProvenBrowserClose = true;
|
|
310
|
+
if (owner.forcePromise !== null) {
|
|
311
|
+
const terminalError = await owner.forcePromise;
|
|
312
|
+
if (owner.requireProvenBrowserClose && leasedBrowsers.has(session.browser)) {
|
|
313
|
+
await forceReleaseWarmBrowserPage(session.browser, owner);
|
|
314
|
+
}
|
|
315
|
+
return terminalError;
|
|
316
|
+
}
|
|
317
|
+
owner.forced = true;
|
|
318
|
+
owner.forcePromise = forceTerminateProvisionSessionOwned(session, event, detail, auditPendingThreeDs);
|
|
319
|
+
return await owner.forcePromise;
|
|
320
|
+
}
|
|
321
|
+
async function forceTerminateProvisionSessionOwned(session, event, detail, auditPendingThreeDs) {
|
|
322
|
+
session.closing = true;
|
|
323
|
+
stopSessionWatchdog(session);
|
|
324
|
+
audit(session.id, event, detail);
|
|
325
|
+
const handoff = session.paymentDispatchHandoff;
|
|
326
|
+
if (handoff !== null) {
|
|
327
|
+
handoff.terminalizing = true;
|
|
328
|
+
const timeoutMs = positiveTimeout("TRUSTY_SQUIRE_OPERATOR_PENDING_3DS_FINALIZE_TIMEOUT_MS", DEFAULT_PENDING_THREE_DS_FINALIZE_TIMEOUT_MS);
|
|
329
|
+
await withTerminalTimeout(handoff.settled, timeoutMs, `payment dispatch handoff exceeded ${timeoutMs}ms`).catch(() => undefined);
|
|
330
|
+
}
|
|
331
|
+
deregisterProvisionSession(session);
|
|
332
|
+
let terminalError;
|
|
333
|
+
if (auditPendingThreeDs && session.pendingThreeDs !== null) {
|
|
334
|
+
try {
|
|
335
|
+
await auditPendingThreeDsForSessionCloseBounded(session);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
terminalError = error;
|
|
339
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
340
|
+
process.stderr.write(`[operator] terminal 3DS audit failed session=${session.id}: ${message}\n`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (handoff !== null) {
|
|
344
|
+
handoff.terminalComplete = true;
|
|
345
|
+
if (handoff.released && session.paymentDispatchHandoff === handoff) {
|
|
346
|
+
session.paymentDispatchHandoff = null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
session.activePayment = null;
|
|
350
|
+
session.paymentFieldSealActive = false;
|
|
351
|
+
session.pendingThreeDs = null;
|
|
352
|
+
const terminalOwner = session.terminalTeardownOwner ?? undefined;
|
|
353
|
+
const ephemeral = leasedBrowsers.get(session.browser);
|
|
354
|
+
if (terminalOwner?.requireProvenBrowserClose === true && ephemeral !== undefined) {
|
|
355
|
+
await Promise.all([...startingBrowsers]
|
|
356
|
+
.filter((pending) => pending.profileDir === ephemeral.profileDir)
|
|
357
|
+
.map(async (pending) => await quiesceStartingBrowser(pending, true)));
|
|
358
|
+
}
|
|
359
|
+
await forceReleaseWarmBrowserPage(session.browser, terminalOwner).catch((error) => {
|
|
360
|
+
if (terminalError === undefined)
|
|
361
|
+
terminalError = error;
|
|
362
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
363
|
+
process.stderr.write(`[operator] terminal browser close failed session=${session.id}: ${message}\n`);
|
|
364
|
+
});
|
|
365
|
+
disposeSessionWatchdog(session);
|
|
366
|
+
return terminalError;
|
|
367
|
+
}
|
|
368
|
+
async function terminateExpiredProvisionSession(session, reason) {
|
|
369
|
+
if (session.initializing ||
|
|
370
|
+
session.closing ||
|
|
371
|
+
session.callCount > 0 ||
|
|
372
|
+
session.paymentCallCount > 0 ||
|
|
373
|
+
sessions.get(session.id) !== session) {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
const owner = session.terminalTeardownOwner ??
|
|
377
|
+
(session.terminalTeardownOwner = {
|
|
378
|
+
forced: false,
|
|
379
|
+
forcePromise: null,
|
|
380
|
+
routinePromise: null,
|
|
381
|
+
requireProvenBrowserClose: false,
|
|
382
|
+
});
|
|
383
|
+
if (owner.forcePromise !== null)
|
|
384
|
+
return false;
|
|
385
|
+
if (owner.routinePromise !== null) {
|
|
386
|
+
await owner.routinePromise;
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
session.closing = true;
|
|
390
|
+
stopSessionWatchdog(session);
|
|
391
|
+
owner.routinePromise = (async () => {
|
|
392
|
+
if (reason.kind !== "idle_timeout" && session.paymentCallCount > 0) {
|
|
393
|
+
const timeoutMs = positiveTimeout("TRUSTY_SQUIRE_OPERATOR_TERMINAL_TRANSITION_TIMEOUT_MS", DEFAULT_SESSION_TERMINAL_TRANSITION_TIMEOUT_MS);
|
|
394
|
+
await withTerminalTimeout(waitForPaymentCallsToDrain(session), timeoutMs, `payment call drain exceeded ${timeoutMs}ms`).catch(() => undefined);
|
|
395
|
+
}
|
|
396
|
+
if (owner.forcePromise !== null) {
|
|
397
|
+
await owner.forcePromise;
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
await forceTerminateProvisionSession(session, "browser_watchdog_terminate", { ...reason });
|
|
401
|
+
})();
|
|
402
|
+
await owner.routinePromise;
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
function startSessionWatchdog(session) {
|
|
406
|
+
if (session.watchdog !== null) {
|
|
407
|
+
session.watchdog.start();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const watchdog = new OperatorBrowserWatchdog({
|
|
411
|
+
startedAt: session.startedAt,
|
|
412
|
+
lastActivityAt: () => session.lastActivityAt,
|
|
413
|
+
hasActiveCall: () => session.initializing || session.callCount > 0 || session.paymentCallCount > 0,
|
|
414
|
+
processMarker: () => session.browser.operatorBrowserMarker?.() ?? null,
|
|
415
|
+
onTerminate: async (reason) => await terminateExpiredProvisionSession(session, reason),
|
|
416
|
+
});
|
|
417
|
+
session.watchdog = watchdog;
|
|
418
|
+
watchdog.start();
|
|
419
|
+
}
|
|
420
|
+
export function sessionForCall(sessionId) {
|
|
421
|
+
return sessions.get(sessionId);
|
|
422
|
+
}
|
|
423
|
+
// Money rule (simplified 2026-08-16): the fence is the live human biometric
|
|
424
|
+
// approval per charge, not a software re-check of replay field values. The
|
|
425
|
+
// only surviving invariant — a card-charging trace step is never blind-
|
|
426
|
+
// replayed — is enforced unconditionally where operate_pay steps are
|
|
427
|
+
// encountered during replay (see replayOperatorRecipe), not here.
|
|
428
|
+
function assertPaymentSessionAllowed(session) {
|
|
429
|
+
if (session.closing) {
|
|
430
|
+
throw new Error(`provision session ${session.id} is closing`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Resolve the compatibility omission once, at tool entry. In particular, do
|
|
434
|
+
// not repeat this lookup in completion callbacks: after an await, a different
|
|
435
|
+
// session could otherwise become the sole process-local session.
|
|
436
|
+
export function paymentSession(sessionId) {
|
|
437
|
+
let session;
|
|
438
|
+
if (sessionId !== undefined) {
|
|
439
|
+
session = sessionForCall(sessionId);
|
|
440
|
+
if (session === undefined)
|
|
441
|
+
throw new Error(`unknown provision session ${sessionId}`);
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
if (sessions.size !== 1) {
|
|
445
|
+
throw new Error(sessions.size === 0
|
|
446
|
+
? "operate_pay requires one active operate_start browser session"
|
|
447
|
+
: "operate_pay requires session_id when multiple operator sessions are active");
|
|
448
|
+
}
|
|
449
|
+
session = sessions.values().next().value;
|
|
450
|
+
}
|
|
451
|
+
assertPaymentSessionAllowed(session);
|
|
452
|
+
return session;
|
|
453
|
+
}
|
|
454
|
+
function acquireSessionCallLease(session) {
|
|
455
|
+
if (session.closing)
|
|
456
|
+
throw new Error(`provision session ${session.id} is closing`);
|
|
457
|
+
session.lastActivityAt = Date.now();
|
|
458
|
+
session.callCount += 1;
|
|
459
|
+
let released = false;
|
|
460
|
+
return () => {
|
|
461
|
+
if (released)
|
|
462
|
+
return;
|
|
463
|
+
released = true;
|
|
464
|
+
session.callCount -= 1;
|
|
465
|
+
session.lastActivityAt = Date.now();
|
|
466
|
+
if (session.callCount === 0) {
|
|
467
|
+
session.lastActivityAt = Date.now();
|
|
468
|
+
for (const wake of session.callDrainWaiters)
|
|
469
|
+
wake();
|
|
470
|
+
session.callDrainWaiters.clear();
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function acquirePaymentCallLease(session) {
|
|
475
|
+
session.paymentCallCount += 1;
|
|
476
|
+
let released = false;
|
|
477
|
+
return () => {
|
|
478
|
+
if (released)
|
|
479
|
+
return;
|
|
480
|
+
released = true;
|
|
481
|
+
session.paymentCallCount -= 1;
|
|
482
|
+
if (session.paymentCallCount === 0) {
|
|
483
|
+
for (const wake of session.paymentCallDrainWaiters)
|
|
484
|
+
wake();
|
|
485
|
+
session.paymentCallDrainWaiters.clear();
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
async function waitForPaymentCallsToDrain(session) {
|
|
490
|
+
if (session.paymentCallCount === 0)
|
|
491
|
+
return;
|
|
492
|
+
await new Promise((resolve) => {
|
|
493
|
+
session.paymentCallDrainWaiters.add(resolve);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
async function waitForSessionCallsToDrain(session) {
|
|
497
|
+
if (session.callCount === 0)
|
|
498
|
+
return;
|
|
499
|
+
await new Promise((resolve) => {
|
|
500
|
+
session.callDrainWaiters.add(resolve);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
async function withSelectedProvisionSessionCall(session, fn) {
|
|
504
|
+
const release = acquireSessionCallLease(session);
|
|
505
|
+
try {
|
|
506
|
+
return await fn(session);
|
|
507
|
+
}
|
|
508
|
+
finally {
|
|
509
|
+
release();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
export async function withProvisionSessionCall(sessionId, fn) {
|
|
513
|
+
const session = sessionForCall(sessionId);
|
|
514
|
+
if (session === undefined)
|
|
515
|
+
throw new Error(`unknown provision session ${sessionId}`);
|
|
516
|
+
return await withSelectedProvisionSessionCall(session, fn);
|
|
517
|
+
}
|
|
518
|
+
export async function withPaymentSessionCall(sessionId, fn) {
|
|
519
|
+
const session = paymentSession(sessionId);
|
|
520
|
+
return await withSelectedProvisionSessionCall(session, async (selectedSession) => {
|
|
521
|
+
const releasePaymentCall = acquirePaymentCallLease(selectedSession);
|
|
522
|
+
try {
|
|
523
|
+
return await fn(selectedSession);
|
|
524
|
+
}
|
|
525
|
+
finally {
|
|
526
|
+
releasePaymentCall();
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
// Where a session's rolling observe snapshot lives. Owned here because the
|
|
531
|
+
// terminal artifact cleanup must remove exactly this directory; perception
|
|
532
|
+
// writes into it through the same helper.
|
|
533
|
+
export function observeSnapshotDir(sessionId) {
|
|
534
|
+
const override = (process.env.TRUSTY_SQUIRE_OBSERVE_DIR ?? "").trim();
|
|
535
|
+
const parent = override.length > 0 ? override : join(tmpdir(), "trusty-squire-observe");
|
|
536
|
+
return join(parent, sessionId);
|
|
537
|
+
}
|
|
538
|
+
function configuredCompactV2Mode() {
|
|
539
|
+
const configured = (process.env.TRUSTY_SQUIRE_OBSERVE_V2 ?? "on").toLowerCase();
|
|
540
|
+
if (configured === "off" || configured === "0")
|
|
541
|
+
return "off";
|
|
542
|
+
return configured === "shadow" ? "shadow" : "on";
|
|
543
|
+
}
|
|
544
|
+
export function googleSessionGate(liveProviders) {
|
|
545
|
+
if (liveProviders.includes("google"))
|
|
546
|
+
return { ok: true };
|
|
547
|
+
return {
|
|
548
|
+
ok: false,
|
|
549
|
+
needs_user: {
|
|
550
|
+
wall: "google_session",
|
|
551
|
+
message: "No live Google session in your Chrome profile, so the operator cannot act " +
|
|
552
|
+
"as you yet. Reconnect with `npx @trusty-squire/mcp connect --force-relogin=google` " +
|
|
553
|
+
"and retry " +
|
|
554
|
+
"— the task has NOT started and nothing was changed.",
|
|
555
|
+
resume: "connect",
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
async function ensureProvisionPrimaryProviderSession(browser) {
|
|
560
|
+
// Chrome materializes the real profile's provider jar after the account
|
|
561
|
+
// surface is opened in this same context. Match the proven live-identity
|
|
562
|
+
// path before reading the markers. The account lookup warms the context; it
|
|
563
|
+
// is not itself the admission signal.
|
|
564
|
+
if (typeof browser.detectGoogleAccountEmail === "function") {
|
|
565
|
+
await browser.detectGoogleAccountEmail().catch(() => null);
|
|
566
|
+
}
|
|
567
|
+
if (typeof browser.detectSessionProviders !== "function") {
|
|
568
|
+
console.error("[operate] provider-session detection unavailable on this browser controller — " +
|
|
569
|
+
"treating as no live provider session");
|
|
570
|
+
return [];
|
|
571
|
+
}
|
|
572
|
+
// Fail closed, but never SILENTLY: an empty list refuses the start with the
|
|
573
|
+
// same `google_session` wall as a genuinely signed-out profile, so a throwing
|
|
574
|
+
// probe used to be indistinguishable from "not signed in". Say which it was.
|
|
575
|
+
return await browser.detectSessionProviders().catch((err) => {
|
|
576
|
+
console.error(`[operate] provider-session detection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
577
|
+
return [];
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
export async function startProvisionSession(opts, ports) {
|
|
581
|
+
const id = randomUUID();
|
|
582
|
+
const compactV2Mode = configuredCompactV2Mode();
|
|
583
|
+
let browser;
|
|
584
|
+
let liveProviders;
|
|
585
|
+
let workerEmail = null;
|
|
586
|
+
const acquired = await acquireWarmBrowser(opts, id);
|
|
587
|
+
browser = acquired.controller;
|
|
588
|
+
try {
|
|
589
|
+
liveProviders = await ensureProvisionPrimaryProviderSession(browser);
|
|
590
|
+
assertProvisionStartAdmitted(acquired.shutdownGeneration);
|
|
591
|
+
const gate = googleSessionGate(liveProviders);
|
|
592
|
+
if (!gate.ok) {
|
|
593
|
+
audit(id, "connect_gate", { ok: false, wall: "google_session" });
|
|
594
|
+
await releaseWarmBrowserPage(browser, false);
|
|
595
|
+
refusedStartSessionIds.add(id);
|
|
596
|
+
return compactV2Mode === "on"
|
|
597
|
+
? {
|
|
598
|
+
session_id: id,
|
|
599
|
+
format: "compact-v2",
|
|
600
|
+
stage: "auth",
|
|
601
|
+
url: "",
|
|
602
|
+
text: "",
|
|
603
|
+
needs_user: gate.needs_user,
|
|
604
|
+
}
|
|
605
|
+
: { session_id: id, url: "", text: "", elements: [], needs_user: gate.needs_user };
|
|
606
|
+
}
|
|
607
|
+
workerEmail =
|
|
608
|
+
typeof browser.detectGoogleAccountEmail === "function"
|
|
609
|
+
? await browser.detectGoogleAccountEmail().catch(() => null)
|
|
610
|
+
: null;
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
await releaseWarmBrowserPage(browser, false);
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
const targetHost = registrableHost(opts.serviceUrl);
|
|
617
|
+
const seedHosts = [
|
|
618
|
+
...(targetHost !== null ? [targetHost] : []),
|
|
619
|
+
...(opts.extraAllowedHosts ?? []),
|
|
620
|
+
];
|
|
621
|
+
// All start-declared hosts are sourced "start" — auto-widen chains off these,
|
|
622
|
+
// and credential egress may seed from these (but never from mid_session).
|
|
623
|
+
const allowedHosts = [...new Set(seedHosts)].map((host) => ({
|
|
624
|
+
host,
|
|
625
|
+
source: "start",
|
|
626
|
+
}));
|
|
627
|
+
const session = createSession({
|
|
628
|
+
id,
|
|
629
|
+
browser,
|
|
630
|
+
allowedHosts,
|
|
631
|
+
compactV2Mode,
|
|
632
|
+
startUrl: opts.serviceUrl,
|
|
633
|
+
hintServed: opts.hint !== undefined,
|
|
634
|
+
consentInboxRead: opts.consentInboxRead !== false,
|
|
635
|
+
userEmail: workerEmail,
|
|
636
|
+
...(opts.api !== undefined ? { api: opts.api } : {}),
|
|
637
|
+
});
|
|
638
|
+
sessions.set(id, session);
|
|
639
|
+
startSessionWatchdog(session);
|
|
640
|
+
try {
|
|
641
|
+
if (typeof browser.setHostScopeAllowedHosts === "function") {
|
|
642
|
+
await browser.setHostScopeAllowedHosts(() => requestScopeHostStrings(session), () => merchantSiblingSeedHosts(session));
|
|
643
|
+
}
|
|
644
|
+
audit(id, "start", {
|
|
645
|
+
service_url: opts.serviceUrl,
|
|
646
|
+
allowed_hosts: hostStrings(session),
|
|
647
|
+
has_hint: opts.hint !== undefined,
|
|
648
|
+
});
|
|
649
|
+
await browser.goto(opts.serviceUrl);
|
|
650
|
+
// A cookie/consent overlay (Usercentrics/OneTrust/…) renders after load and its
|
|
651
|
+
// backdrop occludes the ENTIRE form — the agent then sees every element
|
|
652
|
+
// occluded_by a div and gives up, or falls back to the only thing that looks
|
|
653
|
+
// clickable (e.g. a "Connect wallet" CTA on the Robinhood faucet). Dismiss it
|
|
654
|
+
// BEFORE the first observation so the real actionable form is operable.
|
|
655
|
+
// dismissConsentBanner() existed but had NO call sites (dead code); it only
|
|
656
|
+
// clicks banner-specific CTAs (accept/reject all), so a false click is unlikely.
|
|
657
|
+
// Best-effort + one retry, since the widget lazy-loads a beat after the goto.
|
|
658
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
659
|
+
const cta = await browser.dismissConsentBanner().catch(() => null);
|
|
660
|
+
if (cta !== null) {
|
|
661
|
+
audit(id, "consent_dismissed", { cta });
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
if (attempt === 0)
|
|
665
|
+
await browser.waitForCaptchaChallengeToSettle(800, 0).catch(() => false);
|
|
666
|
+
}
|
|
667
|
+
// Tell the agent which provider the user actually has a live session for
|
|
668
|
+
// (Google-preferred) — the bot knows from the profile cookies, so the agent
|
|
669
|
+
// doesn't have to guess. Composed with the skill route hint (if any).
|
|
670
|
+
const loginHint = loginSessionGuidance(liveProviders);
|
|
671
|
+
const hintParts = [loginHint, ...(opts.hint !== undefined ? [opts.hint] : [])];
|
|
672
|
+
const observation = await ports.observeSession(session, "compact", ports.compactV2StartMetadata(opts.hint, loginHint, session.userEmail));
|
|
673
|
+
session.initializing = false;
|
|
674
|
+
session.lastActivityAt = Date.now();
|
|
675
|
+
if (observation.format === "compact-v2")
|
|
676
|
+
return observation;
|
|
677
|
+
return {
|
|
678
|
+
...observation,
|
|
679
|
+
hint: hintParts.join("\n"),
|
|
680
|
+
...(session.userEmail !== null ? { user_email: session.userEmail } : {}),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
deregisterProvisionSession(session);
|
|
685
|
+
disposeSessionWatchdog(session);
|
|
686
|
+
await releaseWarmBrowserPage(browser, false);
|
|
687
|
+
throw err;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/** Start a normal guarded session on a caller-owned harness page. */
|
|
691
|
+
export async function startHarnessProvisionSession(opts, ports) {
|
|
692
|
+
const id = randomUUID();
|
|
693
|
+
const targetHost = registrableHost(opts.serviceUrl);
|
|
694
|
+
const allowedHosts = [
|
|
695
|
+
...(targetHost === null ? [] : [targetHost]),
|
|
696
|
+
...(opts.extraAllowedHosts ?? []),
|
|
697
|
+
]
|
|
698
|
+
.filter((host, index, hosts) => hosts.indexOf(host) === index)
|
|
699
|
+
.map((host) => ({
|
|
700
|
+
host,
|
|
701
|
+
source: "start",
|
|
702
|
+
}));
|
|
703
|
+
const session = createSession({
|
|
704
|
+
id,
|
|
705
|
+
browser: opts.browser,
|
|
706
|
+
allowedHosts,
|
|
707
|
+
compactV2Mode: opts.observationFormat === "compact-v2" ? "on" : "off",
|
|
708
|
+
startUrl: opts.serviceUrl,
|
|
709
|
+
hintServed: opts.hint !== undefined,
|
|
710
|
+
consentInboxRead: opts.consentInboxRead !== false,
|
|
711
|
+
userEmail: null,
|
|
712
|
+
...(opts.api === undefined ? {} : { api: opts.api }),
|
|
713
|
+
});
|
|
714
|
+
sessions.set(id, session);
|
|
715
|
+
startSessionWatchdog(session);
|
|
716
|
+
try {
|
|
717
|
+
audit(id, "start_harness", {
|
|
718
|
+
service_url: opts.serviceUrl,
|
|
719
|
+
allowed_hosts: hostStrings(session),
|
|
720
|
+
});
|
|
721
|
+
await opts.browser.goto(opts.serviceUrl);
|
|
722
|
+
const observation = await ports.observeSession(session, "compact", ports.compactV2StartMetadata(opts.hint, "", null));
|
|
723
|
+
session.initializing = false;
|
|
724
|
+
session.lastActivityAt = Date.now();
|
|
725
|
+
if (observation.format === "compact-v2")
|
|
726
|
+
return observation;
|
|
727
|
+
return { ...observation, hint: opts.hint ?? "" };
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
deregisterProvisionSession(session);
|
|
731
|
+
disposeSessionWatchdog(session);
|
|
732
|
+
await opts.browser.close().catch(() => undefined);
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
function profileRequiresDestroy(session) {
|
|
737
|
+
return (session.activePayment !== null ||
|
|
738
|
+
session.paymentFieldSealActive ||
|
|
739
|
+
session.pendingThreeDs !== null);
|
|
740
|
+
}
|
|
741
|
+
const OBSERVE_SNAPSHOT_CLEANUP_RETRY_MS = 250;
|
|
742
|
+
const OBSERVE_SNAPSHOT_SHUTDOWN_DRAIN_MS = 500;
|
|
743
|
+
const pendingObserveSnapshotCleanup = new Set();
|
|
744
|
+
let observeSnapshotCleanupTimer = null;
|
|
745
|
+
function observeSnapshotPathState(path) {
|
|
746
|
+
try {
|
|
747
|
+
lstatSync(path);
|
|
748
|
+
return "present";
|
|
749
|
+
}
|
|
750
|
+
catch (error) {
|
|
751
|
+
return error.code === "ENOENT" ? "missing" : "unknown";
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
function scheduleObserveSnapshotCleanup() {
|
|
755
|
+
if (observeSnapshotCleanupTimer !== null || pendingObserveSnapshotCleanup.size === 0)
|
|
756
|
+
return;
|
|
757
|
+
observeSnapshotCleanupTimer = setTimeout(() => {
|
|
758
|
+
observeSnapshotCleanupTimer = null;
|
|
759
|
+
retryPendingObserveSnapshotCleanup();
|
|
760
|
+
scheduleObserveSnapshotCleanup();
|
|
761
|
+
}, OBSERVE_SNAPSHOT_CLEANUP_RETRY_MS);
|
|
762
|
+
observeSnapshotCleanupTimer.unref();
|
|
763
|
+
}
|
|
764
|
+
function retryPendingObserveSnapshotCleanup() {
|
|
765
|
+
for (const path of pendingObserveSnapshotCleanup) {
|
|
766
|
+
try {
|
|
767
|
+
rmSync(path, { recursive: true, force: true });
|
|
768
|
+
}
|
|
769
|
+
catch { }
|
|
770
|
+
if (observeSnapshotPathState(path) === "missing") {
|
|
771
|
+
pendingObserveSnapshotCleanup.delete(path);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
async function drainPendingObserveSnapshotCleanup() {
|
|
776
|
+
if (observeSnapshotCleanupTimer !== null) {
|
|
777
|
+
clearTimeout(observeSnapshotCleanupTimer);
|
|
778
|
+
observeSnapshotCleanupTimer = null;
|
|
779
|
+
}
|
|
780
|
+
const deadline = Date.now() + OBSERVE_SNAPSHOT_SHUTDOWN_DRAIN_MS;
|
|
781
|
+
do {
|
|
782
|
+
retryPendingObserveSnapshotCleanup();
|
|
783
|
+
if (pendingObserveSnapshotCleanup.size === 0)
|
|
784
|
+
return;
|
|
785
|
+
await new Promise((resolveWait) => {
|
|
786
|
+
setTimeout(resolveWait, Math.min(25, Math.max(1, deadline - Date.now())));
|
|
787
|
+
});
|
|
788
|
+
} while (Date.now() < deadline);
|
|
789
|
+
retryPendingObserveSnapshotCleanup();
|
|
790
|
+
scheduleObserveSnapshotCleanup();
|
|
791
|
+
}
|
|
792
|
+
function removeObserveSnapshotDirectory(path) {
|
|
793
|
+
let failure;
|
|
794
|
+
try {
|
|
795
|
+
rmSync(path, { recursive: true, force: true });
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
failure = error;
|
|
799
|
+
}
|
|
800
|
+
if (observeSnapshotPathState(path) === "missing") {
|
|
801
|
+
pendingObserveSnapshotCleanup.delete(path);
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
pendingObserveSnapshotCleanup.add(path);
|
|
805
|
+
scheduleObserveSnapshotCleanup();
|
|
806
|
+
}
|
|
807
|
+
return failure;
|
|
808
|
+
}
|
|
809
|
+
function clearSessionArtifacts(session) {
|
|
810
|
+
session.prevObserve = null;
|
|
811
|
+
session.observeSnapshotFile = null;
|
|
812
|
+
session.secretSlots.clear();
|
|
813
|
+
const error = removeObserveSnapshotDirectory(observeSnapshotDir(session.id));
|
|
814
|
+
if (error !== undefined) {
|
|
815
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
816
|
+
process.stderr.write(`[operator] session artifact cleanup failed session=${session.id}: ${message}\n`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
function deregisterProvisionSession(session) {
|
|
820
|
+
clearSessionArtifacts(session);
|
|
821
|
+
if (sessions.get(session.id) === session)
|
|
822
|
+
sessions.delete(session.id);
|
|
823
|
+
}
|
|
824
|
+
function pendingThreeDsAuditStatus(resolution, pending) {
|
|
825
|
+
if (resolution === "succeeded")
|
|
826
|
+
return "payment_submitted";
|
|
827
|
+
if (resolution === "failed")
|
|
828
|
+
return "payment_declined";
|
|
829
|
+
if (resolution === "challenge_pending")
|
|
830
|
+
pending.outcome = "three_ds";
|
|
831
|
+
return pending.outcome === "three_ds" ? "payment_3ds_unresolved" : "payment_outcome_unknown";
|
|
832
|
+
}
|
|
833
|
+
async function auditPendingThreeDsForSessionClose(session) {
|
|
834
|
+
const pending = session.pendingThreeDs;
|
|
835
|
+
if (pending === null)
|
|
836
|
+
return;
|
|
837
|
+
if (session.api === undefined) {
|
|
838
|
+
throw new Error("operate_finish refused: pending 3-D Secure outcome cannot be audited without an active API session");
|
|
839
|
+
}
|
|
840
|
+
const resolution = await session.browser.waitForThreeDsResolution(0);
|
|
841
|
+
const recordAudit = async () => {
|
|
842
|
+
await session.api.auditPayment({
|
|
843
|
+
...pending.checkout,
|
|
844
|
+
last4: pending.last4,
|
|
845
|
+
status: pendingThreeDsAuditStatus(resolution, pending),
|
|
846
|
+
approval_id: pending.approval_id,
|
|
847
|
+
...(pending.mandate_id !== undefined ? { mandate_id: pending.mandate_id } : {}),
|
|
848
|
+
});
|
|
849
|
+
};
|
|
850
|
+
const handoff = session.paymentDispatchHandoff;
|
|
851
|
+
if (handoff?.state === pending) {
|
|
852
|
+
handoff.auditPromise ??= recordAudit();
|
|
853
|
+
await handoff.auditPromise;
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
await recordAudit();
|
|
857
|
+
}
|
|
858
|
+
async function auditPendingThreeDsForSessionCloseBounded(session) {
|
|
859
|
+
const timeoutMs = positiveTimeout("TRUSTY_SQUIRE_OPERATOR_PENDING_3DS_FINALIZE_TIMEOUT_MS", DEFAULT_PENDING_THREE_DS_FINALIZE_TIMEOUT_MS);
|
|
860
|
+
await withTerminalTimeout(auditPendingThreeDsForSessionClose(session), timeoutMs, `pending 3-D Secure finalization exceeded ${timeoutMs}ms`);
|
|
861
|
+
}
|
|
862
|
+
async function closeFinishingProvisionSession(session, persistState) {
|
|
863
|
+
const sessionId = session.id;
|
|
864
|
+
await auditPendingThreeDsForSessionCloseBounded(session);
|
|
865
|
+
const url = session.browser.currentUrl();
|
|
866
|
+
audit(sessionId, "finish", { url });
|
|
867
|
+
session.activePayment = null;
|
|
868
|
+
session.paymentFieldSealActive = false;
|
|
869
|
+
session.pendingThreeDs = null;
|
|
870
|
+
stopSessionWatchdog(session);
|
|
871
|
+
await releaseWarmBrowserPage(session.browser, persistState, session.terminalTeardownOwner ?? undefined);
|
|
872
|
+
deregisterProvisionSession(session);
|
|
873
|
+
disposeSessionWatchdog(session);
|
|
874
|
+
return { session_id: sessionId, url, closed: true };
|
|
875
|
+
}
|
|
876
|
+
export async function finishProvisionSessionWithPreparation(sessionId, prepare, successfulOutcome = () => false) {
|
|
877
|
+
const session = sessionForCall(sessionId);
|
|
878
|
+
if (session === undefined)
|
|
879
|
+
throw new Error(`unknown provision session ${sessionId}`);
|
|
880
|
+
if (session.closing)
|
|
881
|
+
throw new Error(`provision session ${sessionId} is already closing`);
|
|
882
|
+
const owner = {
|
|
883
|
+
forced: false,
|
|
884
|
+
forcePromise: null,
|
|
885
|
+
routinePromise: null,
|
|
886
|
+
requireProvenBrowserClose: false,
|
|
887
|
+
};
|
|
888
|
+
session.terminalTeardownOwner = owner;
|
|
889
|
+
session.closing = true;
|
|
890
|
+
stopSessionWatchdog(session);
|
|
891
|
+
const transition = (async () => {
|
|
892
|
+
await waitForSessionCallsToDrain(session);
|
|
893
|
+
const prepared = await prepare();
|
|
894
|
+
if (owner.forced || sessions.get(sessionId) !== session) {
|
|
895
|
+
throw new Error(`provision session ${sessionId} terminal transition was forced`);
|
|
896
|
+
}
|
|
897
|
+
const persistState = successfulOutcome() && !profileRequiresDestroy(session);
|
|
898
|
+
try {
|
|
899
|
+
const finish = await closeFinishingProvisionSession(session, persistState);
|
|
900
|
+
return { finish, prepared };
|
|
901
|
+
}
|
|
902
|
+
catch (error) {
|
|
903
|
+
await forceTerminateProvisionSession(session, "finish_forced_terminate", { reason: "terminal_close_failed" }, false);
|
|
904
|
+
throw error;
|
|
905
|
+
}
|
|
906
|
+
})();
|
|
907
|
+
try {
|
|
908
|
+
return await transition;
|
|
909
|
+
}
|
|
910
|
+
catch (error) {
|
|
911
|
+
if (!owner.forced && sessions.get(sessionId) === session) {
|
|
912
|
+
session.closing = false;
|
|
913
|
+
session.terminalTeardownOwner = null;
|
|
914
|
+
startSessionWatchdog(session);
|
|
915
|
+
}
|
|
916
|
+
throw error;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
export async function finishProvisionSession(sessionId) {
|
|
920
|
+
if (refusedStartSessionIds.delete(sessionId)) {
|
|
921
|
+
return { session_id: sessionId, url: "", closed: true };
|
|
922
|
+
}
|
|
923
|
+
return (await finishProvisionSessionWithPreparation(sessionId, async () => undefined)).finish;
|
|
924
|
+
}
|
|
925
|
+
// Test/teardown helper — close every live session (used by the dev shim on exit).
|
|
926
|
+
export async function closeAllProvisionSessions() {
|
|
927
|
+
shutdownGeneration += 1;
|
|
928
|
+
shutdownInProgress += 1;
|
|
929
|
+
try {
|
|
930
|
+
await (async () => {
|
|
931
|
+
await Promise.all([...startingBrowsers].map(async (pending) => {
|
|
932
|
+
await cancelStartingBrowser(pending).catch(() => undefined);
|
|
933
|
+
}));
|
|
934
|
+
const closingSessions = [...sessions.values()];
|
|
935
|
+
for (const session of closingSessions) {
|
|
936
|
+
session.closing = true;
|
|
937
|
+
stopSessionWatchdog(session);
|
|
938
|
+
}
|
|
939
|
+
const closeErrors = await Promise.all(closingSessions.map(async (session) => {
|
|
940
|
+
await waitForSessionCallsToDrain(session);
|
|
941
|
+
return await forceTerminateProvisionSession(session, "shutdown_terminate", {
|
|
942
|
+
reason: "transport_disconnect",
|
|
943
|
+
});
|
|
944
|
+
}));
|
|
945
|
+
await Promise.all([...leasedBrowsers.values()].map(async (ephemeral) => {
|
|
946
|
+
await forceReleaseWarmBrowserPage(ephemeral.controller).catch(() => undefined);
|
|
947
|
+
}));
|
|
948
|
+
const closeError = closeErrors.find((error) => error !== undefined);
|
|
949
|
+
if (closeError !== undefined)
|
|
950
|
+
throw closeError;
|
|
951
|
+
})();
|
|
952
|
+
}
|
|
953
|
+
finally {
|
|
954
|
+
refusedStartSessionIds.clear();
|
|
955
|
+
await drainPendingObserveSnapshotCleanup();
|
|
956
|
+
shutdownInProgress -= 1;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
export function activeSessionCount() {
|
|
960
|
+
return sessions.size;
|
|
961
|
+
}
|
|
962
|
+
//# sourceMappingURL=lifecycle.js.map
|