brightspace-mcp-server 3.4.0 → 3.5.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.
package/README.md CHANGED
@@ -101,7 +101,9 @@ If you miss an MFA request, automatic browser authentication pauses for five min
101
101
  npx -y brightspace-mcp-server@latest auth
102
102
  ```
103
103
 
104
- **MFA at Purdue** commonly uses Microsoft Authenticator number matching: enter the terminal-displayed number on your phone. Google Authenticator and other one-time-code apps work too, with no setting to change: run the auth command above in a terminal and it prompts for the code when your provider asks for one. Pick the visible-browser option during setup only if your identity provider needs interaction the server cannot drive. The MCP also sends authentication progress as logging notifications to clients that display them. Some desktop clients hide server logs, so use the terminal command above for interactive MFA.
104
+ Run it from your home folder. On macOS, a terminal that lacks Files and Folders permission (the terminal panel inside Claude Desktop, or a fresh editor terminal) cannot start `npx` from inside Documents, Desktop, or Downloads — it fails with `EPERM: process.cwd failed … uv_cwd` before the server runs. The same applies if your AI client launches the server with one of those folders as its working directory; grant the app access under System Settings → Privacy & Security → Files and Folders, or start the server elsewhere.
105
+
106
+ **MFA at Purdue** commonly uses Microsoft Authenticator number matching (some schools use Duo instead). When a sign-in needs it, the tool call itself returns quickly with the number to enter, rather than sitting silent for up to five minutes — approve it on your phone, then call the tool again; sign-in finishes in the background in the meantime. Google Authenticator and other one-time-code apps work too, with no setting to change: run the auth command above in a terminal and it prompts for the code when your provider asks for one. Pick the visible-browser option during setup only if your identity provider needs interaction the server cannot drive. The MCP also sends authentication progress as logging notifications to clients that display them, useful if you don't see the number in the tool response for some reason.
105
107
 
106
108
  ## What You Can Ask About
107
109
 
@@ -22,12 +22,25 @@ export class TTLCache {
22
22
  timerId.unref();
23
23
  }
24
24
  // Store entry
25
- this.cache.set(key, { data: value, timerId });
25
+ this.cache.set(key, { data: value, timerId, storedAt: Date.now() });
26
26
  }
27
27
  get(key) {
28
28
  const entry = this.cache.get(key);
29
29
  return entry?.data;
30
30
  }
31
+ /**
32
+ * How long ago this key was written, in milliseconds, or undefined when it
33
+ * is not cached.
34
+ *
35
+ * One key can be written under several TTLs: two tools ask for the same
36
+ * path with different freshness requirements. An entry another caller kept
37
+ * alive for an hour is still too old for a caller that asked for ten
38
+ * minutes, and only its age can say so.
39
+ */
40
+ ageOf(key) {
41
+ const entry = this.cache.get(key);
42
+ return entry ? Date.now() - entry.storedAt : undefined;
43
+ }
31
44
  has(key) {
32
45
  return this.cache.has(key);
33
46
  }
@@ -103,9 +103,12 @@ export class D2LApiClient {
103
103
  this.cacheTTLs = { ...DEFAULT_CACHE_TTLS, ...options.cacheTTLs };
104
104
  // Initialize cache and rate limiter
105
105
  this.cache = new TTLCache();
106
+ // Sized for the per-course fan-out the tools do (a dozen courses is ~60
107
+ // requests); at 3/s that took ~17 s once the limiter actually reserved
108
+ // tokens. Brightspace 429s are still retried with Retry-After.
106
109
  const rateLimitConfig = options.rateLimitConfig ?? {
107
- capacity: 10,
108
- refillRate: 3,
110
+ capacity: 20,
111
+ refillRate: 8,
109
112
  };
110
113
  this.rateLimiter = new TokenBucket(rateLimitConfig.capacity, rateLimitConfig.refillRate);
111
114
  log("DEBUG", `D2LApiClient initialized for ${this.baseUrl}`);
@@ -183,9 +186,18 @@ export class D2LApiClient {
183
186
  async get(path, options) {
184
187
  // Checked before the path is resolved, and keyed by the path as the caller
185
188
  // wrote it, so a cached read needs neither version discovery nor a token.
186
- if (options?.ttl && this.cache.has(path)) {
187
- log("DEBUG", `Cache hit: ${path}`);
188
- return this.cache.get(path);
189
+ //
190
+ // The entry has to be younger than this caller's own TTL, not merely
191
+ // still alive. One path can be written under two TTLs: the discussion
192
+ // forum list is thirty minutes of course content to get_discussions and
193
+ // ten minutes of due dates to get_upcoming_due_dates. Trusting the
194
+ // surviving entry served the longer caller's staleness to the shorter one.
195
+ if (options?.ttl) {
196
+ const age = this.cache.ageOf(path);
197
+ if (age !== undefined && age <= options.ttl) {
198
+ log("DEBUG", `Cache hit: ${path}`);
199
+ return this.cache.get(path);
200
+ }
189
201
  }
190
202
  const resolved = await this.resolvePath(path);
191
203
  const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token));
@@ -3,8 +3,8 @@
3
3
  * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
- // Token bucket rate limiter - allows bursts up to capacity
7
- // Conservative defaults: capacity 10, refill 3/sec
6
+ // Token bucket rate limiter - allows bursts up to capacity.
7
+ // Defaults live in D2LApiClient (client.ts).
8
8
  export class TokenBucket {
9
9
  tokens;
10
10
  lastRefill;
@@ -25,21 +25,30 @@ export class TokenBucket {
25
25
  this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
26
26
  this.lastRefill = now;
27
27
  }
28
+ /**
29
+ * Take `count` tokens, waiting for them if the bucket is short.
30
+ *
31
+ * The tokens are deducted before the wait rather than after it, so
32
+ * concurrent callers queue behind one another. Deducting afterwards let
33
+ * every caller read the same empty bucket, wait for the same single token,
34
+ * and then wake together and take it: a fan-out across courses drained the
35
+ * burst and then admitted the whole remainder one refill later, which is the
36
+ * 429 storm this limiter exists to prevent. It also left the balance deeply
37
+ * negative, so the next unrelated request paid the whole debt in one wait.
38
+ *
39
+ * A negative balance is that debt, owed by the waiters already queued, and
40
+ * refill() pays it down at the refill rate.
41
+ */
28
42
  async consume(count = 1) {
29
43
  this.refill();
30
- if (this.tokens >= count) {
31
- // Enough tokens available - consume immediately
32
- this.tokens -= count;
44
+ const shortfall = count - this.tokens;
45
+ this.tokens -= count;
46
+ if (shortfall <= 0) {
47
+ // Enough tokens were already banked - proceed immediately.
33
48
  return;
34
49
  }
35
- // Not enough tokens - calculate wait time
36
- const tokensNeeded = count - this.tokens;
37
- const waitTimeMs = (tokensNeeded / this.refillRate) * 1000;
38
- // Wait for tokens to refill
50
+ const waitTimeMs = (shortfall / this.refillRate) * 1000;
39
51
  await new Promise((resolve) => setTimeout(resolve, waitTimeMs));
40
- // Refill and consume
41
- this.refill();
42
- this.tokens -= count;
43
52
  }
44
53
  tryConsume(count = 1) {
45
54
  this.refill();
@@ -49,8 +58,10 @@ export class TokenBucket {
49
58
  }
50
59
  return false;
51
60
  }
61
+ /** Tokens free to take right now. Never negative: an outstanding
62
+ * reservation is a debt, not a negative supply. */
52
63
  get availableTokens() {
53
64
  this.refill();
54
- return this.tokens;
65
+ return Math.max(0, this.tokens);
55
66
  }
56
67
  }
@@ -19,11 +19,25 @@ export class AuthenticationCooldownError extends Error {
19
19
  // stalled chat window nothing they can act on.
20
20
  const minutes = Math.max(1, Math.ceil((retryAt - Date.now()) / 60000));
21
21
  super(`The last sign-in prompt was not approved, so automatic sign-in is paused for ${minutes} more minute${minutes === 1 ? "" : "s"}. ` +
22
- `To sign in right now, run this in a terminal: ${AUTH_COMMAND}`);
22
+ `To sign in right now, run this in a terminal from your home folder: ${AUTH_COMMAND}`);
23
23
  this.retryAt = retryAt;
24
24
  this.name = "AuthenticationCooldownError";
25
25
  }
26
26
  }
27
+ /** The recorded retry time, or undefined when the file says nothing usable. */
28
+ function readRetryAt(content) {
29
+ let status;
30
+ try {
31
+ status = JSON.parse(content);
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ if (typeof status !== "object" || status === null || Array.isArray(status))
37
+ return undefined;
38
+ const retryAt = status.retryAt;
39
+ return typeof retryAt === "number" && Number.isFinite(retryAt) ? retryAt : undefined;
40
+ }
27
41
  /** Non-secret retry metadata. Call only while holding the authentication lock. */
28
42
  export class AuthCooldown {
29
43
  file;
@@ -40,9 +54,19 @@ export class AuthCooldown {
40
54
  return;
41
55
  throw error;
42
56
  }
43
- const status = JSON.parse(content);
44
- if (typeof status.retryAt === "number" && status.retryAt > Date.now())
45
- throw new AuthenticationCooldownError(status.retryAt);
57
+ const retryAt = readRetryAt(content);
58
+ if (retryAt === undefined) {
59
+ // This file is non-secret retry metadata, and only a readable retryAt is
60
+ // evidence of anything. A truncated or non-object file used to throw out
61
+ // of here, which runs before the sign-in attempt — and the automatic path
62
+ // only clears the file after a sign-in that then never started. One
63
+ // damaged file disabled background sign-in for good, with nothing on
64
+ // screen naming the cause. Discard it and let this attempt proceed.
65
+ await fs.unlink(this.file).catch(() => { });
66
+ return;
67
+ }
68
+ if (retryAt > Date.now())
69
+ throw new AuthenticationCooldownError(retryAt);
46
70
  }
47
71
  async recordMfaFailure() {
48
72
  await this.write({ retryAt: Date.now() + MFA_COOLDOWN_MS });
@@ -13,17 +13,36 @@ import { AUTH_COMMAND } from "../utils/commands.js";
13
13
  * Timeout for the auth process. It has to outlast the child's own MFA wait,
14
14
  * which is five minutes: a person has to find their phone, unlock it, and read
15
15
  * a number off the screen. A shorter parent budget would kill the child in the
16
- * middle of a sign-in the user was still completing.
16
+ * middle of a sign-in the user was still completing. run() itself usually
17
+ * returns long before this fires — the moment the child reports an MFA
18
+ * challenge — but this timer keeps bounding the background child regardless.
17
19
  */
18
20
  const AUTH_TIMEOUT_MS = 8 * 60 * 1000;
19
21
  const KILL_GRACE_MS = 5000;
20
22
  /**
21
- * The only line auth-cli.ts is allowed to hand back as structured data.
22
- * Deliberately strict (1-3 digits, whole line) so this can never become a
23
- * channel for arbitrary child-process text to reach a tool response —
24
- * anything that doesn't match this exactly is just another log line.
23
+ * How long a caller that joins a background child already reported as
24
+ * mfaPending waits for it before re-answering with the same challenge.
25
+ * Long enough for an already-approved login to finish its next ~2s poll and
26
+ * mint a token; short enough that a not-yet-approved one still answers
27
+ * within this tool call instead of blocking for the rest of the 5-minute
28
+ * approval window.
29
+ */
30
+ const JOIN_GRACE_MS = 5000;
31
+ /**
32
+ * The only two lines auth-cli.ts is allowed to hand back as structured data.
33
+ * Deliberately strict (whole line, 1-3 digits or the literal word) so this
34
+ * can never become a channel for arbitrary child-process text to reach a
35
+ * tool response — anything that doesn't match exactly is just another log
36
+ * line.
25
37
  */
26
38
  const MFA_NUMBER_MARKER = /^MFA_NUMBER:(\d{1,3})$/;
39
+ const MFA_PENDING_MARKER = /^MFA_PENDING$/;
40
+ /** The mfaPending kind and message, with or without number-match digits. */
41
+ function mfaPendingFailure(numberMatch) {
42
+ return numberMatch
43
+ ? ["mfaPending", `Open Microsoft Authenticator and enter ${numberMatch} within 5 minutes, then try again.`]
44
+ : ["mfaPending", "An MFA approval was not completed in time. Try again."];
45
+ }
27
46
  export class AuthProcessError extends AuthError {
28
47
  kind;
29
48
  numberMatch;
@@ -93,6 +112,12 @@ function forwardLines(stream, emit) {
93
112
  *
94
113
  * The child inherits the parent's resolved environment and working directory,
95
114
  * so both processes read the same account configuration and .env file.
115
+ *
116
+ * run() settles as soon as the child reports an MFA challenge (with or
117
+ * without a number to display) rather than waiting for the child to exit —
118
+ * a tool call must not block for the whole approval window. The child keeps
119
+ * running in the background; a later run() call joins that background child
120
+ * instead of spawning a second one.
96
121
  */
97
122
  export class AuthRunner {
98
123
  /**
@@ -106,6 +131,22 @@ export class AuthRunner {
106
131
  * caller await the same login and read the same outcome.
107
132
  */
108
133
  inFlight = null;
134
+ /**
135
+ * The child from a login that answered its caller early (an MFA challenge
136
+ * was reported) and is still running in the background. Set for the
137
+ * duration of every spawned child, not just the early-answer case, so a
138
+ * caller who joins after the answer already exists resolves immediately.
139
+ */
140
+ childDone = null;
141
+ /**
142
+ * The last MFA challenge the current background child reported, if any.
143
+ * Set the moment a marker settles a caller early, cleared alongside
144
+ * childDone. Lets a later joiner re-answer immediately instead of
145
+ * discovering the challenge is stale only after blocking on childDone.
146
+ */
147
+ pendingChallenge = null;
148
+ /** Resolves on the next marker from the current child; null between children. */
149
+ challengeSignal = null;
109
150
  scriptPath;
110
151
  timeoutMs;
111
152
  onProgress;
@@ -125,6 +166,14 @@ export class AuthRunner {
125
166
  log("DEBUG", "Joining the authentication already in flight");
126
167
  return this.inFlight;
127
168
  }
169
+ // A caller that answered early is gone, but its child can still be
170
+ // running in the background (waiting on the user's phone). Join it
171
+ // instead of spawning a second child, which would only hit the
172
+ // cross-process lock and return "busy".
173
+ if (this.childDone) {
174
+ log("DEBUG", "Joining the background sign-in still running from an earlier call");
175
+ return this.joinBackgroundChild(this.childDone);
176
+ }
128
177
  // The latch is released by the flow that owns it, as it settles, so a
129
178
  // failed login is never replayed: the tool call after a declined MFA
130
179
  // prompt starts a fresh attempt rather than inheriting the stale
@@ -137,7 +186,55 @@ export class AuthRunner {
137
186
  this.inFlight = flow;
138
187
  return flow;
139
188
  }
140
- /** One child process, start to finish. */
189
+ /**
190
+ * Join a background child from an earlier early-answered call instead of
191
+ * spawning a new one. A joiner must not simply await childDone: that
192
+ * blocks for whatever is left of the 5-minute approval window, exactly the
193
+ * problem run() otherwise fixes, since the caller usually retries right
194
+ * after reading "call this tool again" and well before actually approving.
195
+ *
196
+ * If no challenge has been reported yet (the child hasn't reached MFA),
197
+ * wait for one — or for the child to finish on its own. Once a challenge
198
+ * is known, race the child against a short grace window: fast enough for
199
+ * an already-approved login to land, short enough to re-answer with the
200
+ * same challenge rather than block.
201
+ */
202
+ async joinBackgroundChild(childDone) {
203
+ if (!this.pendingChallenge && this.challengeSignal) {
204
+ await Promise.race([childDone.catch(() => { }), this.challengeSignal]);
205
+ }
206
+ const challenge = this.pendingChallenge;
207
+ if (!challenge)
208
+ return childDone;
209
+ return new Promise((resolve, reject) => {
210
+ let settled = false;
211
+ const graceTimer = setTimeout(() => {
212
+ if (settled)
213
+ return;
214
+ settled = true;
215
+ reject(new AuthProcessError(...mfaPendingFailure(challenge.numberMatch), challenge.numberMatch));
216
+ }, JOIN_GRACE_MS);
217
+ graceTimer.unref?.();
218
+ childDone.then((value) => {
219
+ if (settled)
220
+ return;
221
+ settled = true;
222
+ clearTimeout(graceTimer);
223
+ resolve(value);
224
+ }, (error) => {
225
+ if (settled)
226
+ return;
227
+ settled = true;
228
+ clearTimeout(graceTimer);
229
+ reject(error);
230
+ });
231
+ });
232
+ }
233
+ /**
234
+ * One child process, start to finish. The returned promise can settle
235
+ * before the child actually exits (see the stdout handler below); the
236
+ * child's real completion is tracked separately on this.childDone.
237
+ */
141
238
  async spawnAuth() {
142
239
  log("INFO", "Auto-launching brightspace-auth...");
143
240
  return await new Promise((resolve, reject) => {
@@ -149,7 +246,14 @@ export class AuthRunner {
149
246
  detached: process.platform !== "win32",
150
247
  });
151
248
  let timedOut = false;
152
- let settled = false;
249
+ // Whether the caller-facing promise above (resolve/reject) has
250
+ // settled. Distinct from childFinished: an early MFA answer settles
251
+ // this while the child keeps running.
252
+ let callerSettled = false;
253
+ // Whether the child has actually finished (exited, timed out, or
254
+ // failed to start) and teardown has run. Guards every handler below
255
+ // against double cleanup.
256
+ let childFinished = false;
153
257
  let numberMatch;
154
258
  let killTimer;
155
259
  const kill = (signal) => {
@@ -182,25 +286,83 @@ export class AuthRunner {
182
286
  };
183
287
  const onExit = () => kill("SIGTERM");
184
288
  process.once("exit", onExit);
185
- const finish = (error) => {
186
- if (settled)
289
+ const settleCaller = (error) => {
290
+ if (callerSettled)
187
291
  return;
188
- settled = true;
292
+ callerSettled = true;
293
+ if (error)
294
+ reject(error);
295
+ else
296
+ resolve(true);
297
+ };
298
+ // Tracks the child to its real end, independent of an early caller
299
+ // answer. Exposed as this.childDone so the next run() call can join
300
+ // it. A dummy catch keeps an unapproved background failure from
301
+ // becoming an unhandled rejection when nobody ever joins it; it does
302
+ // not stop a later `await this.childDone` from seeing the rejection.
303
+ let resolveChildDone;
304
+ let rejectChildDone;
305
+ const completion = new Promise((res, rej) => {
306
+ resolveChildDone = res;
307
+ rejectChildDone = rej;
308
+ });
309
+ const trackedCompletion = completion.finally(() => {
310
+ if (this.childDone === trackedCompletion) {
311
+ this.childDone = null;
312
+ this.pendingChallenge = null;
313
+ this.challengeSignal = null;
314
+ }
315
+ });
316
+ trackedCompletion.catch(() => { });
317
+ this.childDone = trackedCompletion;
318
+ this.pendingChallenge = null;
319
+ let resolveChallengeSignal;
320
+ this.challengeSignal = new Promise((res) => { resolveChallengeSignal = res; });
321
+ // Records the challenge (number or not) and wakes a joiner waiting in
322
+ // joinBackgroundChild. Idempotent on the "already known" question, but
323
+ // a later number still overwrites a numberless pendingChallenge so a
324
+ // fresh joiner sees it — see MFA_PENDING_MARKER's own comment.
325
+ const publishChallenge = (matched) => {
326
+ const firstChallenge = this.pendingChallenge === null;
327
+ if (firstChallenge || matched) {
328
+ this.pendingChallenge = { numberMatch: matched ?? this.pendingChallenge?.numberMatch };
329
+ }
330
+ if (firstChallenge)
331
+ resolveChallengeSignal();
332
+ if (!callerSettled) {
333
+ settleCaller(new AuthProcessError(...mfaPendingFailure(this.pendingChallenge?.numberMatch), this.pendingChallenge?.numberMatch));
334
+ }
335
+ };
336
+ // Runs once the child is actually done. Settles the caller too, if an
337
+ // early answer had not already done so; otherwise this is just the
338
+ // background sign-in finishing, which only childDone's joiner sees.
339
+ const finishChild = (error) => {
340
+ if (childFinished)
341
+ return;
342
+ childFinished = true;
189
343
  clearTimeout(timer);
190
344
  if (killTimer)
191
345
  clearTimeout(killTimer);
192
346
  process.off("exit", onExit);
347
+ const answeredEarly = callerSettled;
348
+ settleCaller(error);
349
+ if (answeredEarly) {
350
+ if (error)
351
+ log("WARN", `Background sign-in finished with ${error.kind}: ${error.message}`);
352
+ else
353
+ log("INFO", "Background sign-in completed successfully after an early MFA response");
354
+ }
193
355
  if (error)
194
- reject(error);
356
+ rejectChildDone(error);
195
357
  else
196
- resolve(true);
358
+ resolveChildDone(true);
197
359
  };
198
360
  const timer = setTimeout(() => {
199
361
  timedOut = true;
200
362
  kill("SIGTERM");
201
363
  killTimer = setTimeout(() => {
202
364
  kill("SIGKILL");
203
- finish(new AuthProcessError("timeout", `Authentication timed out. Run ${AUTH_COMMAND} to try again.`));
365
+ finishChild(new AuthProcessError("timeout", `Authentication timed out. Run ${AUTH_COMMAND} to try again.`));
204
366
  }, KILL_GRACE_MS);
205
367
  }, this.timeoutMs);
206
368
  forwardLines(child.stderr, (line) => {
@@ -211,31 +373,38 @@ export class AuthRunner {
211
373
  catch { /* Logging must not interrupt authentication. */ }
212
374
  });
213
375
  // Piped and drained rather than ignored: a full stdout pipe would
214
- // block the child mid-login.
376
+ // block the child mid-login. The first marker settles the caller
377
+ // immediately, without killing the child — see the class doc comment.
215
378
  forwardLines(child.stdout, (line) => {
216
- const match = MFA_NUMBER_MARKER.exec(line);
217
- if (match)
218
- numberMatch = match[1];
219
- else
379
+ const numberMarker = MFA_NUMBER_MARKER.exec(line);
380
+ if (numberMarker) {
381
+ numberMatch = numberMarker[1];
382
+ publishChallenge(numberMatch);
383
+ }
384
+ else if (MFA_PENDING_MARKER.test(line)) {
385
+ publishChallenge(undefined);
386
+ }
387
+ else {
220
388
  log("DEBUG", line);
389
+ }
221
390
  });
222
391
  child.on("error", (error) => {
223
- if (settled)
392
+ if (childFinished)
224
393
  return;
225
394
  log("ERROR", "Auto-auth process failed", error.message);
226
395
  kill("SIGKILL");
227
- finish(new AuthProcessError("failed", `Could not start authentication. Run ${AUTH_COMMAND} for details.`));
396
+ finishChild(new AuthProcessError("failed", `Could not start authentication. Run ${AUTH_COMMAND} for details.`));
228
397
  });
229
398
  child.on("close", (code) => {
230
- if (settled)
399
+ if (childFinished)
231
400
  return;
232
401
  if (timedOut) {
233
402
  kill("SIGKILL");
234
- finish(new AuthProcessError("timeout", `Authentication timed out. Run ${AUTH_COMMAND} to try again.`));
403
+ finishChild(new AuthProcessError("timeout", `Authentication timed out. Run ${AUTH_COMMAND} to try again.`));
235
404
  }
236
405
  else if (code === 0) {
237
406
  log("INFO", "Auto-auth completed successfully");
238
- finish();
407
+ finishChild();
239
408
  }
240
409
  else {
241
410
  const failures = {
@@ -244,13 +413,11 @@ export class AuthRunner {
244
413
  4: ["unsupported", "This identity provider cannot complete headless authentication. See the authentication logs."],
245
414
  5: ["secureStorage", "The native credential store is unavailable or locked. Unlock it and retry."],
246
415
  6: ["transport", "Brightspace authentication is temporarily unavailable because of a network or server failure. Your saved session was preserved. Try again later."],
247
- 7: numberMatch
248
- ? ["mfaPending", `Open Microsoft Authenticator and enter ${numberMatch} within 5 minutes, then try again.`]
249
- : ["mfaPending", "A Microsoft Authenticator approval was not completed in time. Try again."],
416
+ 7: mfaPendingFailure(numberMatch),
250
417
  };
251
418
  const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
252
419
  kill("SIGKILL");
253
- finish(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined));
420
+ finishChild(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined));
254
421
  }
255
422
  });
256
423
  });
@@ -39,9 +39,9 @@ export class BrowserAuth {
39
39
  ssoFlow;
40
40
  stateStore;
41
41
  cooldown;
42
- constructor(config, requestMfaCode) {
42
+ constructor(config, options = {}) {
43
43
  this.config = config;
44
- this.ssoFlow = createSSOFlow(config, requestMfaCode);
44
+ this.ssoFlow = createSSOFlow(config, options.requestMfaCode, options.onMfaChallenge);
45
45
  this.stateStore = new BrowserStateStore(config.sessionDir);
46
46
  this.cooldown = new AuthCooldown(config.sessionDir);
47
47
  }
@@ -178,11 +178,24 @@ function decodeKey(value) {
178
178
  }
179
179
  return Buffer.from(value, "hex");
180
180
  }
181
+ function sessionKeyAccount(canonicalDir) {
182
+ return `session-key:${createHash("sha256").update(canonicalDir).digest("hex")}`;
183
+ }
184
+ /**
185
+ * Whether this session directory already holds a native encryption key, which
186
+ * is what separates an upgrade that has not run yet from one that finished.
187
+ * A locked or unavailable store raises instead of answering false, so a
188
+ * caller never mistakes "cannot tell" for "not migrated".
189
+ */
190
+ export async function hasSessionEncryptionKey(sessionDir, backend = nativeCredentialBackend) {
191
+ const canonicalDir = await fs.realpath(sessionDir);
192
+ return (await backend.getPassword(SERVICE, sessionKeyAccount(canonicalDir))) !== null;
193
+ }
181
194
  /** The canonical session directory stays stable when DHCP changes the hostname. */
182
195
  export async function getSessionEncryptionKey(sessionDir, backend = nativeCredentialBackend, create = true) {
183
196
  await fs.mkdir(sessionDir, { recursive: true, mode: 0o700 });
184
197
  const canonicalDir = await fs.realpath(sessionDir);
185
- const account = `session-key:${createHash("sha256").update(canonicalDir).digest("hex")}`;
198
+ const account = sessionKeyAccount(canonicalDir);
186
199
  const existing = await backend.getPassword(SERVICE, account);
187
200
  if (existing !== null) {
188
201
  try {