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

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 CHANGED
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.834",
6
+ "changes": [
7
+ "A2A: authenticated agent chat. A verified friend's message (friends message kind, signed and sealed) becomes the agent's turn under the sender's relationship profile, and the reply is sealed back to the sender and stored sealed, so neither direction travels or persists in plaintext over any transport.",
8
+ "Sanctuary: remote turns are relationship-scoped. Any Sanctuary turn from outside the host (Telegram, A2A, future senses) without a known capability profile gets no tools; only the Butler's own inner, CLI, and MCP channels keep the unscoped tool set. Adds a sanctuary-agent-peer profile: the owner's chat tools minus host execution and approval-gated restarts.",
9
+ "CLI: ouro a2a identity mints a file-backed client key (0600, refused if group- or other-readable); ouro a2a message sends a sealed chat to an agent card and prints the verified reply.",
10
+ "A2A: sealed chat is observable (sealed, opened, and refused events), and a reply sealed as any other friends kind is refused before it can reach a store."
11
+ ]
12
+ },
4
13
  {
5
14
  "version": "0.1.0-alpha.833",
6
15
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.833",
2
+ "runtimeVersion": "0.1.0-alpha.834",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -130,6 +130,75 @@
130
130
  "effectScopes": [
131
131
  "telegram.owner_event"
132
132
  ]
133
+ },
134
+ "sanctuary-agent-peer": {
135
+ "version": 1,
136
+ "toolNames": [
137
+ "external_event_disposition",
138
+ "query_active_work",
139
+ "save_friend_note",
140
+ "telegram_contact_manage",
141
+ "query_cares",
142
+ "care_manage",
143
+ "await_condition",
144
+ "resolve_await",
145
+ "cancel_await",
146
+ "list_recent_attachments",
147
+ "materialize_attachment",
148
+ "describe_image",
149
+ "unraid_list_containers",
150
+ "unraid_get_container_logs",
151
+ "unraid_get_storage",
152
+ "sanctuary_get_media_optimization",
153
+ "sanctuary_search_media_catalog",
154
+ "unraid_get_disks",
155
+ "unraid_get_notifications",
156
+ "unraid_get_system",
157
+ "sanctuary_get_install_state",
158
+ "unraid_check_services",
159
+ "sanctuary_get_download_queue",
160
+ "sanctuary_resume_download_queue",
161
+ "steward_policy_manage",
162
+ "send_message",
163
+ "ponder",
164
+ "rest",
165
+ "settle",
166
+ "speak",
167
+ "shell",
168
+ "shell_status",
169
+ "shell_tail",
170
+ "read_file",
171
+ "write_file",
172
+ "edit_file",
173
+ "glob",
174
+ "grep",
175
+ "web_search",
176
+ "search_facts",
177
+ "consult_diary",
178
+ "consult_notes",
179
+ "get_friend_note",
180
+ "session_summary",
181
+ "query_session",
182
+ "set_reasoning_effort",
183
+ "restart_runtime",
184
+ "revive_sense",
185
+ "media_search",
186
+ "media_request",
187
+ "media_request_status",
188
+ "media_diagnose_and_fix",
189
+ "media_chain_health",
190
+ "media_play_or_resolve"
191
+ ],
192
+ "contextScopes": [
193
+ "household.status",
194
+ "household.policy",
195
+ "household.private"
196
+ ],
197
+ "effectScopes": [
198
+ "telegram.proactive",
199
+ "telegram.request_return",
200
+ "telegram.owner_event"
201
+ ]
133
202
  }
134
203
  }
135
204
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.833</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.834</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -1,12 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cardDid = cardDid;
3
4
  exports.endpointForCard = endpointForCard;
4
5
  exports.fetchA2AAgentCard = fetchA2AAgentCard;
5
6
  exports.sendA2AMessage = sendA2AMessage;
6
7
  exports.postA2AMessageEnvelope = postA2AMessageEnvelope;
7
8
  exports.getA2ATask = getA2ATask;
9
+ exports.sendSealedA2AChat = sendSealedA2AChat;
8
10
  const node_crypto_1 = require("node:crypto");
11
+ const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
9
12
  const runtime_1 = require("../nerves/runtime");
13
+ const sealed_chat_1 = require("./sealed-chat");
14
+ /** The trimmed `did:key` a card serves, or undefined when the card is did-less. */
15
+ function cardDid(card) {
16
+ const did = card.did;
17
+ return typeof did === "string" && did.trim() ? did.trim() : undefined;
18
+ }
10
19
  function endpointForCard(card) {
11
20
  const jsonRpc = card.supportedInterfaces?.find((entry) => (entry.protocolBinding ?? entry.transport)?.toUpperCase() === "JSONRPC");
12
21
  if (jsonRpc?.url)
@@ -178,3 +187,50 @@ async function getA2ATask(input) {
178
187
  }
179
188
  return rpc.result;
180
189
  }
