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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +139 -62
  2. package/assets/login/vnc-input.js +9 -0
  3. package/assets/login/vnc.html +214 -202
  4. package/assets/screencaps/connect-walkthrough.png +0 -0
  5. package/assets/screencaps/connect-walkthrough.svg +1 -1
  6. package/dist/api-client.d.ts +26 -3
  7. package/dist/api-client.d.ts.map +1 -1
  8. package/dist/api-client.js +19 -31
  9. package/dist/api-client.js.map +1 -1
  10. package/dist/bin.js +2 -3
  11. package/dist/bin.js.map +1 -1
  12. package/dist/bot/browser.d.ts +134 -77
  13. package/dist/bot/browser.d.ts.map +1 -1
  14. package/dist/bot/browser.js +1788 -1543
  15. package/dist/bot/browser.js.map +1 -1
  16. package/dist/bot/compact-observation-v2.d.ts +167 -0
  17. package/dist/bot/compact-observation-v2.d.ts.map +1 -0
  18. package/dist/bot/compact-observation-v2.js +1038 -0
  19. package/dist/bot/compact-observation-v2.js.map +1 -0
  20. package/dist/bot/credential-shape.d.ts +2 -0
  21. package/dist/bot/credential-shape.d.ts.map +1 -1
  22. package/dist/bot/credential-shape.js +21 -8
  23. package/dist/bot/credential-shape.js.map +1 -1
  24. package/dist/bot/element-fingerprint.d.ts +9 -0
  25. package/dist/bot/element-fingerprint.d.ts.map +1 -0
  26. package/dist/bot/element-fingerprint.js +147 -0
  27. package/dist/bot/element-fingerprint.js.map +1 -0
  28. package/dist/bot/email-verification.d.ts +7 -1
  29. package/dist/bot/email-verification.d.ts.map +1 -1
  30. package/dist/bot/email-verification.js +48 -14
  31. package/dist/bot/email-verification.js.map +1 -1
  32. package/dist/bot/google-login.d.ts +28 -52
  33. package/dist/bot/google-login.d.ts.map +1 -1
  34. package/dist/bot/google-login.js +246 -1119
  35. package/dist/bot/google-login.js.map +1 -1
  36. package/dist/bot/install-completion.d.ts +1 -2
  37. package/dist/bot/install-completion.d.ts.map +1 -1
  38. package/dist/bot/install-completion.js +1 -2
  39. package/dist/bot/install-completion.js.map +1 -1
  40. package/dist/bot/login-state.d.ts +13 -7
  41. package/dist/bot/login-state.d.ts.map +1 -1
  42. package/dist/bot/login-state.js +50 -114
  43. package/dist/bot/login-state.js.map +1 -1
  44. package/dist/bot/oauth-providers.d.ts +1 -1
  45. package/dist/bot/oauth-providers.d.ts.map +1 -1
  46. package/dist/bot/oauth-providers.js +3 -3
  47. package/dist/bot/oauth-providers.js.map +1 -1
  48. package/dist/bot/oauth-scope.d.ts +4 -0
  49. package/dist/bot/oauth-scope.d.ts.map +1 -0
  50. package/dist/bot/oauth-scope.js +58 -0
  51. package/dist/bot/oauth-scope.js.map +1 -0
  52. package/dist/bot/operator-browser-watchdog.d.ts +127 -0
  53. package/dist/bot/operator-browser-watchdog.d.ts.map +1 -0
  54. package/dist/bot/operator-browser-watchdog.js +438 -0
  55. package/dist/bot/operator-browser-watchdog.js.map +1 -0
  56. package/dist/bot/owner-process-reaper-worker.d.ts +2 -0
  57. package/dist/bot/owner-process-reaper-worker.d.ts.map +1 -0
  58. package/dist/bot/owner-process-reaper-worker.js +17 -0
  59. package/dist/bot/owner-process-reaper-worker.js.map +1 -0
  60. package/dist/bot/owner-process-reaper.d.ts +103 -0
  61. package/dist/bot/owner-process-reaper.d.ts.map +1 -0
  62. package/dist/bot/owner-process-reaper.js +1058 -0
  63. package/dist/bot/owner-process-reaper.js.map +1 -0
  64. package/dist/bot/pay-operator.d.ts +13 -2
  65. package/dist/bot/pay-operator.d.ts.map +1 -1
  66. package/dist/bot/pay-operator.js +319 -123
  67. package/dist/bot/pay-operator.js.map +1 -1
  68. package/dist/bot/profile.d.ts +10 -1
  69. package/dist/bot/profile.d.ts.map +1 -1
  70. package/dist/bot/profile.js +137 -11
  71. package/dist/bot/profile.js.map +1 -1
  72. package/dist/bot/promote-to-skill.js +1 -1
  73. package/dist/bot/promote-to-skill.js.map +1 -1
  74. package/dist/bot/provision-session.d.ts +60 -164
  75. package/dist/bot/provision-session.d.ts.map +1 -1
  76. package/dist/bot/provision-session.js +2049 -1546
  77. package/dist/bot/provision-session.js.map +1 -1
  78. package/dist/bot/remote-login-display.d.ts +61 -0
  79. package/dist/bot/remote-login-display.d.ts.map +1 -0
  80. package/dist/bot/remote-login-display.js +765 -0
  81. package/dist/bot/remote-login-display.js.map +1 -0
  82. package/dist/bot/session/hosts.d.ts +8 -0
  83. package/dist/bot/session/hosts.d.ts.map +1 -0
  84. package/dist/bot/session/hosts.js +36 -0
  85. package/dist/bot/session/hosts.js.map +1 -0
  86. package/dist/bot/session/lifecycle.d.ts +56 -0
  87. package/dist/bot/session/lifecycle.d.ts.map +1 -0
  88. package/dist/bot/session/lifecycle.js +962 -0
  89. package/dist/bot/session/lifecycle.js.map +1 -0
  90. package/dist/bot/session/model.d.ts +175 -0
  91. package/dist/bot/session/model.d.ts.map +1 -0
  92. package/dist/bot/session/model.js +77 -0
  93. package/dist/bot/session/model.js.map +1 -0
  94. package/dist/install/cli.d.ts +29 -9
  95. package/dist/install/cli.d.ts.map +1 -1
  96. package/dist/install/cli.js +243 -322
  97. package/dist/install/cli.js.map +1 -1
  98. package/dist/install/interactive.d.ts +1 -0
  99. package/dist/install/interactive.d.ts.map +1 -1
  100. package/dist/install/interactive.js +19 -5
  101. package/dist/install/interactive.js.map +1 -1
  102. package/dist/install/ui.d.ts.map +1 -1
  103. package/dist/install/ui.js +3 -15
  104. package/dist/install/ui.js.map +1 -1
  105. package/dist/operator-env.js +1 -1
  106. package/dist/operator-env.js.map +1 -1
  107. package/dist/server-instance-registry.d.ts +103 -0
  108. package/dist/server-instance-registry.d.ts.map +1 -0
  109. package/dist/server-instance-registry.js +392 -0
  110. package/dist/server-instance-registry.js.map +1 -0
  111. package/dist/server.d.ts +15 -1
  112. package/dist/server.d.ts.map +1 -1
  113. package/dist/server.js +164 -62
  114. package/dist/server.js.map +1 -1
  115. package/dist/session-guard.d.ts +28 -0
  116. package/dist/session-guard.d.ts.map +1 -0
  117. package/dist/session-guard.js +84 -0
  118. package/dist/session-guard.js.map +1 -0
  119. package/dist/session.d.ts +57 -13
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +246 -127
  122. package/dist/session.js.map +1 -1
  123. package/dist/tools/audit-log.d.ts +16 -2
  124. package/dist/tools/audit-log.d.ts.map +1 -1
  125. package/dist/tools/audit-log.js +223 -13
  126. package/dist/tools/audit-log.js.map +1 -1
  127. package/dist/tools/audit-rollup.d.ts +80 -0
  128. package/dist/tools/audit-rollup.d.ts.map +1 -0
  129. package/dist/tools/audit-rollup.js +0 -0
  130. package/dist/tools/audit-rollup.js.map +1 -0
  131. package/dist/tools/fetch-credential.d.ts +37 -0
  132. package/dist/tools/fetch-credential.d.ts.map +1 -0
  133. package/dist/tools/fetch-credential.js +153 -0
  134. package/dist/tools/fetch-credential.js.map +1 -0
  135. package/dist/tools/index.d.ts +3 -1
  136. package/dist/tools/index.d.ts.map +1 -1
  137. package/dist/tools/index.js +7 -3
  138. package/dist/tools/index.js.map +1 -1
  139. package/dist/tools/operate-pay.d.ts +4 -4
  140. package/dist/tools/operate-pay.d.ts.map +1 -1
  141. package/dist/tools/operate-pay.js +163 -96
  142. package/dist/tools/operate-pay.js.map +1 -1
  143. package/dist/tools/provision-drive.d.ts +43 -35
  144. package/dist/tools/provision-drive.d.ts.map +1 -1
  145. package/dist/tools/provision-drive.js +268 -164
  146. package/dist/tools/provision-drive.js.map +1 -1
  147. package/dist/tools/use-credential.d.ts.map +1 -1
  148. package/dist/tools/use-credential.js +3 -2
  149. package/dist/tools/use-credential.js.map +1 -1
  150. package/package.json +4 -6
  151. package/dist/bot/operator-direct-identity.d.ts +0 -26
  152. package/dist/bot/operator-direct-identity.d.ts.map +0 -1
  153. package/dist/bot/operator-direct-identity.js +0 -120
  154. package/dist/bot/operator-direct-identity.js.map +0 -1
  155. package/dist/bot/operator-profile-pool.d.ts +0 -97
  156. package/dist/bot/operator-profile-pool.d.ts.map +0 -1
  157. package/dist/bot/operator-profile-pool.js +0 -855
  158. package/dist/bot/operator-profile-pool.js.map +0 -1
  159. package/dist/bot/xvfb.d.ts +0 -13
  160. package/dist/bot/xvfb.d.ts.map +0 -1
  161. package/dist/bot/xvfb.js +0 -146
  162. package/dist/bot/xvfb.js.map +0 -1
