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.
- package/README.md +267 -10
- package/bin/aamio.js +410 -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 +314 -0
- package/src/node-lock.js +11 -2
- package/src/node-mcp.js +244 -36
- package/src/node-runtime.js +1507 -73
- package/src/node-scopes.js +208 -0
- package/src/node-trace.js +170 -0
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
|
-
|
|
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
|
+
}
|