Ctrl K
ComunidadGuías…

src/services/assistant-installer.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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,
  };
}