@ouro.bot/cli 0.1.0-alpha.833 → 0.1.0-alpha.835

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.
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EMPTY_CHAT_REPLY = void 0;
4
+ exports.sealChatMessage = sealChatMessage;
5
+ exports.openChatMessage = openChatMessage;
6
+ /**
7
+ * Sealed agent-to-agent chat over A2A (friends `message` kind).
8
+ *
9
+ * Both directions use the same primitive: the text is signed by the sender and
10
+ * sealed to the recipient inside ONE friends DataPart, so a message is confidential
11
+ * and tamper-evident over any transport (tailnet, LAN, relay). The network is a
12
+ * pipe, not the lock.
13
+ *
14
+ * - `sealChatMessage` builds the single-DataPart A2A message for a recipient whose
15
+ * Ed25519 key the sender already trusts (a pinned DID, or a did:key card).
16
+ * - `openChatMessage` opens one through the SAME `receiveShare` pipeline every
17
+ * friends kind uses (unseal, sender binding, pinned-key signature, replay guard,
18
+ * trust floor), pinned to the sender the opener expects. There is no second,
19
+ * hand-rolled verification path.
20
+ */
21
+ const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
22
+ const friends_1 = require("@ouro.bot/friends");
23
+ const runtime_1 = require("../nerves/runtime");
24
+ /** What an agent replies when its turn produced no text; sealed like any reply. */
25
+ exports.EMPTY_CHAT_REPLY = "(no reply)";
26
+ /** Seal `text` from `from` to the recipient as a single friends DataPart. Returns
27
+ * the harness-shaped A2A message; callers add role/messageId/contextId. */
28
+ function sealChatMessage(input) {
29
+ const text = input.text.trim() === "" ? exports.EMPTY_CHAT_REPLY : input.text.slice(0, friends_1.MAX_MESSAGE_TEXT_CHARS);
30
+ const prepared = (0, friends_1.prepareMessage)({
31
+ fromAgentId: input.from.did,
32
+ text,
33
+ ...(input.conversationId !== undefined ? { conversationId: input.conversationId } : {}),
34
+ });
35
+ /* v8 ignore next -- the text is normalized above and the sender DID comes from a real identity @preserve */
36
+ if (!prepared.ok)
37
+ throw new Error(`cannot prepare chat message: ${prepared.status}`);
38
+ const sealed = (0, a2a_client_1.sealEnvelope)({
39
+ sodium: input.sodium,
40
+ envelope: prepared.envelope,
41
+ friendsKind: "message",
42
+ fromIdentity: input.from,
43
+ recipientDid: input.recipientDid,
44
+ recipientX25519Pub: (0, a2a_client_1.keyAgreementFromDidKey)({ sodium: input.sodium, ed25519Pub: input.recipientEd25519Pub }),
45
+ });
46
+ const wrapped = (0, a2a_client_1.wrapInDataPart)({ sealedEnvelope: sealed, recipientDid: input.recipientDid });
47
+ (0, runtime_1.emitNervesEvent)({
48
+ component: "channels",
49
+ event: "channel.a2a_chat_sealed",
50
+ message: "sealed an A2A chat message",
51
+ meta: { recipientDid: input.recipientDid, chars: text.length },
52
+ });
53
+ return { parts: wrapped.parts };
54
+ }
55
+ class MemorySeenLedger {
56
+ seen = new Set();
57
+ isSeen(nonce) { return this.seen.has(nonce); }
58
+ markSeen(nonce) { this.seen.add(nonce); }
59
+ }
60
+ /** A `message` receipt imports nothing, so the stores must never be reached. */
61
+ function unreachableStore(name) {
62
+ return new Proxy({}, { get() { throw new Error(`sealed chat must not touch the ${name}`); } });
63
+ }
64
+ /** Open a sealed chat message from an expected sender through `receiveShare`. */
65
+ async function openChatMessage(input) {
66
+ const pinStore = new a2a_client_1.MemoryPinStore();
67
+ (0, a2a_client_1.pinOnFirstContact)({ pinStore, fromAgentId: input.senderDid, did: input.senderDid, ed25519Pub: input.senderEd25519Pub });
68
+ const result = await (0, a2a_client_1.receiveShare)({
69
+ sodium: input.sodium,
70
+ store: unreachableStore("friend store"),
71
+ missionStore: unreachableStore("mission store"),
72
+ pinStore,
73
+ // Only the pre-pinned sender can verify: any other signer resolves to nothing.
74
+ didResolution: {
75
+ async resolveAndPin({ fromAgentId }) {
76
+ const pinned = pinStore.get(fromAgentId);
77
+ return pinned ? { ed25519Pub: pinned.ed25519Pub } : null;
78
+ },
79
+ },
80
+ seen: new MemorySeenLedger(),
81
+ a2aMessage: input.message,
82
+ recipientDid: input.self.did,
83
+ recipientIdentity: { x25519Priv: input.self.x25519Priv, x25519Pub: input.self.x25519Pub },
84
+ // The opener chose this sender; the signature, not this level, is the gate.
85
+ trustOfSource: "family",
86
+ });
87
+ if (result.state === "rejected") {
88
+ (0, runtime_1.emitNervesEvent)({
89
+ level: "warn",
90
+ component: "channels",
91
+ event: "channel.a2a_chat_open_rejected",
92
+ message: "refused a sealed A2A chat message",
93
+ meta: { senderDid: input.senderDid, reason: result.reason },
94
+ });
95
+ return { ok: false, reason: result.reason };
96
+ }
97
+ /* v8 ignore next -- any other kind reaches a store, and these stores throw: only a message can complete here @preserve */
98
+ if (result.friendsKind !== "message" || !result.message)
99
+ return { ok: false, reason: "not_a_message" };
100
+ (0, runtime_1.emitNervesEvent)({
101
+ component: "channels",
102
+ event: "channel.a2a_chat_opened",
103
+ message: "opened a verified A2A chat message",
104
+ meta: { senderDid: input.senderDid, chars: result.message.text.length },
105
+ });
106
+ return {
107
+ ok: true,
108
+ text: result.message.text,
109
+ ...(result.message.conversationId !== undefined ? { conversationId: result.message.conversationId } : {}),
110
+ issuedAt: result.message.issuedAt,
111
+ };
112
+ }
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.a2aChatRelationship = a2aChatRelationship;
36
37
  exports.startA2AServer = startA2AServer;
