src/services/assistant-installer.js
Rama main · réplica de lectura del repositorio original.
import { randomBytes as cryptoRandomBytes } from "node:crypto";
import {
ASSISTANT_MARKER_PATH,
ASSISTANT_PRECHECK_COMMAND,
ASSISTANT_ROOT,
ASSISTANT_ROOT_MARKER_PATH,
assertAssistantOnlyCommands,
createAssistantDeploymentCommands,
createAssistantExactResourceAttestationCommands,
createAssistantInstallation,
createAssistantNetworkCollisionCommand,
createAssistantOwnershipAttestationCommands,
createAssistantVerificationCommands,
getAssistantContainerNames,
getAssistantManagedResourceNames,
getAssistantServiceNames,
parseAssistantPrecheck,
serializeAssistantMarker,
validateAssistantSearxngSelection,
} from "../domain/assistant.js";
import { SHARED_ROOT_MARKER_CONTENT } from "../domain/safety.js";
import {
ASSISTANT_COMPANION_IMAGES,
createAssistantComposeFile,
createAssistantEnv,
createAssistantSecrets,
createSearxngSettings,
} from "../domain/assistant-templates.js";
const CONTAINER_ID_PATTERN = /^[a-f0-9]{12,64}$/iu;
function createN8nSettings({ installation, secrets }) {
const settings = {
N8N_INSTANCE_AI_SANDBOX_ENABLED: "true",
N8N_INSTANCE_AI_SANDBOX_PROVIDER: "n8n-sandbox",
N8N_INSTANCE_AI_SANDBOX_IMAGE: ASSISTANT_COMPANION_IMAGES.sandbox,
N8N_SANDBOX_SERVICE_URL: `http://${installation.sandboxAlias}:8080`,
...(secrets
? { N8N_SANDBOX_SERVICE_API_KEY: secrets.sandboxApiKey }
: {}),
};
if (installation.includeSearxng) {
settings.N8N_INSTANCE_AI_SEARXNG_URL =
`http://${installation.searxngAlias}:8080`;
}
return settings;
}
function getExpectedRunningServices(installation) {
return new Set(
getAssistantServiceNames(installation).filter(
(service) => service !== "relmio-sandbox-certs",
),
);
}
function parseJsonLines(output, label) {
if (typeof output !== "string" || output.trim() === "") {
throw new Error(label + " could not be verified.");
}
try {
const parsed = JSON.parse(output);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
try {
return output.trim().split("\n").map((line) => JSON.parse(line));
} catch {
throw new Error(label + " could not be verified.");
}
}
}
function parseSandboxHealth(output) {
const values = parseJsonLines(output, "The sandbox API health");
if (values.length !== 1 || values[0]?.status !== "ok") {
throw new Error("The sandbox API health could not be verified.");
}
}
function verifyRunningServices(output, installation) {
if (typeof output !== "string") {
throw new Error("The companion service status could not be verified.");
}
const expectedRunningServices = getExpectedRunningServices(installation);
const running = new Set(output.split("\n").map((value) => value.trim()).filter(Boolean));
if (
running.size !== expectedRunningServices.size ||
[...expectedRunningServices].some((service) => !running.has(service))
) {
throw new Error("The expected AI Assistant companion services are not running.");
}
}
function hasPublishedHostPort(output, installation) {
const services = parseJsonLines(output, "The published-port state");
const expectedRunningServices = getExpectedRunningServices(installation);
if (services.length !== expectedRunningServices.size) {
throw new Error("The published-port state could not be verified.");
}
const seen = new Set();
for (const service of services) {
if (
!service ||
typeof service.Service !== "string" ||
!expectedRunningServices.has(service.Service) ||
seen.has(service.Service) ||
(service.Publishers !== null && !Array.isArray(service.Publishers))
) {
throw new Error("The published-port state could not be verified.");
}
seen.add(service.Service);
for (const publisher of service.Publishers ?? []) {
if (
!publisher ||
!Number.isInteger(publisher.PublishedPort) ||
publisher.PublishedPort < 0 ||
typeof publisher.URL !== "string"
) {
throw new Error("The published-port state could not be verified.");
}
if (publisher.PublishedPort > 0 || publisher.URL.trim() !== "") {
return true;
}
}
}
return false;
}
async function runOrThrow(remote, command, label) {
const result = await remote.exec(command);
if (result.code !== 0) {
throw new Error(label + " failed. The existing n8n deployment was not changed.");
}
return result;
}
function attestOutput(output, installation, label) {
if (typeof output !== "string" || output.length > 16 * 1024) {
throw new Error("The AI Assistant " + label + " ownership could not be verified.");
}
let count = 0;
for (const line of output.split("\n").map((value) => value.trim()).filter(Boolean)) {
if (line !== installation.installId + "|true") {
throw new Error("The AI Assistant " + label + " ownership could not be verified.");
}
count += 1;
}
return count;
}
function attestExactResourceOutput(
output,
installation,
label,
policy,
{ legacyOwnedContainerCount = 0 } = {},
) {
if (typeof output !== "string" || output.length > 16 * 1024) {
throw new Error("The AI Assistant exact " + label + " ownership could not be verified.");
}
const names = getAssistantManagedResourceNames(installation);
const expectedNames = new Set(
label === "containers" ? names.containers : [names[label]],
);
const seen = new Set();
const enablingSearxng = policy === "enable-searxng";
const searxngContainer = getAssistantContainerNames(installation).searxng;
for (const line of output.split("\n").map((value) => value.trim()).filter(Boolean)) {
const [name, installId, managed, ...extra] = line.split("|");
if (
extra.length > 0 ||
!expectedNames.has(name) ||
seen.has(name) ||
typeof installId !== "string" ||
typeof managed !== "string"
) {
throw new Error("The AI Assistant exact " + label + " ownership could not be verified.");
}
seen.add(name);
if (policy === "absent") {
throw new Error("A predictable AI Assistant " + label + " resource name is already occupied.");
}
if (enablingSearxng && label === "containers" && name === searxngContainer) {
throw new Error("The optional AI Assistant SearXNG container is already occupied.");
}
if (installId !== installation.installId || managed !== "true") {
throw new Error("The AI Assistant exact " + label + " ownership could not be verified.");
}
}
if (enablingSearxng) {
if (
label === "containers" &&
seen.size === 0 &&
legacyOwnedContainerCount >= getAssistantServiceNames({
...installation,
includeSearxng: false,
}).length
) {
return;
}
const requiredOwnedNames = label === "containers"
? names.containers.filter((name) => name !== searxngContainer)
: [names[label]];
if (requiredOwnedNames.some((name) => !seen.has(name))) {
throw new Error("The AI Assistant exact " + label + " ownership could not be verified.");
}
}
}
async function attestAssistantOwnership(remote, installation, { exactResourcePolicy = "owned" } = {}) {
const commands = createAssistantOwnershipAttestationCommands({ installation });
const ownershipCounts = {};
for (const [label, command] of Object.entries(commands)) {
const result = await remote.exec(command);
if (result.code !== 0) {
throw new Error("The AI Assistant " + label + " ownership could not be verified.");
}
ownershipCounts[label] = attestOutput(result.stdout, installation, label);
}
const exactCommands = createAssistantExactResourceAttestationCommands({ installation });
for (const [label, command] of Object.entries(exactCommands)) {
const result = await remote.exec(command);
if (result.code !== 0) {
throw new Error("The AI Assistant exact " + label + " ownership could not be verified.");
}
attestExactResourceOutput(result.stdout, installation, label, exactResourcePolicy, {
legacyOwnedContainerCount: ownershipCounts.containers,
});
}
}
function parseNetworkAliases(output, installation) {
if (typeof output !== "string" || output.length > 64 * 1024) {
throw new Error("The selected network alias state could not be verified.");
}
const requestedAliases = new Set([
installation.sandboxAlias,
...(installation.includeSearxng ? [installation.searxngAlias] : []),
]);
for (const line of output.split("\n").map((value) => value.trim()).filter(Boolean)) {
const [containerId, installId, aliases, ...extra] = line.split("|");
if (
extra.length > 0 ||
!CONTAINER_ID_PATTERN.test(containerId ?? "") ||
typeof installId !== "string" ||
typeof aliases !== "string"
) {
throw new Error("The selected network alias state could not be verified.");
}
const collidingAlias = aliases.split(",").some((alias) => requestedAliases.has(alias));
if (collidingAlias && installId !== installation.installId) {
throw new Error("A requested AI Assistant network alias is already attached to a foreign container.");
}
}
}
async function verifyNetworkAliases(remote, networkName, installation) {
const command = createAssistantNetworkCollisionCommand({
networkName,
installation,
});
const result = await remote.exec(command);
if (result.code !== 0) {
throw new Error("The selected network alias state could not be verified.");
}
parseNetworkAliases(result.stdout, installation);
}
async function failAfterStart({
remote,
installation,
cleanupCommand,
cleanupScope,
reason,
cleanupState,
}) {
const searxngOnly = cleanupScope === "searxng";
if (cleanupState.attempted) {
throw new Error(
reason + (searxngOnly
? " Optional SearXNG cleanup was already attempted; do not use the AI Assistant companion."
: " Cleanup was already attempted; do not use the AI Assistant companion."),
);
}
cleanupState.attempted = true;
try {
await attestAssistantOwnership(remote, installation);
} catch {
throw new Error(
reason + (searxngOnly
? " 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."
: " Companion ownership could not be confirmed, so cleanup was not attempted. Do not use the AI Assistant companion until its ownership is inspected."),
);
}
let cleanupSucceeded = false;
try {
cleanupSucceeded = (await remote.exec(cleanupCommand)).code === 0;
} catch {
cleanupSucceeded = false;
}
if (!cleanupSucceeded) {
const cleanupMessage = searxngOnly
? "Automatic optional SearXNG cleanup could not be confirmed. Do not use the AI Assistant companion until an administrator verifies its state."
: "Automatic cleanup could not be confirmed. Do not use the AI Assistant companion until an administrator confirms its removal.";
throw Object.assign(
new Error(
reason + " " + cleanupMessage,
),
{
safeMessage: cleanupMessage,
},
);
}
if (searxngOnly) {
throw new Error(
reason + " The optional SearXNG service was removed. The existing sandbox remains; do not use the AI Assistant companion until an administrator verifies its state.",
);
}
throw new Error(
reason + " The ownership-attested AI Assistant companion project was removed; the existing n8n deployment was not changed.",
);
}
export async function installAssistant({
remote,
networkName,
confirmed,
includeSearxng,
randomBytes = cryptoRandomBytes,
}) {
if (confirmed !== true) {
throw new Error("Confirm the AI Assistant companion deployment before installing.");
}
const selectedSearxng = validateAssistantSearxngSelection(includeSearxng);
const precheck = await remote.exec(ASSISTANT_PRECHECK_COMMAND);
if (precheck.code === 42) {
throw new Error("The AI Assistant directory or shared root already exists and is unmanaged. Nothing was overwritten.");
}
if (precheck.code === 43) {
throw new Error("The AI Assistant directory or shared root is unsafe or symlinked. Nothing was overwritten.");
}
if (precheck.code !== 0) {
throw new Error("The VPS AI Assistant install-directory check failed.");
}
const precheckResult = parseAssistantPrecheck(precheck.stdout);
const previousInstallation = precheckResult.installation;
if (
previousInstallation?.includeSearxng === true &&
selectedSearxng === false
) {
throw new Error(
"Disabling previously managed SearXNG would remove a companion service. Use a separately authorized cleanup path instead.",
);
}
const enablingSearxng =
previousInstallation?.includeSearxng === false && selectedSearxng === true;
const installation = previousInstallation
? { ...previousInstallation, includeSearxng: selectedSearxng }
: createAssistantInstallation({ randomBytes, includeSearxng: selectedSearxng });
const deploymentMode = precheckResult.state === "managed" ? "updated" : "installed";
const startupScope = enablingSearxng ? "searxng" : "all";
const cleanupScope = enablingSearxng ? "searxng" : "project";
const deploymentCommands = createAssistantDeploymentCommands({ installation, startupScope });
const verification = createAssistantVerificationCommands({ installation, cleanupScope });
const exactResourcePolicy = deploymentMode === "installed"
? "absent"
: enablingSearxng
? "enable-searxng"
: "owned";
const collisionCommand = createAssistantNetworkCollisionCommand({
networkName,
installation,
});
assertAssistantOnlyCommands({
commands: [
ASSISTANT_PRECHECK_COMMAND,
collisionCommand,
...Object.values(createAssistantOwnershipAttestationCommands({ installation })),
...Object.values(createAssistantExactResourceAttestationCommands({ installation })),
...deploymentCommands,
...Object.values(verification),
],
installation,
networkName,
startupScope,
cleanupScope,
});
await verifyNetworkAliases(remote, networkName, installation);
await attestAssistantOwnership(remote, installation, { exactResourcePolicy });
const composeFile = createAssistantComposeFile({ networkName, installation });
const settingsFile = installation.includeSearxng ? createSearxngSettings() : null;
const secrets = enablingSearxng
? null
: createAssistantSecrets({ randomBytes, includeSearxng: installation.includeSearxng });
const envFile = secrets
? createAssistantEnv(secrets, { includeSearxng: installation.includeSearxng })
: null;
// Both folders are proved root-owned and not group or other writable before the first file.
// Each file is then staged and renamed into place, so a link is never followed.
await runOrThrow(remote, deploymentCommands[0], "AI Assistant shared folder ownership check");
await runOrThrow(remote, deploymentCommands[1], "AI Assistant folder ownership check");
await remote.publishManagedFile(ASSISTANT_ROOT_MARKER_PATH, SHARED_ROOT_MARKER_CONTENT, 0o600);
if (!enablingSearxng) {
await remote.publishManagedFile(ASSISTANT_MARKER_PATH, serializeAssistantMarker(installation), 0o600);
}
await remote.publishManagedFile(ASSISTANT_ROOT + "/docker-compose.yml", composeFile, 0o644);
if (settingsFile) {
await remote.publishManagedFile(ASSISTANT_ROOT + "/searxng-settings.yml", settingsFile, 0o644);
}
if (envFile) {
await remote.publishManagedFile(ASSISTANT_ROOT + "/.env", envFile, 0o600);
}
await runOrThrow(remote, deploymentCommands[2], "AI Assistant private file permissions");
await runOrThrow(remote, deploymentCommands[3], "AI Assistant public file permissions");
await runOrThrow(remote, deploymentCommands[4], "AI Assistant private file mode verification");
await runOrThrow(remote, deploymentCommands[5], "AI Assistant public file mode verification");
await runOrThrow(remote, deploymentCommands[6], "AI Assistant Compose validation");
await attestAssistantOwnership(remote, installation, { exactResourcePolicy });
const cleanupState = { attempted: false };
try {
await runOrThrow(remote, deploymentCommands[7], "AI Assistant companion startup");
} catch {
await failAfterStart({
remote,
installation,
cleanupCommand: verification.cleanup,
cleanupScope,
reason: "The AI Assistant companion startup failed.",
cleanupState,
});
}
try {
const health = await runOrThrow(
remote,
verification.health,
"Sandbox API health check",
);
parseSandboxHealth(health.stdout);
} catch {
await failAfterStart({
remote,
installation,
cleanupCommand: verification.cleanup,
cleanupScope,
reason: "The sandbox API health check failed.",
cleanupState,
});
}
try {
const running = await runOrThrow(
remote,
verification.runningServices,
"AI Assistant companion status check",
);
verifyRunningServices(running.stdout, installation);
} catch {
await failAfterStart({
remote,
installation,
cleanupCommand: verification.cleanup,
cleanupScope,
reason: "The AI Assistant companion service check failed.",
cleanupState,
});
}
let publishedHostPort;
try {
const publication = await remote.exec(verification.publicationState);
if (publication.code !== 0) {
throw new Error("The published-port safety check failed.");
}
publishedHostPort = hasPublishedHostPort(publication.stdout, installation);
} catch {
await failAfterStart({
remote,
installation,
cleanupCommand: verification.cleanup,
cleanupScope,
reason: "The published-port safety check failed.",
cleanupState,
});
}
if (publishedHostPort) {
await failAfterStart({
remote,
installation,
cleanupCommand: verification.cleanup,
cleanupScope,
reason: "Safety check failed: the AI Assistant companion unexpectedly published a host port.",
cleanupState,
});
}
if (enablingSearxng) {
try {
await remote.publishManagedFile(ASSISTANT_MARKER_PATH, serializeAssistantMarker(installation), 0o600);
await runOrThrow(remote, deploymentCommands[2], "AI Assistant private file permissions");
await runOrThrow(remote, deploymentCommands[4], "AI Assistant private file mode verification");
} catch {
await failAfterStart({
remote,
installation,
cleanupCommand: verification.cleanup,
cleanupScope,
reason: "The AI Assistant SearXNG marker update failed.",
cleanupState,
});
}
}
const n8nSettings = createN8nSettings({ installation, secrets });
return {
sandboxUrl: n8nSettings.N8N_SANDBOX_SERVICE_URL,
sandboxApiKey: secrets?.sandboxApiKey ?? null,
includeSearxng: installation.includeSearxng,
webSearch: installation.includeSearxng ? "enabled" : "disabled",
...(installation.includeSearxng
? { searxngUrl: n8nSettings.N8N_INSTANCE_AI_SEARXNG_URL }
: {}),
n8nSettings,
modelProvider: "OpenAI",
modelRecommendation: "preserve-current-supported-selection",
deploymentMode,
};
}