@ouro.bot/cli 0.1.0-alpha.987 → 0.1.0-alpha.989
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/changelog.json +16 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/a2a/client.js +10 -2
- package/dist/a2a/did-resolution.js +180 -68
- package/dist/a2a/inbound-share.js +24 -3
- package/dist/a2a/mission-result-wire.js +91 -22
- package/dist/a2a/outbox-client.js +2 -2
- package/dist/a2a/pin-store.js +45 -10
- package/dist/a2a/refusals.js +43 -0
- package/dist/a2a/sealed-chat.js +2 -1
- package/dist/a2a/seen-ledger.js +97 -7
- package/dist/a2a/server.js +26 -7
- package/dist/heart/daemon/cli-exec.js +9 -1
- package/dist/senses/a2a-entry.js +1 -0
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
package/changelog.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.989",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Agents now use friends 0.1.0-alpha.17. Every signed message is bound to its recipient, kind, a message id and its time, and a message without that binding is refused, so an old message cannot be replayed or resealed to another agent. A message counts as received only once its signature checks out, so a delivery that fails before that can be sent again.",
|
|
8
|
+
"Mission results get the same binding and replay checks as other messages, keyed on the whole sealed message rather than its nonce. A delegated command's id comes from the sender's DID, its signed message id and its signed time, and the refusal a sender gets for a rejected message now says what happened and what to do next.",
|
|
9
|
+
"Compatibility: an agent on this release refuses messages from agents whose harness uses friends 0.1.0-alpha.14 or older (they are unbound), and `ouro a2a` refuses their replies, saying the call may already have run. Upgrade every agent that talks to this one.",
|
|
10
|
+
"Key rotation follows friends' signed successor statement: the old key and the new key both sign it, and an accepted rotation moves the peer's friend record (with its trust) and its pins to the new DID before the message is judged, so the first message from the new DID is accepted. Delegated-command grants do not move: the operator grants again for the new DID. The old DID is kept as a retired pin that verifies nothing. If the record cannot move (another record already holds the new DID), the agent refuses the peer's messages, retries the move on each one, and sends the owner one notice with the command that fixes it. The friends wire has no carrier for successor statements yet, so a DID seen for the first time is still a first contact.",
|
|
11
|
+
"The seen-message ledger drops entries older than the 7-day freshness window when the agent starts and, while it runs, at most once an hour as new entries arrive. Message age limits can never be set longer than the ledger keeps entries. The CLI checks replies against a durable ledger, and `ouro friend link` says when a link was refused instead of reporting it as linked."
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.1.0-alpha.988",
|
|
16
|
+
"changes": [
|
|
17
|
+
"The work-protocol contract test gives the same result on every machine: it no longer compares against a sibling ouro-work-substrate clone when one happens to exist."
|
|
18
|
+
]
|
|
19
|
+
},
|
|
4
20
|
{
|
|
5
21
|
"version": "0.1.0-alpha.987",
|
|
6
22
|
"changes": [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.989</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
package/dist/a2a/client.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.A2ARpcError = void 0;
|
|
|
4
4
|
exports.cardDid = cardDid;
|
|
5
5
|
exports.endpointForCard = endpointForCard;
|
|
6
6
|
exports.fetchA2AAgentCard = fetchA2AAgentCard;
|
|
7
|
+
exports.replyRefusedError = replyRefusedError;
|
|
7
8
|
exports.postJsonRpc = postJsonRpc;
|
|
8
9
|
exports.sendA2AMessage = sendA2AMessage;
|
|
9
10
|
exports.postA2AMessageEnvelope = postA2AMessageEnvelope;
|
|
@@ -51,6 +52,13 @@ async function fetchA2AAgentCard(cardUrl, fetchImpl = fetch) {
|
|
|
51
52
|
});
|
|
52
53
|
return parsed;
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The error for a reply this side refused to open. The request was already delivered, so the peer may have acted on it
|
|
57
|
+
* (an older peer replies unbound, which is refused): say so, so the caller does not resend a command and run it twice.
|
|
58
|
+
*/
|
|
59
|
+
function replyRefusedError(what, reason) {
|
|
60
|
+
return new Error(`A2A ${what} reply rejected: ${reason}; the call may already have run on the peer`);
|
|
61
|
+
}
|
|
54
62
|
/** A JSON-RPC error answer from the peer. `data` carries the peer's structured detail (for a refused delegated command: reason, nothingRan, retry, next). */
|
|
55
63
|
class A2ARpcError extends Error {
|
|
56
64
|
code;
|
|
@@ -242,9 +250,9 @@ async function sendSealedA2AChat(input) {
|
|
|
242
250
|
const replyMessage = task?.status?.message;
|
|
243
251
|
if (!replyMessage)
|
|
244
252
|
throw new Error("A2A chat returned no reply message");
|
|
245
|
-
const opened = await (0, sealed_chat_1.openChatMessage)({ sodium, self: input.identity, message: replyMessage, senderDid: peerDid, senderEd25519Pub: parsed.ed25519Pub });
|
|
253
|
+
const opened = await (0, sealed_chat_1.openChatMessage)({ sodium, self: input.identity, message: replyMessage, senderDid: peerDid, senderEd25519Pub: parsed.ed25519Pub, ...(input.seen ? { seen: input.seen } : {}) });
|
|
246
254
|
if (!opened.ok)
|
|
247
|
-
throw
|
|
255
|
+
throw replyRefusedError("chat", opened.reason);
|
|
248
256
|
(0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_chat_send_end", message: "received sealed A2A chat reply", meta: { endpointUrl, peerDid, taskId: task.id } });
|
|
249
257
|
return { text: opened.text, conversationId, taskId: task.id, peerDid, peerName: card.name };
|
|
250
258
|
}
|
|
@@ -2,49 +2,193 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.makeDidResolution = makeDidResolution;
|
|
4
4
|
const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
6
|
const runtime_1 = require("../nerves/runtime");
|
|
7
|
+
/** Why a record could not move, in words an owner can read. A reason a later friends adds fails the type check. */
|
|
8
|
+
const MOVE_FAILURE_WORDS = {
|
|
9
|
+
rotation_not_accepted: "this agent's saved keys do not show the change cleanly",
|
|
10
|
+
rotation_chain_invalid: "its chain of key changes loops or is too long",
|
|
11
|
+
record_not_found: "its friend record was not found under any of its earlier keys",
|
|
12
|
+
successor_already_linked: "another friend record already holds the new key",
|
|
13
|
+
};
|
|
14
|
+
/** At most this many earlier DIDs are followed back from a rotated pin (friends' `MAX_ROTATION_HOPS`). */
|
|
15
|
+
const MAX_PREDECESSOR_HOPS = 16;
|
|
16
|
+
function refused(fromAgentId, reason, message) {
|
|
17
|
+
(0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_did_resolve_refused", message, meta: { fromAgentId, reason } });
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
6
20
|
/**
|
|
7
|
-
* Build the friends `DidResolution` for did:key peers. `resolveAndPin` is the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* The pin is keyed by the STABLE `fromAgentId` (NOT the did:key string), so a
|
|
17
|
-
* rotation is "same `fromAgentId`, new (did, key)". An unparseable or empty DID is
|
|
18
|
-
* refused (null) and never matchable. The crypto is the friends primitives' — this
|
|
19
|
-
* only sequences them.
|
|
21
|
+
* Build the friends `DidResolution` for did:key peers. `resolveAndPin` is the verify-and-pin core the inbound bridge
|
|
22
|
+
* consumes. friends calls it with the SIGNED sender DID as both `fromAgentId` and `did`, so pins are keyed by DID:
|
|
23
|
+
* unparseable or empty DID, or a retired pin → refused (null)
|
|
24
|
+
* pinned → the pinned key (a did:key's key cannot change under the same DID)
|
|
25
|
+
* not pinned, with a successor statement → `evaluateRotation` from the old DID (at the OLD record's trust);
|
|
26
|
+
* accepted: `applyAcceptedRotation` moves the record, then the new key
|
|
27
|
+
* rejected: refused, never a silent first contact
|
|
28
|
+
* not pinned, no statement → first contact (TOFU `pinOnFirstContact`)
|
|
29
|
+
* The receive paths call `settleSender` first, so by the time `resolveAndPin` runs a rotation is already applied.
|
|
20
30
|
*/
|
|
21
31
|
function makeDidResolution(input) {
|
|
22
|
-
const { sodium,
|
|
32
|
+
const { sodium, store, successorFor } = input;
|
|
33
|
+
const toldOwner = new Set();
|
|
34
|
+
/** Tell the owner, once per rotation, that the record could not follow and how to fix it. */
|
|
35
|
+
function tellOwner(oldDid, newDid, record, holder, reason) {
|
|
36
|
+
const notify = input.notifyOwner;
|
|
37
|
+
const key = `${oldDid}>${newDid}`;
|
|
38
|
+
if (!notify || toldOwner.has(key))
|
|
39
|
+
return;
|
|
40
|
+
toldOwner.add(key);
|
|
41
|
+
const agent = input.agentName ?? "<agent>";
|
|
42
|
+
const [cause, command] = holder
|
|
43
|
+
? [
|
|
44
|
+
`Its new key is already linked to another friend record, ${holder.name} (${holder.id}). If that is the same peer, merge the two records`,
|
|
45
|
+
`ouro friend merge --agent ${agent} --into ${record.id} ${holder.id} --apply`,
|
|
46
|
+
]
|
|
47
|
+
: [
|
|
48
|
+
`The record could not move because ${MOVE_FAILURE_WORDS[reason]}. Link the record to the new key`,
|
|
49
|
+
`ouro friend link ${agent} --friend ${record.id} --provider a2a-agent --external-id ${newDid}`,
|
|
50
|
+
];
|
|
51
|
+
// The command sits on its own line with nothing after it, so it can be copied as is.
|
|
52
|
+
const notice = {
|
|
53
|
+
noticeId: `a2a-rotation-unmoved:${(0, node_crypto_1.createHash)("sha256").update(key).digest("hex").slice(0, 32)}`,
|
|
54
|
+
text: [
|
|
55
|
+
`${record.name} changed its A2A key to ${newDid}, but its friend record could not move to the new key, so this agent refuses its messages until this is fixed.`,
|
|
56
|
+
`${cause}. Run:`,
|
|
57
|
+
command,
|
|
58
|
+
`${record.name}'s next message then moves the record.`,
|
|
59
|
+
].join("\n\n"),
|
|
60
|
+
};
|
|
61
|
+
notify(notice).catch((error) => {
|
|
62
|
+
(0, runtime_1.emitNervesEvent)({
|
|
63
|
+
level: "warn",
|
|
64
|
+
component: "channels",
|
|
65
|
+
event: "channel.a2a_rotation_notice_failed",
|
|
66
|
+
message: "could not tell the owner that a rotated peer's record did not move",
|
|
67
|
+
meta: { oldDid, newDid, error: error instanceof Error ? error.message : String(error) },
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Move the peer's record along the rotation chain to `newDid`. On failure, warn and tell the owner. */
|
|
72
|
+
async function moveRecord(friends, pinStore, oldDid, newDid, record) {
|
|
73
|
+
const moved = await (0, a2a_client_1.applyAcceptedRotation)({ store: friends, pinStore, oldDid, newDid });
|
|
74
|
+
if (moved.ok)
|
|
75
|
+
return true;
|
|
76
|
+
// The pins moved but the friend record did not follow (another record already holds the new DID, or the chain is
|
|
77
|
+
// broken). Refuse rather than run the message under a record that does not carry the old trust.
|
|
78
|
+
(0, runtime_1.emitNervesEvent)({
|
|
79
|
+
level: "warn",
|
|
80
|
+
component: "channels",
|
|
81
|
+
event: "channel.a2a_rotation_record_not_moved",
|
|
82
|
+
message: "A2A key rotation verified but the friend record could not follow; refusing the message",
|
|
83
|
+
meta: { oldDid, newDid, reason: moved.reason },
|
|
84
|
+
});
|
|
85
|
+
tellOwner(oldDid, newDid, record, await friends.findByExternalId("a2a-agent", newDid), moved.reason);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
/** Judge a successor statement for an unpinned DID and, when accepted, move the record. */
|
|
89
|
+
async function rotateIn(pinStore, did, ed25519Pub, statement) {
|
|
90
|
+
// A rotation is judged at the trust of the peer the OLD DID belongs to: the new DID itself is still unknown.
|
|
91
|
+
const oldRecord = store ? await store.findByExternalId("a2a-agent", statement.oldDid) : null;
|
|
92
|
+
const oldTrust = oldRecord?.trustLevel ?? "stranger";
|
|
93
|
+
const decision = (0, a2a_client_1.evaluateRotation)({
|
|
94
|
+
sodium,
|
|
95
|
+
pinStore,
|
|
96
|
+
fromAgentId: statement.oldDid,
|
|
97
|
+
trustOfSource: oldTrust,
|
|
98
|
+
newDid: did,
|
|
99
|
+
newEd25519Pub: ed25519Pub,
|
|
100
|
+
rotationProof: statement.rotationProof,
|
|
101
|
+
successorProof: statement.successorProof,
|
|
102
|
+
issuedAt: statement.issuedAt,
|
|
103
|
+
});
|
|
104
|
+
if (decision.decision !== "accepted") {
|
|
105
|
+
(0, runtime_1.emitNervesEvent)({
|
|
106
|
+
level: "warn",
|
|
107
|
+
component: "channels",
|
|
108
|
+
event: "channel.a2a_rotation_rejected",
|
|
109
|
+
message: "A2A key rotation rejected",
|
|
110
|
+
/* v8 ignore next -- the new DID has no pin, so evaluateRotation cannot call it unchanged @preserve */
|
|
111
|
+
meta: { oldDid: statement.oldDid, newDid: did, trustOfSource: oldTrust, reason: decision.decision === "rejected" ? decision.reason : decision.decision },
|
|
112
|
+
});
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
// Accepted means the old record is family or friend, which was read from `store`, so both are present.
|
|
116
|
+
if (!(await moveRecord(store, pinStore, statement.oldDid, did, oldRecord)))
|
|
117
|
+
return false;
|
|
118
|
+
(0, runtime_1.emitNervesEvent)({
|
|
119
|
+
component: "channels",
|
|
120
|
+
event: "channel.a2a_rotation_accepted",
|
|
121
|
+
message: "A2A signed key rotation accepted",
|
|
122
|
+
meta: { oldDid: statement.oldDid, newDid: did, trustOfSource: oldTrust },
|
|
123
|
+
});
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A pin that a rotation moved to `did`: if the peer's record still sits at an earlier DID on the chain, the move
|
|
128
|
+
* failed earlier, so retry it. Returns false while the record still cannot follow.
|
|
129
|
+
*/
|
|
130
|
+
async function finishMove(pinStore, did) {
|
|
131
|
+
if (!store || typeof pinStore.predecessorOf !== "function")
|
|
132
|
+
return true;
|
|
133
|
+
const chain = [];
|
|
134
|
+
for (let prev = pinStore.predecessorOf(did); prev !== undefined && chain.length < MAX_PREDECESSOR_HOPS; prev = pinStore.predecessorOf(prev)) {
|
|
135
|
+
chain.unshift(prev);
|
|
136
|
+
}
|
|
137
|
+
for (const earlier of chain) {
|
|
138
|
+
const record = await store.findByExternalId("a2a-agent", earlier);
|
|
139
|
+
if (!record)
|
|
140
|
+
continue;
|
|
141
|
+
(0, runtime_1.emitNervesEvent)({
|
|
142
|
+
component: "channels",
|
|
143
|
+
event: "channel.a2a_rotation_record_retry",
|
|
144
|
+
message: "a rotated peer's friend record is still at its old DID; retrying the move",
|
|
145
|
+
meta: { oldDid: earlier, newDid: did },
|
|
146
|
+
});
|
|
147
|
+
if (!(await moveRecord(store, pinStore, chain[0], did, record)))
|
|
148
|
+
return false;
|
|
149
|
+
(0, runtime_1.emitNervesEvent)({
|
|
150
|
+
component: "channels",
|
|
151
|
+
event: "channel.a2a_rotation_record_moved",
|
|
152
|
+
message: "moved a rotated peer's friend record to its new DID",
|
|
153
|
+
meta: { oldDid: earlier, newDid: did },
|
|
154
|
+
});
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
23
159
|
return {
|
|
160
|
+
async settleSender({ did, pinStore }) {
|
|
161
|
+
const parsed = (0, a2a_client_1.parseDidKey)(did);
|
|
162
|
+
// An unusable DID is refused by `resolveAndPin` itself.
|
|
163
|
+
if (!parsed)
|
|
164
|
+
return true;
|
|
165
|
+
const pinned = pinStore.get(did);
|
|
166
|
+
if (pinned)
|
|
167
|
+
return pinned.rotatedAt !== undefined && pinned.retiredBy === undefined ? finishMove(pinStore, did) : true;
|
|
168
|
+
const statement = successorFor?.(did, parsed.ed25519Pub);
|
|
169
|
+
return statement ? rotateIn(pinStore, did, parsed.ed25519Pub, statement) : true;
|
|
170
|
+
},
|
|
24
171
|
async resolveAndPin(args) {
|
|
25
|
-
const { fromAgentId, did, pinStore
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
(0, runtime_1.emitNervesEvent)({
|
|
29
|
-
component: "channels",
|
|
30
|
-
event: "channel.a2a_did_resolve_refused",
|
|
31
|
-
message: "A2A DID resolve refused (empty DID)",
|
|
32
|
-
meta: { fromAgentId, reason: "empty_did" },
|
|
33
|
-
});
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
172
|
+
const { fromAgentId, did, pinStore } = args;
|
|
173
|
+
if (!did)
|
|
174
|
+
return refused(fromAgentId, "empty_did", "A2A DID resolve refused (empty DID)");
|
|
36
175
|
const parsed = (0, a2a_client_1.parseDidKey)(did);
|
|
37
|
-
if (!parsed)
|
|
38
|
-
(
|
|
39
|
-
component: "channels",
|
|
40
|
-
event: "channel.a2a_did_resolve_refused",
|
|
41
|
-
message: "A2A DID resolve refused (unparseable did:key)",
|
|
42
|
-
meta: { fromAgentId, reason: "parse_failed" },
|
|
43
|
-
});
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
176
|
+
if (!parsed)
|
|
177
|
+
return refused(fromAgentId, "parse_failed", "A2A DID resolve refused (unparseable did:key)");
|
|
46
178
|
const pinned = pinStore.get(fromAgentId);
|
|
47
|
-
|
|
179
|
+
// A retired pin is a tombstone: the old key must never verify again (friends requires hosts to enforce this).
|
|
180
|
+
if (pinned?.retiredBy !== undefined)
|
|
181
|
+
return refused(fromAgentId, "retired_pin", "A2A DID resolve refused (the DID was rotated away)");
|
|
182
|
+
if (pinned) {
|
|
183
|
+
const decision = (0, a2a_client_1.evaluateRotation)({ sodium, pinStore, fromAgentId, trustOfSource: args.trustOfSource, newDid: did, newEd25519Pub: parsed.ed25519Pub });
|
|
184
|
+
if (decision.decision === "unchanged")
|
|
185
|
+
return { ed25519Pub: parsed.ed25519Pub };
|
|
186
|
+
// A did:key encodes its key, so a differing pin means the pin was filed under another DID (or edited by hand).
|
|
187
|
+
/* v8 ignore next -- evaluateRotation only returns "accepted" with a proof, and none is passed here @preserve */
|
|
188
|
+
return refused(fromAgentId, decision.decision === "rejected" ? decision.reason : "key_changed", "A2A DID resolve refused (pinned key differs)");
|
|
189
|
+
}
|
|
190
|
+
const statement = successorFor?.(did, parsed.ed25519Pub);
|
|
191
|
+
if (!statement) {
|
|
48
192
|
// First contact (TOFU): accept + pin the (did, key) under fromAgentId.
|
|
49
193
|
(0, a2a_client_1.pinOnFirstContact)({ pinStore, fromAgentId, did, ed25519Pub: parsed.ed25519Pub });
|
|
50
194
|
(0, runtime_1.emitNervesEvent)({
|
|
@@ -55,39 +199,7 @@ function makeDidResolution(input) {
|
|
|
55
199
|
});
|
|
56
200
|
return { ed25519Pub: parsed.ed25519Pub };
|
|
57
201
|
}
|
|
58
|
-
|
|
59
|
-
const rotationProof = rotationProofFor?.(fromAgentId, did, parsed.ed25519Pub);
|
|
60
|
-
const decision = (0, a2a_client_1.evaluateRotation)({
|
|
61
|
-
sodium,
|
|
62
|
-
pinStore,
|
|
63
|
-
fromAgentId,
|
|
64
|
-
trustOfSource,
|
|
65
|
-
newDid: did,
|
|
66
|
-
newEd25519Pub: parsed.ed25519Pub,
|
|
67
|
-
rotationProof,
|
|
68
|
-
});
|
|
69
|
-
if (decision.decision === "unchanged") {
|
|
70
|
-
// Same key — pin-hit. (FileA2APinStore.get already emitted a2a_pin_hit.)
|
|
71
|
-
return { ed25519Pub: parsed.ed25519Pub };
|
|
72
|
-
}
|
|
73
|
-
if (decision.decision === "accepted") {
|
|
74
|
-
// evaluateRotation already re-pinned to the new key under fromAgentId.
|
|
75
|
-
(0, runtime_1.emitNervesEvent)({
|
|
76
|
-
component: "channels",
|
|
77
|
-
event: "channel.a2a_rotation_accepted",
|
|
78
|
-
message: "A2A signed key rotation accepted",
|
|
79
|
-
meta: { fromAgentId, newDid: did, trustOfSource },
|
|
80
|
-
});
|
|
81
|
-
return { ed25519Pub: parsed.ed25519Pub };
|
|
82
|
-
}
|
|
83
|
-
// Rejected — loud security signal (carries the reason).
|
|
84
|
-
(0, runtime_1.emitNervesEvent)({
|
|
85
|
-
component: "channels",
|
|
86
|
-
event: "channel.a2a_rotation_rejected",
|
|
87
|
-
message: "A2A key rotation rejected",
|
|
88
|
-
meta: { fromAgentId, newDid: did, trustOfSource, reason: decision.reason },
|
|
89
|
-
});
|
|
90
|
-
return null;
|
|
202
|
+
return (await rotateIn(pinStore, did, parsed.ed25519Pub, statement)) ? { ed25519Pub: parsed.ed25519Pub } : null;
|
|
91
203
|
},
|
|
92
204
|
};
|
|
93
205
|
}
|
|
@@ -4,6 +4,7 @@ exports.receiveInboundShare = receiveInboundShare;
|
|
|
4
4
|
const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
|
|
5
5
|
const friends_1 = require("@ouro.bot/friends");
|
|
6
6
|
const runtime_1 = require("../nerves/runtime");
|
|
7
|
+
const seen_ledger_1 = require("./seen-ledger");
|
|
7
8
|
/** Whether the inbound message carries a friends data part (kind:"data"). */
|
|
8
9
|
function hasDataPart(message) {
|
|
9
10
|
return Array.isArray(message.parts) && message.parts.some((p) => p && p.kind === "data");
|
|
@@ -20,11 +21,17 @@ function hasDataPart(message) {
|
|
|
20
21
|
* verify the signature; the real auth gate is the `DidVerifier` inside
|
|
21
22
|
* `receiveShare`, so a forged-high-trust claim here is harmless — the forge is
|
|
22
23
|
* rejected by the verifier regardless of the trust we read.)
|
|
23
|
-
* 3. `
|
|
24
|
+
* 3. `settleSender(verifiedDid)` — apply a signed rotation to that DID, or finish
|
|
25
|
+
* one whose record did not follow, BEFORE trust is read, so the first message
|
|
26
|
+
* after a rotation is judged against the moved record. A rejected rotation, or
|
|
27
|
+
* a record that still cannot follow ⇒ `resolve_failed`, nothing consumed. (A
|
|
28
|
+
* successor statement carries its own two signatures, so applying it before
|
|
29
|
+
* this message's signature is checked gives a forger nothing.)
|
|
30
|
+
* 4. `findFriendByDid(store, verifiedDid)` — read trust off that record; an
|
|
24
31
|
* unknown DID ⇒ `stranger` (never a default-up).
|
|
25
|
-
*
|
|
32
|
+
* 5. `receiveShare({ ..., trustOfSource })` — re-unseal (deterministic) + resolve
|
|
26
33
|
* & pin + run `DidVerifier` (forge fails HERE) + replay-dedup + import.
|
|
27
|
-
*
|
|
34
|
+
* 6. Map the result. `completed` ⇒ run the turn at `verifiedDid`/`trust`.
|
|
28
35
|
*/
|
|
29
36
|
async function receiveInboundShare(message, deps) {
|
|
30
37
|
const friendsMessage = message;
|
|
@@ -64,6 +71,15 @@ async function receiveInboundShare(message, deps) {
|
|
|
64
71
|
return { outcome: "rejected", reason: opened.error };
|
|
65
72
|
}
|
|
66
73
|
const verifiedDid = opened.fromAgentId;
|
|
74
|
+
if (!(await deps.didResolution.settleSender({ did: verifiedDid, pinStore: deps.pinStore }))) {
|
|
75
|
+
(0, runtime_1.emitNervesEvent)({
|
|
76
|
+
component: "channels",
|
|
77
|
+
event: "channel.a2a_inbound_rejected",
|
|
78
|
+
message: "rejected inbound A2A share (the sender's key rotation could not be applied)",
|
|
79
|
+
meta: { reason: "resolve_failed", verifiedDid },
|
|
80
|
+
});
|
|
81
|
+
return { outcome: "rejected", reason: "resolve_failed" };
|
|
82
|
+
}
|
|
67
83
|
// Read trust from the friend store BY the verified DID — never defaulted, never
|
|
68
84
|
// from the envelope. Unknown (or empty) DID ⇒ stranger. `findFriendByDid` itself
|
|
69
85
|
// refuses a falsy did (never matches a did-less record), so this is safe to call
|
|
@@ -81,6 +97,9 @@ async function receiveInboundShare(message, deps) {
|
|
|
81
97
|
recipientDid: deps.identity.did,
|
|
82
98
|
recipientIdentity,
|
|
83
99
|
trustOfSource: trust,
|
|
100
|
+
// Every sender on friends alpha.15 or later binds its envelopes to recipient, kind, id and time; an unbound
|
|
101
|
+
// envelope can be replayed or re-sealed to another recipient, so it is refused.
|
|
102
|
+
options: { ...(0, seen_ledger_1.bindingWindowsWithinRetention)(deps.bindingOptions), rejectUnboundEnvelopes: true },
|
|
84
103
|
});
|
|
85
104
|
if (result.state === "completed") {
|
|
86
105
|
(0, runtime_1.emitNervesEvent)({
|
|
@@ -96,6 +115,8 @@ async function receiveInboundShare(message, deps) {
|
|
|
96
115
|
friendsKind: result.friendsKind,
|
|
97
116
|
status: result.status,
|
|
98
117
|
...(result.message ? { message: result.message } : {}),
|
|
118
|
+
// Unbound envelopes are refused above, so a completed receipt always carries its signed binding id.
|
|
119
|
+
bindingId: result.bindingId,
|
|
99
120
|
/* v8 ignore next -- a completed receipt implies a trusted friend record: an unknown DID reads as stranger and is refused @preserve */
|
|
100
121
|
...(record ? { friend: record } : {}),
|
|
101
122
|
};
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.wrapMissionResultDataPart = wrapMissionResultDataPart;
|
|
4
4
|
exports.isMissionResultDataPart = isMissionResultDataPart;
|
|
5
|
+
exports.sealedBlobKey = sealedBlobKey;
|
|
5
6
|
exports.receiveInboundMissionResult = receiveInboundMissionResult;
|
|
6
7
|
const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
|
|
7
8
|
const friends_1 = require("@ouro.bot/friends");
|
|
8
9
|
const runtime_1 = require("../nerves/runtime");
|
|
10
|
+
const seen_ledger_1 = require("./seen-ledger");
|
|
9
11
|
/**
|
|
10
12
|
* THE HARNESS-OWNED RESULT WIRE.
|
|
11
13
|
*
|
|
@@ -33,7 +35,7 @@ const runtime_1 = require("../nerves/runtime");
|
|
|
33
35
|
* 3. hand `importMissionResult` a REAL `DidVerifier` bound to the pinned key, so
|
|
34
36
|
* `verifier.verify(fromAgentId, proof)` runs the Ed25519 signature check against
|
|
35
37
|
* the pinned key — NOT the no-op `tofuVerifier` the importer defaults to.
|
|
36
|
-
* Replay is deduped on the
|
|
38
|
+
* Replay is deduped on a hash of the whole sealed blob (durable `SeenLedger`), matching the share path.
|
|
37
39
|
*/
|
|
38
40
|
const OURO_RESULT_KIND = "mission_result";
|
|
39
41
|
/** Wrap a sealed result envelope into the harness-owned `mission_result` DataPart. */
|
|
@@ -73,24 +75,52 @@ function unwrapMissionResultDataPart(message) {
|
|
|
73
75
|
return null;
|
|
74
76
|
return { sealed: { v: data.v, sealed: data.sealed }, recipientDid: data.recipientDid };
|
|
75
77
|
}
|
|
78
|
+
/** The ledger key for one sealed blob: a hash over its ephemeral key, nonce and ciphertext. Same form as friends'
|
|
79
|
+
* `receiveShare` key, so a blob accepted on either path is a replay on the other. */
|
|
80
|
+
function sealedBlobKey(sodium, blob) {
|
|
81
|
+
const digest = sodium.crypto_generichash(24, `${blob.ePk}|${blob.n}|${blob.ct}`, null);
|
|
82
|
+
return `blob:${sodium.to_base64(digest, sodium.base64_variants.URLSAFE_NO_PADDING)}`;
|
|
83
|
+
}
|
|
84
|
+
/** Results being processed, per seen ledger: a parallel duplicate is refused as `in_flight` (retryable) without
|
|
85
|
+
* touching the durable ledger, matching friends' `receiveShare`. */
|
|
86
|
+
const IN_FLIGHT = new WeakMap();
|
|
87
|
+
function inFlightFor(seen) {
|
|
88
|
+
let set = IN_FLIGHT.get(seen);
|
|
89
|
+
if (!set) {
|
|
90
|
+
set = new Set();
|
|
91
|
+
IN_FLIGHT.set(seen, set);
|
|
92
|
+
}
|
|
93
|
+
return set;
|
|
94
|
+
}
|
|
95
|
+
function rejectResult(reason, message, meta = {}) {
|
|
96
|
+
(0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_result_rejected", message, meta: { reason, ...meta } });
|
|
97
|
+
return { outcome: "rejected", reason };
|
|
98
|
+
}
|
|
76
99
|
/**
|
|
77
100
|
* Route an inbound `mission_result` DataPart to `importMissionResult`, AUTHENTICATING
|
|
78
101
|
* the sender exactly like `receiveShare` does for every other inbound kind.
|
|
79
102
|
*
|
|
80
103
|
* Ordering (mirrors the share bridge's trust trap + authentication gate):
|
|
81
104
|
* 1. recognize + unwrap the harness `mission_result` carrier (else `not-a-result`).
|
|
82
|
-
* 2. replay dedup on the
|
|
105
|
+
* 2. replay dedup on a hash of the whole sealed blob, then the in-flight claim on
|
|
106
|
+
* that hash. The bare seal nonce is never consulted.
|
|
83
107
|
* 3. `openSealedEnvelope` → AEAD-decrypt only (unseal/recipient failures → reject).
|
|
84
|
-
* 4. binding
|
|
108
|
+
* 4. sender binding — reject unless `opened.signerDid === opened.fromAgentId` (and
|
|
85
109
|
* non-empty): the advisory `signerDid` (outer, unsigned) must match the claimed
|
|
86
110
|
* sender. A divergence is a forged/spoofed bundle (`sender_binding_mismatch`).
|
|
87
|
-
* 5.
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
111
|
+
* 5. a retired sender pin → `retired_pin`.
|
|
112
|
+
* 6. the signed envelope binding (`checkEnvelopeBinding`: this recipient, this kind,
|
|
113
|
+
* a fresh issue time, an unseen `mid:` message id), then claim the blob hash and
|
|
114
|
+
* the `mid:` key in flight (released on every exit).
|
|
115
|
+
* 7. `settleSender` — apply a signed rotation to the sender DID, or finish one whose
|
|
116
|
+
* record did not follow, BEFORE trust is read (refused → `resolve_failed`).
|
|
117
|
+
* 8. read trust by the sender DID, then `resolveAndPin` it (TOFU pin) → the pinned
|
|
118
|
+
* key (unresolvable → `resolve_failed`).
|
|
119
|
+
* 9. build a `DidVerifier` bound to THIS envelope + the pinned key and call
|
|
120
|
+
* `importMissionResult({ envelope, fromAgentId: <signed DID>, trustOfSource },
|
|
121
|
+
* { verifier })`. The verifier runs the REAL signature check against the pinned
|
|
122
|
+
* key and marks the claimed keys seen only when it passes; a result signed with
|
|
123
|
+
* any other key is rejected (`untrusted_source`). Every authorization gate
|
|
94
124
|
* (no_mission / no_delegation / assignee_mismatch, fail-closed on a legacy
|
|
95
125
|
* assignee-less delegation) is still enforced by the importer.
|
|
96
126
|
*/
|
|
@@ -98,16 +128,17 @@ async function receiveInboundMissionResult(message, deps) {
|
|
|
98
128
|
const payload = unwrapMissionResultDataPart(message);
|
|
99
129
|
if (!payload)
|
|
100
130
|
return { outcome: "not-a-result" };
|
|
101
|
-
// Replay dedup BEFORE any state change, keyed on the
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
});
|
|
109
|
-
return { outcome: "rejected", reason: "replayed" };
|
|
131
|
+
// Replay dedup BEFORE any state change, keyed only on a hash of the whole sealed blob. The sender picks the seal
|
|
132
|
+
// nonce, and friends' share path marks a bare nonce for any share whose signature verifies, so reading the bare nonce
|
|
133
|
+
// here would let a stranger's share or result burn a real result. A re-sealed copy of the same signed result is still
|
|
134
|
+
// refused by its `mid:` key, and re-importing a result changes nothing (the first result per request wins).
|
|
135
|
+
const blobKey = sealedBlobKey(deps.sodium, payload.sealed.sealed);
|
|
136
|
+
if (deps.seen.isSeen(blobKey)) {
|
|
137
|
+
return rejectResult("replayed", "rejected inbound mission_result (already received)");
|
|
110
138
|
}
|
|
139
|
+
const inFlight = inFlightFor(deps.seen);
|
|
140
|
+
if (inFlight.has(blobKey))
|
|
141
|
+
return rejectResult("in_flight", "rejected inbound mission_result (the same result is being processed)");
|
|
111
142
|
const recipientIdentity = { x25519Priv: deps.identity.x25519Priv, x25519Pub: deps.identity.x25519Pub };
|
|
112
143
|
const opened = (0, a2a_client_1.openSealedEnvelope)({
|
|
113
144
|
sodium: deps.sodium,
|
|
@@ -140,6 +171,34 @@ async function receiveInboundMissionResult(message, deps) {
|
|
|
140
171
|
});
|
|
141
172
|
return { outcome: "rejected", reason: "sender_binding_mismatch" };
|
|
142
173
|
}
|
|
174
|
+
// A retired pin is a tombstone: the old DID's key verifies nothing (matches receiveShare).
|
|
175
|
+
if (deps.pinStore.get(senderDid)?.retiredBy !== undefined) {
|
|
176
|
+
return rejectResult("retired_pin", "rejected inbound mission_result (the sender's DID was rotated away)", { verifiedDid: senderDid });
|
|
177
|
+
}
|
|
178
|
+
// The signed binding: this recipient, this kind, a fresh issue time and an unseen message id.
|
|
179
|
+
const binding = (0, a2a_client_1.checkEnvelopeBinding)(opened, { ...(0, seen_ledger_1.bindingWindowsWithinRetention)(deps.bindingOptions), rejectUnboundEnvelopes: true, recipientDid: deps.identity.did, seen: deps.seen });
|
|
180
|
+
if (!binding.ok)
|
|
181
|
+
return rejectResult(binding.reason, "rejected inbound mission_result (signed binding)", { verifiedDid: senderDid });
|
|
182
|
+
/* v8 ignore next -- an unbound envelope is refused above, so a passing binding always carries its seen key @preserve */
|
|
183
|
+
const claimed = binding.seenKey ? [blobKey, binding.seenKey] : [blobKey];
|
|
184
|
+
/* v8 ignore next -- the same binding id under a different seal nonce needs a hand-built seal (sealEnvelope mints a fresh id) @preserve */
|
|
185
|
+
if (claimed.some((key) => inFlight.has(key)))
|
|
186
|
+
return rejectResult("in_flight", "rejected inbound mission_result (the same result is being processed)");
|
|
187
|
+
for (const key of claimed)
|
|
188
|
+
inFlight.add(key);
|
|
189
|
+
try {
|
|
190
|
+
return await processClaimedResult(opened, senderDid, claimed, deps);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
for (const key of claimed)
|
|
194
|
+
inFlight.delete(key);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function processClaimedResult(opened, senderDid, claimed, deps) {
|
|
198
|
+
// Settle a rotation to this DID first, so the first result after it is judged against the moved record.
|
|
199
|
+
if (!(await deps.didResolution.settleSender({ did: senderDid, pinStore: deps.pinStore }))) {
|
|
200
|
+
return rejectResult("resolve_failed", "rejected inbound mission_result (the sender's key rotation could not be applied)", { verifiedDid: senderDid });
|
|
201
|
+
}
|
|
143
202
|
// Read trust from the friend store BY the verified DID — never defaulted, never from
|
|
144
203
|
// the envelope. Unknown (or empty) DID ⇒ stranger.
|
|
145
204
|
const record = await (0, friends_1.findFriendByDid)(deps.store, senderDid);
|
|
@@ -164,14 +223,24 @@ async function receiveInboundMissionResult(message, deps) {
|
|
|
164
223
|
// single authentication gate (the importer would otherwise default to the no-op TOFU
|
|
165
224
|
// verifier). `verify` confirms the agentId===did binding AND the Ed25519 signature
|
|
166
225
|
// over the envelope against the pinned key.
|
|
167
|
-
const
|
|
226
|
+
const didVerifier = new a2a_client_1.DidVerifier({
|
|
168
227
|
sodium: deps.sodium,
|
|
169
228
|
pinnedEd25519Pub: resolved.ed25519Pub,
|
|
170
229
|
pinnedDid: senderDid,
|
|
171
230
|
envelope: opened.envelope,
|
|
172
231
|
});
|
|
173
|
-
//
|
|
174
|
-
|
|
232
|
+
// The result is consumed the moment its signature verifies (and not before), so a failure before that leaves it
|
|
233
|
+
// redeliverable. The keys are the whole-blob hash and the sender-scoped `mid:` id, so no other sender's message,
|
|
234
|
+
// share or result, can burn this one by reusing its seal nonce.
|
|
235
|
+
const verifier = {
|
|
236
|
+
verify(fromAgentId, proof) {
|
|
237
|
+
const ok = didVerifier.verify(fromAgentId, proof);
|
|
238
|
+
if (ok)
|
|
239
|
+
for (const key of claimed)
|
|
240
|
+
deps.seen.markSeen(key);
|
|
241
|
+
return ok;
|
|
242
|
+
},
|
|
243
|
+
};
|
|
175
244
|
const result = await (0, friends_1.importMissionResult)(deps.missionStore, {
|
|
176
245
|
envelope: opened.envelope,
|
|
177
246
|
fromAgentId: senderDid,
|
|
@@ -40,9 +40,9 @@ async function callOutboxMethod(input) {
|
|
|
40
40
|
const reply = rpc.result;
|
|
41
41
|
if (!reply || !Array.isArray(reply.parts))
|
|
42
42
|
throw new Error("A2A outbox call returned no reply message");
|
|
43
|
-
const opened = await (0, sealed_chat_1.openChatMessage)({ sodium, self: input.identity, message: reply, senderDid: peerDid, senderEd25519Pub: parsed.ed25519Pub });
|
|
43
|
+
const opened = await (0, sealed_chat_1.openChatMessage)({ sodium, self: input.identity, message: reply, senderDid: peerDid, senderEd25519Pub: parsed.ed25519Pub, ...(input.seen ? { seen: input.seen } : {}) });
|
|
44
44
|
if (!opened.ok)
|
|
45
|
-
throw
|
|
45
|
+
throw (0, client_1.replyRefusedError)("outbox", opened.reason);
|
|
46
46
|
let result;
|
|
47
47
|
try {
|
|
48
48
|
result = JSON.parse(opened.text);
|
package/dist/a2a/pin-store.js
CHANGED
|
@@ -42,6 +42,14 @@ const runtime_1 = require("../nerves/runtime");
|
|
|
42
42
|
function pinFileName(fromAgentId) {
|
|
43
43
|
return `${(0, node_crypto_1.createHash)("sha256").update(fromAgentId).digest("hex")}.json`;
|
|
44
44
|
}
|
|
45
|
+
function pinnedFromStored(stored, ed25519Pub) {
|
|
46
|
+
return {
|
|
47
|
+
did: stored.did,
|
|
48
|
+
ed25519Pub,
|
|
49
|
+
...(typeof stored.rotatedAt === "string" ? { rotatedAt: stored.rotatedAt } : {}),
|
|
50
|
+
...(typeof stored.retiredBy === "string" ? { retiredBy: stored.retiredBy } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
45
53
|
class FileA2APinStore {
|
|
46
54
|
dir;
|
|
47
55
|
cache = new Map();
|
|
@@ -79,15 +87,7 @@ class FileA2APinStore {
|
|
|
79
87
|
if (!fromAgentId)
|
|
80
88
|
return;
|
|
81
89
|
const existed = this.cache.has(fromAgentId);
|
|
82
|
-
|
|
83
|
-
// fromAgentId after a rotation), not fromAgentId itself.
|
|
84
|
-
const stored = {
|
|
85
|
-
fromAgentId,
|
|
86
|
-
did: pinned.did,
|
|
87
|
-
pinnedKey: (0, a2a_client_1.base58btcEncode)(pinned.ed25519Pub),
|
|
88
|
-
};
|
|
89
|
-
fs.writeFileSync(path.join(this.dir, pinFileName(fromAgentId)), `${JSON.stringify(stored, null, 2)}\n`, "utf-8");
|
|
90
|
-
this.cache.set(fromAgentId, { did: pinned.did, ed25519Pub: pinned.ed25519Pub });
|
|
90
|
+
this.write(fromAgentId, pinned);
|
|
91
91
|
(0, runtime_1.emitNervesEvent)({
|
|
92
92
|
component: "channels",
|
|
93
93
|
event: "channel.a2a_pin_set",
|
|
@@ -95,6 +95,41 @@ class FileA2APinStore {
|
|
|
95
95
|
meta: { fromAgentId, did: pinned.did, rotated: existed },
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
|
+
/** Keep a tombstone under `fromAgentId`: the old DID and key, marked retired in favour of `retiredBy`. friends
|
|
99
|
+
* calls this after it moves the pin to the successor DID; `receiveShare` then refuses the old DID as `retired_pin`. */
|
|
100
|
+
retire(fromAgentId, retiredBy, rotatedAt) {
|
|
101
|
+
const pinned = this.cache.get(fromAgentId);
|
|
102
|
+
if (!fromAgentId || !pinned)
|
|
103
|
+
return;
|
|
104
|
+
this.write(fromAgentId, { ...pinned, retiredBy, rotatedAt });
|
|
105
|
+
(0, runtime_1.emitNervesEvent)({
|
|
106
|
+
level: "warn",
|
|
107
|
+
component: "channels",
|
|
108
|
+
event: "channel.a2a_pin_retired",
|
|
109
|
+
message: "retired an A2A pin after a verified rotation",
|
|
110
|
+
meta: { fromAgentId, retiredBy, rotatedAt },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** The DID whose retired pin names `did` as its successor, if any (a rotation chain followed backwards). */
|
|
114
|
+
predecessorOf(did) {
|
|
115
|
+
for (const [fromAgentId, pinned] of this.cache) {
|
|
116
|
+
if (pinned.retiredBy === did)
|
|
117
|
+
return fromAgentId;
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
/** Persist a pin (its DID, which may differ from fromAgentId, its key, and the rotation fields) and cache it. */
|
|
122
|
+
write(fromAgentId, pinned) {
|
|
123
|
+
const stored = {
|
|
124
|
+
fromAgentId,
|
|
125
|
+
did: pinned.did,
|
|
126
|
+
pinnedKey: (0, a2a_client_1.base58btcEncode)(pinned.ed25519Pub),
|
|
127
|
+
...(pinned.rotatedAt !== undefined ? { rotatedAt: pinned.rotatedAt } : {}),
|
|
128
|
+
...(pinned.retiredBy !== undefined ? { retiredBy: pinned.retiredBy } : {}),
|
|
129
|
+
};
|
|
130
|
+
fs.writeFileSync(path.join(this.dir, pinFileName(fromAgentId)), `${JSON.stringify(stored, null, 2)}\n`, "utf-8");
|
|
131
|
+
this.cache.set(fromAgentId, pinnedFromStored(stored, pinned.ed25519Pub));
|
|
132
|
+
}
|
|
98
133
|
/** Reload every persisted pin from disk into the in-memory cache (the restart
|
|
99
134
|
* replay). Corrupt/undecodable files are skipped so one bad file can never
|
|
100
135
|
* wedge the store. */
|
|
@@ -125,7 +160,7 @@ class FileA2APinStore {
|
|
|
125
160
|
const ed25519Pub = (0, a2a_client_1.base58btcDecode)(stored.pinnedKey);
|
|
126
161
|
if (!ed25519Pub)
|
|
127
162
|
continue;
|
|
128
|
-
this.cache.set(key,
|
|
163
|
+
this.cache.set(key, pinnedFromStored(stored, ed25519Pub));
|
|
129
164
|
}
|
|
130
165
|
}
|
|
131
166
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.receiveRefusal = receiveRefusal;
|
|
4
|
+
exports.receiveRefusalError = receiveRefusalError;
|
|
5
|
+
const runtime_1 = require("../nerves/runtime");
|
|
6
|
+
const UPGRADE = "Upgrade the sending agent's harness (ouro) and send again.";
|
|
7
|
+
const RESEND = "Send it again as a new message.";
|
|
8
|
+
const REFUSALS = {
|
|
9
|
+
malformed_message: { message: "the message is not a sealed A2A data part", next: UPGRADE, retry: false },
|
|
10
|
+
malformed_plaintext: { message: "the sealed message did not open to a valid envelope", next: UPGRADE, retry: false },
|
|
11
|
+
unseal_failed: { message: "the message could not be opened with this agent's key", next: "Fetch this agent's card again (its key may have changed) and resend.", retry: false },
|
|
12
|
+
recipient_mismatch: { message: "the message was sealed to a different agent", next: "Check the recipient's DID and resend.", retry: false },
|
|
13
|
+
signed_recipient_mismatch: { message: "the signed envelope names a different recipient", next: "Check the recipient's DID and resend.", retry: false },
|
|
14
|
+
signed_kind_mismatch: { message: "the signed envelope's kind does not match how it was sealed", next: UPGRADE, retry: false },
|
|
15
|
+
sender_binding_mismatch: { message: "the claimed sender does not match the signer", next: "Send from the identity the message claims.", retry: false },
|
|
16
|
+
malformed_binding: { message: "the envelope's signed binding is malformed", next: UPGRADE, retry: false },
|
|
17
|
+
unbound_envelope: { message: "the envelope has no signed binding (the sender runs an older harness)", next: UPGRADE, retry: false },
|
|
18
|
+
unbound_delegation: { message: "the delegated command has no signed binding (the sender runs an older harness)", next: UPGRADE, retry: false },
|
|
19
|
+
stale_envelope: { message: "the envelope is more than 7 days old or dated in the future", next: `Check the sender's clock. ${RESEND}`, retry: false },
|
|
20
|
+
stale_delegation: { message: "the delegated command is more than 10 minutes old", next: `Check the sender's clock. ${RESEND}`, retry: false },
|
|
21
|
+
replayed: { message: "this message was already received", next: `Nothing to do if it was delivered; otherwise ${RESEND.toLowerCase()}`, retry: false },
|
|
22
|
+
in_flight: { message: "the same message is being handled right now", next: "Retry in a moment.", retry: true },
|
|
23
|
+
resolve_failed: { message: "the sender's DID could not be resolved or pinned (it is not a did:key, its pinned key differs, or a key rotation was refused)", next: "Send from the identity this agent knows you by, or ask the operator to re-link you.", retry: false },
|
|
24
|
+
retired_pin: { message: "the sender's DID was rotated to a successor and no longer verifies", next: "Send from the successor identity.", retry: false },
|
|
25
|
+
bad_signature: { message: "the signature does not verify against the sender's pinned key", next: "Send from the key this agent pinned for you, or ask the operator to re-link you.", retry: false },
|
|
26
|
+
untrusted_source: { message: "this agent does not trust the sender enough for this kind of message", next: "Ask the operator to connect you as a friend.", retry: false },
|
|
27
|
+
import_failed: { message: "the message verified but could not be stored", next: RESEND, retry: false },
|
|
28
|
+
// Mission results (the harness result wire).
|
|
29
|
+
no_mission: { message: "this agent has no mission with that key", next: "Check the mission key; the delegator may have dropped the mission.", retry: false },
|
|
30
|
+
no_delegation: { message: "this agent delegated no task with that request id", next: "Check the request id the delegation gave you.", retry: false },
|
|
31
|
+
assignee_mismatch: { message: "the task was delegated to a different agent", next: "Only the assignee can send this result.", retry: false },
|
|
32
|
+
};
|
|
33
|
+
const UNKNOWN = { message: "the message was refused", next: "Check the agent's logs.", retry: false };
|
|
34
|
+
/** The wording for one refusal reason (an unknown reason gets a generic line). */
|
|
35
|
+
function receiveRefusal(reason) {
|
|
36
|
+
return Object.hasOwn(REFUSALS, reason) ? REFUSALS[reason] : UNKNOWN;
|
|
37
|
+
}
|
|
38
|
+
/** The JSON-RPC error text and data for a refused inbound message: `A2A <what> rejected: <reason>. <message>. Next: <next>`. */
|
|
39
|
+
function receiveRefusalError(what, reason) {
|
|
40
|
+
const refusal = receiveRefusal(reason);
|
|
41
|
+
(0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_receive_refused", message: "refused an inbound A2A message", meta: { what, reason, retry: refusal.retry } });
|
|
42
|
+
return { message: `A2A ${what} rejected: ${reason}. ${refusal.message}. Next: ${refusal.next}`, data: { reason, retry: refusal.retry, next: refusal.next } };
|
|
43
|
+
}
|
package/dist/a2a/sealed-chat.js
CHANGED
|
@@ -78,12 +78,13 @@ async function openChatMessage(input) {
|
|
|
78
78
|
return pinned ? { ed25519Pub: pinned.ed25519Pub } : null;
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
|
-
seen: new MemorySeenLedger(),
|
|
81
|
+
seen: input.seen ?? new MemorySeenLedger(),
|
|
82
82
|
a2aMessage: input.message,
|
|
83
83
|
recipientDid: input.self.did,
|
|
84
84
|
recipientIdentity: { x25519Priv: input.self.x25519Priv, x25519Pub: input.self.x25519Pub },
|
|
85
85
|
// The opener chose this sender; the signature, not this level, is the gate.
|
|
86
86
|
trustOfSource: "family",
|
|
87
|
+
options: { rejectUnboundEnvelopes: true, ...(input.now !== undefined ? { now: input.now } : {}) },
|
|
87
88
|
});
|
|
88
89
|
if (result.state === "rejected") {
|
|
89
90
|
(0, runtime_1.emitNervesEvent)({
|
package/dist/a2a/seen-ledger.js
CHANGED
|
@@ -33,21 +33,65 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.FileA2ASeenLedger = void 0;
|
|
36
|
+
exports.FileA2ASeenLedger = exports.SEEN_PRUNE_INTERVAL_MS = exports.SEEN_RETENTION_MS = exports.MAX_FUTURE_SKEW_MS = exports.MAX_ENVELOPE_AGE_MS = void 0;
|
|
37
|
+
exports.bindingWindowsWithinRetention = bindingWindowsWithinRetention;
|
|
38
|
+
exports.openPrivateSeenLedger = openPrivateSeenLedger;
|
|
37
39
|
const fs = __importStar(require("node:fs"));
|
|
38
40
|
const path = __importStar(require("node:path"));
|
|
39
41
|
const node_crypto_1 = require("node:crypto");
|
|
40
42
|
const runtime_1 = require("../nerves/runtime");
|
|
43
|
+
/**
|
|
44
|
+
* How long a seen entry must be kept: friends accepts an envelope until 7 days after its `issuedAt`, and an `issuedAt`
|
|
45
|
+
* at most 2 minutes ahead of the clock, so an entry marked longer ago than both can never match an acceptable
|
|
46
|
+
* envelope again (`DEFAULT_MAX_AGE_MS` plus `DEFAULT_MAX_FUTURE_SKEW_MS` in friends `envelope-binding`).
|
|
47
|
+
*/
|
|
48
|
+
exports.MAX_ENVELOPE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
49
|
+
exports.MAX_FUTURE_SKEW_MS = 2 * 60 * 1000;
|
|
50
|
+
exports.SEEN_RETENTION_MS = exports.MAX_ENVELOPE_AGE_MS + exports.MAX_FUTURE_SKEW_MS;
|
|
51
|
+
/** While the agent runs, expired entries are dropped when a new one is marked, at most this often. */
|
|
52
|
+
exports.SEEN_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
|
53
|
+
/**
|
|
54
|
+
* Keep configured age windows inside what the seen ledger remembers. An envelope accepted later than the ledger keeps
|
|
55
|
+
* its entry could be replayed, so any age window above 7 days, or a future skew above 2 minutes, is clamped to that
|
|
56
|
+
* limit with a warning.
|
|
57
|
+
*/
|
|
58
|
+
function bindingWindowsWithinRetention(options) {
|
|
59
|
+
const clamped = { ...options };
|
|
60
|
+
const changed = [];
|
|
61
|
+
for (const [field, limit] of [["maxAgeMs", exports.MAX_ENVELOPE_AGE_MS], ["delegatedMaxAgeMs", exports.MAX_ENVELOPE_AGE_MS], ["maxFutureSkewMs", exports.MAX_FUTURE_SKEW_MS]]) {
|
|
62
|
+
const value = clamped[field];
|
|
63
|
+
if (value !== undefined && value > limit) {
|
|
64
|
+
clamped[field] = limit;
|
|
65
|
+
changed.push(field);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (changed.length > 0) {
|
|
69
|
+
(0, runtime_1.emitNervesEvent)({
|
|
70
|
+
level: "warn",
|
|
71
|
+
component: "channels",
|
|
72
|
+
event: "channel.a2a_binding_window_clamped",
|
|
73
|
+
message: "A2A message age limits were set longer than the replay ledger keeps entries (7 days, plus 2 minutes of clock skew), so they were shortened to those limits",
|
|
74
|
+
meta: { fields: changed },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return clamped;
|
|
78
|
+
}
|
|
41
79
|
function nonceFileName(nonce) {
|
|
42
80
|
return `${(0, node_crypto_1.createHash)("sha256").update(nonce).digest("hex")}.json`;
|
|
43
81
|
}
|
|
44
82
|
class FileA2ASeenLedger {
|
|
45
83
|
dir;
|
|
46
|
-
|
|
47
|
-
|
|
84
|
+
/** Each seen key with when it was marked (NaN: undated, kept) and the file that holds it. */
|
|
85
|
+
cache = new Map();
|
|
86
|
+
now;
|
|
87
|
+
lastPrunedAt;
|
|
88
|
+
constructor(agentRoot, options = {}) {
|
|
48
89
|
this.dir = path.join(agentRoot, "state", "a2a", "seen");
|
|
90
|
+
this.now = options.now ?? Date.now;
|
|
49
91
|
fs.mkdirSync(this.dir, { recursive: true });
|
|
92
|
+
this.lastPrunedAt = this.now();
|
|
50
93
|
this.loadExisting();
|
|
94
|
+
this.prune(this.lastPrunedAt);
|
|
51
95
|
(0, runtime_1.emitNervesEvent)({
|
|
52
96
|
component: "channels",
|
|
53
97
|
event: "channel.a2a_seen_ledger_init",
|
|
@@ -80,14 +124,43 @@ class FileA2ASeenLedger {
|
|
|
80
124
|
return;
|
|
81
125
|
if (this.cache.has(nonce))
|
|
82
126
|
return;
|
|
83
|
-
|
|
84
|
-
this.
|
|
127
|
+
const now = this.now();
|
|
128
|
+
const file = path.join(this.dir, nonceFileName(nonce));
|
|
129
|
+
fs.writeFileSync(file, `${JSON.stringify({ nonce, markedAt: new Date(now).toISOString() }, null, 2)}\n`, "utf-8");
|
|
130
|
+
this.cache.set(nonce, { markedAt: now, file });
|
|
85
131
|
(0, runtime_1.emitNervesEvent)({
|
|
86
132
|
component: "channels",
|
|
87
133
|
event: "channel.a2a_seen_marked",
|
|
88
134
|
message: "marked A2A seal nonce as seen (first sight)",
|
|
89
135
|
meta: { nonceHash: nonceFileName(nonce).slice(0, 16) },
|
|
90
136
|
});
|
|
137
|
+
// A long-running agent drops expired entries as it goes, not only when it starts.
|
|
138
|
+
if (now - this.lastPrunedAt >= exports.SEEN_PRUNE_INTERVAL_MS) {
|
|
139
|
+
this.lastPrunedAt = now;
|
|
140
|
+
this.prune(now);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Drop entries marked longer ago than `SEEN_RETENTION_MS`: they can never match an acceptable envelope again. An
|
|
144
|
+
* undated entry is kept. */
|
|
145
|
+
prune(now) {
|
|
146
|
+
let pruned = 0;
|
|
147
|
+
for (const [nonce, entry] of this.cache) {
|
|
148
|
+
if (!(now - entry.markedAt > exports.SEEN_RETENTION_MS))
|
|
149
|
+
continue;
|
|
150
|
+
/* v8 ignore start -- a file that vanished or cannot be removed is simply left; the next prune drops it from disk at restart @preserve */
|
|
151
|
+
try {
|
|
152
|
+
fs.unlinkSync(entry.file);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// left on disk; it is expired, so dropping it from memory is still safe
|
|
156
|
+
}
|
|
157
|
+
/* v8 ignore stop @preserve */
|
|
158
|
+
this.cache.delete(nonce);
|
|
159
|
+
pruned += 1;
|
|
160
|
+
}
|
|
161
|
+
if (pruned > 0) {
|
|
162
|
+
(0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_seen_pruned", message: "pruned A2A seen entries past the freshness window", meta: { dir: this.dir, pruned } });
|
|
163
|
+
}
|
|
91
164
|
}
|
|
92
165
|
/** Reload every persisted nonce from disk into the in-memory set (the restart
|
|
93
166
|
* replay defense). Corrupt/unreadable artifacts are skipped so one bad file can
|
|
@@ -104,17 +177,34 @@ class FileA2ASeenLedger {
|
|
|
104
177
|
for (const name of entries) {
|
|
105
178
|
if (!name.endsWith(".json"))
|
|
106
179
|
continue;
|
|
180
|
+
const file = path.join(this.dir, name);
|
|
107
181
|
let record;
|
|
108
182
|
try {
|
|
109
|
-
record = JSON.parse(fs.readFileSync(
|
|
183
|
+
record = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
110
184
|
}
|
|
111
185
|
catch {
|
|
112
186
|
continue;
|
|
113
187
|
}
|
|
114
188
|
if (typeof record.nonce === "string" && record.nonce.length > 0) {
|
|
115
|
-
|
|
189
|
+
const markedAt = typeof record.markedAt === "string" ? Date.parse(record.markedAt) : Number.NaN;
|
|
190
|
+
this.cache.set(record.nonce, { markedAt, file });
|
|
116
191
|
}
|
|
117
192
|
}
|
|
118
193
|
}
|
|
119
194
|
}
|
|
120
195
|
exports.FileA2ASeenLedger = FileA2ASeenLedger;
|
|
196
|
+
/**
|
|
197
|
+
* A seen ledger under `root` that only this user can reach (the CLI keeps one for replies). `root` is created 0700 and
|
|
198
|
+
* refused when it is a symlink, not a folder, owned by someone else, or open to group or other, since anyone who can
|
|
199
|
+
* delete entries can replay an old reply.
|
|
200
|
+
*/
|
|
201
|
+
function openPrivateSeenLedger(root) {
|
|
202
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
203
|
+
const stat = fs.lstatSync(root);
|
|
204
|
+
/* v8 ignore next -- process.getuid exists on every POSIX platform the CLI runs on @preserve */
|
|
205
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : stat.uid;
|
|
206
|
+
if (!stat.isDirectory() || stat.uid !== uid || (stat.mode & 0o077) !== 0) {
|
|
207
|
+
throw new Error(`refusing the A2A reply ledger at ${root}: it must be a folder owned by you with mode 0700`);
|
|
208
|
+
}
|
|
209
|
+
return new FileA2ASeenLedger(root);
|
|
210
|
+
}
|
package/dist/a2a/server.js
CHANGED
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.ESCALATION_CONFIRM_INTERVAL_MS = void 0;
|
|
37
37
|
exports.a2aChatRelationship = a2aChatRelationship;
|
|
38
|
+
exports.commandIdFor = commandIdFor;
|
|
38
39
|
exports.startA2AServer = startA2AServer;
|
|
39
40
|
const http = __importStar(require("node:http"));
|
|
40
41
|
const node_crypto_1 = require("node:crypto");
|
|
@@ -56,6 +57,7 @@ const mission_result_wire_1 = require("./mission-result-wire");
|
|
|
56
57
|
const delegation_stores_1 = require("./delegation-stores");
|
|
57
58
|
const task_store_1 = require("./task-store");
|
|
58
59
|
const delegated_command_1 = require("./delegated-command");
|
|
60
|
+
const refusals_1 = require("./refusals");
|
|
59
61
|
const outbox_wire_1 = require("./outbox-wire");
|
|
60
62
|
const failure_reports_1 = require("../heart/failure-reports");
|
|
61
63
|
const MAX_A2A_REQUEST_BYTES = 128 * 1024;
|
|
@@ -206,6 +208,22 @@ function messageHasDataPart(message) {
|
|
|
206
208
|
return false;
|
|
207
209
|
return message.parts.some((part) => part && part.kind === "data");
|
|
208
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* A delegated command's id: a hash of the sender's DID, its signed message id and its signed issue time. A redelivery of
|
|
213
|
+
* the same message names the same command, while a sender reusing a message id on a new message (possible once the
|
|
214
|
+
* old one ages out of the seen ledger) gets a new command, so it can never ride an earlier command's owner notice or
|
|
215
|
+
* effect record. A message without a binding (unbound, so refused upstream) falls back to a random id.
|
|
216
|
+
*/
|
|
217
|
+
function commandIdFor(chat) {
|
|
218
|
+
/* v8 ignore next -- unbound envelopes are refused before a delegated command is admitted @preserve */
|
|
219
|
+
if (!chat.bindingId)
|
|
220
|
+
return (0, node_crypto_1.randomUUID)();
|
|
221
|
+
return (0, node_crypto_1.createHash)("sha256").update(JSON.stringify([chat.did, chat.bindingId, chat.issuedAt ?? ""])).digest("hex").slice(0, 32);
|
|
222
|
+
}
|
|
223
|
+
function refusalResponse(id, what, reason) {
|
|
224
|
+
const refusal = (0, refusals_1.receiveRefusalError)(what, reason);
|
|
225
|
+
return errorResponse(id, rejectionErrorCode(reason), refusal.message, refusal.data);
|
|
226
|
+
}
|
|
209
227
|
/** Map a friends `receiveShare` rejection reason → a JSON-RPC error code.
|
|
210
228
|
* Malformed-shape reasons map to invalid-params (-32602); auth/trust/replay
|
|
211
229
|
* rejections map to a dedicated A2A "rejected" code (-32003). */
|
|
@@ -386,15 +404,16 @@ async function startA2AServer(options) {
|
|
|
386
404
|
let inboundShareDeps;
|
|
387
405
|
if (options.identity) {
|
|
388
406
|
const friendsDir = `${agentRoot}/friends`;
|
|
407
|
+
const friendStore = new friends_1.FileFriendStore(friendsDir);
|
|
389
408
|
inboundShareDeps = {
|
|
390
409
|
sodium: await (0, a2a_client_1.ready)(),
|
|
391
|
-
store:
|
|
410
|
+
store: friendStore,
|
|
392
411
|
// The SAME canonical mission store the Slice-4 delegation tools use, so an
|
|
393
412
|
// inbound `importCoordination` write lands where the read/prepare surface reads.
|
|
394
413
|
missionStore: (0, delegation_stores_1.delegationStoresFor)(agentRoot).missionStore,
|
|
395
414
|
pinStore: new pin_store_1.FileA2APinStore(agentRoot),
|
|
396
415
|
seen: new seen_ledger_1.FileA2ASeenLedger(agentRoot),
|
|
397
|
-
didResolution: (0, did_resolution_1.makeDidResolution)({ sodium: await (0, a2a_client_1.ready)() }),
|
|
416
|
+
didResolution: (0, did_resolution_1.makeDidResolution)({ sodium: await (0, a2a_client_1.ready)(), store: friendStore, agentName: options.agentName, notifyOwner: options.notifyOwner }),
|
|
398
417
|
identity: options.identity,
|
|
399
418
|
};
|
|
400
419
|
}
|
|
@@ -453,7 +472,7 @@ async function startA2AServer(options) {
|
|
|
453
472
|
}
|
|
454
473
|
const bridged = await (0, inbound_share_1.receiveInboundShare)({ ...outboxInbound, kind: outboxInbound.kind ?? "message" }, inboundShareDeps);
|
|
455
474
|
if (bridged.outcome === "rejected") {
|
|
456
|
-
writeJson(res, 200,
|
|
475
|
+
writeJson(res, 200, refusalResponse(rpc.id, "outbox call", bridged.reason));
|
|
457
476
|
return;
|
|
458
477
|
}
|
|
459
478
|
if (bridged.outcome !== "completed" || !bridged.message) {
|
|
@@ -507,7 +526,7 @@ async function startA2AServer(options) {
|
|
|
507
526
|
identity: inboundShareDeps.identity,
|
|
508
527
|
});
|
|
509
528
|
if (imported.outcome === "rejected") {
|
|
510
|
-
writeJson(res, 200,
|
|
529
|
+
writeJson(res, 200, refusalResponse(rpc.id, "result", imported.reason));
|
|
511
530
|
return;
|
|
512
531
|
}
|
|
513
532
|
// imported (store-only, no turn): acknowledge with a minimal completed task.
|
|
@@ -535,7 +554,7 @@ async function startA2AServer(options) {
|
|
|
535
554
|
if (inbound && inboundShareDeps && messageHasDataPart(inbound)) {
|
|
536
555
|
const bridged = await (0, inbound_share_1.receiveInboundShare)(inbound, inboundShareDeps);
|
|
537
556
|
if (bridged.outcome === "rejected") {
|
|
538
|
-
writeJson(res, 200,
|
|
557
|
+
writeJson(res, 200, refusalResponse(rpc.id, "share", bridged.reason));
|
|
539
558
|
return;
|
|
540
559
|
}
|
|
541
560
|
// A message with a data part always resolves to completed|rejected — the
|
|
@@ -550,7 +569,7 @@ async function startA2AServer(options) {
|
|
|
550
569
|
verifiedShareText = bridged.message.text;
|
|
551
570
|
/* v8 ignore next -- a completed chat always has a friend record: an unknown DID reads as stranger and the message is refused @preserve */
|
|
552
571
|
verifiedPeerName = bridged.friend?.name ?? bridged.verifiedDid;
|
|
553
|
-
verifiedChat = { did: bridged.verifiedDid, friend: bridged.friend, ...(bridged.message.onBehalfOf ? { onBehalfOf: bridged.message.onBehalfOf } : {}) };
|
|
572
|
+
verifiedChat = { did: bridged.verifiedDid, friend: bridged.friend, ...(bridged.message.onBehalfOf ? { onBehalfOf: bridged.message.onBehalfOf } : {}), bindingId: bridged.bindingId, issuedAt: bridged.message.issuedAt };
|
|
554
573
|
}
|
|
555
574
|
else {
|
|
556
575
|
verifiedShareText = `[a2a] received ${bridged.friendsKind} (${bridged.status}) from ${bridged.verifiedDid}`;
|
|
@@ -610,7 +629,7 @@ async function startA2AServer(options) {
|
|
|
610
629
|
/* v8 ignore next -- a completed chat always carries the sender's friend record (see the receipt above) @preserve */
|
|
611
630
|
if (!verifiedChat.friend)
|
|
612
631
|
throw new Error("verified chat has no friend record");
|
|
613
|
-
const admission = await (0, delegated_command_1.admitDelegatedCommand)({ friend: verifiedChat.friend, did: verifiedChat.did, text, commandId: (
|
|
632
|
+
const admission = await (0, delegated_command_1.admitDelegatedCommand)({ friend: verifiedChat.friend, did: verifiedChat.did, text, commandId: commandIdFor(verifiedChat), store: inboundShareDeps.store, registry, options: options.delegation });
|
|
614
633
|
if (!admission.ok) {
|
|
615
634
|
// The code and the "delegated command refused: <reason>" prefix stay: the replay gate and older CLIs match them.
|
|
616
635
|
// Only an active family peer is told why: anyone else gets no_grant whatever the real reason was (a broken trust file, a key mismatch), which stays in the local log and nerves.
|
|
@@ -6269,6 +6269,10 @@ async function executeFriendCommand(command, store, paths) {
|
|
|
6269
6269
|
return `friend not found: ${command.friendId}`;
|
|
6270
6270
|
if (result.status === "noop")
|
|
6271
6271
|
return `identity already linked: ${command.provider}:${command.externalId}`;
|
|
6272
|
+
// Anything but linked or merged changed nothing (for example conflict_requires_operator or retry_unsupported).
|
|
6273
|
+
if (result.status !== "linked" && result.status !== "merged") {
|
|
6274
|
+
return `not linked: ${command.provider}:${command.externalId} (${[result.status, result.message].filter(Boolean).join(": ")})`;
|
|
6275
|
+
}
|
|
6272
6276
|
const mergeMessage = result.status === "merged" ? " (merged orphan)" : "";
|
|
6273
6277
|
return `linked ${command.provider}:${command.externalId} to ${command.friendId}${mergeMessage}`;
|
|
6274
6278
|
}
|
|
@@ -6332,6 +6336,9 @@ async function executeA2AClientCommand(command, deps) {
|
|
|
6332
6336
|
/* v8 ignore next -- production CLI keeps the client key under the home dir; tests pass an explicit file @preserve */
|
|
6333
6337
|
const identityFile = command.identityFile ?? path.join(os.homedir(), ".ouro-cli", "a2a", "client-identity.json");
|
|
6334
6338
|
const identity = await loadOrMintA2AIdentityFile({ filePath: identityFile });
|
|
6339
|
+
// Replies are replay-checked against a durable ledger beside the identity file, so an old reply cannot be served again.
|
|
6340
|
+
const { openPrivateSeenLedger } = await Promise.resolve().then(() => __importStar(require("../../a2a/seen-ledger")));
|
|
6341
|
+
const replySeen = () => openPrivateSeenLedger(path.join(path.dirname(identityFile), "reply-ledger"));
|
|
6335
6342
|
if (command.kind === "a2a.identity") {
|
|
6336
6343
|
const message = command.json ? JSON.stringify({ did: identity.did, identityFile }) : `did: ${identity.did}\nidentity file: ${identityFile}`;
|
|
6337
6344
|
deps.writeStdout(message);
|
|
@@ -6349,7 +6356,7 @@ async function executeA2AClientCommand(command, deps) {
|
|
|
6349
6356
|
return { method: "report/resolve", params: { id: claim.reportId, version: claim.version, note: claim.note, resolvedAt: claim.resolvedAt, proof: signResolution({ sodium: await ready(), identity, claim }) } };
|
|
6350
6357
|
})();
|
|
6351
6358
|
const result = await callOutboxMethod({
|
|
6352
|
-
cardUrl: command.to, ...call, identity,
|
|
6359
|
+
cardUrl: command.to, ...call, identity, seen: replySeen(),
|
|
6353
6360
|
/* v8 ignore next -- production CLI uses global fetch; tests inject fetch for a hermetic peer @preserve */
|
|
6354
6361
|
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
6355
6362
|
});
|
|
@@ -6366,6 +6373,7 @@ async function executeA2AClientCommand(command, deps) {
|
|
|
6366
6373
|
...(command.conversationId ? { conversationId: command.conversationId } : {}),
|
|
6367
6374
|
...(command.delegated ? { onBehalfOf: "principal" } : {}),
|
|
6368
6375
|
identity,
|
|
6376
|
+
seen: replySeen(),
|
|
6369
6377
|
/* v8 ignore next -- production CLI uses global fetch; tests inject fetch for a hermetic peer @preserve */
|
|
6370
6378
|
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
6371
6379
|
});
|
package/dist/senses/a2a-entry.js
CHANGED
|
@@ -112,6 +112,7 @@ Promise.all([
|
|
|
112
112
|
},
|
|
113
113
|
} : {}),
|
|
114
114
|
...escalationOptionsFor(agentName, (notice) => sendOwnerNoticeViaTelegram(agentName, notice)),
|
|
115
|
+
notifyOwner: (notice) => sendOwnerNoticeViaTelegram(agentName, notice),
|
|
115
116
|
...(argValue("--host") ? { host: argValue("--host") } : {}),
|
|
116
117
|
...(Number.isInteger(port) ? { port } : {}),
|
|
117
118
|
...(argValue("--base-url") ? { baseUrl: argValue("--base-url") } : {}),
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.989",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.989",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"@microsoft/teams.common": "2.0.11",
|
|
19
19
|
"@microsoft/teams.dev": "2.0.11",
|
|
20
20
|
"@microsoft/teams.graph": "2.0.11",
|
|
21
|
-
"@ouro.bot/friends": "0.1.0-alpha.
|
|
21
|
+
"@ouro.bot/friends": "0.1.0-alpha.17",
|
|
22
22
|
"@types/react": "^17.0.91",
|
|
23
23
|
"@types/ws": "^8.18.1",
|
|
24
24
|
"ajv": "^8.20.0",
|
|
@@ -1398,9 +1398,9 @@
|
|
|
1398
1398
|
}
|
|
1399
1399
|
},
|
|
1400
1400
|
"node_modules/@ouro.bot/friends": {
|
|
1401
|
-
"version": "0.1.0-alpha.
|
|
1402
|
-
"resolved": "https://registry.npmjs.org/@ouro.bot/friends/-/friends-0.1.0-alpha.
|
|
1403
|
-
"integrity": "sha512-
|
|
1401
|
+
"version": "0.1.0-alpha.17",
|
|
1402
|
+
"resolved": "https://registry.npmjs.org/@ouro.bot/friends/-/friends-0.1.0-alpha.17.tgz",
|
|
1403
|
+
"integrity": "sha512-hpThUpNVP7StvleA9ue3dNytIDVPuNzmw+lnrlV40hKn3rF79VMUV6X3cH0kjG0EDp4Jpj3hlfxFKEmSu/oaiw==",
|
|
1404
1404
|
"license": "Apache-2.0",
|
|
1405
1405
|
"dependencies": {
|
|
1406
1406
|
"libsodium-wrappers": "0.8.4"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.989",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=22"
|
|
6
6
|
},
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"@microsoft/teams.common": "2.0.11",
|
|
69
69
|
"@microsoft/teams.dev": "2.0.11",
|
|
70
70
|
"@microsoft/teams.graph": "2.0.11",
|
|
71
|
-
"@ouro.bot/friends": "0.1.0-alpha.
|
|
71
|
+
"@ouro.bot/friends": "0.1.0-alpha.17",
|
|
72
72
|
"@types/react": "^17.0.91",
|
|
73
73
|
"@types/ws": "^8.18.1",
|
|
74
74
|
"ajv": "^8.20.0",
|