Ctrl K
ComunidadGuías…

src/services/local-n8n-assistant-installer.js

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

Archivo original ↗Preguntar a KiBot
import { randomBytes as createRandomBytes, randomUUID } from "node:crypto";
import * as defaultFileSystem from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";

import {
  createAssistantInstallation,
  getAssistantContainerNames,
  getAssistantManagedResourceNames,
  getAssistantServiceNames,
  validateAssistantInstallation,
} from "../domain/assistant.js";
import {
  ASSISTANT_COMPANION_IMAGES,
  createAssistantComposeFile,
  createAssistantEnv,
  createAssistantSecrets,
  createSearxngSettings,
} from "../domain/assistant-templates.js";
import {
  LOCAL_N8N_ASSISTANT_TARGET,
  normalizeLocalN8nAssistantPlan,
} from "../domain/local-n8n-assistant.js";
import { validateDockerObjectId } from "../domain/local-n8n-sidecar.js";
import {
  runLocalProcess,
  lockDownLocalPath,
  validateLocalDockerHost,
} from "../infrastructure/local-process.js";
import { discoverLocalN8nSidecarTargets } from "./local-n8n-sidecar-installer.js";
import { getLocalDockerStatus } from "./local-installer.js";
import {
  acquireLocalIntegrationLifecycleLock,
  settleLocalIntegrationLifecycleOperation,
} from "./local-integration-lifecycle-lock.js";

const COMPOSE_FILENAME = "docker-compose.yml";
const MANAGED_MARKER = ".managed-by-relmio.json";
const ROOT_MARKER = ".managed-by-relmio-root.json";
const MARKER_SCHEMA_VERSION = 1;
const ROOT_MARKER_SCHEMA_VERSION = 1;
const SEARXNG_UPDATE_REVIEW_SCHEMA_VERSION = 1;
const SEARXNG_UPDATE_REVIEW_KIND = "relmio-local-n8n-assistant-searxng-update";
const MAX_DOCKER_METADATA_BYTES = 1024 * 1024;
const DOCKER_SELECTION_VARIABLES = new Set([
  "BUILDKIT_HOST",
  "DOCKER_CERT_PATH",
  "DOCKER_CONFIG",
  "DOCKER_CONTEXT",
  "DOCKER_HOST",
  "DOCKER_TLS_VERIFY",
]);

function isMissing(error) {
  return error?.code === "ENOENT";
}

function assertSupportedPlatform(platform) {
  if (typeof platform !== "string" || platform.length === 0) {
    throw new TypeError("The local platform is invalid.");
  }
}

function rejectDockerEnvironmentOverrides(env) {
  if (!env || typeof env !== "object" || Array.isArray(env)) {
    throw new TypeError("The local process environment is invalid.");
  }
  for (const [name, value] of Object.entries(env)) {
    if (
      DOCKER_SELECTION_VARIABLES.has(name.toUpperCase()) &&
      typeof value === "string" &&
      value !== ""
    ) {
      throw new Error(
        "Relmio local companions require the selected Docker context without a Docker environment override.",
      );
    }
  }
}

function validateAbsolutePath(value) {
  if (
    typeof value !== "string" ||
    value.trim() === "" ||
    value.includes("\0") ||
    !isAbsolute(value)
  ) {
    throw new TypeError("Relmio local storage path is invalid.");
  }
  return resolve(value);
}

function validateManagedBase(value) {
  const resolved = validateAbsolutePath(value);
  if (basename(resolved) !== ".relmio") {
    throw new TypeError("Relmio local storage path is invalid.");
  }
  return resolved;
}

function validateInstallRoot(value) {
  const resolved = validateAbsolutePath(value);
  if (
    basename(resolved) !== LOCAL_N8N_ASSISTANT_TARGET ||
    basename(dirname(resolved)) !== "local" ||
    basename(resolve(resolved, "..", "..")) !== ".relmio"
  ) {
    throw new TypeError("The local n8n Assistant install directory is invalid.");
  }
  return resolved;
}

export async function resolveLocalN8nAssistantInstallRoot({
  env = process.env,
  homeDirectory = homedir(),
  fileSystem = defaultFileSystem,
  platform = process.platform,
} = {}) {
  assertSupportedPlatform(platform);
  const configuredHome =
    typeof env.RELMIO_HOME === "string" && env.RELMIO_HOME.trim() !== ""
      ? env.RELMIO_HOME
      : resolve(homeDirectory, ".relmio");
  const requestedHome = validateManagedBase(configuredHome);
  const requestedParent = dirname(requestedHome);
  let canonicalParent;
  try {
    canonicalParent = await fileSystem.realpath(requestedParent);
  } catch {
    throw new Error("The parent of the Relmio local storage directory is invalid.");
  }
  if (canonicalParent !== resolve(requestedParent)) {
    throw new Error(
      "Relmio refuses a local storage path with a symbolic-link ancestor.",
    );
  }
  return join(
    canonicalParent,
    ".relmio",
    "local",
    LOCAL_N8N_ASSISTANT_TARGET,
  );
}

function validateAssistantInventoryRecords(records, expectedServices) {
  const seen = new Set();
  for (const record of records) {
    if (
      typeof record?.Service !== "string" ||
      !expectedServices.includes(record.Service) ||
      seen.has(record.Service) ||
      typeof record.State !== "string" ||
      typeof record.Health !== "string" ||
      !Array.isArray(record.Publishers)
    ) {
      throw new Error("The local companion status metadata is invalid.");
    }
    seen.add(record.Service);
    for (const publisher of record.Publishers) {
      if (
        !publisher ||
        publisher.PublishedPort !== 0 ||
        publisher.URL !== ""
      ) {
        throw new Error("A local companion published a host port.");
      }
    }
  }
}

function runningAssistantHealthchecksAreHealthy(records) {
  return records.every((record) => {
    if (record.State !== "running") return true;
    if (record.Service === "relmio-sandbox-api") {
      return record.Health === "healthy";
    }
    return record.Health === "" || record.Health === "healthy";
  });
}

function localN8nAssistantSnapshot(installation) {
  return {
    target: "local-n8n-assistant",
    components: {
      codeSandbox: true,
      searxng: installation.includeSearxng,
    },
    auth: { sandboxConfigured: true, disclosure: "one-time" },
    canRemove: true,
  };
}

async function verifyWindowsAssistantStatusPathSecurity({
  fileSystem,
  installRoot,
  installation,
  platform,
  lockDownPath,
}) {
  if (platform !== "win32") return;
  const safeInstallation = validateAssistantInstallation(installation);
  const relmioHome = resolve(installRoot, "..", "..");
  for (const path of [relmioHome, join(relmioHome, "local"), installRoot]) {
    assertDirectory(await lstatIfExists(fileSystem, path));
    await lockDownPath(path, { platform, verifyOnly: true });
  }
  for (const path of [
    join(relmioHome, ROOT_MARKER),
    join(installRoot, MANAGED_MARKER),
    join(installRoot, COMPOSE_FILENAME),
    join(installRoot, ".env"),
    ...(safeInstallation.includeSearxng
      ? [join(installRoot, "searxng-settings.yml")]
      : []),
  ]) {
    const metadata = await lstatIfExists(fileSystem, path);
    if (!metadata?.isFile?.() || metadata.isSymbolicLink()) {
      throw new Error("Relmio refuses an unsafe local companion managed file.");
    }
    await lockDownPath(path, {
      platform,
      kind: "file",
      verifyOnly: true,
      verifyEffectiveOwnerOnly: true,
    });
  }
}

