relmio 0.3.0 → 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/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|\/Users\//iu.test(message)
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
- "/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],
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 === "/styles.css"
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));