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/src/aamio.js CHANGED
@@ -25,6 +25,13 @@ export const ENVELOPE = "nacl.box.v1";
25
25
  // The board's own default lifetime for a post, mirrored here so an ordinary
26
26
  // post gets the same lifetime whether the field is sent or left out.
27
27
  export const BOARD_TTL = 1800;
28
+ // What a post leaves its inbox: time for the work the board advises and the
29
+ // round trip. The board refuses a post that would outlive the inbox when it
30
+ // arrives, and the lifetime is in the body the work covers, so it is set
31
+ // before the work is done. With nothing left over, a post asked for as long
32
+ // as an inbox lives was refused whenever the work took a second (a check of
33
+ // 12 November 2026). The same in aamio-python and aamio-php.
34
+ export const BOARD_POST_MARGIN = 30;
28
35
 
29
36
  // ------------------------------------------------------------------ encoding
30
37
 
@@ -906,7 +913,7 @@ class Board {
906
913
  */
907
914
  async inbox(seconds = 900) {
908
915
  const held = this.client.boardInbox;
909
- if (held && held.expireAt - Math.floor(Date.now() / 1000) > seconds) return held;
916
+ if (held && held.expireAt - Math.floor(Date.now() / 1000) > seconds + BOARD_POST_MARGIN) return held;
910
917
  const thread = await this.client.open({ ttl: Math.min(3600, Math.max(seconds + 60, 900)), allow: ["*"] });
911
918
  this.client.boardInbox = thread;
912
919
  return thread;
@@ -927,7 +934,7 @@ class Board {
927
934
  // an address on the board is always an address that still works. At the top
928
935
  // of the range the inbox cannot be opened for longer than the post asked
929
936
  // for, so the post gives way, not the promise.
930
- const life = Math.min(ttl, thread.expireAt - Math.floor(Date.now() / 1000));
937
+ const life = Math.min(ttl, thread.expireAt - Math.floor(Date.now() / 1000) - BOARD_POST_MARGIN);
931
938
  const fields = { kind, title, text, w: thread.w, ttl: life };
932
939
  if (tags.length) fields.tags = tags;
933
940
  if (lang) fields.lang = lang;
@@ -1401,7 +1408,9 @@ export class Aamio {
1401
1408
  else if (!message.from || !message.verified) out.error = "encrypted message without a verified sender";
1402
1409
  else {
1403
1410
  try {
1404
- out.plain = textDecoder.decode(this.keys.open(message.from, message.body));
1411
+ // The mark is kept, so a sealed text with a byte order mark in front
1412
+ // is text here as in the runtimes, and not read as JSON.
1413
+ out.plain = new TextDecoder("utf-8", { ignoreBOM: true }).decode(this.keys.open(message.from, message.body));
1405
1414
  } catch {
1406
1415
  out.error = "envelope does not open with these keys";
1407
1416
  }
@@ -0,0 +1,495 @@
1
+ import { spawn } from "node:child_process";
2
+ import { keyHash } from "./aamio.js";
3
+ import {
4
+ AddonError, CORE_VERSION, HOST_COMMANDS, Refused, allowHost, allowedHosts, dest, forgetHost, isSameHost, normaliseHost, notAllowed, refusal, stateDir, subjectFields, subjectFor,
5
+ } from "./node-addons.js";
6
+
7
+ /*
8
+ * One call to an add-on: a process of the add-on's own, started for the call
9
+ * and gone when it has answered. The request goes to its standard input as
10
+ * one JSON document, the answer comes from its standard output as one, and a
11
+ * deadline holds whatever the process does. An add-on that hangs, crashes or
12
+ * floods becomes an answer that says so, and the runtime and its MCP server
13
+ * go on. Nothing an add-on answers changes state here.
14
+ *
15
+ * Before the process starts the core settles what is the core's: the website
16
+ * a call would read is on the user's list or the user is asked, and the name
17
+ * a call gives is resolved to a key from the address book or from a message
18
+ * this process verified. The add-on is given that key, and never a name it
19
+ * could look up itself.
20
+ */
21
+
22
+ export const PROBE_DEADLINE = 20;
23
+ export const MAX_AT_ONCE = 2;
24
+ export const MAX_PER_WINDOW = 20;
25
+ export const WINDOW_SECONDS = 60;
26
+ // The question a tool asks, under this name in inputRequests, for a website that is not on the user's list.
27
+ export const HOST_QUESTION = "aamio_addon_host";
28
+ // The most an answer may be, and the most standard error may carry: a process
29
+ // that streams past either is stopped while it runs, not read to the end.
30
+ export const MAX_ANSWER_BYTES = 1_000_000;
31
+ export const MAX_STDERR_BYTES = 65_536;
32
+ // How long a stopped process gets to go away before it is given up on.
33
+ export const STOP_WAIT = 5;
34
+ // What the process gets of the environment: what the interpreter, the path, a
35
+ // temporary directory, the locale, TLS roots and a proxy need, and nothing
36
+ // else. A token in the user's environment is not the add-on's to read, unless
37
+ // its manifest names it, and then aamio addon list says so before it is
38
+ // enabled. The proxy variables can carry credentials of their own.
39
+ export const ENVIRONMENT = new Set([
40
+ "PATH", "PATHEXT", "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "TEMP", "TMP", "TMPDIR",
41
+ "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA",
42
+ "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ",
43
+ "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR",
44
+ // Node goes through the proxy these name only when NODE_USE_ENV_PROXY says
45
+ // so: the user's own word about the same proxy, and it goes with them.
46
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "NODE_USE_ENV_PROXY",
47
+ ]);
48
+
49
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
50
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
51
+
52
+ // ------------------------------------------------------------------- limits
53
+
54
+ /** At most so many calls of one add-on at once, and so many started per minute: a process per call is a cost, and a model can loop. */
55
+ class Limits {
56
+ constructor() {
57
+ this.atOnce = 0;
58
+ this.started = [];
59
+ }
60
+
61
+ take(now = Date.now() / 1000) {
62
+ while (this.started.length && now - this.started[0] > WINDOW_SECONDS) this.started.shift();
63
+ if (this.atOnce >= MAX_AT_ONCE) return "addon_busy";
64
+ if (this.started.length >= MAX_PER_WINDOW) return "addon_rate";
65
+ this.atOnce += 1;
66
+ this.started.push(now);
67
+ return null;
68
+ }
69
+
70
+ giveBack() {
71
+ this.atOnce = Math.max(0, this.atOnce - 1);
72
+ }
73
+ }
74
+
75
+ const LIMITS = new Map();
76
+
77
+ export function limitsOf(name) {
78
+ if (!LIMITS.has(name)) LIMITS.set(name, new Limits());
79
+ return LIMITS.get(name);
80
+ }
81
+
82
+ /**
83
+ * The add-on's own directory for this call, or the answer that says it could
84
+ * not be made: a home whose disk is full or whose addons folder is in the way
85
+ * stops the call before the process starts, as an answer like any other.
86
+ */
87
+ async function ownDirectory(runtime, manifest) {
88
+ try {
89
+ return [await stateDir(runtime.home, manifest), null];
90
+ } catch (error) {
91
+ return [null, failure(manifest, "addon_failed", `the add-on ${manifest.NAME} has no directory of its own: addons/${manifest.NAME} in the home could not be made (${error.code || error.message}). Nothing of the add-on ran. Check the disk and the home's permissions.`)];
92
+ }
93
+ }
94
+
95
+ const busyRefusal = (manifest, busy) => new Refused(busy, `At most ${MAX_AT_ONCE} calls of the add-on ${manifest.NAME} run at once and ${MAX_PER_WINDOW} start per minute here. Nothing was done. Try again shortly.`, "unreachable");
96
+
97
+ // ------------------------------------------------------------------ process
98
+
99
+ function failure(manifest, reason, fix) {
100
+ return { result: { status: "unreachable", reason, fix, addon: manifest.NAME }, exit_code: 5, is_error: true };
101
+ }
102
+
103
+ /** What the process gets of the environment: the short list, and what its manifest declared it reads, when set and when this is a call and not the probe at enable. Never the home or the user's rest. */
104
+ export function childEnvironment(manifest, declared, from = process.env) {
105
+ const env = {};
106
+ for (const [key, value] of Object.entries(from)) {
107
+ if (value === undefined) continue;
108
+ if (ENVIRONMENT.has(key.toUpperCase()) || (declared && manifest.environment.includes(key))) env[key] = value;
109
+ }
110
+ env.AAMIO_ADDON = "2";
111
+ return env;
112
+ }
113
+
114
+ /**
115
+ * One call to the add-on's process: the request on its standard input, its
116
+ * answer from standard output, within the deadline.
117
+ *
118
+ * stderr is a function given what the process wrote to standard error, or
119
+ * null to drop it. declared false keeps the variables the manifest asks for
120
+ * out of the environment: the probe at enable runs so, before the user has
121
+ * seen the names in aamio addon list. The answer is always an object with
122
+ * result or raw, exit_code and is_error; a process that cannot be started,
123
+ * answers nothing this aamio can read or runs past the deadline becomes an
124
+ * unreachable result with the reason, and the deadline kills it. It is
125
+ * answered once, whichever of those comes first, and never throws.
126
+ */
127
+ export function run(manifest, kind, name, args, { subject = null, ownKey = null, stateDirectory = null, stderr = null, deadline = null, declared = true, limits = {} } = {}) {
128
+ const seconds = deadline ?? manifest.deadline;
129
+ const mostOut = limits.answer ?? MAX_ANSWER_BYTES;
130
+ const mostErr = limits.stderr ?? MAX_STDERR_BYTES;
131
+ const stopWait = limits.stop ?? STOP_WAIT;
132
+ const request = JSON.stringify({
133
+ aamio_addon: 2, kind, name, arguments: args, subject, own_key: ownKey, state_dir: stateDirectory, deadline_seconds: seconds,
134
+ aamio: { version: CORE_VERSION, runtime: "node" },
135
+ });
136
+ const stop = `Reinstall it, or aamio addon disable ${manifest.NAME} to go on without it.`;
137
+ return new Promise((resolve) => {
138
+ const out = [];
139
+ const err = [];
140
+ let outBytes = 0;
141
+ let errBytes = 0;
142
+ let settled = false;
143
+ // Why the process was stopped, once it has been: the answer is that, whatever it wrote before.
144
+ let stopped = null;
145
+ let child = null;
146
+ let timer = null;
147
+ let lastWait = null;
148
+ const finish = (answer) => {
149
+ if (settled) return;
150
+ settled = true;
151
+ clearTimeout(timer);
152
+ clearTimeout(lastWait);
153
+ resolve(answer);
154
+ };
155
+ const kill = (reason, fix) => {
156
+ if (stopped !== null || settled) return;
157
+ stopped = [reason, fix];
158
+ try {
159
+ child.kill("SIGKILL");
160
+ } catch {
161
+ // Gone already.
162
+ }
163
+ // A stopped process gets a moment to go away. One that does not, or
164
+ // that left its pipes with a process of its own, is given up on: the
165
+ // call is answered, and what is left is the system's.
166
+ lastWait = setTimeout(() => {
167
+ child.stdout?.destroy();
168
+ child.stderr?.destroy();
169
+ child.unref?.();
170
+ finish(failure(manifest, reason, fix));
171
+ }, stopWait * 1000);
172
+ };
173
+ try {
174
+ // The script by its real path, with this Node, no shell between, and an
175
+ // environment made here: nothing of the parent's is inherited.
176
+ child = spawn(process.execPath, [manifest.entry], { shell: false, env: childEnvironment(manifest, declared), stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
177
+ } catch (error) {
178
+ finish(failure(manifest, "addon_failed", `the process of the add-on ${manifest.NAME} could not be started: ${error.message}. ${stop}`));
179
+ return;
180
+ }
181
+ // Both streams are listened to, and measured, before anything is written
182
+ // to the process: a process that answers at once is measured like a slow one.
183
+ child.stdout.on("data", (chunk) => {
184
+ outBytes += chunk.length;
185
+ if (outBytes > mostOut) kill("addon_failed", `the add-on ${manifest.NAME} wrote more than the ${mostOut} bytes an answer may be, and was stopped. ${stop}`);
186
+ else out.push(chunk);
187
+ });
188
+ child.stderr.on("data", (chunk) => {
189
+ errBytes += chunk.length;
190
+ if (errBytes > mostErr) kill("addon_failed", `the add-on ${manifest.NAME} wrote more than ${mostErr} bytes to standard error, and was stopped. ${stop}`);
191
+ else err.push(chunk);
192
+ });
193
+ // A process that does not read its request, or that is gone before it
194
+ // was written, breaks the pipe: that is not a failure of its own, and
195
+ // what the process answered or did not answer says the rest.
196
+ child.stdin.on("error", () => {});
197
+ child.on("error", (error) => {
198
+ if (stopped === null) finish(failure(manifest, "addon_failed", `the process of the add-on ${manifest.NAME} could not be started: ${error.message}. ${stop}`));
199
+ });
200
+ timer = setTimeout(() => {
201
+ kill("addon_timeout", `the add-on ${manifest.NAME} did not answer within ${seconds} seconds and was stopped. Nothing else here was affected. Try again, or run the command by hand to see what it does.`);
202
+ }, seconds * 1000);
203
+ child.on("close", (code) => {
204
+ if (stopped !== null) {
205
+ finish(failure(manifest, ...stopped));
206
+ return;
207
+ }
208
+ const text = Buffer.concat(err).toString("utf8");
209
+ if (stderr !== null && text) stderr(text);
210
+ let answer;
211
+ try {
212
+ // The mark is kept, so an answer with a byte order mark in front is
213
+ // not JSON here, as it is not in aamio-python and aamio-php.
214
+ answer = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(out)));
215
+ if (!isObject(answer)) throw new Error("not an object");
216
+ } catch {
217
+ const said = text.trim().split(/\r?\n/).filter(Boolean);
218
+ finish(failure(manifest, "addon_failed", `the add-on ${manifest.NAME} exited with code ${code} and gave no answer this aamio can read${said.length ? ": " + said[said.length - 1].slice(0, 200) : ""}. ${stop}`));
219
+ return;
220
+ }
221
+ let exit = "exit_code" in answer ? answer.exit_code : (code === 0 ? 0 : code);
222
+ if (!Number.isInteger(exit) || exit < 0 || exit > 255) exit = 1;
223
+ const normal = { exit_code: exit, is_error: Boolean("is_error" in answer ? answer.is_error : exit !== 0) };
224
+ if (typeof answer.raw === "string") {
225
+ if (!BASE64.test(answer.raw)) {
226
+ finish(failure(manifest, "addon_failed", `the add-on ${manifest.NAME} answered with raw bytes that are not base64. ${stop}`));
227
+ return;
228
+ }
229
+ normal.raw = Buffer.from(answer.raw, "base64");
230
+ } else {
231
+ normal.result = answer.result ?? null;
232
+ }
233
+ if (typeof answer.stderr === "string" && answer.stderr && stderr !== null) stderr(answer.stderr.endsWith("\n") ? answer.stderr : answer.stderr + "\n");
234
+ finish(normal);
235
+ });
236
+ child.stdin.end(request);
237
+ });
238
+ }
239
+
240
+ /**
241
+ * What enable asks before anything is recorded: the process starts, speaks
242
+ * the protocol, and is the add-on the manifest says. It runs with the short
243
+ * list of environment variables and none the manifest asks for: the user sees
244
+ * those names in aamio addon list before anything of the add-on has run, and
245
+ * the first call is what gets them.
246
+ */
247
+ export async function probe(manifest, options = {}) {
248
+ const answer = await run(manifest, "probe", "probe", {}, { ...options, deadline: Math.min(manifest.deadline, PROBE_DEADLINE), declared: false });
249
+ const said = answer.result;
250
+ if (!isObject(said) || said.ok !== true) {
251
+ const reason = isObject(said) ? said.reason : null;
252
+ const detail = isObject(said) && ["addon_timeout", "addon_failed"].includes(reason) ? said.fix : `it answered ${String(JSON.stringify(said ?? null)).slice(0, 200)}`;
253
+ throw new AddonError(`the add-on ${manifest.NAME} did not answer the probe: ${detail}`);
254
+ }
255
+ if (said.aamio_addon !== 2 || said.name !== manifest.NAME || said.version !== manifest.version) {
256
+ throw new AddonError(`the add-on ${manifest.NAME} answered the probe as ${said.name} ${said.version} on contract ${said.aamio_addon}, and its manifest says ${manifest.NAME} ${manifest.version} on contract 2. Reinstall it, or ${manifest.fix} to go on without it.`);
257
+ }
258
+ }
259
+
260
+ // --------------------------------------------------------- the command line
261
+
262
+ const HOST_HELP = {
263
+ allow: ["put a website on this runtime's list, from your own records", [{ name: "host", help: "the host name, as example.org" }]],
264
+ forget: ["take a website off the list", [{ name: "host" }]],
265
+ list: ["the websites this runtime may read for this add-on", []],
266
+ };
267
+
268
+ function declaredOf(manifest, command) {
269
+ if (manifest.hosts !== null && HOST_COMMANDS.includes(command)) return { help: HOST_HELP[command][0], arguments: HOST_HELP[command][1], core: true };
270
+ return Object.hasOwn(manifest.commands, command) ? manifest.commands[command] : null;
271
+ }
272
+
273
+ function shape(argument) {
274
+ if (!argument.name.startsWith("-")) return argument.name.toUpperCase();
275
+ const value = `${argument.name} ${dest(argument.name).toUpperCase()}`;
276
+ return argument.required ? value : `[${value}]`;
277
+ }
278
+
279
+ /** What aamio addon NAME --help prints: the add-on's commands, from its manifest, with nothing of the add-on run. */
280
+ export function usageOf(manifest) {
281
+ const lines = [`Usage: aamio addon ${manifest.NAME} <command> [arguments]`, ""];
282
+ if (manifest.description) lines.push(manifest.description, "");
283
+ const rows = manifest.commandNames().map((command) => {
284
+ const spec = declaredOf(manifest, command);
285
+ return [[command, ...(spec.arguments ?? []).map(shape)].join(" "), spec.help ?? ""];
286
+ });
287
+ const width = Math.min(44, Math.max(0, ...rows.map(([words]) => words.length)));
288
+ for (const [words, help] of rows) lines.push(` ${words.padEnd(width)} ${help}`.trimEnd());
289
+ for (const command of manifest.commandNames()) {
290
+ const helped = (declaredOf(manifest, command).arguments ?? []).filter((argument) => argument.help);
291
+ if (helped.length) lines.push("", `${command}:`, ...helped.map((argument) => ` ${argument.name.padEnd(18)} ${argument.help}`));
292
+ }
293
+ return lines.join("\n") + "\n";
294
+ }
295
+
296
+ const usage = (message) => Object.assign(new Error(message), { code: "usage" });
297
+
298
+ /**
299
+ * The words after aamio addon NAME, read against the manifest: which command,
300
+ * and each of its arguments by the name the request carries it under. An
301
+ * option takes one value, --name value or --name=value, and one left out is
302
+ * null. Throws a usage error for what the command does not take.
303
+ */
304
+ export function parseCommand(manifest, words) {
305
+ const [command, ...rest] = words;
306
+ if (command === undefined || ["--help", "-h", "help"].includes(command)) return { help: true, asked: command !== undefined };
307
+ const spec = declaredOf(manifest, command);
308
+ if (spec === null) throw usage(`aamio addon ${manifest.NAME} has no command ${command}. It has: ${manifest.commandNames().join(", ")}.`);
309
+ const list = spec.arguments ?? [];
310
+ const options = list.filter((argument) => argument.name.startsWith("-"));
311
+ const positional = list.filter((argument) => !argument.name.startsWith("-"));
312
+ const values = Object.fromEntries(list.map((argument) => [dest(argument.name), null]));
313
+ const given = [];
314
+ let ended = false;
315
+ for (let i = 0; i < rest.length; i++) {
316
+ const word = rest[i];
317
+ if (!ended && word === "--") {
318
+ ended = true;
319
+ } else if (!ended && (word === "--help" || word === "-h")) {
320
+ return { help: true, asked: true };
321
+ } else if (!ended && word.startsWith("--")) {
322
+ const [name, inline] = word.includes("=") ? [word.slice(0, word.indexOf("=")), word.slice(word.indexOf("=") + 1)] : [word, undefined];
323
+ if (!options.some((option) => option.name === name)) throw usage(`aamio addon ${manifest.NAME} ${command} does not take ${name}. Run aamio addon ${manifest.NAME} --help for usage.`);
324
+ const value = inline ?? rest[++i];
325
+ if (value === undefined) throw usage(`${name} needs a value.`);
326
+ values[dest(name)] = value;
327
+ } else {
328
+ given.push(word);
329
+ }
330
+ }
331
+ if (given.length !== positional.length) {
332
+ throw usage(`aamio addon ${manifest.NAME} ${command} takes ${positional.length ? positional.map((argument) => argument.name.toUpperCase()).join(" ") : "no arguments"}${options.length ? " and " + options.map(shape).join(" ") : ""}. Run aamio addon ${manifest.NAME} --help for usage.`);
333
+ }
334
+ positional.forEach((argument, index) => { values[dest(argument.name)] = given[index]; });
335
+ const missing = options.filter((option) => option.required && values[dest(option.name)] === null);
336
+ if (missing.length) throw usage(`aamio addon ${manifest.NAME} ${command} needs ${missing.map((option) => option.name).join(" and ")}.`);
337
+ return { help: false, command, spec, values, core: spec.core === true, needsRuntime: spec.core === true || spec.runtime !== false };
338
+ }
339
+
340
+ /**
341
+ * One command of an add-on, as the command line runs it: what to print and
342
+ * the exit code. runtime is the open runtime, or null for a command whose
343
+ * manifest says it runs without one: the process is started all the same,
344
+ * with no directory of its own. out is the JSON to print, raw the bytes to
345
+ * write as they are; one of them, or neither.
346
+ */
347
+ export async function runCommand(manifest, parsed, runtime, { stderr = null, limits } = {}) {
348
+ const { command, spec, values } = parsed;
349
+ if (parsed.core) {
350
+ try {
351
+ if (command === "allow") return { code: 0, out: { ...(await allowHost(runtime, manifest, values.host)), note: `This runtime may now read this website for the add-on ${manifest.NAME} when a call asks for it. Nothing was read now.` } };
352
+ if (command === "forget") return { code: 0, out: await forgetHost(runtime, manifest, values.host) };
353
+ return { code: 0, out: { hosts: await allowedHosts(runtime, manifest) } };
354
+ } catch (refused) {
355
+ if (!(refused instanceof Refused)) throw refused;
356
+ return { code: 2, out: refusal(refused) };
357
+ }
358
+ }
359
+ const args = { ...values };
360
+ let subject = null;
361
+ let ownKey = null;
362
+ let host = null;
363
+ try {
364
+ for (const argument of spec.arguments ?? []) {
365
+ const called = dest(argument.name);
366
+ if (argument.host && args[called] !== null) {
367
+ host = args[called] = normaliseHost(args[called]);
368
+ if (!(await allowedHosts(runtime, manifest)).includes(host)) throw notAllowed(manifest, host);
369
+ }
370
+ if (argument.subject && args[called] !== null) subject = subjectFor(runtime, argument.subject, args[called]);
371
+ }
372
+ if ((spec.needs ?? []).includes("own_key")) ownKey = runtime.keys.public;
373
+ } catch (refused) {
374
+ if (!(refused instanceof Refused)) throw refused;
375
+ return { code: 2, out: refusal(refused, { host, ...subjectFields(subject) }) };
376
+ }
377
+ const [directory, noDirectory] = runtime !== null ? await ownDirectory(runtime, manifest) : [null, null];
378
+ if (noDirectory !== null) return { code: noDirectory.exit_code, out: noDirectory.result };
379
+ const held = limitsOf(manifest.NAME);
380
+ const busy = held.take();
381
+ if (busy !== null) return { code: 5, out: refusal(busyRefusal(manifest, busy), { host }) };
382
+ let answer;
383
+ try {
384
+ answer = await run(manifest, "command", command, args, { subject, ownKey, stateDirectory: directory, stderr, limits });
385
+ } finally {
386
+ held.giveBack();
387
+ }
388
+ if ("raw" in answer) return { code: answer.exit_code, raw: answer.raw };
389
+ return answer.result !== null && answer.result !== undefined ? { code: answer.exit_code, out: answer.result } : { code: answer.exit_code };
390
+ }
391
+
392
+ // ---------------------------------------------------------------------- MCP
393
+
394
+ /**
395
+ * The form for a website that is not on the user's list. The user types the
396
+ * domain, and is not shown the one asked for. A lookalike reads as right when
397
+ * it is put in front of someone, and a form that offered it could be answered
398
+ * with one click. So the domain the agent asked for is not in the form: what
399
+ * the user types has to match it, or nothing is read. Who is being checked is
400
+ * said in words this runtime holds, a name from the address book or a key's
401
+ * hash, and nothing a message wrote is in the text.
402
+ */
403
+ export function hostQuestion(manifest, subject) {
404
+ let who;
405
+ if (subject === null) who = `what the add-on ${manifest.NAME} was asked about`;
406
+ else if (subject.subject_source === "partner") who = `your partner ${subject.partner}`;
407
+ else who = `the key that signed a message (hash ${keyHash(subject.key).slice(0, 8)})`;
408
+ return {
409
+ message: `Your agent asks to read a website, through the add-on ${manifest.NAME}, about ${who}. Which website do you expect? ` +
410
+ "Type the domain from your own records, such as a contract or an earlier exchange, and not from a message: anyone can publish what they like on their own site. " +
411
+ `What you type has to match what your agent asked for, or nothing is read. A match stays on this runtime's list of websites it may read for ${manifest.NAME} until you run aamio addon ${manifest.NAME} forget.`,
412
+ requestedSchema: { type: "object", properties: { domain: { type: "string", title: "Website domain", description: "the host name only, as example.org, with no https:// and no path", minLength: 3, maxLength: 253 } }, required: ["domain"] },
413
+ };
414
+ }
415
+
416
+ /** A website not on the list: the user is asked in a form, or the call is refused with the way by hand. null once the host is allowed. */
417
+ async function consent(manifest, runtime, toolName, host, subject, args, ask, NoDialog, serial) {
418
+ const byHand = subject !== null && subject.subject_source === "message"
419
+ ? `run aamio addon ${manifest.NAME} allow ${host} the next time this server is stopped, and check a later message from the same sender. Stopping it for this message would lose it: what this process verified is not kept, and a message cannot be read again`
420
+ : `stop this server, run aamio addon ${manifest.NAME} allow ${host}, and start it again`;
421
+ const known = { host, ...subjectFields(subject) };
422
+ if (ask === null) {
423
+ return refusal(new Refused("host_not_allowed", `This website is not on the user's list, and this app did not say it can show them a form (the elicitation capability of MCP), so nothing was read. The user adds it from their own records: ${byHand}.`), known);
424
+ }
425
+ // The whole call is what the answer belongs to, so an answer cannot be
426
+ // carried over to another website or another subject.
427
+ const about = JSON.stringify([manifest.NAME, toolName, host, Object.keys(args).sort().map((field) => [field, args[field]])]);
428
+ let answer;
429
+ try {
430
+ answer = await ask(hostQuestion(manifest, subject), about);
431
+ } catch (why) {
432
+ if (!(why instanceof NoDialog)) throw why;
433
+ return refusal(new Refused("host_not_allowed", `${why.message}, so nothing was read. Call ${toolName} again to ask the user again, or the user adds the website on the command line: ${byHand}.`), known);
434
+ }
435
+ if (answer.action !== "accept") {
436
+ return { status: "invalid", reason: answer.action === "decline" ? "host_declined" : "host_not_answered", host, note: "The user did not agree to this website, so nothing was read and nothing was added. Do not ask again unless the user says so." };
437
+ }
438
+ const content = isObject(answer.content) ? answer.content : {};
439
+ if (!isSameHost(content.domain, host)) {
440
+ return refusal(new Refused("host_not_confirmed", "The user typed a different website from the one you asked for, so nothing was read and nothing was added. Ask the user which website they meant, and take it from them and not from a message."), known);
441
+ }
442
+ await serial(() => allowHost(runtime, manifest, host));
443
+ return null;
444
+ }
445
+
446
+ /**
447
+ * One tool call, through the add-on's process, after the core has settled
448
+ * consent and the subject: what the tool answers, and whether it is an error.
449
+ * ask(question, about) puts a form in front of the user, or is null where
450
+ * this client cannot be asked; serial runs one step with the home in turn.
451
+ */
452
+ export async function callTool(manifest, runtime, name, given, { ask = null, NoDialog = Error, serial = (act) => act(), limits } = {}) {
453
+ const spec = manifest.toolsSpec.find((tool) => tool.name === name);
454
+ const fields = Object.keys(spec.inputSchema.properties ?? {});
455
+ const extra = Object.keys(given).filter((field) => !fields.includes(field)).sort();
456
+ if (extra.length) {
457
+ return [{
458
+ error: `${name} takes ${[...fields].sort().join(", ")}, and was given ${extra.join(", ")}`,
459
+ fix: "Call it with those alone. A key comes from the address book or from the signature this process checked, never from the conversation.",
460
+ given: extra,
461
+ }, true];
462
+ }
463
+ const args = { ...given };
464
+ let host = null;
465
+ let subject = null;
466
+ let ownKey = null;
467
+ try {
468
+ if (spec.host && args[spec.host] !== null && args[spec.host] !== undefined) host = args[spec.host] = normaliseHost(args[spec.host]);
469
+ const roles = Object.entries(spec.subject ?? {});
470
+ const named = roles.filter(([, field]) => args[field] !== null && args[field] !== undefined);
471
+ if (roles.length && named.length !== 1) throw new Refused("choose_subject", `Name exactly one of ${roles.map(([role, field]) => `${field} (${role})`).join(" or ")}. A key is never given here.`);
472
+ if (named.length) subject = await serial(() => subjectFor(runtime, named[0][0], args[named[0][1]]));
473
+ if ((spec.needs ?? []).includes("own_key")) ownKey = runtime.keys.public;
474
+ if (host !== null && !(await serial(() => allowedHosts(runtime, manifest))).includes(host)) {
475
+ const answer = await consent(manifest, runtime, name, host, subject, args, ask, NoDialog, serial);
476
+ if (answer !== null) return [answer, "fix" in answer];
477
+ }
478
+ } catch (refused) {
479
+ if (!(refused instanceof Refused)) throw refused;
480
+ return [refusal(refused, { host, ...subjectFields(subject) }), true];
481
+ }
482
+ const [directory, noDirectory] = await ownDirectory(runtime, manifest);
483
+ if (noDirectory !== null) return [noDirectory.result, true];
484
+ const held = limitsOf(manifest.NAME);
485
+ const busy = held.take();
486
+ if (busy !== null) return [refusal(busyRefusal(manifest, busy), { host }), true];
487
+ let answer;
488
+ try {
489
+ answer = await run(manifest, "tool", name, args, { subject, ownKey, stateDirectory: directory, limits });
490
+ } finally {
491
+ held.giveBack();
492
+ }
493
+ if ("raw" in answer) return [{ raw_base64: answer.raw.toString("base64") }, answer.is_error];
494
+ return [answer.result, answer.is_error];
495
+ }