190
+ /**
191
+ * Talk to an A2A agent as a verified friend: seal `text` to the agent's card DID,
192
+ * signed by `identity`, send it, and open the sealed reply. The card must serve a
193
+ * did:key bound to it (the same check onboarding applies), so the reply can only be
194
+ * accepted from that key. Neither direction ever travels in plaintext.
195
+ */
196
+ async function sendSealedA2AChat(input) {
197
+ const fetchImpl = input.fetchImpl ?? fetch;
198
+ const sodium = input.sodium ?? await (0, a2a_client_1.ready)();
199
+ const card = await fetchA2AAgentCard(input.cardUrl, fetchImpl);
200
+ const peerDid = cardDid(card);
201
+ if (!peerDid)
202
+ throw new Error(`A2A card ${input.cardUrl} serves no DID; sealed chat needs one`);
203
+ const parsed = (0, a2a_client_1.parseDidKey)(peerDid);
204
+ if (!parsed || !(0, a2a_client_1.verifyCardDidBinding)({ card: { did: peerDid, url: input.cardUrl }, did: peerDid, didDoc: null })) {
205
+ throw new Error(`A2A card↔DID binding failed for ${peerDid}`);
206
+ }
207
+ const endpointUrl = endpointForCard(card);
208
+ const conversationId = input.conversationId ?? (0, node_crypto_1.randomUUID)();
209
+ const sealed = (0, sealed_chat_1.sealChatMessage)({
210
+ sodium,
211
+ from: input.identity,
212
+ recipientDid: peerDid,
213
+ recipientEd25519Pub: parsed.ed25519Pub,
214
+ text: input.text,
215
+ conversationId,
216
+ });
217
+ const request = {
218
+ jsonrpc: "2.0",
219
+ id: (0, node_crypto_1.randomUUID)(),
220
+ method: "SendMessage",
221
+ params: { message: { kind: "message", role: "ROLE_USER", messageId: (0, node_crypto_1.randomUUID)(), contextId: conversationId, parts: sealed.parts } },
222
+ };
223
+ (0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_chat_send_start", message: "sending sealed A2A chat message", meta: { endpointUrl, peerDid } });
224
+ const rpc = await postJsonRpc(endpointUrl, request, fetchImpl);
225
+ if ("error" in rpc)
226
+ throw new Error(`A2A error ${rpc.error.code}: ${rpc.error.message}`);
227
+ const task = rpc.result;
228
+ const replyMessage = task?.status?.message;
229
+ if (!replyMessage)
230
+ throw new Error("A2A chat returned no reply message");
231
+ const opened = await (0, sealed_chat_1.openChatMessage)({ sodium, self: input.identity, message: replyMessage, senderDid: peerDid, senderEd25519Pub: parsed.ed25519Pub });
232
+ if (!opened.ok)
233
+ throw new Error(`A2A chat reply rejected: ${opened.reason}`);
234
+ (0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_chat_send_end", message: "received sealed A2A chat reply", meta: { endpointUrl, peerDid, taskId: task.id } });
235
+ return { text: opened.text, conversationId, taskId: task.id, peerDid, peerName: card.name };
236
+ }
@@ -36,7 +36,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.readStoredA2ASeed = readStoredA2ASeed;
37
37
  exports.loadOrMintA2AIdentity = loadOrMintA2AIdentity;
38
38
  exports.loadSelfA2AIdentity = loadSelfA2AIdentity;
39
+ exports.loadOrMintA2AIdentityFile = loadOrMintA2AIdentityFile;
39
40
  const crypto = __importStar(require("node:crypto"));
41
+ const fs = __importStar(require("node:fs"));
42
+ const path = __importStar(require("node:path"));
40
43
  const os = __importStar(require("node:os"));
41
44
  const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
42
45
  const runtime_1 = require("../nerves/runtime");
@@ -168,3 +171,34 @@ async function loadSelfA2AIdentity(input) {
168
171
  },
169
172
  });
170
173
  }
