@trusty-squire/mcp 1.1.13-rc.8 → 1.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -62
- package/assets/login/vnc-input.js +9 -0
- package/assets/login/vnc.html +214 -202
- package/assets/screencaps/connect-walkthrough.png +0 -0
- package/assets/screencaps/connect-walkthrough.svg +1 -1
- package/dist/api-client.d.ts +26 -3
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +19 -31
- package/dist/api-client.js.map +1 -1
- package/dist/bin.js +2 -3
- package/dist/bin.js.map +1 -1
- package/dist/bot/browser.d.ts +134 -77
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +1788 -1543
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/compact-observation-v2.d.ts +167 -0
- package/dist/bot/compact-observation-v2.d.ts.map +1 -0
- package/dist/bot/compact-observation-v2.js +1038 -0
- package/dist/bot/compact-observation-v2.js.map +1 -0
- package/dist/bot/credential-shape.d.ts +2 -0
- package/dist/bot/credential-shape.d.ts.map +1 -1
- package/dist/bot/credential-shape.js +21 -8
- package/dist/bot/credential-shape.js.map +1 -1
- package/dist/bot/element-fingerprint.d.ts +9 -0
- package/dist/bot/element-fingerprint.d.ts.map +1 -0
- package/dist/bot/element-fingerprint.js +147 -0
- package/dist/bot/element-fingerprint.js.map +1 -0
- package/dist/bot/email-verification.d.ts +7 -1
- package/dist/bot/email-verification.d.ts.map +1 -1
- package/dist/bot/email-verification.js +48 -14
- package/dist/bot/email-verification.js.map +1 -1
- package/dist/bot/google-login.d.ts +28 -52
- package/dist/bot/google-login.d.ts.map +1 -1
- package/dist/bot/google-login.js +246 -1119
- package/dist/bot/google-login.js.map +1 -1
- package/dist/bot/install-completion.d.ts +1 -2
- package/dist/bot/install-completion.d.ts.map +1 -1
- package/dist/bot/install-completion.js +1 -2
- package/dist/bot/install-completion.js.map +1 -1
- package/dist/bot/login-state.d.ts +13 -7
- package/dist/bot/login-state.d.ts.map +1 -1
- package/dist/bot/login-state.js +50 -114
- package/dist/bot/login-state.js.map +1 -1
- package/dist/bot/oauth-providers.d.ts +1 -1
- package/dist/bot/oauth-providers.d.ts.map +1 -1
- package/dist/bot/oauth-providers.js +3 -3
- package/dist/bot/oauth-providers.js.map +1 -1
- package/dist/bot/oauth-scope.d.ts +4 -0
- package/dist/bot/oauth-scope.d.ts.map +1 -0
- package/dist/bot/oauth-scope.js +58 -0
- package/dist/bot/oauth-scope.js.map +1 -0
- package/dist/bot/operator-browser-watchdog.d.ts +127 -0
- package/dist/bot/operator-browser-watchdog.d.ts.map +1 -0
- package/dist/bot/operator-browser-watchdog.js +438 -0
- package/dist/bot/operator-browser-watchdog.js.map +1 -0
- package/dist/bot/owner-process-reaper-worker.d.ts +2 -0
- package/dist/bot/owner-process-reaper-worker.d.ts.map +1 -0
- package/dist/bot/owner-process-reaper-worker.js +17 -0
- package/dist/bot/owner-process-reaper-worker.js.map +1 -0
- package/dist/bot/owner-process-reaper.d.ts +103 -0
- package/dist/bot/owner-process-reaper.d.ts.map +1 -0
- package/dist/bot/owner-process-reaper.js +1058 -0
- package/dist/bot/owner-process-reaper.js.map +1 -0
- package/dist/bot/pay-operator.d.ts +13 -2
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +319 -123
- package/dist/bot/pay-operator.js.map +1 -1
- package/dist/bot/profile.d.ts +10 -1
- package/dist/bot/profile.d.ts.map +1 -1
- package/dist/bot/profile.js +137 -11
- package/dist/bot/profile.js.map +1 -1
- package/dist/bot/promote-to-skill.js +1 -1
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/provision-session.d.ts +60 -164
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +2049 -1546
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/bot/remote-login-display.d.ts +61 -0
- package/dist/bot/remote-login-display.d.ts.map +1 -0
- package/dist/bot/remote-login-display.js +765 -0
- package/dist/bot/remote-login-display.js.map +1 -0
- package/dist/bot/session/hosts.d.ts +8 -0
- package/dist/bot/session/hosts.d.ts.map +1 -0
- package/dist/bot/session/hosts.js +36 -0
- package/dist/bot/session/hosts.js.map +1 -0
- package/dist/bot/session/lifecycle.d.ts +56 -0
- package/dist/bot/session/lifecycle.d.ts.map +1 -0
- package/dist/bot/session/lifecycle.js +962 -0
- package/dist/bot/session/lifecycle.js.map +1 -0
- package/dist/bot/session/model.d.ts +175 -0
- package/dist/bot/session/model.d.ts.map +1 -0
- package/dist/bot/session/model.js +77 -0
- package/dist/bot/session/model.js.map +1 -0
- package/dist/install/cli.d.ts +29 -9
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +243 -322
- package/dist/install/cli.js.map +1 -1
- package/dist/install/interactive.d.ts +1 -0
- package/dist/install/interactive.d.ts.map +1 -1
- package/dist/install/interactive.js +19 -5
- package/dist/install/interactive.js.map +1 -1
- package/dist/install/ui.d.ts.map +1 -1
- package/dist/install/ui.js +3 -15
- package/dist/install/ui.js.map +1 -1
- package/dist/operator-env.js +1 -1
- package/dist/operator-env.js.map +1 -1
- package/dist/server-instance-registry.d.ts +103 -0
- package/dist/server-instance-registry.d.ts.map +1 -0
- package/dist/server-instance-registry.js +392 -0
- package/dist/server-instance-registry.js.map +1 -0
- package/dist/server.d.ts +15 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +164 -62
- package/dist/server.js.map +1 -1
- package/dist/session-guard.d.ts +28 -0
- package/dist/session-guard.d.ts.map +1 -0
- package/dist/session-guard.js +84 -0
- package/dist/session-guard.js.map +1 -0
- package/dist/session.d.ts +57 -13
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +246 -127
- package/dist/session.js.map +1 -1
- package/dist/tools/audit-log.d.ts +16 -2
- package/dist/tools/audit-log.d.ts.map +1 -1
- package/dist/tools/audit-log.js +223 -13
- package/dist/tools/audit-log.js.map +1 -1
- package/dist/tools/audit-rollup.d.ts +80 -0
- package/dist/tools/audit-rollup.d.ts.map +1 -0
- package/dist/tools/audit-rollup.js +0 -0
- package/dist/tools/audit-rollup.js.map +1 -0
- package/dist/tools/fetch-credential.d.ts +37 -0
- package/dist/tools/fetch-credential.d.ts.map +1 -0
- package/dist/tools/fetch-credential.js +153 -0
- package/dist/tools/fetch-credential.js.map +1 -0
- package/dist/tools/index.d.ts +3 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +7 -3
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/operate-pay.d.ts +4 -4
- package/dist/tools/operate-pay.d.ts.map +1 -1
- package/dist/tools/operate-pay.js +163 -96
- package/dist/tools/operate-pay.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +43 -35
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +268 -164
- package/dist/tools/provision-drive.js.map +1 -1
- package/dist/tools/use-credential.d.ts.map +1 -1
- package/dist/tools/use-credential.js +3 -2
- package/dist/tools/use-credential.js.map +1 -1
- package/package.json +4 -6
- package/dist/bot/operator-direct-identity.d.ts +0 -26
- package/dist/bot/operator-direct-identity.d.ts.map +0 -1
- package/dist/bot/operator-direct-identity.js +0 -120
- package/dist/bot/operator-direct-identity.js.map +0 -1
- package/dist/bot/operator-profile-pool.d.ts +0 -97
- package/dist/bot/operator-profile-pool.d.ts.map +0 -1
- package/dist/bot/operator-profile-pool.js +0 -855
- package/dist/bot/operator-profile-pool.js.map +0 -1
- package/dist/bot/xvfb.d.ts +0 -13
- package/dist/bot/xvfb.d.ts.map +0 -1
- package/dist/bot/xvfb.js +0 -146
- package/dist/bot/xvfb.js.map +0 -1
package/dist/bot/google-login.js
CHANGED
|
@@ -1,45 +1,29 @@
|
|
|
1
1
|
// google-login.ts — Phase 1, T2 (/plan-eng-review).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// it is fully automated.
|
|
3
|
+
// Establishes and reads the provider sessions in the bot's persistent Chrome
|
|
4
|
+
// profile. `connect` (install/cli.ts) is the ONLY caller that opens a login;
|
|
5
|
+
// every signup after it is fully automated.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// The login browser is always PLAIN Chrome (launchPlainLoginBrowser): no
|
|
8
|
+
// `--remote-debugging-port`, no `connectOverCDP`. Google's OAuth secure-browser
|
|
9
|
+
// check rejects a CDP attach (STATE.md 2026-07-20), so the CDP login path was
|
|
10
|
+
// deleted along with the `login` subcommand rather than kept beside it.
|
|
11
|
+
// Completion therefore never comes off a live BrowserContext — it arrives out
|
|
12
|
+
// of band from the install claim plus its nonce-scoped Finish callback.
|
|
11
13
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// display (Xvfb), bridge it out with x11vnc + noVNC + a cloudflared
|
|
17
|
-
// tunnel, and print one URL + a VNC password. The user logs in from
|
|
18
|
-
// any browser on any network. The per-session stack and public URL
|
|
19
|
-
// are removed when the flow completes, times out, errors, or is
|
|
20
|
-
// interrupted; an operator-managed named tunnel remains external.
|
|
21
|
-
//
|
|
22
|
-
// Binaries the headless path needs: Xvfb, x11vnc, websockify
|
|
23
|
-
// (with /usr/share/novnc), cloudflared, and Google Chrome. Missing ones
|
|
24
|
-
// produce a clear, actionable error rather than a cryptic crash.
|
|
25
|
-
import { spawn } from "node:child_process";
|
|
14
|
+
// Interactive login uses a local visible Chrome window when one exists. On a
|
|
15
|
+
// headless host it starts a login-scoped Xvfb + noVNC tunnel so a human can
|
|
16
|
+
// drive that same browser remotely. Automated operator sessions do not use
|
|
17
|
+
// this module's display stack and remain on Chrome's new-headless path.
|
|
26
18
|
import { createRequire } from "node:module";
|
|
27
|
-
import { createServer } from "node:net";
|
|
28
|
-
import { cpSync, chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
29
|
-
import { tmpdir } from "node:os";
|
|
30
|
-
import { join } from "node:path";
|
|
31
|
-
import { fileURLToPath } from "node:url";
|
|
32
|
-
import Database from "better-sqlite3";
|
|
33
|
-
import boxen from "boxen";
|
|
34
19
|
import chalk from "chalk";
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
|
|
38
|
-
export { extractGoogleAccountEmail } from "./browser.js";
|
|
20
|
+
import { CHROME_PROFILE_DIR, closeProfileWithProof, currentProfileHolderPid, launchWithProfileGate, profileProcessIdentity, PROFILE_BUSY_MESSAGE, ProfileBusyError, waitForProfileFree, withProfileOperationGuard, } from "./profile.js";
|
|
21
|
+
import { closeBrowserContextWithin, extractGoogleAccountEmail, launchPlainLoginBrowser, registerLocalBrowserLaunch, resolveChannelBinary, } from "./browser.js";
|
|
22
|
+
export { extractGoogleAccountEmail };
|
|
39
23
|
import { startInstallCompletionListener, withInstallCompletionCallback, } from "./install-completion.js";
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
|
|
24
|
+
import { bindOwnerBrowserLaunch, markOwnerBrowserLaunchTerminal, terminateOwnerBrowserLaunch, untrackOwnerBrowserLaunch, } from "./owner-process-reaper.js";
|
|
25
|
+
import { assertRemoteLoginRigLive, createRemoteLoginRig, exposeRemoteLoginDisplay, registerRemoteLoginRigCleanup, remoteLoginEnvironment, startRemoteLoginDisplay, teardownRemoteLoginRig, } from "./remote-login-display.js";
|
|
26
|
+
export { extractOAuthScopes, scopesAreBasic, scrapeGoogleScopePhrases } from "./oauth-scope.js";
|
|
43
27
|
const require = createRequire(import.meta.url);
|
|
44
28
|
function loginProxyOption() {
|
|
45
29
|
return undefined;
|
|
@@ -47,11 +31,57 @@ function loginProxyOption() {
|
|
|
47
31
|
function selfLaunchProxyDisposition(proxy) {
|
|
48
32
|
return proxy === undefined ? null : { server: proxy.server };
|
|
49
33
|
}
|
|
50
|
-
export async function launchPersistentLoginContext(launcher, userDataDir, options) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
34
|
+
export async function launchPersistentLoginContext(launcher, userDataDir, options, runtime = {}) {
|
|
35
|
+
const register = runtime.registerLocalBrowserLaunch ?? registerLocalBrowserLaunch;
|
|
36
|
+
const markTerminal = runtime.markTerminal ?? markOwnerBrowserLaunchTerminal;
|
|
37
|
+
const terminate = runtime.terminate ?? terminateOwnerBrowserLaunch;
|
|
38
|
+
const untrack = runtime.untrack ?? untrackOwnerBrowserLaunch;
|
|
39
|
+
const ownership = register(userDataDir, options.env ?? process.env);
|
|
40
|
+
let context = null;
|
|
41
|
+
try {
|
|
42
|
+
context = await launcher.launchPersistentContext(userDataDir, {
|
|
43
|
+
...options,
|
|
44
|
+
env: ownership.env,
|
|
45
|
+
channel: "chrome",
|
|
46
|
+
});
|
|
47
|
+
const bindLaunch = runtime.bindLaunch ??
|
|
48
|
+
((marker, profileDir) => {
|
|
49
|
+
const holderPid = currentProfileHolderPid(profileDir);
|
|
50
|
+
const identity = holderPid === null ? null : profileProcessIdentity(holderPid, profileDir);
|
|
51
|
+
return identity !== null && bindOwnerBrowserLaunch(marker, identity);
|
|
52
|
+
});
|
|
53
|
+
if (!bindLaunch(ownership.marker, userDataDir)) {
|
|
54
|
+
throw new Error("persistent login browser identity could not be bound to owner custody");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
markTerminal(ownership.marker);
|
|
59
|
+
if (context !== null)
|
|
60
|
+
await closeBrowserContextWithin(context, runtime.closeTimeoutMs);
|
|
61
|
+
if (await terminate(ownership.marker, userDataDir).catch(() => false)) {
|
|
62
|
+
untrack(ownership.marker);
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
if (context === null)
|
|
67
|
+
throw new Error("persistent login browser did not start");
|
|
68
|
+
const boundContext = context;
|
|
69
|
+
let closing;
|
|
70
|
+
return {
|
|
71
|
+
context: boundContext,
|
|
72
|
+
marker: ownership.marker,
|
|
73
|
+
close: () => {
|
|
74
|
+
closing ??= (async () => {
|
|
75
|
+
markTerminal(ownership.marker);
|
|
76
|
+
await closeBrowserContextWithin(boundContext, runtime.closeTimeoutMs);
|
|
77
|
+
const terminated = await terminate(ownership.marker, userDataDir).catch(() => false);
|
|
78
|
+
if (!terminated)
|
|
79
|
+
throw new Error("persistent login browser closure unproven");
|
|
80
|
+
untrack(ownership.marker);
|
|
81
|
+
})();
|
|
82
|
+
return closing;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
55
85
|
}
|
|
56
86
|
function resolveChromium() {
|
|
57
87
|
try {
|
|
@@ -64,91 +94,58 @@ function resolveChromium() {
|
|
|
64
94
|
return require("playwright").chromium;
|
|
65
95
|
}
|
|
66
96
|
}
|
|
97
|
+
async function detectGoogleAccountEmailInContext(context) {
|
|
98
|
+
let page = null;
|
|
99
|
+
try {
|
|
100
|
+
page = await context.newPage();
|
|
101
|
+
await page.goto("https://myaccount.google.com/", {
|
|
102
|
+
waitUntil: "domcontentloaded",
|
|
103
|
+
timeout: 20_000,
|
|
104
|
+
});
|
|
105
|
+
if (new URL(page.url()).hostname !== "myaccount.google.com")
|
|
106
|
+
return null;
|
|
107
|
+
const labels = await page
|
|
108
|
+
.locator("[aria-label]")
|
|
109
|
+
.evaluateAll((elements) => elements.map((element) => element.getAttribute("aria-label") ?? ""));
|
|
110
|
+
for (const label of labels) {
|
|
111
|
+
const email = extractGoogleAccountEmail(label.trim());
|
|
112
|
+
if (email !== null)
|
|
113
|
+
return email;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
// A probe failure is NOT proof of "signed out", but every caller treats a
|
|
119
|
+
// null the same way, so name it. Losing this line is how a broken probe
|
|
120
|
+
// reads as an empty profile.
|
|
121
|
+
console.error(`[connect] Google identity probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await page?.close().catch(() => undefined);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
67
128
|
const LOGIN_TARGETS = {
|
|
68
129
|
google: {
|
|
69
130
|
provider: "google",
|
|
70
131
|
label: "Google",
|
|
71
|
-
loginUrl: "https://accounts.google.com/",
|
|
72
132
|
cookieOrigin: "https://www.google.com",
|
|
73
|
-
cookies:
|
|
133
|
+
cookies: ["__Secure-1PSID", "SID", "HSID", "SSID", "APISID", "SAPISID"],
|
|
74
134
|
},
|
|
75
135
|
github: {
|
|
76
136
|
provider: "github",
|
|
77
137
|
label: "GitHub",
|
|
78
|
-
loginUrl: "https://github.com/login",
|
|
79
138
|
cookieOrigin: "https://github.com",
|
|
80
139
|
cookies: ["user_session", "__Host-user_session_same_site"],
|
|
81
140
|
},
|
|
82
141
|
};
|
|
83
|
-
// Phone-shaped virtual display — portrait so it scales cleanly onto a
|
|
84
|
-
// phone via noVNC (the spike's 1920x1080 was the UX mistake). 720x1280
|
|
85
|
-
// is the smallest size that doesn't look pixel-y on a 1080p+ phone
|
|
86
|
-
// screen, and stays bandwidth-friendly compared to 1080x1920.
|
|
87
|
-
// Overridable via BOT_NOVNC_W/H — a desktop-width display (e.g. 1440x900) is
|
|
88
|
-
// needed when the remote page is a responsive app whose desktop-only controls
|
|
89
|
-
// (hover-revealed row menus in the Workspace Admin console) collapse at phone
|
|
90
|
-
// width. Defaults stay phone-shaped for the common login-from-phone case.
|
|
91
|
-
const HEADLESS_W = Number(process.env.BOT_NOVNC_W) || 720;
|
|
92
|
-
const HEADLESS_H = Number(process.env.BOT_NOVNC_H) || 1280;
|
|
93
|
-
// The Debian/Ubuntu `novnc` package installs its web assets here — the
|
|
94
|
-
// `core/` RFB library our branded page reuses (see runHeadlessChrome).
|
|
95
|
-
const NOVNC_INSTALL_DIR = "/usr/share/novnc";
|
|
96
|
-
// Resolve a bundled login asset (the branded vnc.html / interstitial).
|
|
97
|
-
// `../../assets/login/` from this module resolves to apps/mcp/assets/
|
|
98
|
-
// whether running from src/ (tsx) or dist/ (compiled) — assets/ sits
|
|
99
|
-
// beside both. Shipped via the package.json `files` allowlist.
|
|
100
|
-
function loginAssetPath(name) {
|
|
101
|
-
return fileURLToPath(new URL(`../../assets/login/${name}`, import.meta.url));
|
|
102
|
-
}
|
|
103
142
|
// --- session detection -------------------------------------------------
|
|
104
143
|
async function hasProviderSession(context, target) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
// Exported for the connect claim loop: does this LIVE context already hold the
|
|
109
|
-
// given provider's session cookies? The force-relogin teardown gates on this so
|
|
110
|
-
// it never closes the noVNC on the bare API claim while the interactive sign-in
|
|
111
|
-
// (e.g. Google's cold-profile second challenge) is still in flight.
|
|
112
|
-
export async function contextHasProviderSession(context, provider) {
|
|
113
|
-
return hasProviderSession(context, LOGIN_TARGETS[provider]);
|
|
114
|
-
}
|
|
115
|
-
const PROVIDER_COOKIE_MARKERS = {
|
|
116
|
-
google: GOOGLE_LOGIN_COOKIE_MARKERS,
|
|
117
|
-
github: ["user_session"],
|
|
118
|
-
};
|
|
119
|
-
export function profileHasProviderCookies(profileDir, provider) {
|
|
120
|
-
const markers = PROVIDER_COOKIE_MARKERS[provider];
|
|
121
|
-
const bases = [join(profileDir, "Default", "Cookies"), join(profileDir, "Cookies")];
|
|
122
|
-
const root = provider === "google" ? "google.com" : "github.com";
|
|
123
|
-
for (const path of bases) {
|
|
124
|
-
if (!existsSync(path))
|
|
125
|
-
continue;
|
|
126
|
-
let db = null;
|
|
127
|
-
try {
|
|
128
|
-
db = new Database(path, {
|
|
129
|
-
readonly: true,
|
|
130
|
-
fileMustExist: true,
|
|
131
|
-
timeout: 250,
|
|
132
|
-
});
|
|
133
|
-
const placeholders = markers.map(() => "?").join(", ");
|
|
134
|
-
const row = db
|
|
135
|
-
.prepare(`SELECT 1
|
|
136
|
-
FROM cookies
|
|
137
|
-
WHERE (host_key = ? OR host_key = ? OR host_key LIKE ?)
|
|
138
|
-
AND name IN (${placeholders})
|
|
139
|
-
LIMIT 1`)
|
|
140
|
-
.get(root, `.${root}`, `%.${root}`, ...markers);
|
|
141
|
-
if (row !== undefined)
|
|
142
|
-
return true;
|
|
143
|
-
}
|
|
144
|
-
catch {
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
finally {
|
|
148
|
-
db?.close();
|
|
149
|
-
}
|
|
144
|
+
if (target.provider === "google") {
|
|
145
|
+
return (await detectGoogleAccountEmailInContext(context)) !== null;
|
|
150
146
|
}
|
|
151
|
-
|
|
147
|
+
const cookies = await context.cookies(target.cookieOrigin);
|
|
148
|
+
return cookies.some((cookie) => target.cookies.includes(cookie.name));
|
|
152
149
|
}
|
|
153
150
|
// VALIDATE a session instead of just spotting a cookie. A provider session that
|
|
154
151
|
// expired server-side often leaves its `user_session` cookie sitting in the
|
|
@@ -157,11 +154,12 @@ export function profileHasProviderCookies(profileDir, provider) {
|
|
|
157
154
|
// auth state — and read that, so a dead-but-present session reports false.
|
|
158
155
|
// GitHub: an anonymous/expired visit sets `logged_in=no`; a live one, `=yes`.
|
|
159
156
|
async function validateProviderSession(context, target) {
|
|
157
|
+
if (target.provider === "google") {
|
|
158
|
+
return (await detectGoogleAccountEmailInContext(context)) !== null;
|
|
159
|
+
}
|
|
160
160
|
// Cheap negative: no session cookie at all → definitely not logged in.
|
|
161
161
|
if (!(await hasProviderSession(context, target)))
|
|
162
162
|
return false;
|
|
163
|
-
if (target.provider !== "github")
|
|
164
|
-
return true; // only GitHub's marker is known to lie
|
|
165
163
|
const page = await context.newPage();
|
|
166
164
|
try {
|
|
167
165
|
await page
|
|
@@ -180,15 +178,12 @@ async function validateProviderSession(context, target) {
|
|
|
180
178
|
await page.close().catch(() => undefined);
|
|
181
179
|
}
|
|
182
180
|
}
|
|
183
|
-
// Inspect
|
|
184
|
-
//
|
|
185
|
-
// file at logged-in-providers.json is a write-once memo from prior
|
|
186
|
-
// runs and can lie (cookies expire, the user logs out from the
|
|
187
|
-
// provider's site, etc.). This function is the source of truth.
|
|
181
|
+
// Inspect a live bot Chrome context and return the providers whose sessions are
|
|
182
|
+
// currently present. This is the source of truth for provider availability.
|
|
188
183
|
//
|
|
189
184
|
// Cost is ~1-1.5s for the persistent-context launch + cookie read +
|
|
190
185
|
// teardown. We only call it at install boundaries (after the install
|
|
191
|
-
// confirm
|
|
186
|
+
// confirm establishes whichever provider the user clicked, before the
|
|
192
187
|
// secondary-provider prompt fires) so the latency is acceptable.
|
|
193
188
|
export async function detectActiveProviderSessions(profileDir = CHROME_PROFILE_DIR) {
|
|
194
189
|
return await withProfileOperationGuard(profileDir, async () => {
|
|
@@ -196,11 +191,12 @@ export async function detectActiveProviderSessions(profileDir = CHROME_PROFILE_D
|
|
|
196
191
|
// wait is fine: reclaim a stale lock, or briefly yield to a live run.
|
|
197
192
|
await waitForProfileFree(profileDir, { deadlineMs: 15_000, pollMs: 500 });
|
|
198
193
|
const chromium = resolveChromium();
|
|
199
|
-
const
|
|
194
|
+
const persistent = await launchWithProfileGate(profileDir, () => launchPersistentLoginContext(chromium, profileDir, {
|
|
200
195
|
headless: true,
|
|
201
196
|
ignoreDefaultArgs: ["--enable-automation"],
|
|
202
197
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
203
198
|
}));
|
|
199
|
+
const ctx = persistent.context;
|
|
204
200
|
try {
|
|
205
201
|
const present = [];
|
|
206
202
|
for (const id of Object.keys(LOGIN_TARGETS)) {
|
|
@@ -209,14 +205,15 @@ export async function detectActiveProviderSessions(profileDir = CHROME_PROFILE_D
|
|
|
209
205
|
// just the install display. validateProviderSession is cheap: it returns
|
|
210
206
|
// a fast false when no cookie is present (no navigation), and only pays
|
|
211
207
|
// the github.com round-trip when a github cookie EXISTS and must be proven
|
|
212
|
-
// live. Google
|
|
208
|
+
// live. Google is verified by a My Account identity probe, since its
|
|
209
|
+
// chooser cookies can survive after the account is signed out.
|
|
213
210
|
if (await validateProviderSession(ctx, LOGIN_TARGETS[id]))
|
|
214
211
|
present.push(id);
|
|
215
212
|
}
|
|
216
213
|
return present;
|
|
217
214
|
}
|
|
218
215
|
finally {
|
|
219
|
-
await
|
|
216
|
+
await persistent.close();
|
|
220
217
|
}
|
|
221
218
|
});
|
|
222
219
|
}
|
|
@@ -254,102 +251,6 @@ export function classifyGoogleAuthState(url, bodyText) {
|
|
|
254
251
|
// rather than risk it proceeding into a page it must not automate.
|
|
255
252
|
return "needs_login";
|
|
256
253
|
}
|
|
257
|
-
// --- T7: OAuth consent scope gate --------------------------------------
|
|
258
|
-
// After the bot clicks "Sign in with Google" and lands on a consent
|
|
259
|
-
// screen, the OAuth signup flow auto-approves it ONLY when every scope
|
|
260
|
-
// the service requested is a basic-identity scope. Anything broader
|
|
261
|
-
// (Gmail/Drive/contacts) aborts the run for human review — a
|
|
262
|
-
// prompt-injected or confused agent must not be able to grant a wide
|
|
263
|
-
// OAuth scope on the user's behalf (see the plan's Security Boundary).
|
|
264
|
-
//
|
|
265
|
-
// The allowlist is Google-OIDC vocabulary. GitHub (Phase 2, D7) gets
|
|
266
|
-
// its own provider-aware allowlist when that provider lands.
|
|
267
|
-
const BASIC_OAUTH_SCOPES = new Set([
|
|
268
|
-
"openid",
|
|
269
|
-
"email",
|
|
270
|
-
"profile",
|
|
271
|
-
"https://www.googleapis.com/auth/userinfo.email",
|
|
272
|
-
"https://www.googleapis.com/auth/userinfo.profile",
|
|
273
|
-
]);
|
|
274
|
-
// Pull the OAuth `scope` parameter off a Google consent URL. Robust by
|
|
275
|
-
// design (a spec refinement): a query-param read, never a DOM scrape or
|
|
276
|
-
// a vision call. Google nests the real authorize request inside a
|
|
277
|
-
// `continue=` (or similar) param on the consent/chooser URL, so this
|
|
278
|
-
// walks nested URL-valued params up to a small depth to find `scope`.
|
|
279
|
-
//
|
|
280
|
-
// Returns the parsed scope list, or null when no `scope` param is
|
|
281
|
-
// present anywhere — the caller treats "can't read the scopes" as
|
|
282
|
-
// "can't confirm they're basic" and pauses for human review.
|
|
283
|
-
//
|
|
284
|
-
// Exported for unit testing — the nested-URL walk is the error-prone bit.
|
|
285
|
-
export function extractOAuthScopes(rawUrl) {
|
|
286
|
-
const scopes = [];
|
|
287
|
-
const visit = (urlStr, depth) => {
|
|
288
|
-
if (scopes.length > 0 || depth > 8)
|
|
289
|
-
return;
|
|
290
|
-
let u;
|
|
291
|
-
try {
|
|
292
|
-
u = new URL(urlStr);
|
|
293
|
-
}
|
|
294
|
-
catch {
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
const scope = u.searchParams.get("scope");
|
|
298
|
-
if (scope !== null && scope.trim().length > 0) {
|
|
299
|
-
// Google separates scopes with spaces; tolerate "+" and "," too.
|
|
300
|
-
for (const s of scope.split(/[\s,+]+/)) {
|
|
301
|
-
const trimmed = s.trim();
|
|
302
|
-
if (trimmed.length > 0)
|
|
303
|
-
scopes.push(trimmed);
|
|
304
|
-
}
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
// Recurse into any param whose value is itself a URL (Google's
|
|
308
|
-
// `continue`, `authError`, etc. carry the nested authorize request).
|
|
309
|
-
for (const value of u.searchParams.values()) {
|
|
310
|
-
if (/^https?:\/\//i.test(value.trim()))
|
|
311
|
-
visit(value, depth + 1);
|
|
312
|
-
}
|
|
313
|
-
};
|
|
314
|
-
visit(rawUrl, 0);
|
|
315
|
-
return scopes.length > 0 ? scopes : null;
|
|
316
|
-
}
|
|
317
|
-
// True when EVERY requested scope is in the basic-identity allowlist —
|
|
318
|
-
// the gate for auto-approving a consent screen. An empty list returns
|
|
319
|
-
// false: no scopes parsed means we could not confirm, so we do not
|
|
320
|
-
// auto-approve. Exported for unit testing.
|
|
321
|
-
export function scopesAreBasic(scopes) {
|
|
322
|
-
return scopes.length > 0 && scopes.every((s) => BASIC_OAUTH_SCOPES.has(s));
|
|
323
|
-
}
|
|
324
|
-
// Defense-in-depth for the case where extractOAuthScopes returns null
|
|
325
|
-
// (no parseable scope= param) but the page IS a real scope-grant
|
|
326
|
-
// consent. Google's consent screen lists each scope visually with a
|
|
327
|
-
// templated verb phrase: "See your", "Manage your", "Edit your", "Send
|
|
328
|
-
// email", etc. A scope-summary / account-chooser / post-grant
|
|
329
|
-
// confirmation does not include these phrases. So when the URL gives
|
|
330
|
-
// us nothing, the visible-text phrases are the next best signal.
|
|
331
|
-
//
|
|
332
|
-
// Returns the list of suspicious phrases found (each capped at 80 chars
|
|
333
|
-
// so a runaway match cannot blow up the response). Empty list = the
|
|
334
|
-
// page does not appear to grant any sensitive scope.
|
|
335
|
-
export function scrapeGoogleScopePhrases(text) {
|
|
336
|
-
const patterns = [
|
|
337
|
-
/see\s+(?:and\s+download|and\s+manage)\s+[^.\n]+/gi,
|
|
338
|
-
/manage\s+(?:your|all|all\s+your)\s+(?:contacts|google\s+drive|photos|calendars?|tasks|mail|gmail|files|account|youtube)[^.\n]*/gi,
|
|
339
|
-
/edit\s+(?:your|all)\s+(?:contacts|google\s+drive|photos|calendars?|tasks|mail|gmail|files)[^.\n]*/gi,
|
|
340
|
-
/send\s+(?:email|mail|messages)\s+(?:on\s+your\s+behalf|as\s+you)[^.\n]*/gi,
|
|
341
|
-
/view\s+(?:your|all|all\s+your)\s+(?:contacts|google\s+drive|photos|calendars?|tasks|mail|gmail|files|youtube|location\s+history)[^.\n]*/gi,
|
|
342
|
-
/access\s+your\s+(?:google\s+drive|gmail|contacts|calendars?|photos|youtube)[^.\n]*/gi,
|
|
343
|
-
/delete\s+(?:your|all)\s+[^.\n]+/gi,
|
|
344
|
-
];
|
|
345
|
-
const matches = new Set();
|
|
346
|
-
for (const p of patterns) {
|
|
347
|
-
for (const m of text.matchAll(p)) {
|
|
348
|
-
matches.add(m[0].slice(0, 80).trim());
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
return Array.from(matches);
|
|
352
|
-
}
|
|
353
254
|
// Google's "number-match" challenge (URL: /signin/challenge/dp) shows
|
|
354
255
|
// ONE big number on the desktop browser; the user's phone shows three
|
|
355
256
|
// options and they tap the matching one. The number is the only piece
|
|
@@ -381,290 +282,59 @@ export function extractGoogleNumberMatch(text) {
|
|
|
381
282
|
return null;
|
|
382
283
|
}
|
|
383
284
|
// --- environment helpers ----------------------------------------------
|
|
384
|
-
export function hasDisplay() {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (
|
|
285
|
+
export function hasDisplay(platform = process.platform, env = process.env) {
|
|
286
|
+
// macOS (Aqua) and Windows (Win32) have native windowing; Linux needs
|
|
287
|
+
// an existing user-visible X display.
|
|
288
|
+
if (platform === "darwin" || platform === "win32")
|
|
388
289
|
return true;
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
// platforms).
|
|
395
|
-
if (process.platform === "darwin" || process.platform === "win32")
|
|
396
|
-
return true;
|
|
397
|
-
// Linux: a non-empty DISPLAY means there's an X server we can draw to.
|
|
398
|
-
// Headless boxes often inherit an automation/Xvfb DISPLAY over SSH, though;
|
|
399
|
-
// that is NOT a human-visible desktop. Prefer noVNC for SSH/TTY Linux
|
|
400
|
-
// sessions unless explicitly overridden with TRUSTY_SQUIRE_FORCE_DISPLAY.
|
|
401
|
-
if (process.env.SSH_CONNECTION !== undefined ||
|
|
402
|
-
process.env.SSH_TTY !== undefined ||
|
|
403
|
-
process.env.XDG_SESSION_TYPE === "tty") {
|
|
290
|
+
if (typeof env.DISPLAY !== "string" || env.DISPLAY.trim().length === 0)
|
|
291
|
+
return false;
|
|
292
|
+
if ((typeof env.SSH_CONNECTION === "string" && env.SSH_CONNECTION.trim().length > 0) ||
|
|
293
|
+
(typeof env.SSH_TTY === "string" && env.SSH_TTY.trim().length > 0) ||
|
|
294
|
+
env.XDG_SESSION_TYPE?.trim().toLowerCase() === "tty") {
|
|
404
295
|
return false;
|
|
405
296
|
}
|
|
406
|
-
return
|
|
407
|
-
}
|
|
408
|
-
// First :N (99..120) whose X socket is free.
|
|
409
|
-
function pickFreeDisplay() {
|
|
410
|
-
for (let n = 99; n <= 120; n++) {
|
|
411
|
-
if (!existsSync(`/tmp/.X11-unix/X${n}`))
|
|
412
|
-
return `:${n}`;
|
|
413
|
-
}
|
|
414
|
-
throw new Error("no free X display number in :99..:120");
|
|
415
|
-
}
|
|
416
|
-
// An OS-assigned free TCP port.
|
|
417
|
-
export function findFreePort() {
|
|
418
|
-
return new Promise((resolve, reject) => {
|
|
419
|
-
const srv = createServer();
|
|
420
|
-
srv.on("error", reject);
|
|
421
|
-
srv.listen(0, "127.0.0.1", () => {
|
|
422
|
-
const addr = srv.address();
|
|
423
|
-
if (addr === null || typeof addr === "string") {
|
|
424
|
-
srv.close();
|
|
425
|
-
reject(new Error("could not resolve a free port"));
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
const { port } = addr;
|
|
429
|
-
srv.close(() => resolve(port));
|
|
430
|
-
});
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
// Standard install locations probed IN ADDITION to $PATH. A binary can be
|
|
434
|
-
// installed yet invisible to `binaryOnPath` when the process that spawned
|
|
435
|
-
// this one trimmed PATH — systemd units and agent/MCP spawns routinely drop
|
|
436
|
-
// /usr/local/bin, which is exactly where cloudflared lands. Probing the
|
|
437
|
-
// well-known dirs stops an installed binary from being reported "missing".
|
|
438
|
-
const STANDARD_BIN_DIRS = [
|
|
439
|
-
"/usr/local/bin",
|
|
440
|
-
"/usr/bin",
|
|
441
|
-
"/bin",
|
|
442
|
-
"/usr/local/sbin",
|
|
443
|
-
"/usr/sbin",
|
|
444
|
-
"/sbin",
|
|
445
|
-
];
|
|
446
|
-
// Is `bin` an executable on PATH (or in a standard bin dir)? A cheap
|
|
447
|
-
// synchronous `command -v` that also probes well-known locations so an
|
|
448
|
-
// installed-but-off-PATH binary isn't misreported as absent.
|
|
449
|
-
export function binaryOnPath(bin) {
|
|
450
|
-
const dirs = [...(process.env.PATH ?? "").split(":"), ...STANDARD_BIN_DIRS];
|
|
451
|
-
return dirs.some((p) => p.length > 0 && existsSync(join(p, bin)));
|
|
452
|
-
}
|
|
453
|
-
// cloudflared's .deb is published per-arch; pick the suffix matching this
|
|
454
|
-
// machine so the suggested install command actually resolves.
|
|
455
|
-
function cloudflaredDebArch() {
|
|
456
|
-
switch (process.arch) {
|
|
457
|
-
case "arm64":
|
|
458
|
-
return "arm64";
|
|
459
|
-
case "arm":
|
|
460
|
-
return "arm";
|
|
461
|
-
case "ia32":
|
|
462
|
-
return "386";
|
|
463
|
-
default:
|
|
464
|
-
return "amd64";
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
// Correct remediation for the specific binaries that are missing. cloudflared
|
|
468
|
-
// is NOT in Debian/Ubuntu's default repos, so an apt-get line can never
|
|
469
|
-
// install it — it needs Cloudflare's own package. The rest are plain apt
|
|
470
|
-
// packages (websockify's noVNC assets ship in the `novnc` package, so both).
|
|
471
|
-
export function installHint(missing) {
|
|
472
|
-
const aptPkgs = missing.flatMap((n) => {
|
|
473
|
-
if (n === "Xvfb")
|
|
474
|
-
return ["xvfb"];
|
|
475
|
-
if (n === "x11vnc")
|
|
476
|
-
return ["x11vnc"];
|
|
477
|
-
if (n === "websockify")
|
|
478
|
-
return ["novnc", "websockify"];
|
|
479
|
-
return [];
|
|
480
|
-
});
|
|
481
|
-
const lines = [];
|
|
482
|
-
if (aptPkgs.length > 0) {
|
|
483
|
-
lines.push(`On Debian/Ubuntu: sudo apt-get install -y ${aptPkgs.join(" ")}`);
|
|
484
|
-
}
|
|
485
|
-
if (missing.includes("cloudflared")) {
|
|
486
|
-
const arch = cloudflaredDebArch();
|
|
487
|
-
lines.push("cloudflared is not in Debian/Ubuntu repos — install its package: " +
|
|
488
|
-
`curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb ` +
|
|
489
|
-
"-o /tmp/cloudflared.deb && sudo dpkg -i /tmp/cloudflared.deb");
|
|
490
|
-
}
|
|
491
|
-
return lines.join("\n");
|
|
492
|
-
}
|
|
493
|
-
function requireBinaries(names) {
|
|
494
|
-
const missing = names.filter((n) => !binaryOnPath(n));
|
|
495
|
-
if (missing.length > 0) {
|
|
496
|
-
throw new Error(`headless login needs these not-installed binaries: ${missing.join(", ")}.\n` +
|
|
497
|
-
installHint(missing));
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
function spawnBg(cmd, args, env) {
|
|
501
|
-
const child = spawn(cmd, args, {
|
|
502
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
503
|
-
env: env ?? process.env,
|
|
504
|
-
});
|
|
505
|
-
child.on("error", (e) => console.error(`[login] ${cmd} failed to spawn: ${String(e)}`));
|
|
506
|
-
return child;
|
|
507
|
-
}
|
|
508
|
-
// Read cloudflared's output until it prints its public trycloudflare URL.
|
|
509
|
-
// Accumulates a rolling buffer rather than matching per-chunk — the URL
|
|
510
|
-
// can straddle two `data` events, and a per-chunk match would miss it
|
|
511
|
-
// and hang until the timeout.
|
|
512
|
-
function awaitTunnelUrl(cf, timeoutMs) {
|
|
513
|
-
return new Promise((resolve, reject) => {
|
|
514
|
-
const timer = setTimeout(() => reject(new Error("cloudflared did not produce a URL in time")), timeoutMs);
|
|
515
|
-
let acc = "";
|
|
516
|
-
const scan = (buf) => {
|
|
517
|
-
acc += buf.toString();
|
|
518
|
-
const m = acc.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
519
|
-
if (m !== null) {
|
|
520
|
-
clearTimeout(timer);
|
|
521
|
-
resolve(m[0]);
|
|
522
|
-
}
|
|
523
|
-
// Bound memory if cloudflared is chatty before the URL appears.
|
|
524
|
-
if (acc.length > 65536)
|
|
525
|
-
acc = acc.slice(-4096);
|
|
526
|
-
};
|
|
527
|
-
cf.stdout?.on("data", scan);
|
|
528
|
-
cf.stderr?.on("data", scan);
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
// Width-aware, boxed VNC banner. Replaces the old hardcoded
|
|
532
|
-
// `"=".repeat(64)` lines that looked broken on narrow phone-via-SSH
|
|
533
|
-
// terminals and lost on wide ones. boxen handles the reflow.
|
|
534
|
-
function printBanner(opts) {
|
|
535
|
-
const width = Math.max(40, Math.min((process.stdout.columns ?? 80) - 2, 78));
|
|
536
|
-
// OSC 8 hyperlink: modern terminals render the URL as cmd-clickable
|
|
537
|
-
// (label = the URL itself so it still copies cleanly). Non-TTY
|
|
538
|
-
// pipes get the raw URL — no escape garbage in logs. Wine-underlined
|
|
539
|
-
// text inside the OSC 8 wrapper preserves a visual cue on terminals
|
|
540
|
-
// that ignore the hyperlink escape.
|
|
541
|
-
const styledUrl = process.stderr.isTTY
|
|
542
|
-
? `\x1b]8;;${opts.tunnelUrl}\x1b\\${chalk.hex("#cf3a52").underline(opts.tunnelUrl)}\x1b]8;;\x1b\\`
|
|
543
|
-
: opts.tunnelUrl;
|
|
544
|
-
const body = `Open this on any device, any network:\n\n` +
|
|
545
|
-
` ${styledUrl}\n\n` +
|
|
546
|
-
`If asked for a VNC password: ${chalk.bold(opts.vncPassword)}\n\n` +
|
|
547
|
-
opts.label;
|
|
548
|
-
console.error("\n" +
|
|
549
|
-
boxen(body, {
|
|
550
|
-
title: "Sign in to Trusty Squire",
|
|
551
|
-
titleAlignment: "left",
|
|
552
|
-
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
553
|
-
borderStyle: "single",
|
|
554
|
-
borderColor: "#cf3a52",
|
|
555
|
-
width,
|
|
556
|
-
}) +
|
|
557
|
-
"\n");
|
|
558
|
-
}
|
|
559
|
-
const headlessRigTeardowns = new WeakMap();
|
|
560
|
-
function childHasExited(child) {
|
|
561
|
-
return child.exitCode !== null || child.signalCode !== null;
|
|
297
|
+
return true;
|
|
562
298
|
}
|
|
563
|
-
function
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const
|
|
569
|
-
|
|
299
|
+
export async function teardownLoginBrowser(opts) {
|
|
300
|
+
let profileState;
|
|
301
|
+
if (opts.identity === null && opts.isRunning !== undefined) {
|
|
302
|
+
const timeoutMs = opts.timeoutMs ?? 15_000;
|
|
303
|
+
let timer;
|
|
304
|
+
const closed = await Promise.race([
|
|
305
|
+
Promise.resolve()
|
|
306
|
+
.then(opts.closeBrowser)
|
|
307
|
+
.then(() => true, () => false),
|
|
308
|
+
new Promise((resolve) => {
|
|
309
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
310
|
+
}),
|
|
311
|
+
]);
|
|
312
|
+
if (timer !== undefined)
|
|
570
313
|
clearTimeout(timer);
|
|
571
|
-
|
|
572
|
-
|
|
314
|
+
const waitForExit = async () => {
|
|
315
|
+
const deadline = Date.now() + 2_000;
|
|
316
|
+
while (opts.isRunning() && Date.now() < deadline) {
|
|
317
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
318
|
+
}
|
|
319
|
+
return !opts.isRunning();
|
|
573
320
|
};
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
try {
|
|
580
|
-
rmSync(rig.webDir, { recursive: true, force: true });
|
|
581
|
-
}
|
|
582
|
-
catch {
|
|
583
|
-
// best-effort
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
if (rig.passFile !== undefined) {
|
|
587
|
-
try {
|
|
588
|
-
rmSync(rig.passFile, { force: true });
|
|
589
|
-
}
|
|
590
|
-
catch {
|
|
591
|
-
// best-effort
|
|
321
|
+
if (closed && (await waitForExit()))
|
|
322
|
+
profileState = "closed";
|
|
323
|
+
else {
|
|
324
|
+
opts.forceClose();
|
|
325
|
+
profileState = (await waitForExit()) ? "closed" : "force_closed_unproven";
|
|
592
326
|
}
|
|
593
327
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
try {
|
|
603
|
-
child.stderr?.destroy();
|
|
604
|
-
}
|
|
605
|
-
catch {
|
|
606
|
-
// best-effort
|
|
607
|
-
}
|
|
608
|
-
try {
|
|
609
|
-
child.unref();
|
|
610
|
-
}
|
|
611
|
-
catch {
|
|
612
|
-
// best-effort
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
function forceTeardownHeadlessRig(rig) {
|
|
616
|
-
for (const child of rig.procs) {
|
|
617
|
-
try {
|
|
618
|
-
if (!childHasExited(child))
|
|
619
|
-
child.kill("SIGKILL");
|
|
620
|
-
}
|
|
621
|
-
catch {
|
|
622
|
-
// best-effort
|
|
623
|
-
}
|
|
624
|
-
releaseChildHandles(child);
|
|
328
|
+
else {
|
|
329
|
+
profileState = await closeProfileWithProof({
|
|
330
|
+
profileDir: opts.profileDir,
|
|
331
|
+
identity: opts.identity,
|
|
332
|
+
close: opts.closeBrowser,
|
|
333
|
+
forceClose: opts.forceClose,
|
|
334
|
+
...(opts.timeoutMs !== undefined ? { closeTimeoutMs: opts.timeoutMs } : {}),
|
|
335
|
+
});
|
|
625
336
|
}
|
|
626
|
-
|
|
627
|
-
}
|
|
628
|
-
export function teardownHeadlessRig(rig, graceMs = 1_000) {
|
|
629
|
-
const existing = headlessRigTeardowns.get(rig);
|
|
630
|
-
if (existing !== undefined)
|
|
631
|
-
return existing;
|
|
632
|
-
const teardown = (async () => {
|
|
633
|
-
const running = rig.procs.filter((child) => !childHasExited(child));
|
|
634
|
-
for (const child of running) {
|
|
635
|
-
try {
|
|
636
|
-
child.kill("SIGTERM");
|
|
637
|
-
}
|
|
638
|
-
catch {
|
|
639
|
-
// best-effort
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
await Promise.all(running.map((child) => waitForChildExit(child, graceMs)));
|
|
643
|
-
const resistant = running.filter((child) => !childHasExited(child));
|
|
644
|
-
for (const child of resistant) {
|
|
645
|
-
try {
|
|
646
|
-
child.kill("SIGKILL");
|
|
647
|
-
}
|
|
648
|
-
catch {
|
|
649
|
-
// best-effort
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
await Promise.all(resistant.map((child) => waitForChildExit(child, graceMs)));
|
|
653
|
-
for (const child of rig.procs)
|
|
654
|
-
releaseChildHandles(child);
|
|
655
|
-
removeHeadlessRigFiles(rig);
|
|
656
|
-
})();
|
|
657
|
-
headlessRigTeardowns.set(rig, teardown);
|
|
658
|
-
return teardown;
|
|
659
|
-
}
|
|
660
|
-
export async function teardownLoginBrowser(opts) {
|
|
661
|
-
return await closeProfileWithProof({
|
|
662
|
-
profileDir: opts.profileDir,
|
|
663
|
-
identity: opts.identity,
|
|
664
|
-
close: opts.closeBrowser,
|
|
665
|
-
forceClose: opts.forceClose,
|
|
666
|
-
...(opts.timeoutMs !== undefined ? { closeTimeoutMs: opts.timeoutMs } : {}),
|
|
667
|
-
});
|
|
337
|
+
return profileState;
|
|
668
338
|
}
|
|
669
339
|
// --- shutdown coordination with the operator server -------------------
|
|
670
340
|
// Every in-flight login run registers a cancel closure here so an external
|
|
@@ -674,16 +344,15 @@ export async function teardownLoginBrowser(opts) {
|
|
|
674
344
|
// process identity), so cancellation never signals a PID it cannot prove
|
|
675
345
|
// ownership of.
|
|
676
346
|
const activeLoginBrowserCancels = new Set();
|
|
677
|
-
//
|
|
678
|
-
// callers. Returns the unregister disposer for the normal completion path.
|
|
347
|
+
// Returns the unregister disposer for the normal completion path.
|
|
679
348
|
export function trackActiveLoginBrowser(cancel) {
|
|
680
349
|
activeLoginBrowserCancels.add(cancel);
|
|
681
350
|
return () => {
|
|
682
351
|
activeLoginBrowserCancels.delete(cancel);
|
|
683
352
|
};
|
|
684
353
|
}
|
|
685
|
-
// Cancel every in-flight login run's browser
|
|
686
|
-
//
|
|
354
|
+
// Cancel every in-flight login run's browser. Called by the MCP server's
|
|
355
|
+
// shutdown path; idempotent and
|
|
687
356
|
// best-effort — a failed teardown must not stall the process exit, whose
|
|
688
357
|
// process-level exit hooks still force-kill anything identity-proven.
|
|
689
358
|
export async function cancelActiveLoginBrowsers() {
|
|
@@ -702,6 +371,10 @@ function createTrackedLoginBrowserLifecycle(teardownRun) {
|
|
|
702
371
|
let browserTeardown;
|
|
703
372
|
let runTeardown;
|
|
704
373
|
let cancellation;
|
|
374
|
+
let resolveCancellation;
|
|
375
|
+
const cancellationSignal = new Promise((resolve) => {
|
|
376
|
+
resolveCancellation = resolve;
|
|
377
|
+
});
|
|
705
378
|
let finishing;
|
|
706
379
|
const settleLaunch = () => {
|
|
707
380
|
if (launchSettled)
|
|
@@ -724,8 +397,10 @@ function createTrackedLoginBrowserLifecycle(teardownRun) {
|
|
|
724
397
|
let lifecycle;
|
|
725
398
|
const untrack = trackActiveLoginBrowser(async () => await lifecycle.cancel());
|
|
726
399
|
lifecycle = {
|
|
400
|
+
cancellation: cancellationSignal,
|
|
727
401
|
cancel: () => {
|
|
728
402
|
cancelled = true;
|
|
403
|
+
resolveCancellation();
|
|
729
404
|
cancellation ??= (async () => {
|
|
730
405
|
await launchSettlement;
|
|
731
406
|
try {
|
|
@@ -768,570 +443,129 @@ function createTrackedLoginBrowserLifecycle(teardownRun) {
|
|
|
768
443
|
};
|
|
769
444
|
return lifecycle;
|
|
770
445
|
}
|
|
771
|
-
const selfManagedSignalExitCoordination = {
|
|
772
|
-
enabled: isSelfManagedChromeTerminationSignalExitEnabled,
|
|
773
|
-
set: setSelfManagedChromeTerminationSignalExitEnabled,
|
|
774
|
-
};
|
|
775
|
-
// Attach the per-login rig to every process termination route. The function
|
|
776
|
-
// returns a disposer for the normal completion path; callers must keep the
|
|
777
|
-
// browser teardown in the getter so an interrupt can also release the Chrome
|
|
778
|
-
// profile lock before Node exits. The hard cap prevents a wedged browser from
|
|
779
|
-
// keeping Xvfb/x11vnc/websockify (or cloudflared) alive indefinitely.
|
|
780
|
-
//
|
|
781
|
-
// Exit ownership is coordinated through `signalExit`, one owner at a time:
|
|
782
|
-
// - When self-managed signal exits are ENABLED (the CLI default), this login
|
|
783
|
-
// takes sole ownership for its duration — it suspends browser.ts's
|
|
784
|
-
// SIGKILL-and-exit handlers (which would otherwise preempt the capped
|
|
785
|
-
// graceful teardown below on the same signal) and restores them on
|
|
786
|
-
// disposal. The always-on `exit` hooks (here for the rig, browser.ts's for
|
|
787
|
-
// identity-proven Chromes) stay as the force-kill backstop.
|
|
788
|
-
// - When they are DISABLED, a central shutdown coordinator (the MCP server's
|
|
789
|
-
// requestShutdown) already owns process exit and drains this login via
|
|
790
|
-
// cancelActiveLoginBrowsers(); installing exit-calling signal or
|
|
791
|
-
// uncaughtException handlers here would race it (and would violate the
|
|
792
|
-
// server's log-and-keep-serving process guards), so only the pure-cleanup
|
|
793
|
-
// `exit` hook is registered.
|
|
794
|
-
export function registerHeadlessRigCleanup(rig, activeBrowserTeardown, runtime = process, signalExit = selfManagedSignalExitCoordination) {
|
|
795
|
-
let finishing = false;
|
|
796
|
-
let signalExitSuspended = false;
|
|
797
|
-
const restoreSignalExitOwnership = () => {
|
|
798
|
-
if (!signalExitSuspended)
|
|
799
|
-
return;
|
|
800
|
-
signalExitSuspended = false;
|
|
801
|
-
signalExit.set(true);
|
|
802
|
-
};
|
|
803
|
-
const teardownBrowserWithin = async () => {
|
|
804
|
-
const closeBrowser = activeBrowserTeardown();
|
|
805
|
-
if (closeBrowser === undefined)
|
|
806
|
-
return;
|
|
807
|
-
await new Promise((resolve) => {
|
|
808
|
-
const timer = setTimeout(resolve, 3_000);
|
|
809
|
-
void closeBrowser()
|
|
810
|
-
.catch(() => undefined)
|
|
811
|
-
.finally(() => {
|
|
812
|
-
clearTimeout(timer);
|
|
813
|
-
resolve();
|
|
814
|
-
});
|
|
815
|
-
});
|
|
816
|
-
};
|
|
817
|
-
const exitAfterCleanup = (code) => {
|
|
818
|
-
if (finishing) {
|
|
819
|
-
forceTeardownHeadlessRig(rig);
|
|
820
|
-
restoreSignalExitOwnership();
|
|
821
|
-
runtime.exit(code);
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
finishing = true;
|
|
825
|
-
void teardownBrowserWithin().finally(async () => {
|
|
826
|
-
await teardownHeadlessRig(rig);
|
|
827
|
-
restoreSignalExitOwnership();
|
|
828
|
-
runtime.exit(code);
|
|
829
|
-
});
|
|
830
|
-
};
|
|
831
|
-
const onExit = () => forceTeardownHeadlessRig(rig);
|
|
832
|
-
const onSigterm = () => exitAfterCleanup(143);
|
|
833
|
-
const onSigint = () => exitAfterCleanup(130);
|
|
834
|
-
const onUncaughtException = (error) => {
|
|
835
|
-
console.error("[login] uncaught exception; tearing down the login browser", error);
|
|
836
|
-
exitAfterCleanup(1);
|
|
837
|
-
};
|
|
838
|
-
const onUnhandledRejection = (reason) => {
|
|
839
|
-
console.error("[login] unhandled rejection; tearing down the login browser", reason);
|
|
840
|
-
exitAfterCleanup(1);
|
|
841
|
-
};
|
|
842
|
-
runtime.once("exit", onExit);
|
|
843
|
-
const ownsTerminationExit = signalExit.enabled();
|
|
844
|
-
if (ownsTerminationExit) {
|
|
845
|
-
signalExit.set(false);
|
|
846
|
-
signalExitSuspended = true;
|
|
847
|
-
runtime.on("SIGTERM", onSigterm);
|
|
848
|
-
runtime.on("SIGINT", onSigint);
|
|
849
|
-
runtime.once("uncaughtException", onUncaughtException);
|
|
850
|
-
runtime.once("unhandledRejection", onUnhandledRejection);
|
|
851
|
-
}
|
|
852
|
-
return () => {
|
|
853
|
-
if (finishing)
|
|
854
|
-
return;
|
|
855
|
-
runtime.removeListener("exit", onExit);
|
|
856
|
-
if (!ownsTerminationExit)
|
|
857
|
-
return;
|
|
858
|
-
runtime.removeListener("SIGTERM", onSigterm);
|
|
859
|
-
runtime.removeListener("SIGINT", onSigint);
|
|
860
|
-
runtime.removeListener("uncaughtException", onUncaughtException);
|
|
861
|
-
runtime.removeListener("unhandledRejection", onUnhandledRejection);
|
|
862
|
-
restoreSignalExitOwnership();
|
|
863
|
-
};
|
|
864
|
-
}
|
|
865
|
-
export function fallbackCloudflaredArgs(webPort) {
|
|
866
|
-
return ["tunnel", "--protocol", "http2", "--url", `http://127.0.0.1:${webPort}`];
|
|
867
|
-
}
|
|
868
|
-
// Assemble the directory websockify serves: a copy of the installed
|
|
869
|
-
// noVNC web assets (for the `core/` RFB library) with our branded
|
|
870
|
-
// vnc.html written over the stock one. A temp dir, NOT the package's
|
|
871
|
-
// own assets/ — under `npx` the package runs from a read-only cache,
|
|
872
|
-
// so the noVNC core cannot be copied into it. Torn down with the rig.
|
|
873
|
-
function buildVncWebDir() {
|
|
874
|
-
const webDir = mkdtempSync(join(tmpdir(), "ts-novnc-"));
|
|
875
|
-
cpSync(NOVNC_INSTALL_DIR, webDir, { recursive: true });
|
|
876
|
-
// The branded page imports `./core/rfb.js`. If the distro's novnc
|
|
877
|
-
// package put its core somewhere else, fail loudly here rather than
|
|
878
|
-
// serving a page that 404s its own script and shows a blank screen.
|
|
879
|
-
if (!existsSync(join(webDir, "core", "rfb.js"))) {
|
|
880
|
-
rmSync(webDir, { recursive: true, force: true });
|
|
881
|
-
throw new Error(`noVNC core not found at ${NOVNC_INSTALL_DIR}/core/rfb.js — the ` +
|
|
882
|
-
`installed novnc package has an unexpected layout`);
|
|
883
|
-
}
|
|
884
|
-
const branded = readFileSync(loginAssetPath("vnc.html"), "utf8");
|
|
885
|
-
writeFileSync(join(webDir, "vnc.html"), branded);
|
|
886
|
-
// Also write index.html so the URL printed to the user's terminal can
|
|
887
|
-
// drop `/vnc.html` — every saved character matters on a phone, where
|
|
888
|
-
// line-wrapping the URL across two terminal lines makes copy/paste
|
|
889
|
-
// hostile. websockify's static server returns index.html for `/`.
|
|
890
|
-
writeFileSync(join(webDir, "index.html"), branded);
|
|
891
|
-
return webDir;
|
|
892
|
-
}
|
|
893
446
|
const LOGIN_BROWSER_CLOSED_ERROR = "the login browser closed before the session completed — re-run the command after closing any other Trusty Squire session";
|
|
894
447
|
const LOGIN_STATUS_CHECK_STALLED_ERROR = "the login status check stopped responding before the session completed";
|
|
895
448
|
export async function runInBotChrome(opts) {
|
|
896
|
-
return await withProfileOperationGuard(opts.profileDir, async () =>
|
|
897
|
-
const result = await runInBotChromeWithProfileGuard(opts);
|
|
898
|
-
await finalizeLoginRun(opts, result);
|
|
899
|
-
return { status: result.status };
|
|
900
|
-
});
|
|
901
|
-
}
|
|
902
|
-
export async function finalizeLoginRun(opts, result, publishSeed = publishOperatorProfileSeed) {
|
|
903
|
-
if (result.status === "completed" || result.status === "preflight_satisfied") {
|
|
904
|
-
await opts.onConfirmedLogin?.();
|
|
905
|
-
}
|
|
906
|
-
const provider = typeof opts.seedProvider === "function" ? opts.seedProvider() : (opts.seedProvider ?? null);
|
|
907
|
-
const proof = { loginStatus: result.status, closeState: result.closeState, provider };
|
|
908
|
-
if (canPublishOperatorProfileSeed(proof)) {
|
|
909
|
-
await publishSeed(opts.profileDir, { proof });
|
|
910
|
-
}
|
|
449
|
+
return await withProfileOperationGuard(opts.profileDir, async () => await runInBotChromeWithProfileGuard(opts));
|
|
911
450
|
}
|
|
912
451
|
async function runInBotChromeWithProfileGuard(opts) {
|
|
913
|
-
// `mcp
|
|
452
|
+
// `mcp connect` runs in a SEPARATE process from the MCP server, so the
|
|
914
453
|
// in-process OAuth mutex can't serialize it against an in-flight signup.
|
|
915
454
|
// Chrome's SingletonLock is the cross-process semaphore: reclaim a stale
|
|
916
|
-
// holder, but fail immediately for a live one.
|
|
917
|
-
// second connect apparently frozen while a duplicate noVNC stack accumulated.
|
|
455
|
+
// holder, but fail immediately for a live one.
|
|
918
456
|
const free = await waitForProfileFree(opts.profileDir, {
|
|
919
457
|
deadlineMs: 0,
|
|
920
458
|
});
|
|
921
459
|
if (!free) {
|
|
922
460
|
throw new ProfileBusyError(PROFILE_BUSY_MESSAGE);
|
|
923
461
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
return
|
|
462
|
+
return await runLoginBrowserForEnvironment(opts);
|
|
463
|
+
}
|
|
464
|
+
export async function runLoginBrowserForEnvironment(opts, runtime = { hasDisplay, runDisplayedChrome, runRemoteLoginChrome }) {
|
|
465
|
+
return runtime.hasDisplay()
|
|
466
|
+
? await runtime.runDisplayedChrome(opts)
|
|
467
|
+
: await runtime.runRemoteLoginChrome(opts);
|
|
928
468
|
}
|
|
929
469
|
export async function runDisplayedChrome(opts, runtime = { resolveChannelBinary, launchPlainLoginBrowser }) {
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
if (opts.plainProfileLogin === true) {
|
|
934
|
-
if (opts.plainPollUntilDone === undefined) {
|
|
935
|
-
throw new Error("plainProfileLogin set without plainPollUntilDone");
|
|
936
|
-
}
|
|
937
|
-
const binary = runtime.resolveChannelBinary("chrome");
|
|
938
|
-
if (binary === null) {
|
|
939
|
-
throw new Error("no Chrome binary found for the plain login browser");
|
|
940
|
-
}
|
|
941
|
-
const proxyOpt = loginProxyOption();
|
|
942
|
-
const proxyDisposition = proxyOpt !== undefined && proxyOpt.password === undefined
|
|
943
|
-
? selfLaunchProxyDisposition(proxyOpt)
|
|
944
|
-
: null;
|
|
945
|
-
opts.onProxyDisposition?.(proxyDisposition);
|
|
946
|
-
const lifecycle = createTrackedLoginBrowserLifecycle();
|
|
947
|
-
let status = "timeout";
|
|
948
|
-
let closeState = "unknown";
|
|
949
|
-
try {
|
|
950
|
-
const browser = await runtime.launchPlainLoginBrowser({
|
|
951
|
-
binary,
|
|
952
|
-
profileDir: opts.profileDir,
|
|
953
|
-
url: opts.url,
|
|
954
|
-
window: { width: 1280, height: 800 },
|
|
955
|
-
env: process.env,
|
|
956
|
-
// Self-launch/--proxy-server can't carry proxy auth — drop a credentialed
|
|
957
|
-
// proxy (direct). Connect from the box is the point anyway.
|
|
958
|
-
proxyServer: proxyDisposition?.server ?? null,
|
|
959
|
-
extraArgs: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
960
|
-
});
|
|
961
|
-
lifecycle.browserLaunched(async () => await teardownLoginBrowser({
|
|
962
|
-
profileDir: opts.profileDir,
|
|
963
|
-
identity: browser.identity,
|
|
964
|
-
closeBrowser: browser.teardown,
|
|
965
|
-
forceClose: browser.forceTeardown,
|
|
966
|
-
}));
|
|
967
|
-
console.error(`\n[login] A Chrome window has opened. ${opts.bannerLabel}\n`);
|
|
968
|
-
const ok = await pollUntil(opts.deadline, () => opts.plainPollUntilDone(opts.profileDir), opts.heartbeatMessage, () => {
|
|
969
|
-
if (!browser.isRunning())
|
|
970
|
-
throw new Error(LOGIN_BROWSER_CLOSED_ERROR);
|
|
971
|
-
});
|
|
972
|
-
if (ok && opts.plainOnSuccess !== undefined) {
|
|
973
|
-
try {
|
|
974
|
-
await opts.plainOnSuccess(opts.profileDir);
|
|
975
|
-
}
|
|
976
|
-
catch {
|
|
977
|
-
/* swallow */
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
status = ok ? "completed" : "timeout";
|
|
981
|
-
}
|
|
982
|
-
finally {
|
|
983
|
-
closeState = await lifecycle.finish();
|
|
984
|
-
}
|
|
985
|
-
return { status, closeState };
|
|
470
|
+
const binary = runtime.resolveChannelBinary("chrome");
|
|
471
|
+
if (binary === null) {
|
|
472
|
+
throw new Error("no Chrome binary found for the plain login browser");
|
|
986
473
|
}
|
|
987
|
-
const chromium = resolveChromium();
|
|
988
474
|
const proxyOpt = loginProxyOption();
|
|
989
|
-
|
|
475
|
+
const proxyDisposition = proxyOpt !== undefined && proxyOpt.password === undefined
|
|
476
|
+
? selfLaunchProxyDisposition(proxyOpt)
|
|
477
|
+
: null;
|
|
478
|
+
opts.onProxyDisposition?.(proxyDisposition);
|
|
990
479
|
const lifecycle = createTrackedLoginBrowserLifecycle();
|
|
991
480
|
let status = "timeout";
|
|
992
481
|
let closeState = "unknown";
|
|
993
482
|
try {
|
|
994
|
-
const
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
//
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
"--disable-dev-shm-usage",
|
|
1006
|
-
],
|
|
1007
|
-
...(proxyOpt !== undefined ? { proxy: proxyOpt } : {}),
|
|
1008
|
-
}), { failFast: true });
|
|
1009
|
-
const holderPid = currentProfileHolderPid(opts.profileDir);
|
|
1010
|
-
const identity = holderPid === null ? null : profileProcessIdentity(holderPid, opts.profileDir);
|
|
483
|
+
const browser = await runtime.launchPlainLoginBrowser({
|
|
484
|
+
binary,
|
|
485
|
+
profileDir: opts.profileDir,
|
|
486
|
+
url: opts.url,
|
|
487
|
+
window: { width: 1280, height: 800 },
|
|
488
|
+
env: process.env,
|
|
489
|
+
// Self-launch/--proxy-server can't carry proxy auth — drop a credentialed
|
|
490
|
+
// proxy (direct). Connect from the box is the point anyway.
|
|
491
|
+
proxyServer: proxyDisposition?.server ?? null,
|
|
492
|
+
extraArgs: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
493
|
+
});
|
|
1011
494
|
lifecycle.browserLaunched(async () => await teardownLoginBrowser({
|
|
1012
495
|
profileDir: opts.profileDir,
|
|
1013
|
-
identity,
|
|
1014
|
-
closeBrowser:
|
|
1015
|
-
forceClose:
|
|
496
|
+
identity: browser.identity,
|
|
497
|
+
closeBrowser: browser.teardown,
|
|
498
|
+
forceClose: browser.forceTeardown,
|
|
499
|
+
isRunning: browser.isRunning,
|
|
1016
500
|
}));
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
const page = context.pages()[0] ?? (await context.newPage());
|
|
1024
|
-
await page.goto(opts.url, { waitUntil: "domcontentloaded" });
|
|
1025
|
-
console.error(`\n[login] A Chrome window has opened. ${opts.bannerLabel}\n`);
|
|
1026
|
-
const ok = await pollUntil(opts.deadline, () => opts.pollUntilDone(context), opts.heartbeatMessage);
|
|
1027
|
-
if (ok && opts.onSuccess !== undefined) {
|
|
1028
|
-
try {
|
|
1029
|
-
await opts.onSuccess(context);
|
|
1030
|
-
}
|
|
1031
|
-
catch {
|
|
1032
|
-
/* swallow */
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
status = ok ? "completed" : "timeout";
|
|
1036
|
-
}
|
|
501
|
+
console.error(`\n[login] A Chrome window has opened. ${opts.bannerLabel}\n`);
|
|
502
|
+
const ok = await pollUntil(opts.deadline, () => opts.pollUntilDone(), opts.heartbeatMessage, () => {
|
|
503
|
+
if (!browser.isRunning())
|
|
504
|
+
throw new Error(LOGIN_BROWSER_CLOSED_ERROR);
|
|
505
|
+
});
|
|
506
|
+
status = ok ? "satisfied" : "timeout";
|
|
1037
507
|
}
|
|
1038
508
|
finally {
|
|
1039
509
|
closeState = await lifecycle.finish();
|
|
1040
510
|
}
|
|
1041
511
|
return { status, closeState };
|
|
1042
512
|
}
|
|
1043
|
-
async function
|
|
1044
|
-
|
|
1045
|
-
if (!existsSync("/usr/share/novnc")) {
|
|
1046
|
-
throw new Error("noVNC web assets not found at /usr/share/novnc — install the `novnc` package");
|
|
1047
|
-
}
|
|
1048
|
-
// G16 — named-tunnel mode. When the operator has set up a dedicated
|
|
1049
|
-
// Cloudflare named tunnel pointing at a fixed local port, opt in via
|
|
1050
|
-
// env. The bot then:
|
|
1051
|
-
// - binds websockify to that fixed local port (so the pre-configured
|
|
1052
|
-
// tunnel's ingress finds it)
|
|
1053
|
-
// - skips the per-session `cloudflared tunnel --url` spawn (the
|
|
1054
|
-
// named tunnel runs as a systemd service outside this process)
|
|
1055
|
-
// - prints the short branded URL instead of the long random one
|
|
1056
|
-
// Both env vars must be set; absence (default) keeps the existing
|
|
1057
|
-
// random `*.trycloudflare.com` flow.
|
|
1058
|
-
const namedTunnelHost = process.env.TS_LOGIN_PUBLIC_HOSTNAME;
|
|
1059
|
-
const namedTunnelPortStr = process.env.TS_LOGIN_LOCAL_PORT;
|
|
1060
|
-
const usingNamedTunnel = namedTunnelHost !== undefined &&
|
|
1061
|
-
namedTunnelHost.length > 0 &&
|
|
1062
|
-
namedTunnelPortStr !== undefined &&
|
|
1063
|
-
namedTunnelPortStr.length > 0;
|
|
1064
|
-
const display = pickFreeDisplay();
|
|
1065
|
-
const vncPort = await findFreePort();
|
|
1066
|
-
const webPort = usingNamedTunnel ? Number(namedTunnelPortStr) : await findFreePort();
|
|
1067
|
-
if (usingNamedTunnel && (!Number.isFinite(webPort) || webPort <= 0)) {
|
|
1068
|
-
throw new Error(`TS_LOGIN_LOCAL_PORT=${JSON.stringify(namedTunnelPortStr)} is not a valid port number`);
|
|
1069
|
-
}
|
|
1070
|
-
const vncPassword = randomBytes(4).toString("hex"); // 8 chars — VNC's limit
|
|
1071
|
-
const rig = { procs: [], display };
|
|
1072
|
-
// The lifecycle below memoizes browser and rig teardown across normal
|
|
1073
|
-
// completion, CLI signals, and server cancellation. Self-launched Chrome
|
|
1074
|
-
// needs child teardown as well as context.close(); every forced close still
|
|
1075
|
-
// goes through the launch-time process-identity proof.
|
|
513
|
+
export async function runRemoteLoginChrome(opts) {
|
|
514
|
+
const rig = createRemoteLoginRig();
|
|
1076
515
|
let activeTeardown;
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
// disposer removes process-global listeners once this login finishes.
|
|
1080
|
-
const removeRigCleanup = registerHeadlessRigCleanup(rig, () => activeTeardown);
|
|
1081
|
-
const lifecycle = createTrackedLoginBrowserLifecycle(async () => await teardownHeadlessRig(rig));
|
|
516
|
+
const removeRigCleanup = registerRemoteLoginRigCleanup(rig, () => activeTeardown);
|
|
517
|
+
const lifecycle = createTrackedLoginBrowserLifecycle(async () => await teardownRemoteLoginRig(rig));
|
|
1082
518
|
activeTeardown = lifecycle.cancel;
|
|
1083
519
|
try {
|
|
1084
|
-
|
|
1085
|
-
rig.procs.push(spawnBg("Xvfb", [display, "-screen", "0", `${HEADLESS_W}x${HEADLESS_H}x24`, "-ac"]));
|
|
1086
|
-
await new Promise((r) => setTimeout(r, 1500));
|
|
520
|
+
await startRemoteLoginDisplay(rig);
|
|
1087
521
|
lifecycle.throwIfCancelled();
|
|
1088
|
-
// 2. Chrome on that display, persistent profile, window filling the display.
|
|
1089
|
-
// Self-launch + connectOverCDP is the STATE.md 2026-06-12 launcher fix:
|
|
1090
|
-
// a launchPersistentContext-driven Chrome carries the launch-time
|
|
1091
|
-
// instrumentation anti-bot (Cloudflare Turnstile, and — the bug this
|
|
1092
|
-
// ports the fix for — Google's sign-in device-prompt) reads as a bot,
|
|
1093
|
-
// while a self-launched Chrome attached over CDP does not. The signup
|
|
1094
|
-
// path (BrowserController) already migrated; the connect login was the
|
|
1095
|
-
// last consumer of the detectable launcher. Fall back to the old
|
|
1096
|
-
// launcher when self-launch is opted out (BOT_SELF_LAUNCH=0), Chrome
|
|
1097
|
-
// isn't resolvable as a real binary, or a credentialed proxy is set
|
|
1098
|
-
// (self-launch's --proxy-server can't carry proxy auth).
|
|
1099
522
|
const proxyOpt = loginProxyOption();
|
|
1100
523
|
const chromeBinary = resolveChannelBinary("chrome");
|
|
1101
|
-
const useSelfLaunch = selfLaunchEnabled() &&
|
|
1102
|
-
chromeBinary !== null &&
|
|
1103
|
-
(proxyOpt === undefined || proxyOpt.password === undefined);
|
|
1104
524
|
const sharedChromeArgs = [
|
|
1105
525
|
"--disable-blink-features=AutomationControlled",
|
|
1106
|
-
// --test-type suppresses the "unsupported command-line flag:
|
|
1107
|
-
// --no-sandbox" yellow infobar that otherwise eats the top strip of the
|
|
1108
|
-
// phone-shaped framebuffer once handed to noVNC. Standard automation
|
|
1109
|
-
// flag; doesn't affect OAuth/cookies.
|
|
1110
526
|
"--test-type",
|
|
1111
527
|
"--no-sandbox",
|
|
1112
528
|
"--disable-dev-shm-usage",
|
|
1113
529
|
];
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
// plainPollUntilDone (API + on-disk cookies), never a live context.
|
|
1118
|
-
const plain = opts.plainProfileLogin === true;
|
|
1119
|
-
let context;
|
|
1120
|
-
if (plain) {
|
|
1121
|
-
if (opts.plainPollUntilDone === undefined) {
|
|
1122
|
-
throw new Error("plainProfileLogin set without plainPollUntilDone");
|
|
1123
|
-
}
|
|
1124
|
-
if (chromeBinary === null) {
|
|
1125
|
-
throw new Error("no Chrome binary found for the plain login browser");
|
|
1126
|
-
}
|
|
1127
|
-
const proxyDisposition = proxyOpt !== undefined && proxyOpt.password === undefined
|
|
1128
|
-
? selfLaunchProxyDisposition(proxyOpt)
|
|
1129
|
-
: null;
|
|
1130
|
-
opts.onProxyDisposition?.(proxyDisposition);
|
|
1131
|
-
const browser = await launchPlainLoginBrowser({
|
|
1132
|
-
binary: chromeBinary,
|
|
1133
|
-
profileDir: opts.profileDir,
|
|
1134
|
-
url: opts.url,
|
|
1135
|
-
window: { width: HEADLESS_W, height: HEADLESS_H },
|
|
1136
|
-
env: { ...process.env, DISPLAY: display },
|
|
1137
|
-
proxyServer: proxyDisposition?.server ?? null,
|
|
1138
|
-
extraArgs: sharedChromeArgs,
|
|
1139
|
-
});
|
|
1140
|
-
lifecycle.browserLaunched(async () => await teardownLoginBrowser({
|
|
1141
|
-
profileDir: opts.profileDir,
|
|
1142
|
-
identity: browser.identity,
|
|
1143
|
-
closeBrowser: browser.teardown,
|
|
1144
|
-
forceClose: browser.forceTeardown,
|
|
1145
|
-
}));
|
|
1146
|
-
plainBrowserIsRunning = browser.isRunning;
|
|
1147
|
-
}
|
|
1148
|
-
else if (useSelfLaunch && chromeBinary !== null) {
|
|
1149
|
-
const proxyDisposition = selfLaunchProxyDisposition(proxyOpt);
|
|
1150
|
-
opts.onProxyDisposition?.(proxyDisposition);
|
|
1151
|
-
const launched = await launchSelfManagedLoginContext({
|
|
1152
|
-
binary: chromeBinary,
|
|
1153
|
-
profileDir: opts.profileDir,
|
|
1154
|
-
// App mode (--app) strips tabs/URL bar so the install page gets the
|
|
1155
|
-
// full phone-shaped framebuffer under noVNC.
|
|
1156
|
-
initialUrl: opts.url,
|
|
1157
|
-
appMode: true,
|
|
1158
|
-
window: { width: HEADLESS_W, height: HEADLESS_H },
|
|
1159
|
-
env: { ...process.env, DISPLAY: display },
|
|
1160
|
-
proxyServer: proxyDisposition?.server ?? null,
|
|
1161
|
-
extraArgs: sharedChromeArgs,
|
|
1162
|
-
});
|
|
1163
|
-
lifecycle.browserLaunched(async () => await teardownLoginBrowser({
|
|
1164
|
-
profileDir: opts.profileDir,
|
|
1165
|
-
identity: launched.identity,
|
|
1166
|
-
closeBrowser: launched.teardown,
|
|
1167
|
-
forceClose: launched.forceTeardown,
|
|
1168
|
-
}));
|
|
1169
|
-
context = launched.context;
|
|
1170
|
-
}
|
|
1171
|
-
else {
|
|
1172
|
-
const chromium = resolveChromium();
|
|
1173
|
-
opts.onProxyDisposition?.(proxyOpt ?? null);
|
|
1174
|
-
const persistent = await launchWithProfileGate(opts.profileDir, () => launchPersistentLoginContext(chromium, opts.profileDir, {
|
|
1175
|
-
headless: false,
|
|
1176
|
-
viewport: null, // use the real window size
|
|
1177
|
-
env: { ...process.env, DISPLAY: display },
|
|
1178
|
-
// Drop --enable-automation: kills the "controlled by automated test
|
|
1179
|
-
// software" infobar and the matching automation fingerprint.
|
|
1180
|
-
ignoreDefaultArgs: ["--enable-automation"],
|
|
1181
|
-
...(proxyOpt !== undefined ? { proxy: proxyOpt } : {}),
|
|
1182
|
-
args: [
|
|
1183
|
-
`--window-position=0,0`,
|
|
1184
|
-
`--window-size=${HEADLESS_W},${HEADLESS_H}`,
|
|
1185
|
-
`--app=${opts.url}`,
|
|
1186
|
-
...sharedChromeArgs,
|
|
1187
|
-
],
|
|
1188
|
-
}), { failFast: true });
|
|
1189
|
-
const persistentPid = currentProfileHolderPid(opts.profileDir);
|
|
1190
|
-
const identity = persistentPid === null ? null : profileProcessIdentity(persistentPid, opts.profileDir);
|
|
1191
|
-
lifecycle.browserLaunched(async () => await teardownLoginBrowser({
|
|
1192
|
-
profileDir: opts.profileDir,
|
|
1193
|
-
identity,
|
|
1194
|
-
closeBrowser: () => persistent.close(),
|
|
1195
|
-
forceClose: () => reapProfileHolderIfOwned(opts.profileDir, identity),
|
|
1196
|
-
}));
|
|
1197
|
-
context = persistent;
|
|
530
|
+
const browserEnv = remoteLoginEnvironment(rig);
|
|
531
|
+
if (chromeBinary === null) {
|
|
532
|
+
throw new Error("no Chrome binary found for the plain login browser");
|
|
1198
533
|
}
|
|
534
|
+
const proxyDisposition = proxyOpt !== undefined && proxyOpt.password === undefined
|
|
535
|
+
? selfLaunchProxyDisposition(proxyOpt)
|
|
536
|
+
: null;
|
|
537
|
+
opts.onProxyDisposition?.(proxyDisposition);
|
|
538
|
+
const browser = await launchPlainLoginBrowser({
|
|
539
|
+
binary: chromeBinary,
|
|
540
|
+
profileDir: opts.profileDir,
|
|
541
|
+
url: opts.url,
|
|
542
|
+
window: { width: rig.width, height: rig.height },
|
|
543
|
+
env: browserEnv,
|
|
544
|
+
proxyServer: proxyDisposition?.server ?? null,
|
|
545
|
+
extraArgs: sharedChromeArgs,
|
|
546
|
+
});
|
|
547
|
+
lifecycle.browserLaunched(async () => await teardownLoginBrowser({
|
|
548
|
+
profileDir: opts.profileDir,
|
|
549
|
+
identity: browser.identity,
|
|
550
|
+
closeBrowser: browser.teardown,
|
|
551
|
+
forceClose: browser.forceTeardown,
|
|
552
|
+
}));
|
|
1199
553
|
try {
|
|
1200
|
-
|
|
1201
|
-
// path has no context — plain Chrome's --app already opened opts.url.
|
|
1202
|
-
if (context !== undefined) {
|
|
1203
|
-
if (opts.preflight !== undefined &&
|
|
1204
|
-
(await checkLoginStatusWithin(opts.deadline, () => opts.preflight(context)))) {
|
|
1205
|
-
const closeState = await lifecycle.finish();
|
|
1206
|
-
return { status: "preflight_satisfied", closeState };
|
|
1207
|
-
}
|
|
1208
|
-
const page = context.pages()[0] ?? (await context.newPage());
|
|
1209
|
-
await page.goto(opts.url, { waitUntil: "domcontentloaded" });
|
|
1210
|
-
}
|
|
1211
|
-
// 3. x11vnc on the display — localhost-only, password-gated, -noshm
|
|
1212
|
-
// (the box's X server lacks the shared-memory extension).
|
|
1213
|
-
// G14 — pass the password via a 0600 file, NOT `-passwd <plain>`
|
|
1214
|
-
// on argv (which any `ps` could read). `rm:` makes x11vnc unlink
|
|
1215
|
-
// the file right after reading it.
|
|
1216
|
-
const passFile = join(tmpdir(), `tsq-vnc-${process.pid}-${vncPort}.pass`);
|
|
1217
|
-
writeFileSync(passFile, vncPassword, { mode: 0o600 });
|
|
1218
|
-
chmodSync(passFile, 0o600); // writeFileSync's mode is honored only on create
|
|
1219
|
-
rig.passFile = passFile;
|
|
1220
|
-
rig.procs.push(spawnBg("x11vnc", [
|
|
1221
|
-
"-display",
|
|
1222
|
-
display,
|
|
1223
|
-
"-rfbport",
|
|
1224
|
-
String(vncPort),
|
|
1225
|
-
"-passwdfile",
|
|
1226
|
-
`rm:${passFile}`,
|
|
1227
|
-
"-localhost",
|
|
1228
|
-
"-forever",
|
|
1229
|
-
"-shared",
|
|
1230
|
-
"-noshm",
|
|
1231
|
-
"-quiet",
|
|
1232
|
-
]));
|
|
1233
|
-
await new Promise((r) => setTimeout(r, 1500));
|
|
554
|
+
await exposeRemoteLoginDisplay(rig, opts.bannerLabel);
|
|
1234
555
|
lifecycle.throwIfCancelled();
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
rig.webDir = buildVncWebDir();
|
|
1239
|
-
rig.procs.push(spawnBg("websockify", [
|
|
1240
|
-
`--web=${rig.webDir}`,
|
|
1241
|
-
`127.0.0.1:${webPort}`,
|
|
1242
|
-
`localhost:${vncPort}`,
|
|
1243
|
-
]));
|
|
1244
|
-
await new Promise((r) => setTimeout(r, 1500));
|
|
1245
|
-
lifecycle.throwIfCancelled();
|
|
1246
|
-
// 5. Outbound tunnel.
|
|
1247
|
-
//
|
|
1248
|
-
// G16 named-tunnel mode (env-gated): the operator pre-provisioned
|
|
1249
|
-
// a dedicated cloudflared tunnel pointed at our fixed webPort
|
|
1250
|
-
// (running as a systemd service outside this process). No per-
|
|
1251
|
-
// session cloudflared spawn — saves ~5-10s of cold-start, and
|
|
1252
|
-
// the URL is short + branded (vnc.trustysquire.ai instead of
|
|
1253
|
-
// shouts-clean-mediawiki-cookies.trycloudflare.com).
|
|
1254
|
-
//
|
|
1255
|
-
// Random-tunnel fallback (default): spawn a fresh trycloudflare
|
|
1256
|
-
// tunnel per session. Works everywhere, no operator setup, but
|
|
1257
|
-
// the URL is ~80 chars and the cold-start hurts.
|
|
1258
|
-
let bannerUrl;
|
|
1259
|
-
if (usingNamedTunnel) {
|
|
1260
|
-
// The persistent operator-managed named tunnel lives outside this
|
|
1261
|
-
// process; run that service with `--protocol http2` too, avoiding the
|
|
1262
|
-
// same QUIC datagram-manager drops as the per-session fallback.
|
|
1263
|
-
// No `/vnc.html` — websockify serves the same content as
|
|
1264
|
-
// index.html (buildVncWebDir writes both). Shorter URL fits
|
|
1265
|
-
// one line on a phone terminal.
|
|
1266
|
-
bannerUrl = `https://${namedTunnelHost}/#p=${vncPassword}`;
|
|
1267
|
-
}
|
|
1268
|
-
else {
|
|
1269
|
-
const cf = spawnBg("cloudflared", fallbackCloudflaredArgs(webPort));
|
|
1270
|
-
rig.procs.push(cf);
|
|
1271
|
-
const tunnelUrl = await awaitTunnelUrl(cf, 30000);
|
|
1272
|
-
lifecycle.throwIfCancelled();
|
|
1273
|
-
const longVncUrl = `${tunnelUrl}/#p=${vncPassword}`;
|
|
1274
|
-
// G15 (deprecated by G16): shorten the cloudflared URL through
|
|
1275
|
-
// the API when we have an API base — much less transcription-
|
|
1276
|
-
// hostile on a phone than the raw cloudflared subdomain. Still
|
|
1277
|
-
// used on the random-tunnel fallback path; the named-tunnel
|
|
1278
|
-
// path doesn't need it (URL is already short).
|
|
1279
|
-
bannerUrl = longVncUrl;
|
|
1280
|
-
if (opts.apiBaseUrl !== undefined) {
|
|
1281
|
-
bannerUrl = await shortenVncUrl(opts.apiBaseUrl, longVncUrl);
|
|
1282
|
-
lifecycle.throwIfCancelled();
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
// The VNC password rides in the URL *fragment* (#), not the query
|
|
1286
|
-
// string — a fragment is never sent to the server, so it stays
|
|
1287
|
-
// out of the cloudflared edge logs and any proxy in between. The
|
|
1288
|
-
// branded vnc.html reads it from location.hash and connects with
|
|
1289
|
-
// no prompt.
|
|
1290
|
-
printBanner({
|
|
1291
|
-
tunnelUrl: bannerUrl,
|
|
1292
|
-
vncPassword,
|
|
1293
|
-
label: opts.bannerLabel,
|
|
1294
|
-
});
|
|
1295
|
-
// 6. Wait for the side effect, run the success hook against the
|
|
1296
|
-
// live context (e.g. inspect cookies that the user's sign-in
|
|
1297
|
-
// set — opening a second context after teardown is racy and
|
|
1298
|
-
// silently fails on profile lock), then tear the whole stack
|
|
1299
|
-
// down.
|
|
1300
|
-
const ok = await pollUntil(opts.deadline, () => context !== undefined
|
|
1301
|
-
? opts.pollUntilDone(context)
|
|
1302
|
-
: opts.plainPollUntilDone(opts.profileDir), opts.heartbeatMessage, () => {
|
|
1303
|
-
if (plainBrowserIsRunning !== undefined && !plainBrowserIsRunning()) {
|
|
556
|
+
const completed = await pollUntil(opts.deadline, () => opts.pollUntilDone(), opts.heartbeatMessage, () => {
|
|
557
|
+
assertRemoteLoginRigLive(rig);
|
|
558
|
+
if (!browser.isRunning())
|
|
1304
559
|
throw new Error(LOGIN_BROWSER_CLOSED_ERROR);
|
|
1305
|
-
}
|
|
1306
560
|
});
|
|
1307
|
-
if (ok) {
|
|
1308
|
-
if (context !== undefined) {
|
|
1309
|
-
if (opts.onSuccess !== undefined) {
|
|
1310
|
-
try {
|
|
1311
|
-
await opts.onSuccess(context);
|
|
1312
|
-
}
|
|
1313
|
-
catch {
|
|
1314
|
-
/* swallow */
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
else if (opts.plainOnSuccess !== undefined) {
|
|
1319
|
-
try {
|
|
1320
|
-
await opts.plainOnSuccess(opts.profileDir);
|
|
1321
|
-
}
|
|
1322
|
-
catch {
|
|
1323
|
-
/* swallow */
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
561
|
const closeState = await lifecycle.finish();
|
|
1328
|
-
return {
|
|
562
|
+
return {
|
|
563
|
+
status: completed ? "satisfied" : "timeout",
|
|
564
|
+
closeState,
|
|
565
|
+
};
|
|
1329
566
|
}
|
|
1330
567
|
finally {
|
|
1331
|
-
// Shared and memoized with the success and cancellation paths, so this
|
|
1332
|
-
// finally cannot double-tear the browser or rig.
|
|
1333
568
|
await lifecycle.finish();
|
|
1334
|
-
// Torn down — the signal handler must not double-tear it.
|
|
1335
569
|
activeTeardown = undefined;
|
|
1336
570
|
}
|
|
1337
571
|
}
|
|
@@ -1347,8 +581,8 @@ async function runHeadlessChrome(opts) {
|
|
|
1347
581
|
}
|
|
1348
582
|
// Shared timed-poll helper. `check` is invoked every 3s until it
|
|
1349
583
|
// resolves true or the deadline passes.
|
|
1350
|
-
// Emits a heartbeat to stderr every ~20s while waiting. After the
|
|
1351
|
-
//
|
|
584
|
+
// Emits a heartbeat to stderr every ~20s while waiting. After the local Chrome
|
|
585
|
+
// window or remote noVNC URL opens, this loop is otherwise silent for up to the full
|
|
1352
586
|
// deadline — which is the connect-hang report: a headless box printed the
|
|
1353
587
|
// sign-in URL and then sat on a blank cursor, looking frozen. The heartbeat
|
|
1354
588
|
// (with remaining time) makes it obviously alive; quick completions (< 20s,
|
|
@@ -1400,74 +634,6 @@ export function checkLoginStatusWithin(deadline, check, assertStillLive, maxChec
|
|
|
1400
634
|
});
|
|
1401
635
|
}
|
|
1402
636
|
// --- public entry ------------------------------------------------------
|
|
1403
|
-
// Ensures `profileDir` holds a valid session for `provider`, doing
|
|
1404
|
-
// whichever login flow the environment calls for. Returns when the
|
|
1405
|
-
// session is present, the deadline passes, or setup fails. T13: the
|
|
1406
|
-
// provider defaults to Google; `mcp login --provider=github` reuses
|
|
1407
|
-
// the same flow against github.com.
|
|
1408
|
-
export async function ensureOAuthSession(opts) {
|
|
1409
|
-
const provider = opts?.provider ?? "google";
|
|
1410
|
-
const target = LOGIN_TARGETS[provider];
|
|
1411
|
-
const profileDir = opts?.profileDir ?? CHROME_PROFILE_DIR;
|
|
1412
|
-
const timeoutMinutes = Math.max(1, opts?.timeoutMinutes ?? 15);
|
|
1413
|
-
const deadline = Date.now() + timeoutMinutes * 60 * 1000;
|
|
1414
|
-
try {
|
|
1415
|
-
const result = await runInBotChrome({
|
|
1416
|
-
profileDir,
|
|
1417
|
-
url: target.loginUrl,
|
|
1418
|
-
deadline,
|
|
1419
|
-
bannerLabel: `You'll see a Chrome window — log into your ${target.label} account.`,
|
|
1420
|
-
// When forceOpen is true, the preflight ALSO clears the
|
|
1421
|
-
// provider's session cookies before returning false. Without
|
|
1422
|
-
// this the noVNC opens but pollUntilDone immediately sees the
|
|
1423
|
-
// still-valid cached session and exits — the operator never
|
|
1424
|
-
// gets a chance to interactively log in or clear a server-side
|
|
1425
|
-
// challenge (GitHub "verify it's you"). Clearing the cookies
|
|
1426
|
-
// forces the next page load to land on the provider's login
|
|
1427
|
-
// page in a real, interactive session.
|
|
1428
|
-
preflight: async (ctx) => {
|
|
1429
|
-
if (opts?.forceOpen === true) {
|
|
1430
|
-
try {
|
|
1431
|
-
// Clear ONLY this provider's session cookies — never a bare
|
|
1432
|
-
// ctx.clearCookies(), which wiped EVERY provider (a
|
|
1433
|
-
// force-relogin=github would nuke the live Google session and the
|
|
1434
|
-
// operate precondition gate would then fail "no Google session").
|
|
1435
|
-
// The named session cookies are what define the login; dropping
|
|
1436
|
-
// them forces the next load to the provider's login page.
|
|
1437
|
-
for (const name of target.cookies) {
|
|
1438
|
-
await ctx.clearCookies({ name });
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
catch {
|
|
1442
|
-
// best-effort — if clear fails we still proceed and let
|
|
1443
|
-
// the operator hit Sign Out manually in the noVNC.
|
|
1444
|
-
}
|
|
1445
|
-
return false;
|
|
1446
|
-
}
|
|
1447
|
-
return hasProviderSession(ctx, target);
|
|
1448
|
-
},
|
|
1449
|
-
pollUntilDone: (ctx) => hasProviderSession(ctx, target),
|
|
1450
|
-
onConfirmedLogin: async () => markProviderLoggedIn(provider, profileDir),
|
|
1451
|
-
seedProvider: provider,
|
|
1452
|
-
...(opts?.apiBaseUrl !== undefined ? { apiBaseUrl: opts.apiBaseUrl } : {}),
|
|
1453
|
-
});
|
|
1454
|
-
// Map runInBotChrome's status set to ensureOAuthSession's contract.
|
|
1455
|
-
let mapped;
|
|
1456
|
-
if (result.status === "preflight_satisfied") {
|
|
1457
|
-
mapped = { status: "already_valid" };
|
|
1458
|
-
}
|
|
1459
|
-
else if (result.status === "completed") {
|
|
1460
|
-
mapped = { status: "logged_in" };
|
|
1461
|
-
}
|
|
1462
|
-
else {
|
|
1463
|
-
mapped = { status: "timeout", detail: "no login completed before the deadline" };
|
|
1464
|
-
}
|
|
1465
|
-
return mapped;
|
|
1466
|
-
}
|
|
1467
|
-
catch (err) {
|
|
1468
|
-
return { status: "error", detail: err instanceof Error ? err.message : String(err) };
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
637
|
// Public entry for the install flow: opens the trustysquire /install
|
|
1472
638
|
// confirm URL in the bot's persistent Chrome profile, runs the
|
|
1473
639
|
// user-supplied check until the active flow's completion gate passes
|
|
@@ -1480,8 +646,6 @@ export async function openInstallConfirmInBotChrome(opts, runChrome = runInBotCh
|
|
|
1480
646
|
const timeoutMinutes = Math.max(1, opts.timeoutMinutes ?? 15);
|
|
1481
647
|
const deadline = Date.now() + timeoutMinutes * 60 * 1000;
|
|
1482
648
|
let completion;
|
|
1483
|
-
let observedGoogleIdentity = false;
|
|
1484
|
-
const completedProviders = new Set();
|
|
1485
649
|
try {
|
|
1486
650
|
const doneUrl = new URL("/install/done", opts.confirmUrl).toString();
|
|
1487
651
|
completion = await startInstallCompletionListener(doneUrl, opts.confirmUrl);
|
|
@@ -1492,47 +656,10 @@ export async function openInstallConfirmInBotChrome(opts, runChrome = runInBotCh
|
|
|
1492
656
|
deadline,
|
|
1493
657
|
bannerLabel: `You'll see a Chrome window with the Trusty Squire install page. ` +
|
|
1494
658
|
`Sign in there to connect this machine — you only sign in once.`,
|
|
1495
|
-
|
|
1496
|
-
// initiates) rejects a CDP-attached Chrome (STATE.md 2026-07-20). The
|
|
1497
|
-
// context-taking pollUntilDone is never invoked in this mode; supply a
|
|
1498
|
-
// stub to satisfy the (CDP-path) type.
|
|
1499
|
-
plainProfileLogin: true,
|
|
1500
|
-
pollUntilDone: () => Promise.resolve(false),
|
|
1501
|
-
plainPollUntilDone: async (dir) => {
|
|
1502
|
-
const claim = await opts.pollUntilClaimed(dir, completion?.isCompleted() === true);
|
|
1503
|
-
if (typeof claim === "object" && claim.provider !== null) {
|
|
1504
|
-
completedProviders.add(claim.provider);
|
|
1505
|
-
}
|
|
1506
|
-
for (const provider of completion?.completedProviders() ?? []) {
|
|
1507
|
-
completedProviders.add(provider);
|
|
1508
|
-
}
|
|
1509
|
-
return installClaimPollCompleted(claim);
|
|
1510
|
-
},
|
|
1511
|
-
...(opts.apiBaseUrl !== undefined ? { apiBaseUrl: opts.apiBaseUrl } : {}),
|
|
659
|
+
pollUntilDone: async () => installClaimPollCompleted(await opts.pollUntilClaimed(completion?.isCompleted() === true)),
|
|
1512
660
|
...(opts.heartbeatMessage !== undefined ? { heartbeatMessage: opts.heartbeatMessage } : {}),
|
|
1513
|
-
// The user's sign-in inside this Chrome leaves a provider session in the
|
|
1514
|
-
// persistent profile. We don't know WHICH provider they used, so probe
|
|
1515
|
-
// both cookie sets (from the on-disk store — no live context in plain
|
|
1516
|
-
// mode) and mark whichever seeded.
|
|
1517
|
-
plainOnSuccess: async (dir) => {
|
|
1518
|
-
for (const provider of ["google", "github"]) {
|
|
1519
|
-
if (profileHasProviderCookies(dir, provider)) {
|
|
1520
|
-
markProviderLoggedIn(provider, dir);
|
|
1521
|
-
if (provider === "google" && completedProviders.has("google")) {
|
|
1522
|
-
observedGoogleIdentity = true;
|
|
1523
|
-
}
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
// NB: eager Google-email capture (captureGoogleEmail) needed a live
|
|
1527
|
-
// context to scrape myaccount.google.com; the plain login path has
|
|
1528
|
-
// none. It was only an optimization ("provision proceeds without a
|
|
1529
|
-
// pre-known email" otherwise) — provision scrapes the email per-run
|
|
1530
|
-
// when unset, so dropping eager capture is safe and keeping CDP off
|
|
1531
|
-
// the OAuth login is the whole point of the plain path.
|
|
1532
|
-
},
|
|
1533
|
-
seedProvider: () => (observedGoogleIdentity ? "google" : null),
|
|
1534
661
|
});
|
|
1535
|
-
if (result.status === "
|
|
662
|
+
if (result.status === "satisfied") {
|
|
1536
663
|
return { status: "claimed" };
|
|
1537
664
|
}
|
|
1538
665
|
return { status: "timeout", detail: "no install completed before the deadline" };
|