relmio 0.4.0 → 0.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/src/web/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { createServer } from "node:http";
4
+ import packageManifest from "../../package.json" with { type: "json" };
4
5
 
5
6
  import { discoverN8n, discoverNetworks } from "../services/discovery.js";
6
7
  import { installSidecar } from "../services/installer.js";
@@ -18,19 +19,54 @@ import {
18
19
  validatePort,
19
20
  } from "../domain/validation.js";
20
21
  import { SIDECAR_HOSTNAME } from "../domain/templates.js";
21
- import { createLocalDeploymentPlan } from "../domain/local-endpoints.js";
22
22
  import {
23
+ createLocalDeploymentPlan,
24
+ validateLocalTarget,
25
+ } from "../domain/local-endpoints.js";
26
+ import {
27
+ acquireLocalEndpointChangeLock,
28
+ activateLocalClientCredentialRotation,
23
29
  attestLocalCodexInstallation,
24
30
  getLocalDockerStatus,
25
31
  installLocalEndpoint,
26
32
  resolveLocalInstallRoot,
27
33
  restartLocalCodex,
34
+ prepareLocalClientCredentialRotation,
28
35
  } from "../services/local-installer.js";
29
36
  import { startCodexDeviceLogin } from "../services/codex-login.js";
30
37
 
31
38
  const MAX_BODY_BYTES = 32 * 1024;
32
39
  const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
33
40
  const RATE_LIMIT_MAX = 10;
41
+ const OAUTH_SHUTDOWN_WAIT_MS = 2_000;
42
+ const LOCAL_ROTATION_STAGE_TTL_MS = 2 * 60 * 1000;
43
+ const PACKAGE_VERSION = packageManifest.version;
44
+
45
+ async function getProjectMeta({ fetchImpl = fetch } = {}) {
46
+ let stars = null;
47
+ try {
48
+ const response = await fetchImpl(
49
+ "https://api.github.com/repos/Demonbane18/relmio",
50
+ {
51
+ headers: {
52
+ Accept: "application/vnd.github+json",
53
+ "User-Agent": `relmio/${PACKAGE_VERSION}`,
54
+ },
55
+ redirect: "error",
56
+ signal: AbortSignal.timeout(5_000),
57
+ },
58
+ );
59
+ if (response.ok) {
60
+ const value = (await response.json())?.stargazers_count;
61
+ if (Number.isSafeInteger(value) && value >= 0) {
62
+ stars = value;
63
+ }
64
+ }
65
+ } catch {
66
+ // The local control keeps a visible fallback when GitHub is unavailable.
67
+ }
68
+ return { stars, version: PACKAGE_VERSION };
69
+ }
34
70
 