export async function getLocalN8nAssistantStatus({
  env = process.env,
  homeDirectory = homedir(),
  fileSystem = defaultFileSystem,
  runProcess = runLocalProcess,
  platform = process.platform,
  lockDownPath = lockDownLocalPath,
} = {}) {
  const target = "local-n8n-assistant";
  const absent = { target, managed: false, state: "absent" };
  const unavailable = { target, managed: false, state: "unavailable" };
  try {
    const installRoot = await resolveLocalN8nAssistantInstallRoot({
      env,
      homeDirectory,
      fileSystem,
      platform,
    });
    const managed = await inspectManagedInstall({ fileSystem, installRoot });
    if (!managed.marker) return absent;
    const { plan, installation } = validateMarker(managed.marker);
    await verifyWindowsAssistantStatusPathSecurity({
      fileSystem,
      installRoot,
      installation,
      platform,
      lockDownPath,
    });
    const docker = await getLocalDockerStatus({
      runProcess,
      cwd: installRoot,
      env,
      platform,
    });
    if (!docker.dockerAvailable || docker.dockerHost !== plan.dockerHost) {
      return unavailable;
    }
    await attestReviewedTarget({
      plan,
      runProcess,
      cwd: installRoot,
      env,
      platform,
    });
    const environment = await readCanonicalPrivateManagedFile({
      fileSystem,
      path: join(installRoot, ".env"),
      platform,
      lockDownPath,
      label: "private environment",
    });
    validateCanonicalAssistantEnv(environment.contents);
    const resources = await inspectOwnedProject({
      installation,
      runProcess,
      cwd: installRoot,
      dockerHost: plan.dockerHost,
      allowAbsent: true,
      allowPartial: true,
      returnDetails: true,
    });
    const snapshot = localN8nAssistantSnapshot(installation);
    if (!resources.exists || !resources.exact) {
      return { target, managed: true, state: "partial" };
    }
    const expectedServices = getAssistantServiceNames(installation).filter(
      (service) => service !== "relmio-sandbox-certs",
    );
    const status = await runOrThrow(
      runProcess,
      {
        file: "docker",
        args: createComposeArgs(installation.projectName, [
          "ps",
          "--all",
          "--format",
          "json",
          ...expectedServices,
        ]),
        cwd: installRoot,
        dockerHost: plan.dockerHost,
      },
      "Local companion inventory check",
    );
    const records = parseJsonRecords(status.stdout, "Local companion inventory check");
    validateAssistantInventoryRecords(records, expectedServices);
    if (records.length !== expectedServices.length) {
      return { target, managed: true, state: "partial", snapshot };
    }
    const states = records.map((record) => record.State);
    if (
      states.every((value) => value === "running") &&
      runningAssistantHealthchecksAreHealthy(records)
    ) {
      return { target, managed: true, state: "healthy", snapshot };
    }
    if (
      states.every((value) => ["created", "exited"].includes(value)) &&
      records.every((record) => !["starting", "unhealthy"].includes(record.Health))
    ) {
      return { target, managed: true, state: "stopped", snapshot };
    }
    return { target, managed: true, state: "partial" };
  } catch {
    return unavailable;
  }
}

async function lstatIfExists(fileSystem, path) {
  try {
    return await fileSystem.lstat(path);
  } catch (error) {
    if (isMissing(error)) return null;
    throw new Error("Relmio could not inspect its local managed directory.");
  }
}

function assertDirectory(metadata) {
  if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
    throw new Error("Relmio refuses an unsafe local managed directory.");
  }
}

async function ensurePrivateDirectory(fileSystem, path, platform, lockDownPath) {
  const metadata = await lstatIfExists(fileSystem, path);
  if (metadata) {
    assertDirectory(metadata);
  } else {
    await fileSystem.mkdir(path, { mode: 0o700 });
  }
  await fileSystem.chmod(path, 0o700);
  if (platform === "win32") await lockDownPath(path, { platform });
}

async function writeManagedFile(
  fileSystem,
  path,
  contents,
  mode,
  lockDownPath = lockDownLocalPath,
) {
  const existing = await lstatIfExists(fileSystem, path);
  if (existing && (existing.isSymbolicLink() || !existing.isFile())) {
    throw new Error("Relmio refuses to replace a non-file in its managed directory.");
  }
  const temporaryPath = `${path}.tmp-${randomUUID()}`;
  try {
    await fileSystem.writeFile(temporaryPath, contents, { flag: "wx", mode });
    await fileSystem.chmod(temporaryPath, mode);
    if (process.platform === "win32") {
      await lockDownPath(temporaryPath, {
        platform: process.platform,
        kind: "file",
      });
    }
    await fileSystem.rename(temporaryPath, path);
  } catch {
    try {
      await fileSystem.unlink(temporaryPath);
    } catch {
      // The temporary file may not have been created.
    }
    throw new Error("Relmio could not write its local n8n Assistant files.");
  }
}

function validateMarker(marker) {
  if (
    marker?.schemaVersion !== MARKER_SCHEMA_VERSION ||
    marker?.kind !== "relmio-local-n8n-assistant" ||
    marker?.target !== LOCAL_N8N_ASSISTANT_TARGET
  ) {
    throw new TypeError("The local n8n Assistant marker is invalid.");
  }
  const plan = normalizeLocalN8nAssistantPlan(marker.plan);
  const installation = validateAssistantInstallation(marker.installation);
  validateLocalDockerHost(plan.dockerHost);
  return { plan, installation };
}

function sameCanonicalValue(left, right) {
  return JSON.stringify(left) === JSON.stringify(right);
}

function createSearxngUpdateReview({ plan, installation }) {
  if (installation.includeSearxng) {
    throw new Error("SearXNG web search is already enabled for this local n8n Assistant installation.");
  }
  return Object.freeze({
    schemaVersion: SEARXNG_UPDATE_REVIEW_SCHEMA_VERSION,
    kind: SEARXNG_UPDATE_REVIEW_KIND,
    target: LOCAL_N8N_ASSISTANT_TARGET,
    plan,
    installation,
    includeSearxng: true,
    sandboxApiKeyRotated: false,
  });
}

function validateSearxngUpdateReview(value) {
  const expectedKeys = [
    "schemaVersion",
    "kind",
    "target",
    "plan",
    "installation",
    "includeSearxng",
    "sandboxApiKeyRotated",
  ];
  if (
    !value || typeof value !== "object" || Array.isArray(value) ||
    Object.keys(value).length !== expectedKeys.length ||
    expectedKeys.some((key) => !Object.hasOwn(value, key)) ||
    value.schemaVersion !== SEARXNG_UPDATE_REVIEW_SCHEMA_VERSION ||
    value.kind !== SEARXNG_UPDATE_REVIEW_KIND ||
    value.target !== LOCAL_N8N_ASSISTANT_TARGET ||
    value.includeSearxng !== true ||
    value.sandboxApiKeyRotated !== false
  ) {
    throw new TypeError("Review a fresh SearXNG enablement plan before updating the local n8n Assistant tools.");
  }
  const plan = normalizeLocalN8nAssistantPlan(value.plan);
  const installation = validateAssistantInstallation(value.installation);
  if (plan.includeSearxng || installation.includeSearxng) {
    throw new Error("SearXNG web search is already enabled for this local n8n Assistant installation.");
  }
  return createSearxngUpdateReview({ plan, installation });
}

