@adapt-toolkit/a2adapt 0.8.0 → 0.9.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/dist/cli.js +129 -4
- package/dist/hooks/runner.js +24 -7
- package/dist/index.js +332 -23
- package/dist/mufl_code/1EE76434DB30C2B2748C87551AC167419C61022BD982D463DA247922DB817B4D.muflo +0 -0
- package/dist/mufl_code/actor.mu +420 -24
- package/package.json +3 -3
- package/dist/mufl_code/BA1E7E4B9D350E98D474F122789C2E5B6A187C7CFE493318E109A469CE2E2D62.muflo +0 -0
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import * as fs2 from "node:fs";
|
|
|
13
13
|
// src/config.ts
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import { resolve, join, dirname } from "node:path";
|
|
16
|
+
import { resolve, join, dirname, basename } from "node:path";
|
|
17
17
|
var DEFAULT_CONFIG = {
|
|
18
18
|
brokerUrl: "ws://a2adapt.adaptframework.solutions/broker",
|
|
19
19
|
port: 3030,
|
|
@@ -70,6 +70,29 @@ function writeConfig(cfg) {
|
|
|
70
70
|
}
|
|
71
71
|
return path;
|
|
72
72
|
}
|
|
73
|
+
var IDENTITY_FILENAME = ".a2adapt-identity";
|
|
74
|
+
function buildIdentityFile(opts) {
|
|
75
|
+
if (!opts.name.trim()) throw new Error("identity name must not be empty");
|
|
76
|
+
const obj = { identity: opts.name.trim() };
|
|
77
|
+
if (opts.force) obj.force = true;
|
|
78
|
+
obj.expose_local = opts.exposeLocal ?? true;
|
|
79
|
+
obj.local_auto_accept = opts.localAutoAccept ?? true;
|
|
80
|
+
return obj;
|
|
81
|
+
}
|
|
82
|
+
function resolveIdentityFilePath(target) {
|
|
83
|
+
const abs = resolve(target);
|
|
84
|
+
return basename(abs) === IDENTITY_FILENAME ? abs : join(abs, IDENTITY_FILENAME);
|
|
85
|
+
}
|
|
86
|
+
function writeIdentityFile(target, opts, overwrite = false) {
|
|
87
|
+
const obj = buildIdentityFile(opts);
|
|
88
|
+
const path = resolveIdentityFilePath(target);
|
|
89
|
+
if (!overwrite && fs.existsSync(path)) {
|
|
90
|
+
throw new Error(`${path} already exists \u2014 pass overwrite to replace it`);
|
|
91
|
+
}
|
|
92
|
+
fs.mkdirSync(dirname(path), { recursive: true });
|
|
93
|
+
fs.writeFileSync(path, JSON.stringify(obj, null, 2) + "\n");
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
73
96
|
|
|
74
97
|
// src/cli.ts
|
|
75
98
|
var CONFIG = loadConfig();
|
|
@@ -274,6 +297,93 @@ daemon is running (pid ${pid}); restart now to apply? [y/N]: `)).trim().toLowerC
|
|
|
274
297
|
const r = spawnSync(process.execPath, [SELF, "start"], { stdio: "inherit" });
|
|
275
298
|
if (r.status !== 0) process.exit(r.status ?? 1);
|
|
276
299
|
}
|
|
300
|
+
function flagPair(argv, on, off) {
|
|
301
|
+
if (argv.includes(off)) return { value: false, set: true };
|
|
302
|
+
if (argv.includes(on)) return { value: true, set: true };
|
|
303
|
+
return { value: void 0, set: false };
|
|
304
|
+
}
|
|
305
|
+
function flagValue(argv, name) {
|
|
306
|
+
const i = argv.indexOf(name);
|
|
307
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : void 0;
|
|
308
|
+
}
|
|
309
|
+
async function cmdDefineLocalIdentityFile(argv) {
|
|
310
|
+
const name = flagValue(argv, "--name");
|
|
311
|
+
const force = flagPair(argv, "--force-bind", "--no-force-bind");
|
|
312
|
+
const localBook = flagPair(argv, "--local-book", "--no-local-book");
|
|
313
|
+
const autoAccept = flagPair(argv, "--auto-accept-local", "--no-auto-accept-local");
|
|
314
|
+
const overwrite = argv.includes("--overwrite");
|
|
315
|
+
const print = argv.includes("--print");
|
|
316
|
+
const target = flagValue(argv, "--path") ?? flagValue(argv, "--dir") ?? process.cwd();
|
|
317
|
+
const nonInteractive = name !== void 0 || force.set || localBook.set || autoAccept.set || print;
|
|
318
|
+
let opts;
|
|
319
|
+
if (nonInteractive) {
|
|
320
|
+
if (!name || !name.trim()) {
|
|
321
|
+
err("define-local-identity-file: --name is required in non-interactive mode.");
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
opts = {
|
|
325
|
+
name: name.trim(),
|
|
326
|
+
force: force.value ?? false,
|
|
327
|
+
exposeLocal: localBook.value ?? true,
|
|
328
|
+
localAutoAccept: autoAccept.value ?? true
|
|
329
|
+
};
|
|
330
|
+
} else {
|
|
331
|
+
opts = await runIdentitySurvey();
|
|
332
|
+
}
|
|
333
|
+
if (print) {
|
|
334
|
+
out(JSON.stringify(buildIdentityFile(opts), null, 2));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const path = resolveIdentityFilePath(target);
|
|
338
|
+
if (!overwrite && fs2.existsSync(path)) {
|
|
339
|
+
if (nonInteractive) {
|
|
340
|
+
err(`define-local-identity-file: ${path} already exists \u2014 pass --overwrite to replace it.`);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
344
|
+
let ok = false;
|
|
345
|
+
try {
|
|
346
|
+
const ans = (await rl.question(`
|
|
347
|
+
${path} already exists \u2014 overwrite? [y/N]: `)).trim().toLowerCase();
|
|
348
|
+
ok = ans === "y" || ans === "yes";
|
|
349
|
+
} finally {
|
|
350
|
+
rl.close();
|
|
351
|
+
}
|
|
352
|
+
if (!ok) {
|
|
353
|
+
out("aborted \u2014 nothing written.");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const written = writeIdentityFile(target, opts, true);
|
|
358
|
+
out("");
|
|
359
|
+
out(`wrote ${written}:`);
|
|
360
|
+
out(JSON.stringify(buildIdentityFile(opts), null, 2));
|
|
361
|
+
}
|
|
362
|
+
async function runIdentitySurvey() {
|
|
363
|
+
out(`a2adapt-mcp define-local-identity-file \u2014 interactive`);
|
|
364
|
+
out(`Answer the prompts; the result is written to ${join2(process.cwd(), IDENTITY_FILENAME)}.`);
|
|
365
|
+
out("");
|
|
366
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
367
|
+
try {
|
|
368
|
+
const askYesNo = async (label, def) => {
|
|
369
|
+
const hint = def ? "Y/n" : "y/N";
|
|
370
|
+
const ans = (await rl.question(` ${label} [${hint}]: `)).trim().toLowerCase();
|
|
371
|
+
if (ans === "") return def;
|
|
372
|
+
return ans === "y" || ans === "yes";
|
|
373
|
+
};
|
|
374
|
+
let name = "";
|
|
375
|
+
while (!name) {
|
|
376
|
+
name = (await rl.question(" Identity name: ")).trim();
|
|
377
|
+
if (!name) out(" (name is required)");
|
|
378
|
+
}
|
|
379
|
+
const force = await askYesNo("Force-bind (pin pre-authorizes evicting another session)?", false);
|
|
380
|
+
const exposeLocal = await askYesNo("Add to the host-local contact book?", true);
|
|
381
|
+
const localAutoAccept = await askYesNo("Auto-accept local invites/introductions?", true);
|
|
382
|
+
return { name, force, exposeLocal, localAutoAccept };
|
|
383
|
+
} finally {
|
|
384
|
+
rl.close();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
277
387
|
function cmdWatch(which) {
|
|
278
388
|
const offsets = /* @__PURE__ */ new Map();
|
|
279
389
|
const scan = (initial) => {
|
|
@@ -321,9 +431,15 @@ function cmdWatch(which) {
|
|
|
321
431
|
} catch {
|
|
322
432
|
continue;
|
|
323
433
|
}
|
|
324
|
-
|
|
325
|
-
`[${name}]
|
|
326
|
-
)
|
|
434
|
+
if (msg.event === "local_contact_request") {
|
|
435
|
+
out(`[${name}] pending local introduction from ${msg.from ?? "?"} \u2014 respond_to_introduction to approve/reject`);
|
|
436
|
+
} else if (msg.event === "pending_message") {
|
|
437
|
+
out(`[${name}] ${msg.from ?? "?"} queued a message awaiting introduction approval (${msg.queued ?? "?"} queued)`);
|
|
438
|
+
} else {
|
|
439
|
+
out(
|
|
440
|
+
`[${name}] new message from ${msg.from ?? "?"}` + (msg.msg_id !== void 0 ? ` (#${msg.msg_id})` : "") + (msg.date ? ` (${msg.date})` : "")
|
|
441
|
+
);
|
|
442
|
+
}
|
|
327
443
|
}
|
|
328
444
|
}
|
|
329
445
|
};
|
|
@@ -476,6 +592,12 @@ function usage() {
|
|
|
476
592
|
out(" serve run in the foreground (used by start; handy for debugging)");
|
|
477
593
|
out(" watch [identity] stream one line per new inbound message (wake source for a Monitor)");
|
|
478
594
|
out("");
|
|
595
|
+
out(" define-local-identity-file write a .a2adapt-identity workspace pin");
|
|
596
|
+
out(" interactive (default): 4-question survey, writes to CWD");
|
|
597
|
+
out(" scripted: --name <s> [--force-bind] [--local-book] [--auto-accept-local]");
|
|
598
|
+
out(" negate with --no-force-bind / --no-local-book / --no-auto-accept-local");
|
|
599
|
+
out(" --dir <path> | --path <file> (default CWD) \xB7 --overwrite \xB7 --print");
|
|
600
|
+
out("");
|
|
479
601
|
out(" install-service install + start a boot-persistent service (systemd/launchd)");
|
|
480
602
|
out(" uninstall-service stop + remove that service");
|
|
481
603
|
out("");
|
|
@@ -525,6 +647,9 @@ async function main() {
|
|
|
525
647
|
case "setup":
|
|
526
648
|
await cmdSetup();
|
|
527
649
|
break;
|
|
650
|
+
case "define-local-identity-file":
|
|
651
|
+
await cmdDefineLocalIdentityFile(process.argv.slice(3));
|
|
652
|
+
break;
|
|
528
653
|
case "watch":
|
|
529
654
|
cmdWatch(process.argv[3]);
|
|
530
655
|
break;
|
package/dist/hooks/runner.js
CHANGED
|
@@ -86,8 +86,14 @@ function findPinnedIdentity(start) {
|
|
|
86
86
|
continue;
|
|
87
87
|
}
|
|
88
88
|
try {
|
|
89
|
-
const
|
|
90
|
-
|
|
89
|
+
const parsed = JSON.parse(raw);
|
|
90
|
+
const name = String(parsed.identity ?? "").trim();
|
|
91
|
+
if (!name) return null;
|
|
92
|
+
const pin = { identity: name };
|
|
93
|
+
if (typeof parsed.force === "boolean") pin.force = parsed.force;
|
|
94
|
+
if (typeof parsed.expose_local === "boolean") pin.expose_local = parsed.expose_local;
|
|
95
|
+
if (typeof parsed.local_auto_accept === "boolean") pin.local_auto_accept = parsed.local_auto_accept;
|
|
96
|
+
return pin;
|
|
91
97
|
} catch {
|
|
92
98
|
return null;
|
|
93
99
|
}
|
|
@@ -117,9 +123,20 @@ function anyIdentityBound() {
|
|
|
117
123
|
return err.code === "EPERM";
|
|
118
124
|
}
|
|
119
125
|
}
|
|
120
|
-
function renderIdentityDirective(
|
|
121
|
-
const
|
|
122
|
-
|
|
126
|
+
function renderIdentityDirective(pin, exists) {
|
|
127
|
+
const name = pin.identity;
|
|
128
|
+
let bind;
|
|
129
|
+
if (exists) {
|
|
130
|
+
bind = pin.force ? `call \`choose_identity({ name: "${name}", force: true })\` to bind it to this session (the workspace pin sets force, so evicting another holder is pre-authorized \u2014 no need to ask)` : `call \`choose_identity({ name: "${name}" })\` to bind it to this session`;
|
|
131
|
+
} else {
|
|
132
|
+
const extras = [];
|
|
133
|
+
if (pin.expose_local !== void 0) extras.push(`expose_local: ${pin.expose_local}`);
|
|
134
|
+
if (pin.local_auto_accept !== void 0) extras.push(`local_auto_accept: ${pin.local_auto_accept}`);
|
|
135
|
+
const args = [`name: "${name}"`, ...extras].join(", ");
|
|
136
|
+
bind = `it does not exist yet \u2014 call \`create_identity({ ${args} })\` to create and bind it`;
|
|
137
|
+
}
|
|
138
|
+
const forceTail = pin.force ? "" : ` If choose_identity reports the identity is held by another session, do NOT retry with force \u2014 tell the user it is bound elsewhere and ask whether to forcibly rebind it to this session; only pass force=true after they confirm.`;
|
|
139
|
+
return `a2adapt \u2014 this workspace is pinned to identity "${name}" (via ${IDENTITY_FILE}). Before other a2adapt work, ${bind}. Then arm a Monitor on the wake source \`a2adapt-mcp watch ${name}\` so new mail wakes you. Do this once, up front. The pin is the workspace default: if the user explicitly asks to use a different identity, bind that instead \u2014 the user's choice always wins.` + forceTail;
|
|
123
140
|
}
|
|
124
141
|
function sessionStart() {
|
|
125
142
|
const raw = readStdin();
|
|
@@ -137,7 +154,7 @@ function sessionStart() {
|
|
|
137
154
|
const pinned = findPinnedIdentity(cwd);
|
|
138
155
|
const unread = collectUnread();
|
|
139
156
|
const blocks = [];
|
|
140
|
-
if (pinned) blocks.push(renderIdentityDirective(pinned, identityExists(pinned)));
|
|
157
|
+
if (pinned) blocks.push(renderIdentityDirective(pinned, identityExists(pinned.identity)));
|
|
141
158
|
if (unread.length > 0) blocks.push(renderContext(unread));
|
|
142
159
|
if (blocks.length === 0) return noop();
|
|
143
160
|
emit({
|
|
@@ -164,7 +181,7 @@ function userPromptSubmit() {
|
|
|
164
181
|
continue: true,
|
|
165
182
|
hookSpecificOutput: {
|
|
166
183
|
hookEventName: "UserPromptSubmit",
|
|
167
|
-
additionalContext: renderIdentityDirective(pinned, identityExists(pinned))
|
|
184
|
+
additionalContext: renderIdentityDirective(pinned, identityExists(pinned.identity))
|
|
168
185
|
}
|
|
169
186
|
});
|
|
170
187
|
}
|
package/dist/index.js
CHANGED
|
@@ -22427,7 +22427,7 @@ var StreamableHTTPServerTransport = class {
|
|
|
22427
22427
|
};
|
|
22428
22428
|
|
|
22429
22429
|
// src/index.ts
|
|
22430
|
-
import { resolve as resolve2, join as join2, dirname as dirname2 } from "node:path";
|
|
22430
|
+
import { resolve as resolve2, join as join2, dirname as dirname2, isAbsolute } from "node:path";
|
|
22431
22431
|
import { fileURLToPath } from "node:url";
|
|
22432
22432
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
22433
22433
|
import { createServer as createHttpServer } from "node:http";
|
|
@@ -22440,7 +22440,7 @@ import { object_to_adapt_value } from "@adapt-toolkit/sdk/wrapper";
|
|
|
22440
22440
|
// src/config.ts
|
|
22441
22441
|
import * as fs from "node:fs";
|
|
22442
22442
|
import { homedir } from "node:os";
|
|
22443
|
-
import { resolve, join, dirname } from "node:path";
|
|
22443
|
+
import { resolve, join, dirname, basename } from "node:path";
|
|
22444
22444
|
var DEFAULT_CONFIG = {
|
|
22445
22445
|
brokerUrl: "ws://a2adapt.adaptframework.solutions/broker",
|
|
22446
22446
|
port: 3030,
|
|
@@ -22487,9 +22487,32 @@ function loadConfig() {
|
|
|
22487
22487
|
gcIntervalMs: envInt("A2ADAPT_GC_INTERVAL_MS") ?? file.gcIntervalMs ?? DEFAULT_CONFIG.gcIntervalMs
|
|
22488
22488
|
};
|
|
22489
22489
|
}
|
|
22490
|
+
var IDENTITY_FILENAME = ".a2adapt-identity";
|
|
22491
|
+
function buildIdentityFile(opts) {
|
|
22492
|
+
if (!opts.name.trim()) throw new Error("identity name must not be empty");
|
|
22493
|
+
const obj = { identity: opts.name.trim() };
|
|
22494
|
+
if (opts.force) obj.force = true;
|
|
22495
|
+
obj.expose_local = opts.exposeLocal ?? true;
|
|
22496
|
+
obj.local_auto_accept = opts.localAutoAccept ?? true;
|
|
22497
|
+
return obj;
|
|
22498
|
+
}
|
|
22499
|
+
function resolveIdentityFilePath(target) {
|
|
22500
|
+
const abs = resolve(target);
|
|
22501
|
+
return basename(abs) === IDENTITY_FILENAME ? abs : join(abs, IDENTITY_FILENAME);
|
|
22502
|
+
}
|
|
22503
|
+
function writeIdentityFile(target, opts, overwrite = false) {
|
|
22504
|
+
const obj = buildIdentityFile(opts);
|
|
22505
|
+
const path = resolveIdentityFilePath(target);
|
|
22506
|
+
if (!overwrite && fs.existsSync(path)) {
|
|
22507
|
+
throw new Error(`${path} already exists \u2014 pass overwrite to replace it`);
|
|
22508
|
+
}
|
|
22509
|
+
fs.mkdirSync(dirname(path), { recursive: true });
|
|
22510
|
+
fs.writeFileSync(path, JSON.stringify(obj, null, 2) + "\n");
|
|
22511
|
+
return path;
|
|
22512
|
+
}
|
|
22490
22513
|
|
|
22491
22514
|
// src/index.ts
|
|
22492
|
-
var VERSION = true ? "0.
|
|
22515
|
+
var VERSION = true ? "0.9.0" : "0.0.0-dev";
|
|
22493
22516
|
var CONFIG = loadConfig();
|
|
22494
22517
|
var STATE_DIR = CONFIG.stateDir;
|
|
22495
22518
|
var BROKER_URL = CONFIG.brokerUrl;
|
|
@@ -22499,6 +22522,7 @@ var GC_INTERVAL_MS = CONFIG.gcIntervalMs;
|
|
|
22499
22522
|
var log = (...parts) => process.stderr.write(`a2adapt: ${parts.join(" ")}
|
|
22500
22523
|
`);
|
|
22501
22524
|
var NAME_RE = /^[A-Za-z0-9 _.-]{1,64}$/;
|
|
22525
|
+
var BOOK_DIR_NAME = "contact-book";
|
|
22502
22526
|
function validateName(name) {
|
|
22503
22527
|
if (!NAME_RE.test(name)) {
|
|
22504
22528
|
return "name must be 1-64 chars of letters, digits, space, _ . or -";
|
|
@@ -22506,6 +22530,9 @@ function validateName(name) {
|
|
|
22506
22530
|
if (name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
22507
22531
|
return "invalid name";
|
|
22508
22532
|
}
|
|
22533
|
+
if (name === BOOK_DIR_NAME) {
|
|
22534
|
+
return `"${BOOK_DIR_NAME}" is reserved for the local contact book`;
|
|
22535
|
+
}
|
|
22509
22536
|
return null;
|
|
22510
22537
|
}
|
|
22511
22538
|
function locateUnit() {
|
|
@@ -22528,6 +22555,8 @@ function locateUnit() {
|
|
|
22528
22555
|
var UNIT;
|
|
22529
22556
|
var wrapper;
|
|
22530
22557
|
var identities = /* @__PURE__ */ new Map();
|
|
22558
|
+
var registrar = null;
|
|
22559
|
+
var registrarAdBlob = null;
|
|
22531
22560
|
var sessionBinding = /* @__PURE__ */ new Map();
|
|
22532
22561
|
var bindingOwner = /* @__PURE__ */ new Map();
|
|
22533
22562
|
var evictedSessions = /* @__PURE__ */ new Set();
|
|
@@ -22551,6 +22580,102 @@ function listPersistedNames() {
|
|
|
22551
22580
|
if (!fs2.existsSync(STATE_DIR)) return [];
|
|
22552
22581
|
return fs2.readdirSync(STATE_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && fs2.existsSync(seedPath(join2(STATE_DIR, d.name)))).map((d) => d.name);
|
|
22553
22582
|
}
|
|
22583
|
+
var bookDir = () => join2(STATE_DIR, BOOK_DIR_NAME);
|
|
22584
|
+
var registrarSeedPath = () => join2(bookDir(), "registrar.seed");
|
|
22585
|
+
var bookPath = () => join2(bookDir(), "book.json");
|
|
22586
|
+
function readBook() {
|
|
22587
|
+
try {
|
|
22588
|
+
const parsed = JSON.parse(fs2.readFileSync(bookPath(), "utf8"));
|
|
22589
|
+
return parsed && typeof parsed.entries === "object" ? parsed.entries : {};
|
|
22590
|
+
} catch {
|
|
22591
|
+
return {};
|
|
22592
|
+
}
|
|
22593
|
+
}
|
|
22594
|
+
function writeBook(entries) {
|
|
22595
|
+
fs2.mkdirSync(bookDir(), { recursive: true });
|
|
22596
|
+
const tmp = `${bookPath()}.tmp`;
|
|
22597
|
+
fs2.writeFileSync(tmp, JSON.stringify({ v: 1, entries }, null, 2), { mode: 384 });
|
|
22598
|
+
fs2.renameSync(tmp, bookPath());
|
|
22599
|
+
}
|
|
22600
|
+
function exportAdBlob(id) {
|
|
22601
|
+
return Buffer.from(readonlyTx(id, "::actor::export_address_document").GetBinary());
|
|
22602
|
+
}
|
|
22603
|
+
async function publishToBook(id) {
|
|
22604
|
+
if (!registrar) throw new Error("registrar is not available");
|
|
22605
|
+
const adBlob = exportAdBlob(id);
|
|
22606
|
+
const sigData = await mutatingTx(registrar, "::actor::sign_book_entry", {
|
|
22607
|
+
name: id.name,
|
|
22608
|
+
ad: registrar.pw.packet.NewBinaryFromBuffer(adBlob)
|
|
22609
|
+
});
|
|
22610
|
+
const entries = readBook();
|
|
22611
|
+
entries[id.name] = {
|
|
22612
|
+
v: 1,
|
|
22613
|
+
name: id.name,
|
|
22614
|
+
container_id: id.cid,
|
|
22615
|
+
address_document: adBlob.toString("base64url"),
|
|
22616
|
+
published_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
22617
|
+
registrar_sig: Buffer.from(sigData.Reduce("sig").GetBinary()).toString("base64url")
|
|
22618
|
+
};
|
|
22619
|
+
writeBook(entries);
|
|
22620
|
+
log(`[${id.name}] published to the local contact book`);
|
|
22621
|
+
}
|
|
22622
|
+
function unpublishFromBook(name) {
|
|
22623
|
+
const entries = readBook();
|
|
22624
|
+
if (!(name in entries)) return;
|
|
22625
|
+
delete entries[name];
|
|
22626
|
+
writeBook(entries);
|
|
22627
|
+
log(`[${name}] removed from the local contact book`);
|
|
22628
|
+
}
|
|
22629
|
+
async function pinRegistrar(id) {
|
|
22630
|
+
if (!registrarAdBlob) throw new Error("registrar is not available");
|
|
22631
|
+
await mutatingTx(id, "::actor::pin_registrar", {
|
|
22632
|
+
registrar_ad: id.pw.packet.NewBinaryFromBuffer(registrarAdBlob)
|
|
22633
|
+
});
|
|
22634
|
+
}
|
|
22635
|
+
async function sendViaLocalBook(id, contact, text) {
|
|
22636
|
+
if (!registrar) {
|
|
22637
|
+
throw new Error(`"${contact}" is not a contact, and the local contact book is unavailable.`);
|
|
22638
|
+
}
|
|
22639
|
+
const entries = readBook();
|
|
22640
|
+
const entry = entries[contact] ?? Object.values(entries).find((e) => e.container_id === contact);
|
|
22641
|
+
if (!entry) {
|
|
22642
|
+
throw new Error(
|
|
22643
|
+
`"${contact}" is not a contact and has no local contact-book entry. Use generate_invite/add_contact for remote peers, or list_local_contact_book to see local ones.`
|
|
22644
|
+
);
|
|
22645
|
+
}
|
|
22646
|
+
if (entry.container_id === id.cid) {
|
|
22647
|
+
throw new Error("that contact-book entry is this identity itself.");
|
|
22648
|
+
}
|
|
22649
|
+
const targetAd = Buffer.from(entry.address_document, "base64url");
|
|
22650
|
+
const entrySig = Buffer.from(entry.registrar_sig, "base64url");
|
|
22651
|
+
const joinerAd = exportAdBlob(id);
|
|
22652
|
+
const minted = await mutatingTx(registrar, "::actor::mint_introduction", {
|
|
22653
|
+
joiner_ad: registrar.pw.packet.NewBinaryFromBuffer(joinerAd),
|
|
22654
|
+
target_ad: registrar.pw.packet.NewBinaryFromBuffer(targetAd)
|
|
22655
|
+
});
|
|
22656
|
+
const introBlob = Buffer.from(minted.Reduce("intro").GetBinary());
|
|
22657
|
+
await mutatingTx(id, "::actor::connect_local", {
|
|
22658
|
+
name: entry.name,
|
|
22659
|
+
target_ad: id.pw.packet.NewBinaryFromBuffer(targetAd),
|
|
22660
|
+
intro: id.pw.packet.NewBinaryFromBuffer(introBlob),
|
|
22661
|
+
entry_sig: id.pw.packet.NewBinaryFromBuffer(entrySig),
|
|
22662
|
+
text
|
|
22663
|
+
});
|
|
22664
|
+
return `"${entry.name}" was not a contact yet \u2014 connected via the local contact book and sent the message with the introduction. If "${entry.name}" requires approval for local introductions, delivery completes once they approve.`;
|
|
22665
|
+
}
|
|
22666
|
+
async function ensureRegistrar() {
|
|
22667
|
+
fs2.mkdirSync(bookDir(), { recursive: true });
|
|
22668
|
+
let seed;
|
|
22669
|
+
try {
|
|
22670
|
+
seed = fs2.readFileSync(registrarSeedPath(), "utf8").trim();
|
|
22671
|
+
} catch {
|
|
22672
|
+
seed = randomBytes(24).toString("hex");
|
|
22673
|
+
fs2.writeFileSync(registrarSeedPath(), seed, { mode: 384 });
|
|
22674
|
+
}
|
|
22675
|
+
registrar = await createPacket(BOOK_DIR_NAME, seed, bookDir(), false);
|
|
22676
|
+
registrarAdBlob = exportAdBlob(registrar);
|
|
22677
|
+
log(`contact-book registrar ready (${registrar.cid})`);
|
|
22678
|
+
}
|
|
22554
22679
|
function hasSavedState(dir) {
|
|
22555
22680
|
try {
|
|
22556
22681
|
return fs2.existsSync(dataPath(dir)) && fs2.statSync(dataPath(dir)).size > 0;
|
|
@@ -22572,11 +22697,10 @@ function saveState(id) {
|
|
|
22572
22697
|
log(`[${id.name}] failed to save state:`, String(err));
|
|
22573
22698
|
}
|
|
22574
22699
|
}
|
|
22575
|
-
function appendNotifyLog(id,
|
|
22700
|
+
function appendNotifyLog(id, event) {
|
|
22576
22701
|
try {
|
|
22577
22702
|
fs2.mkdirSync(id.dir, { recursive: true });
|
|
22578
|
-
|
|
22579
|
-
fs2.appendFileSync(notifyLogPath(id.dir), line);
|
|
22703
|
+
fs2.appendFileSync(notifyLogPath(id.dir), JSON.stringify(event) + "\n");
|
|
22580
22704
|
} catch (err) {
|
|
22581
22705
|
log(`[${id.name}] failed to append notifications.log:`, String(err));
|
|
22582
22706
|
}
|
|
@@ -22660,7 +22784,7 @@ function wireHandlers(id) {
|
|
|
22660
22784
|
const sender = payload.Reduce("sender_name").Visualize();
|
|
22661
22785
|
const msgId = payload.Reduce("msg_id").Visualize();
|
|
22662
22786
|
const date3 = payload.Reduce("date").Visualize();
|
|
22663
|
-
appendNotifyLog(id, sender, msgId, date3);
|
|
22787
|
+
appendNotifyLog(id, { event: "message_received", from: sender, msg_id: msgId, date: date3 });
|
|
22664
22788
|
refreshUnread(id);
|
|
22665
22789
|
process.nextTick(
|
|
22666
22790
|
() => pushNotification(id.name, `[${id.name}] new message from ${sender} (#${msgId})`)
|
|
@@ -22671,6 +22795,29 @@ function wireHandlers(id) {
|
|
|
22671
22795
|
process.nextTick(
|
|
22672
22796
|
() => pushNotification(id.name, `[${id.name}] contact "${name}" (${cid}) accepted your invite.`)
|
|
22673
22797
|
);
|
|
22798
|
+
} else if (event === "local_contact_added") {
|
|
22799
|
+
const name = payload.Reduce("name").Visualize();
|
|
22800
|
+
const cid = payload.Reduce("container_id").Visualize();
|
|
22801
|
+
process.nextTick(
|
|
22802
|
+
() => pushNotification(id.name, `[${id.name}] local contact "${name}" (${cid}) connected via the contact book.`)
|
|
22803
|
+
);
|
|
22804
|
+
} else if (event === "local_contact_request") {
|
|
22805
|
+
const name = payload.Reduce("name").Visualize();
|
|
22806
|
+
const cid = payload.Reduce("container_id").Visualize();
|
|
22807
|
+
appendNotifyLog(id, { event: "local_contact_request", from: name });
|
|
22808
|
+
process.nextTick(
|
|
22809
|
+
() => pushNotification(
|
|
22810
|
+
id.name,
|
|
22811
|
+
`[${id.name}] pending local introduction from "${name}" (${cid}) \u2014 approve or reject with respond_to_introduction.`
|
|
22812
|
+
)
|
|
22813
|
+
);
|
|
22814
|
+
} else if (event === "pending_message") {
|
|
22815
|
+
const name = payload.Reduce("sender_name").Visualize();
|
|
22816
|
+
const queued = payload.Reduce("queued").Visualize();
|
|
22817
|
+
appendNotifyLog(id, { event: "pending_message", from: name, queued });
|
|
22818
|
+
process.nextTick(
|
|
22819
|
+
() => pushNotification(id.name, `[${id.name}] "${name}" queued a message awaiting introduction approval (${queued} queued).`)
|
|
22820
|
+
);
|
|
22674
22821
|
}
|
|
22675
22822
|
return;
|
|
22676
22823
|
}
|
|
@@ -22689,7 +22836,7 @@ function wireHandlers(id) {
|
|
|
22689
22836
|
}
|
|
22690
22837
|
};
|
|
22691
22838
|
}
|
|
22692
|
-
function createPacket(name, seed, dir) {
|
|
22839
|
+
function createPacket(name, seed, dir, track = true) {
|
|
22693
22840
|
const config2 = new PacketWrapperConfigurator();
|
|
22694
22841
|
config2.process_arguments([
|
|
22695
22842
|
"--unit_hash",
|
|
@@ -22718,7 +22865,7 @@ function createPacket(name, seed, dir) {
|
|
|
22718
22865
|
lock: Promise.resolve()
|
|
22719
22866
|
};
|
|
22720
22867
|
wireHandlers(id);
|
|
22721
|
-
identities.set(name, id);
|
|
22868
|
+
if (track) identities.set(name, id);
|
|
22722
22869
|
log(`[${name}] packet created \u2014 container id ${id.cid}`);
|
|
22723
22870
|
resolveCreate(id);
|
|
22724
22871
|
},
|
|
@@ -22726,13 +22873,20 @@ function createPacket(name, seed, dir) {
|
|
|
22726
22873
|
);
|
|
22727
22874
|
});
|
|
22728
22875
|
}
|
|
22729
|
-
async function provisionIdentity(name) {
|
|
22876
|
+
async function provisionIdentity(name, opts = { exposeLocal: true, localAutoAccept: true }) {
|
|
22730
22877
|
const dir = identityDir(name);
|
|
22731
22878
|
fs2.mkdirSync(dir, { recursive: true });
|
|
22732
22879
|
const seed = randomBytes(24).toString("hex");
|
|
22733
22880
|
fs2.writeFileSync(seedPath(dir), seed, { mode: 384 });
|
|
22734
22881
|
const id = await createPacket(name, seed, dir);
|
|
22735
22882
|
await mutatingTx(id, "::actor::set_my_name", { name });
|
|
22883
|
+
await pinRegistrar(id);
|
|
22884
|
+
if (!opts.localAutoAccept) {
|
|
22885
|
+
await mutatingTx(id, "::actor::set_local_policy", { auto_accept: false });
|
|
22886
|
+
}
|
|
22887
|
+
if (opts.exposeLocal) {
|
|
22888
|
+
await publishToBook(id);
|
|
22889
|
+
}
|
|
22736
22890
|
saveState(id);
|
|
22737
22891
|
return id;
|
|
22738
22892
|
}
|
|
@@ -22769,6 +22923,11 @@ async function bootWrapper() {
|
|
|
22769
22923
|
wrapper = await adapt_wrapper.start(argv);
|
|
22770
22924
|
wrapper.on_packet_created_cb = (cid) => log(`wrapper: packet ready ${cid.slice(0, 12)}\u2026`);
|
|
22771
22925
|
wrapper.start();
|
|
22926
|
+
try {
|
|
22927
|
+
await ensureRegistrar();
|
|
22928
|
+
} catch (err) {
|
|
22929
|
+
log("failed to start the contact-book registrar (local contact book disabled):", String(err));
|
|
22930
|
+
}
|
|
22772
22931
|
const names = listPersistedNames();
|
|
22773
22932
|
if (names.length === 0) {
|
|
22774
22933
|
log("no persisted identities \u2014 start with create_identity");
|
|
@@ -22776,7 +22935,10 @@ async function bootWrapper() {
|
|
|
22776
22935
|
log(`restoring ${names.length} identit${names.length === 1 ? "y" : "ies"}: ${names.join(", ")}`);
|
|
22777
22936
|
for (const name of names) {
|
|
22778
22937
|
try {
|
|
22779
|
-
await restoreIdentity(name);
|
|
22938
|
+
const id = await restoreIdentity(name);
|
|
22939
|
+
if (registrar) {
|
|
22940
|
+
await pinRegistrar(id);
|
|
22941
|
+
}
|
|
22780
22942
|
} catch (err) {
|
|
22781
22943
|
log(`failed to restore "${name}":`, String(err));
|
|
22782
22944
|
}
|
|
@@ -22841,6 +23003,20 @@ function renderInbox(v) {
|
|
|
22841
23003
|
}
|
|
22842
23004
|
return out;
|
|
22843
23005
|
}
|
|
23006
|
+
function renderPending(v) {
|
|
23007
|
+
const out = [];
|
|
23008
|
+
if (v.IsNil()) return out;
|
|
23009
|
+
for (const key of v.GetKeys()) {
|
|
23010
|
+
const p = v.Reduce(key);
|
|
23011
|
+
if (p.IsNil()) continue;
|
|
23012
|
+
out.push({
|
|
23013
|
+
container_id: typeof key === "string" ? key : key.Visualize(),
|
|
23014
|
+
name: p.Reduce("name").Visualize(),
|
|
23015
|
+
queued: parseInt(p.Reduce("queued").Visualize(), 10) || 0
|
|
23016
|
+
});
|
|
23017
|
+
}
|
|
23018
|
+
return out;
|
|
23019
|
+
}
|
|
22844
23020
|
function textResult(text, isError = false) {
|
|
22845
23021
|
return { content: [{ type: "text", text }], isError };
|
|
22846
23022
|
}
|
|
@@ -22876,21 +23052,52 @@ function createMcpServer(getSessionId) {
|
|
|
22876
23052
|
};
|
|
22877
23053
|
server.tool(
|
|
22878
23054
|
"create_identity",
|
|
22879
|
-
"Create a new self-sovereign identity (an ADAPT node) with the given display name and bind it to this session. The name is what peers see for you in invites. Persisted permanently; reject if the name already exists.",
|
|
22880
|
-
{
|
|
22881
|
-
|
|
23055
|
+
"Create a new self-sovereign identity (an ADAPT node) with the given display name and bind it to this session. The name is what peers see for you in invites. Persisted permanently; reject if the name already exists. By default the identity is published to the LOCAL contact book, so other identities on this host can message it by name without an invite; pass expose_local=false to opt out.",
|
|
23056
|
+
{
|
|
23057
|
+
name: external_exports.string().min(1).describe('Display name for the new identity, e.g. "Alice".'),
|
|
23058
|
+
expose_local: external_exports.boolean().default(true).describe("Publish this identity in the host-local contact book."),
|
|
23059
|
+
local_auto_accept: external_exports.boolean().default(true).describe("Auto-accept local contact-book introductions (false = they queue for approval).")
|
|
23060
|
+
},
|
|
23061
|
+
async ({ name, expose_local, local_auto_accept }) => {
|
|
22882
23062
|
const bad = validateName(name);
|
|
22883
23063
|
if (bad) return textResult(`create_identity failed: ${bad}`, true);
|
|
22884
23064
|
if (identities.has(name)) return textResult(`create_identity failed: an identity named "${name}" already exists.`, true);
|
|
22885
23065
|
try {
|
|
22886
|
-
const id = await provisionIdentity(name);
|
|
23066
|
+
const id = await provisionIdentity(name, { exposeLocal: expose_local, localAutoAccept: local_auto_accept });
|
|
22887
23067
|
bindSession(getSessionId(), name);
|
|
22888
|
-
|
|
23068
|
+
const exposure = expose_local ? ` Published to the local contact book${local_auto_accept ? "" : " (introductions require approval)"}.` : " Not exposed in the local contact book.";
|
|
23069
|
+
return textResult(`Created identity "${name}" (${id.cid}) and bound it to this session.${exposure}`);
|
|
22889
23070
|
} catch (err) {
|
|
22890
23071
|
return textResult(`create_identity failed: ${String(err)}`, true);
|
|
22891
23072
|
}
|
|
22892
23073
|
}
|
|
22893
23074
|
);
|
|
23075
|
+
server.tool(
|
|
23076
|
+
"define_local_identity_file",
|
|
23077
|
+
"Write a `.a2adapt-identity` workspace-pin file that ties a directory to an identity, so a future Claude Code session here auto-binds it (and the SessionStart hook arms the right Monitor). Use this instead of hand-writing the file. Because this daemon is shared and its CWD is not the user's project, you MUST pass an absolute `path` (the target directory, or the full path ending in .a2adapt-identity). Refuses to overwrite unless overwrite=true.",
|
|
23078
|
+
{
|
|
23079
|
+
name: external_exports.string().min(1).describe("Identity name the workspace belongs to."),
|
|
23080
|
+
path: external_exports.string().min(1).describe("Absolute target: a directory (file is created inside it) or a full path ending in .a2adapt-identity."),
|
|
23081
|
+
force: external_exports.boolean().default(false).describe("Pin pre-authorizes force-binding (evicting another session) \u2014 no user prompt at bind time."),
|
|
23082
|
+
expose_local: external_exports.boolean().default(true).describe("Publish this identity in the host-local contact book."),
|
|
23083
|
+
local_auto_accept: external_exports.boolean().default(true).describe("Auto-accept local contact-book introductions (false = they queue for approval)."),
|
|
23084
|
+
overwrite: external_exports.boolean().default(false).describe("Replace an existing .a2adapt-identity file.")
|
|
23085
|
+
},
|
|
23086
|
+
async ({ name, path, force, expose_local, local_auto_accept, overwrite }) => {
|
|
23087
|
+
if (!isAbsolute(path)) {
|
|
23088
|
+
return textResult(`define_local_identity_file failed: path must be absolute (got "${path}").`, true);
|
|
23089
|
+
}
|
|
23090
|
+
const opts = { name, force, exposeLocal: expose_local, localAutoAccept: local_auto_accept };
|
|
23091
|
+
try {
|
|
23092
|
+
const written = writeIdentityFile(path, opts, overwrite);
|
|
23093
|
+
const json = JSON.stringify(buildIdentityFile(opts), null, 2);
|
|
23094
|
+
return textResult(`Wrote ${written}:
|
|
23095
|
+
${json}`);
|
|
23096
|
+
} catch (err) {
|
|
23097
|
+
return textResult(`define_local_identity_file failed: ${String(err)}`, true);
|
|
23098
|
+
}
|
|
23099
|
+
}
|
|
23100
|
+
);
|
|
22894
23101
|
server.tool(
|
|
22895
23102
|
"choose_identity",
|
|
22896
23103
|
"Bind an existing identity to this session so the messaging tools act as it. Binding is exclusive: if the identity is already in use by another session, this is declined unless force=true, which evicts the other session. Never pass force=true on your own initiative \u2014 ask the user and get an explicit confirmation first.",
|
|
@@ -22960,6 +23167,11 @@ ${lines.join("\n")}`);
|
|
|
22960
23167
|
log(`remove_packet(${id.cid}) failed:`, String(err));
|
|
22961
23168
|
}
|
|
22962
23169
|
identities.delete(name);
|
|
23170
|
+
try {
|
|
23171
|
+
unpublishFromBook(name);
|
|
23172
|
+
} catch (err) {
|
|
23173
|
+
log(`failed to unpublish "${name}" from the contact book:`, String(err));
|
|
23174
|
+
}
|
|
22963
23175
|
const holder = bindingOwner.get(name);
|
|
22964
23176
|
if (holder) {
|
|
22965
23177
|
bindingOwner.delete(name);
|
|
@@ -23040,24 +23252,113 @@ ${blob}`
|
|
|
23040
23252
|
);
|
|
23041
23253
|
server.tool(
|
|
23042
23254
|
"list_contacts",
|
|
23043
|
-
"List the contacts the bound identity knows about (name + container id).",
|
|
23255
|
+
"List the contacts the bound identity knows about (name + container id), plus any pending local-contact-book introductions awaiting approval.",
|
|
23044
23256
|
{},
|
|
23045
23257
|
async () => {
|
|
23046
23258
|
const { id, err } = boundOr();
|
|
23047
23259
|
if (err) return err;
|
|
23048
23260
|
try {
|
|
23049
23261
|
const contacts = renderContacts(readonlyTx(id, "::actor::list_contacts"));
|
|
23050
|
-
|
|
23051
|
-
|
|
23052
|
-
|
|
23262
|
+
const pending = renderPending(readonlyTx(id, "::actor::list_pending_introductions"));
|
|
23263
|
+
const lines = [];
|
|
23264
|
+
lines.push(
|
|
23265
|
+
contacts.length === 0 ? "No contacts yet." : `Contacts (${contacts.length}):
|
|
23266
|
+
${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`
|
|
23267
|
+
);
|
|
23268
|
+
if (pending.length > 0) {
|
|
23269
|
+
lines.push(
|
|
23270
|
+
`Pending local introductions (${pending.length}) \u2014 approve/reject with respond_to_introduction:
|
|
23271
|
+
` + pending.map((p) => `\u2022 ${p.name} \u2014 ${p.container_id} (${p.queued} queued message${p.queued === 1 ? "" : "s"})`).join("\n")
|
|
23272
|
+
);
|
|
23273
|
+
}
|
|
23274
|
+
return textResult(lines.join("\n\n"));
|
|
23053
23275
|
} catch (e) {
|
|
23054
23276
|
return textResult(`list_contacts failed: ${String(e)}`, true);
|
|
23055
23277
|
}
|
|
23056
23278
|
}
|
|
23057
23279
|
);
|
|
23280
|
+
server.tool(
|
|
23281
|
+
"list_local_contact_book",
|
|
23282
|
+
"List the host-local contact book: identities on THIS host that are exposed for inviteless connection. Any of them can be messaged directly with send_message.",
|
|
23283
|
+
{},
|
|
23284
|
+
async () => {
|
|
23285
|
+
const entries = Object.values(readBook());
|
|
23286
|
+
if (entries.length === 0) return textResult("The local contact book is empty.");
|
|
23287
|
+
const sid = getSessionId();
|
|
23288
|
+
const mine = sessionBinding.get(sid);
|
|
23289
|
+
const lines = entries.map((e) => {
|
|
23290
|
+
const tag = e.name === mine ? " \u2190 this session" : "";
|
|
23291
|
+
return `\u2022 ${e.name} \u2014 ${e.container_id} (published ${e.published_at})${tag}`;
|
|
23292
|
+
});
|
|
23293
|
+
return textResult(`Local contact book (${entries.length}):
|
|
23294
|
+
${lines.join("\n")}`);
|
|
23295
|
+
}
|
|
23296
|
+
);
|
|
23297
|
+
server.tool(
|
|
23298
|
+
"set_local_book_policy",
|
|
23299
|
+
"Change the bound identity's local-contact-book settings: expose (publish/unpublish it in the book) and/or auto_accept (whether local introductions are accepted automatically or queue for approval).",
|
|
23300
|
+
{
|
|
23301
|
+
expose: external_exports.boolean().optional().describe("Publish (true) or remove (false) this identity in the local contact book."),
|
|
23302
|
+
auto_accept: external_exports.boolean().optional().describe("Auto-accept local introductions (false = queue them for approval).")
|
|
23303
|
+
},
|
|
23304
|
+
async ({ expose, auto_accept }) => {
|
|
23305
|
+
const { id, err } = boundOr();
|
|
23306
|
+
if (err) return err;
|
|
23307
|
+
if (expose === void 0 && auto_accept === void 0) {
|
|
23308
|
+
return textResult("set_local_book_policy: pass expose and/or auto_accept.", true);
|
|
23309
|
+
}
|
|
23310
|
+
const done = [];
|
|
23311
|
+
try {
|
|
23312
|
+
if (auto_accept !== void 0) {
|
|
23313
|
+
await mutatingTx(id, "::actor::set_local_policy", { auto_accept });
|
|
23314
|
+
done.push(`auto_accept=${auto_accept}`);
|
|
23315
|
+
}
|
|
23316
|
+
if (expose === true) {
|
|
23317
|
+
await publishToBook(id);
|
|
23318
|
+
done.push("published in the local contact book");
|
|
23319
|
+
} else if (expose === false) {
|
|
23320
|
+
unpublishFromBook(id.name);
|
|
23321
|
+
done.push("removed from the local contact book");
|
|
23322
|
+
}
|
|
23323
|
+
return textResult(`Updated "${id.name}": ${done.join("; ")}.`);
|
|
23324
|
+
} catch (e) {
|
|
23325
|
+
return textResult(`set_local_book_policy failed: ${String(e)}`, true);
|
|
23326
|
+
}
|
|
23327
|
+
}
|
|
23328
|
+
);
|
|
23329
|
+
server.tool(
|
|
23330
|
+
"respond_to_introduction",
|
|
23331
|
+
"Approve or reject a pending local-contact-book introduction (see list_contacts for the pending list). Approving registers the contact and delivers any messages it queued while waiting; rejecting drops the introduction and its queue.",
|
|
23332
|
+
{
|
|
23333
|
+
contact: external_exports.string().min(1).describe("Pending introduction to act on (name or container id)."),
|
|
23334
|
+
action: external_exports.enum(["approve", "reject"]).describe("approve or reject.")
|
|
23335
|
+
},
|
|
23336
|
+
async ({ contact, action }) => {
|
|
23337
|
+
const { id, err } = boundOr();
|
|
23338
|
+
if (err) return err;
|
|
23339
|
+
try {
|
|
23340
|
+
if (action === "approve") {
|
|
23341
|
+
const data2 = await mutatingTx(id, "::actor::approve_introduction", { contact });
|
|
23342
|
+
const name2 = data2.Reduce("approved").Visualize();
|
|
23343
|
+
const cid = data2.Reduce("container_id").Visualize();
|
|
23344
|
+
const flushed = data2.Reduce("flushed").Visualize();
|
|
23345
|
+
refreshUnread(id);
|
|
23346
|
+
return textResult(
|
|
23347
|
+
`Approved "${name2}" (${cid}) \u2014 now a contact. ${flushed} queued message(s) moved to the inbox (read them with get_messages).`
|
|
23348
|
+
);
|
|
23349
|
+
}
|
|
23350
|
+
const data = await mutatingTx(id, "::actor::reject_introduction", { contact });
|
|
23351
|
+
const name = data.Reduce("rejected").Visualize();
|
|
23352
|
+
const dropped = data.Reduce("dropped_messages").Visualize();
|
|
23353
|
+
return textResult(`Rejected the introduction from "${name}" and dropped ${dropped} queued message(s).`);
|
|
23354
|
+
} catch (e) {
|
|
23355
|
+
return textResult(`respond_to_introduction failed: ${String(e)}`, true);
|
|
23356
|
+
}
|
|
23357
|
+
}
|
|
23358
|
+
);
|
|
23058
23359
|
server.tool(
|
|
23059
23360
|
"send_message",
|
|
23060
|
-
"Send an end-to-end-encrypted message to a known contact (by name or container id). Requires a bound identity.",
|
|
23361
|
+
"Send an end-to-end-encrypted message to a known contact (by name or container id). If the recipient is not a contact yet but is published in the host-local contact book, the connection is established automatically (no invite needed) and the message is delivered with the introduction. Requires a bound identity.",
|
|
23061
23362
|
{
|
|
23062
23363
|
contact: external_exports.string().min(1).describe("Contact name or container id to send to."),
|
|
23063
23364
|
text: external_exports.string().min(1).describe("The message text.")
|
|
@@ -23069,13 +23370,21 @@ ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`)
|
|
|
23069
23370
|
await mutatingTx(id, "::actor::send_message", { contact, text });
|
|
23070
23371
|
return textResult(`Message sent to "${contact}".`);
|
|
23071
23372
|
} catch (e) {
|
|
23072
|
-
|
|
23373
|
+
if (!/Unknown contact/.test(String(e))) {
|
|
23374
|
+
return textResult(`send_message failed: ${String(e)}`, true);
|
|
23375
|
+
}
|
|
23376
|
+
try {
|
|
23377
|
+
const sent = await sendViaLocalBook(id, contact, text);
|
|
23378
|
+
return textResult(sent);
|
|
23379
|
+
} catch (e2) {
|
|
23380
|
+
return textResult(`send_message failed: ${String(e2)}`, true);
|
|
23381
|
+
}
|
|
23073
23382
|
}
|
|
23074
23383
|
}
|
|
23075
23384
|
);
|
|
23076
23385
|
server.tool(
|
|
23077
23386
|
"remove_contact",
|
|
23078
|
-
"Forget a contact (by name or container id) \u2014 drops it from the bound identity's contacts, so you can no longer message them and inbound messages from them are rejected. This is a contacts-layer forget, NOT a key wipe: the per-peer channel key material persists, so re-adding the same peer reuses the existing encrypted channel rather than re-handshaking. Requires a bound identity.",
|
|
23387
|
+
"Forget a contact (by name or container id) \u2014 drops it from the bound identity's contacts, so you can no longer message them and inbound messages from them are rejected. This is a contacts-layer forget, NOT a key wipe: the per-peer channel key material persists, so re-adding the same peer reuses the existing encrypted channel rather than re-handshaking. Note: if the removed peer is still published in the host-local contact book, a later send_message to it will reconnect through the book. Requires a bound identity.",
|
|
23079
23388
|
{ contact: external_exports.string().min(1).describe("Contact name or container id to remove.") },
|
|
23080
23389
|
async ({ contact }) => {
|
|
23081
23390
|
const { id, err } = boundOr();
|
|
Binary file
|
package/dist/mufl_code/actor.mu
CHANGED
|
@@ -17,9 +17,22 @@
|
|
|
17
17
|
// defer_messages — flip processed/ready_to_delete messages back to unread
|
|
18
18
|
// gc — two-generation GC of handled messages (host-fired, not a tool)
|
|
19
19
|
//
|
|
20
|
+
// Local contact book (host-fired transactions; see the design notes above
|
|
21
|
+
// local_introduce below):
|
|
22
|
+
// export_address_document — (readonly) my signed address document as a blob
|
|
23
|
+
// pin_registrar — pin the host registrar's signing keys (pin-once)
|
|
24
|
+
// set_local_policy — toggle auto-accept of local introductions
|
|
25
|
+
// mint_introduction — registrar-only in practice: sign an introduction credential
|
|
26
|
+
// sign_book_entry — registrar-only in practice: sign a contact-book entry
|
|
27
|
+
// connect_local — register a book contact + send local_introduce (+ first message)
|
|
28
|
+
// approve_introduction — accept a pending local introduction (flushes its queue)
|
|
29
|
+
// reject_introduction — drop a pending local introduction
|
|
30
|
+
// list_pending_introductions — (readonly) pending introductions (names + queue sizes)
|
|
31
|
+
//
|
|
20
32
|
// External transactions (inbound, not exposed as tools):
|
|
21
33
|
// accept_contact — inviter learns the joiner's identity + name
|
|
22
34
|
// receive_message — store a decrypted inbound message
|
|
35
|
+
// local_introduce — same-host peer connects via the local contact book
|
|
23
36
|
//
|
|
24
37
|
// Naming model (personal invites): generate_invite('Bob') tags a pending invite
|
|
25
38
|
// with the peer-name "Bob"; whoever redeems it is registered under "Bob" (the
|
|
@@ -67,6 +80,38 @@ application actor loads libraries
|
|
|
67
80
|
// migrates blobs in this shape forward — see below.
|
|
68
81
|
metadef legacy_message_t: ($sender_id -> global_id, $sender_name -> str, $text -> str, $date -> time).
|
|
69
82
|
|
|
83
|
+
// ---- local contact book wire shapes ---------------------------------
|
|
84
|
+
// Introduction credential, minted PER CONNECT ATTEMPT by the host's
|
|
85
|
+
// registrar packet (never stored in the book). It binds the joiner's
|
|
86
|
+
// identity AND address document to one target, with freshness + a nonce,
|
|
87
|
+
// so possession of book material alone authorizes nothing: only the
|
|
88
|
+
// registrar (whose key never leaves the host) can mint one, which is
|
|
89
|
+
// what makes "local" a cryptographic property rather than a convention.
|
|
90
|
+
metadef intro_t: (
|
|
91
|
+
$version -> int,
|
|
92
|
+
$joiner_cid -> global_id,
|
|
93
|
+
$joiner_ad_hash -> hash_code,
|
|
94
|
+
$target_cid -> global_id,
|
|
95
|
+
$iat -> time,
|
|
96
|
+
$nonce -> global_id
|
|
97
|
+
).
|
|
98
|
+
metadef signed_intro_t: ($i -> intro_t, $s -> crypto_signature).
|
|
99
|
+
// What the registrar signs for a contact-book entry (tamper-evidence for
|
|
100
|
+
// the host-side book file; verified by the SENDER in connect_local).
|
|
101
|
+
metadef book_entry_t: ($version -> int, $name -> str, $ad_hash -> hash_code).
|
|
102
|
+
// A not-yet-approved local introduction, with its bounded message queue.
|
|
103
|
+
metadef pending_msg_t: ($text -> str, $date -> time).
|
|
104
|
+
metadef pending_intro_t: ($name -> str, $ad -> address_document_types::t_address_document, $messages -> pending_msg_t[]).
|
|
105
|
+
metadef pending_view_t: ($name -> str, $queued -> int).
|
|
106
|
+
|
|
107
|
+
// Acceptance window for an introduction credential (seconds since mint;
|
|
108
|
+
// small negative slack for clock oddities) and the matching nonce-table
|
|
109
|
+
// retention horizon (window + slack, so a nonce outlives its credential).
|
|
110
|
+
intro_max_age_seconds is int = 300.
|
|
111
|
+
intro_max_skew_seconds is int = 30.
|
|
112
|
+
seen_nonce_cap is int = 1024.
|
|
113
|
+
pending_queue_cap is int = 50.
|
|
114
|
+
|
|
70
115
|
// Wire the deserialization primitive into the libraries that need it.
|
|
71
116
|
_read_or_abort = grab( _read_or_abort ).
|
|
72
117
|
key_storage::init ($_read_or_abort -> _read_or_abort).
|
|
@@ -94,6 +139,20 @@ application actor loads libraries
|
|
|
94
139
|
// every peer's keys in key_storage so encrypted channels survive the upgrade
|
|
95
140
|
// with no re-handshake. Only peer PUBLIC keys travel here, never secrets.
|
|
96
141
|
peer_ads is (global_id ->> address_document_types::t_address_document) = (,).
|
|
142
|
+
// The host registrar's address document (pinned once at identity
|
|
143
|
+
// creation / injected on upgrade) — its $identity $key_list is what
|
|
144
|
+
// introduction credentials are verified against. NIL means this identity
|
|
145
|
+
// accepts no local-book introductions at all.
|
|
146
|
+
registrar_ad is address_document_types::t_address_document+ = NIL.
|
|
147
|
+
// Whether a verified local introduction registers the joiner immediately
|
|
148
|
+
// (TRUE) or parks it in pending_introductions for explicit approval.
|
|
149
|
+
local_auto_accept is bool = TRUE.
|
|
150
|
+
// Replay guard for introduction credentials: nonce -> when it was seen.
|
|
151
|
+
// Lazily purged past the freshness horizon, hard-capped at seen_nonce_cap.
|
|
152
|
+
seen_nonces is (global_id ->> time) = (,).
|
|
153
|
+
// Verified-but-unapproved local introductions (when auto-accept is off),
|
|
154
|
+
// each with a bounded queue of messages awaiting approval.
|
|
155
|
+
pending_introductions is (global_id ->> pending_intro_t) = (,).
|
|
97
156
|
|
|
98
157
|
// Signal the host to persist the packet. Only emitted at the end of a
|
|
99
158
|
// complete procedure — intermediate states (e.g. channel handshake) are
|
|
@@ -114,6 +173,35 @@ application actor loads libraries
|
|
|
114
173
|
abort "Unknown contact: " + ref when found == NIL.
|
|
115
174
|
return found?.
|
|
116
175
|
}
|
|
176
|
+
|
|
177
|
+
// Append a message to the inbox under a fresh id; returns the id.
|
|
178
|
+
fn deposit_message (sender_id: global_id, sender_name: str, text: str, msg_date: time) -> int
|
|
179
|
+
{
|
|
180
|
+
mid = next_msg_seq.
|
|
181
|
+
next_msg_seq -> next_msg_seq + 1.
|
|
182
|
+
inbox (_count inbox|) -> (
|
|
183
|
+
$msg_id -> mid,
|
|
184
|
+
$sender_id -> sender_id,
|
|
185
|
+
$sender_name -> sender_name,
|
|
186
|
+
$text -> text,
|
|
187
|
+
$date -> msg_date,
|
|
188
|
+
$status -> "unread"
|
|
189
|
+
).
|
|
190
|
+
return mid.
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Resolve a pending introduction by joiner name or stringified container
|
|
194
|
+
// id; aborts when nothing matches.
|
|
195
|
+
fn resolve_pending (ref: str) -> global_id
|
|
196
|
+
{
|
|
197
|
+
found is global_id+ = NIL.
|
|
198
|
+
sc pending_introductions -- (cid -> p) ?? found == NIL && ((p $name) == ref || (_str cid) == ref)
|
|
199
|
+
{
|
|
200
|
+
found -> cid.
|
|
201
|
+
}
|
|
202
|
+
abort "No pending introduction matches: " + ref when found == NIL.
|
|
203
|
+
return found?.
|
|
204
|
+
}
|
|
117
205
|
}
|
|
118
206
|
|
|
119
207
|
// ---- user transactions --------------------------------------------------
|
|
@@ -394,6 +482,180 @@ application actor loads libraries
|
|
|
394
482
|
].
|
|
395
483
|
}
|
|
396
484
|
|
|
485
|
+
// ---- local contact book ---------------------------------------------------
|
|
486
|
+
// The book itself lives HOST-SIDE (wrapper-local file, remote peers have no
|
|
487
|
+
// path to it) and stores only public address material — essentially a stored
|
|
488
|
+
// multi-use invite. It bypasses invite generation/delivery, NOT the key
|
|
489
|
+
// exchange: connecting still runs the normal encrypted_channel handshake.
|
|
490
|
+
// Authorization is per attempt: the host's registrar packet mints a fresh,
|
|
491
|
+
// short-lived, registrar-signed introduction credential for each connect, and
|
|
492
|
+
// the target verifies it against its pinned registrar keys. An external peer
|
|
493
|
+
// can never produce one, so the local boundary holds cryptographically.
|
|
494
|
+
|
|
495
|
+
trn readonly export_address_document _
|
|
496
|
+
{
|
|
497
|
+
return (_write address_document::get_my_address_document()).
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
trn pin_registrar _:($registrar_ad -> registrar_ad_blob: bin, $replace -> replace: bool+)
|
|
501
|
+
{
|
|
502
|
+
current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
|
|
503
|
+
|
|
504
|
+
ad = (_read_or_abort registrar_ad_blob) safe address_document_types::t_address_document.
|
|
505
|
+
if registrar_ad != NIL
|
|
506
|
+
{
|
|
507
|
+
// Idempotent re-pin of the same keys is a no-op; CHANGING the pinned
|
|
508
|
+
// keys is a deliberate act and must be requested explicitly, so no
|
|
509
|
+
// future internal-path code can substitute a registrar silently.
|
|
510
|
+
if (_value_id (registrar_ad? $identity $key_list)) == (_value_id (ad $identity $key_list))
|
|
511
|
+
{
|
|
512
|
+
return transaction::success [
|
|
513
|
+
_return_data ($pinned -> TRUE, $changed -> FALSE)
|
|
514
|
+
].
|
|
515
|
+
}
|
|
516
|
+
abort "A different registrar key list is already pinned; pass $replace -> TRUE to overwrite." when replace == NIL || replace? != TRUE.
|
|
517
|
+
}
|
|
518
|
+
registrar_ad -> ad.
|
|
519
|
+
return transaction::success [
|
|
520
|
+
_return_data ($pinned -> TRUE, $changed -> TRUE),
|
|
521
|
+
_save_state NIL
|
|
522
|
+
].
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
trn set_local_policy _:($auto_accept -> auto_accept: bool)
|
|
526
|
+
{
|
|
527
|
+
current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
|
|
528
|
+
local_auto_accept -> auto_accept.
|
|
529
|
+
return transaction::success [
|
|
530
|
+
_return_data ($auto_accept -> auto_accept),
|
|
531
|
+
_save_state NIL
|
|
532
|
+
].
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Mint an introduction credential. Only meaningful on the host's REGISTRAR
|
|
536
|
+
// packet: targets verify the signature against their pinned registrar keys,
|
|
537
|
+
// so a credential minted by any other packet simply fails verification.
|
|
538
|
+
// Stateless — nothing to save. iat is stamped with transaction time so mint
|
|
539
|
+
// and verify use the same clock domain.
|
|
540
|
+
trn mint_introduction _:($joiner_ad -> joiner_ad_blob: bin, $target_ad -> target_ad_blob: bin)
|
|
541
|
+
{
|
|
542
|
+
current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
|
|
543
|
+
|
|
544
|
+
joiner_ad = (_read_or_abort joiner_ad_blob) safe address_document_types::t_address_document.
|
|
545
|
+
target_ad = (_read_or_abort target_ad_blob) safe address_document_types::t_address_document.
|
|
546
|
+
intro is intro_t = (
|
|
547
|
+
$version -> 1,
|
|
548
|
+
$joiner_cid -> joiner_ad $identity $container_id,
|
|
549
|
+
$joiner_ad_hash -> _value_id joiner_ad,
|
|
550
|
+
$target_cid -> target_ad $identity $container_id,
|
|
551
|
+
$iat -> (current_transaction_info::get_transaction_time())?,
|
|
552
|
+
$nonce -> _new_id "a2adapt local introduction"
|
|
553
|
+
).
|
|
554
|
+
signed is signed_intro_t = ($i -> intro, $s -> key_storage::default_sign (_value_id intro)).
|
|
555
|
+
return transaction::success [
|
|
556
|
+
_return_data ($intro -> (_write signed))
|
|
557
|
+
].
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Sign a contact-book entry (registrar packet only, same caveat as above).
|
|
561
|
+
// Makes the host-side book file tamper-evident: senders re-derive this record
|
|
562
|
+
// from the entry they read and verify the signature before connecting.
|
|
563
|
+
trn sign_book_entry _:($name -> name: str, $ad -> ad_blob: bin)
|
|
564
|
+
{
|
|
565
|
+
current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
|
|
566
|
+
|
|
567
|
+
ad = (_read_or_abort ad_blob) safe address_document_types::t_address_document.
|
|
568
|
+
entry is book_entry_t = ($version -> 1, $name -> name, $ad_hash -> _value_id ad).
|
|
569
|
+
return transaction::success [
|
|
570
|
+
_return_data ($sig -> (_write (key_storage::default_sign (_value_id entry))))
|
|
571
|
+
].
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Connect to a same-host peer found in the local contact book: verify the
|
|
575
|
+
// book entry's registrar signature, register the peer as a contact, then
|
|
576
|
+
// introduce myself over the encrypted channel — carrying the credential the
|
|
577
|
+
// host just minted for this attempt, plus (optionally) the first message so
|
|
578
|
+
// introduction + first delivery are one atomic transaction on the target
|
|
579
|
+
// (no introduce-vs-message ordering race).
|
|
580
|
+
trn connect_local _:($name -> name: str, $target_ad -> target_ad_blob: bin, $intro -> intro_blob: bin, $entry_sig -> entry_sig_blob: bin, $text -> text: str+)
|
|
581
|
+
{
|
|
582
|
+
current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
|
|
583
|
+
abort "No registrar pinned — the local contact book is unavailable for this identity." when registrar_ad == NIL.
|
|
584
|
+
|
|
585
|
+
target_ad = (_read_or_abort target_ad_blob) safe address_document_types::t_address_document.
|
|
586
|
+
target_id = target_ad $identity $container_id.
|
|
587
|
+
abort "This contact-book entry is your own identity." when target_id == _get_container_id().
|
|
588
|
+
|
|
589
|
+
entry is book_entry_t = ($version -> 1, $name -> name, $ad_hash -> _value_id target_ad).
|
|
590
|
+
entry_sig = (_read_or_abort entry_sig_blob) safe crypto_signature.
|
|
591
|
+
abort "Contact-book entry failed registrar verification." when key_storage::check_signature_new_container (_value_id entry) entry_sig (registrar_ad? $identity $key_list) != TRUE.
|
|
592
|
+
|
|
593
|
+
contacts target_id -> ($name -> name, $container_id -> target_id).
|
|
594
|
+
peer_ads target_id -> target_ad.
|
|
595
|
+
|
|
596
|
+
my_self_name = my_name.
|
|
597
|
+
my_ad = address_document::get_my_address_document().
|
|
598
|
+
return encrypted_channel::execute_transaction target_id (fn (_) -> transaction::results::type {
|
|
599
|
+
return transaction::success [
|
|
600
|
+
encrypted_channel::send_encrypted_tx target_id (
|
|
601
|
+
$name -> "::actor::local_introduce",
|
|
602
|
+
$targ -> ($joiner_name -> my_self_name, $joiner_ad -> my_ad, $intro -> intro_blob, $text -> text)
|
|
603
|
+
),
|
|
604
|
+
_return_data ($connected -> name, $container_id -> target_id),
|
|
605
|
+
_save_state NIL
|
|
606
|
+
].
|
|
607
|
+
}).
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
trn approve_introduction _:($contact -> ref: str)
|
|
611
|
+
{
|
|
612
|
+
current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
|
|
613
|
+
|
|
614
|
+
pid = resolve_pending ref.
|
|
615
|
+
entry = (pending_introductions pid)?.
|
|
616
|
+
contacts pid -> ($name -> entry $name, $container_id -> pid).
|
|
617
|
+
peer_ads pid -> entry $ad.
|
|
618
|
+
|
|
619
|
+
queued = entry $messages.
|
|
620
|
+
flushed is int = 0.
|
|
621
|
+
sc queued -- ( -> m)
|
|
622
|
+
{
|
|
623
|
+
deposit_message pid (entry $name) (m $text) (m $date).
|
|
624
|
+
flushed -> flushed + 1.
|
|
625
|
+
}
|
|
626
|
+
delete pending_introductions pid.
|
|
627
|
+
|
|
628
|
+
return transaction::success [
|
|
629
|
+
_return_data ($approved -> entry $name, $container_id -> pid, $flushed -> flushed),
|
|
630
|
+
_save_state NIL
|
|
631
|
+
].
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
trn reject_introduction _:($contact -> ref: str)
|
|
635
|
+
{
|
|
636
|
+
current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
|
|
637
|
+
|
|
638
|
+
pid = resolve_pending ref.
|
|
639
|
+
entry = (pending_introductions pid)?.
|
|
640
|
+
dropped = _count (entry $messages)|.
|
|
641
|
+
delete pending_introductions pid.
|
|
642
|
+
|
|
643
|
+
return transaction::success [
|
|
644
|
+
_return_data ($rejected -> entry $name, $container_id -> pid, $dropped_messages -> dropped),
|
|
645
|
+
_save_state NIL
|
|
646
|
+
].
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
trn readonly list_pending_introductions _
|
|
650
|
+
{
|
|
651
|
+
out is (global_id ->> pending_view_t) = (,).
|
|
652
|
+
sc pending_introductions -- (cid -> p)
|
|
653
|
+
{
|
|
654
|
+
out cid -> ($name -> p $name, $queued -> _count (p $messages)|).
|
|
655
|
+
}
|
|
656
|
+
return out.
|
|
657
|
+
}
|
|
658
|
+
|
|
397
659
|
// ---- upgrade: state export / import -------------------------------------
|
|
398
660
|
// The host persists state by calling export_state (readonly) and serializing
|
|
399
661
|
// the returned value to a code-independent blob. On a code upgrade it recreates
|
|
@@ -406,12 +668,18 @@ application actor loads libraries
|
|
|
406
668
|
trn readonly export_state _
|
|
407
669
|
{
|
|
408
670
|
return (
|
|
409
|
-
$my_name
|
|
410
|
-
$contacts
|
|
411
|
-
$pending_invites
|
|
412
|
-
$inbox
|
|
413
|
-
$next_msg_seq
|
|
414
|
-
$peer_ads
|
|
671
|
+
$my_name -> my_name,
|
|
672
|
+
$contacts -> contacts,
|
|
673
|
+
$pending_invites -> pending_invites,
|
|
674
|
+
$inbox -> inbox,
|
|
675
|
+
$next_msg_seq -> next_msg_seq,
|
|
676
|
+
$peer_ads -> peer_ads,
|
|
677
|
+
$registrar_ad -> registrar_ad,
|
|
678
|
+
$local_auto_accept -> local_auto_accept,
|
|
679
|
+
// Nonces are exported so a restart does not reopen the replay window
|
|
680
|
+
// for still-fresh credentials; stale ones are purged lazily anyway.
|
|
681
|
+
$seen_nonces -> seen_nonces,
|
|
682
|
+
$pending_introductions -> pending_introductions
|
|
415
683
|
).
|
|
416
684
|
}
|
|
417
685
|
|
|
@@ -476,6 +744,25 @@ application actor loads libraries
|
|
|
476
744
|
next_msg_seq -> (data $next_msg_seq) safe int.
|
|
477
745
|
}
|
|
478
746
|
|
|
747
|
+
// Local-contact-book state arrived after the original schema — every
|
|
748
|
+
// field is optional in old blobs and defaults stay in place when absent.
|
|
749
|
+
if (data $registrar_ad) != NIL
|
|
750
|
+
{
|
|
751
|
+
registrar_ad -> (data $registrar_ad) safe address_document_types::t_address_document.
|
|
752
|
+
}
|
|
753
|
+
if (data $local_auto_accept) != NIL
|
|
754
|
+
{
|
|
755
|
+
local_auto_accept -> (data $local_auto_accept) safe bool.
|
|
756
|
+
}
|
|
757
|
+
if (data $seen_nonces) != NIL
|
|
758
|
+
{
|
|
759
|
+
seen_nonces -> (data $seen_nonces) safe (global_id ->> time).
|
|
760
|
+
}
|
|
761
|
+
if (data $pending_introductions) != NIL
|
|
762
|
+
{
|
|
763
|
+
pending_introductions -> (data $pending_introductions) safe (global_id ->> pending_intro_t).
|
|
764
|
+
}
|
|
765
|
+
|
|
479
766
|
// Re-register every peer's keys so encrypted channels keep working after
|
|
480
767
|
// the upgrade — no handshake needed (my own keys are unchanged, and the
|
|
481
768
|
// peers' self-signed address documents re-authorize on this fresh packet).
|
|
@@ -483,6 +770,12 @@ application actor loads libraries
|
|
|
483
770
|
{
|
|
484
771
|
address_document::process_address_document ad TRUE.
|
|
485
772
|
}
|
|
773
|
+
// Pending introducers' keys too: their channel to me predates approval,
|
|
774
|
+
// so it must survive an upgrade exactly like an approved contact's.
|
|
775
|
+
sc pending_introductions -- ( -> p)
|
|
776
|
+
{
|
|
777
|
+
address_document::process_address_document (p $ad) TRUE.
|
|
778
|
+
}
|
|
486
779
|
|
|
487
780
|
return transaction::success [
|
|
488
781
|
_return_data ($imported -> TRUE, $contacts -> _count contacts|, $peers -> _count peer_ads|),
|
|
@@ -499,15 +792,18 @@ application actor loads libraries
|
|
|
499
792
|
|
|
500
793
|
sender_id = current_transaction_info::get_external_envelope_or_abort() $from.
|
|
501
794
|
|
|
502
|
-
//
|
|
503
|
-
//
|
|
795
|
+
// Only an invite I generated (and have not yet consumed) authorizes a
|
|
796
|
+
// contact registration. Without this gate any invite blob would be a
|
|
797
|
+
// multi-use bearer credential: anyone who ever saw one could register
|
|
798
|
+
// themselves as my contact with a self-chosen name.
|
|
504
799
|
assigned_name = pending_invites invite_id.
|
|
505
|
-
|
|
800
|
+
abort "Unknown or already-redeemed invite." when assigned_name == NIL.
|
|
801
|
+
contact_name = assigned_name?.
|
|
506
802
|
|
|
507
803
|
contacts sender_id -> ($name -> contact_name, $container_id -> sender_id).
|
|
508
804
|
// Remember the joiner's address document for upgrade-time re-registration.
|
|
509
805
|
peer_ads sender_id -> joiner_ad.
|
|
510
|
-
|
|
806
|
+
delete pending_invites invite_id.
|
|
511
807
|
|
|
512
808
|
return transaction::success [
|
|
513
809
|
_notify_agent ($event -> $contact_accepted, $name -> contact_name, $container_id -> sender_id),
|
|
@@ -521,23 +817,30 @@ application actor loads libraries
|
|
|
521
817
|
encrypted_channel::check_encrypted_or_abort().
|
|
522
818
|
|
|
523
819
|
sender_id = current_transaction_info::get_external_envelope_or_abort() $from.
|
|
820
|
+
msg_date = (current_transaction_info::get_transaction_time())?.
|
|
524
821
|
sender = contacts sender_id.
|
|
525
|
-
|
|
822
|
+
|
|
823
|
+
if sender == NIL
|
|
824
|
+
{
|
|
825
|
+
// A verified-but-unapproved local introduction may message me before
|
|
826
|
+
// approval: queue (bounded) inside its pending entry. Approval
|
|
827
|
+
// flushes the queue into the inbox in order; anything else from an
|
|
828
|
+
// unknown sender is rejected as before.
|
|
829
|
+
p = pending_introductions sender_id.
|
|
830
|
+
abort "Message from an unknown sender was rejected." when p == NIL.
|
|
831
|
+
entry = p?.
|
|
832
|
+
queued = entry $messages.
|
|
833
|
+
abort "Pending-introduction message queue is full; awaiting approval." when (_count queued|) >= pending_queue_cap.
|
|
834
|
+
queued (_count queued|) -> ($text -> text, $date -> msg_date).
|
|
835
|
+
pending_introductions sender_id -> ($name -> entry $name, $ad -> entry $ad, $messages -> queued).
|
|
836
|
+
return transaction::success [
|
|
837
|
+
_notify_agent ($event -> $pending_message, $sender_name -> entry $name, $queued -> _count queued|),
|
|
838
|
+
_save_state NIL
|
|
839
|
+
].
|
|
840
|
+
}
|
|
526
841
|
|
|
527
842
|
sender_name = sender? $name.
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
mid = next_msg_seq.
|
|
531
|
-
next_msg_seq -> next_msg_seq + 1.
|
|
532
|
-
|
|
533
|
-
inbox (_count inbox|) -> (
|
|
534
|
-
$msg_id -> mid,
|
|
535
|
-
$sender_id -> sender_id,
|
|
536
|
-
$sender_name -> sender_name,
|
|
537
|
-
$text -> text,
|
|
538
|
-
$date -> msg_date,
|
|
539
|
-
$status -> "unread"
|
|
540
|
-
).
|
|
843
|
+
mid = deposit_message sender_id sender_name text msg_date.
|
|
541
844
|
|
|
542
845
|
// The notification deliberately carries NO message body — only that a
|
|
543
846
|
// message arrived, from whom, and its id. The body stays in the packet
|
|
@@ -547,4 +850,97 @@ application actor loads libraries
|
|
|
547
850
|
_save_state NIL
|
|
548
851
|
].
|
|
549
852
|
}
|
|
853
|
+
|
|
854
|
+
// A same-host peer connects via the local contact book. The credential must
|
|
855
|
+
// have been minted by THIS HOST's registrar for THIS sender and THIS target,
|
|
856
|
+
// recently, and never seen before — five checks that together make the book
|
|
857
|
+
// path unusable from outside the host:
|
|
858
|
+
// 1. registrar signature over the credential (pinned key list)
|
|
859
|
+
// 2. envelope $from == credential.joiner_cid (no splicing someone
|
|
860
|
+
// else's credential onto your own channel)
|
|
861
|
+
// 3. hash(joiner_ad) == credential.joiner_ad_hash (no AD substitution)
|
|
862
|
+
// 4. credential.target_cid == me (no cross-target reuse)
|
|
863
|
+
// 5. freshness window + unseen nonce (no replay)
|
|
864
|
+
// The encrypted channel itself authenticates that the sender controls its
|
|
865
|
+
// keys, so no extra challenge-response is needed.
|
|
866
|
+
trn local_introduce _:($joiner_name -> joiner_name: str, $joiner_ad -> joiner_ad: address_document_types::t_address_document, $intro -> intro_blob: bin, $text -> text: str+)
|
|
867
|
+
{
|
|
868
|
+
current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
|
|
869
|
+
encrypted_channel::check_encrypted_or_abort().
|
|
870
|
+
|
|
871
|
+
sender_id = current_transaction_info::get_external_envelope_or_abort() $from.
|
|
872
|
+
abort "This identity does not accept local-contact-book introductions." when registrar_ad == NIL.
|
|
873
|
+
|
|
874
|
+
signed = (_read_or_abort intro_blob) safe signed_intro_t.
|
|
875
|
+
intro = signed $i.
|
|
876
|
+
abort "Unsupported introduction credential version." when (intro $version) != 1.
|
|
877
|
+
abort "Introduction credential was not signed by this host's registrar." when key_storage::check_signature_new_container (_value_id intro) (signed $s) (registrar_ad? $identity $key_list) != TRUE.
|
|
878
|
+
abort "Introduction credential was minted for a different sender." when (intro $joiner_cid) != sender_id.
|
|
879
|
+
abort "Introduction credential does not match the sender's address document." when (intro $joiner_ad_hash) != _value_id joiner_ad.
|
|
880
|
+
abort "Introduction credential targets a different identity." when (intro $target_cid) != _get_container_id().
|
|
881
|
+
|
|
882
|
+
now = (current_transaction_info::get_transaction_time())?.
|
|
883
|
+
age = _substract_seconds now (intro $iat).
|
|
884
|
+
abort "Introduction credential is outside its freshness window." when age > intro_max_age_seconds || age < (0 - intro_max_skew_seconds).
|
|
885
|
+
|
|
886
|
+
// Lazy nonce GC (drop everything past the retention horizon), then the
|
|
887
|
+
// replay check, then a hard cap so a misbehaving local peer cannot bloat
|
|
888
|
+
// packet state inside the window.
|
|
889
|
+
horizon = intro_max_age_seconds + intro_max_skew_seconds.
|
|
890
|
+
fresh_nonces is (global_id ->> time) = (,).
|
|
891
|
+
sc seen_nonces -- (n -> t)
|
|
892
|
+
{
|
|
893
|
+
if (_substract_seconds now t) <= horizon { fresh_nonces n -> t. }
|
|
894
|
+
}
|
|
895
|
+
seen_nonces -> fresh_nonces.
|
|
896
|
+
abort "Replayed introduction credential." when seen_nonces (intro $nonce) != NIL.
|
|
897
|
+
abort "Too many concurrent introductions; try again shortly." when (_count seen_nonces|) >= seen_nonce_cap.
|
|
898
|
+
seen_nonces (intro $nonce) -> now.
|
|
899
|
+
|
|
900
|
+
existing = contacts sender_id.
|
|
901
|
+
if existing != NIL
|
|
902
|
+
{
|
|
903
|
+
// Already a contact (idempotent re-introduction): keep my assigned
|
|
904
|
+
// name, refresh the stored address document, deliver any payload.
|
|
905
|
+
peer_ads sender_id -> joiner_ad.
|
|
906
|
+
if text != NIL
|
|
907
|
+
{
|
|
908
|
+
mid = deposit_message sender_id (existing? $name) text? now.
|
|
909
|
+
return transaction::success [
|
|
910
|
+
_notify_agent ($event -> $message_received, $sender_name -> existing? $name, $msg_id -> mid, $date -> now),
|
|
911
|
+
_save_state NIL
|
|
912
|
+
].
|
|
913
|
+
}
|
|
914
|
+
return transaction::success [ _save_state NIL ].
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if local_auto_accept
|
|
918
|
+
{
|
|
919
|
+
contacts sender_id -> ($name -> joiner_name, $container_id -> sender_id).
|
|
920
|
+
peer_ads sender_id -> joiner_ad.
|
|
921
|
+
if text != NIL
|
|
922
|
+
{
|
|
923
|
+
mid = deposit_message sender_id joiner_name text? now.
|
|
924
|
+
return transaction::success [
|
|
925
|
+
_notify_agent ($event -> $local_contact_added, $name -> joiner_name, $container_id -> sender_id),
|
|
926
|
+
_notify_agent ($event -> $message_received, $sender_name -> joiner_name, $msg_id -> mid, $date -> now),
|
|
927
|
+
_save_state NIL
|
|
928
|
+
].
|
|
929
|
+
}
|
|
930
|
+
return transaction::success [
|
|
931
|
+
_notify_agent ($event -> $local_contact_added, $name -> joiner_name, $container_id -> sender_id),
|
|
932
|
+
_save_state NIL
|
|
933
|
+
].
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Pending-approval policy: park the introduction (with its optional
|
|
937
|
+
// first message) until approve_introduction / reject_introduction.
|
|
938
|
+
queued is pending_msg_t[] = [].
|
|
939
|
+
if text != NIL { queued 0 -> ($text -> text?, $date -> now). }
|
|
940
|
+
pending_introductions sender_id -> ($name -> joiner_name, $ad -> joiner_ad, $messages -> queued).
|
|
941
|
+
return transaction::success [
|
|
942
|
+
_notify_agent ($event -> $local_contact_request, $name -> joiner_name, $container_id -> sender_id, $queued -> _count queued|),
|
|
943
|
+
_save_state NIL
|
|
944
|
+
].
|
|
945
|
+
}
|
|
550
946
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adapt-toolkit/a2adapt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "MCP server daemon for a2adapt — one native ADAPT wrapper hosting N self-sovereign identities, exposing secure agent-to-agent messaging tools over HTTP (Streamable HTTP). Run `a2adapt-mcp start`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@adapt-toolkit/sdk": "^0.
|
|
53
|
-
"@adapt-toolkit/sdk-native": "^0.
|
|
52
|
+
"@adapt-toolkit/sdk": "^0.4.0",
|
|
53
|
+
"@adapt-toolkit/sdk-native": "^0.4.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@modelcontextprotocol/sdk": "^1.0.4",
|