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