async function inspectManagedInstall({ fileSystem, installRoot }) {
  const relmioHome = resolve(installRoot, "..", "..");
  const localRoot = join(relmioHome, "local");
  const homeMetadata = await lstatIfExists(fileSystem, relmioHome);
  if (!homeMetadata) return { baseExists: false, marker: null };
  assertDirectory(homeMetadata);

  const rootMarkerPath = join(relmioHome, ROOT_MARKER);
  const rootMarkerMetadata = await lstatIfExists(fileSystem, rootMarkerPath);
  if (
    !rootMarkerMetadata ||
    rootMarkerMetadata.isSymbolicLink() ||
    !rootMarkerMetadata.isFile()
  ) {
    throw new Error(
      "The Relmio local storage directory is not an owned managed root. Nothing was changed.",
    );
  }
  try {
    const rootMarker = JSON.parse(
      await fileSystem.readFile(rootMarkerPath, "utf8"),
    );
    if (
      rootMarker?.schemaVersion !== ROOT_MARKER_SCHEMA_VERSION ||
      rootMarker?.kind !== "relmio-local-root"
    ) {
      throw new TypeError();
    }
  } catch {
    throw new Error("The Relmio local managed-root marker is invalid.");
  }

  for (const path of [localRoot, installRoot]) {
    const metadata = await lstatIfExists(fileSystem, path);
    if (metadata) assertDirectory(metadata);
  }
  const installMetadata = await lstatIfExists(fileSystem, installRoot);
  if (!installMetadata) return { baseExists: true, marker: null };

  const markerPath = join(installRoot, MANAGED_MARKER);
  const markerMetadata = await lstatIfExists(fileSystem, markerPath);
  if (
    !markerMetadata ||
    markerMetadata.isSymbolicLink() ||
    !markerMetadata.isFile()
  ) {
    throw new Error(
      "The local n8n Assistant directory is unmanaged. Nothing was overwritten.",
    );
  }
  try {
    const marker = JSON.parse(await fileSystem.readFile(markerPath, "utf8"));
    validateMarker(marker);
    return { baseExists: true, marker };
  } catch {
    throw new Error("The local n8n Assistant managed marker is invalid.");
  }
}

async function initializeManagedDirectories({
  fileSystem,
  installRoot,
  baseExists,
  platform,
  lockDownPath,
}) {
  const relmioHome = resolve(installRoot, "..", "..");
  if (!baseExists) {
    let createdIdentity = null;
    try {
      await fileSystem.mkdir(relmioHome, { mode: 0o700 });
      createdIdentity = await captureFreshDirectoryIdentity(fileSystem, relmioHome);
      await fileSystem.chmod(relmioHome, 0o700);
      if (platform === "win32") await lockDownPath(relmioHome, { platform });
      await writeManagedFile(
        fileSystem,
        join(relmioHome, ROOT_MARKER),
        `${JSON.stringify({
          schemaVersion: ROOT_MARKER_SCHEMA_VERSION,
          kind: "relmio-local-root",
        })}\n`,
        0o600,
        lockDownPath,
      );
    } catch (error) {
      await removeFreshEmptyManagedRoot({
        fileSystem,
        relmioHome,
        createdIdentity,
      });
      throw error;
    }
  }
  await fileSystem.chmod(relmioHome, 0o700);
  if (platform === "win32") await lockDownPath(relmioHome, { platform });
  await ensurePrivateDirectory(fileSystem, join(relmioHome, "local"), platform, lockDownPath);
  await ensurePrivateDirectory(fileSystem, installRoot, platform, lockDownPath);
}

function privateFileFingerprint(metadata, contents) {
  return Object.freeze({
    dev: metadata.dev,
    ino: metadata.ino,
    size: metadata.size,
    mtimeMs: metadata.mtimeMs,
    ctimeMs: metadata.ctimeMs,
    contents,
  });
}

function samePrivateFileFingerprint(left, right) {
  return (
    left.dev === right.dev &&
    left.ino === right.ino &&
    left.size === right.size &&
    left.mtimeMs === right.mtimeMs &&
    left.ctimeMs === right.ctimeMs &&
    left.contents === right.contents
  );
}

async function readCanonicalPrivateManagedFile({
  fileSystem,
  path,
  expectedContents,
  platform,
  lockDownPath,
  label,
}) {
  let metadata = await lstatIfExists(fileSystem, path);
  if (!metadata || metadata.isSymbolicLink() || !metadata.isFile()) {
    throw new Error(`The local n8n Assistant ${label} is missing or unsafe.`);
  }
  // `platform` may describe a synthetic Docker fixture in tests; filesystem
  // protection must always follow the host running Relmio.
  if (process.platform !== "win32" && (metadata.mode & 0o777) !== 0o600) {
    throw new Error(`The local n8n Assistant ${label} is not private.`);
  }
  if (process.platform === "win32") {
    await lockDownPath(path, { platform: process.platform, kind: "file", verifyOnly: true });
    metadata = await lstatIfExists(fileSystem, path);
    if (!metadata || metadata.isSymbolicLink() || !metadata.isFile()) {
      throw new Error(`The local n8n Assistant ${label} changed during inspection.`);
    }
  }
  let contents;
  try {
    contents = await fileSystem.readFile(path, "utf8");
  } catch {
    throw new Error(`Relmio could not read the local n8n Assistant ${label}.`);
  }
  const afterRead = await lstatIfExists(fileSystem, path);
  if (!afterRead || afterRead.isSymbolicLink() || !afterRead.isFile()) {
    throw new Error(`The local n8n Assistant ${label} changed during inspection.`);
  }
  const before = privateFileFingerprint(metadata, contents);
  const after = privateFileFingerprint(afterRead, contents);
  if (!samePrivateFileFingerprint(before, after)) {
    throw new Error(`The local n8n Assistant ${label} changed during inspection.`);
  }
  if (expectedContents !== undefined && contents !== expectedContents) {
    throw new Error(`The local n8n Assistant ${label} is not the reviewed canonical file.`);
  }
  return before;
}

function validateCanonicalAssistantEnv(contents) {
  const fields = [
    "SANDBOX_API_KEYS",
    "SANDBOX_API_RUNNER_REGISTRATION_TOKEN",
    "SANDBOX_API_RUNNER_API_KEY",
    "SEARXNG_SECRET",
  ];
  const expression = new RegExp(
    `^${fields.map((field) => `${field}=([A-Za-z0-9_-]{43})`).join("\\n")}\\n$`,
    "u",
  );
  const match = expression.exec(contents);
  if (!match || new Set(match.slice(1)).size !== fields.length) {
    throw new Error("The local n8n Assistant private environment is not canonical.");
  }
}

async function assertNewManagedFileAbsent({ fileSystem, path, label }) {
  const metadata = await lstatIfExists(fileSystem, path);
  if (metadata) {
    throw new Error(`The local n8n Assistant ${label} already exists or changed. Nothing was overwritten.`);
  }
}

function isDirectoryIdentity(metadata, identity) {
  return (
    metadata !== null &&
    !metadata.isSymbolicLink() &&
    metadata.isDirectory() &&
    identity !== null &&
    metadata.dev === identity.dev &&
    metadata.ino === identity.ino
  );
}

async function captureFreshDirectoryIdentity(fileSystem, path) {
  const metadata = await lstatIfExists(fileSystem, path);
  if (!metadata || metadata.isSymbolicLink() || !metadata.isDirectory()) {
    throw new Error("Relmio could not verify its newly created local storage directory.");
  }
  return { dev: metadata.dev, ino: metadata.ino };
}

async function removeFreshEmptyManagedRoot({
  fileSystem,
  relmioHome,
  createdIdentity,
}) {
  if (!createdIdentity) return;
  try {
    let metadata = await lstatIfExists(fileSystem, relmioHome);
    if (!isDirectoryIdentity(metadata, createdIdentity)) return;
    if ((await fileSystem.readdir(relmioHome)).length !== 0) return;
    metadata = await lstatIfExists(fileSystem, relmioHome);
    if (!isDirectoryIdentity(metadata, createdIdentity)) return;
    await fileSystem.rmdir(relmioHome);
  } catch {
    // Only an exact, still-empty root may be removed during recovery.
  }
}

async function acquireAssistantLock({
  fileSystem,
  getProcessIdentity,
  installRoot,
  lockDownPath,
  now,
  platform,
}) {
  const safeInstallRoot = validateInstallRoot(installRoot);
  const lockPath = join(
    dirname(resolve(safeInstallRoot, "..", "..")),
    ".relmio-local-n8n-ai-assistant.lock",
  );
  return acquireLocalIntegrationLifecycleLock({
    fileSystem,
    getProcessIdentity,
    lockDownPath,
    lockPath,
    now,
    // Process identity and ACL evidence must describe the host that is
    // actually running Relmio. `platform` can be a synthetic Docker fixture.
    platform: process.platform,
    label: "local n8n Assistant operation lock",
  });
}

