@ouro.bot/cli 0.1.0-alpha.989 → 0.1.0-alpha.990

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.
@@ -58,6 +58,8 @@ const delegation_stores_1 = require("./delegation-stores");
58
58
  const task_store_1 = require("./task-store");
59
59
  const delegated_command_1 = require("./delegated-command");
60
60
  const refusals_1 = require("./refusals");
61
+ const relay_inbox_1 = require("./relay-inbox");
62
+ const relay_retirements_1 = require("./relay-retirements");
61
63
  const outbox_wire_1 = require("./outbox-wire");
62
64
  const failure_reports_1 = require("../heart/failure-reports");
63
65
  const MAX_A2A_REQUEST_BYTES = 128 * 1024;
@@ -143,7 +145,7 @@ function metadataString(message, key) {
143
145
  return metadataStringFromRecord(metadataRecord(message), key);
144
146
  }
145
147
  function headerString(req, key) {
146
- const value = req.headers[key];
148
+ const value = req?.headers[key];
147
149
  /* v8 ignore next -- Node lower-cases singular request headers here; array values are defensive compatibility @preserve */
148
150
  if (Array.isArray(value))
149
151
  return value[0];
@@ -430,6 +432,171 @@ async function startA2AServer(options) {
430
432
  }
431
433
  };
432
434
  let confirmTimer;
