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 }; }, }); }