async function runOrThrow(runProcess, spec, label) {
  const result = await runProcess(spec);
  if (result.code !== 0) throw new Error(`${label} failed.`);
  return result;
}

function parseJson(value, label) {
  if (
    typeof value !== "string" ||
    value.length === 0 ||
    Buffer.byteLength(value) > MAX_DOCKER_METADATA_BYTES
  ) {
    throw new Error(`${label} returned invalid Docker metadata.`);
  }
  try {
    return JSON.parse(value);
  } catch {
    throw new Error(`${label} returned invalid Docker metadata.`);
  }
}

function parseJsonRecords(value, label) {
  if (
    typeof value !== "string" ||
    Buffer.byteLength(value) > MAX_DOCKER_METADATA_BYTES
  ) {
    throw new Error(`${label} returned invalid Docker metadata.`);
  }
  if (value.trim() === "") return [];
  try {
    const parsed = JSON.parse(value);
    return Array.isArray(parsed) ? parsed : [parsed];
  } catch {
    return value.trim().split("\n").map((line) => parseJson(line, label));
  }
}

function parseLabels(value) {
  if (value && typeof value === "object" && !Array.isArray(value)) {
    return value;
  }
  if (typeof value !== "string") return null;
  const labels = {};
  for (const entry of value.split(",")) {
    const separator = entry.indexOf("=");
    if (separator <= 0) continue;
    labels[entry.slice(0, separator)] = entry.slice(separator + 1);
  }
  return labels;
}

function createComposeArgs(projectName, tail) {
  return [
    "compose",
    "--project-name",
    projectName,
    "--file",
    COMPOSE_FILENAME,
    ...tail,
  ];
}

async function attestReviewedTarget({
  plan,
  runProcess,
  cwd,
  env,
  platform,
}) {
  const discovery = await discoverLocalN8nSidecarTargets({
    runProcess,
    cwd,
    env,
    platform,
  });
  if (discovery.dockerHost !== plan.dockerHost) {
    throw new Error(
      "The selected Docker context changed. Review a fresh n8n Assistant plan.",
    );
  }
  const container = discovery.containers.find(
    (candidate) =>
      candidate.containerId === plan.n8nContainerId &&
      candidate.containerName === plan.n8nContainerName,
  );
  const network = container?.networks.find(
    (candidate) =>
      candidate.dockerNetworkId === plan.dockerNetworkId &&
      candidate.networkName === plan.networkName,
  );
  if (!container || !network) {
    const selected = await inspectSelectedNetwork({ plan, runProcess, cwd });
    if (
      selected?.Id === plan.dockerNetworkId &&
      selected?.Name === plan.networkName &&
      selected?.Internal === true
    ) {
      throw new Error(
        "The selected n8n Docker network has no outbound Internet access. Choose a non-internal Docker network and review a fresh plan.",
      );
    }
    throw new Error(
      "The selected n8n container or Docker network changed. Review a fresh plan.",
    );
  }
}

async function inspectSelectedNetwork({ plan, runProcess, cwd }) {
  const result = await runOrThrow(
    runProcess,
    {
      file: "docker",
      args: [
        "network",
        "inspect",
        "--format",
        "{{json .}}",
        plan.networkName,
      ],
      cwd,
      dockerHost: plan.dockerHost,
    },
    "Selected n8n network inspection",
  );
  return parseJson(result.stdout.trim(), "Selected n8n network inspection");
}

async function attestAliasesAreFree({
  plan,
  installation,
  runProcess,
  cwd,
}) {
  const network = await inspectSelectedNetwork({ plan, runProcess, cwd });
  if (network?.Id !== plan.dockerNetworkId || network?.Name !== plan.networkName) {
    throw new Error(
      "The selected n8n Docker network changed. Review a fresh plan.",
    );
  }
  if (network.Internal !== false) {
    throw new Error(
      "The selected n8n Docker network has no outbound Internet access. Choose a non-internal Docker network and review a fresh plan.",
    );
  }
  const reserved = new Set([
    installation.sandboxAlias,
    ...(installation.includeSearxng ? [installation.searxngAlias] : []),
  ]);
  const endpoints = network?.Containers;
  if (!endpoints || typeof endpoints !== "object" || Array.isArray(endpoints)) {
    throw new Error("The selected n8n Docker network metadata is invalid.");
  }
  for (const endpoint of Object.values(endpoints)) {
    const names = [
      endpoint?.Name,
      ...(Array.isArray(endpoint?.Aliases) ? endpoint.Aliases : []),
    ];
    if (names.some((name) => typeof name === "string" && reserved.has(name))) {
      throw new Error(
        "A generated n8n Assistant network alias has a collision. Nothing was changed.",
      );
    }
  }
}

async function listDockerResources({
  kind,
  filter,
  runProcess,
  cwd,
  dockerHost,
}) {
  const args = kind === "container"
    ? [
        "container",
        "ls",
        "-a",
        "--no-trunc",
        "--filter",
        filter,
        "--format",
        "{{json .}}",
      ]
    : [kind, "ls", "--filter", filter, "--format", "{{json .}}"];
  const result = await runOrThrow(
    runProcess,
    { file: "docker", args, cwd, dockerHost },
    `Docker ${kind} ownership inspection`,
  );
  return parseJsonRecords(result.stdout, `Docker ${kind} ownership inspection`);
}

async function attestNoResourceCollisions({
  installation,
  runProcess,
  cwd,
  dockerHost,
}) {
  const names = getAssistantManagedResourceNames(installation);
  for (const name of names.containers) {
    const records = await listDockerResources({
      kind: "container",
      filter: `name=^/${name}$`,
      runProcess,
      cwd,
      dockerHost,
    });
    if (records.length !== 0) {
      throw new Error("A Docker container uses the generated companion identity.");
    }
  }
  for (const [kind, name] of [
    ["network", names.network],
    ["volume", names.volume],
  ]) {
    const records = await listDockerResources({
      kind,
      filter: `name=^${name}$`,
      runProcess,
      cwd,
      dockerHost,
    });
    if (records.length !== 0) {
      throw new Error(`A Docker ${kind} uses the generated companion identity.`);
    }
  }
  for (const kind of ["container", "network", "volume"]) {
    const records = await listDockerResources({
      kind,
      filter: `label=com.docker.compose.project=${installation.projectName}`,
      runProcess,
      cwd,
      dockerHost,
    });
    if (records.length !== 0) {
      throw new Error("A Docker resource uses the generated companion project identity.");
    }
  }
}

function requireOwnedLabels(record, installation) {
  const labels = parseLabels(record?.Labels);
  if (
    !labels ||
    labels["com.docker.compose.project"] !== installation.projectName ||
    labels["io.relmio.ai-assistant.managed"] !== "true" ||
    labels["io.relmio.ai-assistant.install-id"] !== installation.installId
  ) {
    throw new Error(
      "A local companion Docker resource does not have matching Relmio ownership.",
    );
  }
}

