@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.
- package/changelog.json +11 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/a2a/client.js +38 -6
- package/dist/a2a/outbox-client.js +2 -0
- package/dist/a2a/outbox-wire.js +12 -1
- package/dist/a2a/refusals.js +11 -1
- package/dist/a2a/relay-client.js +106 -0
- package/dist/a2a/relay-config-vault.js +64 -0
- package/dist/a2a/relay-contacts.js +120 -0
- package/dist/a2a/relay-inbox.js +360 -0
- package/dist/a2a/relay-retirements.js +89 -0
- package/dist/a2a/seen-ledger.js +7 -3
- package/dist/a2a/server.js +189 -171
- package/dist/a2a/transport.js +74 -19
- package/dist/repertoire/guardrails.js +4 -0
- package/dist/repertoire/tools-a2a.js +55 -12
- package/dist/senses/a2a-entry.js +8 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/a2a/server.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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);
|
package/dist/a2a/transport.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
47
|
+
if (target.rung !== "direct") {
|
|
21
48
|
(0, runtime_1.emitNervesEvent)({
|
|
22
49
|
component: "channels",
|
|
23
|
-
event: "channel.
|
|
24
|
-
message: "
|
|
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
|
-
|
|
35
|
-
|
|
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.
|
|
39
|
-
message: "
|
|
40
|
-
meta: {
|
|
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
|
-
|
|
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
|
];
|