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,349 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { mkdir, open, readFile, readdir, rename, stat, unlink } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { sha256hex } from "./aamio.js";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* What the local runtime keeps of what was said, and for how long. The
|
|
9
|
+
* service forgets a thread when it expires. A home does not, unless it is told
|
|
10
|
+
* to: archive/ holds what was read, sent and posted, decrypted, one file per
|
|
11
|
+
* channel and one JSON record per line. It is the archive aamio-python and
|
|
12
|
+
* aamio-php keep, in the same files, under the same policy in config.json:
|
|
13
|
+
* keep, which is what a home without a choice has, off, or so many days, with
|
|
14
|
+
* a ceiling on size. "Ephemeral" is about the network.
|
|
15
|
+
*
|
|
16
|
+
* Everything here works on the files of a home it is given. The runtime
|
|
17
|
+
* decides when to write and what a failure means.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const ARCHIVE_MODES = ["keep", "off", "days"];
|
|
21
|
+
export const DEFAULT_POLICY = Object.freeze({ mode: "keep", days: null, max_mb: null });
|
|
22
|
+
|
|
23
|
+
// What a channel may be called when its name is also a file's: letters,
|
|
24
|
+
// digits, dots, dashes and underscores, starting with a letter or a digit.
|
|
25
|
+
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
26
|
+
// Names Windows takes for a device, with or without an extension after them.
|
|
27
|
+
const DEVICE = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i;
|
|
28
|
+
|
|
29
|
+
function isObject(value) {
|
|
30
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function archiveError(code, message) {
|
|
34
|
+
return Object.assign(new Error(message), { code });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether a label can be a file's name as it is: nothing in it leaves the folder or names a device. */
|
|
38
|
+
export function isSafeLabel(label) {
|
|
39
|
+
return typeof label === "string" && SAFE_NAME.test(label) && !DEVICE.test(label) && !label.endsWith(".");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The file a channel's records go in, without its extension. The label, when
|
|
44
|
+
* it can be a file's name. A label that cannot, one another runtime let
|
|
45
|
+
* through with a path or a device in it, is kept under a name made of its
|
|
46
|
+
* hash, inside the folder: written as it stood, ../../notes put what was read
|
|
47
|
+
* on that channel outside the home, in a file no policy ever pruned.
|
|
48
|
+
*/
|
|
49
|
+
export function archiveName(label) {
|
|
50
|
+
return isSafeLabel(label) ? label : "channel-" + sha256hex(String(label)).slice(0, 16);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The text of a file of the home, past a byte order mark at its start. An
|
|
55
|
+
* editor on Windows may leave one in front of a file that is whole otherwise.
|
|
56
|
+
* The state the runtime keeps was read past it, and the rest of the home was
|
|
57
|
+
* not: a policy file with a mark was read as no choice at all, so an archive
|
|
58
|
+
* told to keep nothing was kept again, and nothing said so (a check of 12
|
|
59
|
+
* November 2026). The three runtimes read the home's own files past the mark.
|
|
60
|
+
* The key is not one of them: it holds the seed and nothing else. And what
|
|
61
|
+
* comes from outside the home, a message, a card, an add-on's answer, is read
|
|
62
|
+
* as it is.
|
|
63
|
+
*/
|
|
64
|
+
export const pastMark = (text) => (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A file of the home as text, past one byte order mark, or an error when it
|
|
68
|
+
* is not UTF-8. Read with replacement characters, a policy file with one
|
|
69
|
+
* stray byte was a choice here and no choice in the other two runtimes, and
|
|
70
|
+
* a record written back by a prune came back changed (a check of 12 November
|
|
71
|
+
* 2026). The decoder drops one mark itself.
|
|
72
|
+
*/
|
|
73
|
+
export async function homeText(path) {
|
|
74
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(await readFile(path));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The lines of a file as bytes, the first one past a byte order mark, each with the text it holds when that is UTF-8. */
|
|
78
|
+
async function rawLines(path) {
|
|
79
|
+
let bytes = await readFile(path);
|
|
80
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) bytes = bytes.subarray(3);
|
|
81
|
+
const lines = [];
|
|
82
|
+
let start = 0;
|
|
83
|
+
while (start < bytes.length) {
|
|
84
|
+
let end = bytes.indexOf(0x0a, start);
|
|
85
|
+
if (end === -1) end = bytes.length;
|
|
86
|
+
const raw = bytes.subarray(start, end);
|
|
87
|
+
let text = null;
|
|
88
|
+
try {
|
|
89
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw);
|
|
90
|
+
} catch {
|
|
91
|
+
text = null;
|
|
92
|
+
}
|
|
93
|
+
lines.push({ raw, text });
|
|
94
|
+
start = end + 1;
|
|
95
|
+
}
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The archive policy of a home. No file, or one that says nothing usable, means keep, which is what every version did. */
|
|
100
|
+
export async function readPolicy(home) {
|
|
101
|
+
let stored = null;
|
|
102
|
+
try {
|
|
103
|
+
const config = JSON.parse(await homeText(join(home, "config.json")));
|
|
104
|
+
stored = isObject(config) ? config.archive : null;
|
|
105
|
+
} catch {
|
|
106
|
+
stored = null;
|
|
107
|
+
}
|
|
108
|
+
const policy = { ...DEFAULT_POLICY };
|
|
109
|
+
if (isObject(stored) && ARCHIVE_MODES.includes(stored.mode)) {
|
|
110
|
+
policy.mode = stored.mode;
|
|
111
|
+
if (Number.isInteger(stored.days) && stored.days > 0) policy.days = stored.days;
|
|
112
|
+
if (typeof stored.max_mb === "number" && Number.isFinite(stored.max_mb) && stored.max_mb > 0) policy.max_mb = stored.max_mb;
|
|
113
|
+
}
|
|
114
|
+
if (policy.mode === "days" && !policy.days) policy.mode = "keep";
|
|
115
|
+
return policy;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** keep, off or days:N, as typed on the command line, with a ceiling in megabytes when one is given. Throws invalid_archive. */
|
|
119
|
+
export function parsePolicy(text, maxMb = null) {
|
|
120
|
+
const said = String(text ?? "").trim().toLowerCase();
|
|
121
|
+
const policy = { ...DEFAULT_POLICY };
|
|
122
|
+
const days = /^days:([0-9]{1,6})$/.exec(said);
|
|
123
|
+
if (said === "keep" || said === "off") policy.mode = said;
|
|
124
|
+
else if (days && Number(days[1]) > 0) Object.assign(policy, { mode: "days", days: Number(days[1]) });
|
|
125
|
+
else throw archiveError("invalid_archive", `the archive is keep, off or days:N with N a whole number of days, not ${JSON.stringify(said)}`);
|
|
126
|
+
if (maxMb !== null && maxMb !== undefined) {
|
|
127
|
+
if (typeof maxMb !== "number" || !Number.isFinite(maxMb) || maxMb <= 0) throw archiveError("invalid_archive", "--max-mb is a size above zero");
|
|
128
|
+
policy.max_mb = maxMb;
|
|
129
|
+
}
|
|
130
|
+
return policy;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One sentence a person or a model can act on. The words are aamio-python's. */
|
|
134
|
+
export function describePolicy(policy) {
|
|
135
|
+
const size = policy.max_mb ? ` and never more than ${policy.max_mb} MB, oldest first out` : "";
|
|
136
|
+
if (policy.mode === "off") {
|
|
137
|
+
return "Nothing decrypted is written to this machine. The key, the read keys of open threads and the outbox with sealed bytes still are, since the runtime cannot work without them.";
|
|
138
|
+
}
|
|
139
|
+
if (policy.mode === "days") {
|
|
140
|
+
return `Decrypted messages and receipts are kept in archive/ for ${policy.days} days${size}, then removed. The service itself keeps nothing past a thread's expiry: this archive is yours, on this machine.`;
|
|
141
|
+
}
|
|
142
|
+
return `Decrypted messages and receipts are kept in archive/ until you remove them${size}. The service itself keeps nothing past a thread's expiry: this archive is yours, on this machine. \`aamio archive days:30\` gives it a lifetime, \`aamio archive off\` stops it.`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** After a rename the new name is on disk only when the folder is. Windows has no such call for a folder, and journals the rename. */
|
|
146
|
+
async function syncFolder(folder) {
|
|
147
|
+
if (process.platform === "win32") return;
|
|
148
|
+
try {
|
|
149
|
+
const handle = await open(folder, constants.O_RDONLY);
|
|
150
|
+
try {
|
|
151
|
+
await handle.sync();
|
|
152
|
+
} finally {
|
|
153
|
+
await handle.close();
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// The files are in place. Only that the folder outlives a power cut is not known.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* One record at the end of a channel's file, private from its first byte and
|
|
162
|
+
* on the disk before this returns. A crash in the middle of a write leaves a
|
|
163
|
+
* last line with no end: appended to as it was, the next record became part
|
|
164
|
+
* of that line, and a reader passed over both.
|
|
165
|
+
*/
|
|
166
|
+
export async function appendRecord(home, name, record) {
|
|
167
|
+
const folder = join(home, "archive");
|
|
168
|
+
await mkdir(folder, { recursive: true, mode: 0o700 });
|
|
169
|
+
const path = join(folder, name + ".jsonl");
|
|
170
|
+
let torn = false;
|
|
171
|
+
try {
|
|
172
|
+
const existing = await open(path, "r");
|
|
173
|
+
try {
|
|
174
|
+
const { size } = await existing.stat();
|
|
175
|
+
if (size > 0) {
|
|
176
|
+
const last = Buffer.alloc(1);
|
|
177
|
+
await existing.read(last, 0, 1, size - 1);
|
|
178
|
+
torn = last[0] !== 10;
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
await existing.close();
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error.code !== "ENOENT") throw error;
|
|
185
|
+
}
|
|
186
|
+
const handle = await open(path, "a", 0o600);
|
|
187
|
+
try {
|
|
188
|
+
await handle.writeFile((torn ? "\n" : "") + JSON.stringify(record) + "\n", "utf8");
|
|
189
|
+
await handle.sync();
|
|
190
|
+
} finally {
|
|
191
|
+
await handle.close();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The lines of a file as they are, each with the time its record gives, or
|
|
197
|
+
* null when it gives none. A line that is not UTF-8 gives none, and is kept
|
|
198
|
+
* byte for byte: what cannot be told old is not thrown away as old, and not
|
|
199
|
+
* written back changed either.
|
|
200
|
+
*/
|
|
201
|
+
async function linesOf(path) {
|
|
202
|
+
const found = [];
|
|
203
|
+
for (const { raw, text } of await rawLines(path)) {
|
|
204
|
+
if (text !== null && !text.trim()) continue;
|
|
205
|
+
if (text === null && !raw.length) continue;
|
|
206
|
+
let at = null;
|
|
207
|
+
try {
|
|
208
|
+
const record = text === null ? null : JSON.parse(text);
|
|
209
|
+
if (isObject(record) && typeof record.at === "number" && Number.isFinite(record.at)) at = record.at;
|
|
210
|
+
} catch {
|
|
211
|
+
at = null;
|
|
212
|
+
}
|
|
213
|
+
found.push({ at, line: Buffer.concat([raw, Buffer.from("\n")]) });
|
|
214
|
+
}
|
|
215
|
+
return found;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The files of the archive, by the name before .jsonl, sorted. An archive that is not there has none. */
|
|
219
|
+
export async function archiveNames(home) {
|
|
220
|
+
let names;
|
|
221
|
+
try {
|
|
222
|
+
names = await readdir(join(home, "archive"));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error.code === "ENOENT") return [];
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
return names.filter((name) => name.endsWith(".jsonl")).map((name) => name.slice(0, -".jsonl".length)).sort();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The records of one kind a file holds, oldest first. A file that is not
|
|
232
|
+
* there holds none: an empty archive is a home that was told to keep nothing,
|
|
233
|
+
* or has read nothing yet. A line that is no record is passed over.
|
|
234
|
+
*/
|
|
235
|
+
export async function archivedRecords(home, name, kind) {
|
|
236
|
+
let lines;
|
|
237
|
+
try {
|
|
238
|
+
lines = await rawLines(join(home, "archive", name + ".jsonl"));
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error.code === "ENOENT" || error.code === "EISDIR") return [];
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
const found = [];
|
|
244
|
+
for (const { text } of lines) {
|
|
245
|
+
// A line that is not UTF-8 is no record, as one cut short by a crash is not.
|
|
246
|
+
if (text === null || !text.trim()) continue;
|
|
247
|
+
try {
|
|
248
|
+
const record = JSON.parse(text);
|
|
249
|
+
if (isObject(record) && record.kind === kind) found.push(record);
|
|
250
|
+
} catch {
|
|
251
|
+
// A line cut short by a crash, or not written by a runtime: it is no record.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return found;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Removes what the policy no longer keeps: records past their days, and the
|
|
259
|
+
* oldest ones past the ceiling. A record that cannot be dated is kept: what
|
|
260
|
+
* cannot be told old is not thrown away as old. Each file that changes is
|
|
261
|
+
* written beside itself and renamed over, so a crash in the middle leaves the
|
|
262
|
+
* old file whole. With everything, the whole archive goes. Gives how many
|
|
263
|
+
* records went and how many bytes are left.
|
|
264
|
+
*/
|
|
265
|
+
export async function pruneArchive(home, policy, { now = Date.now() / 1000, everything = false } = {}) {
|
|
266
|
+
const folder = join(home, "archive");
|
|
267
|
+
const names = await archiveNames(home);
|
|
268
|
+
if (!names.length) return { removed: 0, bytes: 0 };
|
|
269
|
+
let removed = 0;
|
|
270
|
+
if (everything) {
|
|
271
|
+
for (const name of names) {
|
|
272
|
+
const path = join(folder, name + ".jsonl");
|
|
273
|
+
removed += (await linesOf(path)).length;
|
|
274
|
+
await unlink(path);
|
|
275
|
+
}
|
|
276
|
+
await syncFolder(folder);
|
|
277
|
+
return { removed, bytes: 0 };
|
|
278
|
+
}
|
|
279
|
+
const horizon = policy.mode === "days" && policy.days ? now - policy.days * 86400 : null;
|
|
280
|
+
const kept = new Map();
|
|
281
|
+
const before = new Map();
|
|
282
|
+
for (const name of names) {
|
|
283
|
+
const all = await linesOf(join(folder, name + ".jsonl"));
|
|
284
|
+
before.set(name, all.length);
|
|
285
|
+
kept.set(name, all.filter((record) => !(horizon !== null && record.at !== null && record.at < horizon)));
|
|
286
|
+
removed += all.length - kept.get(name).length;
|
|
287
|
+
}
|
|
288
|
+
const ceiling = policy.max_mb ? Math.floor(policy.max_mb * 1024 * 1024) : null;
|
|
289
|
+
if (ceiling !== null) {
|
|
290
|
+
let total = 0;
|
|
291
|
+
const oldest = [];
|
|
292
|
+
for (const [name, records] of kept) {
|
|
293
|
+
for (const [index, record] of records.entries()) {
|
|
294
|
+
const size = record.line.length;
|
|
295
|
+
total += size;
|
|
296
|
+
oldest.push({ at: record.at ?? now, name, index, size });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
oldest.sort((a, b) => a.at - b.at || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) || a.index - b.index);
|
|
300
|
+
const gone = new Map();
|
|
301
|
+
for (const record of oldest) {
|
|
302
|
+
if (total <= ceiling) break;
|
|
303
|
+
total -= record.size;
|
|
304
|
+
if (!gone.has(record.name)) gone.set(record.name, new Set());
|
|
305
|
+
gone.get(record.name).add(record.index);
|
|
306
|
+
removed += 1;
|
|
307
|
+
}
|
|
308
|
+
for (const [name, indexes] of gone) kept.set(name, kept.get(name).filter((_, index) => !indexes.has(index)));
|
|
309
|
+
}
|
|
310
|
+
let left = 0;
|
|
311
|
+
for (const [name, records] of kept) {
|
|
312
|
+
const text = Buffer.concat(records.map((record) => record.line));
|
|
313
|
+
left += text.length;
|
|
314
|
+
if (records.length === before.get(name)) continue;
|
|
315
|
+
const path = join(folder, name + ".jsonl");
|
|
316
|
+
const temporary = join(folder, `${name}.jsonl.${randomUUID()}.tmp`);
|
|
317
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
318
|
+
try {
|
|
319
|
+
try {
|
|
320
|
+
await handle.writeFile(text);
|
|
321
|
+
await handle.sync();
|
|
322
|
+
} finally {
|
|
323
|
+
await handle.close();
|
|
324
|
+
}
|
|
325
|
+
await rename(temporary, path);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
await unlink(temporary).catch(() => {});
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (removed) await syncFolder(folder);
|
|
332
|
+
return { removed, bytes: left };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** What the archive holds now, and what the policy means, in the shape aamio-python's archive command answers. */
|
|
336
|
+
export async function archiveStatus(home, policy) {
|
|
337
|
+
let files = 0;
|
|
338
|
+
let bytes = 0;
|
|
339
|
+
let oldest = null;
|
|
340
|
+
for (const name of await archiveNames(home)) {
|
|
341
|
+
const path = join(home, "archive", name + ".jsonl");
|
|
342
|
+
files += 1;
|
|
343
|
+
bytes += (await stat(path)).size;
|
|
344
|
+
for (const record of await linesOf(path)) {
|
|
345
|
+
if (record.at !== null && (oldest === null || record.at < oldest)) oldest = record.at;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return { policy, means: describePolicy(policy), files, bytes, oldest_at: oldest === null ? null : Math.floor(oldest) };
|
|
349
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// aamio doctor for the Node runtime: one answer to three questions, as aamio-python and aamio-php give it. Does this
|
|
2
|
+
// client fit the service, who can read the home, and what is kept. Nothing here is a guess reported as a finding:
|
|
3
|
+
// what could not be checked says so, and is never called fine. The compatibility check and the check of the home are
|
|
4
|
+
// aamio-python's (aamio/compat.py and aamio/storage.py), with the same verdicts and the same words, so the three
|
|
5
|
+
// runtimes say the same of one home and one service.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { basename, join, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
// ------------------------------------------------------------------ compatibility
|
|
12
|
+
|
|
13
|
+
export const PROTOCOL = 1;
|
|
14
|
+
|
|
15
|
+
// Without these there is nothing this client can do.
|
|
16
|
+
export const NEEDS = ["threads", "signing", "long-poll"];
|
|
17
|
+
|
|
18
|
+
// With these it does more. What each one is for, so a missing one can be explained to whoever asks.
|
|
19
|
+
export const USES = {
|
|
20
|
+
allowlist: "opening an inbox only named keys, or only signed messages, may write to",
|
|
21
|
+
reset: "being told when a cursor belongs to an earlier thread at the address",
|
|
22
|
+
gate: "reading what an inbox asks of writers before sending",
|
|
23
|
+
pow: "doing the proof of work an inbox asks for",
|
|
24
|
+
receipts: "taking a receipt for a thread",
|
|
25
|
+
presence: "publishing and looking up where a key can be reached",
|
|
26
|
+
board: "the open board of needs and offers",
|
|
27
|
+
scopes: "unlisted posts for a group of agents",
|
|
28
|
+
"read-limits": "asking for a small answer with X-Limit and X-Max-Bytes, so a busy thread does not arrive all at once",
|
|
29
|
+
"sealed-claim": "having the service refuse a message that calls itself sealed and is readable",
|
|
30
|
+
"canonical-keys": "one key being one string, so allowlists and a reader's own check compare the same strings",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether this client and a service fit, decided from what the service says it can do in its descriptor, as read
|
|
37
|
+
* from /.well-known/aamio.json. full: every capability this client uses is declared; partial: the client works and
|
|
38
|
+
* some of what it offers will not; refuse: another protocol, or something the client cannot work without is missing.
|
|
39
|
+
*/
|
|
40
|
+
export function compatCheck(descriptor) {
|
|
41
|
+
const declared = isObject(descriptor) ? descriptor.protocol : null;
|
|
42
|
+
const details = { client_protocol: PROTOCOL, service_version: isObject(descriptor) ? (descriptor.version ?? null) : null };
|
|
43
|
+
if (!isObject(declared) || !Array.isArray(declared.capabilities)) {
|
|
44
|
+
return Object.assign(details, {
|
|
45
|
+
verdict: "partial",
|
|
46
|
+
why: "This service does not declare a protocol or its capabilities, as services before 0.7.1 did not. The client works with those as far as it has been tested, and cannot tell from here what is missing.",
|
|
47
|
+
missing: [],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
details.service_protocol = declared.version ?? null;
|
|
51
|
+
const offered = new Set(declared.capabilities.map((item) => String(item)));
|
|
52
|
+
if (declared.version !== PROTOCOL) {
|
|
53
|
+
return Object.assign(details, { verdict: "refuse", missing: [], why: `The service speaks protocol ${JSON.stringify(declared.version ?? null)} and this client speaks ${PROTOCOL}. Use a client made for that protocol.` });
|
|
54
|
+
}
|
|
55
|
+
const lacking = NEEDS.filter((name) => !offered.has(name));
|
|
56
|
+
if (lacking.length) {
|
|
57
|
+
return Object.assign(details, { verdict: "refuse", missing: lacking, why: `The service does not offer ${lacking.join(", ")}, and this client cannot work without it.` });
|
|
58
|
+
}
|
|
59
|
+
const missing = Object.keys(USES).filter((name) => !offered.has(name));
|
|
60
|
+
details.missing = missing;
|
|
61
|
+
details.unknown_to_this_client = [...offered].filter((name) => !NEEDS.includes(name) && !(name in USES)).sort();
|
|
62
|
+
if (missing.length) {
|
|
63
|
+
return Object.assign(details, { verdict: "partial", why: "The client works with this service. Not offered there: " + missing.map((name) => `${name} (${USES[name]})`).join("; ") + "." });
|
|
64
|
+
}
|
|
65
|
+
return Object.assign(details, { verdict: "full", why: "Everything this client uses is offered by this service." });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ------------------------------------------------------------------ who can read the home
|
|
69
|
+
|
|
70
|
+
// Who may have access to a private folder on Windows without it being a finding: the system itself, the
|
|
71
|
+
// administrators of the machine, and whoever created or owns the file. Everything else is somebody else.
|
|
72
|
+
const WINDOWS_EXPECTED = new Set(["S-1-5-18", "S-1-5-32-544", "S-1-3-0", "S-1-3-4"]);
|
|
73
|
+
|
|
74
|
+
// SDDL writes the well known accounts as two letters and everyone else as a full SID. An alias not here stays as
|
|
75
|
+
// the two letters it was written as, which reads as an account nobody recognises and is reported: the safe direction
|
|
76
|
+
// for a check about who can read your keys.
|
|
77
|
+
const SDDL_SIDS = {
|
|
78
|
+
BA: "S-1-5-32-544", SY: "S-1-5-18", CO: "S-1-3-0", CG: "S-1-3-1", WD: "S-1-1-0", AU: "S-1-5-11", BU: "S-1-5-32-545",
|
|
79
|
+
BG: "S-1-5-32-546", PU: "S-1-5-32-547", IU: "S-1-5-4", NU: "S-1-5-2", AN: "S-1-5-7", LS: "S-1-5-19", NS: "S-1-5-20",
|
|
80
|
+
RC: "S-1-5-12", OW: "S-1-3-4", AC: "S-1-15-2-1", AO: "S-1-5-32-548", SO: "S-1-5-32-549", PO: "S-1-5-32-550",
|
|
81
|
+
BO: "S-1-5-32-551", RE: "S-1-5-32-552", RU: "S-1-5-32-554", SU: "S-1-5-6", PS: "S-1-5-10", ED: "S-1-5-9",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const WINDOWS_NAMES = {
|
|
85
|
+
"S-1-1-0": "Everyone",
|
|
86
|
+
"S-1-5-11": "Authenticated Users",
|
|
87
|
+
"S-1-5-32-545": "Users",
|
|
88
|
+
"S-1-5-32-546": "Guests",
|
|
89
|
+
"S-1-5-4": "Interactive",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Findings from `sid|rights|type` lines, one per access rule. me is the current user's SID. */
|
|
93
|
+
export function windowsAclFindings(lines, me) {
|
|
94
|
+
const findings = [];
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
const parts = String(line).trim().split("|").map((part) => part.trim());
|
|
97
|
+
if (parts.length !== 3 || parts[0] === "" || parts[2].toLowerCase() !== "allow") continue;
|
|
98
|
+
const [sid, rights] = parts;
|
|
99
|
+
if (sid === me || WINDOWS_EXPECTED.has(sid)) continue;
|
|
100
|
+
const who = WINDOWS_NAMES[sid] ?? sid;
|
|
101
|
+
findings.push({ who, rights, problem: `${who} has access to this folder (${rights}), so the key and everything else in it can be read by others on this machine` });
|
|
102
|
+
}
|
|
103
|
+
return findings;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A Windows tool by its place in System32, so another whoami.exe earlier on the path is not the one asked: Git Bash
|
|
108
|
+
* puts its own first, which answers /user with a usage error, and the second way read nothing.
|
|
109
|
+
*/
|
|
110
|
+
function systemTool(...parts) {
|
|
111
|
+
const path = join(process.env.SystemRoot || process.env.windir || "C:\\Windows", "System32", ...parts);
|
|
112
|
+
return existsSync(path) ? path : parts[parts.length - 1];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function run(command, args) {
|
|
116
|
+
const done = spawnSync(command, args, { encoding: "utf8", timeout: 30_000, windowsHide: true });
|
|
117
|
+
if (done.error) throw done.error;
|
|
118
|
+
if (done.status !== 0) throw Object.assign(new Error(String(done.stderr || done.stdout || `${command} failed`).trim().slice(0, 200)), { code: "EFAILED" });
|
|
119
|
+
return done.stdout;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The folder's access rules, read with Get-Acl, and the current user's SID. */
|
|
123
|
+
export function windowsRulesGetAcl(path, runner = run) {
|
|
124
|
+
const quoted = String(path).replaceAll("'", "''");
|
|
125
|
+
const script = "$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; 'me|' + $me; "
|
|
126
|
+
+ `(Get-Acl -LiteralPath '${quoted}').Access | ForEach-Object { `
|
|
127
|
+
+ "$sid = try { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { $_.IdentityReference.Value }; "
|
|
128
|
+
+ "'{0}|{1}|{2}' -f $sid, $_.FileSystemRights, $_.AccessControlType }";
|
|
129
|
+
const lines = runner(systemTool("WindowsPowerShell", "v1.0", "powershell.exe"), ["-NoProfile", "-NonInteractive", "-Command", script]).split(/\r?\n/).filter((line) => line.trim());
|
|
130
|
+
const meLine = lines.find((line) => line.startsWith("me|"));
|
|
131
|
+
return { me: meLine ? meLine.slice(3).trim() : "", rules: lines.filter((line) => !line.startsWith("me|")) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const unsupported = (message) => Object.assign(new Error(message), { name: "ValueError" });
|
|
135
|
+
|
|
136
|
+
/** icacls writes UTF-16. Read strictly, so a file cut short is not read as a shorter list. */
|
|
137
|
+
function utf16(bytes) {
|
|
138
|
+
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder("utf-16be", { fatal: true }).decode(bytes.subarray(2));
|
|
139
|
+
const body = bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe ? bytes.subarray(2) : bytes;
|
|
140
|
+
return new TextDecoder("utf-16le", { fatal: true }).decode(body);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The same rules as Get-Acl, read with two .exe calls and no PowerShell module: whoami for the SID, and icacls for
|
|
145
|
+
* the folder's access list as SDDL, which gives SIDs where what icacls prints gives account names in the machine's
|
|
146
|
+
* language. Returns what windowsRulesGetAcl returns, so one parser reads both.
|
|
147
|
+
*/
|
|
148
|
+
export function windowsRulesIcacls(path, runner = run, readExport = (file) => readFileSync(file)) {
|
|
149
|
+
const fields = runner(systemTool("whoami.exe"), ["/user", "/fo", "csv", "/nh"]).trim().split(",").map((part) => part.trim().replaceAll('"', ""));
|
|
150
|
+
const me = fields.length ? fields[fields.length - 1] : "";
|
|
151
|
+
if (!me.startsWith("S-1-")) throw unsupported("whoami did not give a SID");
|
|
152
|
+
// Resolved once, so a relative path and its exported name are the same folder. icacls /save writes the final name only.
|
|
153
|
+
const target = resolve(path);
|
|
154
|
+
const folder = mkdtempSync(join(tmpdir(), "aamio-acl-"));
|
|
155
|
+
let exported;
|
|
156
|
+
try {
|
|
157
|
+
const saved = join(folder, "acl.txt");
|
|
158
|
+
runner(systemTool("icacls.exe"), [target, "/save", saved]);
|
|
159
|
+
exported = utf16(readExport(saved)).split(/\r\n|\n|\r/);
|
|
160
|
+
} finally {
|
|
161
|
+
rmSync(folder, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
if (exported.length && exported[exported.length - 1] === "") exported.pop();
|
|
164
|
+
if (exported.length !== 2 || exported[0].toLowerCase() !== basename(target).toLowerCase()) {
|
|
165
|
+
throw unsupported("icacls did not export exactly the requested folder");
|
|
166
|
+
}
|
|
167
|
+
// The complete access list, not fragments of a cut one. NO_ACCESS_CONTROL is not an empty private list. A root can
|
|
168
|
+
// carry S: as well, its integrity label, which grants nothing: only the D: rules belong here.
|
|
169
|
+
const matched = /^D:(?:P|AI|AR)*((?:\([^()]*\))*)(?:S:(?:P|AI|AR)*(?:\([^()]*\))*)?$/.exec(exported[1]);
|
|
170
|
+
if (!matched) throw unsupported("icacls wrote an incomplete or unsupported access control list");
|
|
171
|
+
const rules = [];
|
|
172
|
+
for (const [, ace] of matched[1].matchAll(/\(([^()]*)\)/g)) {
|
|
173
|
+
const parts = ace.split(";");
|
|
174
|
+
if (parts.length !== 6) throw unsupported("icacls wrote an unsupported access rule");
|
|
175
|
+
const [kind, rights, sid] = [parts[0].trim(), parts[2].trim(), parts[5].trim()];
|
|
176
|
+
if (!["A", "D"].includes(kind) || !rights || !sid || parts[3] || parts[4]) throw unsupported("icacls wrote an incomplete or unsupported access rule");
|
|
177
|
+
// A is allow, and only allow rules say who can read this. A deny rule that takes access away is not a finding.
|
|
178
|
+
if (kind === "D") continue;
|
|
179
|
+
rules.push(`${SDDL_SIDS[sid.toUpperCase()] ?? sid}|${rights}|allow`);
|
|
180
|
+
}
|
|
181
|
+
return { me, rules };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const errorName = (error) => error?.code === "ENOENT" ? "FileNotFoundError" : error?.code === "ETIMEDOUT" ? "TimeoutExpired" : error?.name === "ValueError" ? "ValueError" : "OSError";
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Who can read the home, as far as this platform lets us find out. private is true, false, or null when it could not
|
|
188
|
+
* be checked, and a null is never reported as a yes.
|
|
189
|
+
*/
|
|
190
|
+
export function homeCheck(home, { platform = process.platform, getAcl = windowsRulesGetAcl, icacls = windowsRulesIcacls } = {}) {
|
|
191
|
+
const result = { home, private: null, how: null, findings: [] };
|
|
192
|
+
let folder;
|
|
193
|
+
try {
|
|
194
|
+
folder = statSync(home);
|
|
195
|
+
} catch {
|
|
196
|
+
folder = null;
|
|
197
|
+
}
|
|
198
|
+
if (!folder || !folder.isDirectory()) {
|
|
199
|
+
result.how = "not checked: there is no such folder yet";
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
if (platform === "win32") {
|
|
203
|
+
result.how = "the folder's access control list, read with Get-Acl. Mode bits say nothing on Windows.";
|
|
204
|
+
let read;
|
|
205
|
+
try {
|
|
206
|
+
read = getAcl(home);
|
|
207
|
+
} catch (first) {
|
|
208
|
+
// Get-Acl lives in a PowerShell module that does not load everywhere. icacls is an .exe and needs none.
|
|
209
|
+
try {
|
|
210
|
+
read = icacls(home);
|
|
211
|
+
result.how = `the folder's access control list, read with icacls after Get-Acl was not available (${errorName(first)}). Mode bits say nothing on Windows.`;
|
|
212
|
+
} catch (second) {
|
|
213
|
+
result.how = `not checked: the access control list could not be read, by Get-Acl (${errorName(first)}) or by icacls (${errorName(second)})`;
|
|
214
|
+
result.fix = `Run \`icacls "${home}"\` and see that only you, SYSTEM and Administrators are listed.`;
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
result.findings = windowsAclFindings(read.rules, read.me);
|
|
219
|
+
result.private = result.findings.length === 0;
|
|
220
|
+
if (result.findings.length) result.fix = `Remove the inherited access and keep your own: icacls "${home}" /inheritance:r /grant:r "%USERNAME%":(OI)(CI)F`;
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
result.how = "owner and mode bits of the folder and every file in it";
|
|
224
|
+
const me = typeof process.getuid === "function" ? process.getuid() : null;
|
|
225
|
+
const visit = (path, isFolder) => {
|
|
226
|
+
let found;
|
|
227
|
+
try {
|
|
228
|
+
found = statSync(path);
|
|
229
|
+
} catch {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (me !== null && found.uid !== me) result.findings.push({ path, problem: `owned by another user (uid ${found.uid})` });
|
|
233
|
+
else if (found.mode & 0o077) result.findings.push({ path, problem: `readable by others (mode ${(found.mode & 0o777).toString(8)})` });
|
|
234
|
+
if (!isFolder) return;
|
|
235
|
+
let entries;
|
|
236
|
+
try {
|
|
237
|
+
entries = readdirSync(path, { withFileTypes: true });
|
|
238
|
+
} catch {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
for (const entry of entries) visit(join(path, entry.name), entry.isDirectory());
|
|
242
|
+
};
|
|
243
|
+
visit(home, true);
|
|
244
|
+
result.private = result.findings.length === 0;
|
|
245
|
+
if (result.findings.length) result.fix = `chmod -R go-rwx "${home}"`;
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ------------------------------------------------------------------ the answer
|
|
250
|
+
|
|
251
|
+
/** GET a path of the service: the status and the body, and 0 with no body when no answer came. */
|
|
252
|
+
async function ask(runtime, path) {
|
|
253
|
+
try {
|
|
254
|
+
const response = await runtime.client.fetch(runtime.client.base + path, { method: "GET", headers: { Accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
|
|
255
|
+
const text = await response.text();
|
|
256
|
+
let body = null;
|
|
257
|
+
try {
|
|
258
|
+
body = text ? JSON.parse(text) : null;
|
|
259
|
+
} catch {
|
|
260
|
+
body = text;
|
|
261
|
+
}
|
|
262
|
+
return { status: response.status, body };
|
|
263
|
+
} catch {
|
|
264
|
+
return { status: 0, body: null };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** One answer to three questions: does this client fit the service, who can read the home, what is kept. */
|
|
269
|
+
export async function doctor(runtime, version) {
|
|
270
|
+
const health = await ask(runtime, "/health");
|
|
271
|
+
const service = { host: runtime.host, reachable: health.status === 200 };
|
|
272
|
+
if (health.status === 200 && isObject(health.body)) {
|
|
273
|
+
service.version = health.body.version ?? null;
|
|
274
|
+
service.revision = health.body.revision ?? null;
|
|
275
|
+
} else {
|
|
276
|
+
service.problem = `no answer from /health (${health.status}), so nothing below about the service was checked`;
|
|
277
|
+
}
|
|
278
|
+
const descriptor = await ask(runtime, "/.well-known/aamio.json");
|
|
279
|
+
const fits = compatCheck(descriptor.status === 200 ? descriptor.body : null);
|
|
280
|
+
if (descriptor.status !== 200) {
|
|
281
|
+
fits.verdict = "unknown";
|
|
282
|
+
fits.why = `the descriptor could not be read (${descriptor.status}), so compatibility was not checked`;
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
client: { name: "aamio-js", version, protocol: PROTOCOL },
|
|
286
|
+
service,
|
|
287
|
+
compatibility: fits,
|
|
288
|
+
storage: homeCheck(runtime.home),
|
|
289
|
+
archive: await runtime.archiveStatus(),
|
|
290
|
+
outbox_pending: runtime.outboxPending().length,
|
|
291
|
+
};
|
|
292
|
+
}
|
package/src/node-lock.js
CHANGED
|
@@ -97,8 +97,17 @@ async function holdWithFlock(home, path, command) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
/** The operating system's lock on owner.lock. The holder, or null when another process holds it. */
|
|
100
|
-
export async function holdOwnerLock(home,
|
|
101
|
-
|
|
100
|
+
export async function holdOwnerLock(home, options = {}) {
|
|
101
|
+
return holdFile(home, join(home, OWNER_LOCK), options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The operating system's lock on one file of a home, taken the way owner.lock
|
|
106
|
+
* is taken: the holder, or null when another process holds it. The list of
|
|
107
|
+
* enabled add-ons has such a lock beside it, addons.json.lock, which
|
|
108
|
+
* aamio-python and aamio-php take as they take owner.lock.
|
|
109
|
+
*/
|
|
110
|
+
export async function holdFile(home, path, { flock = "flock", platform = process.platform } = {}) {
|
|
102
111
|
if (platform === "win32") return holdWithOpen(home, path, WINDOWS_NO_SHARING, ["EBUSY"]);
|
|
103
112
|
if (BSD.includes(platform)) return holdWithOpen(home, path, BSD_EXLOCK | constants.O_NONBLOCK, ["EAGAIN", "EWOULDBLOCK"]);
|
|
104
113
|
if (platform === "linux") return holdWithFlock(home, path, flock);
|