435
+ // The message/send branch as one function, so a sealed message that arrives through the relay inbox runs exactly the
436
+ // path a direct POST runs. `req` is absent for a relayed message: there are no HTTP headers to take a sender hint from.
437
+ const handleMessageSend = async (rpc, req) => {
438
+ const responseStyle = rpc.method === "message/send" ? "legacy" : "latest";
439
+ const parsedInbound = messageFromParams(rpc.params);
440
+ const inbound = parsedInbound ? { ...parsedInbound, kind: parsedInbound.kind ?? "message" } : null;
441
+ // HARNESS-OWNED RESULT WIRE (runs BEFORE the friends-share branch). A
442
+ // `mission_result`-tagged DataPart is NOT a FriendsKind — it routes to
443
+ // `importMissionResult` (assignee/correlation-gated, store-only, NO turn), never
444
+ // to `receiveShare`. Recognized by the `ouroKind` discriminator.
445
+ if (inbound && inboundShareDeps && (0, mission_result_wire_1.isMissionResultDataPart)(inbound)) {
446
+ const imported = await (0, mission_result_wire_1.receiveInboundMissionResult)(inbound, {
447
+ sodium: inboundShareDeps.sodium,
448
+ store: inboundShareDeps.store,
449
+ missionStore: inboundShareDeps.missionStore,
450
+ // The SAME authentication seam the share bridge uses — so a forged-sender
451
+ // result is rejected by the DidVerifier exactly like a forged share.
452
+ pinStore: inboundShareDeps.pinStore,
453
+ seen: inboundShareDeps.seen,
454
+ didResolution: inboundShareDeps.didResolution,
455
+ identity: inboundShareDeps.identity,
456
+ });
457
+ if (imported.outcome === "rejected") {
458
+ return refusalResponse(rpc.id, "result", imported.reason);
459
+ }
460
+ // imported (store-only, no turn): acknowledge with a minimal completed task.
461
+ /* v8 ignore next -- "not-a-result" is unreachable here (gated by isMissionResultDataPart) @preserve */
462
+ const ackText = imported.outcome === "imported"
463
+ ? `[a2a] imported mission result from ${imported.verifiedDid}`
464
+ /* v8 ignore next -- defensive: the gate above guarantees imported|rejected @preserve */
465
+ : "[a2a] mission result";
466
+ const contextId = inbound.contextId ?? "default";
467
+ const taskId = (0, node_crypto_1.randomUUID)();
468
+ const accessToken = (0, node_crypto_1.randomUUID)();
469
+ const ackTask = taskFor({ taskId, accessToken, contextId, inbound, state: "TASK_STATE_COMPLETED", response: ackText });
470
+ taskStore.put(ackTask, accessTokenScope(accessToken));
471
+ return jsonResponse(rpc.id, publicTask(ackTask, accessToken, responseStyle, true));
472
+ }
473
+ // Inbound friends-DataPart branch (runs BEFORE the legacy text path). A
474
+ // sealed DataPart is unwrapped + verified through the bridge; the turn is
475
+ // then keyed on the VERIFIED sender DID (replacing the unauthenticated
476
+ // sentinel). A non-friends (text) message falls through unchanged.
477
+ let verifiedPeerAgentId;
478
+ let verifiedPeerName;
479
+ let verifiedShareText;
480
+ let verifiedChat;
481
+ if (inbound && inboundShareDeps && messageHasDataPart(inbound)) {
482
+ const bridged = await (0, inbound_share_1.receiveInboundShare)(inbound, inboundShareDeps);
483
+ if (bridged.outcome === "rejected") {
484
+ return refusalResponse(rpc.id, "share", bridged.reason);
485
+ }
486
+ // A message with a data part always resolves to completed|rejected — the
487
+ // bridge only returns "not-a-share" when there is NO data part, which the
488
+ // `messageHasDataPart` gate above excludes. So this is the completed path.
489
+ /* v8 ignore next -- "not-a-share" is unreachable here (gated by messageHasDataPart) @preserve */
490
+ if (bridged.outcome === "completed") {
491
+ verifiedPeerAgentId = bridged.verifiedDid;
492
+ verifiedPeerName = bridged.verifiedDid;
493
+ if (bridged.message) {
494
+ // Authenticated chat: the signed, sealed text IS the turn.
495
+ verifiedShareText = bridged.message.text;
496
+ /* v8 ignore next -- a completed chat always has a friend record: an unknown DID reads as stranger and the message is refused @preserve */
497
+ verifiedPeerName = bridged.friend?.name ?? bridged.verifiedDid;
498
+ verifiedChat = { did: bridged.verifiedDid, friend: bridged.friend, ...(bridged.message.onBehalfOf ? { onBehalfOf: bridged.message.onBehalfOf } : {}), bindingId: bridged.bindingId, issuedAt: bridged.message.issuedAt };
499
+ }
500
+ else {
501
+ verifiedShareText = `[a2a] received ${bridged.friendsKind} (${bridged.status}) from ${bridged.verifiedDid}`;
502
+ }
503
+ }
504
+ }
505
+ const text = verifiedShareText ?? textFromMessage(inbound);
506
+ if (!inbound || !text) {
507
+ return errorResponse(rpc.id, -32602, "SendMessage requires a text message");
508
+ }
509
+ if (text.length > MAX_A2A_MESSAGE_TEXT_CHARS) {
510
+ return errorResponse(rpc.id, -32602, `SendMessage text exceeds ${MAX_A2A_MESSAGE_TEXT_CHARS} characters`);
511
+ }
512
+ const senderHint = senderHintFromMessage(req, inbound);
513
+ // The verified DID wins as the turn's peer identity; otherwise the legacy
514
+ // unauthenticated sentinel (text path, behavior-preserved).
515
+ const peerAgentId = verifiedPeerAgentId ?? untrustedTurnPeerExternalId();
516
+ const peerName = verifiedPeerName ?? senderHint.name;
517
+ const continuationToken = accessTokenFromParams(rpc.params);
518
+ const continuationTask = inbound.taskId && continuationToken
519
+ ? taskStore.get(inbound.taskId, accessTokenScope(continuationToken))
520
+ : null;
521
+ if (inbound.taskId && continuationToken && (!continuationTask || !taskAuthorized(continuationTask, continuationToken))) {
522
+ return errorResponse(rpc.id, -32001, "task not found");
523
+ }
524
+ if (continuationTask && isTerminalTaskState(continuationTask.status.state)) {
525
+ return errorResponse(rpc.id, -32002, "task is terminal; start a new task");
526
+ }
527
+ const contextId = inbound.contextId ?? continuationTask?.contextId ?? "default";
528
+ const taskId = continuationTask?.id ?? (0, node_crypto_1.randomUUID)();
529
+ const accessToken = continuationToken && continuationTask ? continuationToken : (0, node_crypto_1.randomUUID)();
530
+ const tokenScope = accessTokenScope(accessToken);
531
+ const clientTaskId = continuationTask ? taskA2AMetadata(continuationTask).clientTaskId : inbound.taskId;
532
+ let relationshipAuthorization = verifiedChat?.friend
533
+ ? a2aChatRelationship(agentRoot, verifiedChat.friend, taskId)
534
+ : undefined;
535
+ let turnText = text;
536
+ let delegatedCommand;
537
+ // Only the server writes the delegated banner. Any turn that is not about to be admitted as a delegated command
538
+ // (verified or not, including a plain unauthenticated text turn) gets a typed banner marked as the sender's own words.
539
+ if (verifiedChat?.onBehalfOf !== "principal")
540
+ turnText = (0, delegated_command_1.shieldDelegatedBanner)(text);
541
+ if (verifiedChat?.onBehalfOf === "principal") {
542
+ // A delegated command runs with the principal's authority only after the
543
+ // server, never the model, has checked the grant and told the principal.
544
+ let registry;
545
+ try {
546
+ registry = (0, relationship_authorization_1.loadRelationshipCapabilityRegistry)(agentRoot);
547
+ }
548
+ catch {
549
+ registry = undefined;
550
+ }
551
+ /* v8 ignore next -- a completed chat always carries the sender's friend record (see the receipt above) @preserve */
552
+ if (!verifiedChat.friend)
553
+ throw new Error("verified chat has no friend record");
554
+ 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 });
555
+ if (!admission.ok) {
556
+ // The code and the "delegated command refused: <reason>" prefix stay: the replay gate and older CLIs match them.
557
+ // 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.
558
+ const activeFamily = verifiedChat.friend.trustLevel === "family" && verifiedChat.friend.admissionState === "active";
559
+ const shown = activeFamily ? admission.reason : "no_grant";
560
+ const guidance = (0, delegated_command_1.delegationRefusalGuidance)(shown, { legacyRecordGrant: activeFamily && verifiedChat.friend.delegationGrant !== undefined });
561
+ return errorResponse(rpc.id, -32004, `delegated command refused: ${shown} (${guidance.message}). Nothing ran. Next: ${guidance.next}`, { reason: shown, nothingRan: true, retry: guidance.retry, next: guidance.next });
562
+ }
563
+ relationshipAuthorization = admission.relationship;
564
+ turnText = admission.turnText;
565
+ delegatedCommand = admission.context;
566
+ }
567
+ taskStore.put(taskFor({ taskId, accessToken, contextId, inbound, state: "TASK_STATE_WORKING", clientTaskId, previousTask: continuationTask ?? undefined }), tokenScope);
568
+ const turn = await turnRunner({
569
+ agentName: options.agentName,
570
+ peerAgentId,
571
+ peerName,
572
+ sessionKey: contextId,
573
+ message: turnText,
574
+ ...(relationshipAuthorization ? { relationshipAuthorization } : {}),
575
+ ...(delegatedCommand ? { delegatedCommand } : {}),
576
+ });
577
+ let responseParts;
578
+ if (verifiedChat) {
579
+ // A verified chat's reply is sealed to the sender and stored sealed, so neither
580
+ // the wire nor a later GetTask ever carries the plaintext.
581
+ const pinned = inboundShareDeps.pinStore.get(verifiedChat.did);
582
+ /* v8 ignore next 4 -- receiveShare pins the verified sender before it reports a completed chat; refuse rather than reply in plaintext @preserve */
583
+ if (!pinned) {
584
+ return errorResponse(rpc.id, -32003, "A2A chat reply cannot be sealed to the sender");
585
+ }
586
+ responseParts = (0, sealed_chat_1.sealChatMessage)({
587
+ sodium: inboundShareDeps.sodium,
588
+ from: options.identity,
589
+ recipientDid: verifiedChat.did,
590
+ recipientEd25519Pub: pinned.ed25519Pub,
591
+ text: turn.response,
592
+ conversationId: contextId,
593
+ }).parts;
594
+ }
595
+ const task = taskFor({ taskId, accessToken, contextId, inbound, state: "TASK_STATE_COMPLETED", response: turn.response, responseParts, clientTaskId, previousTask: continuationTask ?? undefined });
596
+ taskStore.put(task, tokenScope);
597
+ return jsonResponse(rpc.id, publicTask(task, accessToken, responseStyle, true));
598
+ };
599
+ let relayInbox;
433
600
  let publicBaseUrl = options.baseUrl;
