@adapt-toolkit/a2adapt 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +9 -3
- package/dist/hooks/runner.js +63 -6
- package/dist/index.js +302 -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
|
@@ -321,9 +321,15 @@ function cmdWatch(which) {
|
|
|
321
321
|
} catch {
|
|
322
322
|
continue;
|
|
323
323
|
}
|
|
324
|
-
|
|
325
|
-
`[${name}]
|
|
326
|
-
)
|
|
324
|
+
if (msg.event === "local_contact_request") {
|
|
325
|
+
out(`[${name}] pending local introduction from ${msg.from ?? "?"} \u2014 respond_to_introduction to approve/reject`);
|
|
326
|
+
} else if (msg.event === "pending_message") {
|
|
327
|
+
out(`[${name}] ${msg.from ?? "?"} queued a message awaiting introduction approval (${msg.queued ?? "?"} queued)`);
|
|
328
|
+
} else {
|
|
329
|
+
out(
|
|
330
|
+
`[${name}] new message from ${msg.from ?? "?"}` + (msg.msg_id !== void 0 ? ` (#${msg.msg_id})` : "") + (msg.date ? ` (${msg.date})` : "")
|
|
331
|
+
);
|
|
332
|
+
}
|
|
327
333
|
}
|
|
328
334
|
}
|
|
329
335
|
};
|
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
|
}
|
|
@@ -100,9 +106,37 @@ function identityExists(name) {
|
|
|
100
106
|
return false;
|
|
101
107
|
}
|
|
102
108
|
}
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
function anyIdentityBound() {
|
|
110
|
+
let snap;
|
|
111
|
+
try {
|
|
112
|
+
snap = JSON.parse(fs.readFileSync(join(STATE_DIR, "bindings.json"), "utf8"));
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(snap.bound) || snap.bound.length === 0) return false;
|
|
117
|
+
const pid = Number(snap.pid);
|
|
118
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
119
|
+
try {
|
|
120
|
+
process.kill(pid, 0);
|
|
121
|
+
return true;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return err.code === "EPERM";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
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;
|
|
106
140
|
}
|
|
107
141
|
function sessionStart() {
|
|
108
142
|
const raw = readStdin();
|
|
@@ -120,7 +154,7 @@ function sessionStart() {
|
|
|
120
154
|
const pinned = findPinnedIdentity(cwd);
|
|
121
155
|
const unread = collectUnread();
|
|
122
156
|
const blocks = [];
|
|
123
|
-
if (pinned) blocks.push(renderIdentityDirective(pinned, identityExists(pinned)));
|
|
157
|
+
if (pinned) blocks.push(renderIdentityDirective(pinned, identityExists(pinned.identity)));
|
|
124
158
|
if (unread.length > 0) blocks.push(renderContext(unread));
|
|
125
159
|
if (blocks.length === 0) return noop();
|
|
126
160
|
emit({
|
|
@@ -131,6 +165,26 @@ function sessionStart() {
|
|
|
131
165
|
}
|
|
132
166
|
});
|
|
133
167
|
}
|
|
168
|
+
function userPromptSubmit() {
|
|
169
|
+
const raw = readStdin();
|
|
170
|
+
let cwd = process.cwd();
|
|
171
|
+
if (raw) {
|
|
172
|
+
try {
|
|
173
|
+
const payload = JSON.parse(raw);
|
|
174
|
+
if (typeof payload.cwd === "string" && payload.cwd) cwd = payload.cwd;
|
|
175
|
+
} catch {
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const pinned = findPinnedIdentity(cwd);
|
|
179
|
+
if (!pinned || anyIdentityBound()) return noop();
|
|
180
|
+
emit({
|
|
181
|
+
continue: true,
|
|
182
|
+
hookSpecificOutput: {
|
|
183
|
+
hookEventName: "UserPromptSubmit",
|
|
184
|
+
additionalContext: renderIdentityDirective(pinned, identityExists(pinned.identity))
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
134
188
|
function main() {
|
|
135
189
|
const kind = process.argv[2] ?? "";
|
|
136
190
|
try {
|
|
@@ -138,6 +192,9 @@ function main() {
|
|
|
138
192
|
case "session-start":
|
|
139
193
|
sessionStart();
|
|
140
194
|
return;
|
|
195
|
+
case "user-prompt-submit":
|
|
196
|
+
userPromptSubmit();
|
|
197
|
+
return;
|
|
141
198
|
default:
|
|
142
199
|
noop();
|
|
143
200
|
return;
|
package/dist/index.js
CHANGED
|
@@ -22489,7 +22489,7 @@ function loadConfig() {
|
|
|
22489
22489
|
}
|
|
22490
22490
|
|
|
22491
22491
|
// src/index.ts
|
|
22492
|
-
var VERSION = true ? "0.
|
|
22492
|
+
var VERSION = true ? "0.8.1" : "0.0.0-dev";
|
|
22493
22493
|
var CONFIG = loadConfig();
|
|
22494
22494
|
var STATE_DIR = CONFIG.stateDir;
|
|
22495
22495
|
var BROKER_URL = CONFIG.brokerUrl;
|
|
@@ -22499,6 +22499,7 @@ var GC_INTERVAL_MS = CONFIG.gcIntervalMs;
|
|
|
22499
22499
|
var log = (...parts) => process.stderr.write(`a2adapt: ${parts.join(" ")}
|
|
22500
22500
|
`);
|
|
22501
22501
|
var NAME_RE = /^[A-Za-z0-9 _.-]{1,64}$/;
|
|
22502
|
+
var BOOK_DIR_NAME = "contact-book";
|
|
22502
22503
|
function validateName(name) {
|
|
22503
22504
|
if (!NAME_RE.test(name)) {
|
|
22504
22505
|
return "name must be 1-64 chars of letters, digits, space, _ . or -";
|
|
@@ -22506,6 +22507,9 @@ function validateName(name) {
|
|
|
22506
22507
|
if (name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
22507
22508
|
return "invalid name";
|
|
22508
22509
|
}
|
|
22510
|
+
if (name === BOOK_DIR_NAME) {
|
|
22511
|
+
return `"${BOOK_DIR_NAME}" is reserved for the local contact book`;
|
|
22512
|
+
}
|
|
22509
22513
|
return null;
|
|
22510
22514
|
}
|
|
22511
22515
|
function locateUnit() {
|
|
@@ -22528,9 +22532,22 @@ function locateUnit() {
|
|
|
22528
22532
|
var UNIT;
|
|
22529
22533
|
var wrapper;
|
|
22530
22534
|
var identities = /* @__PURE__ */ new Map();
|
|
22535
|
+
var registrar = null;
|
|
22536
|
+
var registrarAdBlob = null;
|
|
22531
22537
|
var sessionBinding = /* @__PURE__ */ new Map();
|
|
22532
22538
|
var bindingOwner = /* @__PURE__ */ new Map();
|
|
22533
22539
|
var evictedSessions = /* @__PURE__ */ new Set();
|
|
22540
|
+
var bindingsSnapshotPath = () => join2(STATE_DIR, "bindings.json");
|
|
22541
|
+
function persistBindings() {
|
|
22542
|
+
try {
|
|
22543
|
+
fs2.mkdirSync(STATE_DIR, { recursive: true });
|
|
22544
|
+
const tmp = `${bindingsSnapshotPath()}.tmp`;
|
|
22545
|
+
fs2.writeFileSync(tmp, JSON.stringify({ pid: process.pid, bound: [...bindingOwner.keys()] }));
|
|
22546
|
+
fs2.renameSync(tmp, bindingsSnapshotPath());
|
|
22547
|
+
} catch (err) {
|
|
22548
|
+
log("failed to persist bindings snapshot:", String(err));
|
|
22549
|
+
}
|
|
22550
|
+
}
|
|
22534
22551
|
var identityDir = (name) => join2(STATE_DIR, name);
|
|
22535
22552
|
var seedPath = (dir) => join2(dir, "identity.seed");
|
|
22536
22553
|
var dataPath = (dir) => join2(dir, "state_data.bin");
|
|
@@ -22540,6 +22557,102 @@ function listPersistedNames() {
|
|
|
22540
22557
|
if (!fs2.existsSync(STATE_DIR)) return [];
|
|
22541
22558
|
return fs2.readdirSync(STATE_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && fs2.existsSync(seedPath(join2(STATE_DIR, d.name)))).map((d) => d.name);
|
|
22542
22559
|
}
|
|
22560
|
+
var bookDir = () => join2(STATE_DIR, BOOK_DIR_NAME);
|
|
22561
|
+
var registrarSeedPath = () => join2(bookDir(), "registrar.seed");
|
|
22562
|
+
var bookPath = () => join2(bookDir(), "book.json");
|
|
22563
|
+
function readBook() {
|
|
22564
|
+
try {
|
|
22565
|
+
const parsed = JSON.parse(fs2.readFileSync(bookPath(), "utf8"));
|
|
22566
|
+
return parsed && typeof parsed.entries === "object" ? parsed.entries : {};
|
|
22567
|
+
} catch {
|
|
22568
|
+
return {};
|
|
22569
|
+
}
|
|
22570
|
+
}
|
|
22571
|
+
function writeBook(entries) {
|
|
22572
|
+
fs2.mkdirSync(bookDir(), { recursive: true });
|
|
22573
|
+
const tmp = `${bookPath()}.tmp`;
|
|
22574
|
+
fs2.writeFileSync(tmp, JSON.stringify({ v: 1, entries }, null, 2), { mode: 384 });
|
|
22575
|
+
fs2.renameSync(tmp, bookPath());
|
|
22576
|
+
}
|
|
22577
|
+
function exportAdBlob(id) {
|
|
22578
|
+
return Buffer.from(readonlyTx(id, "::actor::export_address_document").GetBinary());
|
|
22579
|
+
}
|
|
22580
|
+
async function publishToBook(id) {
|
|
22581
|
+
if (!registrar) throw new Error("registrar is not available");
|
|
22582
|
+
const adBlob = exportAdBlob(id);
|
|
22583
|
+
const sigData = await mutatingTx(registrar, "::actor::sign_book_entry", {
|
|
22584
|
+
name: id.name,
|
|
22585
|
+
ad: registrar.pw.packet.NewBinaryFromBuffer(adBlob)
|
|
22586
|
+
});
|
|
22587
|
+
const entries = readBook();
|
|
22588
|
+
entries[id.name] = {
|
|
22589
|
+
v: 1,
|
|
22590
|
+
name: id.name,
|
|
22591
|
+
container_id: id.cid,
|
|
22592
|
+
address_document: adBlob.toString("base64url"),
|
|
22593
|
+
published_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
22594
|
+
registrar_sig: Buffer.from(sigData.Reduce("sig").GetBinary()).toString("base64url")
|
|
22595
|
+
};
|
|
22596
|
+
writeBook(entries);
|
|
22597
|
+
log(`[${id.name}] published to the local contact book`);
|
|
22598
|
+
}
|
|
22599
|
+
function unpublishFromBook(name) {
|
|
22600
|
+
const entries = readBook();
|
|
22601
|
+
if (!(name in entries)) return;
|
|
22602
|
+
delete entries[name];
|
|
22603
|
+
writeBook(entries);
|
|
22604
|
+
log(`[${name}] removed from the local contact book`);
|
|
22605
|
+
}
|
|
22606
|
+
async function pinRegistrar(id) {
|
|
22607
|
+
if (!registrarAdBlob) throw new Error("registrar is not available");
|
|
22608
|
+
await mutatingTx(id, "::actor::pin_registrar", {
|
|
22609
|
+
registrar_ad: id.pw.packet.NewBinaryFromBuffer(registrarAdBlob)
|
|
22610
|
+
});
|
|
22611
|
+
}
|
|
22612
|
+
async function sendViaLocalBook(id, contact, text) {
|
|
22613
|
+
if (!registrar) {
|
|
22614
|
+
throw new Error(`"${contact}" is not a contact, and the local contact book is unavailable.`);
|
|
22615
|
+
}
|
|
22616
|
+
const entries = readBook();
|
|
22617
|
+
const entry = entries[contact] ?? Object.values(entries).find((e) => e.container_id === contact);
|
|
22618
|
+
if (!entry) {
|
|
22619
|
+
throw new Error(
|
|
22620
|
+
`"${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.`
|
|
22621
|
+
);
|
|
22622
|
+
}
|
|
22623
|
+
if (entry.container_id === id.cid) {
|
|
22624
|
+
throw new Error("that contact-book entry is this identity itself.");
|
|
22625
|
+
}
|
|
22626
|
+
const targetAd = Buffer.from(entry.address_document, "base64url");
|
|
22627
|
+
const entrySig = Buffer.from(entry.registrar_sig, "base64url");
|
|
22628
|
+
const joinerAd = exportAdBlob(id);
|
|
22629
|
+
const minted = await mutatingTx(registrar, "::actor::mint_introduction", {
|
|
22630
|
+
joiner_ad: registrar.pw.packet.NewBinaryFromBuffer(joinerAd),
|
|
22631
|
+
target_ad: registrar.pw.packet.NewBinaryFromBuffer(targetAd)
|
|
22632
|
+
});
|
|
22633
|
+
const introBlob = Buffer.from(minted.Reduce("intro").GetBinary());
|
|
22634
|
+
await mutatingTx(id, "::actor::connect_local", {
|
|
22635
|
+
name: entry.name,
|
|
22636
|
+
target_ad: id.pw.packet.NewBinaryFromBuffer(targetAd),
|
|
22637
|
+
intro: id.pw.packet.NewBinaryFromBuffer(introBlob),
|
|
22638
|
+
entry_sig: id.pw.packet.NewBinaryFromBuffer(entrySig),
|
|
22639
|
+
text
|
|
22640
|
+
});
|
|
22641
|
+
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.`;
|
|
22642
|
+
}
|
|
22643
|
+
async function ensureRegistrar() {
|
|
22644
|
+
fs2.mkdirSync(bookDir(), { recursive: true });
|
|
22645
|
+
let seed;
|
|
22646
|
+
try {
|
|
22647
|
+
seed = fs2.readFileSync(registrarSeedPath(), "utf8").trim();
|
|
22648
|
+
} catch {
|
|
22649
|
+
seed = randomBytes(24).toString("hex");
|
|
22650
|
+
fs2.writeFileSync(registrarSeedPath(), seed, { mode: 384 });
|
|
22651
|
+
}
|
|
22652
|
+
registrar = await createPacket(BOOK_DIR_NAME, seed, bookDir(), false);
|
|
22653
|
+
registrarAdBlob = exportAdBlob(registrar);
|
|
22654
|
+
log(`contact-book registrar ready (${registrar.cid})`);
|
|
22655
|
+
}
|
|
22543
22656
|
function hasSavedState(dir) {
|
|
22544
22657
|
try {
|
|
22545
22658
|
return fs2.existsSync(dataPath(dir)) && fs2.statSync(dataPath(dir)).size > 0;
|
|
@@ -22561,11 +22674,10 @@ function saveState(id) {
|
|
|
22561
22674
|
log(`[${id.name}] failed to save state:`, String(err));
|
|
22562
22675
|
}
|
|
22563
22676
|
}
|
|
22564
|
-
function appendNotifyLog(id,
|
|
22677
|
+
function appendNotifyLog(id, event) {
|
|
22565
22678
|
try {
|
|
22566
22679
|
fs2.mkdirSync(id.dir, { recursive: true });
|
|
22567
|
-
|
|
22568
|
-
fs2.appendFileSync(notifyLogPath(id.dir), line);
|
|
22680
|
+
fs2.appendFileSync(notifyLogPath(id.dir), JSON.stringify(event) + "\n");
|
|
22569
22681
|
} catch (err) {
|
|
22570
22682
|
log(`[${id.name}] failed to append notifications.log:`, String(err));
|
|
22571
22683
|
}
|
|
@@ -22649,7 +22761,7 @@ function wireHandlers(id) {
|
|
|
22649
22761
|
const sender = payload.Reduce("sender_name").Visualize();
|
|
22650
22762
|
const msgId = payload.Reduce("msg_id").Visualize();
|
|
22651
22763
|
const date3 = payload.Reduce("date").Visualize();
|
|
22652
|
-
appendNotifyLog(id, sender, msgId, date3);
|
|
22764
|
+
appendNotifyLog(id, { event: "message_received", from: sender, msg_id: msgId, date: date3 });
|
|
22653
22765
|
refreshUnread(id);
|
|
22654
22766
|
process.nextTick(
|
|
22655
22767
|
() => pushNotification(id.name, `[${id.name}] new message from ${sender} (#${msgId})`)
|
|
@@ -22660,6 +22772,29 @@ function wireHandlers(id) {
|
|
|
22660
22772
|
process.nextTick(
|
|
22661
22773
|
() => pushNotification(id.name, `[${id.name}] contact "${name}" (${cid}) accepted your invite.`)
|
|
22662
22774
|
);
|
|
22775
|
+
} else if (event === "local_contact_added") {
|
|
22776
|
+
const name = payload.Reduce("name").Visualize();
|
|
22777
|
+
const cid = payload.Reduce("container_id").Visualize();
|
|
22778
|
+
process.nextTick(
|
|
22779
|
+
() => pushNotification(id.name, `[${id.name}] local contact "${name}" (${cid}) connected via the contact book.`)
|
|
22780
|
+
);
|
|
22781
|
+
} else if (event === "local_contact_request") {
|
|
22782
|
+
const name = payload.Reduce("name").Visualize();
|
|
22783
|
+
const cid = payload.Reduce("container_id").Visualize();
|
|
22784
|
+
appendNotifyLog(id, { event: "local_contact_request", from: name });
|
|
22785
|
+
process.nextTick(
|
|
22786
|
+
() => pushNotification(
|
|
22787
|
+
id.name,
|
|
22788
|
+
`[${id.name}] pending local introduction from "${name}" (${cid}) \u2014 approve or reject with respond_to_introduction.`
|
|
22789
|
+
)
|
|
22790
|
+
);
|
|
22791
|
+
} else if (event === "pending_message") {
|
|
22792
|
+
const name = payload.Reduce("sender_name").Visualize();
|
|
22793
|
+
const queued = payload.Reduce("queued").Visualize();
|
|
22794
|
+
appendNotifyLog(id, { event: "pending_message", from: name, queued });
|
|
22795
|
+
process.nextTick(
|
|
22796
|
+
() => pushNotification(id.name, `[${id.name}] "${name}" queued a message awaiting introduction approval (${queued} queued).`)
|
|
22797
|
+
);
|
|
22663
22798
|
}
|
|
22664
22799
|
return;
|
|
22665
22800
|
}
|
|
@@ -22678,7 +22813,7 @@ function wireHandlers(id) {
|
|
|
22678
22813
|
}
|
|
22679
22814
|
};
|
|
22680
22815
|
}
|
|
22681
|
-
function createPacket(name, seed, dir) {
|
|
22816
|
+
function createPacket(name, seed, dir, track = true) {
|
|
22682
22817
|
const config2 = new PacketWrapperConfigurator();
|
|
22683
22818
|
config2.process_arguments([
|
|
22684
22819
|
"--unit_hash",
|
|
@@ -22707,7 +22842,7 @@ function createPacket(name, seed, dir) {
|
|
|
22707
22842
|
lock: Promise.resolve()
|
|
22708
22843
|
};
|
|
22709
22844
|
wireHandlers(id);
|
|
22710
|
-
identities.set(name, id);
|
|
22845
|
+
if (track) identities.set(name, id);
|
|
22711
22846
|
log(`[${name}] packet created \u2014 container id ${id.cid}`);
|
|
22712
22847
|
resolveCreate(id);
|
|
22713
22848
|
},
|
|
@@ -22715,13 +22850,20 @@ function createPacket(name, seed, dir) {
|
|
|
22715
22850
|
);
|
|
22716
22851
|
});
|
|
22717
22852
|
}
|
|
22718
|
-
async function provisionIdentity(name) {
|
|
22853
|
+
async function provisionIdentity(name, opts = { exposeLocal: true, localAutoAccept: true }) {
|
|
22719
22854
|
const dir = identityDir(name);
|
|
22720
22855
|
fs2.mkdirSync(dir, { recursive: true });
|
|
22721
22856
|
const seed = randomBytes(24).toString("hex");
|
|
22722
22857
|
fs2.writeFileSync(seedPath(dir), seed, { mode: 384 });
|
|
22723
22858
|
const id = await createPacket(name, seed, dir);
|
|
22724
22859
|
await mutatingTx(id, "::actor::set_my_name", { name });
|
|
22860
|
+
await pinRegistrar(id);
|
|
22861
|
+
if (!opts.localAutoAccept) {
|
|
22862
|
+
await mutatingTx(id, "::actor::set_local_policy", { auto_accept: false });
|
|
22863
|
+
}
|
|
22864
|
+
if (opts.exposeLocal) {
|
|
22865
|
+
await publishToBook(id);
|
|
22866
|
+
}
|
|
22725
22867
|
saveState(id);
|
|
22726
22868
|
return id;
|
|
22727
22869
|
}
|
|
@@ -22758,6 +22900,11 @@ async function bootWrapper() {
|
|
|
22758
22900
|
wrapper = await adapt_wrapper.start(argv);
|
|
22759
22901
|
wrapper.on_packet_created_cb = (cid) => log(`wrapper: packet ready ${cid.slice(0, 12)}\u2026`);
|
|
22760
22902
|
wrapper.start();
|
|
22903
|
+
try {
|
|
22904
|
+
await ensureRegistrar();
|
|
22905
|
+
} catch (err) {
|
|
22906
|
+
log("failed to start the contact-book registrar (local contact book disabled):", String(err));
|
|
22907
|
+
}
|
|
22761
22908
|
const names = listPersistedNames();
|
|
22762
22909
|
if (names.length === 0) {
|
|
22763
22910
|
log("no persisted identities \u2014 start with create_identity");
|
|
@@ -22765,12 +22912,16 @@ async function bootWrapper() {
|
|
|
22765
22912
|
log(`restoring ${names.length} identit${names.length === 1 ? "y" : "ies"}: ${names.join(", ")}`);
|
|
22766
22913
|
for (const name of names) {
|
|
22767
22914
|
try {
|
|
22768
|
-
await restoreIdentity(name);
|
|
22915
|
+
const id = await restoreIdentity(name);
|
|
22916
|
+
if (registrar) {
|
|
22917
|
+
await pinRegistrar(id);
|
|
22918
|
+
}
|
|
22769
22919
|
} catch (err) {
|
|
22770
22920
|
log(`failed to restore "${name}":`, String(err));
|
|
22771
22921
|
}
|
|
22772
22922
|
}
|
|
22773
22923
|
}
|
|
22924
|
+
persistBindings();
|
|
22774
22925
|
}
|
|
22775
22926
|
function resolveBound(sessionId) {
|
|
22776
22927
|
if (evictedSessions.has(sessionId)) {
|
|
@@ -22784,6 +22935,7 @@ function resolveBound(sessionId) {
|
|
|
22784
22935
|
if (!id) {
|
|
22785
22936
|
sessionBinding.delete(sessionId);
|
|
22786
22937
|
bindingOwner.delete(name);
|
|
22938
|
+
persistBindings();
|
|
22787
22939
|
return { error: `The bound identity "${name}" no longer exists. Choose another with choose_identity.` };
|
|
22788
22940
|
}
|
|
22789
22941
|
return { id };
|
|
@@ -22796,6 +22948,7 @@ function bindSession(sessionId, name) {
|
|
|
22796
22948
|
sessionBinding.set(sessionId, name);
|
|
22797
22949
|
bindingOwner.set(name, sessionId);
|
|
22798
22950
|
evictedSessions.delete(sessionId);
|
|
22951
|
+
persistBindings();
|
|
22799
22952
|
}
|
|
22800
22953
|
function renderContacts(v) {
|
|
22801
22954
|
const out = [];
|
|
@@ -22827,6 +22980,20 @@ function renderInbox(v) {
|
|
|
22827
22980
|
}
|
|
22828
22981
|
return out;
|
|
22829
22982
|
}
|
|
22983
|
+
function renderPending(v) {
|
|
22984
|
+
const out = [];
|
|
22985
|
+
if (v.IsNil()) return out;
|
|
22986
|
+
for (const key of v.GetKeys()) {
|
|
22987
|
+
const p = v.Reduce(key);
|
|
22988
|
+
if (p.IsNil()) continue;
|
|
22989
|
+
out.push({
|
|
22990
|
+
container_id: typeof key === "string" ? key : key.Visualize(),
|
|
22991
|
+
name: p.Reduce("name").Visualize(),
|
|
22992
|
+
queued: parseInt(p.Reduce("queued").Visualize(), 10) || 0
|
|
22993
|
+
});
|
|
22994
|
+
}
|
|
22995
|
+
return out;
|
|
22996
|
+
}
|
|
22830
22997
|
function textResult(text, isError = false) {
|
|
22831
22998
|
return { content: [{ type: "text", text }], isError };
|
|
22832
22999
|
}
|
|
@@ -22862,16 +23029,21 @@ function createMcpServer(getSessionId) {
|
|
|
22862
23029
|
};
|
|
22863
23030
|
server.tool(
|
|
22864
23031
|
"create_identity",
|
|
22865
|
-
"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.",
|
|
22866
|
-
{
|
|
22867
|
-
|
|
23032
|
+
"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.",
|
|
23033
|
+
{
|
|
23034
|
+
name: external_exports.string().min(1).describe('Display name for the new identity, e.g. "Alice".'),
|
|
23035
|
+
expose_local: external_exports.boolean().default(true).describe("Publish this identity in the host-local contact book."),
|
|
23036
|
+
local_auto_accept: external_exports.boolean().default(true).describe("Auto-accept local contact-book introductions (false = they queue for approval).")
|
|
23037
|
+
},
|
|
23038
|
+
async ({ name, expose_local, local_auto_accept }) => {
|
|
22868
23039
|
const bad = validateName(name);
|
|
22869
23040
|
if (bad) return textResult(`create_identity failed: ${bad}`, true);
|
|
22870
23041
|
if (identities.has(name)) return textResult(`create_identity failed: an identity named "${name}" already exists.`, true);
|
|
22871
23042
|
try {
|
|
22872
|
-
const id = await provisionIdentity(name);
|
|
23043
|
+
const id = await provisionIdentity(name, { exposeLocal: expose_local, localAutoAccept: local_auto_accept });
|
|
22873
23044
|
bindSession(getSessionId(), name);
|
|
22874
|
-
|
|
23045
|
+
const exposure = expose_local ? ` Published to the local contact book${local_auto_accept ? "" : " (introductions require approval)"}.` : " Not exposed in the local contact book.";
|
|
23046
|
+
return textResult(`Created identity "${name}" (${id.cid}) and bound it to this session.${exposure}`);
|
|
22875
23047
|
} catch (err) {
|
|
22876
23048
|
return textResult(`create_identity failed: ${String(err)}`, true);
|
|
22877
23049
|
}
|
|
@@ -22879,7 +23051,7 @@ function createMcpServer(getSessionId) {
|
|
|
22879
23051
|
);
|
|
22880
23052
|
server.tool(
|
|
22881
23053
|
"choose_identity",
|
|
22882
|
-
"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.",
|
|
23054
|
+
"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.",
|
|
22883
23055
|
{
|
|
22884
23056
|
name: external_exports.string().min(1).describe("Name of the identity to bind."),
|
|
22885
23057
|
force: external_exports.boolean().default(false).describe("Evict another session that holds this identity.")
|
|
@@ -22892,7 +23064,10 @@ function createMcpServer(getSessionId) {
|
|
|
22892
23064
|
const holder = bindingOwner.get(name);
|
|
22893
23065
|
if (holder && holder !== sid) {
|
|
22894
23066
|
if (!force) {
|
|
22895
|
-
return textResult(
|
|
23067
|
+
return textResult(
|
|
23068
|
+
`choose_identity declined: "${name}" is currently bound to another session. Do not retry with force=true on your own \u2014 tell the user the identity is in use elsewhere and ask whether to forcibly rebind it here; only retry with force=true after they explicitly confirm.`,
|
|
23069
|
+
true
|
|
23070
|
+
);
|
|
22896
23071
|
}
|
|
22897
23072
|
evictedSessions.add(holder);
|
|
22898
23073
|
sessionBinding.delete(holder);
|
|
@@ -22943,10 +23118,16 @@ ${lines.join("\n")}`);
|
|
|
22943
23118
|
log(`remove_packet(${id.cid}) failed:`, String(err));
|
|
22944
23119
|
}
|
|
22945
23120
|
identities.delete(name);
|
|
23121
|
+
try {
|
|
23122
|
+
unpublishFromBook(name);
|
|
23123
|
+
} catch (err) {
|
|
23124
|
+
log(`failed to unpublish "${name}" from the contact book:`, String(err));
|
|
23125
|
+
}
|
|
22946
23126
|
const holder = bindingOwner.get(name);
|
|
22947
23127
|
if (holder) {
|
|
22948
23128
|
bindingOwner.delete(name);
|
|
22949
23129
|
sessionBinding.delete(holder);
|
|
23130
|
+
persistBindings();
|
|
22950
23131
|
}
|
|
22951
23132
|
try {
|
|
22952
23133
|
fs2.rmSync(id.dir, { recursive: true, force: true });
|
|
@@ -23022,24 +23203,113 @@ ${blob}`
|
|
|
23022
23203
|
);
|
|
23023
23204
|
server.tool(
|
|
23024
23205
|
"list_contacts",
|
|
23025
|
-
"List the contacts the bound identity knows about (name + container id).",
|
|
23206
|
+
"List the contacts the bound identity knows about (name + container id), plus any pending local-contact-book introductions awaiting approval.",
|
|
23026
23207
|
{},
|
|
23027
23208
|
async () => {
|
|
23028
23209
|
const { id, err } = boundOr();
|
|
23029
23210
|
if (err) return err;
|
|
23030
23211
|
try {
|
|
23031
23212
|
const contacts = renderContacts(readonlyTx(id, "::actor::list_contacts"));
|
|
23032
|
-
|
|
23033
|
-
|
|
23034
|
-
|
|
23213
|
+
const pending = renderPending(readonlyTx(id, "::actor::list_pending_introductions"));
|
|
23214
|
+
const lines = [];
|
|
23215
|
+
lines.push(
|
|
23216
|
+
contacts.length === 0 ? "No contacts yet." : `Contacts (${contacts.length}):
|
|
23217
|
+
${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`
|
|
23218
|
+
);
|
|
23219
|
+
if (pending.length > 0) {
|
|
23220
|
+
lines.push(
|
|
23221
|
+
`Pending local introductions (${pending.length}) \u2014 approve/reject with respond_to_introduction:
|
|
23222
|
+
` + pending.map((p) => `\u2022 ${p.name} \u2014 ${p.container_id} (${p.queued} queued message${p.queued === 1 ? "" : "s"})`).join("\n")
|
|
23223
|
+
);
|
|
23224
|
+
}
|
|
23225
|
+
return textResult(lines.join("\n\n"));
|
|
23035
23226
|
} catch (e) {
|
|
23036
23227
|
return textResult(`list_contacts failed: ${String(e)}`, true);
|
|
23037
23228
|
}
|
|
23038
23229
|
}
|
|
23039
23230
|
);
|
|
23231
|
+
server.tool(
|
|
23232
|
+
"list_local_contact_book",
|
|
23233
|
+
"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.",
|
|
23234
|
+
{},
|
|
23235
|
+
async () => {
|
|
23236
|
+
const entries = Object.values(readBook());
|
|
23237
|
+
if (entries.length === 0) return textResult("The local contact book is empty.");
|
|
23238
|
+
const sid = getSessionId();
|
|
23239
|
+
const mine = sessionBinding.get(sid);
|
|
23240
|
+
const lines = entries.map((e) => {
|
|
23241
|
+
const tag = e.name === mine ? " \u2190 this session" : "";
|
|
23242
|
+
return `\u2022 ${e.name} \u2014 ${e.container_id} (published ${e.published_at})${tag}`;
|
|
23243
|
+
});
|
|
23244
|
+
return textResult(`Local contact book (${entries.length}):
|
|
23245
|
+
${lines.join("\n")}`);
|
|
23246
|
+
}
|
|
23247
|
+
);
|
|
23248
|
+
server.tool(
|
|
23249
|
+
"set_local_book_policy",
|
|
23250
|
+
"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).",
|
|
23251
|
+
{
|
|
23252
|
+
expose: external_exports.boolean().optional().describe("Publish (true) or remove (false) this identity in the local contact book."),
|
|
23253
|
+
auto_accept: external_exports.boolean().optional().describe("Auto-accept local introductions (false = queue them for approval).")
|
|
23254
|
+
},
|
|
23255
|
+
async ({ expose, auto_accept }) => {
|
|
23256
|
+
const { id, err } = boundOr();
|
|
23257
|
+
if (err) return err;
|
|
23258
|
+
if (expose === void 0 && auto_accept === void 0) {
|
|
23259
|
+
return textResult("set_local_book_policy: pass expose and/or auto_accept.", true);
|
|
23260
|
+
}
|
|
23261
|
+
const done = [];
|
|
23262
|
+
try {
|
|
23263
|
+
if (auto_accept !== void 0) {
|
|
23264
|
+
await mutatingTx(id, "::actor::set_local_policy", { auto_accept });
|
|
23265
|
+
done.push(`auto_accept=${auto_accept}`);
|
|
23266
|
+
}
|
|
23267
|
+
if (expose === true) {
|
|
23268
|
+
await publishToBook(id);
|
|
23269
|
+
done.push("published in the local contact book");
|
|
23270
|
+
} else if (expose === false) {
|
|
23271
|
+
unpublishFromBook(id.name);
|
|
23272
|
+
done.push("removed from the local contact book");
|
|
23273
|
+
}
|
|
23274
|
+
return textResult(`Updated "${id.name}": ${done.join("; ")}.`);
|
|
23275
|
+
} catch (e) {
|
|
23276
|
+
return textResult(`set_local_book_policy failed: ${String(e)}`, true);
|
|
23277
|
+
}
|
|
23278
|
+
}
|
|
23279
|
+
);
|
|
23280
|
+
server.tool(
|
|
23281
|
+
"respond_to_introduction",
|
|
23282
|
+
"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.",
|
|
23283
|
+
{
|
|
23284
|
+
contact: external_exports.string().min(1).describe("Pending introduction to act on (name or container id)."),
|
|
23285
|
+
action: external_exports.enum(["approve", "reject"]).describe("approve or reject.")
|
|
23286
|
+
},
|
|
23287
|
+
async ({ contact, action }) => {
|
|
23288
|
+
const { id, err } = boundOr();
|
|
23289
|
+
if (err) return err;
|
|
23290
|
+
try {
|
|
23291
|
+
if (action === "approve") {
|
|
23292
|
+
const data2 = await mutatingTx(id, "::actor::approve_introduction", { contact });
|
|
23293
|
+
const name2 = data2.Reduce("approved").Visualize();
|
|
23294
|
+
const cid = data2.Reduce("container_id").Visualize();
|
|
23295
|
+
const flushed = data2.Reduce("flushed").Visualize();
|
|
23296
|
+
refreshUnread(id);
|
|
23297
|
+
return textResult(
|
|
23298
|
+
`Approved "${name2}" (${cid}) \u2014 now a contact. ${flushed} queued message(s) moved to the inbox (read them with get_messages).`
|
|
23299
|
+
);
|
|
23300
|
+
}
|
|
23301
|
+
const data = await mutatingTx(id, "::actor::reject_introduction", { contact });
|
|
23302
|
+
const name = data.Reduce("rejected").Visualize();
|
|
23303
|
+
const dropped = data.Reduce("dropped_messages").Visualize();
|
|
23304
|
+
return textResult(`Rejected the introduction from "${name}" and dropped ${dropped} queued message(s).`);
|
|
23305
|
+
} catch (e) {
|
|
23306
|
+
return textResult(`respond_to_introduction failed: ${String(e)}`, true);
|
|
23307
|
+
}
|
|
23308
|
+
}
|
|
23309
|
+
);
|
|
23040
23310
|
server.tool(
|
|
23041
23311
|
"send_message",
|
|
23042
|
-
"Send an end-to-end-encrypted message to a known contact (by name or container id). Requires a bound identity.",
|
|
23312
|
+
"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.",
|
|
23043
23313
|
{
|
|
23044
23314
|
contact: external_exports.string().min(1).describe("Contact name or container id to send to."),
|
|
23045
23315
|
text: external_exports.string().min(1).describe("The message text.")
|
|
@@ -23051,13 +23321,21 @@ ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`)
|
|
|
23051
23321
|
await mutatingTx(id, "::actor::send_message", { contact, text });
|
|
23052
23322
|
return textResult(`Message sent to "${contact}".`);
|
|
23053
23323
|
} catch (e) {
|
|
23054
|
-
|
|
23324
|
+
if (!/Unknown contact/.test(String(e))) {
|
|
23325
|
+
return textResult(`send_message failed: ${String(e)}`, true);
|
|
23326
|
+
}
|
|
23327
|
+
try {
|
|
23328
|
+
const sent = await sendViaLocalBook(id, contact, text);
|
|
23329
|
+
return textResult(sent);
|
|
23330
|
+
} catch (e2) {
|
|
23331
|
+
return textResult(`send_message failed: ${String(e2)}`, true);
|
|
23332
|
+
}
|
|
23055
23333
|
}
|
|
23056
23334
|
}
|
|
23057
23335
|
);
|
|
23058
23336
|
server.tool(
|
|
23059
23337
|
"remove_contact",
|
|
23060
|
-
"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.",
|
|
23338
|
+
"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.",
|
|
23061
23339
|
{ contact: external_exports.string().min(1).describe("Contact name or container id to remove.") },
|
|
23062
23340
|
async ({ contact }) => {
|
|
23063
23341
|
const { id, err } = boundOr();
|
|
@@ -23234,6 +23512,7 @@ async function main() {
|
|
|
23234
23512
|
if (name && bindingOwner.get(name) === sid) bindingOwner.delete(name);
|
|
23235
23513
|
sessionBinding.delete(sid);
|
|
23236
23514
|
evictedSessions.delete(sid);
|
|
23515
|
+
persistBindings();
|
|
23237
23516
|
log(`session ${sid.slice(0, 8)}\u2026 closed`);
|
|
23238
23517
|
}
|
|
23239
23518
|
};
|
|
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.8.1",
|
|
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",
|