35
71
  const defaultServices = {
36
72
  getAuthStatus,
@@ -43,7 +79,11 @@ const defaultServices = {
43
79
  installSidecar,
44
80
  attestLocalCodexInstallation,
45
81
  getLocalDockerStatus,
82
+ getProjectMeta,
46
83
  installLocalEndpoint,
84
+ acquireLocalEndpointChangeLock,
85
+ activateLocalClientCredentialRotation,
86
+ prepareLocalClientCredentialRotation,
47
87
  resolveLocalInstallRoot,
48
88
  restartLocalCodex,
49
89
  startCodexDeviceLogin,
@@ -110,6 +150,37 @@ function enforceRateLimit(state, key) {
110
150
  state.rateLimits.set(key, recent);
111
151
  }
112
152
 
153
+ function waitForBoundedResult(promise, milliseconds) {
154
+ if (!promise) {
155
+ return Promise.resolve(true);
156
+ }
157
+ return new Promise((resolvePromise) => {
158
+ let settled = false;
159
+ const timer = setTimeout(() => {
160
+ if (!settled) {
161
+ settled = true;
162
+ resolvePromise(false);
163
+ }
164
+ }, milliseconds);
165
+ Promise.resolve(promise).then(
166
+ () => {
167
+ if (!settled) {
168
+ settled = true;
169
+ clearTimeout(timer);
170
+ resolvePromise(true);
171
+ }
172
+ },
173
+ () => {
174
+ if (!settled) {
175
+ settled = true;
176
+ clearTimeout(timer);
177
+ resolvePromise(true);
178
+ }
179
+ },
180
+ );
181
+ });
182
+ }
183
+
113
184
  function readJsonBody(request) {
114
185
  return new Promise((resolve, reject) => {
115
186
  const chunks = [];
@@ -192,6 +263,32 @@ function safeErrorMessage(error) {
192
263
  return message;
193
264
  }
194
265
 
266
+ async function cancelOAuthLogin(state, login) {
267
+ try {
268
+ await login.attempt.cancel();
269
+ } catch {
270
+ if (state.oauthLogin === login) {
271
+ login.status = "error";
272
+ login.retryBlocked = true;
273
+ login.error =
274
+ "ChatGPT sign-in could not be stopped safely. Close the sign-in helper, then restart Relmio.";
275
+ }
276
+ state.oauthRetryBlocked = true;
277
+ state.oauthStartupError =
278
+ "ChatGPT sign-in could not be stopped safely. Close the sign-in helper, then restart Relmio.";
279
+ throw Object.assign(
280
+ new Error(
281
+ "ChatGPT sign-in could not be stopped safely. Close the sign-in helper, then restart Relmio.",
282
+ ),
283
+ { retryBlocked: true, statusCode: 409 },
284
+ );
285
+ }
286
+
287
+ if (state.oauthLogin === login && login.status === "pending") {
288
+ login.status = "cancelled";
289
+ }
290
+ }
291
+
195
292
  async function loadDefaultUiFiles() {
196
293
  const files = await Promise.all([
197
294
  readFile(new URL("../ui/index.html", import.meta.url), "utf8"),
@@ -210,7 +307,7 @@ async function loadDefaultUiFiles() {
210
307
 
211
308
  return {
212
309
  "/": files[0],
213
- "/local": files[1],
310
+ "/local": files[1].replaceAll("__RELMIO_PACKAGE_VERSION__", PACKAGE_VERSION),
214
311
  "/app.js": files[2],
215
312
  "/local.js": files[3],
216
313
  "/oauth-popup.js": files[4],
@@ -254,6 +351,38 @@ function createSafeLocalInstallResult(result) {
254
351
  };
255
352
  }
256
353
 
354
+ function createSafeLocalActivationResult(result) {
355
+ return {
356
+ target: result.target,
357
+ endpoint: result.endpoint,
358
+ protocol: result.protocol,
359
+ models: Array.isArray(result.models) ? [...result.models] : [],
360
+ deploymentMode: result.deploymentMode,
361
+ experimental: result.experimental === true,
362
+ browserClients: result.browserClients === true,
363
+ };
364
+ }
365
+
366
+ function createSafeProjectMeta(result) {
367
+ return {
368
+ stars:
369
+ Number.isSafeInteger(result?.stars) && result.stars >= 0
370
+ ? result.stars
371
+ : null,
372
+ version: PACKAGE_VERSION,
373
+ };
374
+ }
375
+
376
+ function getPendingLocalCredentialRotation(state) {
377
+ if (
378
+ state.localCredentialRotationPending &&
379
+ state.localCredentialRotationPending.expiresAt <= Date.now()
380
+ ) {
381
+ state.localCredentialRotationPending = null;
382
+ }
383
+ return state.localCredentialRotationPending;
384
+ }
385
+
257
386
  function createSafeDockerStatus(status, previewMode) {
258
387
  if (previewMode || status?.dockerAvailable !== true) {
259
388
  return {
@@ -297,8 +426,16 @@ async function handleApi(request, response, path, state) {
297
426
  if (request.method === "GET" && path === "/api/oauth/status") {
298
427
  const login = state.oauthLogin;
299
428
  sendJson(response, 200, {
300
- status: login?.status ?? "idle",
301
- ...(login?.status === "error" ? { error: login.error } : {}),
429
+ status: login?.status ?? (state.oauthStartupError ? "error" : "idle"),
430
+ ...(login ? { attemptId: login.attemptId } : {}),
431
+ ...(login?.status === "error"
432
+ ? { error: login.error }
433
+ : state.oauthStartupError
434
+ ? { error: state.oauthStartupError }
435
+ : {}),
436
+ ...(state.oauthRetryBlocked || login?.retryBlocked === true
437
+ ? { retryBlocked: true }
438
+ : {}),
302
439
  });
303
440
  return;
304
441
  }
@@ -315,6 +452,15 @@ async function handleApi(request, response, path, state) {
315
452
  return;
316
453
  }
317
454
 
455
+ if (request.method === "GET" && path === "/api/local/project-meta") {
456
+ sendJson(
457
+ response,
458
+ 200,
459
+ createSafeProjectMeta(await state.services.getProjectMeta()),
460
+ );
461
+ return;
462
+ }
463
+
318
464
  if (
319
465
  request.method === "GET" &&
320
466
  path === "/api/local/codex/login/status"
@@ -342,42 +488,146 @@ async function handleApi(request, response, path, state) {
342
488
  { statusCode: 403 },
343
489
  );
344
490
  }
491
+ if (state.closing) {
492
+ throw Object.assign(new Error("The local wizard is closing."), {
493
+ statusCode: 409,
494
+ });
495
+ }
496
+ if (state.oauthRetryBlocked || state.oauthLogin?.retryBlocked === true) {
497
+ throw Object.assign(
498
+ new Error(
499
+ "ChatGPT sign-in could not be stopped safely. Close the sign-in helper, then restart Relmio.",
500
+ ),
501
+ { retryBlocked: true, statusCode: 409 },
502
+ );
503
+ }
504
+ if (state.oauthLoginStartInFlight) {
505
+ throw Object.assign(
506
+ new Error("A ChatGPT sign-in start is already in progress."),
507
+ { statusCode: 409 },
508
+ );
509
+ }
345
510
  enforceRateLimit(state, path);
346
- if (state.oauthLogin?.status === "pending") {
347
- state.oauthLogin.attempt.cancel();
348
- try {
349
- await state.oauthLogin.attempt.completion;
350
- } catch {
351
- // Starting again intentionally replaces the previous local attempt.
511
+ state.oauthLoginStartInFlight = true;
512
+ state.oauthStartupError = null;
513
+ let startAttempted = false;
514
+ let startPromise;
515
+ try {
516
+ const previousLogin = state.oauthLogin;
517
+ if (previousLogin?.status === "pending") {
518
+ await cancelOAuthLogin(state, previousLogin);
352
519
  }
353
- }
354
520
 
355
- const attempt = await state.services.startOAuthLogin();
356
- const login = {
357
- attempt,
358
- error: null,
359
- status: "pending",
360
- };
361
- state.oauthLogin = login;
362
- attempt.completion.then(
363
- () => {
364
- if (state.oauthLogin === login) {
365
- login.status = "success";
366
- }
367
- },
368
- (error) => {
369
- if (state.oauthLogin === login) {
370
- login.status = "error";
371
- login.error = safeErrorMessage(error);
521
+ if (state.closing) {
522
+ throw Object.assign(new Error("The local wizard is closing."), {
523
+ statusCode: 409,
524
+ });
525
+ }
526
+ if (
527
+ state.oauthLogin === previousLogin &&
528
+ previousLogin?.status !== "pending" &&
529
+ previousLogin?.retryBlocked !== true
530
+ ) {
531
+ state.oauthLogin = null;
532
+ }
533
+ startAttempted = true;
534
+ startPromise = Promise.resolve(state.services.startOAuthLogin());
535
+ state.oauthLoginStartPromise = startPromise;
536
+ const attempt = await startPromise;
537
+ const login = {
538
+ attempt,
539
+ attemptId: randomUUID(),
540
+ error: null,
541
+ retryBlocked: false,
542
+ status: "pending",
543
+ };
544
+ state.oauthLogin = login;
545
+ attempt.completion.then(
546
+ () => {
547
+ if (
548
+ state.oauthLogin === login &&
549
+ login.status === "pending" &&
550
+ !state.closing
551
+ ) {
552
+ login.status = "success";
553
+ }
554
+ },
555
+ (error) => {
556
+ if (
557
+ state.oauthLogin === login &&
558
+ login.status === "pending" &&
559
+ !state.closing
560
+ ) {
561
+ login.status = "error";
562
+ login.error = safeErrorMessage(error);
563
+ if (error?.retryBlocked === true) {
564
+ login.retryBlocked = true;
565
+ state.oauthRetryBlocked = true;
566
+ state.oauthStartupError = login.error;
567
+ }
568
+ }
569
+ },
570
+ );
571
+ if (state.closing) {
572
+ try {
573
+ await cancelOAuthLogin(state, login);
574
+ } catch {
575
+ // The server is already closing and must not restart this helper.
372
576
  }
373
- },
374
- );
375
- sendJson(response, 200, { authorizationUrl: attempt.authorizationUrl });
376
- return;
577
+ throw Object.assign(new Error("The local wizard is closing."), {
578
+ statusCode: 409,
579
+ });
580
+ }
581
+ sendJson(response, 200, {
582
+ authorizationUrl: attempt.authorizationUrl,
583
+ attemptId: login.attemptId,
584
+ });
585
+ return;
586
+ } catch (error) {
587
+ const message = safeErrorMessage(error);
588
+ if (startAttempted) {
589
+ state.oauthStartupError = message;
590
+ }
591
+ if (error?.retryBlocked === true) {
592
+ state.oauthRetryBlocked = true;
593
+ state.oauthStartupError = message;
594
+ }
595
+ throw error;
596
+ } finally {
597
+ if (state.oauthLoginStartPromise === startPromise) {
598
+ state.oauthLoginStartPromise = null;
599
+ }
600
+ state.oauthLoginStartInFlight = false;
601
+ }
377
602
  }
378
603
 
379
604
  const body = await readJsonBody(request);
380
605
 
606
+ if (path === "/api/oauth/cancel") {
607
+ requireLiveLocalAction(state, "Live ChatGPT sign-in");
608
+ const login = state.oauthLogin;
609
+ if (
610
+ !login ||
611
+ login.status !== "pending" ||
612
+ !body ||
613
+ typeof body !== "object" ||
614
+ Array.isArray(body) ||
615
+ typeof body.attemptId !== "string" ||
616
+ body.attemptId !== login.attemptId
617
+ ) {
618
+ throw Object.assign(
619
+ new Error("The ChatGPT sign-in attempt has already changed. Start again."),
620
+ { statusCode: 409 },
621
+ );
622
+ }
623
+ await cancelOAuthLogin(state, login);
624
+ sendJson(response, 200, {
625
+ status: login.status,
626
+ attemptId: login.attemptId,
627
+ });
628
+ return;
629
+ }
630
+
381
631
  if (path === "/api/local/plan") {
382
632
  const plan = createLocalDeploymentPlan({
383
633
  target: body.target,
@@ -398,9 +648,15 @@ async function handleApi(request, response, path, state) {
398
648
  try {
399
649
  requireLiveLocalAction(state, "Local endpoint installation");
400
650
  enforceRateLimit(state, path);
401
- if (state.localInstallInFlight) {
651
+ if (
652
+ state.localInstallInFlight ||
653
+ state.localCredentialRotationInFlight ||
654
+ getPendingLocalCredentialRotation(state) ||
655
+ state.codexLoginStartInFlight ||
656
+ state.codexLogin?.status === "pending"
657
+ ) {
402
658
  throw Object.assign(
403
- new Error("A local endpoint installation is already in progress."),
659
+ new Error("A local endpoint change is already in progress."),
404
660
  { statusCode: 409 },
405
661
  );
406
662
  }
@@ -430,26 +686,105 @@ async function handleApi(request, response, path, state) {
430
686
  return;
431
687
  }
432
688
 
689
+ if (path === "/api/local/client-credential/rotate") {
690
+ requireLiveLocalAction(state, "Local client credential rotation");
691
+ enforceRateLimit(state, path);
692
+ if (
693
+ state.localCredentialRotationInFlight ||
694
+ state.localInstallInFlight ||
695
+ getPendingLocalCredentialRotation(state) ||
696
+ state.codexLoginStartInFlight ||
697
+ state.codexLogin?.status === "pending"
698
+ ) {
699
+ throw Object.assign(
700
+ new Error("A local endpoint change is already in progress."),
701
+ { statusCode: 409 },
702
+ );
703
+ }
704
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
705
+ throw new Error("Choose the installed local endpoint before rotating its credential.");
706
+ }
707
+
708
+ state.localCredentialRotationInFlight = true;
709
+ try {
710
+ const result = await state.services.prepareLocalClientCredentialRotation({
711
+ target: validateLocalTarget(body.target),
712
+ });
713
+ const rotationId = randomUUID();
714
+ state.localCredentialRotationPending = {
715
+ rotationId,
716
+ target: result.target,
717
+ tokenSha256: result.tokenSha256,
718
+ expiresAt: Date.now() + LOCAL_ROTATION_STAGE_TTL_MS,
719
+ };
720
+ sendJson(response, 200, {
721
+ ...createSafeLocalInstallResult(result),
722
+ rotationId,
723
+ });
724
+ } finally {
725
+ state.localCredentialRotationInFlight = false;
726
+ }
727
+ return;
728
+ }
729
+
730
+ if (path === "/api/local/client-credential/activate") {
731
+ requireLiveLocalAction(state, "Local client credential activation");
732
+ enforceRateLimit(state, path);
733
+ if (
734
+ state.localCredentialRotationInFlight ||
735
+ state.localInstallInFlight ||
736
+ state.codexLoginStartInFlight ||
737
+ state.codexLogin?.status === "pending"
738
+ ) {
739
+ throw Object.assign(
740
+ new Error("A local endpoint change is already in progress."),
741
+ { statusCode: 409 },
742
+ );
743
+ }
744
+ const pending = getPendingLocalCredentialRotation(state);
745
+ if (!pending || !tokenMatches(body?.rotationId, pending.rotationId)) {
746
+ throw new Error("Stage a fresh local client credential before activating it.");
747
+ }
748
+
749
+ state.localCredentialRotationPending = null;
750
+ state.localCredentialRotationInFlight = true;
751
+ try {
752
+ const result = await state.services.activateLocalClientCredentialRotation({
753
+ target: pending.target,
754
+ clientCredential: body.clientCredential,
755
+ tokenSha256: pending.tokenSha256,
756
+ });
757
+ sendJson(response, 200, createSafeLocalActivationResult(result));
758
+ } finally {
759
+ state.localCredentialRotationInFlight = false;
760
+ }
761
+ return;
762
+ }
763
+
433
764
  if (path === "/api/local/codex/login") {
434
765
  requireLiveLocalAction(state, "Local Codex sign-in");
435
766
  enforceRateLimit(state, path);
436
- if (state.codexLoginStartInFlight) {
767
+ if (
768
+ state.codexLoginStartInFlight ||
769
+ state.localInstallInFlight ||
770
+ state.localCredentialRotationInFlight ||
771
+ getPendingLocalCredentialRotation(state)
772
+ ) {
437
773
  throw Object.assign(
438
- new Error("A local Codex sign-in start is already in progress."),
774
+ new Error("A local endpoint change is already in progress."),
439
775
  { statusCode: 409 },
440
776
  );
441
777
  }
442
778
 
443
779
  state.codexLoginStartInFlight = true;
780
+ let finishStart;
781
+ const startPromise = new Promise((resolvePromise) => {
782
+ finishStart = resolvePromise;
783
+ });
784
+ state.codexLoginStartPromise = startPromise;
785
+ let releaseChangeLock;
786
+ let lockTransferred = false;
444
787
  try {
445
- const installDirectory = await state.services.resolveLocalInstallRoot({
446
- target: "codex-chatgpt",
447
- });
448
- const { dockerHost, projectName } =
449
- await state.services.attestLocalCodexInstallation({
450
- installDirectory,
451
- });
452
-
453
788
  const previous = state.codexLogin;
454
789
  if (previous?.status === "pending") {
455
790
  state.codexLogin = null;
@@ -460,26 +795,61 @@ async function handleApi(request, response, path, state) {
460
795
  // A fresh attempt intentionally supersedes the old device-code login.
461
796
  }
462
797
  }
798
+ if (state.closing) {
799
+ throw Object.assign(new Error("The local wizard is closing."), {
800
+ statusCode: 409,
801
+ });
802
+ }
803
+
804
+ releaseChangeLock = await state.services.acquireLocalEndpointChangeLock({
805
+ target: "codex-chatgpt",
806
+ });
807
+ if (state.closing) {
808
+ throw Object.assign(new Error("The local wizard is closing."), {
809
+ statusCode: 409,
810
+ });
811
+ }
812
+ const installDirectory = await state.services.resolveLocalInstallRoot({
813
+ target: "codex-chatgpt",
814
+ });
815
+ const { dockerHost, projectName } =
816
+ await state.services.attestLocalCodexInstallation({
817
+ installDirectory,
818
+ });
463
819
 
464
820
  const attempt = await state.services.startCodexDeviceLogin({
465
821
  installDirectory,
466
822
  dockerHost,
467
823
  projectName,
468
824
  });
825
+ if (state.closing) {
826
+ attempt.cancel();
827
+ try {
828
+ await attempt.completion;
829
+ } catch {
830
+ // Shutdown intentionally cancels a helper that finished starting late.
831
+ }
832
+ throw Object.assign(new Error("The local wizard is closing."), {
833
+ statusCode: 409,
834
+ });
835
+ }
469
836
  const login = {
470
837
  cancel: attempt.cancel,
471
- completion: attempt.completion,
838
+ completion: null,
472
839
  error: null,
473
840
  status: "pending",
474
841
  };
475
842
  state.codexLogin = login;
476
- void (async () => {
843
+ login.completion = (async () => {
477
844
  try {
478
- await login.completion;
845
+ await attempt.completion;
479
846
  if (state.codexLogin !== login || state.closing) {
480
847
  return;
481
848
  }
482
- await state.services.restartLocalCodex({ installDirectory });
849
+ await state.services.restartLocalCodex(
850
+ { installDirectory },
851
+ { changeLockHeld: true },
852
+ );
483
853
  if (state.codexLogin === login && !state.closing) {
484
854
  login.status = "success";
485
855
  }
@@ -488,14 +858,24 @@ async function handleApi(request, response, path, state) {
488
858
  login.status = "error";
489
859
  login.error = safeErrorMessage(error);
490
860
  }
861
+ } finally {
862
+ await releaseChangeLock();
491
863
  }
492
864
  })();
865
+ lockTransferred = true;
493
866
  sendJson(response, 200, {
494
867
  verificationUrl: attempt.verificationUrl,
495
868
  userCode: attempt.userCode,
496
869
  });
497
870
  } finally {
871
+ if (!lockTransferred && releaseChangeLock) {
872
+ await releaseChangeLock();
873
+ }
498
874
  state.codexLoginStartInFlight = false;
875
+ finishStart();
876
+ if (state.codexLoginStartPromise === startPromise) {
877
+ state.codexLoginStartPromise = null;
878
+ }
499
879
  }
500
880
  return;
501
881
  }
@@ -670,6 +1050,7 @@ function createRequestHandler(state) {
670
1050
  if (!response.headersSent) {
671
1051
  sendJson(response, error.statusCode ?? 400, {
672
1052
  error: safeErrorMessage(error),
1053
+ ...(error.retryBlocked === true ? { retryBlocked: true } : {}),
673
1054
  });
674
1055
  } else {
675
1056
  response.end();
@@ -684,6 +1065,7 @@ export async function startWizardServer({
684
1065
  uiFiles,
685
1066
  port = 0,
686
1067
  previewMode = false,
1068
+ oauthShutdownWaitMs = OAUTH_SHUTDOWN_WAIT_MS,
687
1069
  } = {}) {
688
1070
  if (typeof sessionToken !== "string" || sessionToken.length < 32) {
689
1071
  throw new TypeError("A strong wizard session token is required.");
@@ -699,12 +1081,20 @@ export async function startWizardServer({
699
1081
  discovery: null,
700
1082
  networksByContainer: new Map(),
701
1083
  oauthLogin: null,
1084
+ oauthRetryBlocked: false,
1085
+ oauthStartupError: null,
1086
+ oauthLoginStartInFlight: false,
1087
+ oauthLoginStartPromise: null,
702
1088
  localPlan: null,
703
1089
  localInstallInFlight: false,
1090
+ localCredentialRotationInFlight: false,
1091
+ localCredentialRotationPending: null,
704
1092
  codexLogin: null,
705
1093
  codexLoginStartInFlight: false,
1094
+ codexLoginStartPromise: null,
706
1095
  rateLimits: new Map(),
707
1096
  previewMode: previewMode === true,
1097
+ oauthShutdownWaitMs,
708
1098
  closing: false,
709
1099
  };
710
1100
  const server = createServer(createRequestHandler(state));
@@ -724,13 +1114,34 @@ export async function startWizardServer({
724
1114
  origin: state.origin,
725
1115
  async close() {
726
1116
  state.closing = true;
727
- state.oauthLogin?.attempt.cancel();
1117
+ await waitForBoundedResult(
1118
+ state.oauthLoginStartPromise,
1119
+ state.oauthShutdownWaitMs,
1120
+ );
1121
+ if (state.oauthLogin?.status === "pending") {
1122
+ try {
1123
+ await cancelOAuthLogin(state, state.oauthLogin);
1124
+ } catch {
1125
+ // The bounded OAuth cancellation result must not prevent server shutdown.
1126
+ }
1127
+ }
728
1128
  const codexLogin = state.codexLogin;
729
1129
  state.codexLogin = null;
730
1130
  codexLogin?.cancel();
1131
+ await waitForBoundedResult(
1132
+ state.codexLoginStartPromise,
1133
+ state.oauthShutdownWaitMs,
1134
+ );
1135
+ await waitForBoundedResult(
1136
+ codexLogin?.completion,
1137
+ state.oauthShutdownWaitMs,
1138
+ );
731
1139
  state.connection?.close();
732
1140
  state.connection = null;
733
- await new Promise((resolve) => server.close(resolve));
1141
+ await waitForBoundedResult(
1142
+ new Promise((resolve) => server.close(resolve)),
1143
+ state.oauthShutdownWaitMs,
1144
+ );
734
1145
  },
735
1146
  };
736
1147
  }