relmio 0.4.0 → 0.4.1

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/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ checks the registry separately after publication.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.4.1] - 2026-08-14
11
+
12
+ ### Changed
13
+
14
+ - Add a manual **Stop sign-in** action while a fresh ChatGPT login is pending,
15
+ then detect and reject results from superseded wizard attempts.
16
+
17
+ ### Fixed
18
+
19
+ - Terminate the OAuth helper process tree when sign-in is stopped or Relmio
20
+ exits, preventing a rejected or abandoned attempt from continuing to hold
21
+ the `localhost:1455` callback port.
22
+
23
+ ### Security
24
+
25
+ - Fail closed when OAuth process cleanup or credential promotion cannot be
26
+ confirmed, blocking another login until Relmio restarts instead of risking
27
+ an ambiguous helper or credential state.
28
+
10
29
  ## [0.4.0] - 2026-08-13
11
30
 
12
31
  ### Added
package/README.md CHANGED
@@ -78,6 +78,11 @@ the private VPS/n8n sidecar and the separate local Docker endpoint paths.
78
78
  > OAuth extension captures that callback, temporarily disable it during local sign-in,
79
79
  > complete the wizard sign-in, and then re-enable it.
80
80
 
81
+ While a fresh local login is pending, **Stop sign-in** terminates the helper
82
+ Relmio started before allowing another attempt. Results from older tabs are
83
+ rejected after a replacement starts. If cleanup cannot be confirmed, retry
84
+ remains disabled; close the wizard and OAuth helper, then restart Relmio.
85
+
81
86
  <figure>
82
87
  <img src="https://cdn.jsdelivr.net/npm/relmio@latest/docs/images/examples/hosted-chat-connected.png" alt="Relmio hosted chat showing a connected ChatGPT session and a ready prompt field" width="720">
83
88
  <figcaption>Successful hosted-chat state: the browser extension completed the OAuth handoff and Relmio shows the ChatGPT session as connected.</figcaption>
@@ -146,7 +146,7 @@ bypassed.
146
146
  | `ChatGPT sign-in did not finish` appears immediately when refreshing an existing credential | Wizard versions through `0.1.3` attempted to reuse `~/.codex/auth.json`, but the bridge CLI requires an interactive terminal before replacing that file. | Update to `0.1.4` or newer. The wizard signs in through its own new credential file and leaves the Codex app credential untouched. |
147
147
  | The wizard keeps showing `Waiting for browser sign-in` after approval | Older versions waited for the OAuth helper process to close even after its credential file was ready. | Update to `0.1.5` or newer. Confirm the new **Credential updated** time appears before continuing. |
148
148
  | **Credential updated** still shows the old time | The callback reached an old/expired tab, was intercepted, or a different wizard session is open. | Close every old OAuth and wizard tab. Keep one current wizard open, select **Refresh ChatGPT sign-in**, and complete only the newly opened page. |
149
- | The fresh login cannot bind `localhost:1455` or reports the address is in use | Another OAuth helper or extension process already owns the local callback port. | Close other OAuth login tools and stale wizard processes, then retry. On macOS/Linux, inspect without killing anything using `lsof -nP -iTCP:1455 -sTCP:LISTEN`. |
149
+ | The fresh login cannot bind `localhost:1455` or reports the address is in use | Another OAuth helper or extension process already owns the local callback port. | In the active wizard, use **Stop sign-in** before starting another attempt. If retry remains disabled because cleanup could not be confirmed, close the wizard and OAuth helper, confirm the port is free, and restart Relmio. On macOS/Linux, inspect without killing anything using `lsof -nP -iTCP:1455 -sTCP:LISTEN`. |
150
150
  | `SSH connection failed. Check the address, password, firewall, and confirmed fingerprint.` | The TCP connection, password authentication, or confirmed host identity did not succeed. | Copy the full address and port from the provider, confirm root password login is enabled, check the provider firewall, rescan and compare the fingerprint, then test `ssh -p 22 root@YOUR_VPS_IP` from the same computer. |
151
151
  | The VPS accepts only an SSH key or passkey | The current wizard supports live password authentication, not SSH keys. | Use the manual installation path or a provider-approved password-authenticated administrator account. Do not weaken SSH security or upload a private key into the wizard. |
152
152
  | The SSH fingerprint changed | The server was rebuilt, its host keys changed, or the connection may be reaching a different host. | Stop. Verify the address and the new fingerprint through the VPS provider console before confirming it. Never bypass the comparison. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relmio",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Install private local OpenAI API and Codex endpoints with explicit provider credential boundaries, plus the existing isolated n8n sidecar.",