async function inspectOwnedProject({
  installation,
  runProcess,
  cwd,
  dockerHost,
  allowAbsent = false,
  allowPartial = false,
  returnDetails = false,
}) {
  const expectedNames = getAssistantManagedResourceNames(installation);
  const resources = {
    containers: await listDockerResources({
      kind: "container",
      filter: `label=com.docker.compose.project=${installation.projectName}`,
      runProcess,
      cwd,
      dockerHost,
    }),
    networks: await listDockerResources({
      kind: "network",
      filter: `label=com.docker.compose.project=${installation.projectName}`,
      runProcess,
      cwd,
      dockerHost,
    }),
    volumes: await listDockerResources({
      kind: "volume",
      filter: `label=com.docker.compose.project=${installation.projectName}`,
      runProcess,
      cwd,
      dockerHost,
    }),
  };
  const count = Object.values(resources).reduce(
    (total, records) => total + records.length,
    0,
  );
  if (count === 0) {
    if (allowAbsent) {
      return returnDetails ? { exists: false, exact: false } : false;
    }
    throw new Error("The local companion Docker resource set is incomplete.");
  }
  for (const record of [
    ...resources.containers,
    ...resources.networks,
    ...resources.volumes,
  ]) {
    requireOwnedLabels(record, installation);
  }
  const containerNames = new Set(resources.containers.map((record) => record.Names));
  const exactResourceSet =
    resources.containers.length === expectedNames.containers.length &&
    resources.networks.length === 1 &&
    resources.volumes.length === 1 &&
    expectedNames.containers.every((name) => containerNames.has(name)) &&
    resources.networks[0]?.Name === expectedNames.network &&
    resources.volumes[0]?.Name === expectedNames.volume;
  if (!allowPartial && !exactResourceSet) {
    throw new Error("The local companion Docker resource set is incomplete.");
  }
  if (
    containerNames.size !== resources.containers.length ||
    [...containerNames].some((name) => !expectedNames.containers.includes(name)) ||
    resources.networks.length > 1 ||
    resources.networks.some((record) => record.Name !== expectedNames.network) ||
    resources.volumes.length > 1 ||
    resources.volumes.some((record) => record.Name !== expectedNames.volume)
  ) {
    throw new Error("The local companion Docker resource identity is invalid.");
  }
  return returnDetails ? { exists: true, exact: exactResourceSet } : true;
}

function assertNoPublishedPorts(records, expectedServices) {
  if (
    records.length !== expectedServices.length ||
    new Set(records.map((record) => record?.Service)).size !== records.length ||
    expectedServices.some(
      (service) => !records.some((record) => record?.Service === service),
    )
  ) {
    throw new Error(
      "The local companion host-publication service set is invalid.",
    );
  }
  for (const record of records) {
    const publishers = record?.Publishers;
    if (!Array.isArray(publishers)) {
      throw new Error("The local companion published-port metadata is invalid.");
    }
    for (const publisher of publishers) {
      if (
        !publisher ||
        !Number.isInteger(publisher.PublishedPort) ||
        typeof publisher.URL !== "string" ||
        publisher.PublishedPort < 0
      ) {
        throw new Error("The local companion published-port metadata is invalid.");
      }
      if (publisher.PublishedPort > 0 || publisher.URL !== "") {
        throw new Error("A local companion published a host port.");
      }
    }
  }
}

async function verifyRunningCompanions({
  plan,
  installation,
  runProcess,
  installRoot,
}) {
  await inspectOwnedProject({
    installation,
    runProcess,
    cwd: installRoot,
    dockerHost: plan.dockerHost,
  });
  const expectedServices = getAssistantServiceNames(installation).filter(
    (service) => service !== "relmio-sandbox-certs",
  );
  const running = await runOrThrow(
    runProcess,
    {
      file: "docker",
      args: createComposeArgs(installation.projectName, [
        "ps",
        "--status",
        "running",
        "--services",
      ]),
      cwd: installRoot,
      dockerHost: plan.dockerHost,
    },
    "Local companion running-service verification",
  );
  const runningServices = running.stdout.trim() === ""
    ? []
    : running.stdout.trim().split("\n").sort();
  if (
    runningServices.length !== expectedServices.length ||
    expectedServices.some((service) => !runningServices.includes(service))
  ) {
    throw new Error("The local companion service set is not running.");
  }
  const publication = await runOrThrow(
    runProcess,
    {
      file: "docker",
      args: createComposeArgs(installation.projectName, ["ps", "--format", "json"]),
      cwd: installRoot,
      dockerHost: plan.dockerHost,
    },
    "Local companion host-publication verification",
  );
  assertNoPublishedPorts(
    parseJsonRecords(publication.stdout, "Local companion host-publication verification"),
    expectedServices,
  );
  await runOrThrow(
    runProcess,
    {
      file: "docker",
      args: createComposeArgs(installation.projectName, [
        "exec",
        "-T",
        "relmio-sandbox-api",
        "wget",
        "-qO-",
        "http://127.0.0.1:8080/healthz",
      ]),
      cwd: installRoot,
      dockerHost: plan.dockerHost,
    },
    "Local Code Sandbox health verification",
  );
  if (installation.includeSearxng) {
    const search = await runOrThrow(
      runProcess,
      {
        file: "docker",
        args: createComposeArgs(installation.projectName, [
          "exec",
          "-T",
          "relmio-sandbox-api",
          "wget",
          "-qO-",
          `http://${installation.searxngAlias}:8080/search?q=relmio&format=json`,
        ]),
        cwd: installRoot,
        dockerHost: plan.dockerHost,
      },
      "Local SearXNG JSON verification",
    );
    let payload;
    try {
      payload = JSON.parse(search.stdout);
    } catch {
      throw new Error("The local SearXNG service did not return JSON.");
    }
    if (!Array.isArray(payload?.results)) {
      throw new Error("The local SearXNG service returned an invalid result.");
    }
  }
}

async function cleanupCompanionProject({
  installation,
  runProcess,
  installRoot,
  dockerHost,
}) {
  await runOrThrow(
    runProcess,
    {
      file: "docker",
      args: createComposeArgs(installation.projectName, [
        "down",
        "--volumes",
        "--remove-orphans",
      ]),
      cwd: installRoot,
      dockerHost,
    },
    "Local companion cleanup",
  );
  const remains = await inspectOwnedProject({
    installation,
    runProcess,
    cwd: installRoot,
    dockerHost,
    allowAbsent: true,
  });
  if (remains) {
    throw new Error("Relmio could not confirm local companion cleanup.");
  }
}

async function removeNewSearxngFile({
  fileSystem,
  path,
  expectedFingerprint,
  expectedContents,
  label,
}) {
  let metadata = await lstatIfExists(fileSystem, path);
  if (!metadata || metadata.isSymbolicLink() || !metadata.isFile()) {
    throw new Error(`The local n8n Assistant ${label} changed during rollback.`);
  }
  let contents;
  try {
    contents = await fileSystem.readFile(path, "utf8");
  } catch {
    throw new Error(`Relmio could not inspect the local n8n Assistant ${label} during rollback.`);
  }
  const afterRead = await lstatIfExists(fileSystem, path);
  if (!afterRead || afterRead.isSymbolicLink() || !afterRead.isFile()) {
    throw new Error(`The local n8n Assistant ${label} changed during rollback.`);
  }
  const current = privateFileFingerprint(afterRead, contents);
  if (
    contents !== expectedContents ||
    !samePrivateFileFingerprint(expectedFingerprint, current)
  ) {
    throw new Error(`The local n8n Assistant ${label} changed during rollback.`);
  }
  await fileSystem.unlink(path);
  metadata = await lstatIfExists(fileSystem, path);
  if (metadata) {
    throw new Error(`Relmio could not remove the local n8n Assistant ${label}.`);
  }
}

async function restoreCanonicalManagedFile({
  fileSystem,
  path,
  expectedCurrentContents,
  restoreContents,
  platform,
  lockDownPath,
  label,
}) {
  await readCanonicalPrivateManagedFile({
    fileSystem,
    path,
    expectedContents: expectedCurrentContents,
    platform,
    lockDownPath,
    label,
  });
  await writeManagedFile(
    fileSystem,
    path,
    restoreContents,
    0o600,
    lockDownPath,
  );
  await readCanonicalPrivateManagedFile({
    fileSystem,
    path,
    expectedContents: restoreContents,
    platform,
    lockDownPath,
    label,
  });
}

function createN8nSettings({ installation, secrets }) {
  const sandboxUrl = `http://${installation.sandboxAlias}:8080`;
  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: sandboxUrl,
    N8N_SANDBOX_SERVICE_API_KEY: secrets.sandboxApiKey,
  };
  if (installation.includeSearxng) {
    settings.N8N_INSTANCE_AI_SEARXNG_URL =
      `http://${installation.searxngAlias}:8080`;
  }
  return settings;
}

