relmio 0.8.0 → 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/CHANGELOG.md +39 -0
- package/README.md +50 -0
- package/docs/ai-assistant.md +230 -0
- package/docs/brand.md +14 -13
- package/docs/images/brand/relmio-logo-rounded.svg +25 -0
- package/docs/images/brand/relmio-logo.png +0 -0
- package/docs/maintenance.md +61 -0
- package/docs/security.md +43 -0
- package/docs/vps-and-n8n.md +3 -0
- package/package.json +1 -1
- package/src/browser.js +3 -2
- package/src/cli.js +21 -8
- package/src/domain/assistant-templates.js +207 -0
- package/src/domain/assistant.js +363 -0
- package/src/domain/safety.js +4 -1
- package/src/services/assistant-installer.js +514 -0
- package/src/services/discovery.js +28 -1
- package/src/services/installer.js +13 -5
- package/src/ui/assistant.css +154 -0
- package/src/ui/assistant.html +351 -0
- package/src/ui/assistant.js +290 -0
- package/src/ui/index.html +7 -12
- package/src/ui/local.html +7 -12
- package/src/ui/relmio-icon-rounded.svg +25 -0
- package/src/ui/relmio-icon.png +0 -0
- package/src/ui/styles.css +0 -4
- package/src/web/server.js +261 -26
- package/docs/images/brand/relmio-mark.svg +0 -10
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { randomBytes as cryptoRandomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ASSISTANT_MARKER_PATH,
|
|
5
|
+
ASSISTANT_PRECHECK_COMMAND,
|
|
6
|
+
ASSISTANT_ROOT,
|
|
7
|
+
ASSISTANT_ROOT_MARKER_PATH,
|
|
8
|
+
assertAssistantOnlyCommands,
|
|
9
|
+
createAssistantDeploymentCommands,
|
|
10
|
+
createAssistantExactResourceAttestationCommands,
|
|
11
|
+
createAssistantInstallation,
|
|
12
|
+
createAssistantNetworkCollisionCommand,
|
|
13
|
+
createAssistantOwnershipAttestationCommands,
|
|
14
|
+
createAssistantVerificationCommands,
|
|
15
|
+
getAssistantContainerNames,
|
|
16
|
+
getAssistantManagedResourceNames,
|
|
17
|
+
getAssistantServiceNames,
|
|
18
|
+
parseAssistantPrecheck,
|
|
19
|
+
serializeAssistantMarker,
|
|
20
|
+
validateAssistantSearxngSelection,
|
|
21
|
+
} from "../domain/assistant.js";
|
|
22
|
+
import { SHARED_ROOT_MARKER_CONTENT } from "../domain/safety.js";
|
|
23
|
+
import {
|
|
24
|
+
createAssistantComposeFile,
|
|
25
|
+
createAssistantEnv,
|
|
26
|
+
createAssistantSecrets,
|
|
27
|
+
createSearxngSettings,
|
|
28
|
+
} from "../domain/assistant-templates.js";
|
|
29
|
+
|
|
30
|
+
const CONTAINER_ID_PATTERN = /^[a-f0-9]{12,64}$/iu;
|
|
31
|
+
|
|
32
|
+
function getExpectedRunningServices(installation) {
|
|
33
|
+
return new Set(
|
|
34
|
+
getAssistantServiceNames(installation).filter(
|
|
35
|
+
(service) => service !== "relmio-sandbox-certs",
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseJsonLines(output, label) {
|
|
41
|
+
if (typeof output !== "string" || output.trim() === "") {
|
|
42
|
+
throw new Error(label + " could not be verified.");
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(output);
|
|
46
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
47
|
+
} catch {
|
|
48
|
+
try {
|
|
49
|
+
return output.trim().split("\n").map((line) => JSON.parse(line));
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(label + " could not be verified.");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseSandboxHealth(output) {
|
|
57
|
+
const values = parseJsonLines(output, "The sandbox API health");
|
|
58
|
+
if (values.length !== 1 || values[0]?.status !== "ok") {
|
|
59
|
+
throw new Error("The sandbox API health could not be verified.");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function verifyRunningServices(output, installation) {
|
|
64
|
+
if (typeof output !== "string") {
|
|
65
|
+
throw new Error("The companion service status could not be verified.");
|
|
66
|
+
}
|
|
67
|
+
const expectedRunningServices = getExpectedRunningServices(installation);
|
|
68
|
+
const running = new Set(output.split("\n").map((value) => value.trim()).filter(Boolean));
|
|
69
|
+
if (
|
|
70
|
+
running.size !== expectedRunningServices.size ||
|
|
71
|
+
[...expectedRunningServices].some((service) => !running.has(service))
|
|
72
|
+
) {
|
|
73
|
+
throw new Error("The expected AI Assistant companion services are not running.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hasPublishedHostPort(output, installation) {
|
|
78
|
+
const services = parseJsonLines(output, "The published-port state");
|
|
79
|
+
const expectedRunningServices = getExpectedRunningServices(installation);
|
|
80
|
+
if (services.length !== expectedRunningServices.size) {
|
|
81
|
+
throw new Error("The published-port state could not be verified.");
|
|
82
|
+
}
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const service of services) {
|
|
85
|
+
if (
|
|
86
|
+
!service ||
|
|
87
|
+
typeof service.Service !== "string" ||
|
|
88
|
+
!expectedRunningServices.has(service.Service) ||
|
|
89
|
+
seen.has(service.Service) ||
|
|
90
|
+
(service.Publishers !== null && !Array.isArray(service.Publishers))
|
|
91
|
+
) {
|
|
92
|
+
throw new Error("The published-port state could not be verified.");
|
|
93
|
+
}
|
|
94
|
+
seen.add(service.Service);
|
|
95
|
+
for (const publisher of service.Publishers ?? []) {
|
|
96
|
+
if (
|
|
97
|
+
!publisher ||
|
|
98
|
+
!Number.isInteger(publisher.PublishedPort) ||
|
|
99
|
+
publisher.PublishedPort < 0 ||
|
|
100
|
+
typeof publisher.URL !== "string"
|
|
101
|
+
) {
|
|
102
|
+
throw new Error("The published-port state could not be verified.");
|
|
103
|
+
}
|
|
104
|
+
if (publisher.PublishedPort > 0 || publisher.URL.trim() !== "") {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function runOrThrow(remote, command, label) {
|
|
113
|
+
const result = await remote.exec(command);
|
|
114
|
+
if (result.code !== 0) {
|
|
115
|
+
throw new Error(label + " failed. The existing n8n deployment was not changed.");
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function attestOutput(output, installation, label) {
|
|
121
|
+
if (typeof output !== "string" || output.length > 16 * 1024) {
|
|
122
|
+
throw new Error("The AI Assistant " + label + " ownership could not be verified.");
|
|
123
|
+
}
|
|
124
|
+
let count = 0;
|
|
125
|
+
for (const line of output.split("\n").map((value) => value.trim()).filter(Boolean)) {
|
|
126
|
+
if (line !== installation.installId + "|true") {
|
|
127
|
+
throw new Error("The AI Assistant " + label + " ownership could not be verified.");
|
|
128
|
+
}
|
|
129
|
+
count += 1;
|
|
130
|
+
}
|
|
131
|
+
return count;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function attestExactResourceOutput(
|
|
135
|
+
output,
|
|
136
|
+
installation,
|
|
137
|
+
label,
|
|
138
|
+
policy,
|
|
139
|
+
{ legacyOwnedContainerCount = 0 } = {},
|
|
140
|
+
) {
|
|
141
|
+
if (typeof output !== "string" || output.length > 16 * 1024) {
|
|
142
|
+
throw new Error("The AI Assistant exact " + label + " ownership could not be verified.");
|
|
143
|
+
}
|
|
144
|
+
const names = getAssistantManagedResourceNames(installation);
|
|
145
|
+
const expectedNames = new Set(
|
|
146
|
+
label === "containers" ? names.containers : [names[label]],
|
|
147
|
+
);
|
|
148
|
+
const seen = new Set();
|
|
149
|
+
const enablingSearxng = policy === "enable-searxng";
|
|
150
|
+
const searxngContainer = getAssistantContainerNames(installation).searxng;
|
|
151
|
+
for (const line of output.split("\n").map((value) => value.trim()).filter(Boolean)) {
|
|
152
|
+
const [name, installId, managed, ...extra] = line.split("|");
|
|
153
|
+
if (
|
|
154
|
+
extra.length > 0 ||
|
|
155
|
+
!expectedNames.has(name) ||
|
|
156
|
+
seen.has(name) ||
|
|
157
|
+
typeof installId !== "string" ||
|
|
158
|
+
typeof managed !== "string"
|
|
159
|
+
) {
|
|
160
|
+
throw new Error("The AI Assistant exact " + label + " ownership could not be verified.");
|
|
161
|
+
}
|
|
162
|
+
seen.add(name);
|
|
163
|
+
if (policy === "absent") {
|
|
164
|
+
throw new Error("A predictable AI Assistant " + label + " resource name is already occupied.");
|
|
165
|
+
}
|
|
166
|
+
if (enablingSearxng && label === "containers" && name === searxngContainer) {
|
|
167
|
+
throw new Error("The optional AI Assistant SearXNG container is already occupied.");
|
|
168
|
+
}
|
|
169
|
+
if (installId !== installation.installId || managed !== "true") {
|
|
170
|
+
throw new Error("The AI Assistant exact " + label + " ownership could not be verified.");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (enablingSearxng) {
|
|
174
|
+
if (
|
|
175
|
+
label === "containers" &&
|
|
176
|
+
seen.size === 0 &&
|
|
177
|
+
legacyOwnedContainerCount >= getAssistantServiceNames({
|
|
178
|
+
...installation,
|
|
179
|
+
includeSearxng: false,
|
|
180
|
+
}).length
|
|
181
|
+
) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const requiredOwnedNames = label === "containers"
|
|
185
|
+
? names.containers.filter((name) => name !== searxngContainer)
|
|
186
|
+
: [names[label]];
|
|
187
|
+
if (requiredOwnedNames.some((name) => !seen.has(name))) {
|
|
188
|
+
throw new Error("The AI Assistant exact " + label + " ownership could not be verified.");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function attestAssistantOwnership(remote, installation, { exactResourcePolicy = "owned" } = {}) {
|
|
194
|
+
const commands = createAssistantOwnershipAttestationCommands({ installation });
|
|
195
|
+
const ownershipCounts = {};
|
|
196
|
+
for (const [label, command] of Object.entries(commands)) {
|
|
197
|
+
const result = await remote.exec(command);
|
|
198
|
+
if (result.code !== 0) {
|
|
199
|
+
throw new Error("The AI Assistant " + label + " ownership could not be verified.");
|
|
200
|
+
}
|
|
201
|
+
ownershipCounts[label] = attestOutput(result.stdout, installation, label);
|
|
202
|
+
}
|
|
203
|
+
const exactCommands = createAssistantExactResourceAttestationCommands({ installation });
|
|
204
|
+
for (const [label, command] of Object.entries(exactCommands)) {
|
|
205
|
+
const result = await remote.exec(command);
|
|
206
|
+
if (result.code !== 0) {
|
|
207
|
+
throw new Error("The AI Assistant exact " + label + " ownership could not be verified.");
|
|
208
|
+
}
|
|
209
|
+
attestExactResourceOutput(result.stdout, installation, label, exactResourcePolicy, {
|
|
210
|
+
legacyOwnedContainerCount: ownershipCounts.containers,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function parseNetworkAliases(output, installation) {
|
|
216
|
+
if (typeof output !== "string" || output.length > 64 * 1024) {
|
|
217
|
+
throw new Error("The selected network alias state could not be verified.");
|
|
218
|
+
}
|
|
219
|
+
const requestedAliases = new Set([
|
|
220
|
+
installation.sandboxAlias,
|
|
221
|
+
...(installation.includeSearxng ? [installation.searxngAlias] : []),
|
|
222
|
+
]);
|
|
223
|
+
for (const line of output.split("\n").map((value) => value.trim()).filter(Boolean)) {
|
|
224
|
+
const [containerId, installId, aliases, ...extra] = line.split("|");
|
|
225
|
+
if (
|
|
226
|
+
extra.length > 0 ||
|
|
227
|
+
!CONTAINER_ID_PATTERN.test(containerId ?? "") ||
|
|
228
|
+
typeof installId !== "string" ||
|
|
229
|
+
typeof aliases !== "string"
|
|
230
|
+
) {
|
|
231
|
+
throw new Error("The selected network alias state could not be verified.");
|
|
232
|
+
}
|
|
233
|
+
const collidingAlias = aliases.split(",").some((alias) => requestedAliases.has(alias));
|
|
234
|
+
if (collidingAlias && installId !== installation.installId) {
|
|
235
|
+
throw new Error("A requested AI Assistant network alias is already attached to a foreign container.");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function verifyNetworkAliases(remote, networkName, installation) {
|
|
241
|
+
const command = createAssistantNetworkCollisionCommand({
|
|
242
|
+
networkName,
|
|
243
|
+
installation,
|
|
244
|
+
});
|
|
245
|
+
const result = await remote.exec(command);
|
|
246
|
+
if (result.code !== 0) {
|
|
247
|
+
throw new Error("The selected network alias state could not be verified.");
|
|
248
|
+
}
|
|
249
|
+
parseNetworkAliases(result.stdout, installation);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function failAfterStart({
|
|
253
|
+
remote,
|
|
254
|
+
installation,
|
|
255
|
+
cleanupCommand,
|
|
256
|
+
cleanupScope,
|
|
257
|
+
reason,
|
|
258
|
+
cleanupState,
|
|
259
|
+
}) {
|
|
260
|
+
const searxngOnly = cleanupScope === "searxng";
|
|
261
|
+
if (cleanupState.attempted) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
reason + (searxngOnly
|
|
264
|
+
? " Optional SearXNG cleanup was already attempted; do not use the AI Assistant companion."
|
|
265
|
+
: " Cleanup was already attempted; do not use the AI Assistant companion."),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
cleanupState.attempted = true;
|
|
269
|
+
try {
|
|
270
|
+
await attestAssistantOwnership(remote, installation);
|
|
271
|
+
} catch {
|
|
272
|
+
throw new Error(
|
|
273
|
+
reason + (searxngOnly
|
|
274
|
+
? " Optional SearXNG ownership could not be confirmed, so cleanup was not attempted. Do not use the AI Assistant companion until an administrator verifies its state."
|
|
275
|
+
: " Companion ownership could not be confirmed, so cleanup was not attempted. Do not use the AI Assistant companion until its ownership is inspected."),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let cleanupSucceeded = false;
|
|
280
|
+
try {
|
|
281
|
+
cleanupSucceeded = (await remote.exec(cleanupCommand)).code === 0;
|
|
282
|
+
} catch {
|
|
283
|
+
cleanupSucceeded = false;
|
|
284
|
+
}
|
|
285
|
+
if (!cleanupSucceeded) {
|
|
286
|
+
const cleanupMessage = searxngOnly
|
|
287
|
+
? "Automatic optional SearXNG cleanup could not be confirmed. Do not use the AI Assistant companion until an administrator verifies its state."
|
|
288
|
+
: "Automatic cleanup could not be confirmed. Do not use the AI Assistant companion until an administrator confirms its removal.";
|
|
289
|
+
throw Object.assign(
|
|
290
|
+
new Error(
|
|
291
|
+
reason + " " + cleanupMessage,
|
|
292
|
+
),
|
|
293
|
+
{
|
|
294
|
+
safeMessage: cleanupMessage,
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (searxngOnly) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
reason + " The optional SearXNG service was removed. The existing sandbox remains; do not use the AI Assistant companion until an administrator verifies its state.",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
throw new Error(
|
|
304
|
+
reason + " The ownership-attested AI Assistant companion project was removed; the existing n8n deployment was not changed.",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function installAssistant({
|
|
309
|
+
remote,
|
|
310
|
+
networkName,
|
|
311
|
+
confirmed,
|
|
312
|
+
includeSearxng,
|
|
313
|
+
randomBytes = cryptoRandomBytes,
|
|
314
|
+
}) {
|
|
315
|
+
if (confirmed !== true) {
|
|
316
|
+
throw new Error("Confirm the AI Assistant companion deployment before installing.");
|
|
317
|
+
}
|
|
318
|
+
const selectedSearxng = validateAssistantSearxngSelection(includeSearxng);
|
|
319
|
+
|
|
320
|
+
const precheck = await remote.exec(ASSISTANT_PRECHECK_COMMAND);
|
|
321
|
+
if (precheck.code === 42) {
|
|
322
|
+
throw new Error("The AI Assistant directory or shared root already exists and is unmanaged. Nothing was overwritten.");
|
|
323
|
+
}
|
|
324
|
+
if (precheck.code === 43) {
|
|
325
|
+
throw new Error("The AI Assistant directory or shared root is unsafe or symlinked. Nothing was overwritten.");
|
|
326
|
+
}
|
|
327
|
+
if (precheck.code !== 0) {
|
|
328
|
+
throw new Error("The VPS AI Assistant install-directory check failed.");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const precheckResult = parseAssistantPrecheck(precheck.stdout);
|
|
332
|
+
const previousInstallation = precheckResult.installation;
|
|
333
|
+
if (
|
|
334
|
+
previousInstallation?.includeSearxng === true &&
|
|
335
|
+
selectedSearxng === false
|
|
336
|
+
) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
"Disabling previously managed SearXNG would remove a companion service. Use a separately authorized cleanup path instead.",
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const enablingSearxng =
|
|
342
|
+
previousInstallation?.includeSearxng === false && selectedSearxng === true;
|
|
343
|
+
const installation = previousInstallation
|
|
344
|
+
? { ...previousInstallation, includeSearxng: selectedSearxng }
|
|
345
|
+
: createAssistantInstallation({ randomBytes, includeSearxng: selectedSearxng });
|
|
346
|
+
const deploymentMode = precheckResult.state === "managed" ? "updated" : "installed";
|
|
347
|
+
const startupScope = enablingSearxng ? "searxng" : "all";
|
|
348
|
+
const cleanupScope = enablingSearxng ? "searxng" : "project";
|
|
349
|
+
const deploymentCommands = createAssistantDeploymentCommands({ installation, startupScope });
|
|
350
|
+
const verification = createAssistantVerificationCommands({ installation, cleanupScope });
|
|
351
|
+
const exactResourcePolicy = deploymentMode === "installed"
|
|
352
|
+
? "absent"
|
|
353
|
+
: enablingSearxng
|
|
354
|
+
? "enable-searxng"
|
|
355
|
+
: "owned";
|
|
356
|
+
const collisionCommand = createAssistantNetworkCollisionCommand({
|
|
357
|
+
networkName,
|
|
358
|
+
installation,
|
|
359
|
+
});
|
|
360
|
+
assertAssistantOnlyCommands({
|
|
361
|
+
commands: [
|
|
362
|
+
ASSISTANT_PRECHECK_COMMAND,
|
|
363
|
+
collisionCommand,
|
|
364
|
+
...Object.values(createAssistantOwnershipAttestationCommands({ installation })),
|
|
365
|
+
...Object.values(createAssistantExactResourceAttestationCommands({ installation })),
|
|
366
|
+
...deploymentCommands,
|
|
367
|
+
...Object.values(verification),
|
|
368
|
+
],
|
|
369
|
+
installation,
|
|
370
|
+
networkName,
|
|
371
|
+
startupScope,
|
|
372
|
+
cleanupScope,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await verifyNetworkAliases(remote, networkName, installation);
|
|
376
|
+
await attestAssistantOwnership(remote, installation, { exactResourcePolicy });
|
|
377
|
+
|
|
378
|
+
const composeFile = createAssistantComposeFile({ networkName, installation });
|
|
379
|
+
const settingsFile = installation.includeSearxng ? createSearxngSettings() : null;
|
|
380
|
+
const secrets = enablingSearxng
|
|
381
|
+
? null
|
|
382
|
+
: createAssistantSecrets({ randomBytes, includeSearxng: installation.includeSearxng });
|
|
383
|
+
const envFile = secrets
|
|
384
|
+
? createAssistantEnv(secrets, { includeSearxng: installation.includeSearxng })
|
|
385
|
+
: null;
|
|
386
|
+
|
|
387
|
+
await runOrThrow(remote, deploymentCommands[0], "AI Assistant shared root creation");
|
|
388
|
+
await runOrThrow(remote, deploymentCommands[1], "AI Assistant directory creation");
|
|
389
|
+
await remote.upload(ASSISTANT_ROOT_MARKER_PATH, SHARED_ROOT_MARKER_CONTENT, 0o600);
|
|
390
|
+
if (!enablingSearxng) {
|
|
391
|
+
await remote.upload(ASSISTANT_MARKER_PATH, serializeAssistantMarker(installation), 0o600);
|
|
392
|
+
}
|
|
393
|
+
await remote.upload(ASSISTANT_ROOT + "/docker-compose.yml", composeFile, 0o644);
|
|
394
|
+
if (settingsFile) {
|
|
395
|
+
await remote.upload(ASSISTANT_ROOT + "/searxng-settings.yml", settingsFile, 0o644);
|
|
396
|
+
}
|
|
397
|
+
if (envFile) {
|
|
398
|
+
await remote.upload(ASSISTANT_ROOT + "/.env", envFile, 0o600);
|
|
399
|
+
}
|
|
400
|
+
await runOrThrow(remote, deploymentCommands[2], "AI Assistant private file permissions");
|
|
401
|
+
await runOrThrow(remote, deploymentCommands[3], "AI Assistant public file permissions");
|
|
402
|
+
await runOrThrow(remote, deploymentCommands[4], "AI Assistant private file mode verification");
|
|
403
|
+
await runOrThrow(remote, deploymentCommands[5], "AI Assistant public file mode verification");
|
|
404
|
+
await runOrThrow(remote, deploymentCommands[6], "AI Assistant Compose validation");
|
|
405
|
+
await attestAssistantOwnership(remote, installation, { exactResourcePolicy });
|
|
406
|
+
|
|
407
|
+
const cleanupState = { attempted: false };
|
|
408
|
+
try {
|
|
409
|
+
await runOrThrow(remote, deploymentCommands[7], "AI Assistant companion startup");
|
|
410
|
+
} catch {
|
|
411
|
+
await failAfterStart({
|
|
412
|
+
remote,
|
|
413
|
+
installation,
|
|
414
|
+
cleanupCommand: verification.cleanup,
|
|
415
|
+
cleanupScope,
|
|
416
|
+
reason: "The AI Assistant companion startup failed.",
|
|
417
|
+
cleanupState,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const health = await runOrThrow(
|
|
423
|
+
remote,
|
|
424
|
+
verification.health,
|
|
425
|
+
"Sandbox API health check",
|
|
426
|
+
);
|
|
427
|
+
parseSandboxHealth(health.stdout);
|
|
428
|
+
} catch {
|
|
429
|
+
await failAfterStart({
|
|
430
|
+
remote,
|
|
431
|
+
installation,
|
|
432
|
+
cleanupCommand: verification.cleanup,
|
|
433
|
+
cleanupScope,
|
|
434
|
+
reason: "The sandbox API health check failed.",
|
|
435
|
+
cleanupState,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
const running = await runOrThrow(
|
|
441
|
+
remote,
|
|
442
|
+
verification.runningServices,
|
|
443
|
+
"AI Assistant companion status check",
|
|
444
|
+
);
|
|
445
|
+
verifyRunningServices(running.stdout, installation);
|
|
446
|
+
} catch {
|
|
447
|
+
await failAfterStart({
|
|
448
|
+
remote,
|
|
449
|
+
installation,
|
|
450
|
+
cleanupCommand: verification.cleanup,
|
|
451
|
+
cleanupScope,
|
|
452
|
+
reason: "The AI Assistant companion service check failed.",
|
|
453
|
+
cleanupState,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let publishedHostPort;
|
|
458
|
+
try {
|
|
459
|
+
const publication = await remote.exec(verification.publicationState);
|
|
460
|
+
if (publication.code !== 0) {
|
|
461
|
+
throw new Error("The published-port safety check failed.");
|
|
462
|
+
}
|
|
463
|
+
publishedHostPort = hasPublishedHostPort(publication.stdout, installation);
|
|
464
|
+
} catch {
|
|
465
|
+
await failAfterStart({
|
|
466
|
+
remote,
|
|
467
|
+
installation,
|
|
468
|
+
cleanupCommand: verification.cleanup,
|
|
469
|
+
cleanupScope,
|
|
470
|
+
reason: "The published-port safety check failed.",
|
|
471
|
+
cleanupState,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (publishedHostPort) {
|
|
475
|
+
await failAfterStart({
|
|
476
|
+
remote,
|
|
477
|
+
installation,
|
|
478
|
+
cleanupCommand: verification.cleanup,
|
|
479
|
+
cleanupScope,
|
|
480
|
+
reason: "Safety check failed: the AI Assistant companion unexpectedly published a host port.",
|
|
481
|
+
cleanupState,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (enablingSearxng) {
|
|
486
|
+
try {
|
|
487
|
+
await remote.upload(ASSISTANT_MARKER_PATH, serializeAssistantMarker(installation), 0o600);
|
|
488
|
+
await runOrThrow(remote, deploymentCommands[2], "AI Assistant private file permissions");
|
|
489
|
+
await runOrThrow(remote, deploymentCommands[4], "AI Assistant private file mode verification");
|
|
490
|
+
} catch {
|
|
491
|
+
await failAfterStart({
|
|
492
|
+
remote,
|
|
493
|
+
installation,
|
|
494
|
+
cleanupCommand: verification.cleanup,
|
|
495
|
+
cleanupScope,
|
|
496
|
+
reason: "The AI Assistant SearXNG marker update failed.",
|
|
497
|
+
cleanupState,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
sandboxUrl: "http://" + installation.sandboxAlias + ":8080",
|
|
504
|
+
sandboxApiKey: secrets?.sandboxApiKey ?? null,
|
|
505
|
+
includeSearxng: installation.includeSearxng,
|
|
506
|
+
webSearch: installation.includeSearxng ? "enabled" : "disabled",
|
|
507
|
+
...(installation.includeSearxng
|
|
508
|
+
? { searxngUrl: "http://" + installation.searxngAlias + ":8080" }
|
|
509
|
+
: {}),
|
|
510
|
+
modelProvider: "OpenAI",
|
|
511
|
+
modelRecommendation: "preserve-current-supported-selection",
|
|
512
|
+
deploymentMode,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
@@ -76,9 +76,35 @@ export function createInspectNetworksCommand(containerName) {
|
|
|
76
76
|
return `docker inspect ${safeName} --format '{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}'`;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
export function createInspectN8nEnabledModulesCommand(containerName) {
|
|
80
|
+
const safeName = validateDockerName(containerName);
|
|
81
|
+
return `docker inspect ${safeName} --format '{{range .Config.Env}}{{if eq (index (split . "=") 0) "N8N_ENABLED_MODULES"}}configured|{{println (join (slice (split . "=") 1) "=")}}{{end}}{{end}}'`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseN8nEnabledModulesOutput(output) {
|
|
85
|
+
if (typeof output !== "string" || output.length > 4 * 1024) {
|
|
86
|
+
throw new Error("The n8n AI Assistant prerequisite could not be verified.");
|
|
87
|
+
}
|
|
88
|
+
const lines = output.trim().split("\n").filter(Boolean);
|
|
89
|
+
if (lines.length === 0) return { status: "missing" };
|
|
90
|
+
if (lines.length !== 1 || !lines[0].startsWith("configured|")) {
|
|
91
|
+
throw new Error("The n8n AI Assistant prerequisite could not be verified.");
|
|
92
|
+
}
|
|
93
|
+
const modules = lines[0]
|
|
94
|
+
.slice("configured|".length)
|
|
95
|
+
.split(",")
|
|
96
|
+
.map((entry) => entry.trim())
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
return { status: modules.includes("instance-ai") ? "enabled" : "configured" };
|
|
99
|
+
}
|
|
100
|
+
|
|
79
101
|
export async function discoverNetworks(remote, containerName) {
|
|
80
102
|
const command = createInspectNetworksCommand(containerName);
|
|
81
|
-
const
|
|
103
|
+
const enabledModulesCommand = createInspectN8nEnabledModulesCommand(containerName);
|
|
104
|
+
const [output, enabledModulesOutput] = await Promise.all([
|
|
105
|
+
runReadOnly(remote, command, "Docker network discovery"),
|
|
106
|
+
runReadOnly(remote, enabledModulesCommand, "n8n AI Assistant prerequisite discovery"),
|
|
107
|
+
]);
|
|
82
108
|
const networks = [
|
|
83
109
|
...new Set(
|
|
84
110
|
output
|
|
@@ -92,5 +118,6 @@ export async function discoverNetworks(remote, containerName) {
|
|
|
92
118
|
return {
|
|
93
119
|
networks,
|
|
94
120
|
recommended: networks.includes("proxy") ? "proxy" : (networks[0] ?? null),
|
|
121
|
+
instanceAi: parseN8nEnabledModulesOutput(enabledModulesOutput),
|
|
95
122
|
};
|
|
96
123
|
}
|
|
@@ -2,6 +2,9 @@ import {
|
|
|
2
2
|
INSTALL_ROOT,
|
|
3
3
|
MANAGED_MARKER_PATH,
|
|
4
4
|
PRECHECK_COMMAND,
|
|
5
|
+
SHARED_ROOT_MARKER_CONTENT,
|
|
6
|
+
SHARED_ROOT_MARKER_PATH,
|
|
7
|
+
SIDECAR_MARKER_CONTENT,
|
|
5
8
|
assertSidecarOnlyCommands,
|
|
6
9
|
createDeploymentCommands,
|
|
7
10
|
createVerificationCommands,
|
|
@@ -13,8 +16,6 @@ import {
|
|
|
13
16
|
} from "../domain/templates.js";
|
|
14
17
|
|
|
15
18
|
const MAX_AUTH_FILE_BYTES = 128 * 1024;
|
|
16
|
-
const MARKER_CONTENT = "Managed by n8n-openai-oauth-setup.\n";
|
|
17
|
-
|
|
18
19
|
function validateAuthContents(contents) {
|
|
19
20
|
if (!Buffer.isBuffer(contents)) {
|
|
20
21
|
throw new TypeError("The OAuth credential file is invalid.");
|
|
@@ -112,8 +113,14 @@ async function failPublicationSafetyCheck(remote, cleanupCommand, reason) {
|
|
|
112
113
|
cleanupSucceeded = false;
|
|
113
114
|
}
|
|
114
115
|
if (!cleanupSucceeded) {
|
|
115
|
-
throw
|
|
116
|
-
|
|
116
|
+
throw Object.assign(
|
|
117
|
+
new Error(
|
|
118
|
+
`${reason} Automatic cleanup could not be confirmed. Do not use the sidecar until it is removed from /docker/n8n-openai-oauth.`,
|
|
119
|
+
),
|
|
120
|
+
{
|
|
121
|
+
safeMessage:
|
|
122
|
+
"Automatic cleanup could not be confirmed. Do not use the sidecar until an administrator confirms its removal.",
|
|
123
|
+
},
|
|
117
124
|
);
|
|
118
125
|
}
|
|
119
126
|
throw new Error(
|
|
@@ -162,7 +169,8 @@ export async function installSidecar({
|
|
|
162
169
|
await runOrThrow(remote, deploymentCommands[0], "Sidecar directory creation");
|
|
163
170
|
await runOrThrow(remote, deploymentCommands[1], "Auth directory creation");
|
|
164
171
|
|
|
165
|
-
await remote.upload(
|
|
172
|
+
await remote.upload(SHARED_ROOT_MARKER_PATH, SHARED_ROOT_MARKER_CONTENT, 0o600);
|
|
173
|
+
await remote.upload(MANAGED_MARKER_PATH, SIDECAR_MARKER_CONTENT, 0o644);
|
|
166
174
|
await remote.upload(`${INSTALL_ROOT}/Dockerfile`, dockerfile, 0o644);
|
|
167
175
|
await remote.upload(
|
|
168
176
|
`${INSTALL_ROOT}/docker-compose.yml`,
|