434
601
  const server = http.createServer(async (req, res) => {
435
602
  /* v8 ignore next -- Node always supplies url/host for accepted HTTP requests; fallback keeps malformed local calls safe @preserve */
@@ -484,7 +651,11 @@ async function startA2AServer(options) {
484
651
  writeJson(res, 200, errorResponse(rpc.id, outbox_wire_1.OUTBOX_ERROR_INVALID, "the signed method does not match the requested method"));
485
652
  return;
486
653
  }
487
- const outcome = await (0, outbox_wire_1.handleOutboxCommand)({ agentRoot, friend: bridged.friend, method: command.method, params: command.params, verifiedDid: bridged.verifiedDid });
654
+ const ownRelay = relayInbox?.registration();
655
+ const outcome = await (0, outbox_wire_1.handleOutboxCommand)({
656
+ agentRoot, friend: bridged.friend, method: command.method, params: command.params, verifiedDid: bridged.verifiedDid,
657
+ relayContact: ownRelay ? { url: ownRelay.relayUrl, handle: ownRelay.handle, sendCredential: ownRelay.sendCredential } : null,
658
+ });
488
659
  if (!outcome.ok) {
489
660
  writeJson(res, 200, errorResponse(rpc.id, outcome.code, outcome.message));
490
661
  return;
@@ -506,175 +677,7 @@ async function startA2AServer(options) {
506
677
  return;
507
678
  }
508
679
  if (rpc.method === "SendMessage" || rpc.method === "message/send") {
509
- const responseStyle = rpc.method === "message/send" ? "legacy" : "latest";
510
- const parsedInbound = messageFromParams(rpc.params);
511
- const inbound = parsedInbound ? { ...parsedInbound, kind: parsedInbound.kind ?? "message" } : null;
512
- // HARNESS-OWNED RESULT WIRE (runs BEFORE the friends-share branch). A
513
- // `mission_result`-tagged DataPart is NOT a FriendsKind — it routes to
514
- // `importMissionResult` (assignee/correlation-gated, store-only, NO turn), never
515
- // to `receiveShare`. Recognized by the `ouroKind` discriminator.
516
- if (inbound && inboundShareDeps && (0, mission_result_wire_1.isMissionResultDataPart)(inbound)) {
517
- const imported = await (0, mission_result_wire_1.receiveInboundMissionResult)(inbound, {
518
- sodium: inboundShareDeps.sodium,
519
- store: inboundShareDeps.store,
520
- missionStore: inboundShareDeps.missionStore,
521
- // The SAME authentication seam the share bridge uses — so a forged-sender
522
- // result is rejected by the DidVerifier exactly like a forged share.
523
- pinStore: inboundShareDeps.pinStore,
524
- seen: inboundShareDeps.seen,
525
- didResolution: inboundShareDeps.didResolution,
526
- identity: inboundShareDeps.identity,
527
- });
528
- if (imported.outcome === "rejected") {
529
- writeJson(res, 200, refusalResponse(rpc.id, "result", imported.reason));
530
- return;
531
- }
532
- // imported (store-only, no turn): acknowledge with a minimal completed task.
533
- /* v8 ignore next -- "not-a-result" is unreachable here (gated by isMissionResultDataPart) @preserve */
534
- const ackText = imported.outcome === "imported"
535
- ? `[a2a] imported mission result from ${imported.verifiedDid}`
536
- /* v8 ignore next -- defensive: the gate above guarantees imported|rejected @preserve */
537
- : "[a2a] mission result";
538
- const contextId = inbound.contextId ?? "default";
539
- const taskId = (0, node_crypto_1.randomUUID)();
540
- const accessToken = (0, node_crypto_1.randomUUID)();
541
- const ackTask = taskFor({ taskId, accessToken, contextId, inbound, state: "TASK_STATE_COMPLETED", response: ackText });
542
- taskStore.put(ackTask, accessTokenScope(accessToken));
543
- writeJson(res, 200, jsonResponse(rpc.id, publicTask(ackTask, accessToken, responseStyle, true)));
544
- return;
545
- }
546
- // Inbound friends-DataPart branch (runs BEFORE the legacy text path). A
547
- // sealed DataPart is unwrapped + verified through the bridge; the turn is
548
- // then keyed on the VERIFIED sender DID (replacing the unauthenticated
549
- // sentinel). A non-friends (text) message falls through unchanged.
550
- let verifiedPeerAgentId;
551
- let verifiedPeerName;
552
- let verifiedShareText;
553
- let verifiedChat;
554
- if (inbound && inboundShareDeps && messageHasDataPart(inbound)) {
555
- const bridged = await (0, inbound_share_1.receiveInboundShare)(inbound, inboundShareDeps);
556
- if (bridged.outcome === "rejected") {
557
- writeJson(res, 200, refusalResponse(rpc.id, "share", bridged.reason));
558
- return;
559
- }
560
- // A message with a data part always resolves to completed|rejected — the
561
- // bridge only returns "not-a-share" when there is NO data part, which the
562
- // `messageHasDataPart` gate above excludes. So this is the completed path.
563
- /* v8 ignore next -- "not-a-share" is unreachable here (gated by messageHasDataPart) @preserve */
564
- if (bridged.outcome === "completed") {
565
- verifiedPeerAgentId = bridged.verifiedDid;
566
- verifiedPeerName = bridged.verifiedDid;
567
- if (bridged.message) {
568
- // Authenticated chat: the signed, sealed text IS the turn.
569
- verifiedShareText = bridged.message.text;
570
- /* v8 ignore next -- a completed chat always has a friend record: an unknown DID reads as stranger and the message is refused @preserve */
571
- verifiedPeerName = bridged.friend?.name ?? bridged.verifiedDid;
572
- verifiedChat = { did: bridged.verifiedDid, friend: bridged.friend, ...(bridged.message.onBehalfOf ? { onBehalfOf: bridged.message.onBehalfOf } : {}), bindingId: bridged.bindingId, issuedAt: bridged.message.issuedAt };
573
- }
574
- else {
575
- verifiedShareText = `[a2a] received ${bridged.friendsKind} (${bridged.status}) from ${bridged.verifiedDid}`;
576
- }
577
- }
578
- }
579
- const text = verifiedShareText ?? textFromMessage(inbound);
580
- if (!inbound || !text) {
581
- writeJson(res, 200, errorResponse(rpc.id, -32602, "SendMessage requires a text message"));
582
- return;
583
- }
584
- if (text.length > MAX_A2A_MESSAGE_TEXT_CHARS) {
585
- writeJson(res, 200, errorResponse(rpc.id, -32602, `SendMessage text exceeds ${MAX_A2A_MESSAGE_TEXT_CHARS} characters`));
586
- return;
587
- }
588
- const senderHint = senderHintFromMessage(req, inbound);
589
- // The verified DID wins as the turn's peer identity; otherwise the legacy
590
- // unauthenticated sentinel (text path, behavior-preserved).
591
- const peerAgentId = verifiedPeerAgentId ?? untrustedTurnPeerExternalId();
592
- const peerName = verifiedPeerName ?? senderHint.name;
593
- const continuationToken = accessTokenFromParams(rpc.params);
594
- const continuationTask = inbound.taskId && continuationToken
595
- ? taskStore.get(inbound.taskId, accessTokenScope(continuationToken))
596
- : null;
597
- if (inbound.taskId && continuationToken && (!continuationTask || !taskAuthorized(continuationTask, continuationToken))) {
598
- writeJson(res, 200, errorResponse(rpc.id, -32001, "task not found"));
599
- return;
600
- }
601
- if (continuationTask && isTerminalTaskState(continuationTask.status.state)) {
602
- writeJson(res, 200, errorResponse(rpc.id, -32002, "task is terminal; start a new task"));
603
- return;
604
- }
605
- const contextId = inbound.contextId ?? continuationTask?.contextId ?? "default";
606
- const taskId = continuationTask?.id ?? (0, node_crypto_1.randomUUID)();
607
- const accessToken = continuationToken && continuationTask ? continuationToken : (0, node_crypto_1.randomUUID)();
608
- const tokenScope = accessTokenScope(accessToken);
609
- const clientTaskId = continuationTask ? taskA2AMetadata(continuationTask).clientTaskId : inbound.taskId;
610
- let relationshipAuthorization = verifiedChat?.friend
611
- ? a2aChatRelationship(agentRoot, verifiedChat.friend, taskId)
612
- : undefined;
613
- let turnText = text;
614
- let delegatedCommand;
615
- // Only the server writes the delegated banner. Any turn that is not about to be admitted as a delegated command
616
- // (verified or not, including a plain unauthenticated text turn) gets a typed banner marked as the sender's own words.
617
- if (verifiedChat?.onBehalfOf !== "principal")
618
- turnText = (0, delegated_command_1.shieldDelegatedBanner)(text);
619
- if (verifiedChat?.onBehalfOf === "principal") {
620
- // A delegated command runs with the principal's authority only after the
621
- // server, never the model, has checked the grant and told the principal.
622
- let registry;
623
- try {
624
- registry = (0, relationship_authorization_1.loadRelationshipCapabilityRegistry)(agentRoot);
625
- }
626
- catch {
627
- registry = undefined;
628
- }
629
- /* v8 ignore next -- a completed chat always carries the sender's friend record (see the receipt above) @preserve */
630
- if (!verifiedChat.friend)
631
- throw new Error("verified chat has no friend record");
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 });
633
- if (!admission.ok) {
634
- // The code and the "delegated command refused: <reason>" prefix stay: the replay gate and older CLIs match them.
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.
636
- const activeFamily = verifiedChat.friend.trustLevel === "family" && verifiedChat.friend.admissionState === "active";
637
- const shown = activeFamily ? admission.reason : "no_grant";
638
- const guidance = (0, delegated_command_1.delegationRefusalGuidance)(shown, { legacyRecordGrant: activeFamily && verifiedChat.friend.delegationGrant !== undefined });
639
- writeJson(res, 200, errorResponse(rpc.id, -32004, `delegated command refused: ${shown} (${guidance.message}). Nothing ran. Next: ${guidance.next}`, { reason: shown, nothingRan: true, retry: guidance.retry, next: guidance.next }));
640
- return;
641
- }
642
- relationshipAuthorization = admission.relationship;
643
- turnText = admission.turnText;
644
- delegatedCommand = admission.context;
645
- }
646
- taskStore.put(taskFor({ taskId, accessToken, contextId, inbound, state: "TASK_STATE_WORKING", clientTaskId, previousTask: continuationTask ?? undefined }), tokenScope);
647
- const turn = await turnRunner({
648
- agentName: options.agentName,
649
- peerAgentId,
650
- peerName,
651
- sessionKey: contextId,
652
- message: turnText,
653
- ...(relationshipAuthorization ? { relationshipAuthorization } : {}),
654
- ...(delegatedCommand ? { delegatedCommand } : {}),
655
- });
656
- let responseParts;
657
- if (verifiedChat) {
658
- // A verified chat's reply is sealed to the sender and stored sealed, so neither
659
- // the wire nor a later GetTask ever carries the plaintext.
660
- const pinned = inboundShareDeps.pinStore.get(verifiedChat.did);
661
- /* v8 ignore next 4 -- receiveShare pins the verified sender before it reports a completed chat; refuse rather than reply in plaintext @preserve */
662
- if (!pinned) {
663
- writeJson(res, 200, errorResponse(rpc.id, -32003, "A2A chat reply cannot be sealed to the sender"));
664
- return;
665
- }
666
- responseParts = (0, sealed_chat_1.sealChatMessage)({
667
- sodium: inboundShareDeps.sodium,
668
- from: options.identity,
669
- recipientDid: verifiedChat.did,
670
- recipientEd25519Pub: pinned.ed25519Pub,
671
- text: turn.response,
672
- conversationId: contextId,
673
- }).parts;
674
- }
675
- const task = taskFor({ taskId, accessToken, contextId, inbound, state: "TASK_STATE_COMPLETED", response: turn.response, responseParts, clientTaskId, previousTask: continuationTask ?? undefined });
676
- taskStore.put(task, tokenScope);
677
- writeJson(res, 200, jsonResponse(rpc.id, publicTask(task, accessToken, responseStyle, true)));
680
+ writeJson(res, 200, await handleMessageSend(rpc, req));
678
681
  return;
679
682
  }
680
683
  if (rpc.method === "GetTask" || rpc.method === "tasks/get") {
@@ -759,6 +762,19 @@ async function startA2AServer(options) {
759
762
  confirmTimer = setInterval(() => { void confirmReports(); }, options.escalation.confirmIntervalMs ?? exports.ESCALATION_CONFIRM_INTERVAL_MS);
760
763
  confirmTimer.unref();
761
764
  }
765
+ if (options.relay && inboundShareDeps) {
766
+ const seen = inboundShareDeps.seen;
767
+ relayInbox = (0, relay_inbox_1.startRelayInbox)({
768
+ ...options.relay,
769
+ // Each old relay's failure clock, and a retirement waiting only for its vault write, survive a restart here.
770
+ retirements: (0, relay_retirements_1.fileRelayRetirements)(agentRoot),
771
+ did: inboundShareDeps.identity.did,
772
+ agentName: options.agentName,
773
+ handle: (rpc) => handleMessageSend(rpc),
774
+ // Asked only after a handler throws: was this copy verified (and so recorded) before it failed?
775
+ seen: seen.has.bind(seen),
776
+ });
777
+ }
762
778
  (0, runtime_1.emitNervesEvent)({
763
779
  component: "channels",
764
780
  event: "channel.a2a_server_started",
@@ -769,9 +785,11 @@ async function startA2AServer(options) {
769
785
  server,
770
786
  url: publicBaseUrl,
771
787
  endpointUrl: endpointUrl.toString(),
788
+ ...(relayInbox ? { relay: relayInbox } : {}),
772
789
  close: () => new Promise((resolve, reject) => {
773
790
  if (confirmTimer)
774
791
  clearInterval(confirmTimer);
792
+ relayInbox?.stop();
775
793
  server.close((error) => {
776
794
  if (error) {
777
795
  reject(error);
@@ -3,43 +3,98 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.makeA2ATransport = makeA2ATransport;
4
4
  const runtime_1 = require("../nerves/runtime");
5
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";
6
+ const relay_client_1 = require("./relay-client");
7
+ /** Failures that prove the request never reached the peer's agent. */
8
+ const NOT_SENT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "UND_ERR_CONNECT_TIMEOUT"]);
9
+ function causeCodes(error) {
10
+ const cause = error.cause;
11
+ const codes = [cause?.code, ...(cause?.errors ?? []).map((inner) => inner.code)];
12
+ return codes.filter((code) => typeof code === "string");
13
+ }
14
+ /** Gateway answers that can come after the request reached the agent: a bad gateway after the agent failed mid-turn,
15
+ * a gateway timeout, and Cloudflare's origin errors and timeouts. */
16
+ const MAY_HAVE_ARRIVED_STATUSES = new Set([502, 504, 520, 521, 522, 523, 524]);
17
+ /** True when the direct attempt certainly did not reach the agent: a refused or unroutable connection, or a reply from
18
+ * something other than the agent (a tunnel or proxy page, except a gateway answer that can follow a delivery). */
19
+ function certainlyNotArrived(error) {
20
+ if (error instanceof client_1.A2AHttpStatusError)
21
+ return !MAY_HAVE_ARRIVED_STATUSES.has(error.status);
22
+ /* v8 ignore next -- fetch only throws Error instances @preserve */
23
+ if (!(error instanceof Error))
24
+ return false;
25
+ const codes = causeCodes(error);
26
+ return codes.length > 0 && codes.every((code) => NOT_SENT_CODES.has(code));
27
+ }
28
+ /** The not-wired marker for rungs friends may name but the harness does not deliver on (the git mailbox). */
29
+ const NOT_WIRED = "A2A transport is not wired for this rung; the harness delivers direct, with the peer's friends-relay inbox as the fallback";
30
+ function describe(error) {
31
+ /* v8 ignore next -- fetch and the relay client only throw Error instances @preserve */
32
+ if (!(error instanceof Error))
33
+ return String(error);
34
+ const code = error.cause?.code;
35
+ return typeof code === "string" ? `${error.message} (${code})` : error.message;
36
+ }
8
37
  /**
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.
38
+ * Build the harness `A2ATransport` (the outbound seam friends' `sendShare` calls). The direct rung HTTP-POSTs the
39
+ * sealed message to the peer's endpoint as a JSON-RPC `message/send`. A JSON-RPC answer, result or error, means the
40
+ * peer was reached and decided. Anything else (a refused connection, a timeout, a tunnel or proxy page) means the
41
+ * peer is not there, and the SAME sealed message is queued at the peer's relay inbox, if the peer shared one. Sending
42
+ * identical bytes is what makes a double delivery harmless: the peer's seen ledger rejects the second copy.
16
43
  */
17
44
  function makeA2ATransport(input = {}) {
18
45
  return {
19
46
  async send(target, message) {
20
- if (target.rung === "direct") {
47
+ if (target.rung !== "direct") {
21
48
  (0, runtime_1.emitNervesEvent)({
22
49
  component: "channels",
23
- event: "channel.a2a_transport_direct_send",
24
- message: "delivering sealed A2A message over the direct rung",
25
- meta: { address: target.address },
50
+ event: "channel.a2a_transport_not_wired",
51
+ message: "refused A2A transport over a rung the harness does not deliver on",
52
+ meta: { rung: target.rung, address: target.address },
26
53
  });
54
+ throw new Error(`${NOT_WIRED} (rung: ${target.rung})`);
55
+ }
56
+ (0, runtime_1.emitNervesEvent)({
57
+ component: "channels",
58
+ event: "channel.a2a_transport_direct_send",
59
+ message: "delivering sealed A2A message over the direct rung",
60
+ meta: { address: target.address },
61
+ });
62
+ let directError;
63
+ try {
27
64
  await (0, client_1.postA2AMessageEnvelope)({
28
65
  endpointUrl: target.address,
29
66
  message,
30
67
  ...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
31
68
  });
69
+ input.onDelivered?.("direct");
32
70
  return;
33
71
  }
34
- // relay / mailbox: the typed-but-stubbed seam. Loud refusal (security-relevant:
35
- // we never silently fall through to an untrusted relay).
72
+ catch (error) {
73
+ if (error instanceof client_1.A2ARpcError)
74
+ throw error;
75
+ directError = error;
76
+ }
77
+ const contact = input.relayContact;
78
+ if (!contact) {
79
+ const fate = certainlyNotArrived(directError) ? "it was not delivered" : "it may not have been delivered";
80
+ throw new Error(`${describe(directError)}; ${fate}, and the peer has shared no relay contact to queue it for later`);
81
+ }
36
82
  (0, runtime_1.emitNervesEvent)({
37
83
  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" },
84
+ event: "channel.a2a_transport_relay_fallback",
85
+ message: "peer not reachable directly; queuing the sealed A2A message at its relay",
86
+ meta: { address: target.address, relayUrl: contact.url, directError: describe(directError) },
41
87
  });
42
- throw new Error(`${RELAY_NOT_WIRED} (rung: ${target.rung})`);
88
+ try {
89
+ await (0, relay_client_1.relaySend)({ url: contact.url, handle: contact.handle, sendCredential: contact.sendCredential, message, fetchImpl: input.fetchImpl });
90
+ }
91
+ catch (error) {
92
+ if (error instanceof relay_client_1.RelayHttpError && [401, 403, 404].includes(error.status))
93
+ input.onRelayContactRefused?.();
94
+ const fate = certainlyNotArrived(directError) ? "the message was not delivered" : "the message may not have been delivered";
95
+ throw new Error(`peer not reachable directly (${describe(directError)}) or through its relay (${describe(error)}); ${fate}`);
96
+ }
97
+ input.onDelivered?.("relay", { directMayHaveArrived: !certainlyNotArrived(directError) });
43
98
  },
44
99
  };
45
100
  }
@@ -71,6 +71,10 @@ const PROTECTED_PATH_SEGMENTS = [
71
71
  "state/policy/",
72
72
  // The pre-trust-directory home of the escalation grants; the file is no longer read, and a model must not recreate it.
73
73
  "state/a2a/escalation-grants.json",
74
+ // Where to queue mail for offline peers; a model that rewrote a contact could send a peer's mail to its own relay.
75
+ "state/a2a/relay-contacts/",
76
+ // Relay retirements awaiting their vault write; a forged marker would skip draining an old relay inbox.
77
+ "state/a2a/relay-retired/",
74
78
  "state/outbox/",
75
79
  "state/reports/",
76
80
  ];