function createSanitizedSearxngUpdateResult({ plan, installation }) {
  const sandboxUrl = `http://${installation.sandboxAlias}:8080`;
  const searxngUrl = `http://${installation.searxngAlias}:8080`;
  return Object.freeze({
    target: LOCAL_N8N_ASSISTANT_TARGET,
    endpoint: sandboxUrl,
    sandboxUrl,
    searxngUrl,
    protocol: "n8n-instance-ai-companion",
    includeSearxng: true,
    networkName: plan.networkName,
    n8nContainerName: plan.n8nContainerName,
    hostPublication: "none",
    privilegedRunner: true,
    n8nConfigurationRequired: true,
    n8nSettings: Object.freeze({
      N8N_INSTANCE_AI_SEARXNG_URL: searxngUrl,
    }),
    deploymentMode: "searxng-enabled",
    sandboxApiKeyRotated: false,
  });
}

export async function installLocalN8nAssistant(
  { plan, confirmed },
  {
    fileSystem = defaultFileSystem,
    env = process.env,
    homeDirectory = homedir(),
    runProcess = runLocalProcess,
    randomBytes = createRandomBytes,
    platform = process.platform,
    lockDownPath = lockDownLocalPath,
    getProcessIdentity,
    lifecycleLockNow = Date.now,
  } = {},
) {
  if (confirmed !== true) {
    throw new Error(
      "Confirm the reviewed privileged n8n Assistant companion plan before installing.",
    );
  }
  assertSupportedPlatform(platform);
  rejectDockerEnvironmentOverrides(env);
  const normalizedPlan = normalizeLocalN8nAssistantPlan(plan);
  const installRoot = await resolveLocalN8nAssistantInstallRoot({
    env,
    homeDirectory,
    fileSystem,
    platform,
  });
  const releaseLock = await acquireAssistantLock({
    fileSystem,
    getProcessIdentity,
    installRoot,
    lockDownPath,
    now: lifecycleLockNow,
    platform,
  });
  return settleLocalIntegrationLifecycleOperation({
    completionLabel: "Local n8n Assistant companion installation",
    releaseLock,
    operation: async () => {
      let installation;
      let managedDirectoryCreated = false;
      let deploymentStarted = false;
      try {
    const managed = await inspectManagedInstall({ fileSystem, installRoot });
    if (managed.marker) {
      throw new Error(
        "The managed local n8n Assistant tools are already installed. Remove them before installing a fresh companion stack.",
      );
    }
    const workingDirectory = dirname(resolve(installRoot, "..", ".."));
    await attestReviewedTarget({
      plan: normalizedPlan,
      runProcess,
      cwd: workingDirectory,
      env,
      platform,
    });
    installation = createAssistantInstallation({
      randomBytes,
      includeSearxng: normalizedPlan.includeSearxng,
    });
    const secrets = createAssistantSecrets({
      randomBytes,
      includeSearxng: normalizedPlan.includeSearxng,
    });
    await attestAliasesAreFree({
      plan: normalizedPlan,
      installation,
      runProcess,
      cwd: workingDirectory,
    });
    await attestNoResourceCollisions({
      installation,
      runProcess,
      cwd: workingDirectory,
      dockerHost: normalizedPlan.dockerHost,
    });
    await initializeManagedDirectories({
      fileSystem,
      installRoot,
      baseExists: managed.baseExists,
      platform,
      lockDownPath,
    });
    managedDirectoryCreated = true;
    await writeManagedFile(
      fileSystem,
      join(installRoot, COMPOSE_FILENAME),
      createAssistantComposeFile({
        networkName: normalizedPlan.networkName,
        installation,
      }),
      0o600,
      lockDownPath,
    );
    await writeManagedFile(
      fileSystem,
      join(installRoot, ".env"),
      createAssistantEnv(secrets, {
        includeSearxng: normalizedPlan.includeSearxng,
      }),
      0o600,
      lockDownPath,
    );
    if (normalizedPlan.includeSearxng) {
      await writeManagedFile(
        fileSystem,
        join(installRoot, "searxng-settings.yml"),
        createSearxngSettings(),
        0o600,
        lockDownPath,
      );
    }
    await writeManagedFile(
      fileSystem,
      join(installRoot, MANAGED_MARKER),
      `${JSON.stringify({
        schemaVersion: MARKER_SCHEMA_VERSION,
        kind: "relmio-local-n8n-assistant",
        target: LOCAL_N8N_ASSISTANT_TARGET,
        plan: normalizedPlan,
        installation,
      })}\n`,
      0o600,
      lockDownPath,
    );
    await runOrThrow(
      runProcess,
      {
        file: "docker",
        args: createComposeArgs(installation.projectName, ["config", "--quiet"]),
        cwd: installRoot,
        dockerHost: normalizedPlan.dockerHost,
      },
      "Local n8n Assistant Compose validation",
    );
    await attestReviewedTarget({
      plan: normalizedPlan,
      runProcess,
      cwd: installRoot,
      env,
      platform,
    });
    await attestAliasesAreFree({
      plan: normalizedPlan,
      installation,
      runProcess,
      cwd: installRoot,
    });
    await attestNoResourceCollisions({
      installation,
      runProcess,
      cwd: installRoot,
      dockerHost: normalizedPlan.dockerHost,
    });

    deploymentStarted = true;
    await runOrThrow(
      runProcess,
      {
        file: "docker",
        args: createComposeArgs(installation.projectName, [
          "up",
          "-d",
          "--wait",
          "--wait-timeout",
          "90",
          ...getAssistantServiceNames(installation),
        ]),
        cwd: installRoot,
        dockerHost: normalizedPlan.dockerHost,
      },
      "Local n8n Assistant companion start",
    );
    await verifyRunningCompanions({
      plan: normalizedPlan,
      installation,
      runProcess,
      installRoot,
    });
    const n8nSettings = createN8nSettings({ installation, secrets });
    return {
      target: LOCAL_N8N_ASSISTANT_TARGET,
      endpoint: n8nSettings.N8N_SANDBOX_SERVICE_URL,
      sandboxUrl: n8nSettings.N8N_SANDBOX_SERVICE_URL,
      sandboxApiKey: secrets.sandboxApiKey,
      ...(installation.includeSearxng
        ? { searxngUrl: n8nSettings.N8N_INSTANCE_AI_SEARXNG_URL }
        : {}),
      protocol: "n8n-instance-ai-companion",
      includeSearxng: installation.includeSearxng,
      networkName: normalizedPlan.networkName,
      n8nContainerName: normalizedPlan.n8nContainerName,
      hostPublication: "none",
      privilegedRunner: true,
      n8nConfigurationRequired: true,
      n8nSettings,
      deploymentMode: "installed",
      credentialShownOnce: true,
    };
      } catch (error) {
    if (deploymentStarted && installation) {
      try {
        const resourcesExist = await inspectOwnedProject({
          installation,
          runProcess,
          cwd: installRoot,
          dockerHost: normalizedPlan.dockerHost,
          allowAbsent: true,
          allowPartial: true,
        });
        if (resourcesExist) {
          await cleanupCompanionProject({
            installation,
            runProcess,
            installRoot,
            dockerHost: normalizedPlan.dockerHost,
          });
        }
      } catch {
        throw new Error(
          "Local companion verification failed and automatic cleanup could not be confirmed. Inspect only the Relmio n8n Assistant project before retrying.",
        );
      }
    }
    if (managedDirectoryCreated) {
      try {
        await fileSystem.rm(installRoot, { recursive: true, force: false });
      } catch {
        throw new Error(
          "Local companion installation failed and managed-file cleanup could not be confirmed.",
        );
      }
    }
        throw error;
      }
    },
  });
}