5
5
  "keywords": [
6
6
  "relmio",
@@ -10,6 +10,10 @@ const LOGIN_URL_TIMEOUT_MS = 15_000;
10
10
  const LOGIN_TIMEOUT_MS = 300_000;
11
11
  const PROCESS_TIMEOUT_MS = LOGIN_TIMEOUT_MS + 15_000;
12
12
  const CREDENTIAL_POLL_INTERVAL_MS = 100;
13
+ const PROCESS_TERMINATION_GRACE_MS = 1_000;
14
+ const PROCESS_TERMINATION_FORCE_WAIT_MS = 1_000;
15
+ const TERMINATION_UNCONFIRMED_MESSAGE =
16
+ "ChatGPT sign-in could not be stopped safely. Close the sign-in helper, then restart Relmio.";
13
17
  const LOGIN_URL_PREFIX = "OpenAI OAuth login URL: ";
14
18
  const OPENAI_AUTH_ORIGIN = "https://auth.openai.com";
15
19
  const SUPPORTED_LOOPBACK_REDIRECT_HOSTNAMES = new Set([
@@ -206,6 +210,11 @@ export async function startOAuthLogin({
206
210
  spawnProcess = spawn,
207
211
  createPendingId = randomUUID,
208
212
  waitForCredentialPoll = wait,
213
+ killProcess = process.kill,
214
+ terminationGraceMs = PROCESS_TERMINATION_GRACE_MS,
215
+ terminationForceWaitMs = PROCESS_TERMINATION_FORCE_WAIT_MS,
216
+ createTimer = setTimeout,
217
+ clearTimer = clearTimeout,
209
218
  } = {}) {
210
219
  const npxInvocation = createNpxInvocation({ platform, env, execPath });
211
220
  const authPath = resolveAuthPath({ env, homeDirectory });
@@ -241,6 +250,7 @@ export async function startOAuthLogin({
241
250
  shell: false,
242
251
  stdio: ["ignore", "pipe", "pipe"],
243
252
  windowsHide: true,
253
+ ...(platform === "win32" ? {} : { detached: true }),
244
254
  },
245
255
  );
246
256
  } catch (error) {
@@ -251,6 +261,7 @@ export async function startOAuthLogin({
251
261
  }
252
262
  const loginOutput = { stdout: "", stderr: "" };
253
263
  let loginOutputBytes = 0;
264
+ let cancelAttempt = () => Promise.resolve();
254
265
  let resolveAuthorizationUrl;
255
266
  let rejectAuthorizationUrl;
256
267
  let authorizationUrlSettled = false;
@@ -280,7 +291,9 @@ export async function startOAuthLogin({
280
291
  settleAuthorizationUrl(
281
292
  new Error("The sign-in command returned too much output."),
282
293
  );
283
- child.kill?.("SIGTERM");
294
+ void requestCancellation("The sign-in command returned too much output.").catch(
295
+ () => {},
296
+ );
284
297
  return;
285
298
  }
286
299
  loginOutput[stream] += output;
@@ -291,7 +304,7 @@ export async function startOAuthLogin({
291
304
  }
292
305
  } catch (error) {
293
306
  settleAuthorizationUrl(error);
294
- child.kill?.("SIGTERM");
307
+ void requestCancellation(error.message).catch(() => {});
295
308
  }
296
309
  };
297
310
 
@@ -346,24 +359,273 @@ export async function startOAuthLogin({
346
359
  settleProcessClose(null, code);
347
360
  });
348
361
 
349
- const loginUrlTimeout = setTimeout(() => {
350
- settleAuthorizationUrl(
351
- new Error("The sign-in command did not provide a fresh login link."),
362
+ const loginUrlTimeout = createTimer(() => {
363
+ const error = new Error(
364
+ "The sign-in command did not provide a fresh login link.",
352
365
  );
353
- child.kill?.("SIGTERM");
366
+ settleAuthorizationUrl(error);
367
+ void requestCancellation(error.message).catch(() => {});
354
368
  }, LOGIN_URL_TIMEOUT_MS);
355
369
 
356
- const savePendingCredential = async () => {
357
- await readAuthContents({
358
- authPath: pendingAuthPath,
359
- fileSystem,
370
+ let keepPollingForCredential = true;
371
+ let cancellationRequested = false;
372
+ let rejectCancellation;
373
+ const cancellationPromise = new Promise((_, rejectPromise) => {
374
+ rejectCancellation = rejectPromise;
375
+ });
376
+ cancellationPromise.catch(() => {});
377
+
378
+ const promotionAuthPath = `${pendingAuthPath}.ready`;
379
+ let credentialPromotion;
380
+ let promotionPhase = "idle";
381
+ const promotionCancellationWaitMs =
382
+ terminationGraceMs + terminationForceWaitMs;
383
+ const createRetryBlockedError = () =>
384
+ Object.assign(new Error(TERMINATION_UNCONFIRMED_MESSAGE), {
385
+ retryBlocked: true,
360
386
  });
361
- await fileSystem.chmod(pendingAuthPath, 0o600);
362
- await fileSystem.copyFile(pendingAuthPath, authPath);
363
- await fileSystem.chmod(authPath, 0o600);
387
+ const assertPromotionActive = () => {
388
+ if (cancellationRequested) {
389
+ throw new Error("ChatGPT sign-in did not finish. Start a fresh login.");
390
+ }
391
+ };
392
+ const savePendingCredential = () => {
393
+ if (credentialPromotion) {
394
+ return credentialPromotion;
395
+ }
396
+
397
+ credentialPromotion = (async () => {
398
+ promotionPhase = "staging";
399
+ try {
400
+ assertPromotionActive();
401
+ await readAuthContents({
402
+ authPath: pendingAuthPath,
403
+ fileSystem,
404
+ });
405
+ assertPromotionActive();
406
+ await fileSystem.chmod(pendingAuthPath, 0o600);
407
+ assertPromotionActive();
408
+ await fileSystem.copyFile(pendingAuthPath, promotionAuthPath);
409
+ assertPromotionActive();
410
+ await fileSystem.chmod(promotionAuthPath, 0o600);
411
+ assertPromotionActive();
412
+ promotionPhase = "committing";
413
+ await fileSystem.rename(promotionAuthPath, authPath);
414
+ promotionPhase = "committed";
415
+ await fileSystem.chmod(authPath, 0o600);
416
+ } catch (error) {
417
+ if (cancellationRequested && promotionPhase !== "committed") {
418
+ promotionPhase = "cancelled";
419
+ }
420
+ throw error;
421
+ }
422
+ })();
423
+ credentialPromotion.catch(() => {});
424
+ return credentialPromotion;
425
+ };
426
+
427
+ const waitForBoundedResult = (promise, milliseconds) =>
428
+ new Promise((resolvePromise) => {
429
+ let settled = false;
430
+ const timer = createTimer(() => {
431
+ if (!settled) {
432
+ settled = true;
433
+ resolvePromise(false);
434
+ }
435
+ }, milliseconds);
436
+ promise.then(
437
+ () => {
438
+ if (!settled) {
439
+ settled = true;
440
+ clearTimer(timer);
441
+ resolvePromise(true);
442
+ }
443
+ },
444
+ () => {
445
+ if (!settled) {
446
+ settled = true;
447
+ clearTimer(timer);
448
+ resolvePromise(true);
449
+ }
450
+ },
451
+ );
452
+ });
453
+
454
+ const waitForDuration = (milliseconds) =>
455
+ new Promise((resolvePromise) => {
456
+ createTimer(() => resolvePromise(true), milliseconds);
457
+ });
458
+
459
+ const waitForTaskkill = (taskkill, milliseconds) =>
460
+ new Promise((resolvePromise) => {
461
+ let settled = false;
462
+ const finish = (confirmed) => {
463
+ if (settled) {
464
+ return;
465
+ }
466
+ settled = true;
467
+ clearTimer(timer);
468
+ resolvePromise(confirmed);
469
+ };
470
+ const timer = createTimer(() => finish(false), milliseconds);
471
+ taskkill?.once?.("error", () => finish(false));
472
+ taskkill?.once?.("close", (code) => finish(code === 0));
473
+ if (!taskkill?.once) {
474
+ finish(false);
475
+ }
476
+ });
477
+
478
+ let terminationPromise;
479
+ const terminateProcessTree = () => {
480
+ if (terminationPromise) {
481
+ return terminationPromise;
482
+ }
483
+
484
+ terminationPromise = (async () => {
485
+ const hasChildPid = Number.isSafeInteger(child.pid) && child.pid > 0;
486
+
487
+ if (platform === "win32" && hasChildPid) {
488
+ const runTaskkill = async (force, timeout) => {
489
+ try {
490
+ const taskkill = spawnProcess(
491
+ "taskkill",
492
+ ["/pid", String(child.pid), "/t", ...(force ? ["/f"] : [])],
493
+ {
494
+ shell: false,
495
+ stdio: "ignore",
496
+ windowsHide: true,
497
+ },
498
+ );
499
+ return await waitForTaskkill(taskkill, timeout);
500
+ } catch {
501
+ return false;
502
+ }
503
+ };
504
+ if (
505
+ (await runTaskkill(false, terminationGraceMs)) ||
506
+ (await runTaskkill(true, terminationForceWaitMs))
507
+ ) {
508
+ return;
509
+ }
510
+ } else if (hasChildPid) {
511
+ const processGroupIsGone = () => {
512
+ try {
513
+ killProcess(-child.pid, 0);
514
+ return false;
515
+ } catch (error) {
516
+ return error?.code === "ESRCH";
517
+ }
518
+ };
519
+ try {
520
+ killProcess(-child.pid, "SIGTERM");
521
+ } catch {
522
+ // The group may have already exited between launch and cancellation.
523
+ }
524
+ if (
525
+ processGroupIsGone() ||
526
+ ((await waitForDuration(terminationGraceMs)) && processGroupIsGone())
527
+ ) {
528
+ return;
529
+ }
530
+ try {
531
+ killProcess(-child.pid, "SIGKILL");
532
+ } catch {
533
+ // A final process-group check below determines whether it is gone.
534
+ }
535
+ if (
536
+ processGroupIsGone() ||
537
+ ((await waitForDuration(terminationForceWaitMs)) &&
538
+ processGroupIsGone())
539
+ ) {
540
+ return;
541
+ }
542
+ } else {
543
+ try {
544
+ child.kill?.("SIGTERM");
545
+ } catch {
546
+ // The process may have already exited before cancellation.
547
+ }
548
+ if (
549
+ processCloseSettled ||
550
+ (await waitForBoundedResult(
551
+ processClosePromise,
552
+ terminationGraceMs,
553
+ ))
554
+ ) {
555
+ return;
556
+ }
557
+ try {
558
+ child.kill?.("SIGKILL");
559
+ } catch {
560
+ // The direct child is only a last resort when no PID is available.
561
+ }
562
+ if (
563
+ processCloseSettled ||
564
+ (await waitForBoundedResult(
565
+ processClosePromise,
566
+ terminationForceWaitMs,
567
+ ))
568
+ ) {
569
+ return;
570
+ }
571
+ }
572
+
573
+ throw createRetryBlockedError();
574
+ })();
575
+ return terminationPromise;
576
+ };
577
+
578
+ cancelAttempt = async (
579
+ message = "ChatGPT sign-in stopped. Start a fresh login.",
580
+ ) => {
581
+ if (!cancellationRequested) {
582
+ cancellationRequested = true;
583
+ keepPollingForCredential = false;
584
+ rejectCancellation(new Error(message));
585
+ }
586
+ let promotionError;
587
+ try {
588
+ if (
589
+ credentialPromotion &&
590
+ !(await waitForBoundedResult(
591
+ credentialPromotion,
592
+ promotionCancellationWaitMs,
593
+ ))
594
+ ) {
595
+ promotionError = createRetryBlockedError();
596
+ }
597
+ } catch {
598
+ // Cancellation intentionally abandons a staged but unpromoted credential.
599
+ }
600
+ if (
601
+ promotionPhase === "committing" ||
602
+ promotionPhase === "committed"
603
+ ) {
604
+ promotionError = createRetryBlockedError();
605
+ }
606
+ let terminationError;
607
+ try {
608
+ await terminateProcessTree();
609
+ } catch (error) {
610
+ terminationError = error;
611
+ }
612
+ if (promotionError) {
613
+ throw promotionError;
614
+ }
615
+ if (terminationError) {
616
+ throw terminationError;
617
+ }
618
+ };
619
+
620
+ let cancellationResult;
621
+ const requestCancellation = (message) => {
622
+ if (!cancellationResult) {
623
+ cancellationResult = cancelAttempt(message);
624
+ cancellationResult.catch(() => {});
625
+ }
626
+ return cancellationResult;
364
627
  };
365
628
 
366
- let keepPollingForCredential = true;
367
629
  const pendingCredentialPromise = (async () => {
368
630
  await authorizationUrlPromise;
369
631
  while (keepPollingForCredential) {
@@ -384,8 +646,9 @@ export async function startOAuthLogin({
384
646
 
385
647
  const completion = (async () => {
386
648
  let processTimeout;
649
+ let completedSuccessfully = false;
387
650
  try {
388
- return await Promise.race([
651
+ const result = await Promise.race([
389
652
  pendingCredentialPromise,
390
653
  processClosePromise.then(async (code) => {
391
654
  if (code !== 0) {
@@ -396,27 +659,51 @@ export async function startOAuthLogin({
396
659
  await savePendingCredential();
397
660
  return { success: true };
398
661
  }),
662
+ cancellationPromise,
399
663
  new Promise((_, rejectPromise) => {
400
- processTimeout = setTimeout(() => {
401
- child.kill?.("SIGTERM");
402
- rejectPromise(
403
- new Error("The sign-in request expired. Start a fresh login."),
664
+ processTimeout = createTimer(() => {
665
+ const error = new Error(
666
+ "The sign-in request expired. Start a fresh login.",
404
667
  );
668
+ void requestCancellation(error.message).catch(() => {});
669
+ rejectPromise(error);
405
670
  }, PROCESS_TIMEOUT_MS);
406
671
  }),
407
672
  ]);
673
+ completedSuccessfully = result.success === true;
674
+ return result;
408
675
  } finally {
409
676
  keepPollingForCredential = false;
410
- clearTimeout(processTimeout);
411
- clearTimeout(loginUrlTimeout);
412
- if (!processCloseSettled) {
413
- child.kill?.("SIGTERM");
677
+ clearTimer(processTimeout);
678
+ clearTimer(loginUrlTimeout);
679
+ try {
680
+ await credentialPromotion;
681
+ } catch {
682
+ // A cancellation can abandon an attempt-local staged credential.
683
+ }
684
+ if (cancellationResult) {
685
+ try {
686
+ await cancellationResult;
687
+ } catch (error) {
688
+ if (error?.retryBlocked === true) {
689
+ throw error;
690
+ }
691
+ }
692
+ }
693
+ const committedBeforeFailure =
694
+ !completedSuccessfully && promotionPhase === "committed";
695
+ if (!completedSuccessfully || !processCloseSettled) {
696
+ await terminateProcessTree();
414
697
  }
415
698
  try {
416
699
  await fileSystem.rm(pendingAuthPath, { force: true });
700
+ await fileSystem.rm(promotionAuthPath, { force: true });
417
701
  } catch {
418
702
  // A failed cleanup must not hide the actionable sign-in result.
419
703
  }
704
+ if (committedBeforeFailure) {
705
+ throw createRetryBlockedError();
706
+ }
420
707
  }
421
708
  })();
422
709
  completion.catch(() => {});
@@ -435,22 +722,30 @@ export async function startOAuthLogin({
435
722
  },
436
723
  ),
437
724
  ]);
438
- clearTimeout(loginUrlTimeout);
725
+ clearTimer(loginUrlTimeout);
439
726
  return {
440
727
  authorizationUrl,
441
728
  completion,
442
729
  cancel() {
443
- child.kill?.("SIGTERM");
730
+ return requestCancellation();
444
731
  },
445
732
  };
446
733
  } catch (error) {
447
- clearTimeout(loginUrlTimeout);
448
- child.kill?.("SIGTERM");
734
+ clearTimer(loginUrlTimeout);
735
+ let retryBlocked = false;
449
736
  try {
450
- await completion;
737
+ await requestCancellation(error.message);
738
+ } catch {
739
+ retryBlocked = true;
740
+ }
741
+ try {
742
+ await waitForBoundedResult(completion, promotionCancellationWaitMs);
451
743
  } catch {
452
744
  // Preserve the more specific authorization-link error.
453
745
  }
746
+ if (retryBlocked) {
747
+ error.retryBlocked = true;
748
+ }
454
749
  throw error;
455
750
  }
456
751
  }
package/src/ui/app.js CHANGED
@@ -10,6 +10,10 @@ const state = {
10
10
  discovery: null,
11
11
  networks: null,
12
12
  installAttempted: false,
13
+ oauthAttemptId: null,
14
+ oauthRetryBlocked: false,
15
+ oauthLoginGeneration: 0,
16
+ oauthLoginWindow: null,
13
17
  };
14
18
 
15
19
  const element = (id) => document.getElementById(id);
@@ -245,16 +249,63 @@ function validateAuthorizationUrl(value) {
245
249
  return url.toString();
246
250
  }
247
251
 
248
- async function waitForOAuthCompletion() {
252
+ function validateOAuthAttemptId(value) {
253
+ if (typeof value !== "string" || !/^[0-9a-f-]{8,128}$/iu.test(value)) {
254
+ throw new Error(
255
+ "The wizard returned an unexpected sign-in attempt. Start again.",
256
+ );
257
+ }
258
+ return value;
259
+ }
260
+
261
+ function setOAuthStopControlVisible(visible) {
262
+ const stopButton = element("stop-login-button");
263
+ stopButton.hidden = !visible;
264
+ stopButton.disabled = !visible;
265
+ stopButton.setAttribute("aria-busy", "false");
266
+ }
267
+
268
+ function blockOAuthRetry() {
269
+ state.oauthRetryBlocked = true;
270
+ state.oauthAttemptId = null;
271
+ state.oauthLoginWindow?.close?.();
272
+ state.oauthLoginWindow = null;
273
+ element("login-link").hidden = true;
274
+ element("login-link").removeAttribute("href");
275
+ setOAuthStopControlVisible(false);
276
+ const loginButton = element("login-button");
277
+ setBusy(loginButton, false);
278
+ loginButton.disabled = true;
279
+ }
280
+
281
+ async function waitForOAuthCompletion(expectedAttemptId) {
249
282
  for (let attempt = 0; attempt < 330; attempt += 1) {
250
283
  const result = await api("/api/oauth/status");
284
+ if (result.retryBlocked === true) {
285
+ const error = new Error(
286
+ result.error ??
287
+ "ChatGPT sign-in could not be stopped safely. Close the sign-in helper, then restart Relmio.",
288
+ );
289
+ error.oauthRetryBlocked = true;
290
+ throw error;
291
+ }
292
+ if (result.attemptId !== expectedAttemptId) {
293
+ throw new Error(
294
+ "The ChatGPT sign-in was replaced by a newer attempt. Start again.",
295
+ );
296
+ }
251
297
  if (result.status === "success") {
252
298
  return;
253
299
  }
254
300
  if (result.status === "error") {
255
- throw new Error(
301
+ const error = new Error(
256
302
  result.error ?? "ChatGPT sign-in did not finish. Start again.",
257
303
  );
304
+ error.oauthRetryBlocked = result.retryBlocked === true;
305
+ throw error;
306
+ }
307
+ if (result.status === "cancelled") {
308
+ throw new Error("ChatGPT sign-in was stopped. Start again.");
258
309
  }
259
310
  await delay(attempt < 40 ? 250 : 1_000);
260
311
  }
@@ -324,7 +375,9 @@ async function api(path, { method = "GET", body } = {}) {
324
375
  );
325
376
  }
326
377
  if (!response.ok) {
327
- throw new Error(result.error ?? "The request failed.");
378
+ const error = new Error(result.error ?? "The request failed.");
379
+ error.oauthRetryBlocked = result.retryBlocked === true;
380
+ throw error;
328
381
  }
329
382
  return result;
330
383
  }
@@ -450,13 +503,21 @@ async function discover() {
450
503
 
451
504
  element("login-button").addEventListener("click", async (event) => {
452
505
  const button = event.currentTarget;
506
+ if (state.oauthRetryBlocked) {
507
+ return;
508
+ }
453
509
  const loginLink = element("login-link");
510
+ const loginGeneration = state.oauthLoginGeneration + 1;
511
+ state.oauthLoginGeneration = loginGeneration;
512
+ state.oauthAttemptId = null;
454
513
  const loginWindow = window.open("about:blank", "_blank");
514
+ state.oauthLoginWindow = loginWindow;
455
515
  let loginWindowNavigated = false;
456
516
  prepareOAuthPopup(loginWindow);
457
517
  clearError();
458
518
  loginLink.hidden = true;
459
519
  loginLink.removeAttribute("href");
520
+ setOAuthStopControlVisible(false);
460
521
  setBusy(button, true, "Waiting for browser sign-in…");
461
522
  setMessage(
462
523
  "Preparing a fresh ChatGPT sign-in. The existing local credential will be replaced only after sign-in succeeds.",
@@ -467,6 +528,12 @@ element("login-button").addEventListener("click", async (event) => {
467
528
  body: {},
468
529
  });
469
530
  const authorizationUrl = validateAuthorizationUrl(result.authorizationUrl);
531
+ const attemptId = validateOAuthAttemptId(result.attemptId);
532
+ if (state.oauthLoginGeneration !== loginGeneration) {
533
+ return;
534
+ }
535
+ state.oauthAttemptId = attemptId;
536
+ setOAuthStopControlVisible(true);
470
537
  loginLink.href = authorizationUrl;
471
538
  loginLink.hidden = false;
472
539
  if (loginWindow) {
@@ -477,7 +544,10 @@ element("login-button").addEventListener("click", async (event) => {
477
544
  setMessage(
478
545
  "Complete the newly opened sign-in within five minutes. If no tab opened, use “Open fresh ChatGPT sign-in” below. If an OpenAI OAuth browser extension intercepts the callback, disable it temporarily and start again.",
479
546
  );
480
- await waitForOAuthCompletion();
547
+ await waitForOAuthCompletion(attemptId);
548
+ if (state.oauthLoginGeneration !== loginGeneration) {
549
+ return;
550
+ }
481
551
  loginLink.hidden = true;
482
552
  loginLink.removeAttribute("href");
483
553
  await refreshAuthStatus({ fresh: true });
@@ -485,9 +555,66 @@ element("login-button").addEventListener("click", async (event) => {
485
555
  if (loginWindow && !loginWindowNavigated) {
486
556
  loginWindow.close();
487
557
  }
488
- showError(error);
558
+ if (state.oauthLoginGeneration === loginGeneration) {
559
+ if (error.oauthRetryBlocked === true) {
560
+ blockOAuthRetry();
561
+ }
562
+ showError(error);
563
+ }
489
564
  } finally {
490
- setBusy(button, false);
565
+ if (state.oauthLoginGeneration === loginGeneration) {
566
+ state.oauthAttemptId = null;
567
+ state.oauthLoginWindow = null;
568
+ setOAuthStopControlVisible(false);
569
+ setBusy(button, false);
570
+ if (state.oauthRetryBlocked) {
571
+ button.disabled = true;
572
+ }
573
+ }
574
+ }
575
+ });
576
+
577
+ element("stop-login-button").addEventListener("click", async (event) => {
578
+ const stopButton = event.currentTarget;
579
+ const attemptId = state.oauthAttemptId;
580
+ const loginGeneration = state.oauthLoginGeneration;
581
+ if (typeof attemptId !== "string") {
582
+ return;
583
+ }
584
+ clearError();
585
+ setBusy(stopButton, true, "Stopping sign-in…");
586
+ try {
587
+ const result = await api("/api/oauth/cancel", {
588
+ method: "POST",
589
+ body: { attemptId },
590
+ });
591
+ if (
592
+ state.oauthLoginGeneration !== loginGeneration ||
593
+ result.attemptId !== attemptId ||
594
+ result.status !== "cancelled"
595
+ ) {
596
+ return;
597
+ }
598
+ state.oauthLoginGeneration += 1;
599
+ state.oauthAttemptId = null;
600
+ state.oauthLoginWindow?.close?.();
601
+ state.oauthLoginWindow = null;
602
+ element("login-link").hidden = true;
603
+ element("login-link").removeAttribute("href");
604
+ setBusy(element("login-button"), false);
605
+ setOAuthStopControlVisible(false);
606
+ setMessage("ChatGPT sign-in stopped. You can start again.");
607
+ } catch (error) {
608
+ if (state.oauthLoginGeneration === loginGeneration) {
609
+ if (error.oauthRetryBlocked === true) {
610
+ state.oauthLoginGeneration += 1;
611
+ blockOAuthRetry();
612
+ }
613
+ showError(error);
614
+ if (!state.oauthRetryBlocked) {
615
+ setBusy(stopButton, false);
616
+ }
617
+ }
491
618
  }
492
619
  });
493
620
 
package/src/ui/index.html CHANGED
@@ -203,6 +203,9 @@
203
203
  >
204
204
  Open fresh ChatGPT sign-in
205
205
  </a>
206
+ <button id="stop-login-button" class="button secondary" type="button" hidden>
207
+ Stop ChatGPT sign-in
208
+ </button>
206
209
  <button id="signin-next" class="button primary" type="button" disabled>
207
210
  Continue to VPS
208
211
  </button>
package/src/web/server.js CHANGED
@@ -31,6 +31,7 @@ import { startCodexDeviceLogin } from "../services/codex-login.js";
31
31
  const MAX_BODY_BYTES = 32 * 1024;
32
32
  const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
33
33
  const RATE_LIMIT_MAX = 10;
34
+ const OAUTH_SHUTDOWN_WAIT_MS = 2_000;
34
35
 
35
36
  const defaultServices = {
36
37
  getAuthStatus,
@@ -110,6 +111,37 @@ function enforceRateLimit(state, key) {
110
111
  state.rateLimits.set(key, recent);
111
112
  }
112
113
 
114
+ function waitForBoundedResult(promise, milliseconds) {
115
+ if (!promise) {
116
+ return Promise.resolve(true);
117
+ }
118
+ return new Promise((resolvePromise) => {
119
+ let settled = false;
120
+ const timer = setTimeout(() => {
121
+ if (!settled) {
122
+ settled = true;
123
+ resolvePromise(false);
124
+ }
125
+ }, milliseconds);
126
+ Promise.resolve(promise).then(
127
+ () => {
128
+ if (!settled) {
129
+ settled = true;
130
+ clearTimeout(timer);
131
+ resolvePromise(true);
132
+ }
133
+ },
134
+ () => {
135
+ if (!settled) {
136
+ settled = true;
137
+ clearTimeout(timer);
138
+ resolvePromise(true);
139
+ }
140
+ },
141
+ );
142
+ });
143
+ }
144
+
113
145
  function readJsonBody(request) {
114
146
  return new Promise((resolve, reject) => {
115
147
  const chunks = [];
@@ -192,6 +224,32 @@ function safeErrorMessage(error) {
192
224
  return message;
193
225
  }
194
226
 
227
+ async function cancelOAuthLogin(state, login) {
228
+ try {
229
+ await login.attempt.cancel();
230
+ } catch {
231
+ if (state.oauthLogin === login) {
232
+ login.status = "error";
233
+ login.retryBlocked = true;
234
+ login.error =
235
+ "ChatGPT sign-in could not be stopped safely. Close the sign-in helper, then restart Relmio.";
236
+ }
237
+ state.oauthRetryBlocked = true;
238
+ state.oauthStartupError =
239
+ "ChatGPT sign-in could not be stopped safely. Close the sign-in helper, then restart Relmio.";
240
+ throw Object.assign(
241
+ new Error(
242
+ "ChatGPT sign-in could not be stopped safely. Close the sign-in helper, then restart Relmio.",
243
+ ),
244
+ { retryBlocked: true, statusCode: 409 },
245
+ );
246
+ }
247
+
248
+ if (state.oauthLogin === login && login.status === "pending") {
249
+ login.status = "cancelled";
250
+ }
251
+ }
252
+
195
253
  async function loadDefaultUiFiles() {
196
254
  const files = await Promise.all([
197
255
  readFile(new URL("../ui/index.html", import.meta.url), "utf8"),
@@ -297,8 +355,16 @@ async function handleApi(request, response, path, state) {
297
355
  if (request.method === "GET" && path === "/api/oauth/status") {
298
356
  const login = state.oauthLogin;
299
357
  sendJson(response, 200, {
300
- status: login?.status ?? "idle",
301
- ...(login?.status === "error" ? { error: login.error } : {}),
358
+ status: login?.status ?? (state.oauthStartupError ? "error" : "idle"),
359
+ ...(login ? { attemptId: login.attemptId } : {}),
360
+ ...(login?.status === "error"
361
+ ? { error: login.error }
362
+ : state.oauthStartupError
363
+ ? { error: state.oauthStartupError }
364
+ : {}),
365
+ ...(state.oauthRetryBlocked || login?.retryBlocked === true
366
+ ? { retryBlocked: true }
367
+ : {}),
302
368
  });
303
369
  return;
304
370
  }
@@ -342,42 +408,146 @@ async function handleApi(request, response, path, state) {
342
408
  { statusCode: 403 },
343
409
  );
344
410
  }
411
+ if (state.closing) {
412
+ throw Object.assign(new Error("The local wizard is closing."), {
413
+ statusCode: 409,
414
+ });
415
+ }
416
+ if (state.oauthRetryBlocked || state.oauthLogin?.retryBlocked === true) {
417
+ throw Object.assign(
418
+ new Error(
419
+ "ChatGPT sign-in could not be stopped safely. Close the sign-in helper, then restart Relmio.",
420
+ ),
421
+ { retryBlocked: true, statusCode: 409 },
422
+ );
423
+ }
424
+ if (state.oauthLoginStartInFlight) {
425
+ throw Object.assign(
426
+ new Error("A ChatGPT sign-in start is already in progress."),
427
+ { statusCode: 409 },
428
+ );
429
+ }
345
430
  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.
431
+ state.oauthLoginStartInFlight = true;
432
+ state.oauthStartupError = null;
433
+ let startAttempted = false;
434
+ let startPromise;
435
+ try {
436
+ const previousLogin = state.oauthLogin;
437
+ if (previousLogin?.status === "pending") {
438
+ await cancelOAuthLogin(state, previousLogin);
352
439
  }
353
- }
354
440
 
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);
441
+ if (state.closing) {
442
+ throw Object.assign(new Error("The local wizard is closing."), {
443
+ statusCode: 409,
444
+ });
445
+ }
446
+ if (
447
+ state.oauthLogin === previousLogin &&
448
+ previousLogin?.status !== "pending" &&
449
+ previousLogin?.retryBlocked !== true
450
+ ) {
451
+ state.oauthLogin = null;
452
+ }
453
+ startAttempted = true;
454
+ startPromise = Promise.resolve(state.services.startOAuthLogin());
455
+ state.oauthLoginStartPromise = startPromise;
456
+ const attempt = await startPromise;
457
+ const login = {
458
+ attempt,
459
+ attemptId: randomUUID(),
460
+ error: null,
461
+ retryBlocked: false,
462
+ status: "pending",
463
+ };
464
+ state.oauthLogin = login;
465
+ attempt.completion.then(
466
+ () => {
467
+ if (
468
+ state.oauthLogin === login &&
469
+ login.status === "pending" &&
470
+ !state.closing
471
+ ) {
472
+ login.status = "success";
473
+ }
474
+ },
475
+ (error) => {
476
+ if (
477
+ state.oauthLogin === login &&
478
+ login.status === "pending" &&
479
+ !state.closing
480
+ ) {
481
+ login.status = "error";
482
+ login.error = safeErrorMessage(error);
483
+ if (error?.retryBlocked === true) {
484
+ login.retryBlocked = true;
485
+ state.oauthRetryBlocked = true;
486
+ state.oauthStartupError = login.error;
487
+ }
488
+ }
489
+ },
490
+ );
491
+ if (state.closing) {
492
+ try {
493
+ await cancelOAuthLogin(state, login);
494
+ } catch {
495
+ // The server is already closing and must not restart this helper.
372
496
  }
373
- },
374
- );
375
- sendJson(response, 200, { authorizationUrl: attempt.authorizationUrl });
376
- return;
497
+ throw Object.assign(new Error("The local wizard is closing."), {
498
+ statusCode: 409,
499
+ });
500
+ }
501
+ sendJson(response, 200, {
502
+ authorizationUrl: attempt.authorizationUrl,
503
+ attemptId: login.attemptId,
504
+ });
505
+ return;
506
+ } catch (error) {
507
+ const message = safeErrorMessage(error);
508
+ if (startAttempted) {
509
+ state.oauthStartupError = message;
510
+ }
511
+ if (error?.retryBlocked === true) {
512
+ state.oauthRetryBlocked = true;
513
+ state.oauthStartupError = message;
514
+ }
515
+ throw error;
516
+ } finally {
517
+ if (state.oauthLoginStartPromise === startPromise) {
518
+ state.oauthLoginStartPromise = null;
519
+ }
520
+ state.oauthLoginStartInFlight = false;
521
+ }
377
522
  }
378
523
 
379
524
  const body = await readJsonBody(request);
380
525
 
526
+ if (path === "/api/oauth/cancel") {
527
+ requireLiveLocalAction(state, "Live ChatGPT sign-in");
528
+ const login = state.oauthLogin;
529
+ if (
530
+ !login ||
531
+ login.status !== "pending" ||
532
+ !body ||
533
+ typeof body !== "object" ||
534
+ Array.isArray(body) ||
535
+ typeof body.attemptId !== "string" ||
536
+ body.attemptId !== login.attemptId
537
+ ) {
538
+ throw Object.assign(
539
+ new Error("The ChatGPT sign-in attempt has already changed. Start again."),
540
+ { statusCode: 409 },
541
+ );
542
+ }
543
+ await cancelOAuthLogin(state, login);
544
+ sendJson(response, 200, {
545
+ status: login.status,
546
+ attemptId: login.attemptId,
547
+ });
548
+ return;
549
+ }
550
+
381
551
  if (path === "/api/local/plan") {
382
552
  const plan = createLocalDeploymentPlan({
383
553
  target: body.target,
@@ -670,6 +840,7 @@ function createRequestHandler(state) {
670
840
  if (!response.headersSent) {
671
841
  sendJson(response, error.statusCode ?? 400, {
672
842
  error: safeErrorMessage(error),
843
+ ...(error.retryBlocked === true ? { retryBlocked: true } : {}),
673
844
  });
674
845
  } else {
675
846
  response.end();
@@ -684,6 +855,7 @@ export async function startWizardServer({
684
855
  uiFiles,
685
856
  port = 0,
686
857
  previewMode = false,
858
+ oauthShutdownWaitMs = OAUTH_SHUTDOWN_WAIT_MS,
687
859
  } = {}) {
688
860
  if (typeof sessionToken !== "string" || sessionToken.length < 32) {
689
861
  throw new TypeError("A strong wizard session token is required.");
@@ -699,12 +871,17 @@ export async function startWizardServer({
699
871
  discovery: null,
700
872
  networksByContainer: new Map(),
701
873
  oauthLogin: null,
874
+ oauthRetryBlocked: false,
875
+ oauthStartupError: null,
876
+ oauthLoginStartInFlight: false,
877
+ oauthLoginStartPromise: null,
702
878
  localPlan: null,
703
879
  localInstallInFlight: false,
704
880
  codexLogin: null,
705
881
  codexLoginStartInFlight: false,
706
882
  rateLimits: new Map(),
707
883
  previewMode: previewMode === true,
884
+ oauthShutdownWaitMs,
708
885
  closing: false,
709
886
  };
710
887
  const server = createServer(createRequestHandler(state));
@@ -724,13 +901,26 @@ export async function startWizardServer({
724
901
  origin: state.origin,
725
902
  async close() {
726
903
  state.closing = true;
727
- state.oauthLogin?.attempt.cancel();
904
+ await waitForBoundedResult(
905
+ state.oauthLoginStartPromise,
906
+ state.oauthShutdownWaitMs,
907
+ );
908
+ if (state.oauthLogin?.status === "pending") {
909
+ try {
910
+ await cancelOAuthLogin(state, state.oauthLogin);
911
+ } catch {
912
+ // The bounded OAuth cancellation result must not prevent server shutdown.
913
+ }
914
+ }
728
915
  const codexLogin = state.codexLogin;
729
916
  state.codexLogin = null;
730
917
  codexLogin?.cancel();
731
918
  state.connection?.close();
732
919
  state.connection = null;
733
- await new Promise((resolve) => server.close(resolve));
920
+ await waitForBoundedResult(
921
+ new Promise((resolve) => server.close(resolve)),
922
+ state.oauthShutdownWaitMs,
923
+ );
734
924
  },
735
925
  };
736
926
  }