37
38
  const http = __importStar(require("node:http"));
38
39
  const node_crypto_1 = require("node:crypto");
@@ -47,6 +48,8 @@ const pin_store_1 = require("./pin-store");
47
48
  const seen_ledger_1 = require("./seen-ledger");
48
49
  const did_resolution_1 = require("./did-resolution");
49
50
  const inbound_share_1 = require("./inbound-share");
51
+ const sealed_chat_1 = require("./sealed-chat");
52
+ const relationship_authorization_1 = require("../repertoire/relationship-authorization");
50
53
  const mission_result_wire_1 = require("./mission-result-wire");
51
54
  const delegation_stores_1 = require("./delegation-stores");
52
55
  const task_store_1 = require("./task-store");
@@ -155,10 +158,24 @@ async function defaultTurnRunner(input) {
155
158
  externalId: input.peerAgentId,
156
159
  displayName: input.peerName,
157
160
  },
161
+ ...(input.relationshipAuthorization ? { toolContext: { relationshipAuthorization: input.relationshipAuthorization } } : {}),
158
162
  });
159
163
  return { response: result.response };
160
164
  }
161
165
  /* v8 ignore stop */
166
+ /** The relationship that scopes a verified friend's A2A chat turn, from the agent's
167
+ * own capability registry. No registry (or an invalid one) means no relationship,
168
+ * which Sanctuary's tool selection turns into "no tools" for a remote turn. */
169
+ function a2aChatRelationship(agentRoot, friend, requestId) {
170
+ let registry;
171
+ try {
172
+ registry = (0, relationship_authorization_1.loadRelationshipCapabilityRegistry)(agentRoot);
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ return (0, relationship_authorization_1.createRelationshipAuthorizationEvaluator)({ friend, registry, requestId, requestPhase: "inbound" });
178
+ }
162
179
  function accessTokenHash(accessToken) {
163
180
  return (0, node_crypto_1.createHash)("sha256").update(accessToken).digest("hex");
164
181
  }
@@ -223,9 +240,15 @@ function taskA2AMetadata(task) {
223
240
  return {};
224
241
  return a2a;
225
242
  }
243
+ /** A friends sealed DataPart: opaque ciphertext addressed to one DID, so it is safe
244
+ * to expose to the task holder (a sealed chat reply travels this way). */
245
+ function isSealedFriendsPart(part) {
246
+ const data = part.data;
247
+ return part.kind === "data" && !!data && typeof data === "object" && typeof data.sealed === "object" && data.sealed !== null && typeof data.recipientDid === "string";
248
+ }
226
249
  function publicMessage(message, style) {
227
250
  const parts = message.parts
228
- .filter((part) => part && typeof part === "object" && typeof part.text === "string")
251
+ .filter((part) => part && typeof part === "object" && (typeof part.text === "string" || isSealedFriendsPart(part)))
229
252
  .map((part) => style === "legacy" ? { ...part, kind: part.kind ?? "text" } : part);
230
253
  if (style === "latest")
231
254
  return { ...message, kind: "message", parts };
@@ -293,14 +316,15 @@ function taskFor(input) {
293
316
  const now = new Date().toISOString();
294
317
  const history = [...(input.previousTask?.history ?? []), input.inbound];
295
318
  const previousA2A = input.previousTask ? taskA2AMetadata(input.previousTask) : {};
296
- const responseMessage = input.response
319
+ const responseParts = input.responseParts ?? (input.response ? [{ text: input.response }] : undefined);
320
+ const responseMessage = responseParts
297
321
  ? {
298
322
  kind: "message",
299
323
  role: "ROLE_AGENT",
300
324
  taskId: input.taskId,
301
325
  contextId: input.contextId,
302
326
  messageId: (0, node_crypto_1.randomUUID)(),
303
- parts: [{ text: input.response }],
327
+ parts: responseParts,
304
328
  }
305
329
  : undefined;
306
330
  if (responseMessage)
@@ -438,6 +462,7 @@ async function startA2AServer(options) {
438
462
  let verifiedPeerAgentId;
439
463
  let verifiedPeerName;
440
464
  let verifiedShareText;
465
+ let verifiedChat;
441
466
  if (inbound && inboundShareDeps && messageHasDataPart(inbound)) {
442
467
  const bridged = await (0, inbound_share_1.receiveInboundShare)(inbound, inboundShareDeps);
443
468
  if (bridged.outcome === "rejected") {
@@ -451,7 +476,16 @@ async function startA2AServer(options) {
451
476
  if (bridged.outcome === "completed") {
452
477
  verifiedPeerAgentId = bridged.verifiedDid;
453
478
  verifiedPeerName = bridged.verifiedDid;
454
- verifiedShareText = `[a2a] received ${bridged.friendsKind} (${bridged.status}) from ${bridged.verifiedDid}`;
479
+ if (bridged.message) {
480
+ // Authenticated chat: the signed, sealed text IS the turn.
481
+ verifiedShareText = bridged.message.text;
482
+ /* v8 ignore next -- a completed chat always has a friend record: an unknown DID reads as stranger and the message is refused @preserve */
483
+ verifiedPeerName = bridged.friend?.name ?? bridged.verifiedDid;
484
+ verifiedChat = { did: bridged.verifiedDid, friend: bridged.friend };
485
+ }
486
+ else {
487
+ verifiedShareText = `[a2a] received ${bridged.friendsKind} (${bridged.status}) from ${bridged.verifiedDid}`;
488
+ }
455
489
  }
456
490
  }
457
491
  const text = verifiedShareText ?? textFromMessage(inbound);
@@ -486,14 +520,37 @@ async function startA2AServer(options) {
486
520
  const tokenScope = accessTokenScope(accessToken);
487
521
  const clientTaskId = continuationTask ? taskA2AMetadata(continuationTask).clientTaskId : inbound.taskId;
488
522
  taskStore.put(taskFor({ taskId, accessToken, contextId, inbound, state: "TASK_STATE_WORKING", clientTaskId, previousTask: continuationTask ?? undefined }), tokenScope);
523
+ const relationshipAuthorization = verifiedChat?.friend
524
+ ? a2aChatRelationship(agentRoot, verifiedChat.friend, taskId)
525
+ : undefined;
489
526
  const turn = await turnRunner({
490
527
  agentName: options.agentName,
491
528
  peerAgentId,
492
529
  peerName,
493
530
  sessionKey: contextId,
494
531
  message: text,
532
+ ...(relationshipAuthorization ? { relationshipAuthorization } : {}),
495
533
  });
496
- const task = taskFor({ taskId, accessToken, contextId, inbound, state: "TASK_STATE_COMPLETED", response: turn.response, clientTaskId, previousTask: continuationTask ?? undefined });
534
+ let responseParts;
535
+ if (verifiedChat) {
536
+ // A verified chat's reply is sealed to the sender and stored sealed, so neither
537
+ // the wire nor a later GetTask ever carries the plaintext.
538
+ const pinned = inboundShareDeps.pinStore.get(verifiedChat.did);
539
+ /* v8 ignore next 4 -- receiveShare pins the verified sender before it reports a completed chat; refuse rather than reply in plaintext @preserve */
540
+ if (!pinned) {
541
+ writeJson(res, 200, errorResponse(rpc.id, -32003, "A2A chat reply cannot be sealed to the sender"));
542
+ return;
543
+ }
544
+ responseParts = (0, sealed_chat_1.sealChatMessage)({
545
+ sodium: inboundShareDeps.sodium,
546
+ from: options.identity,
547
+ recipientDid: verifiedChat.did,
548
+ recipientEd25519Pub: pinned.ed25519Pub,
549
+ text: turn.response,
550
+ conversationId: contextId,
551
+ }).parts;
552
+ }
553
+ const task = taskFor({ taskId, accessToken, contextId, inbound, state: "TASK_STATE_COMPLETED", response: turn.response, responseParts, clientTaskId, previousTask: continuationTask ?? undefined });
497
554
  taskStore.put(task, tokenScope);
498
555
  writeJson(res, 200, jsonResponse(rpc.id, publicTask(task, accessToken, responseStyle, true)));
499
556
  return;
@@ -6062,6 +6062,29 @@ async function executeFriendCommand(command, store) {
6062
6062
  return `identity not linked: ${command.provider}:${command.externalId}`;
6063
6063
  return `unlinked ${command.provider}:${command.externalId} from ${command.friendId}`;
6064
6064
  }
6065
+ async function executeA2AClientCommand(command, deps) {
6066
+ const { loadOrMintA2AIdentityFile } = await Promise.resolve().then(() => __importStar(require("../../a2a/identity")));
6067
+ /* v8 ignore next -- production CLI keeps the client key under the home dir; tests pass an explicit file @preserve */
6068
+ const identityFile = command.identityFile ?? path.join(os.homedir(), ".ouro-cli", "a2a", "client-identity.json");
6069
+ const identity = await loadOrMintA2AIdentityFile({ filePath: identityFile });
6070
+ if (command.kind === "a2a.identity") {
6071
+ const message = command.json ? JSON.stringify({ did: identity.did, identityFile }) : `did: ${identity.did}\nidentity file: ${identityFile}`;
6072
+ deps.writeStdout(message);
6073
+ return message;
6074
+ }
6075
+ const { sendSealedA2AChat } = await Promise.resolve().then(() => __importStar(require("../../a2a/client")));
6076
+ const reply = await sendSealedA2AChat({
6077
+ cardUrl: command.to,
6078
+ text: command.text,
6079
+ ...(command.conversationId ? { conversationId: command.conversationId } : {}),
6080
+ identity,
6081
+ /* v8 ignore next -- production CLI uses global fetch; tests inject fetch for a hermetic peer @preserve */
6082
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
6083
+ });
6084
+ const message = command.json ? JSON.stringify(reply) : `${reply.peerName}: ${reply.text}\n[conversation: ${reply.conversationId}]`;
6085
+ deps.writeStdout(message);
6086
+ return message;
6087
+ }
6065
6088
  async function executeA2ACommand(command, deps) {
6066
6089
  if (command.kind === "a2a.card") {
6067
6090
  const { buildA2AAgentCard } = await Promise.resolve().then(() => __importStar(require("../../a2a/card")));
@@ -7132,6 +7155,10 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
7132
7155
  deps.writeStdout(message);
7133
7156
  return message;
7134
7157
  }
7158
+ // ── a2a client commands (this machine talks to an agent as a verified friend; no agent, no daemon) ──
7159
+ if (command.kind === "a2a.identity" || command.kind === "a2a.message") {
7160
+ return executeA2AClientCommand(command, deps);
7161
+ }
7135
7162
  // ── versions command (local install list + published update truth, no daemon socket needed) ──
7136
7163
  if (command.kind === "versions") {
7137
7164
  const versions = deps.listCliVersions?.() ?? [];
@@ -152,6 +152,8 @@ function usage() {
152
152
  " ouro a2a card [--agent <name>] [--base-url <url>] [--json]",
153
153
  " ouro a2a onboard [--agent <name>] --card-url <url> [--trust <level>] [--name <name>]",
154
154
  " ouro a2a serve [--agent <name>] [--host <host>] [--port <port>] [--base-url <url>] [--path <path>]",
155
+ " ouro a2a identity [--identity-file <path>] [--json]",
156
+ " ouro a2a message --to <card-url> --text <text> [--context <id>] [--identity-file <path>] [--json]",
155
157
  " ouro whoami [--agent <name>]",
156
158
  " ouro session list [--agent <name>]",
157
159
  " ouro mcp list",
@@ -1450,6 +1452,56 @@ function parseA2ACommand(args) {
1450
1452
  }
1451
1453
  return { kind: "a2a.card", ...(agent ? { agent } : {}), ...(baseUrl ? { baseUrl } : {}), ...(json ? { json: true } : {}) };
1452
1454
  }
1455
+ if (sub === "identity") {
1456
+ let identityFile;
1457
+ let json = false;
1458
+ for (let i = 0; i < rest.length; i += 1) {
1459
+ if (rest[i] === "--identity-file" && rest[i + 1]) {
1460
+ identityFile = rest[++i];
1461
+ continue;
1462
+ }
1463
+ if (rest[i] === "--json") {
1464
+ json = true;
1465
+ continue;
1466
+ }
1467
+ throw new Error("Usage: ouro a2a identity [--identity-file <path>] [--json]");
1468
+ }
1469
+ return { kind: "a2a.identity", ...(identityFile ? { identityFile } : {}), ...(json ? { json: true } : {}) };
1470
+ }
1471
+ if (sub === "message") {
1472
+ const usageText = "Usage: ouro a2a message --to <card-url> --text <text> [--context <id>] [--identity-file <path>] [--json]";
1473
+ let to;
1474
+ let text;
1475
+ let conversationId;
1476
+ let identityFile;
1477
+ let json = false;
1478
+ for (let i = 0; i < rest.length; i += 1) {
1479
+ if (rest[i] === "--to" && rest[i + 1]) {
1480
+ to = rest[++i];
1481
+ continue;
1482
+ }
1483
+ if (rest[i] === "--text" && rest[i + 1]) {
1484
+ text = rest[++i];
1485
+ continue;
1486
+ }
1487
+ if (rest[i] === "--context" && rest[i + 1]) {
1488
+ conversationId = rest[++i];
1489
+ continue;
1490
+ }
1491
+ if (rest[i] === "--identity-file" && rest[i + 1]) {
1492
+ identityFile = rest[++i];
1493
+ continue;
1494
+ }
1495
+ if (rest[i] === "--json") {
1496
+ json = true;
1497
+ continue;
1498
+ }
1499
+ throw new Error(usageText);
1500
+ }
1501
+ if (!to || !text)
1502
+ throw new Error(usageText);
1503
+ return { kind: "a2a.message", to, text, ...(conversationId ? { conversationId } : {}), ...(identityFile ? { identityFile } : {}), ...(json ? { json: true } : {}) };
1504
+ }
1453
1505
  if (sub === "onboard") {
1454
1506
  let cardUrl;
1455
1507
  let trustLevel;
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.readSanctuaryAuthorityEpoch = readSanctuaryAuthorityEpoch;
37
37
  exports.prepareSanctuaryAuthorityEpoch = prepareSanctuaryAuthorityEpoch;
38
38
  exports.releaseSanctuaryAuthorityToken = releaseSanctuaryAuthorityToken;
39
+ exports.rebindSanctuaryAuthorityEpochPackage = rebindSanctuaryAuthorityEpochPackage;
39
40
  exports.retireSanctuaryAuthorityEpoch = retireSanctuaryAuthorityEpoch;
40
41
  const node_crypto_1 = require("node:crypto");
41
42
  const fs = __importStar(require("node:fs"));
@@ -238,6 +239,30 @@ function releaseSanctuaryAuthorityToken(root, options) {
238
239
  fs.unlinkSync(epoch.tokenPath);
239
240
  syncRoot(root);
240
241
  }
242
+ /**
243
+ * Move a live epoch to a new reviewed package without replacing its token, issuer
244
+ * or cursor. An in-place upgrade (and its rollback) rebinds the package the epoch
245
+ * trusts; the gateway still refuses any config whose package differs from the epoch's.
246
+ */
247
+ function rebindSanctuaryAuthorityEpochPackage(root, options) {
248
+ if (!DIGEST.test(options.from) || !DIGEST.test(options.to))
249
+ throw new Error("Sanctuary authority package rebind is invalid");
250
+ const epochPath = path.join(root, "epoch.json");
251
+ return (0, session_transaction_1.withImmediateSessionTurnLease)(epochPath, (lease) => {
252
+ const transaction = (0, session_transaction_1.readSessionTransaction)(epochPath, lease);
253
+ const epoch = readSanctuaryAuthorityEpoch(root, options);
254
+ if (epoch.state !== "prepared")
255
+ throw new Error("Sanctuary authority epoch is retired");
256
+ if (epoch.packageDigest === options.to)
257
+ return epoch;
258
+ if (epoch.packageDigest !== options.from)
259
+ throw new Error("Sanctuary authority epoch package changed");
260
+ const rebound = { ...epoch, packageDigest: options.to };
261
+ (0, session_transaction_1.writeSessionTransaction)(epochPath, rebound, { lease, expectedRevision: transaction.revision });
262
+ (0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_authority_epoch_package_rebound", message: "Sanctuary authority epoch rebound to a reviewed package", meta: { epochId: epoch.epochId, from: options.from, to: options.to } });
263
+ return rebound;
264
+ });
265
+ }
241
266
  function retireSanctuaryAuthorityEpoch(root, options) {
242
267
  if (options.quiescent !== true)
243
268
  throw new Error("Sanctuary authority execution state is not quiescent");