brightspace-mcp-server 1.6.3 → 3.0.0

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 (127) hide show
  1. package/README.md +43 -35
  2. package/build/api/client.d.ts +37 -10
  3. package/build/api/client.d.ts.map +1 -1
  4. package/build/api/client.js +158 -100
  5. package/build/api/client.js.map +1 -1
  6. package/build/api/errors.d.ts +4 -0
  7. package/build/api/errors.d.ts.map +1 -1
  8. package/build/api/errors.js +7 -0
  9. package/build/api/errors.js.map +1 -1
  10. package/build/auth/auth-cooldown.d.ts +24 -0
  11. package/build/auth/auth-cooldown.d.ts.map +1 -0
  12. package/build/auth/auth-cooldown.js +60 -0
  13. package/build/auth/auth-cooldown.js.map +1 -0
  14. package/build/auth/auth-lock.d.ts +7 -0
  15. package/build/auth/auth-lock.d.ts.map +1 -0
  16. package/build/auth/auth-lock.js +162 -0
  17. package/build/auth/auth-lock.js.map +1 -0
  18. package/build/auth/auth-runner.d.ts +30 -9
  19. package/build/auth/auth-runner.d.ts.map +1 -1
  20. package/build/auth/auth-runner.js +171 -56
  21. package/build/auth/auth-runner.js.map +1 -1
  22. package/build/auth/browser-auth.d.ts +19 -87
  23. package/build/auth/browser-auth.d.ts.map +1 -1
  24. package/build/auth/browser-auth.js +266 -614
  25. package/build/auth/browser-auth.js.map +1 -1
  26. package/build/auth/browser-state-store.d.ts +19 -0
  27. package/build/auth/browser-state-store.d.ts.map +1 -0
  28. package/build/auth/browser-state-store.js +101 -0
  29. package/build/auth/browser-state-store.js.map +1 -0
  30. package/build/auth/credential-store.d.ts +29 -0
  31. package/build/auth/credential-store.d.ts.map +1 -0
  32. package/build/auth/credential-store.js +233 -0
  33. package/build/auth/credential-store.js.map +1 -0
  34. package/build/auth/encrypted-store.d.ts +25 -0
  35. package/build/auth/encrypted-store.d.ts.map +1 -0
  36. package/build/auth/encrypted-store.js +61 -0
  37. package/build/auth/encrypted-store.js.map +1 -0
  38. package/build/auth/legacy-profile.d.ts +3 -0
  39. package/build/auth/legacy-profile.d.ts.map +1 -0
  40. package/build/auth/legacy-profile.js +41 -0
  41. package/build/auth/legacy-profile.js.map +1 -0
  42. package/build/auth/legacy-state.d.ts +14 -0
  43. package/build/auth/legacy-state.d.ts.map +1 -0
  44. package/build/auth/legacy-state.js +107 -0
  45. package/build/auth/legacy-state.js.map +1 -0
  46. package/build/auth/purdue-sso.d.ts +25 -36
  47. package/build/auth/purdue-sso.d.ts.map +1 -1
  48. package/build/auth/purdue-sso.js +210 -191
  49. package/build/auth/purdue-sso.js.map +1 -1
  50. package/build/auth/session-store.d.ts +16 -38
  51. package/build/auth/session-store.d.ts.map +1 -1
  52. package/build/auth/session-store.js +157 -152
  53. package/build/auth/session-store.js.map +1 -1
  54. package/build/auth/sso-flow.d.ts +16 -4
  55. package/build/auth/sso-flow.d.ts.map +1 -1
  56. package/build/auth/sso-flow.js +24 -2
  57. package/build/auth/sso-flow.js.map +1 -1
  58. package/build/auth/suny-sso.d.ts +6 -2
  59. package/build/auth/suny-sso.d.ts.map +1 -1
  60. package/build/auth/suny-sso.js +17 -24
  61. package/build/auth/suny-sso.js.map +1 -1
  62. package/build/auth/token-manager.d.ts +10 -3
  63. package/build/auth/token-manager.d.ts.map +1 -1
  64. package/build/auth/token-manager.js +62 -26
  65. package/build/auth/token-manager.js.map +1 -1
  66. package/build/auth/token-mint.d.ts +2 -2
  67. package/build/auth/token-mint.d.ts.map +1 -1
  68. package/build/auth/token-mint.js +31 -9
  69. package/build/auth/token-mint.js.map +1 -1
  70. package/build/auth-cli.d.ts +1 -7
  71. package/build/auth-cli.d.ts.map +1 -1
  72. package/build/auth-cli.js +71 -70
  73. package/build/auth-cli.js.map +1 -1
  74. package/build/index.js +20 -64
  75. package/build/index.js.map +1 -1
  76. package/build/setup.js +44 -19
  77. package/build/setup.js.map +1 -1
  78. package/build/tools/tool-helpers.d.ts +7 -1
  79. package/build/tools/tool-helpers.d.ts.map +1 -1
  80. package/build/tools/tool-helpers.js +96 -11
  81. package/build/tools/tool-helpers.js.map +1 -1
  82. package/build/types/index.d.ts +6 -0
  83. package/build/types/index.d.ts.map +1 -1
  84. package/build/utils/browser-install.d.ts +23 -0
  85. package/build/utils/browser-install.d.ts.map +1 -0
  86. package/build/utils/browser-install.js +33 -0
  87. package/build/utils/browser-install.js.map +1 -0
  88. package/build/utils/commands.d.ts +26 -0
  89. package/build/utils/commands.d.ts.map +1 -0
  90. package/build/utils/commands.js +26 -0
  91. package/build/utils/commands.js.map +1 -0
  92. package/build/utils/config-store.d.ts +1 -0
  93. package/build/utils/config-store.d.ts.map +1 -1
  94. package/build/utils/config-store.js +3 -0
  95. package/build/utils/config-store.js.map +1 -1
  96. package/build/utils/config.d.ts +2 -1
  97. package/build/utils/config.d.ts.map +1 -1
  98. package/build/utils/config.js +32 -10
  99. package/build/utils/config.js.map +1 -1
  100. package/build/utils/download-errors.d.ts +45 -0
  101. package/build/utils/download-errors.d.ts.map +1 -0
  102. package/build/utils/download-errors.js +45 -0
  103. package/build/utils/download-errors.js.map +1 -0
  104. package/build/utils/download-helpers.d.ts.map +1 -1
  105. package/build/utils/download-helpers.js +2 -1
  106. package/build/utils/download-helpers.js.map +1 -1
  107. package/build/utils/file-validator.d.ts +1 -1
  108. package/build/utils/file-validator.d.ts.map +1 -1
  109. package/build/utils/file-validator.js +47 -7
  110. package/build/utils/file-validator.js.map +1 -1
  111. package/build/utils/install-sites.d.ts +61 -0
  112. package/build/utils/install-sites.d.ts.map +1 -0
  113. package/build/utils/install-sites.js +207 -0
  114. package/build/utils/install-sites.js.map +1 -0
  115. package/build/utils/secure-config.d.ts +6 -0
  116. package/build/utils/secure-config.d.ts.map +1 -0
  117. package/build/utils/secure-config.js +57 -0
  118. package/build/utils/secure-config.js.map +1 -0
  119. package/build/utils/self-update.d.ts +61 -0
  120. package/build/utils/self-update.d.ts.map +1 -0
  121. package/build/utils/self-update.js +92 -0
  122. package/build/utils/self-update.js.map +1 -0
  123. package/build/utils/update-checker.d.ts +85 -7
  124. package/build/utils/update-checker.d.ts.map +1 -1
  125. package/build/utils/update-checker.js +141 -36
  126. package/build/utils/update-checker.js.map +1 -1
  127. package/package.json +4 -2
