relmio 0.8.1 → 0.9.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
@@ -5,6 +5,7 @@ import packageManifest from "../../package.json" with { type: "json" };
5
5
 
6
6
  import { discoverN8n, discoverNetworks } from "../services/discovery.js";
7
7
  import { installSidecar } from "../services/installer.js";
8
+ import { installAssistant } from "../services/assistant-installer.js";
8
9
  import {
9
10
  getAuthStatus,
10
11
  readAuthContents,
@@ -19,6 +20,9 @@ import {
19
20
  validatePort,
20
21
  } from "../domain/validation.js";
21
22
  import { SIDECAR_HOSTNAME } from "../domain/templates.js";
23
+ import {
24
+ ASSISTANT_ROOT,
25
+ } from "../domain/assistant.js";
22
26
  import {
23
27
  createLocalDeploymentPlan,
24
28
  validateLocalTarget,
@@ -78,6 +82,7 @@ const defaultServices = {
78
82
  discoverN8n,
79
83
  discoverNetworks,
80
84
  installSidecar,
85
+ installAssistant,
81
86
  attestLocalCodexInstallation,
82
87
  getLocalDockerStatus,
83
88
  getProjectMeta,
@@ -289,6 +294,95 @@ function requireConnection(state) {
289
294
  return state.connection;
290
295
  }
291
296
 
297
+ function rejectActiveVpsMutation(state) {
298
+ if (state.closing) {
299
+ throw Object.assign(new Error("The local wizard is closing."), {
300
+ statusCode: 409,
301
+ });
302
+ }
303
+ if (state.vpsMutationInFlight) {
304
+ throw Object.assign(
305
+ new Error("A VPS installation is already in progress. Wait for it to finish before trying another installation."),
306
+ { statusCode: 409 },
307
+ );
308
+ }
309
+ }
310
+
311
+ function acquireVpsMutationLock(state) {
312
+ rejectActiveVpsMutation(state);
313
+ const lock = Symbol("vps-mutation");
314
+ let resolveCompletion;
315
+ state.vpsMutationInFlight = true;
316
+ state.vpsMutationLock = lock;
317
+ state.vpsMutationCompletion = new Promise((resolve) => {
318
+ resolveCompletion = resolve;
319
+ });
320
+ return () => {
321
+ if (state.vpsMutationLock === lock) {
322
+ state.vpsMutationInFlight = false;
323
+ state.vpsMutationLock = null;
324
+ state.vpsMutationCompletion = null;
325
+ resolveCompletion();
326
+ }
327
+ };
328
+ }
329
+
330
+ function requireReviewedVpsPlan(plan, body, label) {
331
+ if (
332
+ !plan ||
333
+ plan.containerName !== body.containerName ||
334
+ plan.networkName !== body.networkName
335
+ ) {
336
+ throw new Error(`Review a fresh ${label} plan before installing.`);
337
+ }
338
+ }
339
+
340
+ function requireAssistantSearxngSelection(value) {
341
+ if (typeof value !== "boolean") {
342
+ throw new Error("Choose whether to include optional SearXNG web search.");
343
+ }
344
+ return value;
345
+ }
346
+
347
+ function requireEnabledInstanceAi(state, containerName) {
348
+ const instanceAi = state.networksByContainer.get(containerName)?.instanceAi;
349
+ if (instanceAi?.status === "enabled") return instanceAi;
350
+ if (instanceAi?.status === "missing" || instanceAi?.status === "configured") {
351
+ throw new Error(
352
+ "The selected n8n container needs N8N_ENABLED_MODULES to include instance-ai. Update its existing deployment separately; restart n8n outside this wizard only if you later authorize that action.",
353
+ );
354
+ }
355
+ throw new Error("The selected n8n container's AI Assistant prerequisite could not be verified.");
356
+ }
357
+
358
+ function requireReviewedAssistantPlan(state, plan, body) {
359
+ requireReviewedVpsPlan(plan, body, "AI Assistant");
360
+ if (plan.includeSearxng !== body.includeSearxng) {
361
+ throw new Error("Review a fresh AI Assistant plan for the selected web-search option.");
362
+ }
363
+ const instanceAi = requireEnabledInstanceAi(state, body.containerName);
364
+ if (plan.instanceAi?.status !== instanceAi.status) {
365
+ throw new Error("Review a fresh AI Assistant plan after prerequisite discovery.");
366
+ }
367
+ }
368
+
369
+ function requireFreshAssistantInstallState(plan, networks, body) {
370
+ if (!Array.isArray(networks?.networks) || !networks.networks.includes(body.networkName)) {
371
+ throw new Error(
372
+ "The selected Docker network changed after plan review. Review a fresh AI Assistant plan before installing.",
373
+ );
374
+ }
375
+ const instanceAi = networks.instanceAi;
376
+ if (instanceAi?.status !== "enabled") {
377
+ throw new Error(
378
+ "The selected n8n container needs N8N_ENABLED_MODULES to include instance-ai. Update its existing deployment separately; restart n8n outside this wizard only if you later authorize that action.",
379
+ );
380
+ }
381
+ if (plan.instanceAi?.status !== instanceAi.status) {
382
+ throw new Error("Review a fresh AI Assistant plan after prerequisite discovery.");
383
+ }
384
+ }
385
+
292
386
  function requireDiscoveredContainer(state, containerName) {
293
387
  const container = state.discovery?.containers.find(
294
388
  (candidate) => candidate.name === containerName,
@@ -310,7 +404,11 @@ function requireDiscoveredNetwork(state, containerName, networkName) {
310
404
 
311
405
  function safeErrorMessage(error) {
312
406
  const message =
313
- typeof error?.message === "string" ? error.message : "Request failed.";
407
+ typeof error?.safeMessage === "string"
408
+ ? error.safeMessage
409
+ : typeof error?.message === "string"
410
+ ? error.message
411
+ : "Request failed.";
314
412
  if (
315
413
  message.length > 240 ||
316
414
  /[\r\n]/u.test(message) ||
@@ -356,6 +454,9 @@ async function loadDefaultUiFiles() {
356
454
  readFile(new URL("../ui/app.js", import.meta.url), "utf8"),
357
455
  readFile(new URL("../ui/local.js", import.meta.url), "utf8"),
358
456
  readFile(new URL("../ui/oauth-popup.js", import.meta.url), "utf8"),
457
+ readFile(new URL("../ui/assistant.html", import.meta.url), "utf8"),
458
+ readFile(new URL("../ui/assistant.js", import.meta.url), "utf8"),
459
+ readFile(new URL("../ui/assistant.css", import.meta.url), "utf8"),
359
460
  readFile(new URL("../ui/theme.js", import.meta.url), "utf8"),
360
461
  readFile(new URL("../ui/time.js", import.meta.url), "utf8"),
361
462
  readFile(new URL("../ui/styles.css", import.meta.url), "utf8"),
@@ -364,6 +465,7 @@ async function loadDefaultUiFiles() {
364
465
  readFile(new URL("../ui/icons/sun.svg", import.meta.url), "utf8"),
365
466
  readFile(new URL("../ui/icons/moon.svg", import.meta.url), "utf8"),
366
467
  readFile(new URL("../ui/relmio-icon.png", import.meta.url)),
468
+ readFile(new URL("../ui/relmio-icon-rounded.svg", import.meta.url), "utf8"),
367
469
  ]);
368
470
 
369
471
  return {
@@ -372,14 +474,18 @@ async function loadDefaultUiFiles() {
372
474
  "/app.js": files[2],
373
475
  "/local.js": files[3],
374
476
  "/oauth-popup.js": files[4],
375
- "/theme.js": files[5],
376
- "/time.js": files[6],
377
- "/styles.css": files[7],
378
- "/local.css": files[8],
379
- "/icons/monitor.svg": files[9],
380
- "/icons/sun.svg": files[10],
381
- "/icons/moon.svg": files[11],
382
- "/relmio-icon.png": files[12],
477
+ "/assistant": files[5],
478
+ "/assistant.js": files[6],
479
+ "/assistant.css": files[7],
480
+ "/theme.js": files[8],
481
+ "/time.js": files[9],
482
+ "/styles.css": files[10],
483
+ "/local.css": files[11],
484
+ "/icons/monitor.svg": files[12],
485
+ "/icons/sun.svg": files[13],
486
+ "/icons/moon.svg": files[14],
487
+ "/relmio-icon.png": files[15],
488
+ "/relmio-icon-rounded.svg": files[16],
383
489
  };
384
490
  }
385
491
 
@@ -1087,6 +1193,7 @@ async function handleApi(request, response, path, state) {
1087
1193
  }
1088
1194
 
1089
1195
  if (path === "/api/ssh/fingerprint") {
1196
+ rejectActiveVpsMutation(state);
1090
1197
  enforceRateLimit(state, path);
1091
1198
  const host = validateHostname(body.host);
1092
1199
  const port = validatePort(body.port);
@@ -1094,12 +1201,14 @@ async function handleApi(request, response, path, state) {
1094
1201
  host,
1095
1202
  port,
1096
1203
  });
1204
+ rejectActiveVpsMutation(state);
1097
1205
  state.scannedHost = { host, port, fingerprint };
1098
1206
  sendJson(response, 200, { fingerprint });
1099
1207
  return;
1100
1208
  }
1101
1209
 
1102
1210
  if (path === "/api/ssh/connect") {
1211
+ rejectActiveVpsMutation(state);
1103
1212
  enforceRateLimit(state, path);
1104
1213
  const host = validateHostname(body.host);
1105
1214
  const port = validatePort(body.port);
@@ -1118,8 +1227,9 @@ async function handleApi(request, response, path, state) {
1118
1227
  state.connection?.close();
1119
1228
  state.connection = null;
1120
1229
 
1230
+ let candidateConnection = null;
1121
1231
  try {
1122
- state.connection = await state.services.connectVerified({
1232
+ candidateConnection = await state.services.connectVerified({
1123
1233
  host,
1124
1234
  port,
1125
1235
  username: body.username,
@@ -1127,43 +1237,67 @@ async function handleApi(request, response, path, state) {
1127
1237
  agent: body.useAgent ? process.env.SSH_AUTH_SOCK : undefined,
1128
1238
  expectedFingerprint: scannedHost.fingerprint,
1129
1239
  });
1240
+ rejectActiveVpsMutation(state);
1241
+ state.connection = candidateConnection;
1242
+ candidateConnection = null;
1130
1243
  } finally {
1131
1244
  body.password = undefined;
1245
+ try {
1246
+ candidateConnection?.close();
1247
+ } catch {
1248
+ // A stale connection must never become the shared VPS connection.
1249
+ }
1132
1250
  }
1133
1251
 
1134
1252
  state.scannedHost = null;
1135
1253
  state.discovery = null;
1136
1254
  state.networksByContainer.clear();
1255
+ state.sidecarPlan = null;
1256
+ state.assistantPlan = null;
1137
1257
  sendJson(response, 200, { connected: true });
1138
1258
  return;
1139
1259
  }
1140
1260
 
1141
1261
  if (path === "/api/discover") {
1262
+ rejectActiveVpsMutation(state);
1142
1263
  const connection = requireConnection(state);
1143
- state.discovery = await state.services.discoverN8n(connection);
1264
+ const discovery = await state.services.discoverN8n(connection);
1265
+ rejectActiveVpsMutation(state);
1266
+ state.discovery = discovery;
1144
1267
  state.networksByContainer.clear();
1268
+ state.sidecarPlan = null;
1269
+ state.assistantPlan = null;
1145
1270
  sendJson(response, 200, state.discovery);
1146
1271
  return;
1147
1272
  }
1148
1273
 
1149
1274
  if (path === "/api/networks") {
1275
+ rejectActiveVpsMutation(state);
1150
1276
  const connection = requireConnection(state);
1151
1277
  requireDiscoveredContainer(state, body.containerName);
1152
1278
  const networks = await state.services.discoverNetworks(
1153
1279
  connection,
1154
1280
  body.containerName,
1155
1281
  );
1282
+ rejectActiveVpsMutation(state);
1156
1283
  state.networksByContainer.set(body.containerName, networks);
1284
+ state.sidecarPlan = null;
1285
+ state.assistantPlan = null;
1157
1286
  sendJson(response, 200, networks);
1158
1287
  return;
1159
1288
  }
1160
1289
 
1161
1290
  if (path === "/api/plan") {
1291
+ rejectActiveVpsMutation(state);
1162
1292
  requireDiscoveredNetwork(
1163
1293
  state,
1164
1294
  body.containerName,
1165
1295
  body.networkName,
1166
1296
  );
1297
+ state.sidecarPlan = {
1298
+ containerName: body.containerName,
1299
+ networkName: body.networkName,
1300
+ };
1167
1301
  sendJson(response, 200, {
1168
1302
  installDirectory: "/docker/n8n-openai-oauth",
1169
1303
  sidecarProject: "n8n-openai-oauth",
@@ -1176,16 +1310,93 @@ async function handleApi(request, response, path, state) {
1176
1310
  return;
1177
1311
  }
1178
1312
 
1179
- if (path === "/api/install") {
1313
+ if (path === "/api/assistant/plan") {
1314
+ rejectActiveVpsMutation(state);
1315
+ const includeSearxng = requireAssistantSearxngSelection(body.includeSearxng);
1316
+ requireDiscoveredNetwork(
1317
+ state,
1318
+ body.containerName,
1319
+ body.networkName,
1320
+ );
1321
+ const instanceAi = requireEnabledInstanceAi(state, body.containerName);
1322
+ state.assistantPlan = {
1323
+ containerName: body.containerName,
1324
+ networkName: body.networkName,
1325
+ includeSearxng,
1326
+ instanceAi,
1327
+ };
1328
+ sendJson(response, 200, {
1329
+ installDirectory: ASSISTANT_ROOT,
1330
+ companionProject: "generated ownership-bound project after confirmation",
1331
+ sandboxUrl: "generated after verified installation",
1332
+ includeSearxng,
1333
+ webSearch: includeSearxng ? "enabled" : "disabled",
1334
+ ...(includeSearxng ? { searxngUrl: "generated after verified installation" } : {}),
1335
+ networkName: body.networkName,
1336
+ instanceAi,
1337
+ existingN8nChanges: [],
1338
+ existingN8nRestarts: 0,
1339
+ publishedPorts: [],
1340
+ privilegedRunner: true,
1341
+ preview: true,
1342
+ });
1343
+ return;
1344
+ }
1345
+
1346
+ if (path === "/api/assistant/install") {
1347
+ rejectActiveVpsMutation(state);
1348
+ enforceRateLimit(state, path);
1349
+ const includeSearxng = requireAssistantSearxngSelection(body.includeSearxng);
1350
+ requireDiscoveredNetwork(
1351
+ state,
1352
+ body.containerName,
1353
+ body.networkName,
1354
+ );
1355
+ const reviewedPlan = state.assistantPlan;
1356
+ requireReviewedAssistantPlan(state, reviewedPlan, body);
1180
1357
  const connection = requireConnection(state);
1181
- let result;
1358
+ state.assistantPlan = null;
1359
+ const releaseVpsMutationLock = acquireVpsMutationLock(state);
1182
1360
  try {
1183
- enforceRateLimit(state, path);
1184
- requireDiscoveredNetwork(
1185
- state,
1361
+ const freshNetworks = await state.services.discoverNetworks(
1362
+ connection,
1186
1363
  body.containerName,
1187
- body.networkName,
1188
1364
  );
1365
+ requireFreshAssistantInstallState(reviewedPlan, freshNetworks, body);
1366
+ const result = await state.services.installAssistant({
1367
+ remote: connection,
1368
+ networkName: body.networkName,
1369
+ confirmed: body.confirmed,
1370
+ includeSearxng,
1371
+ });
1372
+ sendJson(response, 200, result);
1373
+ } finally {
1374
+ try {
1375
+ connection.close();
1376
+ if (state.connection === connection) {
1377
+ state.connection = null;
1378
+ }
1379
+ } finally {
1380
+ releaseVpsMutationLock();
1381
+ }
1382
+ }
1383
+ return;
1384
+ }
1385
+
1386
+ if (path === "/api/install") {
1387
+ rejectActiveVpsMutation(state);
1388
+ enforceRateLimit(state, path);
1389
+ requireDiscoveredNetwork(
1390
+ state,
1391
+ body.containerName,
1392
+ body.networkName,
1393
+ );
1394
+ requireReviewedVpsPlan(state.sidecarPlan, body, "sidecar");
1395
+ const connection = requireConnection(state);
1396
+ state.sidecarPlan = null;
1397
+ const releaseVpsMutationLock = acquireVpsMutationLock(state);
1398
+ let result;
1399
+ try {
1189
1400
  const authStatus = await state.services.getAuthStatus();
1190
1401
  if (!authStatus.exists) {
1191
1402
  throw new Error("Sign in with ChatGPT before installing.");
@@ -1200,9 +1411,13 @@ async function handleApi(request, response, path, state) {
1200
1411
  confirmed: body.confirmed,
1201
1412
  });
1202
1413
  } finally {
1203
- connection.close();
1204
- if (state.connection === connection) {
1205
- state.connection = null;
1414
+ try {
1415
+ connection.close();
1416
+ if (state.connection === connection) {
1417
+ state.connection = null;
1418
+ }
1419
+ } finally {
1420
+ releaseVpsMutationLock();
1206
1421
  }
1207
1422
  }
1208
1423
 
@@ -1211,8 +1426,11 @@ async function handleApi(request, response, path, state) {
1211
1426
  }
1212
1427
 
1213
1428
  if (path === "/api/disconnect") {
1429
+ rejectActiveVpsMutation(state);
1214
1430
  state.connection?.close();
1215
1431
  state.connection = null;
1432
+ state.sidecarPlan = null;
1433
+ state.assistantPlan = null;
1216
1434
  sendJson(response, 200, { disconnected: true });
1217
1435
  return;
1218
1436
  }
@@ -1225,6 +1443,10 @@ function createRequestHandler(state) {
1225
1443
  setSecurityHeaders(response);
1226
1444
 
1227
1445
  try {
1446
+ if (state.closing) {
1447
+ sendJson(response, 503, { error: "The local wizard is closing." });
1448
+ return;
1449
+ }
1228
1450
  const url = new URL(request.url, state.origin);
1229
1451
  const path = url.pathname;
1230
1452
 
@@ -1292,6 +1514,11 @@ export async function startWizardServer({
1292
1514
  scannedHost: null,
1293
1515
  discovery: null,
1294
1516
  networksByContainer: new Map(),
1517
+ sidecarPlan: null,
1518
+ assistantPlan: null,
1519
+ vpsMutationInFlight: false,
1520
+ vpsMutationLock: null,
1521
+ vpsMutationCompletion: null,
1295
1522
  oauthLogin: null,
1296
1523
  oauthRetryBlocked: false,
1297
1524
  oauthStartupError: null,
@@ -1327,6 +1554,7 @@ export async function startWizardServer({
1327
1554
  origin: state.origin,
1328
1555
  async close() {
1329
1556
  state.closing = true;
1557
+ const serverClose = new Promise((resolve) => server.close(resolve));
1330
1558
  state.localChatTest.dispose?.();
1331
1559
  await waitForBoundedResult(
1332
1560
  state.oauthLoginStartPromise,
@@ -1350,12 +1578,15 @@ export async function startWizardServer({
1350
1578
  codexLogin?.completion,
1351
1579
  state.oauthShutdownWaitMs,
1352
1580
  );
1353
- state.connection?.close();
1354
- state.connection = null;
1355
- await waitForBoundedResult(
1356
- new Promise((resolve) => server.close(resolve)),
1581
+ const vpsMutationFinished = await waitForBoundedResult(
1582
+ state.vpsMutationCompletion,
1357
1583
  state.oauthShutdownWaitMs,
1358
1584
  );
1585
+ if (vpsMutationFinished) {
1586
+ state.connection?.close();
1587
+ state.connection = null;
1588
+ }
1589
+ await waitForBoundedResult(serverClose, state.oauthShutdownWaitMs);
1359
1590
  },
1360
1591
  };
1361
1592
  }