/**
 * Read the identity of an existing SearXNG-free local Assistant installation
 * for a separately confirmed additive update. This changes neither Docker nor
 * the private environment.
 */
export async function prepareLocalN8nAssistantSearxngUpdate(
  { includeSearxng },
  {
    fileSystem = defaultFileSystem,
    env = process.env,
    homeDirectory = homedir(),
    platform = process.platform,
  } = {},
) {
  if (includeSearxng !== true) {
    throw new Error("This local n8n Assistant edit can only enable SearXNG web search.");
  }
  assertSupportedPlatform(platform);
  rejectDockerEnvironmentOverrides(env);
  const installRoot = await resolveLocalN8nAssistantInstallRoot({
    env,
    homeDirectory,
    fileSystem,
    platform,
  });
  const managed = await inspectManagedInstall({ fileSystem, installRoot });
  if (!managed.marker) {
    throw new Error("The managed local n8n Assistant tools are not installed.");
  }
  return createSearxngUpdateReview(validateMarker(managed.marker));
}

async function attestSearxngIdentityIsFree({
  plan,
  installation,
  runProcess,
  cwd,
}) {
  const network = await inspectSelectedNetwork({ plan, runProcess, cwd });
  if (
    network?.Id !== plan.dockerNetworkId ||
    network?.Name !== plan.networkName ||
    network?.Internal !== false ||
    !network.Containers ||
    typeof network.Containers !== "object" ||
    Array.isArray(network.Containers)
  ) {
    throw new Error("The selected n8n Docker network changed. Review a fresh SearXNG enablement plan.");
  }
  for (const endpoint of Object.values(network.Containers)) {
    const aliases = Array.isArray(endpoint?.Aliases) ? endpoint.Aliases : [];
    if (endpoint?.Name === installation.searxngAlias || aliases.includes(installation.searxngAlias)) {
      throw new Error("The generated SearXNG network alias is already in use. Nothing was changed.");
    }
  }
  const names = getAssistantContainerNames(installation);
  const containers = await listDockerResources({
    kind: "container",
    filter: `name=^/${names.searxng}$`,
    runProcess,
    cwd,
    dockerHost: plan.dockerHost,
  });
  if (containers.length !== 0) {
    throw new Error("A Docker container uses the generated SearXNG identity. Nothing was changed.");
  }
}

async function rollbackSearxngEnablement({
  fileSystem,
  installRoot,
  lockDownPath,
  newCompose,
  newInstallation,
  oldCompose,
  oldEnv,
  oldInstallation,
  oldMarkerContents,
  oldPlan,
  platform,
  runProcess,
  searxngSettingsFingerprint,
  searxngStartAttempted,
}) {
  if (searxngStartAttempted) {
    const names = getAssistantContainerNames(newInstallation);
    const containers = await listDockerResources({
      kind: "container",
      filter: `name=^/${names.searxng}$`,
      runProcess,
      cwd: installRoot,
      dockerHost: oldPlan.dockerHost,
    });
    if (containers.length > 1) {
      throw new Error("The local SearXNG rollback identity is ambiguous.");
    }
    if (containers.length === 1) {
      const [container] = containers;
      const labels = parseLabels(container?.Labels);
      const containerId = validateDockerObjectId(
        container?.ID,
        "local SearXNG container",
      );
      if (
        container?.Names !== names.searxng ||
        labels?.["com.docker.compose.project"] !== oldInstallation.projectName ||
        labels?.["com.docker.compose.service"] !== "relmio-searxng" ||
        labels?.["io.relmio.ai-assistant.managed"] !== "true" ||
        labels?.["io.relmio.ai-assistant.install-id"] !== oldInstallation.installId
      ) {
        throw new Error("The local SearXNG rollback ownership could not be verified.");
      }
      await runOrThrow(
        runProcess,
        {
          file: "docker",
          args: ["container", "rm", "--force", containerId],
          cwd: installRoot,
          dockerHost: oldPlan.dockerHost,
        },
        "Local SearXNG rollback",
      );
    }
  }
  await inspectOwnedProject({
    installation: oldInstallation,
    runProcess,
    cwd: installRoot,
    dockerHost: oldPlan.dockerHost,
  });
  await restoreCanonicalManagedFile({
    fileSystem,
    path: join(installRoot, COMPOSE_FILENAME),
    expectedCurrentContents: newCompose,
    restoreContents: oldCompose,
    platform,
    lockDownPath,
    label: "Compose file",
  });
  await removeNewSearxngFile({
    fileSystem,
    path: join(installRoot, "searxng-settings.yml"),
    expectedFingerprint: searxngSettingsFingerprint,
    expectedContents: createSearxngSettings(),
    label: "SearXNG settings file",
  });
  const environment = await readCanonicalPrivateManagedFile({
    fileSystem,
    path: join(installRoot, ".env"),
    expectedContents: oldEnv,
    platform,
    lockDownPath,
    label: "private environment",
  });
  validateCanonicalAssistantEnv(environment.contents);
  await readCanonicalPrivateManagedFile({
    fileSystem,
    path: join(installRoot, MANAGED_MARKER),
    expectedContents: oldMarkerContents,
    platform,
    lockDownPath,
    label: "managed marker",
  });
  await verifyRunningCompanions({
    plan: oldPlan,
    installation: oldInstallation,
    runProcess,
    installRoot,
  });
}

/**
 * Enable SearXNG for one exact reviewed local Assistant installation. The
 * sandbox environment and credential are never written or returned here.
 */