@@ -1,350 +1,162 @@
1
1
  /**
2
- * Purdue Brightspace MCP Server
2
+ * Brightspace MCP Server
3
3
  * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
- * Licensed under MIT — see LICENSE file for details.
4
+ * Licensed under MIT: see LICENSE file for details.
5
5
  */
6
6
  import * as path from "node:path";
7
- import * as fs from "node:fs/promises";
8
- import * as os from "node:os";
7
+ import { readFileSync, accessSync } from "node:fs";
9
8
  import { BrowserAuthError } from "../utils/errors.js";
10
9
  import { log } from "../utils/logger.js";
11
- import { createSSOFlow } from "./sso-flow.js";
12
- /**
13
- * The silent-SSO budget. When the D2L session is dead but the Microsoft Entra
14
- * cookie in the persistent profile is still alive, the SSO chain completes on
15
- * its own within a few seconds, provided the two no-secret surfaces in its way
16
- * get clicked. A URL check cannot see any of that, so the wait is a poll on a
17
- * positive session check instead.
18
- */
10
+ import { createSSOFlow, UnsupportedAuthenticationError, MfaApprovalError } from "./sso-flow.js";
11
+ import { BrowserStateStore } from "./browser-state-store.js";
12
+ import { acquireProcessLock } from "./auth-lock.js";
13
+ import { AuthCooldown } from "./auth-cooldown.js";
14
+ import { mintAccessToken } from "./token-mint.js";
15
+ import { isMissingBrowserError, PLAYWRIGHT_INSTALL_HINT } from "../utils/browser-install.js";
19
16
  const SILENT_SSO_TIMEOUT_MS = 30000;
20
17
  const SILENT_SSO_POLL_MS = 1000;
18
+ const INITIAL_NAVIGATION_TIMEOUT_MS = 60000;
21
19
  const SILENT_SSO = {
22
- /** Entra asking who you are. No amount of silent waiting passes it. */
23
- emailField: "input[type=email], input[name=loginfmt]",
24
- /** The Brightspace campus selector's own link to the SAML endpoint. */
20
+ emailFields: ["input[type=email]", "input[name=loginfmt]"],
21
+ credentialFields: ['input#username', 'input#userName', 'input[type="password"]'],
22
+ mfaChallenges: ["#idRichContext_DisplaySign", "#idDiv_SAOTCAS_Title", "#idDiv_SAOTCC_Title"],
25
23
  campusSaml: 'a[href*="/d2l/lp/auth/saml/initiate-login"]',
26
- /** Markers unique to the "Stay signed in?" page. */
27
24
  kmsiCheckbox: "#KmsiCheckboxField",
28
25
  kmsiTitle: "Stay signed in?",
29
26
  kmsiSubmit: "#idSIButton9",
30
27
  };
