@ouro.bot/cli 0.1.0-alpha.671 → 0.1.0-alpha.673

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.
@@ -36,11 +36,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.startA2AServer = startA2AServer;
37
37
  const http = __importStar(require("node:http"));
38
38
  const node_crypto_1 = require("node:crypto");
39
+ const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
40
+ const friends_1 = require("@ouro.bot/friends");
39
41
  const identity_1 = require("../heart/identity");
40
42
  const runtime_1 = require("../nerves/runtime");
41
43
  const shared_turn_1 = require("../senses/shared-turn");
42
44
  const card_1 = require("./card");
43
45
  const config_1 = require("./config");
46
+ const pin_store_1 = require("./pin-store");
47
+ const seen_ledger_1 = require("./seen-ledger");
48
+ const did_resolution_1 = require("./did-resolution");
49
+ const inbound_share_1 = require("./inbound-share");
50
+ const mission_result_wire_1 = require("./mission-result-wire");
51
+ const delegation_stores_1 = require("./delegation-stores");
44
52
  const task_store_1 = require("./task-store");
45
53
  const MAX_A2A_REQUEST_BYTES = 128 * 1024;
46
54
  const MAX_A2A_MESSAGE_TEXT_CHARS = 16_000;
@@ -160,6 +168,21 @@ function accessTokenScope(accessToken) {
160
168
  function untrustedTurnPeerExternalId() {
161
169
  return "unauthenticated-a2a-peer";
162
170
  }
171
+ /** Whether an inbound message carries a friends sealed DataPart (`kind:"data"`). */
172
+ function messageHasDataPart(message) {
173
+ /* v8 ignore next -- defensive: callers pass a parsed inbound whose `parts` is always an array; guards malformed direct input @preserve */
174
+ if (!Array.isArray(message.parts))
175
+ return false;
176
+ return message.parts.some((part) => part && part.kind === "data");
177
+ }
178
+ /** Map a friends `receiveShare` rejection reason → a JSON-RPC error code.
179
+ * Malformed-shape reasons map to invalid-params (-32602); auth/trust/replay
180
+ * rejections map to a dedicated A2A "rejected" code (-32003). */
181
+ function rejectionErrorCode(reason) {
182
+ if (reason === "malformed_message" || reason === "malformed_plaintext")
183
+ return -32602;
184
+ return -32003;
185
+ }
163
186
  function senderHintFromMessage(req, inbound) {
164
187
  const idHint = metadataString(inbound, "senderAgentId")
165
188
  ?? metadataString(inbound, "senderCardUrl")
@@ -315,9 +338,28 @@ async function startA2AServer(options) {
315
338
  const port = options.port ?? (0, config_1.defaultA2APort)(options.agentName);
316
339
  const a2aPath = (0, config_1.normalizeA2APath)(options.path);
317
340
  /* v8 ignore next -- foreground CLI/default daemon paths own the ambient agent-root fallback; protocol tests inject an isolated root @preserve */
318
- const taskStore = new task_store_1.FileA2ATaskStore(options.agentRoot ?? (0, identity_1.getAgentRoot)(options.agentName));
341
+ const agentRoot = options.agentRoot ?? (0, identity_1.getAgentRoot)(options.agentName);
342
+ const taskStore = new task_store_1.FileA2ATaskStore(agentRoot);
319
343
  /* v8 ignore next -- default runner crosses into the full live agent pipeline; shared-turn covers that pipeline and server tests inject a deterministic runner @preserve */
320
344
  const turnRunner = options.turnRunner ?? defaultTurnRunner;
345
+ // The inbound friends-DataPart bridge is available only when the agent has a
346
+ // cryptographic identity (its did:key + X25519 unseal keys). Build its durable
347
+ // stores once at start; without an identity, inbound stays on the text path.
348
+ let inboundShareDeps;
349
+ if (options.identity) {
350
+ const friendsDir = `${agentRoot}/friends`;
351
+ inboundShareDeps = {
352
+ sodium: await (0, a2a_client_1.ready)(),
353
+ store: new friends_1.FileFriendStore(friendsDir),
354
+ // The SAME canonical mission store the Slice-4 delegation tools use, so an
355
+ // inbound `importCoordination` write lands where the read/prepare surface reads.
356
+ missionStore: (0, delegation_stores_1.delegationStoresFor)(agentRoot).missionStore,
357
+ pinStore: new pin_store_1.FileA2APinStore(agentRoot),
358
+ seen: new seen_ledger_1.FileA2ASeenLedger(agentRoot),
359
+ didResolution: (0, did_resolution_1.makeDidResolution)({ sodium: await (0, a2a_client_1.ready)() }),
360
+ identity: options.identity,
361
+ };
362
+ }
321
363
  let publicBaseUrl = options.baseUrl;
322
364
  const server = http.createServer(async (req, res) => {
323
365
  /* v8 ignore next -- Node always supplies url/host for accepted HTTP requests; fallback keeps malformed local calls safe @preserve */
@@ -325,7 +367,12 @@ async function startA2AServer(options) {
325
367
  /* v8 ignore next -- host fallback is defensive for malformed local requests; publicBaseUrl/default host branches are covered @preserve */
326
368
  const currentBaseUrl = publicBaseUrl ?? `http://${req.headers.host ?? `${host}:${port}`}`;
327
369
  if (req.method === "GET" && (requestUrl.pathname === "/.well-known/agent-card.json" || requestUrl.pathname === "/agent-card.json")) {
328
- writeJson(res, 200, (0, card_1.buildA2AAgentCard)({ agentName: options.agentName, baseUrl: currentBaseUrl, path: a2aPath }));
370
+ writeJson(res, 200, (0, card_1.buildA2AAgentCard)({
371
+ agentName: options.agentName,
372
+ baseUrl: currentBaseUrl,
373
+ path: a2aPath,
374
+ ...(options.identity ? { did: options.identity.did } : {}),
375
+ }));
329
376
  return;
330
377
  }
331
378
  if (req.method !== "POST" || requestUrl.pathname !== a2aPath) {
@@ -350,7 +397,64 @@ async function startA2AServer(options) {
350
397
  const responseStyle = rpc.method === "message/send" ? "legacy" : "latest";
351
398
  const parsedInbound = messageFromParams(rpc.params);
352
399
  const inbound = parsedInbound ? { ...parsedInbound, kind: parsedInbound.kind ?? "message" } : null;
353
- const text = textFromMessage(inbound);
400
+ // HARNESS-OWNED RESULT WIRE (runs BEFORE the friends-share branch). A
401
+ // `mission_result`-tagged DataPart is NOT a FriendsKind — it routes to
402
+ // `importMissionResult` (assignee/correlation-gated, store-only, NO turn), never
403
+ // to `receiveShare`. Recognized by the `ouroKind` discriminator.
404
+ if (inbound && inboundShareDeps && (0, mission_result_wire_1.isMissionResultDataPart)(inbound)) {
405
+ const imported = await (0, mission_result_wire_1.receiveInboundMissionResult)(inbound, {
406
+ sodium: inboundShareDeps.sodium,
407
+ store: inboundShareDeps.store,
408
+ missionStore: inboundShareDeps.missionStore,
409
+ // The SAME authentication seam the share bridge uses — so a forged-sender
410
+ // result is rejected by the DidVerifier exactly like a forged share.
411
+ pinStore: inboundShareDeps.pinStore,
412
+ seen: inboundShareDeps.seen,
413
+ didResolution: inboundShareDeps.didResolution,
414
+ identity: inboundShareDeps.identity,
415
+ });
416
+ if (imported.outcome === "rejected") {
417
+ writeJson(res, 200, errorResponse(rpc.id, rejectionErrorCode(imported.reason), `A2A result rejected: ${imported.reason}`));
418
+ return;
419
+ }
420
+ // imported (store-only, no turn): acknowledge with a minimal completed task.
421
+ /* v8 ignore next -- "not-a-result" is unreachable here (gated by isMissionResultDataPart) @preserve */
422
+ const ackText = imported.outcome === "imported"
423
+ ? `[a2a] imported mission result from ${imported.verifiedDid}`
424
+ /* v8 ignore next -- defensive: the gate above guarantees imported|rejected @preserve */
425
+ : "[a2a] mission result";
426
+ const contextId = inbound.contextId ?? "default";
427
+ const taskId = (0, node_crypto_1.randomUUID)();
428
+ const accessToken = (0, node_crypto_1.randomUUID)();
429
+ const ackTask = taskFor({ taskId, accessToken, contextId, inbound, state: "TASK_STATE_COMPLETED", response: ackText });
430
+ taskStore.put(ackTask, accessTokenScope(accessToken));
431
+ writeJson(res, 200, jsonResponse(rpc.id, publicTask(ackTask, accessToken, responseStyle, true)));
432
+ return;
433
+ }
434
+ // Inbound friends-DataPart branch (runs BEFORE the legacy text path). A
435
+ // sealed DataPart is unwrapped + verified through the bridge; the turn is
436
+ // then keyed on the VERIFIED sender DID (replacing the unauthenticated
437
+ // sentinel). A non-friends (text) message falls through unchanged.
438
+ let verifiedPeerAgentId;
439
+ let verifiedPeerName;
440
+ let verifiedShareText;
441
+ if (inbound && inboundShareDeps && messageHasDataPart(inbound)) {
442
+ const bridged = await (0, inbound_share_1.receiveInboundShare)(inbound, inboundShareDeps);
443
+ if (bridged.outcome === "rejected") {
444
+ writeJson(res, 200, errorResponse(rpc.id, rejectionErrorCode(bridged.reason), `A2A share rejected: ${bridged.reason}`));
445
+ return;
446
+ }
447
+ // A message with a data part always resolves to completed|rejected — the
448
+ // bridge only returns "not-a-share" when there is NO data part, which the
449
+ // `messageHasDataPart` gate above excludes. So this is the completed path.
450
+ /* v8 ignore next -- "not-a-share" is unreachable here (gated by messageHasDataPart) @preserve */
451
+ if (bridged.outcome === "completed") {
452
+ verifiedPeerAgentId = bridged.verifiedDid;
453
+ verifiedPeerName = bridged.verifiedDid;
454
+ verifiedShareText = `[a2a] received ${bridged.friendsKind} (${bridged.status}) from ${bridged.verifiedDid}`;
455
+ }
456
+ }
457
+ const text = verifiedShareText ?? textFromMessage(inbound);
354
458
  if (!inbound || !text) {
355
459
  writeJson(res, 200, errorResponse(rpc.id, -32602, "SendMessage requires a text message"));
356
460
  return;
@@ -360,8 +464,10 @@ async function startA2AServer(options) {
360
464
  return;
361
465
  }
362
466
  const senderHint = senderHintFromMessage(req, inbound);
363
- const peerAgentId = untrustedTurnPeerExternalId();
364
- const peerName = senderHint.name;
467
+ // The verified DID wins as the turn's peer identity; otherwise the legacy
468
+ // unauthenticated sentinel (text path, behavior-preserved).
469
+ const peerAgentId = verifiedPeerAgentId ?? untrustedTurnPeerExternalId();
470
+ const peerName = verifiedPeerName ?? senderHint.name;
365
471
  const continuationToken = accessTokenFromParams(rpc.params);
366
472
  const continuationTask = inbound.taskId && continuationToken
367
473
  ? taskStore.get(inbound.taskId, accessTokenScope(continuationToken))
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.makeA2ATransport = makeA2ATransport;
4
+ const runtime_1 = require("../nerves/runtime");
5
+ const client_1 = require("./client");
6
+ /** The not-wired marker for the relay/mailbox rungs (the stubbed seam). */
7
+ const RELAY_NOT_WIRED = "A2A relay/mailbox transport is not wired — see ourostack/friends-relay";
8
+ /**
9
+ * Build the harness `A2ATransport` (the outbound delivery seam friends' `sendShare`
10
+ * calls). Only the `direct` rung is wired in this slice: it HTTP-POSTs the sealed,
11
+ * `wrapInDataPart`-wrapped message to the peer's `endpointUrl` (the `address`) as a
12
+ * JSON-RPC `message/send`, reusing the harness `client.ts` plumbing. The `relay` and
13
+ * `mailbox` rungs are a TYPED-but-STUBBED seam: they throw a clear not-wired error
14
+ * naming the `ourostack/friends-relay` dependency, so a future relay slice has an
15
+ * exact insertion point without this slice shipping an untrusted relay.
16
+ */
17
+ function makeA2ATransport(input = {}) {
18
+ return {
19
+ async send(target, message) {
20
+ if (target.rung === "direct") {
21
+ (0, runtime_1.emitNervesEvent)({
22
+ component: "channels",
23
+ event: "channel.a2a_transport_direct_send",
24
+ message: "delivering sealed A2A message over the direct rung",
25
+ meta: { address: target.address },
26
+ });
27
+ await (0, client_1.postA2AMessageEnvelope)({
28
+ endpointUrl: target.address,
29
+ message,
30
+ ...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
31
+ });
32
+ return;
33
+ }
34
+ // relay / mailbox: the typed-but-stubbed seam. Loud refusal (security-relevant:
35
+ // we never silently fall through to an untrusted relay).
36
+ (0, runtime_1.emitNervesEvent)({
37
+ component: "channels",
38
+ event: "channel.a2a_transport_not_wired",
39
+ message: "refused A2A transport over a non-direct rung (not wired)",
40
+ meta: { rung: target.rung, address: target.address, dependency: "ourostack/friends-relay" },
41
+ });
42
+ throw new Error(`${RELAY_NOT_WIRED} (rung: ${target.rung})`);
43
+ },
44
+ };
45
+ }
@@ -38,8 +38,14 @@ const path = __importStar(require("node:path"));
38
38
  const identity_1 = require("../heart/identity");
39
39
  const runtime_credentials_1 = require("../heart/runtime-credentials");
40
40
  const friends_1 = require("@ouro.bot/friends");
41
+ const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
41
42
  const runtime_1 = require("../nerves/runtime");
42
43
  const client_1 = require("../a2a/client");
44
+ const onboarding_1 = require("../a2a/onboarding");
45
+ const identity_2 = require("../a2a/identity");
46
+ const transport_1 = require("../a2a/transport");
47
+ const delegation_stores_1 = require("../a2a/delegation-stores");
48
+ const mission_result_wire_1 = require("../a2a/mission-result-wire");
43
49
  const outboundTaskTokens = new Map();
44
50
  function tokenKey(ctx, friendId, taskId) {
45
51
  return `${ctx?.agentRoot ?? "ambient"}\n${friendId}\n${taskId}`;
@@ -138,7 +144,176 @@ async function resolveA2AEndpoint(friend) {
138
144
  }
139
145
  throw new Error("A2A peer has no endpointUrl or cardUrl in agentMeta.a2a");
140
146
  }
147
+ /** The management sense the current turn arrived through, defaulting to a non-`local`
148
+ * value when unknown so the owner-only gate fails CLOSED on an unexpected origin. */
149
+ function senseTypeOf(ctx) {
150
+ /* v8 ignore next -- in-turn tool calls always carry a resolved channel context; the open-default fail-closed fallback is defensive @preserve */
151
+ return ctx?.context?.channel?.senseType ?? "open";
152
+ }
153
+ /** The pinned DID a peer record carries (its durable A2A identity), or undefined for a
154
+ * legacy (non-DID-keyed) peer. */
155
+ function peerDid(peer) {
156
+ const did = peer.agentMeta?.a2a?.did;
157
+ return typeof did === "string" && did.trim() ? did.trim() : undefined;
158
+ }
159
+ /** Locate an imported delegation by its correlation `requestId` across all missions:
160
+ * who delegated it (`fromAgentId`) + which local mission (`missionId`). The delegation
161
+ * lives under `importedDelegations[fromAgentId][requestId]` — what the inbound bridge
162
+ * quarantined. Returns undefined when no mission carries that requestId. */
163
+ function findImportedDelegation(missions, requestId) {
164
+ for (const mission of missions) {
165
+ for (const [fromAgentId, byRequest] of Object.entries(mission.importedDelegations ?? {})) {
166
+ if (byRequest[requestId])
167
+ return { fromAgentId, missionId: mission.id };
168
+ }
169
+ }
170
+ return undefined;
171
+ }
172
+ /**
173
+ * Seal an arbitrary friends plaintext envelope to a DID-keyed peer and deliver it over
174
+ * the `direct` rung via friends' `sendShare`. `sendShare` builds the SAME SealedEnvelope
175
+ * regardless of rung, `wrapInDataPart`-wraps it, and calls the transport itself; the
176
+ * recipient X25519 is derived from the peer's pinned did:key. Throws when the peer's DID
177
+ * is unparseable (cannot seal) or the rung is unreachable.
178
+ */
179
+ async function sealEnvelopeToPeer(input) {
180
+ const parsed = (0, a2a_client_1.parseDidKey)(input.did);
181
+ if (!parsed)
182
+ throw new Error(`cannot seal: peer DID is not a parseable did:key (${input.did})`);
183
+ const reachability = (0, a2a_client_1.resolveReachability)(input.peer.agentMeta?.a2a, input.peer.agentMeta?.mailbox);
184
+ if (reachability.rung === "unreachable") {
185
+ throw new Error("cannot seal: peer has no reachable A2A endpoint");
186
+ }
187
+ (0, runtime_1.emitNervesEvent)({
188
+ component: "channels",
189
+ event: "channel.a2a_outbound_seal",
190
+ message: "sealing outbound A2A envelope to a DID-keyed peer",
191
+ meta: { selfDid: input.self.did, peerDid: input.did, rung: reachability.rung, friendsKind: input.friendsKind },
192
+ });
193
+ const result = await (0, a2a_client_1.sendShare)({
194
+ sodium: input.sodium,
195
+ transport: (0, transport_1.makeA2ATransport)(),
196
+ fromIdentity: input.self,
197
+ toPeer: { a2a: input.peer.agentMeta?.a2a },
198
+ recipientDid: input.did,
199
+ recipientX25519Pub: (0, a2a_client_1.keyAgreementFromDidKey)({ sodium: input.sodium, ed25519Pub: parsed.ed25519Pub }),
200
+ plaintextEnvelope: input.envelope,
201
+ friendsKind: input.friendsKind,
202
+ });
203
+ /* v8 ignore next 3 -- unreachable behind the resolveReachability pre-check above: sendShare only returns ok:false on the SAME `unreachable` plan we already rejected; a redundant guard. @preserve */
204
+ if (!result.ok) {
205
+ throw new Error(`sealed send failed (${result.reason})`);
206
+ }
207
+ }
208
+ /**
209
+ * Seal an outbound free-text message to a DID-keyed peer over the `direct` rung. The
210
+ * text rides a minimal `coordination` envelope (`intent:"offer"`, the text as `note`) —
211
+ * the kind the Slice-1 inbound bridge verifies (signed sender DID) and turns into a
212
+ * peer turn.
213
+ */
214
+ async function sealAndSendToPeer(input) {
215
+ const sodium = await (0, a2a_client_1.ready)();
216
+ const self = await (0, identity_2.loadSelfA2AIdentity)({ agentName: input.agentName, sodium });
217
+ const envelope = {
218
+ subject: { missionKey: `dm:${self.did}:${input.did}`, title: "Direct message" },
219
+ fromAgentId: self.did,
220
+ intent: "offer",
221
+ note: input.message,
222
+ issuedAt: new Date().toISOString(),
223
+ };
224
+ await sealEnvelopeToPeer({
225
+ self: { did: self.did, keyId: self.keyId, ed25519Priv: self.ed25519Priv },
226
+ sodium, peer: input.peer, did: input.did, envelope, friendsKind: "coordination",
227
+ });
228
+ }
141
229
  exports.a2aToolDefinitions = [
230
+ {
231
+ tool: {
232
+ type: "function",
233
+ function: {
234
+ name: "connect_to",
235
+ description: "Owner-only: link an A2A agent (by its agent-card URL) into your fleet as a family-trusted, DID-keyed peer. Reachable only from your own local/CLI management sense — not exposed to network peers.",
236
+ parameters: {
237
+ type: "object",
238
+ properties: {
239
+ card_url: { type: "string", description: "The agent-card URL of the agent to connect to (its /.well-known/agent-card.json)." },
240
+ name: { type: "string", description: "Optional display name for the linked peer (defaults to the card name)." },
241
+ },
242
+ required: ["card_url"],
243
+ },
244
+ },
245
+ },
246
+ handler: async (args, ctx) => {
247
+ (0, runtime_1.emitNervesEvent)({
248
+ component: "repertoire",
249
+ event: "repertoire.tool_connect_to",
250
+ message: "connect_to invoked",
251
+ meta: { tool: "connect_to" },
252
+ });
253
+ const guard = requireTrustedRequester(ctx);
254
+ if (guard)
255
+ return guard;
256
+ // OWNER-ONLY GATE (fail-closed): authorizeConnect commits ONLY on the `local`
257
+ // (owner stdio/CLI) management sense. Any network/group/internal sense
258
+ // downgrades — refused BEFORE any card fetch or record write, so this tool is
259
+ // never reachable as a general in-turn capability a network peer could exercise.
260
+ // No `membership` is computed (roster auto-family is OUT of scope), so a `closed`
261
+ // sense ALSO downgrades — never a blanket-allow without the real roster verifier.
262
+ const senseType = senseTypeOf(ctx);
263
+ const authorization = (0, friends_1.authorizeConnect)({ senseType });
264
+ if (authorization.decision !== "commit") {
265
+ (0, runtime_1.emitNervesEvent)({
266
+ component: "repertoire",
267
+ event: "repertoire.tool_connect_to_refused",
268
+ message: "connect_to refused (non-owner management sense)",
269
+ meta: { tool: "connect_to", senseType, reason: authorization.reason },
270
+ });
271
+ return `connect_to is available only from your own local management sense (the CLI you launched). This sense (${senseType}) is not permitted to link agents.`;
272
+ }
273
+ const agentName = agentNameFromRoot(ctx?.agentRoot);
274
+ // ONE store for both halves — the DID-keyed onboarding write and the
275
+ // connectAgents resolve/link MUST point at the same store, or the link can't
276
+ // resolve the record the onboarding just wrote.
277
+ const store = storeFor(ctx);
278
+ try {
279
+ // 1) DID-keyed onboarding (the footgun fix): fetch the card, verify the
280
+ // card↔DID binding, and write the record keyed on the verified DID. This
281
+ // is what makes the peer resolvable BY DID for connectAgents below (and for
282
+ // the inbound resolve path). A mis-bound card throws here → no link.
283
+ const onboarded = await (0, onboarding_1.onboardA2APeer)({
284
+ /* v8 ignore next -- in-turn connect_to always carries an agentRoot; the ambient-name fallback is defensive @preserve */
285
+ agentName: agentName ?? "ouro-agent",
286
+ cardUrl: args.card_url,
287
+ trustLevel: "family",
288
+ ...(args.name ? { name: args.name } : {}),
289
+ store,
290
+ });
291
+ const did = onboarded.agentMeta?.a2a?.did;
292
+ // onboardA2APeer always sets a2a.agentId (== the DID when DID-keyed, else the
293
+ // card URL), so this is the resolvable handle connectAgents disambiguates on.
294
+ /* v8 ignore next -- upsertAgentPeer always writes a2a.agentId; the record-id fallback guards a future store-shape regression @preserve */
295
+ const peerHandle = onboarded.agentMeta?.a2a?.agentId ?? onboarded.id;
296
+ // 2) The authority-gated link + control-plane audit. connectAgents re-runs the
297
+ // `local` gate, resolves the just-written record (by DID when present, else
298
+ // by its agent-peer handle), links at family, and records action:"connect".
299
+ const result = await (0, friends_1.connectAgents)(store, {
300
+ peer: did ? { did } : { agentId: peerHandle },
301
+ senseType,
302
+ trustLevel: "family",
303
+ }, { actor: "owner:local", originSense: senseType });
304
+ /* v8 ignore next 3 -- onboardA2APeer wrote a DID/agentId-keyed record, so connectAgents always resolves it (ok:true); this guards a future store-shape regression @preserve */
305
+ if (!result.ok) {
306
+ return `connect_to could not complete the link (${result.status}).`;
307
+ }
308
+ return `connected ${result.record.name} (${did ?? result.record.id}) at family trust.`;
309
+ }
310
+ catch (error) {
311
+ return `connect_to error: ${error instanceof Error ? error.message : /* v8 ignore next -- defensive non-Error failures @preserve */ String(error)}`;
312
+ }
313
+ },
314
+ summaryKeys: ["card_url", "name"],
315
+ riskProfile: { mutates: "durable_state_write", risk: "high", reason: "links a new agent peer into the friend model at family trust" },
316
+ },
142
317
  {
143
318
  tool: {
144
319
  type: "function",
@@ -208,6 +383,25 @@ exports.a2aToolDefinitions = [
208
383
  return `A2A peer not found: ${args.friend_id}`;
209
384
  if (!(0, friends_1.isTrustedLevel)(peer.trustLevel))
210
385
  return "target A2A peer must be friend or family trust before outbound messages.";
386
+ // ROUTING (explicit, no impl judgment): a DID-keyed peer → seal+sign via
387
+ // sendShare over the direct rung (E2E, verified-DID inbound). A legacy peer
388
+ // with no pinned DID → the existing text send, byte-for-byte unchanged.
389
+ const did = peerDid(peer);
390
+ if (did) {
391
+ try {
392
+ await sealAndSendToPeer({
393
+ /* v8 ignore next -- in-turn a2a_send_message always carries a bundle agentRoot; the ambient-name fallback is defensive @preserve */
394
+ agentName: agentNameFromRoot(ctx?.agentRoot) ?? "ouro-agent",
395
+ peer,
396
+ did,
397
+ message: args.message,
398
+ });
399
+ return `sealed message delivered to ${peer.name} (${did}) over the direct rung.`;
400
+ }
401
+ catch (error) {
402
+ return `A2A sealed send error: ${error instanceof Error ? error.message : /* v8 ignore next -- defensive non-Error transport failures @preserve */ String(error)}`;
403
+ }
404
+ }
211
405
  try {
212
406
  const endpoint = await resolveA2AEndpoint(peer);
213
407
  const task = await (0, client_1.sendA2AMessage)({
@@ -279,4 +473,227 @@ exports.a2aToolDefinitions = [
279
473
  },
280
474
  summaryKeys: ["friend_id", "task_id"],
281
475
  },
476
+ {
477
+ tool: {
478
+ type: "function",
479
+ function: {
480
+ name: "coordinate",
481
+ description: "Delegate a task to a trusted (friend/family) A2A agent peer over a named shared mission. Mints a correlation requestId, records your first-party delegation, and seals the coordination to the peer over the direct rung. The peer reads it via its quarantined imported delegations and returns a result with send_result.",
482
+ parameters: {
483
+ type: "object",
484
+ properties: {
485
+ friend_id: { type: "string", description: "Friend record ID for the A2A agent peer to delegate to." },
486
+ mission_key: { type: "string", description: "The shared mission join key both agents can name (e.g. a stable project key)." },
487
+ mission_title: { type: "string", description: "Human title for the mission (used when first creating it)." },
488
+ task_summary: { type: "string", description: "What the peer is being asked to do." },
489
+ task_details: { type: "string", description: "Optional fuller description of the task." },
490
+ },
491
+ required: ["friend_id", "mission_key", "mission_title", "task_summary"],
492
+ },
493
+ },
494
+ },
495
+ handler: async (args, ctx) => {
496
+ (0, runtime_1.emitNervesEvent)({
497
+ component: "repertoire",
498
+ event: "repertoire.tool_coordinate",
499
+ message: "coordinate invoked",
500
+ meta: { tool: "coordinate", friendId: args.friend_id, missionKey: args.mission_key },
501
+ });
502
+ const guard = requireTrustedRequester(ctx);
503
+ if (guard)
504
+ return guard;
505
+ const peer = await storeFor(ctx).get(args.friend_id);
506
+ if (!peer || !isA2APeer(peer))
507
+ return `A2A peer not found: ${args.friend_id}`;
508
+ if (!(0, friends_1.isTrustedLevel)(peer.trustLevel))
509
+ return "target A2A peer must be friend or family trust before delegating.";
510
+ const did = peerDid(peer);
511
+ if (!did)
512
+ return "coordinate requires a DID-keyed peer (connect_to it first to pin its did:key).";
513
+ /* v8 ignore next -- in-turn coordinate always carries a bundle agentRoot; the ambient-name fallback is defensive (same pattern as connect_to) @preserve */
514
+ const agentName = agentNameFromRoot(ctx?.agentRoot) ?? "ouro-agent";
515
+ const store = storeFor(ctx);
516
+ /* v8 ignore next -- in-turn tool calls always carry an agentRoot; the getAgentRoot() ambient fallback depends on process argv (same pattern as storeFor) @preserve */
517
+ const { missionStore, grantStore } = (0, delegation_stores_1.delegationStoresFor)(ctx?.agentRoot ?? (0, identity_1.getAgentRoot)());
518
+ try {
519
+ const sodium = await (0, a2a_client_1.ready)();
520
+ const self = await (0, identity_2.loadSelfA2AIdentity)({ agentName, sodium });
521
+ // Ensure the local mission exists (first-party), then prepare the coordination:
522
+ // mints the requestId + records the first-party delegation (assignee = the peer).
523
+ const mission = await (0, friends_1.recordMission)(missionStore, { missionKey: args.mission_key, title: args.mission_title });
524
+ const prepared = await (0, friends_1.prepareCoordination)(missionStore, store, grantStore, {
525
+ missionId: mission.id,
526
+ toAgentId: did,
527
+ intent: "request",
528
+ selfAgentId: self.did,
529
+ task: { summary: args.task_summary, ...(args.task_details ? { details: args.task_details } : {}) },
530
+ });
531
+ /* v8 ignore next 3 -- unreachable for a trusted DID-keyed peer: recordMission guarantees the mission exists (no not_found) and the isTrustedLevel guard above guarantees trust≥friend, which the tiered consent default always approves (no no_consent). Defensive only. @preserve */
532
+ if (!prepared.ok) {
533
+ return `coordinate refused (${prepared.status}).`;
534
+ }
535
+ const requestId = prepared.envelope.task?.requestId;
536
+ // Seal + send the coordination envelope to the peer over direct.
537
+ await sealEnvelopeToPeer({
538
+ self: { did: self.did, keyId: self.keyId, ed25519Priv: self.ed25519Priv },
539
+ sodium, peer, did,
540
+ envelope: prepared.envelope,
541
+ friendsKind: "coordination",
542
+ });
543
+ (0, runtime_1.emitNervesEvent)({
544
+ component: "repertoire",
545
+ event: "repertoire.tool_coordinate_sent",
546
+ message: "coordination delegated + sealed to peer",
547
+ meta: { tool: "coordinate", peerDid: did, missionKey: args.mission_key, requestId },
548
+ });
549
+ return `coordinated: delegated "${args.task_summary}" to ${peer.name} on mission ${args.mission_key} (requestId ${requestId}).`;
550
+ }
551
+ catch (error) {
552
+ return `coordinate error: ${error instanceof Error ? error.message : /* v8 ignore next -- defensive non-Error failures @preserve */ String(error)}`;
553
+ }
554
+ },
555
+ summaryKeys: ["friend_id", "mission_key"],
556
+ riskProfile: { mutates: "external_side_effect", risk: "high", reason: "delegates a task to a remote agent peer" },
557
+ },
558
+ {
559
+ tool: {
560
+ type: "function",
561
+ function: {
562
+ name: "list_delegations",
563
+ description: "List delegations other agents have sent you (quarantined imported delegations). Each entry names the requesting agent, the mission, the correlation requestId, and the task summary. Use this to see what work peers have asked you to do, then send_result when done.",
564
+ parameters: { type: "object", properties: {} },
565
+ },
566
+ },
567
+ handler: async (_args, ctx) => {
568
+ (0, runtime_1.emitNervesEvent)({
569
+ component: "repertoire",
570
+ event: "repertoire.tool_list_delegations",
571
+ message: "list_delegations invoked",
572
+ meta: { tool: "list_delegations" },
573
+ });
574
+ const guard = requireTrustedRequester(ctx);
575
+ if (guard)
576
+ return guard;
577
+ /* v8 ignore next -- in-turn tool calls always carry an agentRoot; the getAgentRoot() ambient fallback depends on process argv (same pattern as storeFor) @preserve */
578
+ const { missionStore } = (0, delegation_stores_1.delegationStoresFor)(ctx?.agentRoot ?? (0, identity_1.getAgentRoot)());
579
+ /* v8 ignore next -- FileMissionStore always implements listAll; the [] fallback guards a future store-shape regression @preserve */
580
+ const missions = missionStore.listAll ? await missionStore.listAll() : [];
581
+ const rows = [];
582
+ for (const mission of missions) {
583
+ for (const [fromAgentId, byRequest] of Object.entries(mission.importedDelegations ?? {})) {
584
+ for (const [requestId, delegation] of Object.entries(byRequest)) {
585
+ rows.push({
586
+ requestId,
587
+ fromAgentId,
588
+ missionKey: mission.missionKey,
589
+ missionTitle: mission.title,
590
+ summary: delegation.task.summary,
591
+ ...(delegation.task.details ? { details: delegation.task.details } : {}),
592
+ });
593
+ }
594
+ }
595
+ }
596
+ return JSON.stringify(rows, null, 2);
597
+ },
598
+ summaryKeys: [],
599
+ },
600
+ {
601
+ tool: {
602
+ type: "function",
603
+ function: {
604
+ name: "send_result",
605
+ description: "Return a result for a delegation another agent sent you (by its requestId, from list_delegations). Seals the result and delivers it back to the delegating agent over the direct rung; they import it (assignee/correlation-gated). Deliver-back is explicit — do the work in your turn, then call this.",
606
+ parameters: {
607
+ type: "object",
608
+ properties: {
609
+ request_id: { type: "string", description: "The delegation correlation requestId (from list_delegations)." },
610
+ summary: { type: "string", description: "A summary of the result/deliverable." },
611
+ artifact: { type: "string", description: "Optional artifact reference (e.g. a PR or document URL)." },
612
+ },
613
+ required: ["request_id", "summary"],
614
+ },
615
+ },
616
+ },
617
+ handler: async (args, ctx) => {
618
+ (0, runtime_1.emitNervesEvent)({
619
+ component: "repertoire",
620
+ event: "repertoire.tool_send_result",
621
+ message: "send_result invoked",
622
+ meta: { tool: "send_result", requestId: args.request_id },
623
+ });
624
+ const guard = requireTrustedRequester(ctx);
625
+ if (guard)
626
+ return guard;
627
+ const store = storeFor(ctx);
628
+ /* v8 ignore next -- in-turn tool calls always carry an agentRoot; the getAgentRoot() ambient fallback depends on process argv (same pattern as storeFor) @preserve */
629
+ const { missionStore, grantStore } = (0, delegation_stores_1.delegationStoresFor)(ctx?.agentRoot ?? (0, identity_1.getAgentRoot)());
630
+ // Find the imported delegation this result answers (who delegated + which mission).
631
+ /* v8 ignore next -- FileMissionStore always implements listAll; the [] fallback guards a future store-shape regression @preserve */
632
+ const missions = missionStore.listAll ? await missionStore.listAll() : [];
633
+ const found = findImportedDelegation(missions, args.request_id);
634
+ if (!found)
635
+ return `no imported delegation found for requestId ${args.request_id} (check list_delegations).`;
636
+ // The delegator (the recipient of the result) must be a trusted, reachable,
637
+ // DID-keyed peer in our store.
638
+ const delegator = await (0, friends_1.findFriendByDid)(store, found.fromAgentId);
639
+ if (!delegator || !isA2APeer(delegator))
640
+ return `delegating peer not found for ${found.fromAgentId}.`;
641
+ if (!(0, friends_1.isTrustedLevel)(delegator.trustLevel))
642
+ return "delegating peer must be friend or family trust to return a result.";
643
+ const delegatorDid = peerDid(delegator);
644
+ /* v8 ignore next -- unreachable: findFriendByDid matched this record BY its DID (via resolveAgentIdentity, which omits empty DIDs), so a2a.did is always present here. Defensive. @preserve */
645
+ if (!delegatorDid)
646
+ return "delegating peer is not DID-keyed (cannot seal the result).";
647
+ const parsedDelegator = (0, a2a_client_1.parseDidKey)(delegatorDid);
648
+ if (!parsedDelegator)
649
+ return `cannot seal: delegating peer DID is unparseable (${delegatorDid}).`;
650
+ const endpointUrl = delegator.agentMeta?.a2a?.endpointUrl;
651
+ if (!endpointUrl)
652
+ return "delegating peer has no reachable A2A endpoint to return the result to.";
653
+ /* v8 ignore next -- in-turn send_result always carries a bundle agentRoot; the ambient-name fallback is defensive (same pattern as connect_to) @preserve */
654
+ const agentName = agentNameFromRoot(ctx?.agentRoot) ?? "ouro-agent";
655
+ try {
656
+ const sodium = await (0, a2a_client_1.ready)();
657
+ const self = await (0, identity_2.loadSelfA2AIdentity)({ agentName, sodium });
658
+ // prepareMissionResult records the result first-party (results[requestId]) +
659
+ // returns the MissionResultEnvelope (consent-gated: trust ≥ friend recipient).
660
+ const prepared = await (0, friends_1.prepareMissionResult)(missionStore, store, grantStore, {
661
+ missionId: found.missionId,
662
+ toAgentId: delegatorDid,
663
+ requestId: args.request_id,
664
+ selfAgentId: self.did,
665
+ result: { summary: args.summary, ...(args.artifact ? { artifact: args.artifact } : {}) },
666
+ });
667
+ /* v8 ignore next 3 -- unreachable for a trusted DID-keyed delegator: the mission exists (it carried the imported delegation we matched) and the isTrustedLevel guard guarantees trust≥friend, which the tiered consent default approves. Defensive only. @preserve */
668
+ if (!prepared.ok) {
669
+ return `send_result refused (${prepared.status}).`;
670
+ }
671
+ // HARNESS-OWNED RESULT WIRE: seal the envelope (reusing the seal crypto), wrap
672
+ // it in the mission_result-tagged DataPart, and POST it to the delegator over
673
+ // direct. The inbound side routes it to importMissionResult (NOT receiveShare).
674
+ const sealed = (0, a2a_client_1.sealEnvelope)({
675
+ sodium,
676
+ envelope: prepared.envelope,
677
+ friendsKind: "coordination",
678
+ fromIdentity: { did: self.did, keyId: self.keyId, ed25519Priv: self.ed25519Priv },
679
+ recipientDid: delegatorDid,
680
+ recipientX25519Pub: (0, a2a_client_1.keyAgreementFromDidKey)({ sodium, ed25519Pub: parsedDelegator.ed25519Pub }),
681
+ });
682
+ const message = (0, mission_result_wire_1.wrapMissionResultDataPart)({ sealedEnvelope: sealed, recipientDid: delegatorDid });
683
+ (0, runtime_1.emitNervesEvent)({
684
+ component: "channels",
685
+ event: "channel.a2a_result_send",
686
+ message: "delivering sealed mission result over the direct rung",
687
+ meta: { selfDid: self.did, toDid: delegatorDid, requestId: args.request_id },
688
+ });
689
+ await (0, client_1.postA2AMessageEnvelope)({ endpointUrl, message });
690
+ return `result sent to ${delegator.name} (${delegatorDid}) for requestId ${args.request_id}.`;
691
+ }
692
+ catch (error) {
693
+ return `send_result error: ${error instanceof Error ? error.message : /* v8 ignore next -- defensive non-Error failures @preserve */ String(error)}`;
694
+ }
695
+ },
696
+ summaryKeys: ["request_id"],
697
+ riskProfile: { mutates: "external_side_effect", risk: "high", reason: "returns a result to a remote agent peer" },
698
+ },
282
699
  ];