relmio 0.3.1 → 0.4.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 +32 -0
- package/README.md +67 -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/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/ui/app.js +4 -0
- package/src/ui/index.html +13 -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 +247 -12
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,6 +18,15 @@ 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;
|
|
@@ -32,6 +41,12 @@ const defaultServices = {
|
|
|
32
41
|
discoverN8n,
|
|
33
42
|
discoverNetworks,
|
|
34
43
|
installSidecar,
|
|
44
|
+
attestLocalCodexInstallation,
|
|
45
|
+
getLocalDockerStatus,
|
|
46
|
+
installLocalEndpoint,
|
|
47
|
+
resolveLocalInstallRoot,
|
|
48
|
+
restartLocalCodex,
|
|
49
|
+
startCodexDeviceLogin,
|
|
35
50
|
};
|
|
36
51
|
|
|
37
52
|
function setSecurityHeaders(response) {
|
|
@@ -168,7 +183,9 @@ function safeErrorMessage(error) {
|
|
|
168
183
|
if (
|
|
169
184
|
message.length > 240 ||
|
|
170
185
|
/[\r\n]/u.test(message) ||
|
|
171
|
-
/(?:access|refresh)[_-]?token|private[_-]?key
|
|
186
|
+
/(?: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(
|
|
187
|
+
message,
|
|
188
|
+
)
|
|
172
189
|
) {
|
|
173
190
|
return "The request could not be completed safely.";
|
|
174
191
|
}
|
|
@@ -178,11 +195,14 @@ function safeErrorMessage(error) {
|
|
|
178
195
|
async function loadDefaultUiFiles() {
|
|
179
196
|
const files = await Promise.all([
|
|
180
197
|
readFile(new URL("../ui/index.html", import.meta.url), "utf8"),
|
|
198
|
+
readFile(new URL("../ui/local.html", import.meta.url), "utf8"),
|
|
181
199
|
readFile(new URL("../ui/app.js", import.meta.url), "utf8"),
|
|
200
|
+
readFile(new URL("../ui/local.js", import.meta.url), "utf8"),
|
|
182
201
|
readFile(new URL("../ui/oauth-popup.js", import.meta.url), "utf8"),
|
|
183
202
|
readFile(new URL("../ui/theme.js", import.meta.url), "utf8"),
|
|
184
203
|
readFile(new URL("../ui/time.js", import.meta.url), "utf8"),
|
|
185
204
|
readFile(new URL("../ui/styles.css", import.meta.url), "utf8"),
|
|
205
|
+
readFile(new URL("../ui/local.css", import.meta.url), "utf8"),
|
|
186
206
|
readFile(new URL("../ui/icons/monitor.svg", import.meta.url), "utf8"),
|
|
187
207
|
readFile(new URL("../ui/icons/sun.svg", import.meta.url), "utf8"),
|
|
188
208
|
readFile(new URL("../ui/icons/moon.svg", import.meta.url), "utf8"),
|
|
@@ -190,17 +210,76 @@ async function loadDefaultUiFiles() {
|
|
|
190
210
|
|
|
191
211
|
return {
|
|
192
212
|
"/": files[0],
|
|
193
|
-
"/
|
|
194
|
-
"/
|
|
195
|
-
"/
|
|
196
|
-
"/
|
|
197
|
-
"/
|
|
198
|
-
"/
|
|
199
|
-
"/
|
|
200
|
-
"/
|
|
213
|
+
"/local": files[1],
|
|
214
|
+
"/app.js": files[2],
|
|
215
|
+
"/local.js": files[3],
|
|
216
|
+
"/oauth-popup.js": files[4],
|
|
217
|
+
"/theme.js": files[5],
|
|
218
|
+
"/time.js": files[6],
|
|
219
|
+
"/styles.css": files[7],
|
|
220
|
+
"/local.css": files[8],
|
|
221
|
+
"/icons/monitor.svg": files[9],
|
|
222
|
+
"/icons/sun.svg": files[10],
|
|
223
|
+
"/icons/moon.svg": files[11],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function createSafeLocalPlan(plan) {
|
|
228
|
+
return {
|
|
229
|
+
target: plan.target,
|
|
230
|
+
label: plan.label,
|
|
231
|
+
bindHost: plan.bindHost,
|
|
232
|
+
port: plan.port,
|
|
233
|
+
endpoint: plan.endpoint,
|
|
234
|
+
protocol: plan.protocol,
|
|
235
|
+
upstreamAuth: plan.upstreamAuth,
|
|
236
|
+
allowedOrigins: [...plan.allowedOrigins],
|
|
237
|
+
browserClients: plan.browserClients,
|
|
238
|
+
experimental: plan.experimental,
|
|
239
|
+
managedPath: plan.managedPath,
|
|
201
240
|
};
|
|
202
241
|
}
|
|
203
242
|
|
|
243
|
+
function createSafeLocalInstallResult(result) {
|
|
244
|
+
return {
|
|
245
|
+
target: result.target,
|
|
246
|
+
endpoint: result.endpoint,
|
|
247
|
+
protocol: result.protocol,
|
|
248
|
+
clientCredential: result.clientCredential,
|
|
249
|
+
credentialShownOnce: result.credentialShownOnce === true,
|
|
250
|
+
models: Array.isArray(result.models) ? [...result.models] : [],
|
|
251
|
+
deploymentMode: result.deploymentMode,
|
|
252
|
+
experimental: result.experimental === true,
|
|
253
|
+
browserClients: result.browserClients === true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function createSafeDockerStatus(status, previewMode) {
|
|
258
|
+
if (previewMode || status?.dockerAvailable !== true) {
|
|
259
|
+
return {
|
|
260
|
+
dockerAvailable: false,
|
|
261
|
+
...(previewMode ? { previewMode: true } : {}),
|
|
262
|
+
...(!previewMode && status?.unsupportedPlatform === true
|
|
263
|
+
? { unsupportedPlatform: true }
|
|
264
|
+
: {}),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
dockerAvailable: true,
|
|
269
|
+
dockerVersion: status.dockerVersion,
|
|
270
|
+
composeVersion: status.composeVersion,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function requireLiveLocalAction(state, action) {
|
|
275
|
+
if (state.previewMode) {
|
|
276
|
+
throw Object.assign(
|
|
277
|
+
new Error(`${action} is disabled in sanitized preview mode.`),
|
|
278
|
+
{ statusCode: 403 },
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
204
283
|
async function handleApi(request, response, path, state) {
|
|
205
284
|
requireApiToken(request, state);
|
|
206
285
|
requireSameOrigin(request, state);
|
|
@@ -224,6 +303,31 @@ async function handleApi(request, response, path, state) {
|
|
|
224
303
|
return;
|
|
225
304
|
}
|
|
226
305
|
|
|
306
|
+
if (request.method === "GET" && path === "/api/local/docker/status") {
|
|
307
|
+
const status = state.previewMode
|
|
308
|
+
? null
|
|
309
|
+
: await state.services.getLocalDockerStatus();
|
|
310
|
+
sendJson(
|
|
311
|
+
response,
|
|
312
|
+
200,
|
|
313
|
+
createSafeDockerStatus(status, state.previewMode),
|
|
314
|
+
);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (
|
|
319
|
+
request.method === "GET" &&
|
|
320
|
+
path === "/api/local/codex/login/status"
|
|
321
|
+
) {
|
|
322
|
+
const login = state.codexLogin;
|
|
323
|
+
sendJson(response, 200, {
|
|
324
|
+
status: login?.status ?? "idle",
|
|
325
|
+
...(login?.status === "error" ? { error: login.error } : {}),
|
|
326
|
+
...(state.previewMode ? { previewMode: true } : {}),
|
|
327
|
+
});
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
227
331
|
if (request.method !== "POST") {
|
|
228
332
|
sendJson(response, 405, { error: "Method not allowed." });
|
|
229
333
|
return;
|
|
@@ -274,6 +378,128 @@ async function handleApi(request, response, path, state) {
|
|
|
274
378
|
|
|
275
379
|
const body = await readJsonBody(request);
|
|
276
380
|
|
|
381
|
+
if (path === "/api/local/plan") {
|
|
382
|
+
const plan = createLocalDeploymentPlan({
|
|
383
|
+
target: body.target,
|
|
384
|
+
port: body.port,
|
|
385
|
+
allowedOrigins: body.allowedOrigins,
|
|
386
|
+
});
|
|
387
|
+
const planId = randomUUID();
|
|
388
|
+
state.localPlan = { planId, plan };
|
|
389
|
+
sendJson(response, 200, {
|
|
390
|
+
planId,
|
|
391
|
+
plan: createSafeLocalPlan(plan),
|
|
392
|
+
});
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (path === "/api/local/install") {
|
|
397
|
+
let acquiredInstallLock = false;
|
|
398
|
+
try {
|
|
399
|
+
requireLiveLocalAction(state, "Local endpoint installation");
|
|
400
|
+
enforceRateLimit(state, path);
|
|
401
|
+
if (state.localInstallInFlight) {
|
|
402
|
+
throw Object.assign(
|
|
403
|
+
new Error("A local endpoint installation is already in progress."),
|
|
404
|
+
{ statusCode: 409 },
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
const pending = state.localPlan;
|
|
408
|
+
if (!pending || !tokenMatches(body.planId, pending.planId)) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
"Review a fresh local endpoint plan before installing.",
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
state.localInstallInFlight = true;
|
|
415
|
+
acquiredInstallLock = true;
|
|
416
|
+
state.localPlan = null;
|
|
417
|
+
const result = await state.services.installLocalEndpoint({
|
|
418
|
+
plan: pending.plan,
|
|
419
|
+
apiKey: body.apiKey,
|
|
420
|
+
confirmed: body.confirmed,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
sendJson(response, 200, createSafeLocalInstallResult(result));
|
|
424
|
+
} finally {
|
|
425
|
+
if (acquiredInstallLock) {
|
|
426
|
+
state.localInstallInFlight = false;
|
|
427
|
+
}
|
|
428
|
+
body.apiKey = undefined;
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (path === "/api/local/codex/login") {
|
|
434
|
+
requireLiveLocalAction(state, "Local Codex sign-in");
|
|
435
|
+
enforceRateLimit(state, path);
|
|
436
|
+
if (state.codexLoginStartInFlight) {
|
|
437
|
+
throw Object.assign(
|
|
438
|
+
new Error("A local Codex sign-in start is already in progress."),
|
|
439
|
+
{ statusCode: 409 },
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
state.codexLoginStartInFlight = true;
|
|
444
|
+
try {
|
|
445
|
+
const installDirectory = await state.services.resolveLocalInstallRoot({
|
|
446
|
+
target: "codex-chatgpt",
|
|
447
|
+
});
|
|
448
|
+
const { dockerHost, projectName } =
|
|
449
|
+
await state.services.attestLocalCodexInstallation({
|
|
450
|
+
installDirectory,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const previous = state.codexLogin;
|
|
454
|
+
if (previous?.status === "pending") {
|
|
455
|
+
state.codexLogin = null;
|
|
456
|
+
previous.cancel();
|
|
457
|
+
try {
|
|
458
|
+
await previous.completion;
|
|
459
|
+
} catch {
|
|
460
|
+
// A fresh attempt intentionally supersedes the old device-code login.
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const attempt = await state.services.startCodexDeviceLogin({
|
|
465
|
+
installDirectory,
|
|
466
|
+
dockerHost,
|
|
467
|
+
projectName,
|
|
468
|
+
});
|
|
469
|
+
const login = {
|
|
470
|
+
cancel: attempt.cancel,
|
|
471
|
+
completion: attempt.completion,
|
|
472
|
+
error: null,
|
|
473
|
+
status: "pending",
|
|
474
|
+
};
|
|
475
|
+
state.codexLogin = login;
|
|
476
|
+
void (async () => {
|
|
477
|
+
try {
|
|
478
|
+
await login.completion;
|
|
479
|
+
if (state.codexLogin !== login || state.closing) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
await state.services.restartLocalCodex({ installDirectory });
|
|
483
|
+
if (state.codexLogin === login && !state.closing) {
|
|
484
|
+
login.status = "success";
|
|
485
|
+
}
|
|
486
|
+
} catch (error) {
|
|
487
|
+
if (state.codexLogin === login && !state.closing) {
|
|
488
|
+
login.status = "error";
|
|
489
|
+
login.error = safeErrorMessage(error);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
})();
|
|
493
|
+
sendJson(response, 200, {
|
|
494
|
+
verificationUrl: attempt.verificationUrl,
|
|
495
|
+
userCode: attempt.userCode,
|
|
496
|
+
});
|
|
497
|
+
} finally {
|
|
498
|
+
state.codexLoginStartInFlight = false;
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
277
503
|
if (path === "/api/ssh/fingerprint") {
|
|
278
504
|
enforceRateLimit(state, path);
|
|
279
505
|
const host = validateHostname(body.host);
|
|
@@ -431,7 +657,7 @@ function createRequestHandler(state) {
|
|
|
431
657
|
? "text/javascript; charset=utf-8"
|
|
432
658
|
: path.endsWith(".svg")
|
|
433
659
|
? "image/svg+xml; charset=utf-8"
|
|
434
|
-
: path
|
|
660
|
+
: path.endsWith(".css")
|
|
435
661
|
? "text/css; charset=utf-8"
|
|
436
662
|
: "text/html; charset=utf-8";
|
|
437
663
|
const contents = state.uiFiles[path];
|
|
@@ -465,7 +691,7 @@ export async function startWizardServer({
|
|
|
465
691
|
|
|
466
692
|
const state = {
|
|
467
693
|
sessionToken,
|
|
468
|
-
services,
|
|
694
|
+
services: { ...defaultServices, ...services },
|
|
469
695
|
uiFiles: uiFiles ?? (await loadDefaultUiFiles()),
|
|
470
696
|
origin: "http://127.0.0.1",
|
|
471
697
|
connection: null,
|
|
@@ -473,8 +699,13 @@ export async function startWizardServer({
|
|
|
473
699
|
discovery: null,
|
|
474
700
|
networksByContainer: new Map(),
|
|
475
701
|
oauthLogin: null,
|
|
702
|
+
localPlan: null,
|
|
703
|
+
localInstallInFlight: false,
|
|
704
|
+
codexLogin: null,
|
|
705
|
+
codexLoginStartInFlight: false,
|
|
476
706
|
rateLimits: new Map(),
|
|
477
707
|
previewMode: previewMode === true,
|
|
708
|
+
closing: false,
|
|
478
709
|
};
|
|
479
710
|
const server = createServer(createRequestHandler(state));
|
|
480
711
|
server.requestTimeout = 330_000;
|
|
@@ -492,7 +723,11 @@ export async function startWizardServer({
|
|
|
492
723
|
return {
|
|
493
724
|
origin: state.origin,
|
|
494
725
|
async close() {
|
|
726
|
+
state.closing = true;
|
|
495
727
|
state.oauthLogin?.attempt.cancel();
|
|
728
|
+
const codexLogin = state.codexLogin;
|
|
729
|
+
state.codexLogin = null;
|
|
730
|
+
codexLogin?.cancel();
|
|
496
731
|
state.connection?.close();
|
|
497
732
|
state.connection = null;
|
|
498
733
|
await new Promise((resolve) => server.close(resolve));
|