aamio 0.6.3 → 0.7.1

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.
@@ -0,0 +1,790 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdir, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { isIP } from "node:net";
4
+ import { isAbsolute, join, relative, sep } from "node:path";
5
+ import { homeText } from "./node-archive.js";
6
+ import { holdFile } from "./node-lock.js";
7
+
8
+ /*
9
+ * Add-ons: what a runtime can do beyond the core, installed one by one and
10
+ * enabled by name.
11
+ *
12
+ * The core is the rendezvous: keys, inbox, presence, the board, receipts.
13
+ * Other things are add-ons, each an npm package of its own that carries a
14
+ * manifest, aamio-addon.json, named in its package.json under aamioAddon, and
15
+ * a script the core runs as a process of the add-on's own for each call: one
16
+ * JSON request on its standard input, one JSON answer on its standard output,
17
+ * and a deadline. That is contract 2 of aamio's add-ons, the same manifest and
18
+ * the same protocol aamio-python and aamio-php speak, written up at
19
+ * https://aamio.at/docs/addons. This runtime knows no other contract: nothing
20
+ * of an add-on is ever loaded into this process.
21
+ *
22
+ * An add-on is installed in a folder of the home's own, addon-packages/npm,
23
+ * with npm: which packages are there does not depend on the folder a command
24
+ * is run in, or on the cache npx started this aamio from. Installed is not
25
+ * enabled. What is installed is listed from package.json and the manifest,
26
+ * running none of it. `aamio addon enable NAME` starts the add-on's process
27
+ * once, to see that it answers as its manifest says, and records the choice in
28
+ * addons.json in the home, where aamio-python and aamio-php keep theirs. An
29
+ * entry of this runtime's is written npm:<package>, so a package on npm and a
30
+ * distribution on PyPI that share a name are two choices, each made for the
31
+ * code it names, and the other clients' entries are left as they are.
32
+ *
33
+ * The core keeps what is the core's: the key, the home and its lock, the
34
+ * address book, the messages this process verified, and the user's consent to
35
+ * the websites an add-on may read. The add-on gets the call's arguments, a
36
+ * subject the core resolved to a key, a directory of its own, and a short
37
+ * list of environment variables rather than the user's. It is not a sandbox:
38
+ * the process runs as the same user and reads what the user reads.
39
+ */
40
+
41
+ export const CORE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
42
+ export const MANIFEST_FIELD = "aamioAddon";
43
+ export const ENABLED_FILE = "addons.json";
44
+ // An entry of this runtime's in addons.json: a package of npm's.
45
+ export const PREFIX = "npm:";
46
+ export const KNOWN_CONTRACTS = [2];
47
+ export const DEADLINE_DEFAULT = 30;
48
+ export const DEADLINE_MAX = 120;
49
+ // The consent list lives beside the add-on's own directory, not in it: the
50
+ // add-on's process must not be able to widen what it may read.
51
+ export const HOSTS_SUFFIX = ".hosts.json";
52
+ export const HOST_COMMANDS = ["allow", "forget", "list"];
53
+ // Commands of aamio addon itself, which no add-on may be called.
54
+ export const RESERVED = ["list", "enable", "disable", "run"];
55
+ // Names an add-on may not ask for in its environment list.
56
+ export const NEVER_PASSED = ["AAMIO_HOME", "AAMIO_ADDONS", "AAMIO_ADDON"];
57
+ // How long a change waits for another aamio that is changing the same list.
58
+ export const LOCK_WAIT = 10;
59
+ export const RESTART = "A local MCP server already running keeps the tools it started with: start it again to see the change.";
60
+
61
+ const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/;
62
+ // Names Windows takes for a device. An add-on's short name is the name of its
63
+ // list of websites and of its own directory in the home.
64
+ const DEVICE = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])$/;
65
+ const COMMAND = /^[a-z][a-z0-9-]{0,31}$/;
66
+ const POSITIONAL = /^[a-z][a-z0-9_-]{0,31}$/;
67
+ const OPTION = /^--[a-z][a-z0-9-]{0,31}$/;
68
+ const VARIABLE = /^[A-Z][A-Z0-9_]{0,63}$/;
69
+ const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
70
+ const KEY = /^[A-Za-z0-9_-]{43}$/;
71
+ const HASH = /^[0-9a-f]{64}$/;
72
+ // A package's name as npm takes it, with or without a scope. Each part starts
73
+ // with a letter, a digit, a dash or a tilde, so none of them is a way out of
74
+ // node_modules.
75
+ const PACKAGE = /^(?:@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/i;
76
+ // The script a manifest names: a file of the package's, in JavaScript.
77
+ const SCRIPT = /\.(?:js|mjs|cjs)$/;
78
+
79
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
80
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
81
+
82
+ /** An add-on that cannot be used as one, with what to do about it. */
83
+ export class AddonError extends Error {
84
+ constructor(message) {
85
+ super(message);
86
+ this.code = "addon";
87
+ }
88
+ }
89
+
90
+ /** A call settled before the add-on's process starts: a bad argument, no such subject, a website the user has not agreed to. */
91
+ export class Refused extends Error {
92
+ constructor(reason, fix, status = "invalid") {
93
+ super(fix);
94
+ this.reason = reason;
95
+ this.fix = fix;
96
+ this.status = status;
97
+ }
98
+ }
99
+
100
+ export function refusal(refused, known = {}) {
101
+ const answer = { status: refused.status, reason: refused.reason, fix: refused.fix };
102
+ for (const [field, value] of Object.entries(known)) if (value !== null && value !== undefined) answer[field] = value;
103
+ return answer;
104
+ }
105
+
106
+ /** The name of an argument as the request carries it: --reply-to is reply_to. */
107
+ export function dest(name) {
108
+ return name.replace(/^-+/, "").replaceAll("-", "_");
109
+ }
110
+
111
+ // ---------------------------------------------------------------- installed
112
+
113
+ /** The folder of the home's own that add-ons are installed in with npm. */
114
+ export function addonRoot(home) {
115
+ return join(home, "addon-packages", "npm");
116
+ }
117
+
118
+ /** How an add-on is installed here, for the messages that say so. */
119
+ export function installWords(home, name = "aamio-domain-keys") {
120
+ return `npm install --prefix "${addonRoot(home)}" --ignore-scripts ${name}`;
121
+ }
122
+
123
+ /**
124
+ * Every add-on installed in the home's folder for add-ons, from package.json
125
+ * and without running any of it: the packages that folder's own package.json
126
+ * depends on, which is what npm install put there by name, and of those the
127
+ * ones whose package.json names a manifest. What another package brought with
128
+ * it is not an add-on of this home's. A home that has no such folder has none.
129
+ */
130
+ export async function installed(home) {
131
+ const root = addonRoot(home);
132
+ let top;
133
+ try {
134
+ top = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
135
+ } catch (error) {
136
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") return [];
137
+ throw new AddonError(`${join(root, "package.json")} could not be read: ${error.code || error.message}. It lists the add-ons installed in this home. Fix or remove the file, and install the add-ons again: ${installWords(home)}.`);
138
+ }
139
+ const names = new Set();
140
+ for (const field of ["dependencies", "optionalDependencies"]) {
141
+ if (isObject(top) && isObject(top[field])) for (const name of Object.keys(top[field])) if (PACKAGE.test(name)) names.add(name);
142
+ }
143
+ const rows = [];
144
+ for (const distribution of [...names].sort()) {
145
+ const dir = join(root, "node_modules", ...distribution.split("/"));
146
+ let pkg;
147
+ try {
148
+ pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
149
+ } catch {
150
+ // Named and not there, or not a package: nothing is installed under that name.
151
+ continue;
152
+ }
153
+ if (!isObject(pkg) || !(MANIFEST_FIELD in pkg)) continue;
154
+ rows.push({ distribution, version: typeof pkg.version === "string" ? pkg.version : null, dir, declared: pkg[MANIFEST_FIELD] });
155
+ }
156
+ return rows;
157
+ }
158
+
159
+ /**
160
+ * A file of the package's, by the path the package gives for it: the real
161
+ * path, when that is a file inside the package's own real folder. A path out
162
+ * of the package, by .. or by a link, is refused, and so is a folder.
163
+ */
164
+ async function inside(row, given, what, fix) {
165
+ const wrong = (why) => new AddonError(`the add-on ${row.distribution} cannot be used: ${what} ${why}. Reinstall it, or ${fix} to go on without it.`);
166
+ if (typeof given !== "string" || !given || given.includes("\0") || isAbsolute(given)) throw wrong("is not a path inside the package");
167
+ let root;
168
+ let real;
169
+ try {
170
+ root = await realpath(row.dir);
171
+ real = await realpath(join(root, given));
172
+ } catch (error) {
173
+ throw wrong(`(${given}) is not there: ${error.code || error.message}`);
174
+ }
175
+ const from = relative(root, real);
176
+ if (!from || from === ".." || from.startsWith(".." + sep) || isAbsolute(from)) throw wrong(`(${given}) is outside the package`);
177
+ if (!(await stat(real)).isFile()) throw wrong(`(${given}) is not a file`);
178
+ return real;
179
+ }
180
+
181
+ // ----------------------------------------------------------------- manifest
182
+
183
+ /**
184
+ * What an add-on offers, as its manifest says it: its short name, its
185
+ * commands and its tools, the script to run, and what it asks the core for.
186
+ * Everything is checked here, before the add-on is used or recorded.
187
+ */
188
+ export class Manifest {
189
+ constructor(distribution, path, data, fix) {
190
+ this.distribution = distribution;
191
+ this.path = path;
192
+ this.fix = fix;
193
+ this.NAME = data.name;
194
+ this.version = data.version;
195
+ this.script = isObject(data.process) ? data.process.node : undefined;
196
+ this.deadline = data.deadline_seconds === undefined ? DEADLINE_DEFAULT : data.deadline_seconds;
197
+ this.description = data.description ?? null;
198
+ this.environment = data.environment ?? [];
199
+ this.hosts = data.hosts;
200
+ this.commands = data.commands ?? {};
201
+ this.toolsSpec = data.tools ?? [];
202
+ // The script's real path, set once it is found inside the package.
203
+ this.entry = null;
204
+ this.validate();
205
+ this.tools = this.toolsSpec.map((spec) => ({
206
+ name: spec.name, description: spec.description, inputSchema: spec.inputSchema, ...(spec.annotations !== undefined && spec.annotations !== null ? { annotations: spec.annotations } : {}),
207
+ }));
208
+ }
209
+
210
+ wrong(what) {
211
+ throw new AddonError(`${this.distribution} cannot be used as an add-on: ${what}. Reinstall it, or ${this.fix} to go on without it.`);
212
+ }
213
+
214
+ validate() {
215
+ if (typeof this.NAME !== "string" || !NAME.test(this.NAME)) this.wrong("its manifest has no name of 1 to 32 characters of a-z, 0-9, dash and underscore");
216
+ if (RESERVED.includes(this.NAME)) throw new AddonError(`${this.distribution} cannot be called ${this.NAME}: that is a command of aamio addon itself. To go on without it: ${this.fix}.`);
217
+ // On Windows a file of that name is the device: a list of websites written to nul.hosts.json is written nowhere.
218
+ if (DEVICE.test(this.NAME)) this.wrong(`its manifest's name, ${this.NAME}, is one Windows keeps for a device, and it names the add-on's files in the home`);
219
+ if (typeof this.version !== "string" || !this.version) this.wrong("its manifest has no version");
220
+ if (typeof this.script !== "string" || !SCRIPT.test(this.script)) this.wrong("its manifest names no JavaScript file to run under process.node");
221
+ if (!Number.isInteger(this.deadline) || this.deadline < 1 || this.deadline > DEADLINE_MAX) this.wrong(`deadline_seconds in its manifest is not a whole number from 1 to ${DEADLINE_MAX}`);
222
+ if (this.description !== null && typeof this.description !== "string") this.wrong("description in its manifest is not text");
223
+ if (!Array.isArray(this.environment) || this.environment.some((name) => typeof name !== "string" || !VARIABLE.test(name))) {
224
+ this.wrong("environment in its manifest is not a list of variable names in A-Z, 0-9 and underscore");
225
+ }
226
+ const never = this.environment.filter((name) => NEVER_PASSED.includes(name));
227
+ if (never.length) this.wrong(`environment in its manifest asks for ${never.join(", ")}, which no add-on gets`);
228
+ if (this.hosts === true) this.hosts = {};
229
+ if (this.hosts !== undefined && this.hosts !== null && this.hosts !== false) {
230
+ if (!isObject(this.hosts)) this.wrong("hosts in its manifest is neither true nor an object");
231
+ const legacy = this.hosts.legacy_file;
232
+ if (legacy !== undefined && legacy !== null && (typeof legacy !== "string" || !legacy || /[\\/\0]/.test(legacy) || legacy === "." || legacy === "..")) {
233
+ this.wrong("hosts.legacy_file in its manifest is not a plain file name");
234
+ }
235
+ } else {
236
+ this.hosts = null;
237
+ }
238
+ if (!isObject(this.commands)) this.wrong("commands in its manifest is not an object");
239
+ for (const [name, spec] of Object.entries(this.commands)) {
240
+ if (!COMMAND.test(name)) this.wrong(`the command ${JSON.stringify(name)} in its manifest is not a name of a-z, 0-9 and dashes`);
241
+ if (this.hosts !== null && HOST_COMMANDS.includes(name)) this.wrong(`its manifest has a command ${name}, which aamio provides for an add-on with hosts`);
242
+ if (!isObject(spec)) this.wrong(`the command ${name} in its manifest is not an object`);
243
+ if (spec.help !== undefined && typeof spec.help !== "string") this.wrong(`the help of the command ${name} is not text`);
244
+ if (spec.runtime !== undefined && typeof spec.runtime !== "boolean") this.wrong(`runtime on the command ${name} is not true or false`);
245
+ const needs = spec.needs ?? [];
246
+ if (!Array.isArray(needs) || needs.some((need) => need !== "own_key")) this.wrong(`needs on the command ${name} names something other than own_key`);
247
+ if (needs.length && spec.runtime === false) this.wrong(`the command ${name} needs own_key and says it runs without a runtime`);
248
+ this.checkArguments(name, spec);
249
+ }
250
+ if (!Array.isArray(this.toolsSpec)) this.wrong("tools in its manifest is not a list");
251
+ const seen = new Set();
252
+ for (const spec of this.toolsSpec) {
253
+ if (!isObject(spec) || typeof spec.name !== "string" || !spec.name) this.wrong("a tool of its has no name");
254
+ if (seen.has(spec.name)) this.wrong(`its manifest has two tools called ${spec.name}`);
255
+ seen.add(spec.name);
256
+ if (typeof spec.description !== "string" || !spec.description) this.wrong(`the tool ${spec.name} has no description`);
257
+ const schema = spec.inputSchema;
258
+ if (!isObject(schema) || schema.type !== "object" || (schema.properties !== undefined && !isObject(schema.properties))) this.wrong(`the tool ${spec.name} has no inputSchema of type object`);
259
+ const fields = Object.keys(schema.properties ?? {});
260
+ if ("oneOf" in schema || "anyOf" in schema) this.wrong(`the tool ${spec.name} has oneOf or anyOf at the top of its schema, which hosts refuse`);
261
+ const host = spec.host;
262
+ if (host !== undefined && host !== null && (typeof host !== "string" || !fields.includes(host))) this.wrong(`host on the tool ${spec.name} names no field of its schema`);
263
+ if (host !== undefined && host !== null && this.hosts === null) this.wrong(`the tool ${spec.name} has a host field, and the manifest has no hosts`);
264
+ const subject = spec.subject;
265
+ if (subject !== undefined && subject !== null) {
266
+ const roles = isObject(subject) ? Object.entries(subject) : [];
267
+ if (!roles.length || roles.some(([role, field]) => !["partner", "message"].includes(role) || typeof field !== "string" || !fields.includes(field))) {
268
+ this.wrong(`subject on the tool ${spec.name} is not an object of partner or message to a field of its schema`);
269
+ }
270
+ }
271
+ const needs = spec.needs ?? [];
272
+ if (!Array.isArray(needs) || needs.some((need) => need !== "own_key")) this.wrong(`needs on the tool ${spec.name} names something other than own_key`);
273
+ if (spec.annotations !== undefined && spec.annotations !== null && !isObject(spec.annotations)) this.wrong(`annotations on the tool ${spec.name} is not an object`);
274
+ }
275
+ }
276
+
277
+ checkArguments(command, spec) {
278
+ const list = spec.arguments ?? [];
279
+ if (!Array.isArray(list)) this.wrong(`the arguments of the command ${command} are not a list`);
280
+ let hosts = 0;
281
+ let subjects = 0;
282
+ const dests = new Set();
283
+ for (const argument of list) {
284
+ if (!isObject(argument) || typeof argument.name !== "string") this.wrong(`an argument of the command ${command} has no name`);
285
+ const name = argument.name;
286
+ if (name.startsWith("-")) {
287
+ if (!OPTION.test(name)) this.wrong(`the option ${name} of the command ${command} is not --name in a-z, 0-9 and dashes`);
288
+ } else if (!POSITIONAL.test(name)) {
289
+ this.wrong(`the argument ${name} of the command ${command} is not a name in a-z, 0-9, dash and underscore`);
290
+ } else if (argument.required !== undefined && argument.required !== null) {
291
+ this.wrong(`the argument ${name} of the command ${command} is positional, and required is for options`);
292
+ }
293
+ const called = dest(name);
294
+ if (dests.has(called) || called === this.NAME + "_command") this.wrong(`the command ${command} has two arguments that would be called ${called}`);
295
+ dests.add(called);
296
+ if ((argument.help !== undefined && typeof argument.help !== "string") || (argument.required !== undefined && argument.required !== null && typeof argument.required !== "boolean")
297
+ || (argument.host !== undefined && typeof argument.host !== "boolean")) {
298
+ this.wrong(`the argument ${name} of the command ${command} has help, required or host of the wrong kind`);
299
+ }
300
+ if (argument.host) {
301
+ hosts += 1;
302
+ if (this.hosts === null) this.wrong(`the argument ${name} of the command ${command} is a host, and the manifest has no hosts`);
303
+ if (spec.runtime === false) this.wrong(`the argument ${name} of the command ${command} is a host, which needs the runtime the command says it does without`);
304
+ }
305
+ if (argument.subject !== undefined && argument.subject !== null) {
306
+ subjects += 1;
307
+ if (!["partner", "message"].includes(argument.subject)) this.wrong(`subject on the argument ${name} of the command ${command} is neither partner nor message`);
308
+ if (spec.runtime === false) this.wrong(`the argument ${name} of the command ${command} is a subject, which needs the runtime the command says it does without`);
309
+ }
310
+ }
311
+ if (hosts > 1 || subjects > 1) this.wrong(`the command ${command} has more than one host or more than one subject argument`);
312
+ }
313
+
314
+ /** The commands under aamio addon NAME, the ones the core adds for an add-on with hosts among them, sorted. */
315
+ commandNames() {
316
+ return [...Object.keys(this.commands), ...(this.hosts !== null ? HOST_COMMANDS : [])].sort();
317
+ }
318
+ }
319
+
320
+ /**
321
+ * The manifest of an installed add-on, read and checked, running nothing of
322
+ * it: the file package.json names, inside the package, and the script the
323
+ * manifest names, inside the package too. A manifest for a contract this
324
+ * aamio does not know is an error that says which.
325
+ */
326
+ export async function readManifest(row) {
327
+ const fix = `aamio addon disable ${row.distribution}`;
328
+ const path = await inside(row, row.declared, `the manifest its package.json names under ${MANIFEST_FIELD}`, fix);
329
+ let data;
330
+ try {
331
+ data = JSON.parse(await readFile(path, "utf8"));
332
+ } catch (error) {
333
+ throw new AddonError(`the add-on ${row.distribution} has a manifest, ${path}, that cannot be read: ${error.name}: ${error.message}. Reinstall it, or ${fix} to go on without it.`);
334
+ }
335
+ const number = isObject(data) ? data.aamio_addon : undefined;
336
+ if (number !== 2) {
337
+ throw new AddonError(`the add-on ${row.distribution} is written for contract ${JSON.stringify(number ?? null)} of aamio's add-ons, and this aamio (${CORE_VERSION} for Node) knows ${KNOWN_CONTRACTS.join(" and ")}, a process of the add-on's own for each call. Upgrade aamio, or install a version of the add-on for this one; ${fix} goes on without it.`);
338
+ }
339
+ const manifest = new Manifest(row.distribution, path, data, fix);
340
+ // One package, one version: the list shows it before the add-on is enabled,
341
+ // and the probe holds the process to it afterwards.
342
+ if (row.version !== manifest.version) {
343
+ throw new AddonError(`${row.distribution} cannot be used as an add-on: its manifest says version ${manifest.version} and its package.json ${row.version ?? "none"}. Reinstall it, or ${fix} to go on without it.`);
344
+ }
345
+ manifest.entry = await inside(row, manifest.script, "the script its manifest names under process.node", fix);
346
+ return manifest;
347
+ }
348
+
349
+ // ------------------------------------------------------------------ enabled
350
+
351
+ export function enabledFile(home) {
352
+ return join(home, ENABLED_FILE);
353
+ }
354
+
355
+ /** Every name in the home's list, as the file spells them: this runtime's entries and the other clients'. */
356
+ export async function enabledNames(home) {
357
+ const path = enabledFile(home);
358
+ let data;
359
+ try {
360
+ data = JSON.parse(await homeText(path));
361
+ } catch (error) {
362
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") return [];
363
+ throw new AddonError(`${path} could not be read: ${error.code || error.message}. Fix or remove the file; it lists the add-ons enabled here, one name each under "enabled".`);
364
+ }
365
+ const names = isObject(data) ? data.enabled : null;
366
+ if (!Array.isArray(names) || names.some((name) => typeof name !== "string")) {
367
+ throw new AddonError(`${path} is not a list of enabled add-ons. Fix or remove the file; it has one name each under "enabled".`);
368
+ }
369
+ return names;
370
+ }
371
+
372
+ const entryOf = (distribution) => PREFIX + distribution;
373
+ const sameEntry = (name, distribution) => name.toLowerCase() === entryOf(distribution).toLowerCase();
374
+ const isEnabled = (row, names) => names.some((name) => sameEntry(name, row.distribution));
375
+
376
+ /**
377
+ * The enabled list's lock, one per home, held from reading the list to
378
+ * writing it back: the operating system's, as the home's owner lock is, so
379
+ * two commands at once, from this runtime or from aamio-python or aamio-php
380
+ * on the same home, take turns rather than each writing over the other's
381
+ * change. On Windows this runtime holds the file with no sharing, so another
382
+ * client that asks meanwhile is told the list could not be locked and changes
383
+ * nothing, where it would have waited for one of its own kind.
384
+ */
385
+ async function withListLock(home, act, { wait = LOCK_WAIT, lock = holdFile } = {}) {
386
+ const path = enabledFile(home) + ".lock";
387
+ const deadline = Date.now() + wait * 1000;
388
+ let holder;
389
+ for (;;) {
390
+ try {
391
+ holder = await lock(home, path);
392
+ } catch (error) {
393
+ throw new AddonError(`${path} could not be locked: ${error.message} Nothing changed.`);
394
+ }
395
+ if (holder !== null) break;
396
+ if (Date.now() >= deadline) throw new AddonError(`${enabledFile(home)} is being changed by another aamio, which did not finish in ${wait} seconds. Nothing changed; try again.`);
397
+ await sleep(50);
398
+ }
399
+ try {
400
+ return await act();
401
+ } finally {
402
+ await holder.release();
403
+ }
404
+ }
405
+
406
+ /** Writes the list, under the lock the caller holds: a file of this process's own first, then one rename. */
407
+ async function writeEnabled(home, names) {
408
+ const path = enabledFile(home);
409
+ const temporary = `${path}.${process.pid}.tmp`;
410
+ try {
411
+ await writeFile(temporary, JSON.stringify({ enabled: names }, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
412
+ } catch (error) {
413
+ await unlink(temporary).catch(() => {});
414
+ throw new AddonError(`${path} could not be written: ${error.code || error.message}. Nothing changed; check the disk and the home's permissions, and try again.`);
415
+ }
416
+ for (let attempt = 0; ; attempt++) {
417
+ try {
418
+ await rename(temporary, path);
419
+ return;
420
+ } catch (error) {
421
+ // Windows: a reader holds the file for a moment.
422
+ if (!["EPERM", "EACCES", "EBUSY"].includes(error.code) || attempt >= 19) {
423
+ await unlink(temporary).catch(() => {});
424
+ throw new AddonError(`${path} could not be replaced: ${error.code || error.message}. Nothing changed; try again.`);
425
+ }
426
+ await sleep(50);
427
+ }
428
+ }
429
+ }
430
+
431
+ // ------------------------------------------------------------------- loaded
432
+
433
+ /**
434
+ * The add-ons that run in this home: the installed ones the list names, each
435
+ * read from its manifest and checked, with nothing of them run. Two add-ons
436
+ * with one short name, or a tool whose name is taken already, stop here and
437
+ * say so, rather than let the order they were found in decide which one runs:
438
+ * a broken install is not a quiet absence.
439
+ */
440
+ export async function loaded(home, { coreTools = [], names = null, rows = null } = {}) {
441
+ const wanted = names ?? (await enabledNames(home));
442
+ const found = [];
443
+ for (const row of rows ?? (await installed(home))) {
444
+ if (!isEnabled(row, wanted)) continue;
445
+ const manifest = await readManifest(row);
446
+ const other = found.find((held) => held.NAME === manifest.NAME);
447
+ if (other) throw new AddonError(`two add-ons are called ${manifest.NAME}: ${other.distribution} and ${manifest.distribution}. Disable or uninstall one, or the order they were found in would decide which one runs.`);
448
+ found.push(manifest);
449
+ }
450
+ // The tools too, here and not first when a server lists them: `aamio addon
451
+ // list` and `aamio serve` have to agree on whether this installation works.
452
+ toolsOf(coreTools, found);
453
+ return found;
454
+ }
455
+
456
+ /** Every tool the add-ons bring, refused when a name is taken already. */
457
+ export function toolsOf(coreTools, manifests) {
458
+ const taken = new Map(coreTools.map((tool) => [typeof tool === "string" ? tool : tool.name, "the core"]));
459
+ const found = [];
460
+ for (const manifest of manifests) {
461
+ for (const tool of manifest.tools) {
462
+ if (taken.has(tool.name)) {
463
+ throw new AddonError(`the tool ${tool.name} is offered by ${taken.get(tool.name)} and by ${manifest.distribution}. Disable or uninstall one, or the order they were found in would decide which one runs.`);
464
+ }
465
+ taken.set(tool.name, manifest.distribution);
466
+ found.push(tool);
467
+ }
468
+ }
469
+ return found;
470
+ }
471
+
472
+ /**
473
+ * What aamio addon list answers: every add-on installed in this home, whether
474
+ * it runs here, and for one that runs its commands and tools. Nothing is run
475
+ * and nothing is written: a home that is not there is as it was afterwards.
476
+ * When an enabled add-on cannot be loaded the list still answers, with the
477
+ * error and the command that takes the add-on out.
478
+ */
479
+ export async function describe(home, { coreTools = [] } = {}) {
480
+ const wanted = await enabledNames(home);
481
+ let rows = [];
482
+ let running = [];
483
+ let error = null;
484
+ try {
485
+ rows = await installed(home);
486
+ running = await loaded(home, { coreTools, names: wanted, rows });
487
+ } catch (failure) {
488
+ if (!(failure instanceof AddonError)) throw failure;
489
+ error = failure.message;
490
+ }
491
+ const manifests = new Map();
492
+ const said = [];
493
+ for (const row of rows) {
494
+ const entry = { name: null, version: row.version, distribution: row.distribution, enabled: isEnabled(row, wanted) };
495
+ try {
496
+ // Which contract, its short name, and which variables it asks for by
497
+ // name: said before the add-on is enabled, from the manifest alone.
498
+ const manifest = await readManifest(row);
499
+ manifests.set(row.distribution, manifest);
500
+ entry.name = manifest.NAME;
501
+ entry.contract = 2;
502
+ if (manifest.environment.length) entry.environment = [...manifest.environment];
503
+ } catch (failure) {
504
+ if (!(failure instanceof AddonError)) throw failure;
505
+ entry.error = failure.message;
506
+ }
507
+ said.push(entry);
508
+ }
509
+ for (const entry of said) {
510
+ const manifest = manifests.get(entry.distribution);
511
+ if (entry.enabled) {
512
+ const runs = running.find((held) => held.distribution === entry.distribution);
513
+ if (runs) Object.assign(entry, { version: runs.version, commands: runs.commandNames(), mcp_tools: runs.tools.map((tool) => tool.name) });
514
+ else entry.loaded = false;
515
+ } else {
516
+ // By its short name when no other installed add-on has it, by its package otherwise.
517
+ const same = manifest ? said.filter((other) => other !== entry && other.name === manifest.NAME) : [];
518
+ entry.fix = `aamio addon enable ${manifest && !same.length ? manifest.NAME : entry.distribution}`;
519
+ }
520
+ }
521
+ const answer = { addons: said };
522
+ const missing = notInstalled(wanted, rows);
523
+ if (missing.length) answer.enabled_but_not_installed = missing;
524
+ if (error !== null) answer.error = error;
525
+ if (!said.length && error === null && !missing.length) {
526
+ answer.note = `No add-on is installed in this home. An add-on is a package of its own, installed with npm in the home's folder for add-ons, once aamio init has given the home its identity, and then enabled by name: ${installWords(home)} and aamio addon enable domain for Domain Keys.`;
527
+ }
528
+ return answer;
529
+ }
530
+
531
+ /** The packages this runtime recorded as enabled and does not find among the installed ones. */
532
+ function notInstalled(names, rows) {
533
+ return names.filter((name) => name.toLowerCase().startsWith(PREFIX) && !rows.some((row) => sameEntry(name, row.distribution))).map((name) => name.slice(PREFIX.length));
534
+ }
535
+
536
+ /**
537
+ * The add-ons enabled in this home that are not installed there any more: an
538
+ * uninstall, or a folder that was moved. They do not run, and that is said
539
+ * where the user would otherwise only see a tool that is not there.
540
+ */
541
+ export async function enabledButMissing(home) {
542
+ return notInstalled(await enabledNames(home), await installed(home));
543
+ }
544
+
545
+ /** The installed add-on `which` names, by short name or by package: its package, refused when none or two fit. */
546
+ async function pick(home, which) {
547
+ const hits = new Set();
548
+ const unread = [];
549
+ for (const row of await installed(home)) {
550
+ if (row.distribution.toLowerCase() === String(which).toLowerCase()) {
551
+ hits.add(row.distribution);
552
+ continue;
553
+ }
554
+ try {
555
+ if ((await readManifest(row)).NAME === which) hits.add(row.distribution);
556
+ } catch (failure) {
557
+ // One that cannot be read has no short name to be picked by.
558
+ if (!(failure instanceof AddonError)) throw failure;
559
+ unread.push(row.distribution);
560
+ }
561
+ }
562
+ const packages = [...hits].sort();
563
+ if (!packages.length) {
564
+ // The one that was meant may be among those that could not be read: said, with where the reason is.
565
+ const broken = unread.length ? ` ${unread.join(" and ")} ${unread.length === 1 ? "is" : "are"} installed and cannot be read as an add-on, so ${unread.length === 1 ? "it has" : "they have"} no short name: aamio addon list says why, and aamio addon enable with the package's name says it too.` : "";
566
+ throw new AddonError(`no installed add-on is called ${which}.${broken} aamio addon list says which are installed in this home; an add-on is installed first, as ${installWords(home)} for Domain Keys.`);
567
+ }
568
+ if (packages.length > 1) throw new AddonError(`two installed add-ons are called ${which}: ${packages.join(" and ")}. Enable one by its package name.`);
569
+ return packages[0];
570
+ }
571
+
572
+ /**
573
+ * What enable says of an add-on: the same words as aamio-python, aamio-php
574
+ * and https://aamio.at/docs/addons. probed is whether the probe ran now; it
575
+ * does not for an add-on that was enabled already.
576
+ */
577
+ export function enableNote(distribution, environment, probed = true) {
578
+ const asked = environment.length ? `, and ${environment.join(", ")}, which its manifest asks for` : "";
579
+ const ran = probed ? "enable started it once just now, before the choice was recorded, to see that it answers as its manifest says." : "It was enabled already, and nothing of it ran now.";
580
+ return `${distribution} runs here from now on, in a process of its own for each call. ${ran} ` +
581
+ "A call gets its arguments, a key aamio resolved for it, the runtime's public key where a " +
582
+ "command asks for it, a directory of its own under the home, and a short list of environment variables: the path, a temporary directory, " +
583
+ `the locale, the interpreter's own settings, TLS roots and the proxy variables, which can carry credentials of their own${asked}, and never the ` +
584
+ "home's key or the rest of your environment. Nothing it answers changes state in aamio. It runs with your own file permissions.";
585
+ }
586
+
587
+ /**
588
+ * Lets an installed add-on run here, once it has been read clean and has
589
+ * answered the probe: a broken one is refused, not recorded. probe(manifest)
590
+ * starts the add-on's process once, before anything is written.
591
+ */
592
+ export async function enable(home, which, { probe, coreTools = [], lock } = {}) {
593
+ const distribution = await pick(home, which);
594
+ let probed = false;
595
+ await withListLock(home, async () => {
596
+ const names = await enabledNames(home);
597
+ if (names.some((name) => sameEntry(name, distribution))) return;
598
+ const manifests = await loaded(home, { coreTools, names: [...names, entryOf(distribution)] });
599
+ // Before anything is recorded: the process starts, speaks the protocol
600
+ // and is the add-on the manifest says.
601
+ await probe(manifests.find((manifest) => manifest.distribution === distribution));
602
+ probed = true;
603
+ await writeEnabled(home, [...names, entryOf(distribution)]);
604
+ }, lock);
605
+ const answer = await describe(home, { coreTools });
606
+ const row = answer.addons.find((entry) => entry.distribution === distribution) ?? {};
607
+ answer.note = `${enableNote(distribution, row.environment ?? [], probed)} ${RESTART}`;
608
+ return answer;
609
+ }
610
+
611
+ /** Takes an add-on out of the enabled list, by short name or by package, reading no more of it than its name and running nothing. */
612
+ export async function disable(home, which, { coreTools = [], lock } = {}) {
613
+ const packages = new Set([String(which).toLowerCase()]);
614
+ // What cannot be read has no short name to go by, and is taken out by its package.
615
+ for (const row of await installed(home).catch((failure) => (failure instanceof AddonError ? [] : Promise.reject(failure)))) {
616
+ try {
617
+ if ((await readManifest(row)).NAME === which) packages.add(row.distribution.toLowerCase());
618
+ } catch (failure) {
619
+ if (!(failure instanceof AddonError)) throw failure;
620
+ }
621
+ }
622
+ const mine = (name) => name.toLowerCase().startsWith(PREFIX) && packages.has(name.slice(PREFIX.length).toLowerCase());
623
+ let changed = false;
624
+ // A home that is not there has no list, and none is made to say so.
625
+ if ((await enabledNames(home)).some(mine)) {
626
+ await withListLock(home, async () => {
627
+ const names = await enabledNames(home);
628
+ const kept = names.filter((name) => !mine(name));
629
+ if (kept.length !== names.length) {
630
+ await writeEnabled(home, kept);
631
+ changed = true;
632
+ }
633
+ }, lock);
634
+ }
635
+ const answer = await describe(home, { coreTools });
636
+ answer.note = changed ? `${which} is disabled. ${RESTART}` : `${which} was not enabled; nothing on disk changed.`;
637
+ return answer;
638
+ }
639
+
640
+ // -------------------------------------------------------------------- hosts
641
+
642
+ /**
643
+ * The exact host a list is read from, in lowercase, or Refused. Plain ASCII
644
+ * names of two labels or more. No port, path, IP literal, trailing dot,
645
+ * underscore or space is trimmed into shape: anything but a host name is
646
+ * refused. www.example.org and example.org are different places.
647
+ */
648
+ export function normaliseHost(value) {
649
+ const refused = new Refused("invalid_host", "A website is a plain host name of two labels or more, as example.org, with no https://, port or path. www.example.org and example.org are different places. Internationalised names are not read yet.");
650
+ if (typeof value !== "string" || !/^[\x00-\x7f]*$/.test(value)) throw refused;
651
+ const host = value.toLowerCase();
652
+ const labels = host.split(".");
653
+ if (host.length < 1 || host.length > 253 || labels.length < 2) throw refused;
654
+ if (labels.some((label) => !LABEL.test(label) || label.startsWith("xn--"))) throw refused;
655
+ if (/^[0-9]+$/.test(labels[labels.length - 1])) throw refused;
656
+ if (isIP(host) !== 0) throw refused;
657
+ return host;
658
+ }
659
+
660
+ export function isSameHost(typed, host) {
661
+ try {
662
+ return normaliseHost(typed) === host;
663
+ } catch {
664
+ return false;
665
+ }
666
+ }
667
+
668
+ export function hostsFile(home, manifest) {
669
+ return join(home, "addons", manifest.NAME + HOSTS_SUFFIX);
670
+ }
671
+
672
+ const unreadable = (path, manifest) => new Refused("hosts_unreadable", `${path} could not be read as a list of websites, so no website is read. Repair the file, or move it away and add the websites again with aamio addon ${manifest.NAME} allow.`);
673
+
674
+ async function readHosts(path, manifest) {
675
+ let rows;
676
+ try {
677
+ // A file of the home: read past a byte order mark at its start, as the
678
+ // other two runtimes read it. Bytes that are not UTF-8 are no list.
679
+ rows = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await readFile(path)));
680
+ } catch (error) {
681
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
682
+ throw unreadable(path, manifest);
683
+ }
684
+ if (!Array.isArray(rows)) throw unreadable(path, manifest);
685
+ for (const row of rows) {
686
+ try {
687
+ if (normaliseHost(row) !== row) throw unreadable(path, manifest);
688
+ } catch {
689
+ throw unreadable(path, manifest);
690
+ }
691
+ }
692
+ return rows;
693
+ }
694
+
695
+ async function writeHosts(runtime, manifest, rows) {
696
+ try {
697
+ await mkdir(join(runtime.home, "addons"), { recursive: true, mode: 0o700 });
698
+ await runtime.saveJson(join("addons", manifest.NAME + HOSTS_SUFFIX), rows);
699
+ } catch {
700
+ throw new Refused("hosts_unwritable", `${hostsFile(runtime.home, manifest)} could not be written, so the list is as it was. Check the disk and the home's permissions.`);
701
+ }
702
+ }
703
+
704
+ /**
705
+ * The websites the user has agreed to for this add-on, from the file as it is
706
+ * now. A file that cannot be read is Refused, never an empty list. When the
707
+ * list does not exist yet and the manifest names a file an earlier version
708
+ * kept in the home, that file is read once and becomes the list, so the user
709
+ * who allowed websites before finds them again.
710
+ */
711
+ export async function allowedHosts(runtime, manifest) {
712
+ const rows = await readHosts(hostsFile(runtime.home, manifest), manifest);
713
+ if (rows !== null) return rows;
714
+ const legacy = manifest.hosts?.legacy_file;
715
+ if (legacy) {
716
+ const old = await readHosts(join(runtime.home, legacy), manifest);
717
+ if (old !== null && old.length) {
718
+ await writeHosts(runtime, manifest, old);
719
+ return old;
720
+ }
721
+ }
722
+ return [];
723
+ }
724
+
725
+ export async function allowHost(runtime, manifest, value) {
726
+ const host = normaliseHost(value);
727
+ const rows = await allowedHosts(runtime, manifest);
728
+ const added = !rows.includes(host);
729
+ if (added) await writeHosts(runtime, manifest, [...rows, host]);
730
+ return { host, added, hosts: added ? [...rows, host] : rows };
731
+ }
732
+
733
+ export async function forgetHost(runtime, manifest, value) {
734
+ const host = normaliseHost(value);
735
+ const rows = await allowedHosts(runtime, manifest);
736
+ const removed = rows.includes(host);
737
+ const kept = rows.filter((row) => row !== host);
738
+ if (removed) await writeHosts(runtime, manifest, kept);
739
+ return { host, removed, hosts: kept };
740
+ }
741
+
742
+ export function notAllowed(manifest, host) {
743
+ const tool = manifest.toolsSpec.find((spec) => spec.host)?.name;
744
+ const over = tool ? ` Over MCP ${tool} asks them.` : "";
745
+ return new Refused("host_not_allowed", `This runtime reads only websites the user has put on its list, and this one is not there. The user adds it with aamio addon ${manifest.NAME} allow ${host}, from their own records and never from the message being checked.${over}`);
746
+ }
747
+
748
+ // ------------------------------------------------------------------ subject
749
+
750
+ /** The key to check and where it came from, resolved by the core: a partner in the address book, or the signer of a message this process verified. */
751
+ export function subjectFor(runtime, source, value) {
752
+ if (source === "partner") {
753
+ if (typeof value !== "string" || !value) throw new Refused("invalid_partner", "partner is a name in the address book.");
754
+ let row;
755
+ try {
756
+ row = runtime.partnerByName(value);
757
+ } catch (error) {
758
+ throw new Refused("partner_unavailable", `${error.message}`);
759
+ }
760
+ if (!row || typeof row.key !== "string" || !KEY.test(row.key)) {
761
+ throw new Refused("partner_unavailable", "No partner by that name with a usable key is in the address book. aamio_partners or aamio partner list shows who is.");
762
+ }
763
+ return { key: row.key, subject_source: "partner", partner: row.name, signature_checked_locally: null };
764
+ }
765
+ if (typeof value !== "string" || !HASH.test(value)) {
766
+ throw new Refused("invalid_message_hash", "message is the sha256 of a message as read shows it: 64 lowercase hexadecimal characters.");
767
+ }
768
+ const keys = runtime.verifiedSigners(value);
769
+ if (keys.some((key) => typeof key !== "string" || !KEY.test(key))) {
770
+ throw new Refused("invalid_local_observation", "The message is recorded here without a usable signing key, so it cannot be checked.");
771
+ }
772
+ if (!keys.length) {
773
+ throw new Refused("source_unavailable", "This process holds no verified message with that hash. Only a message this process read itself and whose signature checked out can be used: an unsigned one has no key to check, and what an earlier process read is not kept and cannot be read again. Check a later message from the same sender, or the partner by name.");
774
+ }
775
+ if (keys.length !== 1) throw new Refused("ambiguous_signer", "Different keys signed the same bytes, so the hash does not say which one to check. Check the partner by name.");
776
+ return { key: keys[0], subject_source: "message", message_sha256: value, signature_checked_locally: true };
777
+ }
778
+
779
+ export function subjectFields(subject) {
780
+ if (!subject) return {};
781
+ const { key, ...rest } = subject;
782
+ return rest;
783
+ }
784
+
785
+ /** A directory of the add-on's own under the home, made on first use, private to the user. */
786
+ export async function stateDir(home, manifest) {
787
+ const path = join(home, "addons", manifest.NAME);
788
+ await mkdir(path, { recursive: true, mode: 0o700 });
789
+ return path;
790
+ }