aamio 0.6.3 → 0.7.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/README.md +262 -10
- package/bin/aamio.js +406 -39
- package/package.json +7 -1
- package/src/aamio.js +12 -3
- package/src/node-addon-process.js +495 -0
- package/src/node-addons.js +790 -0
- package/src/node-archive.js +349 -0
- package/src/node-doctor.js +292 -0
- package/src/node-lock.js +11 -2
- package/src/node-mcp.js +202 -27
- package/src/node-runtime.js +1507 -73
- package/src/node-scopes.js +208 -0
- package/src/node-trace.js +170 -0
|
@@ -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
|
+
}
|