aamio 0.7.1 → 0.7.2
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 +1 -1
- package/bin/aamio.js +26 -2
- package/package.json +1 -1
- package/src/node-archive.js +6 -3
- package/src/node-mcp.js +10 -1
- package/src/node-runtime.js +315 -24
package/README.md
CHANGED
|
@@ -71,7 +71,7 @@ else: the message is handed over and the send is delivered, with
|
|
|
71
71
|
`archive_failed` in `attention` or `archive_error` on the answer.
|
|
72
72
|
|
|
73
73
|
To connect an MCP client, set its command to `npx` and its arguments to
|
|
74
|
-
`["-y", "aamio@0.7.
|
|
74
|
+
`["-y", "aamio@0.7.2", "--home", "/absolute/path/to/home", "serve"]`, or from a
|
|
75
75
|
checkout the command `node` and the arguments
|
|
76
76
|
`["/absolute/path/to/aamio-js/bin/aamio.js", "--home", "/absolute/path/to/home", "serve"]`.
|
|
77
77
|
The server speaks JSON-RPC over stdio and creates a new identity on first
|
package/bin/aamio.js
CHANGED
|
@@ -9,7 +9,7 @@ import { AddonError, describe, disable, enable, enabledButMissing, installWords,
|
|
|
9
9
|
import { parseCommand, probe, runCommand, usageOf } from "../src/node-addon-process.js";
|
|
10
10
|
import { archiveStatus, parsePolicy } from "../src/node-archive.js";
|
|
11
11
|
import { doctor, homeCheck } from "../src/node-doctor.js";
|
|
12
|
-
import { CHANNEL_TTL, NodeRuntime, SEND_STOPS, checkChannelTtl, checkGate, checkLabel, checkPartner, checkRe, stopFields, tagsFrom } from "../src/node-runtime.js";
|
|
12
|
+
import { BODY_MAX_DEPTH, CHANNEL_TTL, NodeRuntime, SEND_STOPS, checkChannelTtl, checkGate, checkLabel, checkPartner, checkRe, stopFields, tagsFrom, unfitJson } from "../src/node-runtime.js";
|
|
13
13
|
import { checkAccess, checkScopeAdd, checkScopeName } from "../src/node-scopes.js";
|
|
14
14
|
|
|
15
15
|
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
@@ -222,11 +222,34 @@ function scopeName(value) {
|
|
|
222
222
|
|
|
223
223
|
function jsonData(value) {
|
|
224
224
|
if (value === undefined) return null;
|
|
225
|
+
let parsed;
|
|
225
226
|
try {
|
|
226
|
-
|
|
227
|
+
parsed = JSON.parse(value);
|
|
227
228
|
} catch {
|
|
228
229
|
throw Object.assign(new Error("--data is not JSON. Give a JSON value, quoted for the shell."), { code: "usage" });
|
|
229
230
|
}
|
|
231
|
+
return holdable(parsed, "--data", BODY_MAX_DEPTH - 1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A JSON option every reader can hold, or the usage error that says why not:
|
|
236
|
+
* a number that is not finite, as 1e999 is, which JSON.parse makes Infinity
|
|
237
|
+
* and JSON.stringify sends as null, and --data nested so deep that the
|
|
238
|
+
* message around it passes BODY_MAX_DEPTH, which a reader hands over as text.
|
|
239
|
+
* The three runtimes refuse both alike (a security review of 13 November 2026).
|
|
240
|
+
*/
|
|
241
|
+
function holdable(parsed, option, limit) {
|
|
242
|
+
const unreadable = unfitJson(parsed, limit);
|
|
243
|
+
if (unreadable && unreadable[1] === "half a pair") {
|
|
244
|
+
throw Object.assign(new Error(`${option} holds half of a UTF-16 surrogate pair, as "\\ud800" is: JSON can write it, and UTF-8 cannot carry it.`), { code: "usage" });
|
|
245
|
+
}
|
|
246
|
+
if (unreadable && unreadable[1] === "not finite") {
|
|
247
|
+
throw Object.assign(new Error(`${option} holds a number that is not finite, as 1e999 is: JSON can write it, and nothing that reads it can hold it.`), { code: "usage" });
|
|
248
|
+
}
|
|
249
|
+
if (unreadable) {
|
|
250
|
+
throw Object.assign(new Error(`${option} is nested too deep: with the message around it, it is deeper than ${BODY_MAX_DEPTH}, and a reader hands such a message over as text.`), { code: "usage" });
|
|
251
|
+
}
|
|
252
|
+
return parsed;
|
|
230
253
|
}
|
|
231
254
|
|
|
232
255
|
/**
|
|
@@ -349,6 +372,7 @@ function plan(command, positional, options) {
|
|
|
349
372
|
} catch {
|
|
350
373
|
throw wrong("--gate is not JSON. Quote it as one argument, as in --gate '{\"require\": {\"pow\": {\"bits\": 20}}}'.");
|
|
351
374
|
}
|
|
375
|
+
holdable(gate, "--gate", 1000000);
|
|
352
376
|
usage(() => checkGate(gate));
|
|
353
377
|
}
|
|
354
378
|
return (runtime) => runtime.channelOpen(first, { ttl, allow, gate });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aamio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"mcpName": "at.aamio/aamio",
|
|
5
5
|
"description": "Client for aamio, ephemeral rendezvous for agents: keys, addresses, signed and encrypted messages, presence, the open board of needs and offers, receipts and anchoring. Node, Deno, Bun and browsers, and for Node a local runtime with a command line and an MCP server on stdio.",
|
|
6
6
|
"type": "module",
|
package/src/node-archive.js
CHANGED
|
@@ -132,14 +132,17 @@ export function parsePolicy(text, maxMb = null) {
|
|
|
132
132
|
|
|
133
133
|
/** One sentence a person or a model can act on. The words are aamio-python's. */
|
|
134
134
|
export function describePolicy(policy) {
|
|
135
|
-
|
|
135
|
+
// The ceiling holds each time the archive is tidied, not at every write:
|
|
136
|
+
// under one of 128 bytes a row of 415 stayed until the next tidying, where
|
|
137
|
+
// this said never more than (the fifth release check of 14 November 2026).
|
|
138
|
+
const size = policy.max_mb ? ` Each time it is tidied, when a runtime starts and at most once an hour after, the oldest go first until it holds no more than ${policy.max_mb} MB, so in between it can hold more.` : "";
|
|
136
139
|
if (policy.mode === "off") {
|
|
137
140
|
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
141
|
}
|
|
139
142
|
if (policy.mode === "days") {
|
|
140
|
-
return `Decrypted messages and receipts are kept in archive/ for ${policy.days} days
|
|
143
|
+
return `Decrypted messages and receipts are kept in archive/ for ${policy.days} days, then removed.${size} The service itself keeps nothing past a thread's expiry: this archive is yours, on this machine.`;
|
|
141
144
|
}
|
|
142
|
-
return `Decrypted messages and receipts are kept in archive/ until you remove them
|
|
145
|
+
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
146
|
}
|
|
144
147
|
|
|
145
148
|
/** 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. */
|
package/src/node-mcp.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { GateStop, isKey } from "./aamio.js";
|
|
4
4
|
import { readIdentity } from "./node-identity.js";
|
|
5
|
-
import { OUTBOX_NOTES, SEND_STOPS, checkGate, outboxOutcome, stopFields } from "./node-runtime.js";
|
|
5
|
+
import { OUTBOX_NOTES, SEND_STOPS, checkGate, outboxOutcome, stopFields, unfitJson, unfitText } from "./node-runtime.js";
|
|
6
6
|
import { toolsOf } from "./node-addons.js";
|
|
7
7
|
import { HOST_QUESTION, callTool } from "./node-addon-process.js";
|
|
8
8
|
|
|
@@ -595,6 +595,15 @@ async function callAddonTool(id, params, session, version, reply, complete, mani
|
|
|
595
595
|
/** One tool of the runtime. A call and the presence timer take turns with the home. */
|
|
596
596
|
async function callRuntimeTool(id, params, session, version, reply, complete) {
|
|
597
597
|
const runtime = session.runtime;
|
|
598
|
+
// A number that is not finite, as 1e999 is, or nesting deeper than a reader
|
|
599
|
+
// reads as JSON, is refused before any tool runs, an add-on's included, as in
|
|
600
|
+
// the other two runtimes. JSON.parse made 1e999 Infinity here and it went on
|
|
601
|
+
// as null; in aamio-php the arguments vanished and an add-on ran with its
|
|
602
|
+
// defaults (a security review of 13 November 2026).
|
|
603
|
+
if (object(params.arguments)) {
|
|
604
|
+
const unreadable = unfitJson(params.arguments);
|
|
605
|
+
if (unreadable) return complete(toolResult({ error: unfitText(unreadable), error_code: "invalid_arguments", fix: ARGUMENTS_FIX }, true));
|
|
606
|
+
}
|
|
598
607
|
const tool = RUNTIME_TOOLS.find((t) => t.name === params.name);
|
|
599
608
|
if (!tool) {
|
|
600
609
|
const manifest = session.addons.find((held) => held.tools.some((offered) => offered.name === params.name));
|
package/src/node-runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { open, readFile, rename, stat as fsStat, unlink } from "node:fs/promises";
|
|
2
3
|
import { constants } from "node:fs";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -249,6 +250,80 @@ export function checkGate(gate) {
|
|
|
249
250
|
return gate;
|
|
250
251
|
}
|
|
251
252
|
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* What a body may be and still be read as JSON, in all three runtimes: every
|
|
256
|
+
* number finite and nested at most this deep, the outermost array or object
|
|
257
|
+
* counted as one. Anything else is handed over as text. A body nested 5 000
|
|
258
|
+
* deep made every read of its thread throw here, and the messages other
|
|
259
|
+
* channels had handed over in the same read, their cursors saved, were lost.
|
|
260
|
+
* 1e999 became Infinity, and went on as null (a security review of 13 November
|
|
261
|
+
* 2026).
|
|
262
|
+
*/
|
|
263
|
+
export const BODY_MAX_DEPTH = 64;
|
|
264
|
+
// How far back from the end of a channel's archive a read looks for a delivery
|
|
265
|
+
// an earlier process wrote down and did not get past with its cursor.
|
|
266
|
+
const ARCHIVE_TAIL_BYTES = 16 * 1024 * 1024;
|
|
267
|
+
|
|
268
|
+
/** One step of a path: a member name with half a pair in it is written escaped, so the path can be printed. */
|
|
269
|
+
function segment(key) {
|
|
270
|
+
return key.isWellFormed() ? key : JSON.stringify(key).slice(1, -1);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* [path, what] for the first part of value no reader can hold, in document
|
|
275
|
+
* order, or null: "not finite" for a number such as 1e999, "too deep" for
|
|
276
|
+
* arrays and objects nested deeper than limit, and "half a pair" for a string
|
|
277
|
+
* or member name with half of a UTF-16 surrogate pair in it, as "\\ud800"
|
|
278
|
+
* decodes to: JSON can write it and UTF-8 cannot carry it, so it went out as
|
|
279
|
+
* U+FFFD in its place, and aamio-php reads no such body as JSON (the release
|
|
280
|
+
* check of 14 November 2026). The path names keys and indices with dots, and
|
|
281
|
+
* is "" for value itself.
|
|
282
|
+
*/
|
|
283
|
+
export function unfitJson(value, limit = BODY_MAX_DEPTH) {
|
|
284
|
+
const stack = [[value, "", 0]];
|
|
285
|
+
while (stack.length) {
|
|
286
|
+
const [node, path, level] = stack.pop();
|
|
287
|
+
if (typeof node === "number") {
|
|
288
|
+
if (!Number.isFinite(node)) return [path, "not finite"];
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (typeof node === "string") {
|
|
292
|
+
if (!node.isWellFormed()) return [path, "half a pair"];
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (node === null || typeof node !== "object") continue;
|
|
296
|
+
if (level + 1 > limit) return [path, "too deep"];
|
|
297
|
+
const items = Array.isArray(node) ? node.map((child, index) => [String(index), child]) : Object.entries(node);
|
|
298
|
+
for (const [key, child] of items.reverse()) {
|
|
299
|
+
const where = path ? `${path}.${segment(key)}` : segment(key);
|
|
300
|
+
stack.push([child, where, level + 1]);
|
|
301
|
+
// The member name, where it stands: before its value.
|
|
302
|
+
if (!Array.isArray(node)) stack.push([key, where, level + 1]);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** What is wrong with an argument unfitJson found, as all three runtimes say it. */
|
|
309
|
+
export function unfitText([path, what]) {
|
|
310
|
+
const where = path || "arguments";
|
|
311
|
+
if (what === "not finite") return `${where} is a number that is not finite, as 1e999 is: JSON can write it, and nothing that reads it can hold it`;
|
|
312
|
+
if (what === "half a pair") return `${where} holds half of a UTF-16 surrogate pair, as "\\ud800" is: JSON can write it, and UTF-8 cannot carry it`;
|
|
313
|
+
return `${where} is nested deeper than ${BODY_MAX_DEPTH}: a reader hands a message nested that deep over as text`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A body's JSON as { value }, or null when it is to be read as text: not JSON, a number that is not finite, or too deep. */
|
|
317
|
+
function bodyJson(text) {
|
|
318
|
+
let value;
|
|
319
|
+
try {
|
|
320
|
+
value = JSON.parse(text);
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
return unfitJson(value) === null ? { value } : null;
|
|
325
|
+
}
|
|
326
|
+
|
|
252
327
|
/**
|
|
253
328
|
* A label for a channel of the caller's own. It names the channel in every
|
|
254
329
|
* answer and its file in the archive, so it is made of what a file's name may
|
|
@@ -373,6 +448,12 @@ export class NodeRuntime {
|
|
|
373
448
|
this.archivePolicy = { ...DEFAULT_POLICY };
|
|
374
449
|
this.archiveEnabled = false;
|
|
375
450
|
this.prunedAt = 0;
|
|
451
|
+
// The read an async call belongs to, how many reads are saying what the
|
|
452
|
+
// archive holds, and a tidying under way: tidying waits for those reads
|
|
453
|
+
// (the fifth release check of 14 November 2026).
|
|
454
|
+
this.readScope = new AsyncLocalStorage();
|
|
455
|
+
this.receiptWindows = 0;
|
|
456
|
+
this.tidying = null;
|
|
376
457
|
// What this process read on each channel, by write address, as it was
|
|
377
458
|
// handed over, with the thread's instance and the archive's mark: what
|
|
378
459
|
// board replies lists beside the archive. In memory only, and gone with
|
|
@@ -380,7 +461,16 @@ export class NodeRuntime {
|
|
|
380
461
|
this.received = new Map();
|
|
381
462
|
// Messages the archive holds from a read whose cursor could not be
|
|
382
463
|
// written: read again, they are not written down a second time.
|
|
383
|
-
|
|
464
|
+
// By delivery, the name of the archive file each went to.
|
|
465
|
+
this.writtenDown = new Map();
|
|
466
|
+
// What earlier processes wrote to each channel's archive, from the end of
|
|
467
|
+
// the file, by file name: read once a process.
|
|
468
|
+
this.archiveBefore = new Map();
|
|
469
|
+
// How often a delivery was handed over here with a row an earlier process
|
|
470
|
+
// had written: copies held here that wrote none, by board replies' key.
|
|
471
|
+
this.rowsReused = new Map();
|
|
472
|
+
// How far back from the end of a channel's archive that is read.
|
|
473
|
+
this.archiveTailBytes = ARCHIVE_TAIL_BYTES;
|
|
384
474
|
this.host = host.replace(/\/+$/, "");
|
|
385
475
|
// Where a receipt's root is anchored, as aamio-python reads it: AAMIO_VERIFYUM.
|
|
386
476
|
this.verifyum = String(verifyum).replace(/\/+$/, "");
|
|
@@ -498,7 +588,7 @@ export class NodeRuntime {
|
|
|
498
588
|
for (const channel of this.state.channels.filter(gone)) {
|
|
499
589
|
this.received.delete(channel.w);
|
|
500
590
|
// And what was written down of it at a read that could not be saved: it will not be read again.
|
|
501
|
-
for (const delivery of this.writtenDown) if (delivery.startsWith(channel.w + "\u0000")) this.writtenDown.delete(delivery);
|
|
591
|
+
for (const delivery of [...this.writtenDown.keys()]) if (delivery.startsWith(channel.w + "\u0000")) this.writtenDown.delete(delivery);
|
|
502
592
|
}
|
|
503
593
|
this.state.channels = this.state.channels.filter((channel) => !gone(channel));
|
|
504
594
|
}
|
|
@@ -511,6 +601,50 @@ export class NodeRuntime {
|
|
|
511
601
|
// fails costs the record, and is said, and the message, the send or the
|
|
512
602
|
// post it is about stands.
|
|
513
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Tidies the archive when an hour has passed since it was tidied last, and
|
|
606
|
+
* no read is saying what the archive holds. Never within a read, and never
|
|
607
|
+
* while another read is between its first word on the archive and its
|
|
608
|
+
* return: one read() over two channels tidied between them, took the first
|
|
609
|
+
* channel's row and still said archived of it (the fifth release check of
|
|
610
|
+
* 14 November 2026). A tidying that has to wait waits for the next chance.
|
|
611
|
+
*/
|
|
612
|
+
async pruneWhenDue() {
|
|
613
|
+
if (this.now() - this.prunedAt <= 3600) return;
|
|
614
|
+
if (this.readScope?.getStore() !== undefined) return;
|
|
615
|
+
if (this.receiptWindows > 0 || this.tidying) return;
|
|
616
|
+
this.tidying = this.prune().finally(() => {
|
|
617
|
+
this.tidying = null;
|
|
618
|
+
});
|
|
619
|
+
await this.tidying;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* A read as its caller asked for it: it tidies first, when that is due, and
|
|
624
|
+
* nothing within it tidies. The channels it reads one by one, the reads
|
|
625
|
+
* within it and the rows it writes are one read for the archive, so what it
|
|
626
|
+
* says of each message is so when it returns.
|
|
627
|
+
*/
|
|
628
|
+
async outerRead(read) {
|
|
629
|
+
if (!this.readScope || this.readScope.getStore() !== undefined) return read();
|
|
630
|
+
if (this.archiveEnabled && !this.closed) await this.pruneWhenDue();
|
|
631
|
+
const scope = { receipts: false };
|
|
632
|
+
try {
|
|
633
|
+
return await this.readScope.run(scope, read);
|
|
634
|
+
} finally {
|
|
635
|
+
if (scope.receipts) this.receiptWindows--;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** From here until the outer read returns, what it says of the archive is to stay so: tidying waits. */
|
|
640
|
+
async givingReceipts() {
|
|
641
|
+
const scope = this.readScope?.getStore();
|
|
642
|
+
if (!scope || scope.receipts) return;
|
|
643
|
+
while (this.tidying) await this.tidying;
|
|
644
|
+
this.receiptWindows++;
|
|
645
|
+
scope.receipts = true;
|
|
646
|
+
}
|
|
647
|
+
|
|
514
648
|
/** Removes what the policy no longer keeps. A failure is said, since the archive then keeps growing in plain text. */
|
|
515
649
|
async prune() {
|
|
516
650
|
const policy = this.archivePolicy;
|
|
@@ -518,6 +652,11 @@ export class NodeRuntime {
|
|
|
518
652
|
this.prunedAt = this.now();
|
|
519
653
|
try {
|
|
520
654
|
await pruneArchive(this.home, policy, { now: this.now() });
|
|
655
|
+
// What the end of each file held may be gone with it, and with it a row
|
|
656
|
+
// this process wrote at a read whose cursor was not written: the next
|
|
657
|
+
// read looks in the file again.
|
|
658
|
+
this.archiveBefore.clear();
|
|
659
|
+
this.writtenDown.clear();
|
|
521
660
|
} catch (error) {
|
|
522
661
|
this.note("archive", "unpruned", `the archive was not pruned: ${error.code || error.name}: ${error.message}. It keeps growing, and what is in it stays readable to whoever can read this folder.`);
|
|
523
662
|
}
|
|
@@ -530,11 +669,100 @@ export class NodeRuntime {
|
|
|
530
669
|
*/
|
|
531
670
|
async archive(label, record) {
|
|
532
671
|
if (!this.archiveEnabled || this.closed) return false;
|
|
672
|
+
// Before the row, never after it: a ceiling under one row took the row just
|
|
673
|
+
// written, and the read said archived (the fourth release check of 14
|
|
674
|
+
// November 2026). Within a read, never: the outer read tidies before it
|
|
675
|
+
// begins.
|
|
676
|
+
await this.pruneWhenDue();
|
|
533
677
|
await appendRecord(this.home, archiveName(label), record);
|
|
534
|
-
if (this.now() - this.prunedAt > 3600) await this.prune();
|
|
535
678
|
return true;
|
|
536
679
|
}
|
|
537
680
|
|
|
681
|
+
/**
|
|
682
|
+
* What earlier processes wrote to this channel's archive, from the end of
|
|
683
|
+
* the file, as deliveries. The end is enough for what an earlier process
|
|
684
|
+
* wrote and did not get past with its cursor, which was written last: a
|
|
685
|
+
* read takes at most a thread's megabyte of messages. A row further back is
|
|
686
|
+
* not found, and that delivery is written down again. The rows are the ones
|
|
687
|
+
* the archive's own reader counts, by its rules: past a byte order mark at
|
|
688
|
+
* the start of the file, UTF-8, JSON. The first line of the end counts when
|
|
689
|
+
* it starts where a line starts, and is passed by when it is the tail of a
|
|
690
|
+
* line cut by where the end begins.
|
|
691
|
+
*
|
|
692
|
+
* Read again when the file is not the one that was read: replaced, as a
|
|
693
|
+
* prune replaces it, or shorter. A row a prune had removed was still taken
|
|
694
|
+
* as there, and the message said archived: true with no row behind it (the
|
|
695
|
+
* release check of 14 November 2026).
|
|
696
|
+
*/
|
|
697
|
+
async archivedBefore(label) {
|
|
698
|
+
const name = archiveName(label);
|
|
699
|
+
const path = join(this.home, "archive", name + ".jsonl");
|
|
700
|
+
let found = null;
|
|
701
|
+
try {
|
|
702
|
+
found = await fsStat(path, { bigint: true });
|
|
703
|
+
} catch {
|
|
704
|
+
found = null;
|
|
705
|
+
}
|
|
706
|
+
const version = found === null ? null : `${found.dev}:${found.ino}`;
|
|
707
|
+
const size = found === null ? 0 : Number(found.size);
|
|
708
|
+
const held = this.archiveBefore.get(name);
|
|
709
|
+
if (held && held.version === version && size >= held.size) return held.rows;
|
|
710
|
+
// Another file now, or a shorter one: what this process wrote to it at a
|
|
711
|
+
// read that did not get past its cursor is looked for in it again.
|
|
712
|
+
if (held) for (const [delivery, at] of [...this.writtenDown]) if (at === name) this.writtenDown.delete(delivery);
|
|
713
|
+
const rows = new Set();
|
|
714
|
+
if (found !== null) {
|
|
715
|
+
let handle = null;
|
|
716
|
+
try {
|
|
717
|
+
handle = await open(path, "r");
|
|
718
|
+
const start = Math.max(0, size - this.archiveTailBytes);
|
|
719
|
+
// The byte before the end says whether its first line is whole.
|
|
720
|
+
const from = Math.max(0, start - 1);
|
|
721
|
+
const buffer = Buffer.alloc(size - from);
|
|
722
|
+
let filled = 0;
|
|
723
|
+
while (filled < buffer.length) {
|
|
724
|
+
const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, from + filled);
|
|
725
|
+
if (bytesRead === 0) break;
|
|
726
|
+
filled += bytesRead;
|
|
727
|
+
}
|
|
728
|
+
let bytes = buffer.subarray(0, filled);
|
|
729
|
+
let whole = true;
|
|
730
|
+
if (start > 0) {
|
|
731
|
+
whole = bytes[0] === 0x0a;
|
|
732
|
+
bytes = bytes.subarray(1);
|
|
733
|
+
} else if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
734
|
+
bytes = bytes.subarray(3);
|
|
735
|
+
}
|
|
736
|
+
const lines = [];
|
|
737
|
+
for (let at = 0; at < bytes.length; ) {
|
|
738
|
+
let end = bytes.indexOf(0x0a, at);
|
|
739
|
+
if (end === -1) end = bytes.length;
|
|
740
|
+
lines.push(bytes.subarray(at, end));
|
|
741
|
+
at = end + 1;
|
|
742
|
+
}
|
|
743
|
+
if (!whole) lines.shift();
|
|
744
|
+
for (const raw of lines) {
|
|
745
|
+
let row;
|
|
746
|
+
try {
|
|
747
|
+
row = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw));
|
|
748
|
+
} catch {
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (isObject(row) && row.kind === "received" && typeof row.w === "string" && typeof row.instance === "string" && Number.isInteger(row.seq) && typeof row.sha256 === "string") {
|
|
752
|
+
rows.add([row.w, row.instance, row.seq, row.sha256].join("\u0000"));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
} catch {
|
|
756
|
+
// One that cannot be read: there is no row to use again.
|
|
757
|
+
rows.clear();
|
|
758
|
+
} finally {
|
|
759
|
+
await handle?.close();
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
this.archiveBefore.set(name, { rows, version, size });
|
|
763
|
+
return rows;
|
|
764
|
+
}
|
|
765
|
+
|
|
538
766
|
/** The same for a record of something that is done already: what went wrong is given back, said in attention, and never thrown. */
|
|
539
767
|
async archiveSafely(label, record, what) {
|
|
540
768
|
try {
|
|
@@ -568,6 +796,8 @@ export class NodeRuntime {
|
|
|
568
796
|
/** Removes what the policy no longer keeps, or with everything the whole archive, and says how much went. */
|
|
569
797
|
async archivePrune({ everything = false } = {}) {
|
|
570
798
|
const gone = await pruneArchive(this.home, this.archivePolicy, { now: this.now(), everything });
|
|
799
|
+
this.archiveBefore.clear();
|
|
800
|
+
this.writtenDown.clear();
|
|
571
801
|
return { ...(await this.archiveStatus()), removed: gone.removed };
|
|
572
802
|
}
|
|
573
803
|
|
|
@@ -2210,6 +2440,11 @@ export class NodeRuntime {
|
|
|
2210
2440
|
* board inbox is waited on, and the others are asked once.
|
|
2211
2441
|
*/
|
|
2212
2442
|
async boardPoll(wait = 0) {
|
|
2443
|
+
return this.outerRead(() => this.boardPollHeld(wait));
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
/** boardPoll, as one read for the archive. */
|
|
2447
|
+
async boardPollHeld(wait) {
|
|
2213
2448
|
this.dropExpired();
|
|
2214
2449
|
const now = this.now();
|
|
2215
2450
|
const boards = this.state.channels.filter((channel) => isObject(channel) && (channel.label === "board" || String(channel.label).startsWith("board-")) && !channel.muted && Number(channel.expire_at) > now)
|
|
@@ -2280,6 +2515,9 @@ export class NodeRuntime {
|
|
|
2280
2515
|
// and the copies held here are later readings of it.
|
|
2281
2516
|
const stored = count(rows.map(one));
|
|
2282
2517
|
const written = count(held.filter((entry) => entry.archived !== false).map(one));
|
|
2518
|
+
// A copy held here whose row an earlier process wrote wrote none: it is a
|
|
2519
|
+
// later reading of that row (the release check of 14 November 2026).
|
|
2520
|
+
for (const [delivery, times] of this.rowsReused) if (written.has(delivery)) written.set(delivery, Math.max(0, written.get(delivery) - times));
|
|
2283
2521
|
const earlier = new Set([...stored].filter(([delivery, rowsOfIt]) => rowsOfIt > (written.get(delivery) ?? 0)).map(([delivery]) => delivery));
|
|
2284
2522
|
const out = [];
|
|
2285
2523
|
const skipped = [];
|
|
@@ -2365,12 +2603,9 @@ export class NodeRuntime {
|
|
|
2365
2603
|
const raw = message.body;
|
|
2366
2604
|
if (!message.verified || !message.from) return [{ text: raw }, { signed: false, encrypted: false, format: "unsigned" }];
|
|
2367
2605
|
if (!isEnvelope(raw)) {
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
} catch {
|
|
2372
|
-
return [{ text: raw }, { signed: true, encrypted: false, format: "text" }];
|
|
2373
|
-
}
|
|
2606
|
+
const read = bodyJson(raw);
|
|
2607
|
+
if (read === null) return [{ text: raw }, { signed: true, encrypted: false, format: "text" }];
|
|
2608
|
+
const parsed = read.value;
|
|
2374
2609
|
return isObject(parsed) ? [parsed, { signed: true, encrypted: false, format: "json" }] : [{ text: raw }, { signed: true, encrypted: false, format: "json" }];
|
|
2375
2610
|
}
|
|
2376
2611
|
let text;
|
|
@@ -2382,12 +2617,9 @@ export class NodeRuntime {
|
|
|
2382
2617
|
} catch (error) {
|
|
2383
2618
|
return [{ text: null }, { signed: true, encrypted: true, format: "unreadable", error: error.name || "Error" }];
|
|
2384
2619
|
}
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
} catch {
|
|
2389
|
-
return [{ text }, { signed: true, encrypted: true, format: "text" }];
|
|
2390
|
-
}
|
|
2620
|
+
const read = bodyJson(text);
|
|
2621
|
+
if (read === null) return [{ text }, { signed: true, encrypted: true, format: "text" }];
|
|
2622
|
+
const parsed = read.value;
|
|
2391
2623
|
return isObject(parsed) ? [parsed, { signed: true, encrypted: true, format: "json" }] : [{ text: parsed }, { signed: true, encrypted: true, format: "json" }];
|
|
2392
2624
|
}
|
|
2393
2625
|
|
|
@@ -2445,21 +2677,46 @@ export class NodeRuntime {
|
|
|
2445
2677
|
* one before it redelivers what the stored hashes then mark as replays.
|
|
2446
2678
|
*/
|
|
2447
2679
|
async poll(channel, wait = 0, limit = null, maxBytes = null) {
|
|
2680
|
+
return this.outerRead(() => this.pollHeld(channel, wait, limit, maxBytes));
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
/** One poll, inside the read it belongs to. */
|
|
2684
|
+
async pollHeld(channel, wait, limit, maxBytes) {
|
|
2448
2685
|
const before = { after: channel.after, created_at: channel.created_at, instance: channel.instance ?? null, expire_at: channel.expire_at, gone: channel.gone, seen: [...channel.seen], seen_from: [...(channel.seen_from ?? [])], peers: { ...this.state.peers } };
|
|
2449
2686
|
try {
|
|
2450
2687
|
return await this.pollOnce(channel, wait, limit, true, maxBytes);
|
|
2451
2688
|
} catch (error) {
|
|
2452
|
-
if (!error?.notSaved) throw error;
|
|
2453
2689
|
// What this read changed is undone in memory too: the cursor, the hashes
|
|
2454
2690
|
// and the bindings stay as the file has them, and the messages wait at
|
|
2455
|
-
// the service for the next read, which hands them over.
|
|
2691
|
+
// the service for the next read, which hands them over. Whatever stopped
|
|
2692
|
+
// the read: one that threw halfway through its messages kept the hashes of
|
|
2693
|
+
// the ones it had taken, the next channel's save wrote them down, and the
|
|
2694
|
+
// next read handed those messages over marked replay, never having handed
|
|
2695
|
+
// them over at all (the release check of 13 November 2026).
|
|
2456
2696
|
Object.assign(channel, { after: before.after, created_at: before.created_at, instance: before.instance, expire_at: before.expire_at, gone: before.gone, seen: before.seen, seen_from: before.seen_from });
|
|
2457
2697
|
this.state.peers = before.peers;
|
|
2698
|
+
if (!error?.notSaved) throw error;
|
|
2458
2699
|
this.note(channel.label, "not_saved", `what this read found here could not be written down (${error.cause?.code || error.cause?.message}), so nothing from this channel was handed over. It is still there for the next read, and nothing was skipped.`, channel.w);
|
|
2459
2700
|
return [];
|
|
2460
2701
|
}
|
|
2461
2702
|
}
|
|
2462
2703
|
|
|
2704
|
+
/**
|
|
2705
|
+
* One channel of a read across channels, and what went wrong there said in
|
|
2706
|
+
* attention rather than thrown, as aamio-python and aamio-php do. One channel
|
|
2707
|
+
* that threw rejected the whole read, and what the channels before it had
|
|
2708
|
+
* handed over, their cursors already saved, was lost (a security review of
|
|
2709
|
+
* 13 November 2026).
|
|
2710
|
+
*/
|
|
2711
|
+
async pollSafely(channel, wait, limit, maxBytes) {
|
|
2712
|
+
try {
|
|
2713
|
+
return await this.poll(channel, wait, limit, maxBytes);
|
|
2714
|
+
} catch (error) {
|
|
2715
|
+
this.note(channel.label, "unread", `this channel could not be read: ${error?.name || "Error"}. Messages from the other channels are still returned.`);
|
|
2716
|
+
return [];
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2463
2720
|
async saveRead() {
|
|
2464
2721
|
try {
|
|
2465
2722
|
await this.saveState();
|
|
@@ -2606,6 +2863,13 @@ export class NodeRuntime {
|
|
|
2606
2863
|
// cursor cannot be written, so the same rows are not written down again
|
|
2607
2864
|
// when this process reads them again.
|
|
2608
2865
|
const written = [];
|
|
2866
|
+
// The outer read tidied before it began, and from the first message weighed
|
|
2867
|
+
// for the archive nothing tidies until it returns, in this read or another:
|
|
2868
|
+
// what it says of the archive is so as it returns. Tidied here, channel by
|
|
2869
|
+
// channel, a ceiling under one row took the first channel's row when the
|
|
2870
|
+
// hour passed before the second (the fifth release check of 14 November
|
|
2871
|
+
// 2026).
|
|
2872
|
+
if (Array.isArray(data.messages) && data.messages.length > 0 && this.archiveEnabled && !this.closed) await this.givingReceipts();
|
|
2609
2873
|
for (const raw of data.messages) {
|
|
2610
2874
|
if (limit !== null && entries.length >= limit) break;
|
|
2611
2875
|
const entry = this.readEntry(channel, raw);
|
|
@@ -2680,13 +2944,33 @@ export class NodeRuntime {
|
|
|
2680
2944
|
if (typeof channel.instance === "string") row.instance = channel.instance;
|
|
2681
2945
|
const delivery = this.deliveryOf(channel, entry);
|
|
2682
2946
|
if (this.archiveEnabled) {
|
|
2947
|
+
// The file first: one that is another now, or shorter, makes this
|
|
2948
|
+
// process forget what it wrote to it, and that is looked for in it
|
|
2949
|
+
// again (the third release check of 14 November 2026).
|
|
2950
|
+
const before = delivery !== null && typeof channel.instance === "string" ? await this.archivedBefore(channel.label) : new Set();
|
|
2951
|
+
if (delivery !== null && !this.writtenDown.has(delivery) && before.has(delivery)) {
|
|
2952
|
+
// Written down by an earlier process that stopped before its cursor:
|
|
2953
|
+
// the row holds the same message and is used again, where it was
|
|
2954
|
+
// written a second time (the release check of 14 November 2026). A
|
|
2955
|
+
// row without the thread's instance, from a version before it was
|
|
2956
|
+
// kept, could be another delivery, so both are kept.
|
|
2957
|
+
this.writtenDown.set(delivery, archiveName(channel.label));
|
|
2958
|
+
const shown = [channel.w, channel.instance, entry.seq].join("\u0000");
|
|
2959
|
+
this.rowsReused.set(shown, (this.rowsReused.get(shown) ?? 0) + 1);
|
|
2960
|
+
}
|
|
2683
2961
|
if (delivery !== null && this.writtenDown.has(delivery)) {
|
|
2684
|
-
// Written down at a read
|
|
2962
|
+
// Written down at a read that did not get as far as its cursor, by this process or an earlier one.
|
|
2685
2963
|
entry.archived = true;
|
|
2686
2964
|
} else {
|
|
2687
2965
|
try {
|
|
2688
2966
|
entry.archived = (await this.archive(channel.label, row)) !== false;
|
|
2689
|
-
if (entry.archived && delivery !== null)
|
|
2967
|
+
if (entry.archived && delivery !== null) {
|
|
2968
|
+
written.push(delivery);
|
|
2969
|
+
// Known at once: a read that throws before its cursor is written
|
|
2970
|
+
// reads it again, and wrote it down a second time (the release
|
|
2971
|
+
// check of 14 November 2026).
|
|
2972
|
+
this.writtenDown.set(delivery, archiveName(channel.label));
|
|
2973
|
+
}
|
|
2690
2974
|
} catch (error) {
|
|
2691
2975
|
entry.archived = false;
|
|
2692
2976
|
said.push(["archive_failed", `message ${entry.seq} was handed over and could not be written to the archive: ${error.code || error.name}: ${error.message}. The archive does not have it.`]);
|
|
@@ -2739,7 +3023,7 @@ export class NodeRuntime {
|
|
|
2739
3023
|
try {
|
|
2740
3024
|
await this.saveRead();
|
|
2741
3025
|
} catch (error) {
|
|
2742
|
-
for (const delivery of written) this.writtenDown.
|
|
3026
|
+
for (const delivery of written) this.writtenDown.set(delivery, archiveName(channel.label));
|
|
2743
3027
|
throw error;
|
|
2744
3028
|
}
|
|
2745
3029
|
for (const [state, what] of said) this.note(channel.label, state, what, channel.w);
|
|
@@ -2866,6 +3150,13 @@ export class NodeRuntime {
|
|
|
2866
3150
|
|
|
2867
3151
|
/** Messages on every channel still read, the inbox first. The inbox is opened and presence published as the read starts. */
|
|
2868
3152
|
async read({ wait = 0, limit = 50, maxBytes = null } = {}) {
|
|
3153
|
+
// The channels it reads one by one are one read for the archive: it is
|
|
3154
|
+
// tidied before the first, and not again until this returns.
|
|
3155
|
+
return this.outerRead(() => this.readHeld({ wait, limit, maxBytes }));
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
/** read, as one read for the archive. */
|
|
3159
|
+
async readHeld({ wait, limit, maxBytes }) {
|
|
2869
3160
|
const noted = this.attention.length;
|
|
2870
3161
|
await this.ensureInbox();
|
|
2871
3162
|
await this.publishPresence();
|
|
@@ -2896,11 +3187,11 @@ export class NodeRuntime {
|
|
|
2896
3187
|
notAsked.push(channel);
|
|
2897
3188
|
continue;
|
|
2898
3189
|
}
|
|
2899
|
-
messages.push(...(await this.
|
|
3190
|
+
messages.push(...(await this.pollSafely(channel, 0, limit - messages.length, maxBytes)));
|
|
2900
3191
|
}
|
|
2901
3192
|
const inbox = this.channel("inbox");
|
|
2902
3193
|
if (!messages.length && wait > 0 && inbox && !inbox.muted) {
|
|
2903
|
-
messages.push(...(await this.
|
|
3194
|
+
messages.push(...(await this.pollSafely(inbox, wait, limit, maxBytes)));
|
|
2904
3195
|
// What reached the other channels during the wait comes with this
|
|
2905
3196
|
// answer, as in aamio-python: only the inbox can end the wait early. It
|
|
2906
3197
|
// used to wait for the next read (a review of 9 November 2026).
|
|
@@ -2910,7 +3201,7 @@ export class NodeRuntime {
|
|
|
2910
3201
|
notAskedAfter.push(channel);
|
|
2911
3202
|
continue;
|
|
2912
3203
|
}
|
|
2913
|
-
messages.push(...(await this.
|
|
3204
|
+
messages.push(...(await this.pollSafely(channel, 0, limit - messages.length, maxBytes)));
|
|
2914
3205
|
}
|
|
2915
3206
|
}
|
|
2916
3207
|
for (const channel of notAsked) {
|