@uuaid/agent-commons 0.0.0 → 0.2.0-alpha.4
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/CONFORMANCE.md +26 -0
- package/LICENSE +201 -0
- package/NOTICE +22 -0
- package/PROVENANCE.json +24 -0
- package/README.md +404 -5
- package/bin/agent-commons.mjs +76 -0
- package/examples/Dockerfile +12 -0
- package/examples/agent.mjs +17 -0
- package/examples/global-policy.json +9 -0
- package/examples/run-cohort.mjs +68 -0
- package/package.json +31 -8
- package/profiles/agentnet.chat.json +11 -0
- package/profiles/local.json +11 -0
- package/profiles/zilligon.com.json +11 -0
- package/profiles/zilligons.com.json +11 -0
- package/src/carrier.mjs +84 -0
- package/src/channels.d.mts +75 -0
- package/src/channels.mjs +158 -0
- package/src/cohort.mjs +61 -0
- package/src/collaboration.mjs +875 -0
- package/src/config.mjs +40 -0
- package/src/continuity.d.mts +24 -0
- package/src/continuity.mjs +601 -0
- package/src/governance-scope.d.mts +46 -0
- package/src/governance-scope.mjs +139 -0
- package/src/governance.mjs +355 -0
- package/src/index.d.ts +111 -0
- package/src/index.mjs +15 -0
- package/src/loop.mjs +71 -0
- package/src/offline.d.mts +7 -0
- package/src/offline.mjs +29 -0
- package/src/pillar.mjs +59 -0
- package/src/profiles.d.mts +12 -0
- package/src/profiles.mjs +92 -0
- package/src/relay/auth.mjs +294 -0
- package/src/relay/board.mjs +223 -0
- package/src/relay/claims.mjs +25 -0
- package/src/relay/cli.mjs +49 -0
- package/src/relay/client.mjs +437 -0
- package/src/relay/docs/reconnect-v1.md +27 -0
- package/src/relay/docs/working-rules-v1.md +42 -0
- package/src/relay/enroll.mjs +232 -0
- package/src/relay/export-cli.mjs +47 -0
- package/src/relay/governance.mjs +1328 -0
- package/src/relay/mailbox.mjs +103 -0
- package/src/relay/records.mjs +146 -0
- package/src/relay/render.mjs +38 -0
- package/src/relay/roster.mjs +134 -0
- package/src/relay/routes.mjs +87 -0
- package/src/relay/schema.mjs +1081 -0
- package/src/relay/seed.mjs +89 -0
- package/src/relay/serve.mjs +116 -0
- package/src/relay/server.mjs +633 -0
- package/src/relay/signing.mjs +693 -0
- package/src/relay/test-mode.mjs +73 -0
- package/src/runtime.mjs +359 -0
- package/src/spaces/manifest.mjs +245 -0
- package/src/spaces/pillar-group.mjs +12 -0
- package/src/spaces/policy.mjs +229 -0
- package/src/spaces/store.mjs +325 -0
- package/src/store.mjs +73 -0
- package/src/transport.d.mts +76 -0
- package/src/transport.mjs +267 -0
- package/src/trust.mjs +104 -0
- package/src/uuaid-namespace.d.mts +6 -0
- package/src/uuaid-namespace.mjs +48 -0
- package/src/vendor/pillar/blob/blob.mjs +508 -0
- package/src/vendor/pillar/crypto/e2e.mjs +97 -0
- package/src/vendor/pillar/groups-v2.mjs +1108 -0
- package/src/vendor/pillar/groups.mjs +175 -0
- package/src/vendor/pillar/identity/jcs.mjs +34 -0
- package/src/vendor/pillar/identity/keychain.mjs +241 -0
- package/src/vendor/pillar/identity/tier.mjs +130 -0
- package/src/vendor/pillar/net/carrier-client.mjs +219 -0
- package/src/vendor/pillar/net/delegation.mjs +209 -0
- package/src/vendor/pillar/net/envelope.mjs +258 -0
- package/src/vendor/pillar/net/refusals.mjs +129 -0
- package/src/vendor/pillar/net/seed-tier.mjs +185 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Cloud Commons relay: the --test-mode hooks (spec v3.4.5 13.2 "Test mode").
|
|
2
|
+
//
|
|
3
|
+
// server.mjs loads this module only when the relay starts with --test-mode, and only after the
|
|
4
|
+
// conjunctive start refusal has passed (the test aud AND a loopback or named test-interface
|
|
5
|
+
// bind). A non-test build can leave this file out entirely (security invariant: its entrypoint must not carry the
|
|
6
|
+
// flag).
|
|
7
|
+
//
|
|
8
|
+
// - Clock: relay time is the integer (ms) in <dir>/clock, read on every use. The harness moves it;
|
|
9
|
+
// nothing in a request can set or move it.
|
|
10
|
+
// - Barriers: a closed list of named points in the request path. While <dir>/barriers/<name>.hold
|
|
11
|
+
// exists, a request reaching that point writes <dir>/barriers/<name>.reached and waits until the
|
|
12
|
+
// .hold file is gone. No request can name, create or clear a barrier.
|
|
13
|
+
// - Container-local only: <dir> is one absolute, normalized path given at start, never
|
|
14
|
+
// taken from a request, with no symlink anywhere in it (its realpath is itself). The directory and
|
|
15
|
+
// every file read from it must be a real directory or regular file (never a symlink, so no path is
|
|
16
|
+
// redirected elsewhere), owned by this process's user, and not writable by group or others. The
|
|
17
|
+
// harness asserts no host mounts (13.2).
|
|
18
|
+
|
|
19
|
+
import { lstatSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
21
|
+
import { RelayError } from "./records.mjs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The closed barrier list. Relay: before the record handler runs, and after it committed (before the
|
|
25
|
+
* response). Reference client (13.2: "the reference client takes the same barrier hooks"): after it
|
|
26
|
+
* read an inbox record and before handling it, after handling and before acknowledging, and before
|
|
27
|
+
* it drains its outbox.
|
|
28
|
+
*/
|
|
29
|
+
export const BARRIERS = Object.freeze(["before-dispatch", "after-commit", "client-after-read", "client-after-handle", "client-before-drain"]);
|
|
30
|
+
const POLL_MS = 10;
|
|
31
|
+
|
|
32
|
+
function privateEntry(path, kind) {
|
|
33
|
+
let st;
|
|
34
|
+
try { st = lstatSync(path); } catch { return null; }
|
|
35
|
+
const ok = (kind === "dir" ? st.isDirectory() : st.isFile()) && !st.isSymbolicLink()
|
|
36
|
+
&& (typeof process.getuid !== "function" || st.uid === process.getuid()) && (st.mode & 0o022) === 0;
|
|
37
|
+
if (!ok) throw new RelayError("test-mode-path", 500, `${kind} must be a private ${kind === "dir" ? "directory" : "regular file"} of this user, not a symlink`);
|
|
38
|
+
return st;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Open the hooks for one test directory. Throws (and the relay refuses to start) unless the
|
|
43
|
+
* directory and its clock file pass the checks above and the clock holds a valid time.
|
|
44
|
+
* Returns { now(), barrier(name, signal), dir }.
|
|
45
|
+
*/
|
|
46
|
+
export function openTestHooks(dir) {
|
|
47
|
+
if (typeof dir !== "string" || !isAbsolute(dir) || normalize(dir) !== dir || dir.length > 512) throw new RelayError("test-mode-path", 500, "test dir must be an absolute, normalized path");
|
|
48
|
+
if (!privateEntry(dir, "dir")) throw new RelayError("test-mode-path", 500, "test dir missing");
|
|
49
|
+
if (realpathSync(dir) !== dir) throw new RelayError("test-mode-path", 500, "test dir path must contain no symlink");
|
|
50
|
+
const clockPath = join(dir, "clock");
|
|
51
|
+
const barrierDir = join(dir, "barriers");
|
|
52
|
+
|
|
53
|
+
function now() {
|
|
54
|
+
if (!privateEntry(clockPath, "file")) throw new RelayError("test-clock", 500, "clock file missing");
|
|
55
|
+
const text = readFileSync(clockPath, "utf8");
|
|
56
|
+
if (!/^(0|[1-9][0-9]{0,15})\n?$/.test(text)) throw new RelayError("test-clock", 500, "clock file must hold one integer (ms)");
|
|
57
|
+
const t = Number(text.trim());
|
|
58
|
+
if (!Number.isSafeInteger(t)) throw new RelayError("test-clock", 500, "clock out of range");
|
|
59
|
+
return t;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Wait at a named point while its .hold file exists. Resolves at once when there is none. */
|
|
63
|
+
async function barrier(name, signal) {
|
|
64
|
+
if (!BARRIERS.includes(name)) throw new RelayError("test-barrier", 500, "unknown barrier");
|
|
65
|
+
const hold = join(barrierDir, `${name}.hold`);
|
|
66
|
+
if (!privateEntry(barrierDir, "dir") || !privateEntry(hold, "file")) return;
|
|
67
|
+
writeFileSync(join(barrierDir, `${name}.reached`), `${process.pid}\n`, { mode: 0o600 });
|
|
68
|
+
while (!signal?.aborted && privateEntry(hold, "file")) await new Promise(r => setTimeout(r, POLL_MS));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
now(); // refuse to start on a missing or invalid clock
|
|
72
|
+
return Object.freeze({ now, barrier, dir });
|
|
73
|
+
}
|
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Keychain, seal, open, decrypt, envelopeSha, CarrierClient, jcs } from "./pillar.mjs";
|
|
3
|
+
import { CommonsStore } from "./store.mjs";
|
|
4
|
+
import { RegistryTrust } from "./trust.mjs";
|
|
5
|
+
import { BASE_PROTOCOL, createProfile, verifyProfile, proposeAlias, encode, decode, digest, signDocument, verifyDocument, contribution } from "./profiles.mjs";
|
|
6
|
+
// Pluggable transport and channel/tenant admission
|
|
7
|
+
import { PillarCarrierTransport } from "./transport.mjs";
|
|
8
|
+
import { classifyThread, assertChannelSendAdmission, assertChannelReceiveAdmission, validateTenantBinding } from "./channels.mjs";
|
|
9
|
+
|
|
10
|
+
// D-12: only a string envelope id goes into an audit event; anything else a peer sends (absent, 1.5, an object) becomes null.
|
|
11
|
+
const auditEnvelopeId=id=>typeof id==="string"?id:null;
|
|
12
|
+
|
|
13
|
+
export class AgentCommons {
|
|
14
|
+
// options.transport and channel/tenant policy flags
|
|
15
|
+
constructor({keychain,store=new CommonsStore(),policy={mode:"local",agents:{}},carriers=[],transport,trust}={}){
|
|
16
|
+
if(!keychain?._identity)throw new Error("A loaded Pillar keychain is required");
|
|
17
|
+
this.keychain=keychain;this.store=store;this.policy=policy;this.trust=trust??new RegistryTrust({policy});this.carriers=carriers;
|
|
18
|
+
for(const carrier of carriers){const url=new URL(carrier);if(policy.mode==="global"&&url.protocol!=="https:")throw new Error("Global carrier URLs must use HTTPS");if(url.username||url.password||url.search||url.hash)throw new Error("Carrier URLs must not embed credentials, queries, or fragments")}
|
|
19
|
+
if(!store.verify())throw new Error("Audit integrity failure: runtime is fail-closed");
|
|
20
|
+
this.transport=transport??(carriers.length?new PillarCarrierTransport({keychain,carriers}):null);
|
|
21
|
+
this.channelsEnabled=policy?.channelsEnabled===true;
|
|
22
|
+
this._applyTail=Promise.resolve();this._receiveTail=Promise.resolve();this._flushTail=Promise.resolve();
|
|
23
|
+
}
|
|
24
|
+
get uuaid(){return this.keychain._identity.uuaid}
|
|
25
|
+
get publicKey(){return this.keychain._identity.publicKeyHex}
|
|
26
|
+
profiles(){return this.store.get("profiles",[])}
|
|
27
|
+
addProfile(profile){verifyProfile(profile);if(profile.scope==="global")throw new Error("Use importGlobal for published global profiles");const profiles=this.profiles();if(!profiles.some(p=>p.id===profile.id)){profiles.push(profile);this.store.set("profiles",profiles)}const active=this.store.get("active",{});if(!active[profile.namespace]){active[profile.namespace]=profile.id;this.store.set("active",active)}return profile}
|
|
28
|
+
profile(id){const profile=this.profiles().find(p=>p.id===id);if(!profile)throw new Error("Unknown local profile digest");verifyProfile(profile);return profile}
|
|
29
|
+
activeProfile(namespace){const active=this.store.get("active",{});const id=active[namespace]??this.profiles().filter(p=>p.namespace===namespace).sort((a,b)=>b.revision-a.revision)[0]?.id;if(!id)throw new Error("Unknown active profile namespace");return this.profile(id)}
|
|
30
|
+
async authorizedProfile(id){const profile=this.profile(id);if(profile.scope==="global"){const code=this.store.get("globalProfilePins",{})[id];if(!code)throw new Error("Global profile publication binding is missing");await this.trust.publishedProfilePin(code,id)}return profile}
|
|
31
|
+
async importGlobal(profile,code){verifyProfile(profile);if(profile.scope!=="global")throw new Error("Global profile scope required");await this.trust.publishedProfilePin(code,profile.id);const profiles=this.profiles();if(!profiles.some(p=>p.id===profile.id)){profiles.push(profile);this.store.set("profiles",profiles)}const pins=this.store.get("globalProfilePins",{});pins[profile.id]=code;this.store.set("globalProfilePins",pins);const active=this.store.get("active",{});active[profile.namespace]=profile.id;this.store.set("active",active);return profile}
|
|
32
|
+
async propose(profileId,phrase){
|
|
33
|
+
await this.trust.authorize(this.uuaid,this.publicKey,"commons:evolve");
|
|
34
|
+
const proposal=proposeAlias(this.profile(profileId),phrase);
|
|
35
|
+
const document=signDocument(this.keychain,"profile-proposal",{proposal});
|
|
36
|
+
await this.apply(document);return document;
|
|
37
|
+
}
|
|
38
|
+
async vote(proposalId,approve,rationale=""){
|
|
39
|
+
if(typeof approve!=="boolean"||typeof rationale!=="string"||rationale.length>2000)throw new Error("Invalid vote");
|
|
40
|
+
await this.trust.authorize(this.uuaid,this.publicKey,"commons:evolve");
|
|
41
|
+
return signDocument(this.keychain,"profile-vote",{proposalId,approve,rationale});
|
|
42
|
+
}
|
|
43
|
+
// Replay keys are namespaced (doc:/msg:sender:) so a peer-chosen message ID cannot poison a signed control document ID.
|
|
44
|
+
seenScoped(key,legacyId,hash){if(this.store.seen(key,hash))return true;try{return this.store.seen(legacyId,hash)}catch{return false}}
|
|
45
|
+
apply(document){const next=this._applyTail.then(()=>this.applyInternal(document));this._applyTail=next.catch(()=>{});return next}
|
|
46
|
+
async applyInternal(document){
|
|
47
|
+
const body=verifyDocument(document);
|
|
48
|
+
if(this.seenScoped(`doc:${document.id}`,document.id,digest(document)))return {duplicate:true};
|
|
49
|
+
if(!Number.isFinite(Date.parse(body.createdAt))||Math.abs(Date.now()-Date.parse(body.createdAt))>86400000)throw new Error("Stale profile document");
|
|
50
|
+
const capability=body.kind==="profile-contribution"?"commons:contribute":body.kind==="profile-recovery"?"commons:recover":"commons:evolve";
|
|
51
|
+
await this.trust.authorize(body.issuer,body.publicKey,capability);
|
|
52
|
+
const proposals=this.store.get("proposals",[]);
|
|
53
|
+
let result;
|
|
54
|
+
if(body.kind==="profile-proposal"){
|
|
55
|
+
const p=body.payload.proposal;
|
|
56
|
+
const base=this.profile(p.parent);
|
|
57
|
+
const recalculated=proposeAlias(base,p.phrase);
|
|
58
|
+
if(p.candidate?.id!==recalculated.candidate.id||p.alias!==recalculated.alias||p.status!=="pending"||Object.keys(p.votes??{}).length)throw new Error("Proposal evidence is not reproducible");
|
|
59
|
+
if(proposals.some(x=>x.id===p.id))throw new Error("Conflicting proposal ID");
|
|
60
|
+
if(typeof p.id!=="string"||!/^[0-9a-f-]{36}$/.test(p.id))throw new Error("Invalid proposal ID");
|
|
61
|
+
proposals.push({...p,author:body.issuer,authorPublicKey:body.publicKey,documentId:document.id});
|
|
62
|
+
result={proposalId:p.id,status:"pending"};
|
|
63
|
+
} else if(body.kind==="profile-vote"){
|
|
64
|
+
const {proposalId,approve}=body.payload;
|
|
65
|
+
const p=proposals.find(p=>p.id===proposalId);
|
|
66
|
+
if(!p||p.status!=="pending"||p.author===body.issuer||p.authorPublicKey===body.publicKey||Object.hasOwn(p.votes,body.issuer)||Object.values(p.votes).some(v=>v.document?.publicKey===body.publicKey)||typeof approve!=="boolean")throw new Error("Ineligible or duplicate peer vote");
|
|
67
|
+
p.votes[body.issuer]={approve,document};
|
|
68
|
+
const parent=this.profile(p.parent);
|
|
69
|
+
await this.trust.authorize(p.author,p.authorPublicKey,"commons:evolve");
|
|
70
|
+
// Recheck every previously approving peer: revoked or unadmitted votes
|
|
71
|
+
// cannot remain valid just because their old signature still verifies.
|
|
72
|
+
const approvals=[],approvalKeys=new Set();
|
|
73
|
+
for(const vote of Object.values(p.votes)){
|
|
74
|
+
if(!vote.approve)continue;
|
|
75
|
+
const voter=verifyDocument(vote.document);
|
|
76
|
+
await this.trust.authorize(voter.issuer,voter.publicKey,"commons:evolve");
|
|
77
|
+
// Quorum counts distinct public keys, never UUAID spellings, and never the proposer's own key.
|
|
78
|
+
if(voter.publicKey===p.authorPublicKey||approvalKeys.has(voter.publicKey))continue;
|
|
79
|
+
approvalKeys.add(voter.publicKey);approvals.push(voter.issuer);
|
|
80
|
+
}
|
|
81
|
+
if(approvals.length>=parent.quorum){
|
|
82
|
+
const latest=this.activeProfile(parent.namespace);
|
|
83
|
+
if(latest.id!==p.parent)throw new Error("Stale proposal cannot replace a newer local profile");
|
|
84
|
+
verifyProfile(p.candidate);
|
|
85
|
+
if(parent.scope==="global")throw new Error("Local votes cannot ratify global profile changes");
|
|
86
|
+
p.status="adopted";
|
|
87
|
+
}
|
|
88
|
+
result={proposalId:p.id,status:p.status,approvals:approvals.length};
|
|
89
|
+
} else if(body.kind==="profile-contribution"){
|
|
90
|
+
const candidate=body.payload.contribution;
|
|
91
|
+
if(candidate?.stage!=="candidate"||candidate?.base!==BASE_PROTOCOL)throw new Error("Invalid global contribution stage");
|
|
92
|
+
if(candidate.profile){verifyProfile(candidate.profile);if(candidate.profile.id!==candidate.profileId)throw new Error("Contribution digest mismatch")}
|
|
93
|
+
const queue=this.store.get("contributions",[]);
|
|
94
|
+
queue.push({id:document.id,issuer:body.issuer,candidate,stage:"awaiting-independent-review",ratified:false});
|
|
95
|
+
result={stage:"awaiting-independent-review",ratified:false};
|
|
96
|
+
this.store.transaction(()=>{this.store.set("contributions",queue);this.store.mark(`doc:${document.id}`,digest(document));this.store.append(document.id,{kind:body.kind,issuer:body.issuer,digest:digest(document),result})});return result;
|
|
97
|
+
} else if(body.kind==="profile-recovery"){
|
|
98
|
+
await this.trust.authorize(body.issuer,body.publicKey,"commons:recover");
|
|
99
|
+
const profile=this.profile(body.payload.profileId);
|
|
100
|
+
if(profile.scope==="global")throw new Error("Global recovery needs independent IAASO disposition, not a local rollback");
|
|
101
|
+
if(typeof body.payload.reason!=="string"||body.payload.reason.length<3)throw new Error("Recovery rationale required");
|
|
102
|
+
this.store.transaction(()=>{const active=this.store.get("active",{});active[profile.namespace]=profile.id;this.store.set("active",active);this.store.mark(`doc:${document.id}`,digest(document));this.store.append(document.id,{kind:body.kind,issuer:body.issuer,profileId:profile.id,reason:body.payload.reason})});
|
|
103
|
+
return {status:"local-profile-pinned",profileId:profile.id};
|
|
104
|
+
} else throw new Error("Unsupported profile control document");
|
|
105
|
+
this.store.transaction(()=>{
|
|
106
|
+
this.store.set("proposals",proposals);
|
|
107
|
+
const adopted=proposals.find(p=>p.id===result.proposalId&&p.status==="adopted");
|
|
108
|
+
if(adopted){if(!this.profiles().some(p=>p.id===adopted.candidate.id)){const profiles=this.profiles();profiles.push(adopted.candidate);this.store.set("profiles",profiles)}const active=this.store.get("active",{});active[adopted.candidate.namespace]=adopted.candidate.id;this.store.set("active",active)}
|
|
109
|
+
this.store.mark(`doc:${document.id}`,digest(document));this.store.append(document.id,{kind:body.kind,issuer:body.issuer,digest:digest(document),result});
|
|
110
|
+
});
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
async prepareContribution(profileId,options={}){
|
|
114
|
+
await this.trust.authorize(this.uuaid,this.publicKey,"commons:contribute");
|
|
115
|
+
return signDocument(this.keychain,"profile-contribution",{contribution:contribution(this.profile(profileId),options)});
|
|
116
|
+
}
|
|
117
|
+
// Channel/tenant admission, atomic authenticated admission record
|
|
118
|
+
async send({recipient,profileId,namespace,body,thread="commons",kind="message",deferFlush=false}){
|
|
119
|
+
if(typeof body!=="string"||Buffer.byteLength(body)>32000||typeof thread!=="string"||thread.length>120||!["message","profile-control"].includes(kind))throw new Error("Invalid bounded message");
|
|
120
|
+
const classified=classifyThread(thread,this.policy);
|
|
121
|
+
if(classified.ok!==true)throw new Error(classified.error);
|
|
122
|
+
if(classified.classification==="structured"){
|
|
123
|
+
assertChannelSendAdmission(classified.channelUri,this.uuaid,[recipient],this.policy);
|
|
124
|
+
}
|
|
125
|
+
const recipientKey=this.policy.agents?.[recipient]?.publicKey;
|
|
126
|
+
await this.trust.authorize(this.uuaid,this.publicKey,"commons:message");
|
|
127
|
+
await this.trust.authorize(recipient,recipientKey,"commons:message");
|
|
128
|
+
if(!profileId)profileId=this.activeProfile(namespace).id;
|
|
129
|
+
const profile=await this.authorizedProfile(profileId);
|
|
130
|
+
validateTenantBinding(this.uuaid,recipient,profile,this.policy);
|
|
131
|
+
const payload={v:BASE_PROTOCOL,id:randomUUID(),kind,thread,profileId:profile.id,wire:encode(body,profile.lexicon),bodyHash:digest(body),expiresAt:new Date(Date.now()+3600000).toISOString()};
|
|
132
|
+
const envelope=seal(this.keychain,{recipient,recipientPublicKey:recipientKey,kind:BASE_PROTOCOL,payload});
|
|
133
|
+
const tuple={id:envelope.id,sha:envelopeSha(envelope),thread,profileId:profile.id,sender:this.uuaid,recipient};
|
|
134
|
+
const signature=this.keychain.sign(Buffer.from(jcs(tuple))).toString("hex");
|
|
135
|
+
const meta={...tuple,signature};
|
|
136
|
+
this.store.transaction(()=>{
|
|
137
|
+
this.store.queue(envelope,profile.id);
|
|
138
|
+
this.store.set(`outbox_meta:${envelope.id}`,meta);
|
|
139
|
+
this.store.append(`admission-${envelope.id}`,{kind:"outbox-admission",id:envelope.id,sha:meta.sha,thread,profileId:profile.id,sender:this.uuaid,recipient,signature});
|
|
140
|
+
});
|
|
141
|
+
if(!this.transport||deferFlush)return {envelope,state:"pending",reason:deferFlush?"Queued for bounded batch delivery":"No configured carrier"};
|
|
142
|
+
const result=await this.flush();return {envelope,result};
|
|
143
|
+
}
|
|
144
|
+
async broadcast({recipients,...message}){
|
|
145
|
+
if(!Array.isArray(recipients)||!recipients.length||recipients.length>32||new Set(recipients).size!==recipients.length)throw new Error("Broadcast requires 1–32 distinct agent recipients");
|
|
146
|
+
for(const recipient of recipients)await this.trust.authorize(recipient,this.policy.agents?.[recipient]?.publicKey,"commons:message");
|
|
147
|
+
const queued=[];
|
|
148
|
+
for(const recipient of recipients)queued.push(await this.send({...message,recipient,deferFlush:true}));
|
|
149
|
+
return {queued:queued.map(x=>({recipient:x.envelope.recipient,id:x.envelope.id})),deliveries:await this.flush(),groupPrivacy:"individually-sealed-recipient-copies"};
|
|
150
|
+
}
|
|
151
|
+
flush(){const next=this._flushTail.then(()=>this.flushInternal());this._flushTail=next.catch(()=>{});return next}
|
|
152
|
+
// Pre-delivery rechecks, signature verification, and delivery
|
|
153
|
+
async flushInternal(){
|
|
154
|
+
if(!this.transport)return [];
|
|
155
|
+
const results=[];
|
|
156
|
+
for(let pageIndex=0;pageIndex<20;pageIndex++){
|
|
157
|
+
const page=this.store.pending();
|
|
158
|
+
if(!page.length)break;
|
|
159
|
+
let deliveredInPass=0;
|
|
160
|
+
for(const row of page){
|
|
161
|
+
try{
|
|
162
|
+
const envelope=JSON.parse(row.body);
|
|
163
|
+
if(!open(envelope).ok||envelope.sender!==this.uuaid||envelope.id!==row.id)throw new Error("Outbox integrity or sender mismatch");
|
|
164
|
+
const meta=this.store.get(`outbox_meta:${row.id}`);
|
|
165
|
+
if(!meta||meta.id!==row.id||meta.sha!==envelopeSha(envelope)||meta.profileId!==row.profile_id||meta.sender!==envelope.sender||meta.recipient!==envelope.recipient||typeof meta.thread!=="string"){
|
|
166
|
+
throw new Error("INVALID_OUTBOX_METADATA");
|
|
167
|
+
}
|
|
168
|
+
const signedTuple={id:row.id,sha:meta.sha,thread:meta.thread,profileId:meta.profileId,sender:meta.sender,recipient:meta.recipient};
|
|
169
|
+
const okSig=Keychain.verifyDetached(this.publicKey,Buffer.from(jcs(signedTuple)),Buffer.from(meta.signature,"hex"));
|
|
170
|
+
if(!okSig)throw new Error("INVALID_OUTBOX_METADATA");
|
|
171
|
+
const classified=classifyThread(meta.thread,this.policy);
|
|
172
|
+
if(classified.ok!==true)throw new Error(classified.error);
|
|
173
|
+
if(classified.classification==="structured"){
|
|
174
|
+
assertChannelSendAdmission(classified.channelUri,envelope.sender,[envelope.recipient],this.policy);
|
|
175
|
+
}
|
|
176
|
+
const profile=meta.profileId?await this.authorizedProfile(meta.profileId):(row.profile_id?await this.authorizedProfile(row.profile_id):null);
|
|
177
|
+
validateTenantBinding(envelope.sender,envelope.recipient,profile,this.policy);
|
|
178
|
+
await this.trust.authorize(this.uuaid,this.publicKey,"commons:message");
|
|
179
|
+
await this.trust.authorize(envelope.recipient,this.policy.agents?.[envelope.recipient]?.publicKey,"commons:message");
|
|
180
|
+
const receipt=await this.transport.deliver(envelope,{timeoutMs:10000});
|
|
181
|
+
if(receipt?.accepted!==true)throw new Error("Carrier rejected envelope");
|
|
182
|
+
this.store.delivered(row.id);
|
|
183
|
+
this.store.set(`outbox_meta:${row.id}`,null);
|
|
184
|
+
this.store.append(`outbox-${row.id}`,{kind:"carrier-accepted",id:row.id,sha:envelopeSha(envelope),carrier:receipt.carrier});
|
|
185
|
+
results.push({id:row.id,state:"carrier-accepted",receipt});
|
|
186
|
+
deliveredInPass++;
|
|
187
|
+
}
|
|
188
|
+
catch(error){
|
|
189
|
+
this.store.failure(row.id,error.message);
|
|
190
|
+
results.push({id:row.id,state:"retry-or-quarantine",error:error.code??error.message});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if(deliveredInPass===0)break;
|
|
194
|
+
}
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
receive(envelope){const next=this._receiveTail.then(()=>this.receiveInternal(envelope));this._receiveTail=next.catch(()=>{});return next}
|
|
198
|
+
// Inbound channel and tenant admission
|
|
199
|
+
async receiveInternal(envelope){
|
|
200
|
+
const valid=open(envelope);if(!valid.ok)throw new Error(`Invalid Pillar envelope: ${valid.reason}`);
|
|
201
|
+
if(envelope.delegation!==undefined)throw new Error("Delegated host admission requires a configured principal-status adapter; not yet enabled");
|
|
202
|
+
await this.trust.authorize(envelope.sender,envelope.transportSignature.publicKey,"commons:message");
|
|
203
|
+
const payload=decrypt(this.keychain,envelope);
|
|
204
|
+
if(payload?.v!==BASE_PROTOCOL||payload.profileId===undefined||!["message","profile-control"].includes(payload.kind)||typeof payload.wire!=="string"||Buffer.byteLength(payload.wire)>64000||typeof payload.id!=="string"||!/^[0-9a-f-]{36}$/.test(payload.id)||!Number.isFinite(Date.parse(payload.expiresAt))||Date.parse(payload.expiresAt)<=Date.now())throw new Error("Invalid or expired Agent Commons payload");
|
|
205
|
+
const msgKey=`msg:${envelope.sender}:${payload.id}`;if(this.seenScoped(msgKey,payload.id,digest(payload)))return {duplicate:true};
|
|
206
|
+
const profile=await this.authorizedProfile(payload.profileId),body=decode(payload.wire,profile.lexicon);
|
|
207
|
+
if(digest(body)!==payload.bodyHash)throw new Error("Lossless body digest mismatch");
|
|
208
|
+
const classified=classifyThread(payload.thread,this.policy);
|
|
209
|
+
if(classified.ok!==true)throw new Error(classified.error);
|
|
210
|
+
if(classified.classification==="structured"){
|
|
211
|
+
try{
|
|
212
|
+
assertChannelReceiveAdmission(classified.channelUri,envelope.sender,this.uuaid,this.policy);
|
|
213
|
+
}catch(err){
|
|
214
|
+
if(this.policy.holdBeforeAdmission!==true)throw err;
|
|
215
|
+
if(err.code!=="UNAUTHORIZED_CHANNEL_SENDER"&&err.code!=="UNAUTHORIZED_CHANNEL_RECEIVER")throw err;
|
|
216
|
+
if(payload.kind!=="message")throw err;
|
|
217
|
+
const agent=this.policy.agents?.[envelope.sender];
|
|
218
|
+
const admitted=agent&&agent.kind==="agent"&&Array.isArray(agent.capabilities)&&agent.capabilities.includes("commons:message")&&agent.publicKey===envelope.transportSignature?.publicKey;
|
|
219
|
+
if(!admitted)throw err;
|
|
220
|
+
const expired=this.store.pruneExpiredHeld();
|
|
221
|
+
for(const exp of expired){
|
|
222
|
+
this.store.append(randomUUID(),{kind:"hold-expired",id:exp.id,sender:exp.sender,channel:exp.channel,sha:exp.sha,reason:"expired"});
|
|
223
|
+
}
|
|
224
|
+
// a re-run of a still-ineligible held entry returns {held:true} here, so release keeps it without counting it again (C4)
|
|
225
|
+
if(this.store.isHeld(envelope.id,msgKey)){
|
|
226
|
+
return {held:true,id:envelope.id,sender:envelope.sender,channel:classified.channelUri};
|
|
227
|
+
}
|
|
228
|
+
const counts=this.store.holdCounts(classified.channelUri,envelope.sender);
|
|
229
|
+
let violatedBound=null;
|
|
230
|
+
if(counts.channelSender>=20)violatedBound="channel-sender";
|
|
231
|
+
else if(counts.sender>=20)violatedBound="sender";
|
|
232
|
+
else if(counts.channel>=100)violatedBound="channel";
|
|
233
|
+
else if(counts.total>=500)violatedBound="total";
|
|
234
|
+
if(violatedBound){
|
|
235
|
+
this.store.append(randomUUID(),{kind:"hold-refused",id:envelope.id,sender:envelope.sender,channel:classified.channelUri,sha:envelopeSha(envelope),reason:violatedBound});
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
const payloadExpires=Date.parse(payload.expiresAt);
|
|
239
|
+
const expiresAt=Math.min(Date.now()+600000,payloadExpires);
|
|
240
|
+
this.store.hold(envelope,msgKey,classified.channelUri,envelope.sender,envelopeSha(envelope),expiresAt);
|
|
241
|
+
this.store.append(randomUUID(),{kind:"hold-added",id:envelope.id,sender:envelope.sender,channel:classified.channelUri,sha:envelopeSha(envelope)});
|
|
242
|
+
return {held:true,id:envelope.id,sender:envelope.sender,channel:classified.channelUri};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
validateTenantBinding(envelope.sender,this.uuaid,profile,this.policy);
|
|
246
|
+
if(payload.kind==="profile-control"){
|
|
247
|
+
const result=await this.apply(JSON.parse(body));
|
|
248
|
+
this.store.transaction(()=>{this.store.mark(msgKey,digest(payload));this.store.append(`ctl:${envelope.sender}:${payload.id}`,{kind:"control-received",sender:envelope.sender,envelopeId:envelope.id,result})});
|
|
249
|
+
return {id:payload.id,kind:payload.kind,result};
|
|
250
|
+
}
|
|
251
|
+
this.store.transaction(()=>{this.store.mark(msgKey,digest(payload));this.store.append(`msg:${envelope.sender}:${payload.id}`,{kind:"message-received",sender:envelope.sender,envelopeId:envelope.id,profileId:profile.id,bodyHash:payload.bodyHash,wireBytes:Buffer.byteLength(payload.wire)})});
|
|
252
|
+
return {id:payload.id,sender:envelope.sender,thread:payload.thread,body,profileId:profile.id,accepted:true,receipt:{kind:"host-accepted",notProofOfTaskCompletion:true}};
|
|
253
|
+
}
|
|
254
|
+
releaseHeld(){const next=this._receiveTail.then(()=>this.releaseHeldInternal());this._receiveTail=next.catch(()=>{});return next}
|
|
255
|
+
async releaseHeldInternal(){
|
|
256
|
+
const heldEntries=this.store.allHeld();
|
|
257
|
+
const results=[];
|
|
258
|
+
const now=Date.now();
|
|
259
|
+
for(const entry of heldEntries){
|
|
260
|
+
if(entry.expires_at<=now){
|
|
261
|
+
this.store.removeHeld(entry.id);
|
|
262
|
+
this.store.append(randomUUID(),{kind:"hold-expired",id:entry.id,sender:entry.sender,channel:entry.channel,sha:entry.sha,reason:"expired"});
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const envelope=JSON.parse(entry.envelope);
|
|
266
|
+
const agent=this.policy.agents?.[entry.sender];
|
|
267
|
+
const eligible=agent&&agent.kind==="agent"&&Array.isArray(agent.capabilities)&&agent.capabilities.includes("commons:message")&&agent.publicKey===envelope.transportSignature?.publicKey;
|
|
268
|
+
if(!eligible){
|
|
269
|
+
this.store.removeHeld(entry.id);
|
|
270
|
+
this.store.append(randomUUID(),{kind:"hold-dropped",id:entry.id,sender:entry.sender,channel:entry.channel,sha:entry.sha,reason:"sender-not-admitted"});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
try{
|
|
274
|
+
const res=await this.receiveInternal(envelope);
|
|
275
|
+
if(res?.duplicate===true){
|
|
276
|
+
this.store.removeHeld(entry.id);
|
|
277
|
+
this.store.append(randomUUID(),{kind:"hold-released",id:entry.id,sender:entry.sender,channel:entry.channel,sha:entry.sha,reason:"duplicate-of-accepted"});
|
|
278
|
+
results.push(res);
|
|
279
|
+
} else if(res?.accepted===true){
|
|
280
|
+
this.store.removeHeld(entry.id);
|
|
281
|
+
this.store.append(randomUUID(),{kind:"hold-released",id:entry.id,sender:entry.sender,channel:entry.channel,sha:entry.sha,reason:"released"});
|
|
282
|
+
results.push(res);
|
|
283
|
+
}
|
|
284
|
+
}catch(error){
|
|
285
|
+
if(error?.transient===true){
|
|
286
|
+
this.store.append(randomUUID(),{kind:"hold-deferred",id:entry.id,sender:entry.sender,channel:entry.channel,sha:entry.sha,reason:error.code??error.message??"transient-error"});
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
this.store.removeHeld(entry.id);
|
|
290
|
+
this.store.append(randomUUID(),{kind:"hold-dropped",id:entry.id,sender:entry.sender,channel:entry.channel,sha:entry.sha,reason:error.code??error.message});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return results;
|
|
294
|
+
}
|
|
295
|
+
// Foreign item skip with cursor advance and recipient isolation
|
|
296
|
+
async poll(){
|
|
297
|
+
if(!this.transport)throw new Error("No carrier configured");
|
|
298
|
+
const cursors=this.store.get("cursors",{}),health=this.store.get("carrierHealth",{}),results=[];
|
|
299
|
+
const sources=this.transport?.sources?.()??this.carriers;
|
|
300
|
+
for(const carrier of sources){
|
|
301
|
+
if(health[carrier]?.nextProbeAt>Date.now()){results.push({carrier,rejected:true,state:"bounded-backoff"});continue}
|
|
302
|
+
let inbox;
|
|
303
|
+
try{inbox=await this.transport.fetchInbox(carrier,{since:cursors[carrier]??0,waitS:0,timeoutMs:10000});health[carrier]={failures:0,nextProbeAt:0};this.store.set("carrierHealth",health)}
|
|
304
|
+
catch(error){const failures=(health[carrier]?.failures??0)+1;health[carrier]={failures,nextProbeAt:Date.now()+Math.min(60000,1000*2**Math.min(failures,6)),error:error.code??"carrier-unavailable"};this.store.set("carrierHealth",health);this.store.append(randomUUID(),{kind:"carrier-poll-failed",carrier,failures,reason:error.code??"carrier-unavailable"});results.push({carrier,rejected:true,state:failures>=3?"circuit-backoff":"retry-backoff"});continue}
|
|
305
|
+
let last=cursors[carrier]??0;
|
|
306
|
+
for(const item of inbox.envelopes){
|
|
307
|
+
// A carrier may not rewind or repeat sequence numbers within a response.
|
|
308
|
+
if(!Number.isSafeInteger(item?.seq)||item.seq<=last){results.push({carrier,rejected:true,reason:"non-monotonic-carrier-sequence"});continue}
|
|
309
|
+
// D-12: open() can throw on peer input (it interpolates fields); a throw is one more invalid envelope, not a poll failure.
|
|
310
|
+
let valid;
|
|
311
|
+
try{valid=open(item?.envelope)}catch{valid={ok:false,reason:"validation-threw"}}
|
|
312
|
+
if(!valid.ok){
|
|
313
|
+
// D-12: the peer's id is data inside the event, never the audit key; the cursor moves only after the audit write lands.
|
|
314
|
+
const id=auditEnvelopeId(item?.envelope?.id),reason=`Invalid Pillar envelope: ${typeof valid.reason==="string"?valid.reason:"invalid"}`;
|
|
315
|
+
results.push({id,rejected:true,reason});
|
|
316
|
+
this.store.append(randomUUID(),{kind:"quarantined-inbound",id,reason});
|
|
317
|
+
last=item.seq;cursors[carrier]=item.seq;this.store.set("cursors",cursors);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if(item.envelope.recipient!==this.uuaid){
|
|
321
|
+
results.push({id:item.envelope.id,skipped:true,recipient:item.envelope.recipient});
|
|
322
|
+
last=item.seq;cursors[carrier]=item.seq;this.store.set("cursors",cursors);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// Held envelopes return { held: true }, advancing the cursor without writing quarantined-inbound (R6)
|
|
326
|
+
try{results.push(await this.receive(item.envelope))}
|
|
327
|
+
catch(error){
|
|
328
|
+
if(error?.transient===true){
|
|
329
|
+
// Trust backend outage is not a verdict on the sender: keep the cursor so the envelope is retried, and stop to preserve order.
|
|
330
|
+
const failures=(health[carrier]?.failures??0)+1;health[carrier]={failures,nextProbeAt:Date.now()+Math.min(60000,1000*2**Math.min(failures,6)),error:error.code??"trust-unavailable"};this.store.set("carrierHealth",health);
|
|
331
|
+
// D-12: JSON.stringify keeps the key one-to-one with id (null vs "null", lone surrogates), and the event is a
|
|
332
|
+
// function of id alone, so a retry rewrites the same row and two different ids never share a key. The
|
|
333
|
+
// "deferred/v2:" namespace keeps these keys apart from rows written as `deferred-${id}` before this change.
|
|
334
|
+
const id=auditEnvelopeId(item.envelope?.id);
|
|
335
|
+
this.store.append(`deferred/v2:${JSON.stringify(id)}`,{kind:"inbound-deferred-trust-unavailable",id});
|
|
336
|
+
results.push({id,rejected:false,deferred:true,state:"trust-unavailable-retry",reason:error.code??"trust-unavailable"});break;
|
|
337
|
+
}
|
|
338
|
+
// D-12: a thrown value need not carry a string message; never serialize it into the audit.
|
|
339
|
+
const id=auditEnvelopeId(item.envelope?.id),reason=typeof error?.message==="string"?error.message:"receive-failed";
|
|
340
|
+
results.push({id,rejected:true,reason});this.store.append(randomUUID(),{kind:"quarantined-inbound",id,reason});
|
|
341
|
+
}
|
|
342
|
+
last=item.seq;cursors[carrier]=item.seq;this.store.set("cursors",cursors);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return results;
|
|
346
|
+
}
|
|
347
|
+
async retryOutbox(envelopeId){
|
|
348
|
+
await this.trust.authorize(this.uuaid,this.publicKey,"commons:recover");
|
|
349
|
+
const row=this.store.db.prepare("SELECT * FROM outbox WHERE id=?").get(envelopeId);
|
|
350
|
+
if(!row||row.state!=="quarantined")throw new Error("Only a known quarantined outbox envelope can be retried");
|
|
351
|
+
const envelope=JSON.parse(row.body);
|
|
352
|
+
if(row.profile_id)await this.authorizedProfile(row.profile_id);
|
|
353
|
+
if(!open(envelope).ok||envelope.sender!==this.uuaid||Date.parse(envelope.createdAt)<Date.now()-3500000)throw new Error("Envelope is invalid or expired; create a fresh authorized message instead");
|
|
354
|
+
await this.trust.authorize(envelope.recipient,this.policy.agents?.[envelope.recipient]?.publicKey,"commons:message");
|
|
355
|
+
this.store.transaction(()=>{this.store.db.prepare("UPDATE outbox SET attempts=0,next_at=0,state='pending',error=NULL WHERE id=?").run(envelopeId);this.store.append(randomUUID(),{kind:"outbox-retry-authorized",envelopeId,issuer:this.uuaid})});
|
|
356
|
+
return {id:envelopeId,state:"pending",networkCallMade:false};
|
|
357
|
+
}
|
|
358
|
+
status(){return {name:"Agent Commons",protocol:BASE_PROTOCOL,uuaid:this.uuaid,publicKey:this.publicKey,mode:this.policy.mode??"local",profiles:this.profiles().map(p=>({id:p.id,name:p.name,namespace:p.namespace,scope:p.scope,revision:p.revision})),activeProfiles:this.store.get("active",{}),carriers:this.carriers,pending:this.store.pending().length,proposals:this.store.get("proposals",[]).length,contributions:this.store.get("contributions",[]).length,auditVerified:this.store.verify(),standardization:"draft-profile-not-IAASO-certified"}}
|
|
359
|
+
}
|