relmio 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -23,9 +23,14 @@ export function isCliEntryPath(entryPath, realpath = realpathSync) {
23
23
  }
24
24
 
25
25
  export function cliMode(argumentsList) {
26
- return argumentsList.length === 1 &&
26
+ if (
27
+ argumentsList.length === 1 &&
27
28
  (argumentsList[0] === "--version" || argumentsList[0] === "-v")
28
- ? "version"
29
+ ) {
30
+ return "version";
31
+ }
32
+ return argumentsList.length === 1 && argumentsList[0] === "assistant"
33
+ ? "assistant"
29
34
  : "wizard";
30
35
  }
31
36
 
@@ -41,8 +46,12 @@ export async function runCli({
41
46
  log = console.log,
42
47
  readPackage = () => readFile(packageJsonUrl, "utf8"),
43
48
  isInteractive = hasInteractiveTerminal,
49
+ startServer = startWizardServer,
50
+ open = openBrowser,
51
+ attachReopen = attachBrowserReopenOnEnter,
44
52
  } = {}) {
45
- if (cliMode(argumentsList) === "version") {
53
+ const mode = cliMode(argumentsList);
54
+ if (mode === "version") {
46
55
  const packageJson = JSON.parse(await readPackage());
47
56
  if (typeof packageJson.version !== "string" || packageJson.version === "") {
48
57
  throw new Error("Relmio package metadata does not contain a version.");
@@ -59,21 +68,25 @@ export async function runCli({
59
68
  }
60
69
 
61
70
  const sessionToken = randomBytes(32).toString("base64url");
62
- const wizard = await startWizardServer({ sessionToken });
63
- const url = `${wizard.origin}/?session=${sessionToken}`;
71
+ const wizard = await startServer({ sessionToken });
72
+ const url = `${wizard.origin}${mode === "assistant" ? "/assistant" : "/"}?session=${sessionToken}`;
64
73
 
65
74
  log("");
66
75
  log("Relmio");
67
76
  log("---------");
68
77
  log(`Local wizard: ${url}`);
69
78
  log("");
70
- log("This creates a separate sidecar and never restarts n8n.");
79
+ log(
80
+ mode === "assistant"
81
+ ? "This creates only the separate AI Assistant companion and never restarts n8n."
82
+ : "This creates a separate sidecar and never restarts n8n.",
83
+ );
71
84
  log("Keep this Terminal window open while using the wizard.");
72
85
  log("Press Control+C to stop.");
73
86
  log("");
74
87
 
75
- openBrowser(url);
76
- const detachBrowserReopen = attachBrowserReopenOnEnter({ url });
88
+ open(url);
89
+ const detachBrowserReopen = attachReopen({ url });
77
90
 
78
91
  let closing = false;
79
92
  async function close() {
@@ -0,0 +1,207 @@
1
+ import {
2
+ getAssistantContainerNames,
3
+ getAssistantLabels,
4
+ validateAssistantInstallation,
5
+ validateAssistantSearxngSelection,
6
+ } from "./assistant.js";
7
+ import { validateDockerName } from "./validation.js";
8
+
9
+ export const ASSISTANT_COMPANION_IMAGES = Object.freeze({
10
+ api: "ghcr.io/n8n-io/n8n-sandbox-service-api:1.1.1@sha256:21672029fee08495e2398cff7fc370ff60ce0e7c461610732bf2f5265cb75704",
11
+ runner: "ghcr.io/n8n-io/n8n-sandbox-service-runner-dind:1.1.1@sha256:9de7a8aad7f0d2293716daff40206be60577a59a2c2dae641dd9a425c18bf6fd",
12
+ sandbox: "ghcr.io/n8n-io/n8n-sandbox-service-sandbox:1.1.0@sha256:16f62fb90a4ce61ef74925f62ea76bb11eb2a5598888b7c0651100c7944ed2d8",
13
+ searxng: "ghcr.io/searxng/searxng:2026.8.28-a30b2d474@sha256:addd2cf36efb4b9815a2820a522aef7cce4da0d1c0e4527f6675f5663332fc9b",
14
+ });
15
+
16
+ const SANDBOX_SECRET_FIELDS = Object.freeze([
17
+ "sandboxApiKey",
18
+ "runnerRegistrationToken",
19
+ "runnerApiKey",
20
+ ]);
21
+
22
+ function createSecret(randomBytes) {
23
+ const value = randomBytes(32);
24
+ if (!Buffer.isBuffer(value) || value.length !== 32) {
25
+ throw new TypeError("The local secret generator is unavailable.");
26
+ }
27
+ return value.toString("base64url");
28
+ }
29
+
30
+ function getRequiredSecretFields(includeSearxng) {
31
+ validateAssistantSearxngSelection(includeSearxng);
32
+ return [...SANDBOX_SECRET_FIELDS, "searxngSecret"];
33
+ }
34
+
35
+ export function createAssistantSecrets({ randomBytes, includeSearxng }) {
36
+ if (typeof randomBytes !== "function") {
37
+ throw new TypeError("A cryptographic random-byte generator is required.");
38
+ }
39
+ const selectedSearxng = validateAssistantSearxngSelection(includeSearxng);
40
+ const secrets = {
41
+ sandboxApiKey: createSecret(randomBytes),
42
+ runnerRegistrationToken: createSecret(randomBytes),
43
+ runnerApiKey: createSecret(randomBytes),
44
+ searxngSecret: createSecret(randomBytes),
45
+ };
46
+ if (new Set(Object.values(secrets)).size !== getRequiredSecretFields(selectedSearxng).length) {
47
+ throw new TypeError("AI Assistant secrets must be independently generated.");
48
+ }
49
+ return secrets;
50
+ }
51
+
52
+ export function createAssistantEnv(secrets, { includeSearxng }) {
53
+ const selectedSearxng = validateAssistantSearxngSelection(includeSearxng);
54
+ const requiredSecretFields = getRequiredSecretFields(selectedSearxng);
55
+ if (
56
+ !secrets ||
57
+ requiredSecretFields.some(
58
+ (field) =>
59
+ typeof secrets[field] !== "string" ||
60
+ !/^[A-Za-z0-9_-]{43}$/u.test(secrets[field]),
61
+ )
62
+ ) {
63
+ throw new TypeError("AI Assistant secrets are invalid.");
64
+ }
65
+ const searxngEnv = `SEARXNG_SECRET=${secrets.searxngSecret}
66
+ `;
67
+ return `SANDBOX_API_KEYS=${secrets.sandboxApiKey}
68
+ SANDBOX_API_RUNNER_REGISTRATION_TOKEN=${secrets.runnerRegistrationToken}
69
+ SANDBOX_API_RUNNER_API_KEY=${secrets.runnerApiKey}
70
+ ${searxngEnv}`;
71
+ }
72
+
73
+ export function createSearxngSettings() {
74
+ return `use_default_settings: true
75
+ search:
76
+ formats:
77
+ - html
78
+ - json
79
+ `;
80
+ }
81
+
82
+ export function createAssistantComposeFile({ networkName, installation }) {
83
+ const safeNetworkName = validateDockerName(networkName);
84
+ const safeInstallation = validateAssistantInstallation(installation);
85
+ const containerNames = getAssistantContainerNames(safeInstallation);
86
+ const labels = getAssistantLabels(safeInstallation);
87
+
88
+ return `volumes:
89
+ sandbox-tls:
90
+ name: ${safeInstallation.projectName}-sandbox-tls
91
+ labels:
92
+ io.relmio.ai-assistant.managed: "${labels["io.relmio.ai-assistant.managed"]}"
93
+ io.relmio.ai-assistant.install-id: "${labels["io.relmio.ai-assistant.install-id"]}"
94
+
95
+ services:
96
+ relmio-sandbox-certs:
97
+ container_name: ${containerNames.certs}
98
+ image: ${ASSISTANT_COMPANION_IMAGES.api}
99
+ user: '0:0'
100
+ entrypoint: ['sh', '-c']
101
+ command:
102
+ - >
103
+ bootstrap-mtls.sh --out-dir /tls --api-san relmio-sandbox-api
104
+ --control-san-prefix relmio-sandbox-runner &&
105
+ chown -R sandbox-api:sandbox-api /tls/api
106
+ environment:
107
+ NUM_RUNNERS: '1'
108
+ volumes:
109
+ - sandbox-tls:/tls
110
+ networks:
111
+ - assistant-internal
112
+ labels:
113
+ io.relmio.ai-assistant.managed: "${labels["io.relmio.ai-assistant.managed"]}"
114
+ io.relmio.ai-assistant.install-id: "${labels["io.relmio.ai-assistant.install-id"]}"
115
+
116
+ relmio-sandbox-api:
117
+ container_name: ${containerNames.api}
118
+ image: ${ASSISTANT_COMPANION_IMAGES.api}
119
+ depends_on:
120
+ relmio-sandbox-certs:
121
+ condition: service_completed_successfully
122
+ environment:
123
+ SANDBOX_API_KEYS: \${SANDBOX_API_KEYS}
124
+ SANDBOX_API_RUNNER_REGISTRATION_TOKEN: \${SANDBOX_API_RUNNER_REGISTRATION_TOKEN}
125
+ SANDBOX_API_RUNNER_API_KEY: \${SANDBOX_API_RUNNER_API_KEY}
126
+ SANDBOX_API_GRPC_TLS_CERT_FILE: /tls/api/grpc-server.crt
127
+ SANDBOX_API_GRPC_TLS_KEY_FILE: /tls/api/grpc-server.key
128
+ SANDBOX_API_GRPC_TLS_CLIENT_CA_FILE: /tls/api/ca.crt
129
+ SANDBOX_API_RUNNER_CONTROL_GRPC_TLS_CA_FILE: /tls/api/ca.crt
130
+ SANDBOX_API_RUNNER_CONTROL_GRPC_TLS_CERT_FILE: /tls/api/control-grpc-api-client.crt
131
+ SANDBOX_API_RUNNER_CONTROL_GRPC_TLS_KEY_FILE: /tls/api/control-grpc-api-client.key
132
+ SANDBOX_API_RUNNER_CONTROL_GRPC_TLS_SERVER_NAME: relmio-sandbox-runner-1
133
+ volumes:
134
+ - sandbox-tls:/tls:ro
135
+ healthcheck:
136
+ test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
137
+ interval: 5s
138
+ timeout: 3s
139
+ retries: 5
140
+ start_period: 10s
141
+ networks:
142
+ assistant-internal:
143
+ n8n-shared:
144
+ aliases:
145
+ - ${safeInstallation.sandboxAlias}
146
+ labels:
147
+ io.relmio.ai-assistant.managed: "${labels["io.relmio.ai-assistant.managed"]}"
148
+ io.relmio.ai-assistant.install-id: "${labels["io.relmio.ai-assistant.install-id"]}"
149
+
150
+ relmio-sandbox-runner-1:
151
+ container_name: ${containerNames.runner}
152
+ image: ${ASSISTANT_COMPANION_IMAGES.runner}
153
+ privileged: true
154
+ depends_on:
155
+ relmio-sandbox-api:
156
+ condition: service_healthy
157
+ environment:
158
+ SANDBOX_RUNNER_API_KEYS: \${SANDBOX_API_RUNNER_API_KEY}
159
+ SANDBOX_RUNNER_REGISTRATION_TOKEN: \${SANDBOX_API_RUNNER_REGISTRATION_TOKEN}
160
+ SANDBOX_RUNNER_API_GRPC_ADDR: relmio-sandbox-api:9090
161
+ SANDBOX_RUNNER_HTTP_BASE_URL: http://relmio-sandbox-runner-1:8080
162
+ SANDBOX_RUNNER_CONTROL_GRPC_LISTEN_ADDR: ':9091'
163
+ SANDBOX_RUNNER_CONTROL_GRPC_ADVERTISE_ADDR: relmio-sandbox-runner-1:9091
164
+ SANDBOX_RUNNER_ID: runner-1
165
+ SANDBOX_RUNNER_DOCKER_SANDBOX_IMAGE: ${ASSISTANT_COMPANION_IMAGES.sandbox}
166
+ SANDBOX_RUNNER_REGISTRATION_GRPC_CA_FILE: /tls/runner/ca.crt
167
+ SANDBOX_RUNNER_REGISTRATION_GRPC_CERT_FILE: /tls/runner/grpc-client.crt
168
+ SANDBOX_RUNNER_REGISTRATION_GRPC_KEY_FILE: /tls/runner/grpc-client.key
169
+ SANDBOX_RUNNER_REGISTRATION_GRPC_SERVER_NAME: relmio-sandbox-api
170
+ SANDBOX_RUNNER_CONTROL_GRPC_TLS_CERT_FILE: /tls/runner/control-grpc-server.crt
171
+ SANDBOX_RUNNER_CONTROL_GRPC_TLS_KEY_FILE: /tls/runner/control-grpc-server.key
172
+ SANDBOX_RUNNER_CONTROL_GRPC_TLS_CLIENT_CA_FILE: /tls/runner/ca.crt
173
+ volumes:
174
+ - sandbox-tls:/tls:ro
175
+ networks:
176
+ - assistant-internal
177
+ labels:
178
+ io.relmio.ai-assistant.managed: "${labels["io.relmio.ai-assistant.managed"]}"
179
+ io.relmio.ai-assistant.install-id: "${labels["io.relmio.ai-assistant.install-id"]}"
180
+
181
+ ${safeInstallation.includeSearxng ? ` relmio-searxng:
182
+ container_name: ${containerNames.searxng}
183
+ image: ${ASSISTANT_COMPANION_IMAGES.searxng}
184
+ environment:
185
+ SEARXNG_SECRET: \${SEARXNG_SECRET}
186
+ volumes:
187
+ - ./searxng-settings.yml:/etc/searxng/settings.yml:ro
188
+ networks:
189
+ n8n-shared:
190
+ aliases:
191
+ - ${safeInstallation.searxngAlias}
192
+ labels:
193
+ io.relmio.ai-assistant.managed: "${labels["io.relmio.ai-assistant.managed"]}"
194
+ io.relmio.ai-assistant.install-id: "${labels["io.relmio.ai-assistant.install-id"]}"
195
+
196
+ ` : ""}networks:
197
+
198
+ assistant-internal:
199
+ name: ${safeInstallation.projectName}-internal
200
+ labels:
201
+ io.relmio.ai-assistant.managed: "${labels["io.relmio.ai-assistant.managed"]}"
202
+ io.relmio.ai-assistant.install-id: "${labels["io.relmio.ai-assistant.install-id"]}"
203
+ n8n-shared:
204
+ external: true
205
+ name: ${safeNetworkName}
206
+ `;
207
+ }
@@ -0,0 +1,363 @@
1
+ import {
2
+ INSTALL_ROOT,
3
+ MANAGED_MARKER_PATH,
4
+ SHARED_ROOT_MARKER_CONTENT,
5
+ SHARED_ROOT_MARKER_PATH,
6
+ SIDECAR_MARKER_CONTENT,
7
+ } from "./safety.js";
8
+ import { validateDockerName } from "./validation.js";
9
+
10
+ export const ASSISTANT_ROOT = INSTALL_ROOT + "/assistant-sandbox";
11
+ export const ASSISTANT_MARKER_PATH =
12
+ ASSISTANT_ROOT + "/.managed-by-relmio-ai-assistant";
13
+ export { SHARED_ROOT_MARKER_PATH as ASSISTANT_ROOT_MARKER_PATH };
14
+
15
+ const ASSISTANT_MARKER_VERSION = 2;
16
+ const LEGACY_ASSISTANT_MARKER_VERSION = 1;
17
+ const INSTALL_ID_PATTERN = /^[a-f0-9]{32}$/u;
18
+ const PROJECT_NAME_PATTERN = /^relmio-ai-[a-f0-9]{32}$/u;
19
+ const SANDBOX_ALIAS_PATTERN = /^relmio-ai-sandbox-[a-f0-9]{32}$/u;
20
+ const SEARXNG_ALIAS_PATTERN = /^relmio-ai-searxng-[a-f0-9]{32}$/u;
21
+ const MAX_MARKER_BYTES = 512;
22
+
23
+ const MANAGED_LABEL = "io.relmio.ai-assistant.managed";
24
+ const INSTALL_ID_LABEL = "io.relmio.ai-assistant.install-id";
25
+ const EXPECTED_MANAGED_LABEL_VALUE = "true";
26
+ const SANDBOX_SERVICE_NAMES = Object.freeze([
27
+ "relmio-sandbox-certs",
28
+ "relmio-sandbox-api",
29
+ "relmio-sandbox-runner-1",
30
+ ]);
31
+ const SEARXNG_SERVICE_NAME = "relmio-searxng";
32
+
33
+ const ROOT_PRECHECK =
34
+ "if [ -e " + INSTALL_ROOT + " ]; then " +
35
+ "if [ -L " + INSTALL_ROOT + " ] || [ ! -d " + INSTALL_ROOT + " ] || " +
36
+ "[ -L " + SHARED_ROOT_MARKER_PATH + " ] || [ -L " + MANAGED_MARKER_PATH + " ]; then exit 43; " +
37
+ "elif [ -e " + SHARED_ROOT_MARKER_PATH + " ]; then " +
38
+ "if [ ! -f " + SHARED_ROOT_MARKER_PATH + " ] || [ \"$(cat " + SHARED_ROOT_MARKER_PATH + ")\" != \"" +
39
+ SHARED_ROOT_MARKER_CONTENT.trim() + "\" ]; then exit 42; fi; " +
40
+ "elif [ -f " + MANAGED_MARKER_PATH + " ] && [ \"$(cat " +
41
+ MANAGED_MARKER_PATH + ")\" = \"" + SIDECAR_MARKER_CONTENT.trim() +
42
+ "\" ]; then :; else exit 42; fi; fi";
43
+
44
+ export const ASSISTANT_PRECHECK_COMMAND =
45
+ ROOT_PRECHECK + "; if [ -L " + ASSISTANT_ROOT + " ]; then exit 43; " +
46
+ "elif [ -e " + ASSISTANT_ROOT + " ]; then " +
47
+ "if [ ! -d " + ASSISTANT_ROOT + " ]; then exit 42; " +
48
+ "elif [ -L " + ASSISTANT_MARKER_PATH + " ] || [ -L " +
49
+ ASSISTANT_ROOT + "/docker-compose.yml ] || [ -L " +
50
+ ASSISTANT_ROOT + "/searxng-settings.yml ] || [ -L " +
51
+ ASSISTANT_ROOT + "/.env ]; then exit 43; " +
52
+ "elif [ ! -f " + ASSISTANT_MARKER_PATH + " ]; then exit 42; " +
53
+ "else printf '%s\\n' managed; cat " + ASSISTANT_MARKER_PATH +
54
+ "; fi; else printf '%s\\n' new; fi";
55
+
56
+ function createInstallHex(randomBytes) {
57
+ const value = randomBytes(16);
58
+ if (!Buffer.isBuffer(value) || value.length !== 16) {
59
+ throw new TypeError("A cryptographic AI Assistant install generator is required.");
60
+ }
61
+ return value.toString("hex");
62
+ }
63
+
64
+ function objectHasExactKeys(value, keys) {
65
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
66
+ const actual = Object.keys(value).sort();
67
+ const expected = [...keys].sort();
68
+ return actual.length === expected.length &&
69
+ actual.every((key, index) => key === expected[index]);
70
+ }
71
+
72
+ export function validateAssistantInstallation(value) {
73
+ const legacyKeys = [
74
+ "version",
75
+ "installId",
76
+ "projectName",
77
+ "sandboxAlias",
78
+ "searxngAlias",
79
+ ];
80
+ if (
81
+ value?.version === LEGACY_ASSISTANT_MARKER_VERSION &&
82
+ objectHasExactKeys(value, legacyKeys)
83
+ ) {
84
+ return validateAssistantInstallation({
85
+ ...value,
86
+ version: ASSISTANT_MARKER_VERSION,
87
+ includeSearxng: true,
88
+ });
89
+ }
90
+ const requiredKeys = [...legacyKeys, "includeSearxng"];
91
+ if (
92
+ !objectHasExactKeys(value, requiredKeys) ||
93
+ value.version !== ASSISTANT_MARKER_VERSION ||
94
+ typeof value.installId !== "string" ||
95
+ !INSTALL_ID_PATTERN.test(value.installId) ||
96
+ typeof value.projectName !== "string" ||
97
+ !PROJECT_NAME_PATTERN.test(value.projectName) ||
98
+ typeof value.sandboxAlias !== "string" ||
99
+ !SANDBOX_ALIAS_PATTERN.test(value.sandboxAlias) ||
100
+ typeof value.searxngAlias !== "string" ||
101
+ !SEARXNG_ALIAS_PATTERN.test(value.searxngAlias) ||
102
+ typeof value.includeSearxng !== "boolean" ||
103
+ value.sandboxAlias === value.searxngAlias ||
104
+ value.sandboxAlias.endsWith(value.installId) ||
105
+ value.searxngAlias.endsWith(value.installId) ||
106
+ value.sandboxAlias.slice(-32) === value.searxngAlias.slice(-32)
107
+ ) {
108
+ throw new TypeError("The AI Assistant installation marker is invalid.");
109
+ }
110
+ return {
111
+ version: ASSISTANT_MARKER_VERSION,
112
+ installId: value.installId,
113
+ projectName: value.projectName,
114
+ sandboxAlias: value.sandboxAlias,
115
+ searxngAlias: value.searxngAlias,
116
+ includeSearxng: value.includeSearxng,
117
+ };
118
+ }
119
+
120
+ export function validateAssistantSearxngSelection(includeSearxng) {
121
+ if (typeof includeSearxng !== "boolean") {
122
+ throw new TypeError("Choose whether to include optional SearXNG web search.");
123
+ }
124
+ return includeSearxng;
125
+ }
126
+
127
+ export function createAssistantInstallation({ randomBytes, includeSearxng }) {
128
+ if (typeof randomBytes !== "function") {
129
+ throw new TypeError("A cryptographic AI Assistant install generator is required.");
130
+ }
131
+ const selectedSearxng = validateAssistantSearxngSelection(includeSearxng);
132
+ const installId = createInstallHex(randomBytes);
133
+ const projectSuffix = createInstallHex(randomBytes);
134
+ const sandboxSuffix = createInstallHex(randomBytes);
135
+ const searxngSuffix = createInstallHex(randomBytes);
136
+ return validateAssistantInstallation({
137
+ version: ASSISTANT_MARKER_VERSION,
138
+ installId,
139
+ projectName: "relmio-ai-" + projectSuffix,
140
+ sandboxAlias: "relmio-ai-sandbox-" + sandboxSuffix,
141
+ searxngAlias: "relmio-ai-searxng-" + searxngSuffix,
142
+ includeSearxng: selectedSearxng,
143
+ });
144
+ }
145
+
146
+ export function serializeAssistantMarker(installation) {
147
+ return JSON.stringify(validateAssistantInstallation(installation)) + "\n";
148
+ }
149
+
150
+ export function parseAssistantMarker(contents) {
151
+ if (
152
+ typeof contents !== "string" ||
153
+ contents.length === 0 ||
154
+ Buffer.byteLength(contents, "utf8") > MAX_MARKER_BYTES
155
+ ) {
156
+ throw new TypeError("The AI Assistant installation marker is invalid.");
157
+ }
158
+ try {
159
+ return validateAssistantInstallation(JSON.parse(contents));
160
+ } catch {
161
+ throw new TypeError("The AI Assistant installation marker is invalid.");
162
+ }
163
+ }
164
+
165
+ export function parseAssistantPrecheck(output) {
166
+ if (typeof output !== "string" || output.length === 0) {
167
+ throw new TypeError("The VPS AI Assistant install-directory check returned an invalid state.");
168
+ }
169
+ if (output === "new\n") return { state: "new", installation: null };
170
+ if (!output.startsWith("managed\n")) {
171
+ throw new TypeError("The VPS AI Assistant install-directory check returned an invalid state.");
172
+ }
173
+ return {
174
+ state: "managed",
175
+ installation: parseAssistantMarker(output.slice("managed\n".length)),
176
+ };
177
+ }
178
+
179
+ function composePrefix(installation) {
180
+ const safeInstallation = validateAssistantInstallation(installation);
181
+ return "docker compose --project-name " + safeInstallation.projectName +
182
+ " --file " + ASSISTANT_ROOT + "/docker-compose.yml";
183
+ }
184
+
185
+ export function getAssistantServiceNames(installation) {
186
+ const safeInstallation = validateAssistantInstallation(installation);
187
+ return safeInstallation.includeSearxng
188
+ ? [...SANDBOX_SERVICE_NAMES, SEARXNG_SERVICE_NAME]
189
+ : [...SANDBOX_SERVICE_NAMES];
190
+ }
191
+
192
+ export function getAssistantContainerNames(installation) {
193
+ const safeInstallation = validateAssistantInstallation(installation);
194
+ const prefix = "relmio-ai-" + safeInstallation.installId.slice(0, 16) + "-";
195
+ return {
196
+ certs: prefix + "certs",
197
+ api: prefix + "api",
198
+ runner: prefix + "runner",
199
+ ...(safeInstallation.includeSearxng ? { searxng: prefix + "search" } : {}),
200
+ };
201
+ }
202
+
203
+ function validateAssistantStartupScope(installation, startupScope) {
204
+ if (startupScope === "all") return getAssistantServiceNames(installation);
205
+ if (startupScope === "searxng" && installation.includeSearxng) {
206
+ return [SEARXNG_SERVICE_NAME];
207
+ }
208
+ throw new TypeError("The AI Assistant startup scope is invalid.");
209
+ }
210
+
211
+ export function createAssistantDeploymentCommands({ installation, startupScope = "all" }) {
212
+ const safeInstallation = validateAssistantInstallation(installation);
213
+ const prefix = composePrefix(safeInstallation);
214
+ const publicPaths = [ASSISTANT_ROOT + "/docker-compose.yml"];
215
+ if (safeInstallation.includeSearxng) {
216
+ publicPaths.push(ASSISTANT_ROOT + "/searxng-settings.yml");
217
+ }
218
+ const startupServices = validateAssistantStartupScope(safeInstallation, startupScope);
219
+ return [
220
+ "install -d -m 0755 " + INSTALL_ROOT,
221
+ "install -d -m 0700 " + ASSISTANT_ROOT,
222
+ "chmod 600 " + SHARED_ROOT_MARKER_PATH + " " + ASSISTANT_MARKER_PATH +
223
+ " " + ASSISTANT_ROOT + "/.env",
224
+ "chmod 644 " + publicPaths.join(" "),
225
+ "test \"$(stat -c '%a' " + SHARED_ROOT_MARKER_PATH + ")\" = 600 && " +
226
+ "test \"$(stat -c '%a' " + ASSISTANT_MARKER_PATH + ")\" = 600 && " +
227
+ "test \"$(stat -c '%a' " + ASSISTANT_ROOT + "/.env)\" = 600",
228
+ publicPaths.map(
229
+ (path) => "test \"$(stat -c '%a' " + path + ")\" = 644",
230
+ ).join(" && "),
231
+ prefix + " config --quiet",
232
+ prefix + " up -d --wait --wait-timeout 90 " + startupServices.join(" "),
233
+ ];
234
+ }
235
+
236
+ export function createAssistantVerificationCommands({ installation, cleanupScope = "project" }) {
237
+ const safeInstallation = validateAssistantInstallation(installation);
238
+ const services = getAssistantServiceNames(safeInstallation).filter(
239
+ (service) => service !== "relmio-sandbox-certs",
240
+ );
241
+ if (cleanupScope !== "project" && cleanupScope !== "searxng") {
242
+ throw new TypeError("The AI Assistant cleanup scope is invalid.");
243
+ }
244
+ if (cleanupScope === "searxng" && !safeInstallation.includeSearxng) {
245
+ throw new TypeError("The AI Assistant cleanup scope is invalid.");
246
+ }
247
+ const prefix = composePrefix(safeInstallation);
248
+ return {
249
+ health: prefix +
250
+ " exec -T relmio-sandbox-api wget -qO- http://127.0.0.1:8080/healthz",
251
+ runningServices: prefix + " ps --status running --services",
252
+ publicationState: prefix + " ps --format json " + services.join(" "),
253
+ cleanup: cleanupScope === "searxng"
254
+ ? prefix + " rm -sf " + SEARXNG_SERVICE_NAME
255
+ : prefix + " down --volumes",
256
+ };
257
+ }
258
+
259
+ export function getAssistantManagedResourceNames(installation) {
260
+ const safeInstallation = validateAssistantInstallation(installation);
261
+ return {
262
+ containers: Object.values(getAssistantContainerNames(safeInstallation)),
263
+ network: safeInstallation.projectName + "-internal",
264
+ volume: safeInstallation.projectName + "-sandbox-tls",
265
+ };
266
+ }
267
+
268
+ function createAttestationCommand(resource, installation) {
269
+ const safeInstallation = validateAssistantInstallation(installation);
270
+ const filter = "--filter \"label=com.docker.compose.project=" +
271
+ safeInstallation.projectName + "\"";
272
+ if (resource === "containers") {
273
+ return "docker container ls -a " + filter +
274
+ " --format '{{.Label \"" + INSTALL_ID_LABEL + "\"}}|{{.Label \"" +
275
+ MANAGED_LABEL + "\"}}'";
276
+ }
277
+ if (resource === "networks") {
278
+ return "docker network ls " + filter +
279
+ " --format '{{.Label \"" + INSTALL_ID_LABEL + "\"}}|{{.Label \"" +
280
+ MANAGED_LABEL + "\"}}'";
281
+ }
282
+ return "docker volume ls " + filter +
283
+ " --format '{{.Label \"" + INSTALL_ID_LABEL + "\"}}|{{.Label \"" +
284
+ MANAGED_LABEL + "\"}}'";
285
+ }
286
+
287
+ export function createAssistantOwnershipAttestationCommands({ installation }) {
288
+ return {
289
+ containers: createAttestationCommand("containers", installation),
290
+ networks: createAttestationCommand("networks", installation),
291
+ volumes: createAttestationCommand("volumes", installation),
292
+ };
293
+ }
294
+
295
+ function createExactResourceCommand(kind, names) {
296
+ const nameField = kind === "containers" ? ".Names" : ".Name";
297
+ const format = " --format '{{" + nameField + "}}|{{.Label \"" + INSTALL_ID_LABEL +
298
+ "\"}}|{{.Label \"" + MANAGED_LABEL + "\"}}'";
299
+ if (kind === "containers") {
300
+ return names.containers.map(
301
+ (name) => "docker container ls -a --filter \"name=" + name + "\"" + format,
302
+ ).join(" && ");
303
+ }
304
+ if (kind === "network") {
305
+ return "docker network ls --filter \"name=" + names.network + "\"" + format;
306
+ }
307
+ return "docker volume ls --filter \"name=" + names.volume + "\"" + format;
308
+ }
309
+
310
+ export function createAssistantExactResourceAttestationCommands({ installation }) {
311
+ const names = getAssistantManagedResourceNames(installation);
312
+ return {
313
+ containers: createExactResourceCommand("containers", names),
314
+ network: createExactResourceCommand("network", names),
315
+ volume: createExactResourceCommand("volume", names),
316
+ };
317
+ }
318
+
319
+ export function createAssistantNetworkCollisionCommand({ networkName, installation }) {
320
+ const safeNetworkName = validateDockerName(networkName);
321
+ validateAssistantInstallation(installation);
322
+ return "ids=$(docker network inspect " + safeNetworkName +
323
+ " --format '{{range $id, $_ := .Containers}}{{$id}}{{\"\\n\"}}{{end}}') && " +
324
+ "if [ -n \"$ids\" ]; then printf '%s\\n' \"$ids\" | xargs -r docker inspect --format '{{.Id}}|{{index .Config.Labels \"" +
325
+ INSTALL_ID_LABEL + "\"}}|{{with index .NetworkSettings.Networks \"" +
326
+ safeNetworkName + "\"}}{{join .Aliases \",\"}}{{end}}'; fi";
327
+ }
328
+
329
+ export function getAssistantLabels(installation) {
330
+ const safeInstallation = validateAssistantInstallation(installation);
331
+ return {
332
+ [MANAGED_LABEL]: EXPECTED_MANAGED_LABEL_VALUE,
333
+ [INSTALL_ID_LABEL]: safeInstallation.installId,
334
+ };
335
+ }
336
+
337
+ export function assertAssistantOnlyCommands({
338
+ commands,
339
+ installation,
340
+ networkName,
341
+ startupScope = "all",
342
+ cleanupScope = "project",
343
+ }) {
344
+ if (!Array.isArray(commands) || commands.length === 0) {
345
+ throw new TypeError("An AI Assistant command list is required.");
346
+ }
347
+ const allowed = new Set([
348
+ ASSISTANT_PRECHECK_COMMAND,
349
+ createAssistantNetworkCollisionCommand({ networkName, installation }),
350
+ ...Object.values(createAssistantOwnershipAttestationCommands({ installation })),
351
+ ...Object.values(createAssistantExactResourceAttestationCommands({ installation })),
352
+ ...createAssistantDeploymentCommands({ installation, startupScope }),
353
+ ...Object.values(createAssistantVerificationCommands({ installation, cleanupScope })),
354
+ ]);
355
+ for (const command of commands) {
356
+ if (typeof command !== "string" || !allowed.has(command)) {
357
+ throw new Error(
358
+ "Command rejected: only the managed AI Assistant companion may be changed; n8n must remain untouched.",
359
+ );
360
+ }
361
+ }
362
+ return commands;
363
+ }
@@ -2,11 +2,14 @@ export const INSTALL_ROOT = "/docker/n8n-openai-oauth";
2
2
  export const PROJECT_NAME = "n8n-openai-oauth";
3
3
  export const SERVICE_NAME = "openai-oauth";
4
4
  export const MANAGED_MARKER_PATH = `${INSTALL_ROOT}/.managed-by-n8n-openai-oauth`;
5
+ export const SHARED_ROOT_MARKER_PATH = `${INSTALL_ROOT}/.managed-by-relmio-root`;
6
+ export const SHARED_ROOT_MARKER_CONTENT = "relmio-managed-root:v1\n";
7
+ export const SIDECAR_MARKER_CONTENT = "Managed by n8n-openai-oauth-setup.\n";
5
8
 
6
9
  const COMPOSE_PREFIX =
7
10
  "docker compose --project-name n8n-openai-oauth --file /docker/n8n-openai-oauth/docker-compose.yml";
8
11
 
9
- export const PRECHECK_COMMAND = `if [ -e ${INSTALL_ROOT} ]; then if [ -f ${MANAGED_MARKER_PATH} ]; then printf '%s\\n' managed; else exit 42; fi; else printf '%s\\n' new; fi`;
12
+ export const PRECHECK_COMMAND = `if [ -e ${INSTALL_ROOT} ]; then if [ -L ${INSTALL_ROOT} ] || [ ! -d ${INSTALL_ROOT} ] || [ -L ${SHARED_ROOT_MARKER_PATH} ] || [ -L ${MANAGED_MARKER_PATH} ]; then exit 43; elif [ -e ${SHARED_ROOT_MARKER_PATH} ]; then if [ ! -f ${SHARED_ROOT_MARKER_PATH} ] || [ "$(cat ${SHARED_ROOT_MARKER_PATH})" != "${SHARED_ROOT_MARKER_CONTENT.trim()}" ]; then exit 42; elif [ -e ${MANAGED_MARKER_PATH} ]; then if [ ! -f ${MANAGED_MARKER_PATH} ] || [ "$(cat ${MANAGED_MARKER_PATH})" != "${SIDECAR_MARKER_CONTENT.trim()}" ]; then exit 42; else printf '%s\\n' managed; fi; else printf '%s\\n' new; fi; elif [ -f ${MANAGED_MARKER_PATH} ] && [ "$(cat ${MANAGED_MARKER_PATH})" = "${SIDECAR_MARKER_CONTENT.trim()}" ]; then printf '%s\\n' managed; else exit 42; fi; else printf '%s\\n' new; fi`;
10
13
 
11
14
  const DEPLOYMENT_COMMANDS = Object.freeze([
12
15
  `install -d -m 0755 ${INSTALL_ROOT}`,