31
- /**
32
- * Thrown when a headless run reaches the point where a human has to see the
33
- * screen. Not a failure: the caller relaunches the browser visible and retries.
34
- */
35
- export class HeadlessLoginRequiredError extends Error {
36
- constructor() {
37
- super("A sign-in is needed and the browser must be visible for it");
38
- this.name = "HeadlessLoginRequiredError";
28
+ export class BrowserAuthTransportError extends Error {
29
+ code = "AUTH_TRANSPORT";
30
+ constructor(message, options) {
31
+ super(message, options);
32
+ this.name = "BrowserAuthTransportError";
39
33
  }
40
34
  }
41
- /** Whether the current attempt has to start over with a window on screen. */
42
- export function needsVisibleBrowser({ launchedHeadless }) {
43
- return launchedHeadless;
44
- }
45
35
  export class BrowserAuth {
46
36
  config;
47
37
  ssoFlow;
48
- /** Whether the browser for the current attempt was launched without a window. */
49
- launchedHeadless = false;
50
- constructor(config) {
38
+ stateStore;
39
+ cooldown;
40
+ constructor(config, requestMfaCode) {
51
41
  this.config = config;
52
- this.ssoFlow = createSSOFlow(config);
42
+ this.ssoFlow = createSSOFlow(config, requestMfaCode);
43
+ this.stateStore = new BrowserStateStore(config.sessionDir);
44
+ this.cooldown = new AuthCooldown(config.sessionDir);
53
45
  }
54
- /**
55
- * Detect if running inside WSL (Windows Subsystem for Linux) or Docker.
56
- * These environments require --no-sandbox for Chromium to launch.
57
- */
58
46
  static isWSLOrDocker() {
59
47
  try {
60
- // WSL: /proc/version contains "microsoft" or "WSL"
61
- const procVersion = require("node:fs").readFileSync("/proc/version", "utf-8");
62
- if (/microsoft|wsl/i.test(procVersion))
48
+ if (/microsoft|wsl/i.test(readFileSync("/proc/version", "utf8")))
63
49
  return true;
64
50
  }
65
- catch {
66
- // Not Linux or /proc not available
67
- }
51
+ catch { /* Not WSL. */ }
68
52
  try {
69
- // Docker: /.dockerenv exists or /proc/1/cgroup contains "docker"
70
- require("node:fs").accessSync("/.dockerenv");
53
+ accessSync("/.dockerenv");
71
54
  return true;
72
55
  }
73
- catch {
74
- // Not Docker
75
- }
56
+ catch { /* Not Docker. */ }
76
57
  try {
77
- const cgroup = require("node:fs").readFileSync("/proc/1/cgroup", "utf-8");
78
- if (cgroup.includes("docker") || cgroup.includes("containerd"))
79
- return true;
58
+ return /docker|containerd/.test(readFileSync("/proc/1/cgroup", "utf8"));
80
59
  }
81
60
  catch {
82
- // Not in a container
61
+ return false;
83
62
  }
84
- return false;
85
- }
86
- /**
87
- * Build Chromium launch args based on the current platform and environment.
88
- */
89
- static buildChromiumArgs() {
90
- const args = ["--disable-blink-features=AutomationControlled"];
91
- if (process.platform === "win32") {
92
- args.push("--disable-gpu");
93
- }
94
- // On macOS, NSPersistentUIRestorer is disabled via `defaults write` in
95
- // applyMacOSCrashGuard() — see issue #10. Passing "-ApplePersistenceIgnoreState YES"
96
- // as argv doesn't work here because Playwright's launchPersistentContext rejects
97
- // non-flag positional arguments.
98
- if (BrowserAuth.isWSLOrDocker()) {
99
- args.push("--no-sandbox", "--disable-setuid-sandbox");
100
- log("INFO", "Detected WSL/Docker environment — launching Chromium with --no-sandbox");
101
- }
102
- return args;
103
63
  }
104
- /**
105
- * Prevent Chrome for Testing from SIGTRAP'ing on launch on macOS (issue #10).
106
- *
107
- * Three layers, all idempotent and cheap:
108
- * 1. ApplePersistenceIgnoreState — disables NSPersistentUIRestorer's crash-prompt
109
- * modal, which Chrome for Testing's AppKit bridge cannot handle.
110
- * 2. IIO_LaunchInfo=0 — resets LaunchServices' per-app crash counter so macOS
111
- * stops triggering the recovery pathway in the first place.
112
- * 3. Nuke the Cocoa saved-application-state bundle — if it exists, AppKit tries
113
- * to replay window state during launch and can crash the browser process.
114
- *
115
- * Runs before every launch. None of these are destructive to user data; Chrome
116
- * for Testing is a disposable test profile.
117
- */
118
- static async applyMacOSCrashGuard() {
119
- if (process.platform !== "darwin")
120
- return;
121
- const { execFile } = await import("node:child_process");
122
- const { promisify } = await import("node:util");
123
- const execFileAsync = promisify(execFile);
124
- try {
125
- await execFileAsync("defaults", [
126
- "write",
127
- "com.google.chrome.for.testing",
128
- "ApplePersistenceIgnoreState",
129
- "-bool",
130
- "yes",
131
- ]);
132
- }
133
- catch {
134
- // Non-fatal
135
- }
136
- try {
137
- await execFileAsync("defaults", [
138
- "write",
139
- "com.google.chrome.for.testing",
140
- "IIO_LaunchInfo",
141
- "-int",
142
- "0",
143
- ]);
144
- }
145
- catch {
146
- // Non-fatal
147
- }
64
+ async authenticate(options = {}) {
65
+ const release = await acquireProcessLock(path.join(this.config.sessionDir, ".auth.lock"));
148
66
  try {
149
- const savedState = path.join(os.homedir(), "Library", "Saved Application State", "com.google.chrome.for.testing.savedState");
150
- await fs.rm(savedState, { recursive: true, force: true });
67
+ // Even a cookie-only SAML redirect can issue an MFA push. Suppress all
68
+ // automatic browser attempts during cooldown; HTTP token refresh runs
69
+ // independently before this entrypoint and remains available.
70
+ if (options.automatic)
71
+ await this.cooldown.assertAllowed();
72
+ const token = await this.attemptAuthentication();
73
+ await options.onAuthenticated?.(token);
74
+ await this.cooldown.clear();
75
+ return token;
151
76
  }
152
- catch {
153
- // Non-fatal
77
+ finally {
78
+ await release();
154
79
  }
155
- log("DEBUG", "Applied macOS crash guards for Chrome for Testing");
156
80
  }
157
- /**
158
- * Recovery step: the current browser-data profile is corrupted in a way that
159
- * makes Chromium SIGTRAP during launch (issue #10). Move it aside so the next
160
- * launch attempt gets a clean profile. Cheap — user just re-authenticates.
161
- */
162
- async quarantineBrowserDataDir(browserDataDir) {
81
+ async attemptAuthentication() {
82
+ let browser;
83
+ let context;
84
+ let page;
85
+ let listener;
86
+ let interrupted = false;
87
+ const closeOnSignal = () => {
88
+ interrupted = true;
89
+ void browser?.close().catch(() => { });
90
+ };
163
91
  try {
164
- const stamp = Date.now();
165
- const quarantined = `${browserDataDir}.corrupted.${stamp}`;
166
- await fs.rename(browserDataDir, quarantined);
167
- log("WARN", `Quarantined corrupted browser profile to ${quarantined} — starting fresh`);
168
- }
169
- catch (error) {
170
- // If rename fails (permissions, missing dir), try a recursive delete instead.
92
+ const state = await this.stateStore.load();
93
+ const { chromium } = await import("playwright");
94
+ const args = ["--disable-blink-features=AutomationControlled"];
95
+ if (BrowserAuth.isWSLOrDocker())
96
+ args.push("--no-sandbox", "--disable-setuid-sandbox");
97
+ // Use Playwright's own timeout, which cleans up an unsuccessful launch.
171
98
  try {
172
- await fs.rm(browserDataDir, { recursive: true, force: true });
173
- log("WARN", "Deleted corrupted browser profile — starting fresh");
174
- }
175
- catch (rmError) {
176
- log("WARN", "Failed to quarantine browser profile", rmError);
177
- }
178
- }
179
- }
180
- /**
181
- * Authenticate, opening a window if the flow turns out to need one.
182
- *
183
- * Headless is only ever safe for the silent path. The moment a real sign-in
184
- * is required the run starts over with a visible browser, because the MFA
185
- * number match has to be readable by a human.
186
- */
187
- async authenticate() {
188
- try {
189
- return await this.attemptAuthentication(this.config.headless);
190
- }
191
- catch (error) {
192
- if (error instanceof HeadlessLoginRequiredError) {
193
- log("INFO", "A sign-in is needed, so the browser is reopening with a window");
194
- return await this.attemptAuthentication(false);
195
- }
196
- throw error;
197
- }
198
- }
199
- async attemptAuthentication(preferHeadless) {
200
- let context = null;
201
- try {
202
- log("INFO", "Starting browser authentication");
203
- await BrowserAuth.applyMacOSCrashGuard();
204
- const mkdirOpts = { recursive: true };
205
- if (process.platform !== "win32") {
206
- mkdirOpts.mode = 0o700;
207
- }
208
- await fs.mkdir(this.config.sessionDir, mkdirOpts);
209
- const browserDataDir = path.join(this.config.sessionDir, "browser-data");
210
- // Remove stale Chromium lock files that can block persistent context launch.
211
- // On Windows, if the browser is killed by antivirus or force-closed, these
212
- // lock files persist and prevent all future auth attempts.
213
- await this.validateAndClearLockFiles(browserDataDir);
214
- // Force headed mode when no credentials — user must interact with the browser
215
- const headless = this.ssoFlow.hasCredentials() ? preferHeadless : false;
216
- if (!this.ssoFlow.hasCredentials() && preferHeadless) {
217
- log("INFO", "Overriding headless mode — browser must be visible for manual login");
99
+ browser = await chromium.launch({ headless: this.config.headless, timeout: 60000, args });
218
100
  }
219
- this.launchedHeadless = headless;
220
- const launchOptions = {
221
- headless,
222
- viewport: { width: 1280, height: 720 },
223
- args: BrowserAuth.buildChromiumArgs(),
224
- timeout: 60000,
225
- };
226
- context = await this.launchBrowserWithRetry(browserDataDir, launchOptions);
227
- log("INFO", "Browser context launched");
228
- // If the user Ctrl+C's while Chrome is running, Node tears down the
229
- // subprocess with SIGKILL — Chrome writes "Crashed" to exit_type, the
230
- // LaunchServices crash counter ticks up, and the next launch can SIGTRAP.
231
- // Hook SIGINT/SIGTERM so we close the context gracefully first. See issue #10.
232
- const contextRef = context;
233
- const cleanShutdown = async (signal) => {
234
- log("WARN", `Received ${signal} — closing browser cleanly`);
235
- try {
236
- await contextRef.close();
237
- }
238
- catch {
239
- // Already closing
101
+ catch (launchError) {
102
+ // Keep the remedy attached to the failure. Without this the hint is
103
+ // lost when the auth runner flattens errors into "Authentication failed".
104
+ if (isMissingBrowserError(launchError)) {
105
+ throw new BrowserAuthError(PLAYWRIGHT_INSTALL_HINT, "browser_missing", launchError);
240
106
  }
241
- process.exit(130);
107
+ throw launchError;
108
+ }
109
+ process.once("SIGINT", closeOnSignal);
110
+ process.once("SIGTERM", closeOnSignal);
111
+ context = await browser.newContext({ viewport: { width: 1280, height: 720 }, storageState: state });
112
+ page = await context.newPage();
113
+ let captured;
114
+ listener = (request) => {
115
+ const url = new URL(request.url());
116
+ if (url.origin !== new URL(this.config.baseUrl).origin || !url.pathname.startsWith("/d2l/"))
117
+ return;
118
+ const header = request.headers().authorization;
119
+ if (header?.startsWith("Bearer "))
120
+ captured = header.slice(7);
242
121
  };
243
- process.once("SIGINT", cleanShutdown);
244
- process.once("SIGTERM", cleanShutdown);
245
- // Load saved storage state if it exists (cookies + localStorage)
246
- // This works around Playwright bug #36139 where session cookies don't persist
247
- await this.loadStorageState(context);
248
- const page = context.pages()[0] || (await context.newPage());
249
- // CRITICAL: Set up token interception BEFORE navigation
250
- // Use longer timeout for manual login (5 min) vs automated SSO (2 min)
251
- const interceptTimeout = this.ssoFlow.hasCredentials() ? 120000 : 300000;
252
- const tokenPromise = this.setupTokenInterception(page, interceptTimeout);
253
- // Navigate and login if needed
254
- const alreadyAuthenticated = await this.navigateAndLogin(page);
255
- // Run the extraction strategy chain on BOTH paths. Modern Brightspace's
256
- // /d2l/home uses cookie auth and emits no Bearer header, so the passive
257
- // interceptor never fires on a fresh manual login — the chain rescues
258
- // us via localStorage instead. See issue #10.
259
- log("INFO", alreadyAuthenticated
260
- ? "Session cookies active — trying to extract API token"
261
- : "Login complete — extracting API token from session");
262
- const extracted = await this.tryExtractToken(page, context);
263
- if (extracted) {
264
- const material = await this.harvestSessionMaterial(page, context);
265
- await this.saveStorageState(context);
266
- log("INFO", "Authentication complete");
267
- return { ...extracted, ...material };
268
- }
269
- // Last resort for the cookie-restore path: clear cookies, force full
270
- // re-login through SSO, and race the passive listener as a final fallback.
271
- if (alreadyAuthenticated) {
272
- log("WARN", "Could not extract valid token from existing session, forcing re-login");
273
- await context.clearCookies();
274
- await page.close();
275
- const freshPage = await context.newPage();
276
- const freshTokenPromise = this.setupTokenInterception(freshPage);
277
- await this.navigateAndLogin(freshPage);
278
- const freshExtracted = await this.tryExtractToken(freshPage, context);
279
- if (freshExtracted) {
280
- const material = await this.harvestSessionMaterial(freshPage, context);
281
- await this.saveStorageState(context);
282
- log("INFO", "Authentication complete");
283
- return { ...freshExtracted, ...material };
122
+ page.on("request", listener);
123
+ await this.navigateAndLogin(page);
124
+ // Persist the verified browser state before token acquisition. Token
125
+ // minting can fail independently, and a temporary outage must not throw
126
+ // away newly renewed Entra or Brightspace cookies.
127
+ await this.stateStore.save(await context.storageState());
128
+ const material = await this.harvestSessionMaterial(page, context);
129
+ let token = null;
130
+ if (material.cookieHeader && material.csrfToken) {
131
+ const minted = await mintAccessToken({ baseUrl: this.config.baseUrl, cookieHeader: material.cookieHeader, csrfToken: material.csrfToken });
132
+ if (minted.ok) {
133
+ token = { accessToken: minted.accessToken, capturedAt: Date.now(), expiresAt: Date.now() + this.config.tokenTtl * 1000, source: "browser" };
284
134
  }
285
- const accessToken = await freshTokenPromise;
286
- log("INFO", "Bearer token captured after forced re-login");
287
- const now = Date.now();
288
- const tokenData = {
289
- accessToken,
290
- capturedAt: now,
291
- expiresAt: now + this.config.tokenTtl * 1000,
292
- source: "browser",
293
- ...(await this.harvestSessionMaterial(freshPage, context)),
294
- };
295
- await this.saveStorageState(context);
296
- return tokenData;
297
- }
298
- // Fresh-login final fallback: wait on the passive listener.
299
- // Rarely reached in practice — tryExtractToken typically hits localStorage first.
300
- log("INFO", "Waiting for Bearer token from network interception");
301
- const accessToken = await tokenPromise;
302
- log("INFO", "Bearer token captured successfully");
303
- const now = Date.now();
304
- const tokenData = {
305
- accessToken,
306
- capturedAt: now,
307
- expiresAt: now + this.config.tokenTtl * 1000,
308
- source: "browser",
309
- ...(await this.harvestSessionMaterial(page, context)),
310
- };
311
- await this.saveStorageState(context);
312
- log("INFO", "Authentication complete");
313
- return tokenData;
314
- }
315
- catch (error) {
316
- const errMsg = error instanceof Error ? error.message : String(error);
317
- log("ERROR", "Browser authentication failed", error);
318
- // Provide platform-specific troubleshooting hints
319
- let hint = "";
320
- if (process.platform === "win32") {
321
- if (errMsg.includes("Target page, context or browser has been closed")) {
322
- hint = " (Windows hint: antivirus or firewall may be closing the browser. Try adding Chromium to your exclusion list.)";
135
+ else if (minted.reason === "transport") {
136
+ throw new BrowserAuthTransportError(`Token mint temporarily failed: ${minted.detail ?? "network unavailable"}. Saved sign-in state is preserved.`);
323
137
  }
324
- else if (errMsg.includes("EPERM") || errMsg.includes("EACCES")) {
325
- hint = " (Windows hint: try running as Administrator, or check that no other process has locked the session directory.)";
326
- }
327
- else if (errMsg.includes("Timeout") || errMsg.includes("timeout")) {
328
- hint = " (Windows hint: browser launch timed out. Close all Chromium/Chrome instances in Task Manager and try again. Antivirus may also be blocking the launch.)";
138
+ else {
139
+ throw new BrowserAuthError("Brightspace session expired while acquiring an API token. Retry authentication.", "token_extraction");
329
140
  }
330
141
  }
331
- if (BrowserAuth.isWSLOrDocker() && (errMsg.includes("spawn") || errMsg.includes("ENOENT") || errMsg.includes("sandbox"))) {
332
- hint = " (WSL/Docker hint: ensure Chromium dependencies are installed. Run: npx playwright install-deps chromium)";
142
+ token ??= await this.tryExtractToken(page, context);
143
+ if (!token && captured && await this.validateToken(captured)) {
144
+ token = { accessToken: captured, capturedAt: Date.now(), expiresAt: Date.now() + this.config.tokenTtl * 1000, source: "browser" };
333
145
  }
334
- throw new BrowserAuthError(`Authentication failed${hint}`, "authenticate", error);
146
+ if (!token)
147
+ throw new BrowserAuthError("Brightspace did not provide a usable API token. Saved SSO cookies have been preserved.", "token_extraction");
148
+ if (interrupted)
149
+ throw new BrowserAuthError("Authentication interrupted", "interrupted");
150
+ log("INFO", "Browser authentication complete");
151
+ return { ...token, ...material, tenantOrigin: new URL(this.config.baseUrl).origin };
335
152
  }
336
153
  finally {
337
- if (context) {
338
- log("DEBUG", "Closing browser context");
339
- try {
340
- await context.close();
341
- }
342
- catch (closeError) {
343
- // Context may already be closed (e.g. browser crashed or was closed externally).
344
- // This is common on Windows where the browser process can terminate unexpectedly.
345
- log("DEBUG", "Browser context already closed or failed to close", closeError);
346
- }
347
- }
154
+ process.removeListener("SIGINT", closeOnSignal);
155
+ process.removeListener("SIGTERM", closeOnSignal);
156
+ if (page && listener)
157
+ page.removeListener("request", listener);
158
+ await context?.close().catch(() => { });
159
+ await browser?.close().catch(() => { });
348
160
  }
349
161
  }
350
162
  /**
@@ -362,7 +174,7 @@ export class BrowserAuth {
362
174
  source: "browser",
363
175
  };
364
176
  };
365
- // Strategy 0: localStorage (D2L.Fetch.Tokens) — fastest
177
+ // Strategy 0: localStorage (D2L.Fetch.Tokens) : fastest
366
178
  const lsToken = await this.extractLocalStorageToken(page);
367
179
  if (lsToken && (await this.validateToken(lsToken))) {
368
180
  log("INFO", "Extracted valid Bearer token from localStorage");
@@ -380,7 +192,9 @@ export class BrowserAuth {
380
192
  return build(lsToken2);
381
193
  }
382
194
  }
383
- catch {
195
+ catch (error) {
196
+ if (error instanceof BrowserAuthTransportError)
197
+ throw error;
384
198
  log("DEBUG", "Direct API navigation did not produce Bearer token");
385
199
  }
386
200
  // Strategy 2: XSRF / page JS context
@@ -406,115 +220,84 @@ export class BrowserAuth {
406
220
  * Returns true if the token is accepted by D2L, false otherwise.
407
221
  */
408
222
  async validateToken(token) {
223
+ let response;
409
224
  try {
410
- const headers = {
411
- "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
412
- };
413
- if (token.startsWith("cookie:")) {
414
- headers["Cookie"] = token.substring(7);
415
- }
416
- else {
417
- headers["Authorization"] = `Bearer ${token}`;
418
- }
419
- const response = await fetch(`${this.config.baseUrl}/d2l/api/lp/1.45/users/whoami`, {
420
- method: "GET",
421
- headers,
225
+ response = await fetch(`${this.config.baseUrl}/d2l/api/lp/1.45/users/whoami`, {
226
+ headers: token.startsWith("cookie:") ? { Cookie: token.slice(7) } : { Authorization: `Bearer ${token}` },
422
227
  signal: AbortSignal.timeout(10000),
423
228
  });
424
- if (response.ok) {
425
- log("DEBUG", "Token validation succeeded (whoami returned 200)");
426
- return true;
427
- }
428
- log("DEBUG", `Token validation failed: HTTP ${response.status}`);
429
- return false;
430
229
  }
431
230
  catch (error) {
432
- log("DEBUG", "Token validation error", error);
433
- return false;
231
+ throw new BrowserAuthTransportError("Token validation could not reach Brightspace.", { cause: error });
434
232
  }
233
+ if (response.ok) {
234
+ const contentType = response.headers.get("content-type") ?? "";
235
+ if (!contentType.includes("json"))
236
+ return false;
237
+ let user;
238
+ try {
239
+ user = await response.json();
240
+ }
241
+ catch (error) {
242
+ throw new BrowserAuthTransportError("Token validation returned unreadable JSON.", { cause: error });
243
+ }
244
+ const identifier = user?.Identifier;
245
+ return (typeof identifier === "string" && identifier.length > 0) || (typeof identifier === "number" && Number.isFinite(identifier));
246
+ }
247
+ if (response.status === 401 || response.status === 403)
248
+ return false;
249
+ throw new BrowserAuthTransportError(`Token validation temporarily failed with HTTP ${response.status}.`);
435
250
  }
436
- /**
437
- * Set up passive network request listener to capture Bearer token.
438
- * MUST be called BEFORE page.goto() to avoid race condition.
439
- */
440
- setupTokenInterception(page, timeoutMs = 120000) {
441
- return new Promise((resolve, reject) => {
442
- const timeout = setTimeout(() => {
443
- reject(new BrowserAuthError(`Token interception timed out after ${timeoutMs / 1000} seconds`, "token_interception"));
444
- }, timeoutMs);
445
- page.on("request", (request) => {
446
- const url = request.url();
447
- // Look for any request with a Bearer token
448
- if (url.includes("/d2l/")) {
449
- const authHeader = request.headers()["authorization"];
450
- if (authHeader && authHeader.startsWith("Bearer ")) {
451
- const token = authHeader.substring("Bearer ".length);
452
- log("DEBUG", `Token captured from request to ${url}`);
453
- clearTimeout(timeout);
454
- resolve(token);
455
- }
456
- }
457
- });
458
- log("DEBUG", "Token interception listener registered");
459
- });
460
- }
461
- /**
462
- * Navigate to Brightspace and login if needed.
463
- * Returns true if already authenticated (cookies valid), false if SSO login was performed.
464
- */
251
+ /** Restore first, and only attempt credentials after a real sign-in is needed. */
465
252
  async navigateAndLogin(page) {
253
+ let navigationError;
254
+ let response;
466
255
  try {
467
- log("INFO", `Navigating to ${this.config.baseUrl}/d2l/home`);
468
- await page.goto(`${this.config.baseUrl}/d2l/home`, {
256
+ response = await page.goto(`${this.config.baseUrl}/d2l/home`, {
469
257
  waitUntil: "domcontentloaded",
470
- timeout: 30000,
258
+ timeout: INITIAL_NAVIGATION_TIMEOUT_MS,
471
259
  });
472
- if (await this.awaitSilentSSO(page)) {
473
- log("INFO", "Already authenticated - skipping SSO login");
474
- await this.settle(page);
475
- return true;
476
- }
477
- // A credential login on Entra ends in a number match: two digits on the
478
- // page that the human types into Authenticator. Headless has nowhere to
479
- // show them, so the login would time out with the number sitting in a
480
- // log file. Bail out and let authenticate() relaunch with a window.
481
- if (needsVisibleBrowser({ launchedHeadless: this.launchedHeadless })) {
482
- throw new HeadlessLoginRequiredError();
483
- }
484
- let loginSuccess;
485
- if (this.ssoFlow.hasCredentials()) {
486
- log("INFO", `Login required (stopped at ${page.url()}) - starting SSO flow`);
487
- loginSuccess = await this.ssoFlow.login(page);
488
- if (!loginSuccess) {
489
- log("WARN", "Automated SSO flow failed or timed out. Falling back to manual login.");
490
- log("INFO", "Please complete the login manually in the open browser window.");
491
- loginSuccess = await this.ssoFlow.manualLogin(page);
492
- }
493
- }
494
- else {
495
- log("INFO", `Login required (stopped at ${page.url()}) - opening browser for manual login`);
496
- loginSuccess = await this.ssoFlow.manualLogin(page);
497
- }
498
- if (!loginSuccess) {
499
- throw new BrowserAuthError("Manual login flow failed", "manual_login");
500
- }
501
- await this.settle(page);
502
- return false;
503
260
  }
504
261
  catch (error) {
505
- if (error instanceof BrowserAuthError)
506
- throw error;
507
- throw new BrowserAuthError("Failed to navigate and login", "navigate_login", error);
262
+ // Brightspace Bar tolerates a navigation timeout because SAML may keep
263
+ // redirecting after Playwright stops waiting. Continue with the same
264
+ // bounded page-state poll, but never infer that credentials are needed
265
+ // from the navigation failure itself.
266
+ const message = error instanceof Error ? error.message : String(error);
267
+ if (!/timeout|ERR_ABORTED|navigation.*interrupted/i.test(message)) {
268
+ throw new BrowserAuthTransportError("Could not reach Brightspace. Retry when the connection is available.", { cause: error });
269
+ }
270
+ navigationError = error;
271
+ log("WARN", "Initial Brightspace navigation did not settle; checking the current SSO page");
272
+ }
273
+ if (response && (response.status() >= 500 || response.status() === 429)) {
274
+ throw new BrowserAuthTransportError(`Brightspace temporarily returned HTTP ${response.status()}. Saved state is preserved.`);
275
+ }
276
+ if (await this.awaitSilentSSO(page)) {
277
+ log("INFO", "Saved session is active");
278
+ return true;
279
+ }
280
+ const pendingMfa = await this.isAnyOnScreen(page, SILENT_SSO.mfaChallenges);
281
+ if (!pendingMfa && !await this.hasCredentialPrompt(page)) {
282
+ throw new BrowserAuthTransportError("The sign-in page has not settled on a supported login challenge. Retry shortly; saved state is preserved.", navigationError === undefined ? undefined : { cause: navigationError });
283
+ }
284
+ if (!this.ssoFlow.hasCredentials() && !pendingMfa) {
285
+ throw new UnsupportedAuthenticationError("Headless sign-in requires saved credentials. Run brightspace-mcp-server setup.");
508
286
  }
509
- }
510
- /** Let the page finish loading, without letting a slow one fail the run. */
511
- async settle(page) {
512
287
  try {
513
- await page.waitForLoadState("domcontentloaded", { timeout: 30000 });
288
+ if (!await this.ssoFlow.login(page)) {
289
+ throw new UnsupportedAuthenticationError("The identity provider could not complete headless sign-in.");
290
+ }
514
291
  }
515
- catch {
516
- log("DEBUG", "Page wait timed out, proceeding anyway");
292
+ catch (error) {
293
+ if (error instanceof MfaApprovalError)
294
+ await this.cooldown.recordMfaFailure();
295
+ throw error;
296
+ }
297
+ if (!await this.hasLiveSession(page)) {
298
+ throw new BrowserAuthError("Sign-in did not produce a verified Brightspace session.", "session_validation");
517
299
  }
300
+ return false;
518
301
  }
519
302
  /**
520
303
  * Give the SSO chain a bounded window to finish with no human input.
@@ -522,20 +305,57 @@ export class BrowserAuth {
522
305
  */
523
306
  async awaitSilentSSO(page) {
524
307
  const deadline = Date.now() + SILENT_SSO_TIMEOUT_MS;
308
+ let accountHintAttempted = false;
309
+ let passwordPromptPolls = 0;
525
310
  do {
526
311
  if (await this.hasLiveSession(page))
527
312
  return true;
528
- // Fail fast rather than spend the budget on a page that will never
529
- // advance: a fresh profile otherwise stalls here for the full 30s.
530
- if (await this.isOnScreen(page, SILENT_SSO.emailField)) {
531
- log("INFO", "Silent SSO cannot pass an account-name prompt - a credential login is needed");
313
+ // Only a visible supported challenge justifies entering credentials or
314
+ // waiting for MFA. A stalled redirect is not proof of session expiry.
315
+ if (await this.isAnyOnScreen(page, SILENT_SSO.emailFields)) {
316
+ if (!accountHintAttempted && this.ssoFlow.identifyAccount) {
317
+ accountHintAttempted = true;
318
+ if (await this.ssoFlow.identifyAccount(page)) {
319
+ log("DEBUG", "Submitted the configured account name to resume saved SSO");
320
+ await page.waitForTimeout(SILENT_SSO_POLL_MS);
321
+ continue;
322
+ }
323
+ }
324
+ log("INFO", "The identity provider requires an account login");
532
325
  return false;
533
326
  }
534
- await this.clickSilentSurfaces(page);
535
- await page.waitForTimeout(SILENT_SSO_POLL_MS);
327
+ if (await this.isAnyOnScreen(page, SILENT_SSO.mfaChallenges)) {
328
+ log("INFO", "The identity provider requires a login challenge");
329
+ return false;
330
+ }
331
+ if (await this.isAnyOnScreen(page, SILENT_SSO.credentialFields)) {
332
+ passwordPromptPolls += 1;
333
+ // Entra can briefly expose a password-shaped control while its email
334
+ // view initializes. Brightspace Bar polls the evolving page instead of
335
+ // concluding from that first transient snapshot.
336
+ if (passwordPromptPolls >= 2) {
337
+ log("INFO", "The identity provider requires a login challenge");
338
+ return false;
339
+ }
340
+ await page.waitForTimeout(SILENT_SSO_POLL_MS);
341
+ continue;
342
+ }
343
+ passwordPromptPolls = 0;
344
+ try {
345
+ await this.clickSilentSurfaces(page);
346
+ await page.waitForTimeout(SILENT_SSO_POLL_MS);
347
+ }
348
+ catch (error) {
349
+ if (error instanceof BrowserAuthError || error instanceof BrowserAuthTransportError)
350
+ throw error;
351
+ throw new BrowserAuthTransportError("Silent sign-in could not reach the identity provider. Saved state is preserved.", { cause: error });
352
+ }
536
353
  } while (Date.now() < deadline);
537
- log("DEBUG", `No session after ${SILENT_SSO_TIMEOUT_MS / 1000}s of silent SSO`);
538
- return false;
354
+ throw new BrowserAuthTransportError("Silent sign-in did not reach Brightspace or a supported login challenge within 30 seconds. Retry shortly; saved state is preserved.");
355
+ }
356
+ async hasCredentialPrompt(page) {
357
+ return await this.isAnyOnScreen(page, SILENT_SSO.emailFields) ||
358
+ await this.isAnyOnScreen(page, SILENT_SSO.credentialFields);
539
359
  }
540
360
  /**
541
361
  * A POSITIVE session check, never "the URL doesn't look like a login page".
@@ -545,6 +365,13 @@ export class BrowserAuth {
545
365
  */
546
366
  async hasLiveSession(page) {
547
367
  try {
368
+ const location = new URL(page.url());
369
+ if (location.origin !== new URL(this.config.baseUrl).origin)
370
+ return false;
371
+ // The /d2l/login shell also exposes D2L.LP and stale session cookies.
372
+ // Only the requested authenticated home page is evidence of success.
373
+ if (!/^\/d2l\/home(?:\/|$)/.test(location.pathname))
374
+ return false;
548
375
  const cookies = await page.context().cookies(this.config.baseUrl);
549
376
  if (!cookies.some((c) => c.name === "d2lSessionVal" && Boolean(c.value))) {
550
377
  return false;
@@ -554,8 +381,8 @@ export class BrowserAuth {
554
381
  return d2l !== undefined && Boolean(d2l.LP);
555
382
  });
556
383
  }
557
- catch {
558
- return false;
384
+ catch (error) {
385
+ throw new BrowserAuthTransportError("The browser could not verify the saved Brightspace session. Saved state is preserved.", { cause: error });
559
386
  }
560
387
  }
561
388
  /**
@@ -565,6 +392,11 @@ export class BrowserAuth {
565
392
  async clickSilentSurfaces(page) {
566
393
  const url = page.url();
567
394
  if (url.includes("/d2l/login")) {
395
+ // The Purdue buttons are not ordinary links. Follow the school's known
396
+ // SAML entry before deciding whether saved Microsoft state has expired.
397
+ await this.ssoFlow.prepareLogin?.(page);
398
+ if (!page.url().includes("/d2l/login"))
399
+ return;
568
400
  // Brightspace renders the campus buttons in a shadow DOM, which
569
401
  // Playwright's selectors see through. A configured campus is matched by
570
402
  // name; otherwise the selector's own SAML link is the only affordance
@@ -598,7 +430,20 @@ export class BrowserAuth {
598
430
  }
599
431
  /** Visibility without the actionability wait, so a poll keeps its rhythm. */
600
432
  async isOnScreen(page, selector) {
601
- return page.locator(selector).first().isVisible().catch(() => false);
433
+ try {
434
+ return await page.locator(selector).first().isVisible();
435
+ }
436
+ catch (error) {
437
+ throw new BrowserAuthTransportError("The browser could not inspect the sign-in page. Saved state is preserved.", { cause: error });
438
+ }
439
+ }
440
+ /** Check each selector independently so a hidden earlier match cannot mask a visible one. */
441
+ async isAnyOnScreen(page, selectors) {
442
+ for (const selector of selectors) {
443
+ if (await this.isOnScreen(page, selector))
444
+ return true;
445
+ }
446
+ return false;
602
447
  }
603
448
  /**
604
449
  * Try to extract Bearer token from D2L's localStorage.
@@ -693,7 +538,15 @@ export class BrowserAuth {
693
538
  timeout: 15000,
694
539
  });
695
540
  }
696
- return await page.evaluate(() => {
541
+ }
542
+ catch (error) {
543
+ log("DEBUG", "Could not navigate to Brightspace before XSRF extraction", error);
544
+ return null;
545
+ }
546
+ // Brightspace Bar retries each evaluation independently because a redirect
547
+ // can destroy one JavaScript context while the next one is already valid.
548
+ for (let attempt = 0; attempt < 10; attempt += 1) {
549
+ const token = await page.evaluate(() => {
697
550
  const d2l = window.D2L;
698
551
  try {
699
552
  const lp = d2l?.LP;
@@ -702,7 +555,7 @@ export class BrowserAuth {
702
555
  const xsrf = auth?.Xsrf;
703
556
  const getToken = xsrf?.GetXsrfToken;
704
557
  if (getToken) {
705
- const value = getToken();
558
+ const value = getToken.call(xsrf);
706
559
  if (value)
707
560
  return value;
708
561
  }
@@ -712,12 +565,15 @@ export class BrowserAuth {
712
565
  }
713
566
  const metaToken = document.querySelector('meta[name="d2l-xsrf-token"]');
714
567
  return metaToken ? metaToken.getAttribute("content") : null;
568
+ }).catch((error) => {
569
+ log("DEBUG", "XSRF evaluation raced a page transition, retrying", error);
570
+ return null;
715
571
  });
572
+ if (token)
573
+ return token;
574
+ await page.waitForTimeout(1000);
716
575
  }
717
- catch (error) {
718
- log("DEBUG", "XSRF-only extraction failed", error);
719
- return null;
720
- }
576
+ return null;
721
577
  }
722
578
  /**
723
579
  * Try to extract XSRF/API token from D2L's JavaScript context.
@@ -746,7 +602,7 @@ export class BrowserAuth {
746
602
  const xsrf = auth?.Xsrf;
747
603
  const getToken = xsrf?.GetXsrfToken;
748
604
  if (getToken)
749
- return getToken();
605
+ return getToken.call(xsrf);
750
606
  }
751
607
  catch {
752
608
  // Not available
@@ -804,209 +660,5 @@ export class BrowserAuth {
804
660
  return null;
805
661
  }
806
662
  }
807
- /**
808
- * Load previously saved storage state (cookies + localStorage).
809
- * Workaround for Playwright bug #36139: session cookies don't persist in persistent context.
810
- */
811
- async loadStorageState(context) {
812
- try {
813
- const storageStatePath = path.join(this.config.sessionDir, "storage-state.json");
814
- // Check if storage state file exists
815
- let stats;
816
- try {
817
- stats = await fs.stat(storageStatePath);
818
- }
819
- catch {
820
- log("DEBUG", "No existing storage state to load");
821
- return;
822
- }
823
- // Skip loading stale cookies that would fake "already authenticated"
824
- const ageMs = Date.now() - stats.mtimeMs;
825
- const maxAgeMs = this.config.tokenTtl * 1000;
826
- if (ageMs > maxAgeMs) {
827
- log("INFO", `Storage state is ${Math.round(ageMs / 60000)}min old (TTL ${this.config.tokenTtl}s) — skipping stale cookies`);
828
- return;
829
- }
830
- // Read storage state
831
- const stateJson = await fs.readFile(storageStatePath, "utf-8");
832
- const state = JSON.parse(stateJson);
833
- // Restore cookies
834
- if (state.cookies && state.cookies.length > 0) {
835
- await context.addCookies(state.cookies);
836
- log("INFO", `Restored ${state.cookies.length} cookies from storage state`);
837
- }
838
- // Restore localStorage for each origin
839
- if (state.origins && state.origins.length > 0) {
840
- for (const origin of state.origins) {
841
- if (origin.localStorage && origin.localStorage.length > 0) {
842
- let tempPage = null;
843
- try {
844
- // Create a temporary page to set localStorage
845
- tempPage = await context.newPage();
846
- await tempPage.goto(origin.origin, { timeout: 10000 });
847
- // Set each localStorage item
848
- await tempPage.evaluate((items) => {
849
- for (const item of items) {
850
- localStorage.setItem(item.name, item.value);
851
- }
852
- }, origin.localStorage);
853
- log("INFO", `Restored ${origin.localStorage.length} localStorage items for ${origin.origin}`);
854
- }
855
- catch (originError) {
856
- log("WARN", `Failed to restore localStorage for ${origin.origin}`, originError);
857
- }
858
- finally {
859
- if (tempPage) {
860
- try {
861
- await tempPage.close();
862
- }
863
- catch {
864
- // Page may already be closed
865
- }
866
- }
867
- }
868
- }
869
- }
870
- }
871
- log("INFO", "Storage state restored successfully");
872
- }
873
- catch (error) {
874
- log("WARN", "Failed to load storage state", error);
875
- }
876
- }
877
- async saveStorageState(context) {
878
- try {
879
- const storageStatePath = path.join(this.config.sessionDir, "storage-state.json");
880
- await context.storageState({ path: storageStatePath });
881
- await fs.chmod(storageStatePath, 0o600);
882
- log("DEBUG", `Storage state saved to ${storageStatePath}`);
883
- }
884
- catch (error) {
885
- log("WARN", "Failed to save storage state", error);
886
- }
887
- }
888
- /**
889
- * Launch browser with retry logic.
890
- * Windows is prone to 180s launch timeouts (Playwright issue #22117) caused by
891
- * lingering Chromium processes, antivirus interference, or resource contention.
892
- * On timeout, we clear lock files and retry once.
893
- */
894
- async launchBrowserWithRetry(browserDataDir, options) {
895
- // The only place the browser is needed, so the only place it is loaded.
896
- const { chromium } = await import("playwright");
897
- // Validate lock files before every launch attempt
898
- await this.validateAndClearLockFiles(browserDataDir);
899
- try {
900
- // Wrap in Promise.race so a hung launch (e.g. stale SingletonLock
901
- // that wasn't caught) still falls into the retry path
902
- const launchPromise = chromium.launchPersistentContext(browserDataDir, options);
903
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout: browser launch hung")), options.timeout));
904
- return await Promise.race([launchPromise, timeoutPromise]);
905
- }
906
- catch (error) {
907
- const errMsg = error instanceof Error ? error.message : String(error);
908
- const isTimeout = errMsg.includes("Timeout") || errMsg.includes("timeout") || errMsg.includes("hung");
909
- // macOS: Chrome for Testing died during launch with SIGTRAP / browser
910
- // closed. The profile is likely corrupted from prior crashes. Quarantine
911
- // it, reapply the crash guards, and retry with a fresh profile.
912
- // See issue #10.
913
- const isMacBrowserCrash = process.platform === "darwin" &&
914
- (errMsg.includes("Target page, context or browser has been closed") ||
915
- errMsg.includes("SIGTRAP") ||
916
- errMsg.includes("did exit"));
917
- if (isMacBrowserCrash) {
918
- log("WARN", "Chrome for Testing crashed during launch — quarantining profile and retrying");
919
- await this.quarantineBrowserDataDir(browserDataDir);
920
- await BrowserAuth.applyMacOSCrashGuard();
921
- return await chromium.launchPersistentContext(browserDataDir, {
922
- ...options,
923
- timeout: 90000,
924
- });
925
- }
926
- if (isTimeout) {
927
- log("WARN", "Browser launch timed out — clearing lock files and retrying");
928
- await this.validateAndClearLockFiles(browserDataDir);
929
- return await chromium.launchPersistentContext(browserDataDir, {
930
- ...options,
931
- timeout: 90000,
932
- });
933
- }
934
- throw error;
935
- }
936
- }
937
- /**
938
- * Validate and remove stale Chromium lock files from the browser data directory.
939
- * Playwright's persistent context uses Chromium's SingletonLock mechanism.
940
- * If the browser is killed unexpectedly (antivirus, force close, crash),
941
- * these lock files persist and block all future launch attempts.
942
- *
943
- * For SingletonLock (a symlink whose target is "hostname-pid"), we check
944
- * whether the owning process is still alive before removing it. This avoids
945
- * deleting a lock held by a legitimate running instance.
946
- */
947
- async validateAndClearLockFiles(browserDataDir) {
948
- // Ensure the directory exists before scanning
949
- try {
950
- await fs.access(browserDataDir);
951
- }
952
- catch {
953
- return; // Directory doesn't exist yet, nothing to clean
954
- }
955
- const lockFiles = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
956
- for (const lockFile of lockFiles) {
957
- const lockPath = path.join(browserDataDir, lockFile);
958
- try {
959
- const stat = await fs.lstat(lockPath);
960
- if (stat.isSymbolicLink() && lockFile === "SingletonLock") {
961
- // SingletonLock is a symlink with target "hostname-pid"
962
- const target = await fs.readlink(lockPath);
963
- const dashIdx = target.lastIndexOf("-");
964
- if (dashIdx > 0) {
965
- const lockHostname = target.substring(0, dashIdx);
966
- const lockPid = parseInt(target.substring(dashIdx + 1), 10);
967
- if (lockHostname !== os.hostname()) {
968
- // Lock from a different machine (e.g. NFS home dir, or stale after network change)
969
- log("WARN", `Removing SingletonLock from different host (${lockHostname} vs ${os.hostname()})`);
970
- await fs.unlink(lockPath);
971
- continue;
972
- }
973
- if (!isNaN(lockPid)) {
974
- try {
975
- process.kill(lockPid, 0); // Signal 0 checks if process exists
976
- // Process is alive, leave the lock alone
977
- log("DEBUG", `SingletonLock held by live process ${lockPid}, skipping`);
978
- continue;
979
- }
980
- catch (killErr) {
981
- const code = killErr.code;
982
- if (code === "ESRCH") {
983
- // Process is dead, safe to remove
984
- log("WARN", `Removing SingletonLock from dead process ${lockPid}`);
985
- await fs.unlink(lockPath);
986
- continue;
987
- }
988
- if (code === "EPERM") {
989
- // Process exists but we lack permissions, leave it alone
990
- log("DEBUG", `SingletonLock held by process ${lockPid} (EPERM), skipping`);
991
- continue;
992
- }
993
- }
994
- }
995
- }
996
- // Could not parse target, remove as a safety measure
997
- log("WARN", `Removing unparseable SingletonLock: ${target}`);
998
- await fs.unlink(lockPath);
999
- }
1000
- else {
1001
- // Regular file (SingletonCookie, SingletonSocket) or unknown symlink
1002
- await fs.unlink(lockPath);
1003
- log("WARN", `Removed stale lock file: ${lockFile}`);
1004
- }
1005
- }
1006
- catch {
1007
- // File doesn't exist, expected in normal operation
1008
- }
1009
- }
1010
- }
1011
663
  }
1012
664
  //# sourceMappingURL=browser-auth.js.map