@@ -1,45 +1,29 @@
1
1
  // google-login.ts — Phase 1, T2 (/plan-eng-review).
2
2
  //
3
- // Ensures the bot's persistent Chrome profile holds a valid Google
4
- // session. This is the one-time interactive login; every signup after
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
- // TWO PATHS, by environment — deliberately just two (decided after the
8
- // 2026-05-17 spike). Tailscale and Codespaces/Replit-native forwarding
9
- // were considered and dropped: they add environment-detection code for
10
- // a minority of users, and the cloudflared tunnel covers everyone.
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
- // 1. A display is available → launch Chrome headed. A window opens,
13
- // the user logs in. No virtual display, no noVNC, no tunnel.
14
- //
15
- // 2. Headless (no DISPLAY) → run Chrome on a phone-shaped virtual
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 { shortenVncUrl } from "../api-client.js";
36
- import { CHROME_PROFILE_DIR, closeProfileWithProof, currentProfileHolderPid, launchWithProfileGate, profileProcessIdentity, PROFILE_BUSY_MESSAGE, ProfileBusyError, reapProfileHolderIfOwned, waitForProfileFree, withProfileOperationGuard, } from "./profile.js";
37
- import { isSelfManagedChromeTerminationSignalExitEnabled, launchPlainLoginBrowser, launchSelfManagedLoginContext, resolveChannelBinary, selfLaunchEnabled, setSelfManagedChromeTerminationSignalExitEnabled, } from "./browser.js";
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 { markProviderLoggedIn } from "./login-state.js";
41
- import { randomBytes } from "node:crypto";
42
- import { canPublishOperatorProfileSeed, GOOGLE_LOGIN_COOKIE_MARKERS, publishOperatorProfileSeed, } from "./operator-profile-pool.js";
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
- return await launcher.launchPersistentContext(userDataDir, {
52
- ...options,
53
- channel: "chrome",
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: GOOGLE_LOGIN_COOKIE_MARKERS,
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
- const cookies = await context.cookies(target.cookieOrigin);
106
- return cookies.some((c) => target.cookies.includes(c.name));
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
- return false;
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 the bot Chrome profile on disk and return the set of OAuth
184
- // providers whose session cookies are currently present. The marker
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 seeds whatever provider the user clicked, before the
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 ctx = await launchWithProfileGate(profileDir, () => launchPersistentLoginContext(chromium, profileDir, {
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 stays presence-based (it doesn't lie this way).
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 ctx.close();
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
- if (process.env.TRUSTY_SQUIRE_FORCE_HEADLESS === "true")
386
- return false;
387
- if (process.env.TRUSTY_SQUIRE_FORCE_DISPLAY === "true")
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
- // macOS (Aqua) and Windows (Win32) have native windowing — Chrome
390
- // opens a real window without an X server. DISPLAY is a Unix concept
391
- // they don't set, so a DISPLAY-only check would have wrongly routed
392
- // every Mac/Windows install into the headless+noVNC+cloudflared rig
393
- // (which needs Xvfb/x11vnc/etc. that aren't installed on those
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 typeof process.env.DISPLAY === "string" && process.env.DISPLAY.length > 0;
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 waitForChildExit(child, timeoutMs) {
564
- if (childHasExited(child))
565
- return Promise.resolve(true);
566
- return new Promise((resolve) => {
567
- const onExit = () => finish(true);
568
- const timer = setTimeout(() => finish(childHasExited(child)), timeoutMs);
569
- const finish = (exited) => {
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
- child.removeListener("exit", onExit);
572
- resolve(exited);
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
- child.once("exit", onExit);
575
- });
576
- }
577
- function removeHeadlessRigFiles(rig) {
578
- if (rig.webDir !== undefined) {
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
- function releaseChildHandles(child) {
596
- try {
597
- child.stdout?.destroy();
598
- }
599
- catch {
600
- // best-effort
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
- removeHeadlessRigFiles(rig);
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
- // Exported for tests; runDisplayedChrome/runHeadlessChrome are the real
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 (and headless rig, where one
686
- // exists). Called by the MCP server's shutdown path; idempotent and
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 login` runs in a SEPARATE process from the MCP server, so the
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. Waiting here used to leave a
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
- if (hasDisplay()) {
925
- return await runDisplayedChrome(opts);
926
- }
927
- return await runHeadlessChrome(opts);
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
- // PLAIN-BROWSER path (connect claim): launch plain Chrome, never attach CDP,
931
- // detect completion off the API + SQLite cookie store. See
932
- // launchPlainLoginBrowser / RunInBotChromeOpts.plainProfileLogin.
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
- opts.onProxyDisposition?.(proxyOpt ?? null);
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 context = await launchWithProfileGate(opts.profileDir, () => launchPersistentLoginContext(chromium, opts.profileDir, {
995
- headless: false,
996
- viewport: { width: 1280, height: 800 },
997
- // Drop Playwright's default --enable-automation switch: it paints the
998
- // "Chrome is being controlled by automated test software" infobar AND
999
- // is itself an automation fingerprint the provider can read during the
1000
- // sign-in (so removing it also helps the session survive).
1001
- ignoreDefaultArgs: ["--enable-automation"],
1002
- args: [
1003
- "--disable-blink-features=AutomationControlled",
1004
- "--no-sandbox",
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: () => context.close(),
1015
- forceClose: () => reapProfileHolderIfOwned(opts.profileDir, identity),
496
+ identity: browser.identity,
497
+ closeBrowser: browser.teardown,
498
+ forceClose: browser.forceTeardown,
499
+ isRunning: browser.isRunning,
1016
500
  }));
1017
- const preflightSatisfied = opts.preflight !== undefined &&
1018
- (await checkLoginStatusWithin(opts.deadline, () => opts.preflight(context)));
1019
- if (preflightSatisfied) {
1020
- status = "preflight_satisfied";
1021
- }
1022
- else {
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 runHeadlessChrome(opts) {
1044
- requireBinaries(["Xvfb", "x11vnc", "websockify", "cloudflared"]);
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
- let plainBrowserIsRunning;
1078
- // Cover normal cleanup, interrupts, and fatal process errors. The returned
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
- // 1. Virtual display — phone-shaped.
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
- // PLAIN-BROWSER mode (connect claim): NO CDP at all — a CDP-attached Chrome
1115
- // fails Google's OAuth "secure browser" check (STATE.md 2026-07-20, fully
1116
- // bisected). `context` stays undefined; completion is read via
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
- // CDP path only: preflight + drive the first page to the URL. The plain
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
- // 4. noVNC web bridge — serves our branded vnc.html plus the
1236
- // installed noVNC core from a temp dir; localhost only,
1237
- // cloudflared reaches it.
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 { status: ok ? "completed" : "timeout", closeState };
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 noVNC banner
1351
- // (or the Chrome window) opens, this loop is otherwise silent for up to the full
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
- // PLAIN browser — no CDP. Google's OAuth flow (which this confirm page
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 === "completed") {
662
+ if (result.status === "satisfied") {
1536
663
  return { status: "claimed" };
1537
664
  }
1538
665
  return { status: "timeout", detail: "no install completed before the deadline" };