174
+ /**
175
+ * A file-backed A2A identity for a client that is not an ouro agent (for example a
176
+ * coding harness talking to an agent over A2A). The file holds the Ed25519 seed, so
177
+ * it is treated like an SSH private key: created 0600 inside a 0700 directory, and
178
+ * REFUSED if group or other can read it.
179
+ */
180
+ async function loadOrMintA2AIdentityFile(input) {
181
+ const sodium = input.sodium ?? await (0, a2a_client_1.ready)();
182
+ return fs.existsSync(input.filePath) ? readA2AIdentityFile(sodium, input.filePath) : mintA2AIdentityFile(sodium, input.filePath);
183
+ }
184
+ function readA2AIdentityFile(sodium, filePath) {
185
+ const mode = fs.statSync(filePath).mode & 0o077;
186
+ if (mode !== 0)
187
+ throw new Error(`A2A identity file ${filePath} is readable by group or other; chmod 600 it`);
188
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
189
+ if (typeof parsed.seed !== "string")
190
+ throw new Error(`A2A identity file ${filePath} has no seed`);
191
+ return deriveIdentity(sodium, decodeSeed(parsed.seed));
192
+ }
193
+ function mintA2AIdentityFile(sodium, filePath) {
194
+ const identity = deriveIdentity(sodium, mintSeed());
195
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
196
+ fs.writeFileSync(filePath, `${JSON.stringify({ version: 1, did: identity.did, seed: identity.seed }, null, 2)}\n`, { mode: 0o600 });
197
+ (0, runtime_1.emitNervesEvent)({
198
+ component: "channels",
199
+ event: "channel.a2a_client_identity_minted",
200
+ message: "minted file-backed A2A client identity",
201
+ meta: { did: identity.did },
202
+ });
203
+ return identity;
204
+ }
@@ -95,6 +95,9 @@ async function receiveInboundShare(message, deps) {
95
95
  trust,
96
96
  friendsKind: result.friendsKind,
97
97
  status: result.status,
98
+ ...(result.message ? { message: result.message } : {}),
99
+ /* v8 ignore next -- a completed receipt implies a trusted friend record: an unknown DID reads as stranger and is refused @preserve */
100
+ ...(record ? { friend: record } : {}),
98
101
  };
99
102
  }