export async function editLocalN8nAssistantSearxng(
  { review, confirmed },
  {
    fileSystem = defaultFileSystem,
    env = process.env,
    homeDirectory = homedir(),
    runProcess = runLocalProcess,
    platform = process.platform,
    lockDownPath = lockDownLocalPath,
    getProcessIdentity,
    lifecycleLockNow = Date.now,
  } = {},
) {
  if (confirmed !== true) {
    throw new Error("Confirm enabling SearXNG web search for the reviewed local n8n Assistant installation.");
  }
  assertSupportedPlatform(platform);
  rejectDockerEnvironmentOverrides(env);
  const reviewed = validateSearxngUpdateReview(review);
  const installRoot = await resolveLocalN8nAssistantInstallRoot({
    env,
    homeDirectory,
    fileSystem,
    platform,
  });
  const releaseLock = await acquireAssistantLock({
    fileSystem,
    getProcessIdentity,
    installRoot,
    lockDownPath,
    now: lifecycleLockNow,
    platform,
  });
  return settleLocalIntegrationLifecycleOperation({
    completionLabel: "Local n8n Assistant SearXNG enablement",
    releaseLock,
    operation: async () => {
      const managed = await inspectManagedInstall({ fileSystem, installRoot });
      if (!managed.marker) {
        throw new Error("The managed local n8n Assistant tools are not installed.");
      }
      const { plan: oldPlan, installation: oldInstallation } = validateMarker(managed.marker);
      if (
        !sameCanonicalValue(reviewed.plan, oldPlan) ||
        !sameCanonicalValue(reviewed.installation, oldInstallation)
      ) {
        throw new Error("The local n8n Assistant installation changed after review. Review a fresh SearXNG enablement plan.");
      }
      if (oldPlan.includeSearxng || oldInstallation.includeSearxng) {
        throw new Error("SearXNG web search is already enabled for this local n8n Assistant installation.");
      }
      await verifyWindowsAssistantStatusPathSecurity({
        fileSystem,
        installRoot,
        installation: oldInstallation,
        platform,
        lockDownPath,
      });
      const newPlan = normalizeLocalN8nAssistantPlan({ ...oldPlan, includeSearxng: true });
      const newInstallation = validateAssistantInstallation({
        ...oldInstallation,
        includeSearxng: true,
      });
      const oldCompose = createAssistantComposeFile({
        networkName: oldPlan.networkName,
        installation: oldInstallation,
      });
      const newCompose = createAssistantComposeFile({
        networkName: newPlan.networkName,
        installation: newInstallation,
      });
      const oldMarkerContents = `${JSON.stringify(managed.marker)}\n`;
      const workingDirectory = dirname(resolve(installRoot, "..", ".."));
      await readCanonicalPrivateManagedFile({
        fileSystem,
        path: join(installRoot, MANAGED_MARKER),
        expectedContents: oldMarkerContents,
        platform,
        lockDownPath,
        label: "managed marker",
      });
      await readCanonicalPrivateManagedFile({
        fileSystem,
        path: join(installRoot, COMPOSE_FILENAME),
        expectedContents: oldCompose,
        platform,
        lockDownPath,
        label: "Compose file",
      });
      const environment = await readCanonicalPrivateManagedFile({
        fileSystem,
        path: join(installRoot, ".env"),
        platform,
        lockDownPath,
        label: "private environment",
      });
      const oldEnv = environment.contents;
      validateCanonicalAssistantEnv(oldEnv);
      await assertNewManagedFileAbsent({
        fileSystem,
        path: join(installRoot, "searxng-settings.yml"),
        label: "SearXNG settings file",
      });
      await attestReviewedTarget({
        plan: oldPlan,
        runProcess,
        cwd: workingDirectory,
        env,
        platform,
      });
      await verifyRunningCompanions({
        plan: oldPlan,
        installation: oldInstallation,
        runProcess,
        installRoot,
      });
      await attestSearxngIdentityIsFree({
        plan: oldPlan,
        installation: newInstallation,
        runProcess,
        cwd: installRoot,
      });

      let searxngSettingsFingerprint = null;
      let composeChanged = false;
      let searxngStartAttempted = false;
      try {
        await writeManagedFile(
          fileSystem,
          join(installRoot, "searxng-settings.yml"),
          createSearxngSettings(),
          0o600,
          lockDownPath,
        );
        searxngSettingsFingerprint = await readCanonicalPrivateManagedFile({
          fileSystem,
          path: join(installRoot, "searxng-settings.yml"),
          expectedContents: createSearxngSettings(),
          platform,
          lockDownPath,
          label: "SearXNG settings file",
        });
        await writeManagedFile(
          fileSystem,
          join(installRoot, COMPOSE_FILENAME),
          newCompose,
          0o600,
          lockDownPath,
        );
        composeChanged = true;
        searxngStartAttempted = true;
        await runOrThrow(
          runProcess,
          {
            file: "docker",
            args: createComposeArgs(newInstallation.projectName, ["config", "--quiet"]),
            cwd: installRoot,
            dockerHost: newPlan.dockerHost,
          },
          "Local n8n Assistant SearXNG Compose validation",
        );
        await attestReviewedTarget({
          plan: oldPlan,
          runProcess,
          cwd: installRoot,
          env,
          platform,
        });
        await verifyRunningCompanions({
          plan: oldPlan,
          installation: oldInstallation,
          runProcess,
          installRoot,
        });
        await attestSearxngIdentityIsFree({
          plan: oldPlan,
          installation: newInstallation,
          runProcess,
          cwd: installRoot,
        });
        await runOrThrow(
          runProcess,
          {
            file: "docker",
            args: createComposeArgs(newInstallation.projectName, [
              "up", "-d", "--wait", "--wait-timeout", "90", "--no-deps", "relmio-searxng",
            ]),
            cwd: installRoot,
            dockerHost: newPlan.dockerHost,
          },
          "Local SearXNG start",
        );
        await verifyRunningCompanions({
          plan: newPlan,
          installation: newInstallation,
          runProcess,
          installRoot,
        });
        const unchangedEnvironment = await readCanonicalPrivateManagedFile({
          fileSystem,
          path: join(installRoot, ".env"),
          expectedContents: oldEnv,
          platform,
          lockDownPath,
          label: "private environment",
        });
        validateCanonicalAssistantEnv(unchangedEnvironment.contents);
        await writeManagedFile(
          fileSystem,
          join(installRoot, MANAGED_MARKER),
          `${JSON.stringify({
            schemaVersion: MARKER_SCHEMA_VERSION,
            kind: "relmio-local-n8n-assistant",
            target: LOCAL_N8N_ASSISTANT_TARGET,
            plan: newPlan,
            installation: newInstallation,
          })}\n`,
          0o600,
          lockDownPath,
        );
        return createSanitizedSearxngUpdateResult({ plan: newPlan, installation: newInstallation });
      } catch (error) {
        if (!searxngSettingsFingerprint) {
          throw new Error(
            "Local SearXNG enablement failed and the new settings file could not be safely inspected for rollback. Inspect only the Relmio n8n Assistant project before retrying.",
          );
        }
        try {
          if (!composeChanged) {
            await removeNewSearxngFile({
              fileSystem,
              path: join(installRoot, "searxng-settings.yml"),
              expectedFingerprint: searxngSettingsFingerprint,
              expectedContents: createSearxngSettings(),
              label: "SearXNG settings file",
            });
            const environment = await readCanonicalPrivateManagedFile({
              fileSystem,
              path: join(installRoot, ".env"),
              expectedContents: oldEnv,
              platform,
              lockDownPath,
              label: "private environment",
            });
            validateCanonicalAssistantEnv(environment.contents);
            await readCanonicalPrivateManagedFile({
              fileSystem,
              path: join(installRoot, MANAGED_MARKER),
              expectedContents: oldMarkerContents,
              platform,
              lockDownPath,
              label: "managed marker",
            });
          } else {
            await rollbackSearxngEnablement({
              fileSystem,
              installRoot,
              lockDownPath,
              newCompose,
              newInstallation,
              oldCompose,
              oldEnv,
              oldInstallation,
              oldMarkerContents,
              oldPlan,
              platform,
              runProcess,
              searxngSettingsFingerprint,
              searxngStartAttempted,
            });
          }
        } catch {
          throw new Error(
            "Local SearXNG enablement failed and rollback could not be confirmed. Inspect only the Relmio n8n Assistant project before retrying.",
          );
        }
        throw error;
      }
    },
  });
}

export async function removeLocalN8nAssistant(
  { confirmed },
  {
    fileSystem = defaultFileSystem,
    env = process.env,
    homeDirectory = homedir(),
    runProcess = runLocalProcess,
    platform = process.platform,
    lockDownPath = lockDownLocalPath,
    getProcessIdentity,
    lifecycleLockNow = Date.now,
  } = {},
) {
  if (confirmed !== true) {
    throw new Error("Confirm removal of the managed local n8n Assistant tools.");
  }
  assertSupportedPlatform(platform);
  rejectDockerEnvironmentOverrides(env);
  const installRoot = await resolveLocalN8nAssistantInstallRoot({
    env,
    homeDirectory,
    fileSystem,
    platform,
  });
  const releaseLock = await acquireAssistantLock({
    fileSystem,
    getProcessIdentity,
    installRoot,
    lockDownPath,
    now: lifecycleLockNow,
    platform,
  });
  return settleLocalIntegrationLifecycleOperation({
    completionLabel: "Local n8n Assistant companion removal",
    releaseLock,
    operation: async () => {
    const managed = await inspectManagedInstall({ fileSystem, installRoot });
    if (!managed.marker) {
      throw new Error("The managed local n8n Assistant tools are not installed.");
    }
    const { plan, installation } = validateMarker(managed.marker);
    await verifyWindowsAssistantStatusPathSecurity({
      fileSystem,
      installRoot,
      installation,
      platform,
      lockDownPath,
    });
    await inspectOwnedProject({
      installation,
      runProcess,
      cwd: installRoot,
      dockerHost: plan.dockerHost,
    });
    await cleanupCompanionProject({
      installation,
      runProcess,
      installRoot,
      dockerHost: plan.dockerHost,
    });
    await fileSystem.rm(installRoot, { recursive: true, force: false });
      return { target: LOCAL_N8N_ASSISTANT_TARGET, removed: true };
    },
  });
}