relmio 0.4.1 → 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 +27 -0
- package/README.md +11 -2
- package/docs/local-endpoints.md +22 -13
- package/package.json +1 -1
- package/src/domain/local-endpoints.js +4 -1
- package/src/services/local-installer.js +772 -13
- 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 +239 -18
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 (
|
|
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
|
|
659
|
+
new Error("A local endpoint change is already in progress."),
|
|
574
660
|
{ statusCode: 409 },
|
|
575
661
|
);
|
|
576
662
|
}
|
|
@@ -600,26 +686,105 @@ 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 (
|
|
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
|
|
774
|
+
new Error("A local endpoint change is already in progress."),
|
|
609
775
|
{ statusCode: 409 },
|
|
610
776
|
);
|
|
611
777
|
}
|
|
612
778
|
|
|
613
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;
|
|
614
787
|
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
788
|
const previous = state.codexLogin;
|
|
624
789
|
if (previous?.status === "pending") {
|
|
625
790
|
state.codexLogin = null;
|
|
@@ -630,26 +795,61 @@ async function handleApi(request, response, path, state) {
|
|
|
630
795
|
// A fresh attempt intentionally supersedes the old device-code login.
|
|
631
796
|
}
|
|
632
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
|
+
});
|
|
633
819
|
|
|
634
820
|
const attempt = await state.services.startCodexDeviceLogin({
|
|
635
821
|
installDirectory,
|
|
636
822
|
dockerHost,
|
|
637
823
|
projectName,
|
|
638
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
|
+
}
|
|
639
836
|
const login = {
|
|
640
837
|
cancel: attempt.cancel,
|
|
641
|
-
completion:
|
|
838
|
+
completion: null,
|
|
642
839
|
error: null,
|
|
643
840
|
status: "pending",
|
|
644
841
|
};
|
|
645
842
|
state.codexLogin = login;
|
|
646
|
-
|
|
843
|
+
login.completion = (async () => {
|
|
647
844
|
try {
|
|
648
|
-
await
|
|
845
|
+
await attempt.completion;
|
|
649
846
|
if (state.codexLogin !== login || state.closing) {
|
|
650
847
|
return;
|
|
651
848
|
}
|
|
652
|
-
await state.services.restartLocalCodex(
|
|
849
|
+
await state.services.restartLocalCodex(
|
|
850
|
+
{ installDirectory },
|
|
851
|
+
{ changeLockHeld: true },
|
|
852
|
+
);
|
|
653
853
|
if (state.codexLogin === login && !state.closing) {
|
|
654
854
|
login.status = "success";
|
|
655
855
|
}
|
|
@@ -658,14 +858,24 @@ async function handleApi(request, response, path, state) {
|
|
|
658
858
|
login.status = "error";
|
|
659
859
|
login.error = safeErrorMessage(error);
|
|
660
860
|
}
|
|
861
|
+
} finally {
|
|
862
|
+
await releaseChangeLock();
|
|
661
863
|
}
|
|
662
864
|
})();
|
|
865
|
+
lockTransferred = true;
|
|
663
866
|
sendJson(response, 200, {
|
|
664
867
|
verificationUrl: attempt.verificationUrl,
|
|
665
868
|
userCode: attempt.userCode,
|
|
666
869
|
});
|
|
667
870
|
} finally {
|
|
871
|
+
if (!lockTransferred && releaseChangeLock) {
|
|
872
|
+
await releaseChangeLock();
|
|
873
|
+
}
|
|
668
874
|
state.codexLoginStartInFlight = false;
|
|
875
|
+
finishStart();
|
|
876
|
+
if (state.codexLoginStartPromise === startPromise) {
|
|
877
|
+
state.codexLoginStartPromise = null;
|
|
878
|
+
}
|
|
669
879
|
}
|
|
670
880
|
return;
|
|
671
881
|
}
|
|
@@ -877,8 +1087,11 @@ export async function startWizardServer({
|
|
|
877
1087
|
oauthLoginStartPromise: null,
|
|
878
1088
|
localPlan: null,
|
|
879
1089
|
localInstallInFlight: false,
|
|
1090
|
+
localCredentialRotationInFlight: false,
|
|
1091
|
+
localCredentialRotationPending: null,
|
|
880
1092
|
codexLogin: null,
|
|
881
1093
|
codexLoginStartInFlight: false,
|
|
1094
|
+
codexLoginStartPromise: null,
|
|
882
1095
|
rateLimits: new Map(),
|
|
883
1096
|
previewMode: previewMode === true,
|
|
884
1097
|
oauthShutdownWaitMs,
|
|
@@ -915,6 +1128,14 @@ export async function startWizardServer({
|
|
|
915
1128
|
const codexLogin = state.codexLogin;
|
|
916
1129
|
state.codexLogin = null;
|
|
917
1130
|
codexLogin?.cancel();
|
|
1131
|
+
await waitForBoundedResult(
|
|
1132
|
+
state.codexLoginStartPromise,
|
|
1133
|
+
state.oauthShutdownWaitMs,
|
|
1134
|
+
);
|
|
1135
|
+
await waitForBoundedResult(
|
|
1136
|
+
codexLogin?.completion,
|
|
1137
|
+
state.oauthShutdownWaitMs,
|
|
1138
|
+
);
|
|
918
1139
|
state.connection?.close();
|
|
919
1140
|
state.connection = null;
|
|
920
1141
|
await waitForBoundedResult(
|