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 +19 -0
- package/README.md +5 -0
- package/docs/troubleshooting.md +1 -1
- package/package.json +1 -1
- package/src/services/oauth.js +323 -28
- package/src/ui/app.js +133 -6
- package/src/ui/index.html +3 -0
- package/src/web/server.js +222 -32
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>
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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. |
|
|
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
package/src/services/oauth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
350
|
-
|
|
351
|
-
|
|
362
|
+
const loginUrlTimeout = createTimer(() => {
|
|
363
|
+
const error = new Error(
|
|
364
|
+
"The sign-in command did not provide a fresh login link.",
|
|
352
365
|
);
|
|
353
|
-
|
|
366
|
+
settleAuthorizationUrl(error);
|
|
367
|
+
void requestCancellation(error.message).catch(() => {});
|
|
354
368
|
}, LOGIN_URL_TIMEOUT_MS);
|
|
355
369
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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 =
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
725
|
+
clearTimer(loginUrlTimeout);
|
|
439
726
|
return {
|
|
440
727
|
authorizationUrl,
|
|
441
728
|
completion,
|
|
442
729
|
cancel() {
|
|
443
|
-
|
|
730
|
+
return requestCancellation();
|
|
444
731
|
},
|
|
445
732
|
};
|
|
446
733
|
} catch (error) {
|
|
447
|
-
|
|
448
|
-
|
|
734
|
+
clearTimer(loginUrlTimeout);
|
|
735
|
+
let retryBlocked = false;
|
|
449
736
|
try {
|
|
450
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
+
if (state.oauthLoginGeneration === loginGeneration) {
|
|
559
|
+
if (error.oauthRetryBlocked === true) {
|
|
560
|
+
blockOAuthRetry();
|
|
561
|
+
}
|
|
562
|
+
showError(error);
|
|
563
|
+
}
|
|
489
564
|
} finally {
|
|
490
|
-
|
|
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
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
(
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
920
|
+
await waitForBoundedResult(
|
|
921
|
+
new Promise((resolve) => server.close(resolve)),
|
|
922
|
+
state.oauthShutdownWaitMs,
|
|
923
|
+
);
|
|
734
924
|
},
|
|
735
925
|
};
|
|
736
926
|
}
|