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/CHANGELOG.md +51 -0
- package/README.md +40 -16
- package/docs/architecture.md +21 -9
- package/docs/local-endpoints-spec.md +101 -13
- package/docs/local-endpoints.md +101 -30
- package/docs/security.md +40 -15
- package/package.json +1 -1
- package/src/domain/local-endpoints.js +187 -14
- package/src/gateway/codex-chat.js +754 -0
- package/src/services/codex-login.js +11 -3
- package/src/services/local-installer.js +859 -20
- package/src/ui/local.css +140 -2
- package/src/ui/local.html +89 -9
- package/src/ui/local.js +157 -37
- package/src/web/server.js +247 -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,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 (
|
|
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
|
|
|
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:
|
|
843
|
+
completion: null,
|
|
642
844
|
error: null,
|
|
643
845
|
status: "pending",
|
|
644
846
|
};
|
|
645
847
|
state.codexLogin = login;
|
|
646
|
-
|
|
848
|
+
login.completion = (async () => {
|
|
647
849
|
try {
|
|
648
|
-
await
|
|
850
|
+
await attempt.completion;
|
|
649
851
|
if (state.codexLogin !== login || state.closing) {
|
|
650
852
|
return;
|
|
651
853
|
}
|
|
652
|
-
await state.services.restartLocalCodex(
|
|
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(
|