relmio 0.3.1 → 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 +51 -0
- package/README.md +72 -10
- package/docs/architecture.md +57 -0
- package/docs/local-endpoints-spec.md +370 -0
- package/docs/local-endpoints.md +374 -0
- package/docs/npm-publish.md +121 -201
- package/docs/security.md +92 -3
- package/docs/troubleshooting.md +1 -1
- package/package.json +2 -2
- package/src/domain/local-endpoints.js +460 -0
- package/src/gateway/openai.js +834 -0
- package/src/infrastructure/local-process.js +375 -0
- package/src/services/codex-login.js +711 -0
- package/src/services/local-installer.js +1120 -0
- package/src/services/oauth.js +323 -28
- package/src/ui/app.js +137 -6
- package/src/ui/index.html +16 -0
- package/src/ui/local.css +262 -0
- package/src/ui/local.html +442 -0
- package/src/ui/local.js +550 -0
- package/src/web/server.js +466 -41
package/src/web/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
|
|
@@ -18,10 +18,20 @@ import {
|
|
|
18
18
|
validatePort,
|
|
19
19
|
} from "../domain/validation.js";
|
|
20
20
|
import { SIDECAR_HOSTNAME } from "../domain/templates.js";
|
|
21
|
+
import { createLocalDeploymentPlan } from "../domain/local-endpoints.js";
|
|
22
|
+
import {
|
|
23
|
+
attestLocalCodexInstallation,
|
|
24
|
+
getLocalDockerStatus,
|
|
25
|
+
installLocalEndpoint,
|
|
26
|
+
resolveLocalInstallRoot,
|
|
27
|
+
restartLocalCodex,
|
|
28
|
+
} from "../services/local-installer.js";
|
|
29
|
+
import { startCodexDeviceLogin } from "../services/codex-login.js";
|
|
21
30
|
|
|
22
31
|
const MAX_BODY_BYTES = 32 * 1024;
|
|
23
32
|
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
24
33
|
const RATE_LIMIT_MAX = 10;
|
|
34
|
+
const OAUTH_SHUTDOWN_WAIT_MS = 2_000;
|
|
25
35
|
|
|
26
36
|
const defaultServices = {
|
|
27
37
|
getAuthStatus,
|
|
@@ -32,6 +42,12 @@ const defaultServices = {
|
|
|
32
42
|
discoverN8n,
|
|
33
43
|
discoverNetworks,
|
|
34
44
|
installSidecar,
|
|
45
|
+
attestLocalCodexInstallation,
|
|
46
|
+
getLocalDockerStatus,
|
|
47
|
+
installLocalEndpoint,
|
|
48
|
+
resolveLocalInstallRoot,
|
|
49
|
+
restartLocalCodex,
|
|
50
|
+
startCodexDeviceLogin,
|
|
35
51
|
};
|
|
36
52
|
|
|
37
53
|
function setSecurityHeaders(response) {
|
|
@@ -95,6 +111,37 @@ function enforceRateLimit(state, key) {
|
|
|
95
111
|
state.rateLimits.set(key, recent);
|
|
96
112
|
}
|
|
97
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
|
+
|
|
98
145
|
function readJsonBody(request) {
|
|
99
146
|
return new Promise((resolve, reject) => {
|
|
100
147
|
const chunks = [];
|
|
@@ -168,21 +215,52 @@ function safeErrorMessage(error) {
|
|
|
168
215
|
if (
|
|
169
216
|
message.length > 240 ||
|
|
170
217
|
/[\r\n]/u.test(message) ||
|
|
171
|
-
/(?:access|refresh)[_-]?token|private[_-]?key
|
|
218
|
+
/(?:access|refresh)[_-]?token|private[_-]?key|\bsk-[A-Za-z0-9_-]{8,}|\bBearer\s+\S+|\/(?:Users|home|private|tmp|var|opt|docker)\/|[A-Za-z]:\\/iu.test(
|
|
219
|
+
message,
|
|
220
|
+
)
|
|
172
221
|
) {
|
|
173
222
|
return "The request could not be completed safely.";
|
|
174
223
|
}
|
|
175
224
|
return message;
|
|
176
225
|
}
|
|
177
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
|
+
|
|
178
253
|
async function loadDefaultUiFiles() {
|
|
179
254
|
const files = await Promise.all([
|
|
180
255
|
readFile(new URL("../ui/index.html", import.meta.url), "utf8"),
|
|
256
|
+
readFile(new URL("../ui/local.html", import.meta.url), "utf8"),
|
|
181
257
|
readFile(new URL("../ui/app.js", import.meta.url), "utf8"),
|
|
258
|
+
readFile(new URL("../ui/local.js", import.meta.url), "utf8"),
|
|
182
259
|
readFile(new URL("../ui/oauth-popup.js", import.meta.url), "utf8"),
|
|
183
260
|
readFile(new URL("../ui/theme.js", import.meta.url), "utf8"),
|
|
184
261
|
readFile(new URL("../ui/time.js", import.meta.url), "utf8"),
|
|
185
262
|
readFile(new URL("../ui/styles.css", import.meta.url), "utf8"),
|
|
263
|
+
readFile(new URL("../ui/local.css", import.meta.url), "utf8"),
|
|
186
264
|
readFile(new URL("../ui/icons/monitor.svg", import.meta.url), "utf8"),
|
|
187
265
|
readFile(new URL("../ui/icons/sun.svg", import.meta.url), "utf8"),
|
|
188
266
|
readFile(new URL("../ui/icons/moon.svg", import.meta.url), "utf8"),
|
|
@@ -190,17 +268,76 @@ async function loadDefaultUiFiles() {
|
|
|
190
268
|
|
|
191
269
|
return {
|
|
192
270
|
"/": files[0],
|
|
193
|
-
"/
|
|
194
|
-
"/
|
|
195
|
-
"/
|
|
196
|
-
"/
|
|
197
|
-
"/
|
|
198
|
-
"/
|
|
199
|
-
"/
|
|
200
|
-
"/
|
|
271
|
+
"/local": files[1],
|
|
272
|
+
"/app.js": files[2],
|
|
273
|
+
"/local.js": files[3],
|
|
274
|
+
"/oauth-popup.js": files[4],
|
|
275
|
+
"/theme.js": files[5],
|
|
276
|
+
"/time.js": files[6],
|
|
277
|
+
"/styles.css": files[7],
|
|
278
|
+
"/local.css": files[8],
|
|
279
|
+
"/icons/monitor.svg": files[9],
|
|
280
|
+
"/icons/sun.svg": files[10],
|
|
281
|
+
"/icons/moon.svg": files[11],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function createSafeLocalPlan(plan) {
|
|
286
|
+
return {
|
|
287
|
+
target: plan.target,
|
|
288
|
+
label: plan.label,
|
|
289
|
+
bindHost: plan.bindHost,
|
|
290
|
+
port: plan.port,
|
|
291
|
+
endpoint: plan.endpoint,
|
|
292
|
+
protocol: plan.protocol,
|
|
293
|
+
upstreamAuth: plan.upstreamAuth,
|
|
294
|
+
allowedOrigins: [...plan.allowedOrigins],
|
|
295
|
+
browserClients: plan.browserClients,
|
|
296
|
+
experimental: plan.experimental,
|
|
297
|
+
managedPath: plan.managedPath,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function createSafeLocalInstallResult(result) {
|
|
302
|
+
return {
|
|
303
|
+
target: result.target,
|
|
304
|
+
endpoint: result.endpoint,
|
|
305
|
+
protocol: result.protocol,
|
|
306
|
+
clientCredential: result.clientCredential,
|
|
307
|
+
credentialShownOnce: result.credentialShownOnce === true,
|
|
308
|
+
models: Array.isArray(result.models) ? [...result.models] : [],
|
|
309
|
+
deploymentMode: result.deploymentMode,
|
|
310
|
+
experimental: result.experimental === true,
|
|
311
|
+
browserClients: result.browserClients === true,
|
|
201
312
|
};
|
|
202
313
|
}
|
|
203
314
|
|
|
315
|
+
function createSafeDockerStatus(status, previewMode) {
|
|
316
|
+
if (previewMode || status?.dockerAvailable !== true) {
|
|
317
|
+
return {
|
|
318
|
+
dockerAvailable: false,
|
|
319
|
+
...(previewMode ? { previewMode: true } : {}),
|
|
320
|
+
...(!previewMode && status?.unsupportedPlatform === true
|
|
321
|
+
? { unsupportedPlatform: true }
|
|
322
|
+
: {}),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
dockerAvailable: true,
|
|
327
|
+
dockerVersion: status.dockerVersion,
|
|
328
|
+
composeVersion: status.composeVersion,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function requireLiveLocalAction(state, action) {
|
|
333
|
+
if (state.previewMode) {
|
|
334
|
+
throw Object.assign(
|
|
335
|
+
new Error(`${action} is disabled in sanitized preview mode.`),
|
|
336
|
+
{ statusCode: 403 },
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
204
341
|
async function handleApi(request, response, path, state) {
|
|
205
342
|
requireApiToken(request, state);
|
|
206
343
|
requireSameOrigin(request, state);
|
|
@@ -217,9 +354,42 @@ async function handleApi(request, response, path, state) {
|
|
|
217
354
|
|
|
218
355
|
if (request.method === "GET" && path === "/api/oauth/status") {
|
|
219
356
|
const login = state.oauthLogin;
|
|
357
|
+
sendJson(response, 200, {
|
|
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
|
+
: {}),
|
|
368
|
+
});
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (request.method === "GET" && path === "/api/local/docker/status") {
|
|
373
|
+
const status = state.previewMode
|
|
374
|
+
? null
|
|
375
|
+
: await state.services.getLocalDockerStatus();
|
|
376
|
+
sendJson(
|
|
377
|
+
response,
|
|
378
|
+
200,
|
|
379
|
+
createSafeDockerStatus(status, state.previewMode),
|
|
380
|
+
);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (
|
|
385
|
+
request.method === "GET" &&
|
|
386
|
+
path === "/api/local/codex/login/status"
|
|
387
|
+
) {
|
|
388
|
+
const login = state.codexLogin;
|
|
220
389
|
sendJson(response, 200, {
|
|
221
390
|
status: login?.status ?? "idle",
|
|
222
391
|
...(login?.status === "error" ? { error: login.error } : {}),
|
|
392
|
+
...(state.previewMode ? { previewMode: true } : {}),
|
|
223
393
|
});
|
|
224
394
|
return;
|
|
225
395
|
}
|
|
@@ -238,42 +408,268 @@ async function handleApi(request, response, path, state) {
|
|
|
238
408
|
{ statusCode: 403 },
|
|
239
409
|
);
|
|
240
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
|
+
}
|
|
241
430
|
enforceRateLimit(state, path);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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);
|
|
439
|
+
}
|
|
440
|
+
|
|
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;
|
|
248
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.
|
|
496
|
+
}
|
|
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;
|
|
249
521
|
}
|
|
522
|
+
}
|
|
250
523
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
524
|
+
const body = await readJsonBody(request);
|
|
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
|
+
|
|
551
|
+
if (path === "/api/local/plan") {
|
|
552
|
+
const plan = createLocalDeploymentPlan({
|
|
553
|
+
target: body.target,
|
|
554
|
+
port: body.port,
|
|
555
|
+
allowedOrigins: body.allowedOrigins,
|
|
556
|
+
});
|
|
557
|
+
const planId = randomUUID();
|
|
558
|
+
state.localPlan = { planId, plan };
|
|
559
|
+
sendJson(response, 200, {
|
|
560
|
+
planId,
|
|
561
|
+
plan: createSafeLocalPlan(plan),
|
|
562
|
+
});
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (path === "/api/local/install") {
|
|
567
|
+
let acquiredInstallLock = false;
|
|
568
|
+
try {
|
|
569
|
+
requireLiveLocalAction(state, "Local endpoint installation");
|
|
570
|
+
enforceRateLimit(state, path);
|
|
571
|
+
if (state.localInstallInFlight) {
|
|
572
|
+
throw Object.assign(
|
|
573
|
+
new Error("A local endpoint installation is already in progress."),
|
|
574
|
+
{ statusCode: 409 },
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
const pending = state.localPlan;
|
|
578
|
+
if (!pending || !tokenMatches(body.planId, pending.planId)) {
|
|
579
|
+
throw new Error(
|
|
580
|
+
"Review a fresh local endpoint plan before installing.",
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
state.localInstallInFlight = true;
|
|
585
|
+
acquiredInstallLock = true;
|
|
586
|
+
state.localPlan = null;
|
|
587
|
+
const result = await state.services.installLocalEndpoint({
|
|
588
|
+
plan: pending.plan,
|
|
589
|
+
apiKey: body.apiKey,
|
|
590
|
+
confirmed: body.confirmed,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
sendJson(response, 200, createSafeLocalInstallResult(result));
|
|
594
|
+
} finally {
|
|
595
|
+
if (acquiredInstallLock) {
|
|
596
|
+
state.localInstallInFlight = false;
|
|
597
|
+
}
|
|
598
|
+
body.apiKey = undefined;
|
|
599
|
+
}
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (path === "/api/local/codex/login") {
|
|
604
|
+
requireLiveLocalAction(state, "Local Codex sign-in");
|
|
605
|
+
enforceRateLimit(state, path);
|
|
606
|
+
if (state.codexLoginStartInFlight) {
|
|
607
|
+
throw Object.assign(
|
|
608
|
+
new Error("A local Codex sign-in start is already in progress."),
|
|
609
|
+
{ statusCode: 409 },
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
state.codexLoginStartInFlight = true;
|
|
614
|
+
try {
|
|
615
|
+
const installDirectory = await state.services.resolveLocalInstallRoot({
|
|
616
|
+
target: "codex-chatgpt",
|
|
617
|
+
});
|
|
618
|
+
const { dockerHost, projectName } =
|
|
619
|
+
await state.services.attestLocalCodexInstallation({
|
|
620
|
+
installDirectory,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const previous = state.codexLogin;
|
|
624
|
+
if (previous?.status === "pending") {
|
|
625
|
+
state.codexLogin = null;
|
|
626
|
+
previous.cancel();
|
|
627
|
+
try {
|
|
628
|
+
await previous.completion;
|
|
629
|
+
} catch {
|
|
630
|
+
// A fresh attempt intentionally supersedes the old device-code login.
|
|
262
631
|
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const attempt = await state.services.startCodexDeviceLogin({
|
|
635
|
+
installDirectory,
|
|
636
|
+
dockerHost,
|
|
637
|
+
projectName,
|
|
638
|
+
});
|
|
639
|
+
const login = {
|
|
640
|
+
cancel: attempt.cancel,
|
|
641
|
+
completion: attempt.completion,
|
|
642
|
+
error: null,
|
|
643
|
+
status: "pending",
|
|
644
|
+
};
|
|
645
|
+
state.codexLogin = login;
|
|
646
|
+
void (async () => {
|
|
647
|
+
try {
|
|
648
|
+
await login.completion;
|
|
649
|
+
if (state.codexLogin !== login || state.closing) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
await state.services.restartLocalCodex({ installDirectory });
|
|
653
|
+
if (state.codexLogin === login && !state.closing) {
|
|
654
|
+
login.status = "success";
|
|
655
|
+
}
|
|
656
|
+
} catch (error) {
|
|
657
|
+
if (state.codexLogin === login && !state.closing) {
|
|
658
|
+
login.status = "error";
|
|
659
|
+
login.error = safeErrorMessage(error);
|
|
660
|
+
}
|
|
268
661
|
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
|
|
662
|
+
})();
|
|
663
|
+
sendJson(response, 200, {
|
|
664
|
+
verificationUrl: attempt.verificationUrl,
|
|
665
|
+
userCode: attempt.userCode,
|
|
666
|
+
});
|
|
667
|
+
} finally {
|
|
668
|
+
state.codexLoginStartInFlight = false;
|
|
669
|
+
}
|
|
272
670
|
return;
|
|
273
671
|
}
|
|
274
672
|
|
|
275
|
-
const body = await readJsonBody(request);
|
|
276
|
-
|
|
277
673
|
if (path === "/api/ssh/fingerprint") {
|
|
278
674
|
enforceRateLimit(state, path);
|
|
279
675
|
const host = validateHostname(body.host);
|
|
@@ -431,7 +827,7 @@ function createRequestHandler(state) {
|
|
|
431
827
|
? "text/javascript; charset=utf-8"
|
|
432
828
|
: path.endsWith(".svg")
|
|
433
829
|
? "image/svg+xml; charset=utf-8"
|
|
434
|
-
: path
|
|
830
|
+
: path.endsWith(".css")
|
|
435
831
|
? "text/css; charset=utf-8"
|
|
436
832
|
: "text/html; charset=utf-8";
|
|
437
833
|
const contents = state.uiFiles[path];
|
|
@@ -444,6 +840,7 @@ function createRequestHandler(state) {
|
|
|
444
840
|
if (!response.headersSent) {
|
|
445
841
|
sendJson(response, error.statusCode ?? 400, {
|
|
446
842
|
error: safeErrorMessage(error),
|
|
843
|
+
...(error.retryBlocked === true ? { retryBlocked: true } : {}),
|
|
447
844
|
});
|
|
448
845
|
} else {
|
|
449
846
|
response.end();
|
|
@@ -458,6 +855,7 @@ export async function startWizardServer({
|
|
|
458
855
|
uiFiles,
|
|
459
856
|
port = 0,
|
|
460
857
|
previewMode = false,
|
|
858
|
+
oauthShutdownWaitMs = OAUTH_SHUTDOWN_WAIT_MS,
|
|
461
859
|
} = {}) {
|
|
462
860
|
if (typeof sessionToken !== "string" || sessionToken.length < 32) {
|
|
463
861
|
throw new TypeError("A strong wizard session token is required.");
|
|
@@ -465,7 +863,7 @@ export async function startWizardServer({
|
|
|
465
863
|
|
|
466
864
|
const state = {
|
|
467
865
|
sessionToken,
|
|
468
|
-
services,
|
|
866
|
+
services: { ...defaultServices, ...services },
|
|
469
867
|
uiFiles: uiFiles ?? (await loadDefaultUiFiles()),
|
|
470
868
|
origin: "http://127.0.0.1",
|
|
471
869
|
connection: null,
|
|
@@ -473,8 +871,18 @@ export async function startWizardServer({
|
|
|
473
871
|
discovery: null,
|
|
474
872
|
networksByContainer: new Map(),
|
|
475
873
|
oauthLogin: null,
|
|
874
|
+
oauthRetryBlocked: false,
|
|
875
|
+
oauthStartupError: null,
|
|
876
|
+
oauthLoginStartInFlight: false,
|
|
877
|
+
oauthLoginStartPromise: null,
|
|
878
|
+
localPlan: null,
|
|
879
|
+
localInstallInFlight: false,
|
|
880
|
+
codexLogin: null,
|
|
881
|
+
codexLoginStartInFlight: false,
|
|
476
882
|
rateLimits: new Map(),
|
|
477
883
|
previewMode: previewMode === true,
|
|
884
|
+
oauthShutdownWaitMs,
|
|
885
|
+
closing: false,
|
|
478
886
|
};
|
|
479
887
|
const server = createServer(createRequestHandler(state));
|
|
480
888
|
server.requestTimeout = 330_000;
|
|
@@ -492,10 +900,27 @@ export async function startWizardServer({
|
|
|
492
900
|
return {
|
|
493
901
|
origin: state.origin,
|
|
494
902
|
async close() {
|
|
495
|
-
state.
|
|
903
|
+
state.closing = true;
|
|
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
|
+
}
|
|
915
|
+
const codexLogin = state.codexLogin;
|
|
916
|
+
state.codexLogin = null;
|
|
917
|
+
codexLogin?.cancel();
|
|
496
918
|
state.connection?.close();
|
|
497
919
|
state.connection = null;
|
|
498
|
-
await
|
|
920
|
+
await waitForBoundedResult(
|
|
921
|
+
new Promise((resolve) => server.close(resolve)),
|
|
922
|
+
state.oauthShutdownWaitMs,
|
|
923
|
+
);
|
|
499
924
|
},
|
|
500
925
|
};
|
|
501
926
|
}
|