relmio 0.4.1 → 0.6.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/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,13 +19,19 @@ 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
 
@@ -32,6 +39,34 @@ const MAX_BODY_BYTES = 32 * 1024;
32
39
  const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
33
40
  const RATE_LIMIT_MAX = 10;
34
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
+ }
35
70
 
36
71
  const defaultServices = {
37
72
  getAuthStatus,
@@ -44,7 +79,11 @@ const defaultServices = {
44
79
  installSidecar,
45
80
  attestLocalCodexInstallation,
46
81
  getLocalDockerStatus,
82
+ getProjectMeta,
47
83
  installLocalEndpoint,
84
+ acquireLocalEndpointChangeLock,
85
+ activateLocalClientCredentialRotation,
86
+ prepareLocalClientCredentialRotation,
48
87
  resolveLocalInstallRoot,
49
88
  restartLocalCodex,
50
89
  startCodexDeviceLogin,
@@ -268,7 +307,7 @@ async function loadDefaultUiFiles() {
268
307
 
269
308
  return {
270
309
  "/": files[0],
271
- "/local": files[1],
310
+ "/local": files[1].replaceAll("__RELMIO_PACKAGE_VERSION__", PACKAGE_VERSION),
272
311
  "/app.js": files[2],
273
312
  "/local.js": files[3],
274
313
  "/oauth-popup.js": files[4],
@@ -312,6 +351,38 @@ function createSafeLocalInstallResult(result) {
312
351
  };
313
352
  }
314
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
+
315
386
  function createSafeDockerStatus(status, previewMode) {
316
387
  if (previewMode || status?.dockerAvailable !== true) {
317
388
  return {
@@ -381,6 +452,15 @@ async function handleApi(request, response, path, state) {
381
452
  return;
382
453
  }
383
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
+
384
464
  if (
385
465
  request.method === "GET" &&
386
466
  path === "/api/local/codex/login/status"
@@ -568,9 +648,15 @@ async function handleApi(request, response, path, state) {
568
648
  try {
569
649
  requireLiveLocalAction(state, "Local endpoint installation");
570
650
  enforceRateLimit(state, path);
571
- if (state.localInstallInFlight) {
651
+ if (
652
+ state.localInstallInFlight ||
653
+ state.localCredentialRotationInFlight ||
654
+ getPendingLocalCredentialRotation(state) ||
655
+ state.codexLoginStartInFlight ||
656
+ state.codexLogin?.status === "pending"
657
+ ) {
572
658
  throw Object.assign(
573
- new Error("A local endpoint installation is already in progress."),
659
+ new Error("A local endpoint change is already in progress."),
574
660
  { statusCode: 409 },
575
661
  );
576
662
  }
@@ -600,26 +686,109 @@ async function handleApi(request, response, path, state) {
600
686
  return;
601
687
  }
602
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
+
603
764
  if (path === "/api/local/codex/login") {
604
765
  requireLiveLocalAction(state, "Local Codex sign-in");
605
766
  enforceRateLimit(state, path);
606
- if (state.codexLoginStartInFlight) {
767
+ if (
768
+ state.codexLoginStartInFlight ||
769
+ state.localInstallInFlight ||
770
+ state.localCredentialRotationInFlight ||
771
+ getPendingLocalCredentialRotation(state)
772
+ ) {
607
773
  throw Object.assign(
608
- new Error("A local Codex sign-in start is already in progress."),
774
+ new Error("A local endpoint change is already in progress."),
609
775
  { statusCode: 409 },
610
776
  );
611
777
  }
612
778
 
779
+ const target = validateLocalTarget(body?.target ?? "codex-chatgpt");
780
+ if (target !== "codex-chatgpt" && target !== "codex-chat") {
781
+ throw new Error("Choose a Codex local endpoint before signing in.");
782
+ }
613
783
  state.codexLoginStartInFlight = true;
784
+ let finishStart;
785
+ const startPromise = new Promise((resolvePromise) => {
786
+ finishStart = resolvePromise;
787
+ });
788
+ state.codexLoginStartPromise = startPromise;
789
+ let releaseChangeLock;
790
+ let lockTransferred = false;
614
791
  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
792
  const previous = state.codexLogin;
624
793
  if (previous?.status === "pending") {
625
794
  state.codexLogin = null;
@@ -630,26 +799,65 @@ async function handleApi(request, response, path, state) {
630
799
  // A fresh attempt intentionally supersedes the old device-code login.
631
800
  }
632
801
  }
802
+ if (state.closing) {
803
+ throw Object.assign(new Error("The local wizard is closing."), {
804
+ statusCode: 409,
805
+ });
806
+ }
807
+
808
+ releaseChangeLock = await state.services.acquireLocalEndpointChangeLock({
809
+ target,
810
+ });
811
+ if (state.closing) {
812
+ throw Object.assign(new Error("The local wizard is closing."), {
813
+ statusCode: 409,
814
+ });
815
+ }
816
+ const installDirectory = await state.services.resolveLocalInstallRoot({
817
+ target,
818
+ });
819
+ const { dockerHost, projectName } =
820
+ await state.services.attestLocalCodexInstallation({
821
+ installDirectory,
822
+ ...(target === "codex-chat" ? { target } : {}),
823
+ });
633
824
 
634
825
  const attempt = await state.services.startCodexDeviceLogin({
635
826
  installDirectory,
636
827
  dockerHost,
637
828
  projectName,
638
829
  });
830
+ if (state.closing) {
831
+ attempt.cancel();
832
+ try {
833
+ await attempt.completion;
834
+ } catch {
835
+ // Shutdown intentionally cancels a helper that finished starting late.
836
+ }
837
+ throw Object.assign(new Error("The local wizard is closing."), {
838
+ statusCode: 409,
839
+ });
840
+ }
639
841
  const login = {
640
842
  cancel: attempt.cancel,
641
- completion: attempt.completion,
843
+ completion: null,
642
844
  error: null,
643
845
  status: "pending",
644
846
  };
645
847
  state.codexLogin = login;
646
- void (async () => {
848
+ login.completion = (async () => {
647
849
  try {
648
- await login.completion;
850
+ await attempt.completion;
649
851
  if (state.codexLogin !== login || state.closing) {
650
852
  return;
651
853
  }
652
- await state.services.restartLocalCodex({ installDirectory });
854
+ await state.services.restartLocalCodex(
855
+ {
856
+ installDirectory,
857
+ ...(target === "codex-chat" ? { target } : {}),
858
+ },
859
+ { changeLockHeld: true },
860
+ );
653
861
  if (state.codexLogin === login && !state.closing) {
654
862
  login.status = "success";
655
863
  }
@@ -658,14 +866,24 @@ async function handleApi(request, response, path, state) {
658
866
  login.status = "error";
659
867
  login.error = safeErrorMessage(error);
660
868
  }
869
+ } finally {
870
+ await releaseChangeLock();
661
871
  }
662
872
  })();
873
+ lockTransferred = true;
663
874
  sendJson(response, 200, {
664
875
  verificationUrl: attempt.verificationUrl,
665
876
  userCode: attempt.userCode,
666
877
  });
667
878
  } finally {
879
+ if (!lockTransferred && releaseChangeLock) {
880
+ await releaseChangeLock();
881
+ }
668
882
  state.codexLoginStartInFlight = false;
883
+ finishStart();
884
+ if (state.codexLoginStartPromise === startPromise) {
885
+ state.codexLoginStartPromise = null;
886
+ }
669
887
  }
670
888
  return;
671
889
  }
@@ -877,8 +1095,11 @@ export async function startWizardServer({
877
1095
  oauthLoginStartPromise: null,
878
1096
  localPlan: null,
879
1097
  localInstallInFlight: false,
1098
+ localCredentialRotationInFlight: false,
1099
+ localCredentialRotationPending: null,
880
1100
  codexLogin: null,
881
1101
  codexLoginStartInFlight: false,
1102
+ codexLoginStartPromise: null,
882
1103
  rateLimits: new Map(),
883
1104
  previewMode: previewMode === true,
884
1105
  oauthShutdownWaitMs,
@@ -915,6 +1136,14 @@ export async function startWizardServer({
915
1136
  const codexLogin = state.codexLogin;
916
1137
  state.codexLogin = null;
917
1138
  codexLogin?.cancel();
1139
+ await waitForBoundedResult(
1140
+ state.codexLoginStartPromise,
1141
+ state.oauthShutdownWaitMs,
1142
+ );
1143
+ await waitForBoundedResult(
1144
+ codexLogin?.completion,
1145
+ state.oauthShutdownWaitMs,
1146
+ );
918
1147
  state.connection?.close();
919
1148
  state.connection = null;
920
1149
  await waitForBoundedResult(