100
103
  (0, runtime_1.emitNervesEvent)({
@@ -52,11 +52,6 @@ function urlAgentId(cardUrl) {
52
52
  parsed.hash = "";
53
53
  return parsed.toString();
54
54
  }
55
- /** The trimmed `did:key` a card serves, or undefined when the card is did-less. */
56
- function cardDid(card) {
57
- const did = card.did;
58
- return typeof did === "string" && did.trim() ? did.trim() : undefined;
59
- }
60
55
  async function onboardA2APeer(options) {
61
56
  // HTTP/URL glue stays harness-side: fetch the agent card, resolve its JSON-RPC
62
57
  // endpoint, and derive the stable agentId. The record-shaping (mint/update the
@@ -77,7 +72,7 @@ async function onboardA2APeer(options) {
77
72
  // an inbound envelope, so onboarding it would silently break the resolve later). A
78
73
  // card whose DID is unparseable or fails the binding is REFUSED (no record
79
74
  // written) — never TOFU-trust a mis-bound or malformed card.
80
- const did = cardDid(card);
75
+ const did = (0, client_1.cardDid)(card);
81
76
  if (did) {
82
77
  const parsed = (0, a2a_client_1.parseDidKey)(did);
83
78
  const bound = parsed !== null && (0, a2a_client_1.verifyCardDidBinding)({ card: { did, url: options.cardUrl }, did, didDoc: null });
@@ -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;
@@ -74,6 +74,8 @@ exports.SANCTUARY_MCP_PROVIDED_TOOLS = new Set([
74
74
  "media_search", "media_request", "media_request_status",
75
75
  "media_diagnose_and_fix", "media_chain_health", "media_play_or_resolve",
76
76
  ]);
77
+ /** Channels a Sanctuary turn can arrive on from the host itself, not from a remote party. */
78
+ const SANCTUARY_LOCAL_CHANNELS = new Set(["inner", "cli", "mcp"]);
77
79
  exports.SANCTUARY_OWNER_ADDITIONS = new Set([
78
80
  "shell", "shell_status", "shell_tail", "read_file", "write_file", "edit_file", "glob", "grep",
79
81
  "web_search", "search_facts", "consult_diary", "consult_notes", "get_friend_note",
@@ -125,10 +127,20 @@ function applyPreference(tool, pref) {
125
127
  function selectToolsForChannel(capabilities, toolPreferences, _context, providerCapabilities, mcpManager, _chatModel, context) {
126
128
  const relationship = context?.relationshipAuthorization;
127
129
  const profileId = relationship?.profileId;
128
- const sanctuary = (context?.agentName === "sanctuary" && (relationship !== undefined || capabilities?.channel === "telegram"))
130
+ // Every Sanctuary turn that reaches the agent from outside the host (Telegram, A2A,
131
+ // any future remote sense) is relationship-scoped: without a known capability
132
+ // profile it gets no tools at all. Only the Butler's own local channels (its inner
133
+ // loop and the host-local CLI/MCP) keep the unscoped tool set.
134
+ const channel = capabilities?.channel;
135
+ const remote = channel !== undefined && !SANCTUARY_LOCAL_CHANNELS.has(channel);
136
+ const sanctuary = (context?.agentName === "sanctuary" && (relationship !== undefined || remote))
129
137
  || profileId?.startsWith("sanctuary-") === true;
130
138
  const owner = profileId === "sanctuary-owner";
131
- const knownSanctuaryProfile = relationship !== undefined && (owner || profileId === "sanctuary-household" || profileId === "sanctuary-event");
139
+ // A trusted peer agent acting for the owner gets the owner's chat tool menu. Host
140
+ // execution and approval-gated routine actions stay Telegram-owner-only: the host
141
+ // tool is only ever added on the Telegram channel, and the peer profile omits them.
142
+ const ownerLike = owner || profileId === "sanctuary-agent-peer";
143
+ const knownSanctuaryProfile = relationship !== undefined && (ownerLike || profileId === "sanctuary-household" || profileId === "sanctuary-event");
132
144
  const mcp = mcpManager ? (0, mcp_tools_1.mcpToolsAsDefinitions)(mcpManager) : [];
133
145
  const native = [...tools_base_1.baseToolDefinitions, ...additionalDefinitions];
134
146
  assertUniqueToolSchemas([
@@ -140,8 +152,8 @@ function selectToolsForChannel(capabilities, toolPreferences, _context, provider
140
152
  ordinary = knownSanctuaryProfile
141
153
  ? [
142
154
  ...tools_base_1.baseToolDefinitions.filter((definition) => SANCTUARY_RELATIONSHIP_BASE_TOOLS.has(definition.tool.function.name)
143
- || (owner && exports.SANCTUARY_OWNER_ADDITIONS.has(definition.tool.function.name))),
144
- ...tools_unraid_1.unraidToolDefinitions, tools_steward_policy_1.stewardPolicyToolDefinition, ...(owner ? mcp : []),
155
+ || (ownerLike && exports.SANCTUARY_OWNER_ADDITIONS.has(definition.tool.function.name))),
156
+ ...tools_unraid_1.unraidToolDefinitions, tools_steward_policy_1.stewardPolicyToolDefinition, ...(ownerLike ? mcp : []),
145
157
  ]
146
158
  : [];
147
159
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.833",
3
+ "version": "0.1.0-alpha.834",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.833",
9
+ "version": "0.1.0-alpha.834",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
@@ -18,7 +18,7 @@
18
18
  "@microsoft/teams.common": "2.0.11",
19
19
  "@microsoft/teams.dev": "2.0.11",
20
20
  "@microsoft/teams.graph": "2.0.11",
21
- "@ouro.bot/friends": "0.1.0-alpha.10",
21
+ "@ouro.bot/friends": "0.1.0-alpha.12",
22
22
  "@types/react": "^17.0.91",
23
23
  "@types/ws": "^8.18.1",
24
24
  "ajv": "^8.20.0",
@@ -1357,9 +1357,9 @@
1357
1357
  }
1358
1358
  },
1359
1359
  "node_modules/@ouro.bot/friends": {
1360
- "version": "0.1.0-alpha.10",
1361
- "resolved": "https://registry.npmjs.org/@ouro.bot/friends/-/friends-0.1.0-alpha.10.tgz",
1362
- "integrity": "sha512-HxKCXDv74pWnVPcyEYyWn4L1Okg4otanHo3o80J1yofN2ycb56Nreimj+IPSTf7d3lthIsoSbksgIpDyE11OGw==",
1360
+ "version": "0.1.0-alpha.12",
1361
+ "resolved": "https://registry.npmjs.org/@ouro.bot/friends/-/friends-0.1.0-alpha.12.tgz",
1362
+ "integrity": "sha512-pzV2PQvi1eUkcog3iDDHIvwSBDwpsB8/ykrJpoZQ/wB84qQa0xGKJSDnTt45r8H4j/68Cc5LU6vcjlwI5GG47g==",
1363
1363
  "license": "Apache-2.0",
1364
1364
  "dependencies": {
1365
1365
  "libsodium-wrappers": "0.8.4"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.833",
3
+ "version": "0.1.0-alpha.834",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -65,7 +65,7 @@
65
65
  "@microsoft/teams.common": "2.0.11",
66
66
  "@microsoft/teams.dev": "2.0.11",
67
67
  "@microsoft/teams.graph": "2.0.11",
68
- "@ouro.bot/friends": "0.1.0-alpha.10",
68
+ "@ouro.bot/friends": "0.1.0-alpha.12",
69
69
  "@types/react": "^17.0.91",
70
70
  "@types/ws": "^8.18.1",
71
71
  "ajv": "^8.20.0",