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/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|\/Users\//iu.test(message)
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
- "/app.js": files[1],
194
- "/oauth-popup.js": files[2],
195
- "/theme.js": files[3],
196
- "/time.js": files[4],
197
- "/styles.css": files[5],
198
- "/icons/monitor.svg": files[6],
199
- "/icons/sun.svg": files[7],
200
- "/icons/moon.svg": files[8],
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
- if (state.oauthLogin?.status === "pending") {
243
- state.oauthLogin.attempt.cancel();
244
- try {
245
- await state.oauthLogin.attempt.completion;
246
- } catch {
247
- // Starting again intentionally replaces the previous local attempt.
431
+ state.oauthLoginStartInFlight = true;
432
+ state.oauthStartupError = null;
433
+ let startAttempted = false;
434
+ let startPromise;
435
+ try {
436
+ const previousLogin = state.oauthLogin;
437
+ if (previousLogin?.status === "pending") {
438
+ await cancelOAuthLogin(state, previousLogin);
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
- const attempt = await state.services.startOAuthLogin();
252
- const login = {
253
- attempt,
254
- error: null,
255
- status: "pending",
256
- };
257
- state.oauthLogin = login;
258
- attempt.completion.then(
259
- () => {
260
- if (state.oauthLogin === login) {
261
- login.status = "success";
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
- (error) => {
265
- if (state.oauthLogin === login) {
266
- login.status = "error";
267
- login.error = safeErrorMessage(error);
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
- sendJson(response, 200, { authorizationUrl: attempt.authorizationUrl });
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 === "/styles.css"
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.oauthLogin?.attempt.cancel();
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 new Promise((resolve) => server.close(resolve));
920
+ await waitForBoundedResult(
921
+ new Promise((resolve) => server.close(resolve)),
922
+ state.oauthShutdownWaitMs,
923
+ );
499
924
  },
500
925
  };
501
926
  }