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/CHANGELOG.md +46 -0
- package/README.md +16 -2
- package/docs/local-endpoints.md +22 -13
- package/docs/troubleshooting.md +1 -1
- package/package.json +1 -1
- package/src/domain/local-endpoints.js +4 -1
- package/src/services/local-installer.js +772 -13
- package/src/services/oauth.js +323 -28
- package/src/ui/app.js +133 -6
- package/src/ui/index.html +3 -0
- package/src/ui/local.css +140 -2
- package/src/ui/local.html +69 -1
- package/src/ui/local.js +58 -0
- package/src/web/server.js +461 -50
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
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
(
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
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:
|
|
838
|
+
completion: null,
|
|
472
839
|
error: null,
|
|
473
840
|
status: "pending",
|
|
474
841
|
};
|
|
475
842
|
state.codexLogin = login;
|
|
476
|
-
|
|
843
|
+
login.completion = (async () => {
|
|
477
844
|
try {
|
|
478
|
-
await
|
|
845
|
+
await attempt.completion;
|
|
479
846
|
if (state.codexLogin !== login || state.closing) {
|
|
480
847
|
return;
|
|
481
848
|
}
|
|
482
|
-
await state.services.restartLocalCodex(
|
|
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
|
-
|
|
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
|
|
1141
|
+
await waitForBoundedResult(
|
|
1142
|
+
new Promise((resolve) => server.close(resolve)),
|
|
1143
|
+
state.oauthShutdownWaitMs,
|
|
1144
|
+
);
|
|
734
1145
|
},
|
|
735
1146
|
};
|
|
736
1147
|
}
|