@ouro.bot/cli 0.1.0-alpha.672 → 0.1.0-alpha.674

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,19 @@
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.674",
6
+ "changes": [
7
+ "Add an inner-dialog auto-start guard so enabled agents can stay online without autonomous model-spending boot turns, including client-side self-message fallback paths and daemon health/status surfaces that respect intentionally parked agents."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.673",
12
+ "changes": [
13
+ "A2A connect + delegation layer (friends @ouro.bot/friends alpha.7), on the merged inbound-auth foundation. DID-keyed onboarding: a DID-bearing agent card keys the friend record on its verified did:key (externalId == DID, a2a.did == DID) after verifyCardDidBinding + parseDidKey, so an inbound sealed envelope resolves to that same record (the owner's own agent no longer collapses to stranger); a did-less legacy card stays URL-keyed (backward compatible). New owner-only connect_to tool, gated to the local management sense via authorizeConnect (network/group/internal senses are refused before any card fetch; closed-sense membership fails closed without the roster verifier) and linking at family via connectAgents. Outbound becomes signed+sealed over the direct rung: a new A2ATransport (relay/mailbox are a typed-but-stubbed seam naming ourostack/friends-relay), a2a_send_message seals via sendShare for DID-keyed peers (legacy text send preserved), and a full delegation round-trip — connect_to → coordinate(request, task) (prepareCoordination mints the requestId + first-party delegation, sealed to the peer; the recipient's receiveShare quarantines it under importedDelegations) → list_delegations → send_result (prepareMissionResult over the harness-owned mission_result DataPart wire, which routes to importMissionResult, NOT receiveShare, since mission-result is not a FriendsKind) → importMissionResult lands importedResults[B][requestId], assignee/correlation-gated (assignee_mismatch / no_delegation / no_mission / untrusted_source, fail-closed on a legacy assignee-less delegation). Imported delegations and results land quarantined + attributed, never seed trust, never overwrite first-party; deliver-back is explicit (autonomy is a non-goal).",
14
+ "Security fix (mission_result forgery): the harness-owned mission_result wire now authenticates the sender exactly like the inbound share path. Previously receiveInboundMissionResult trusted the envelope's self-asserted fromAgentId (openSealedEnvelope does AEAD decryption only — no signature verification) and called importMissionResult with no verifier (falling back to the no-op TOFU verifier), so a result signed with an attacker's own key but claiming a victim assignee's DID was accepted and stored under importedResults[victim][requestId] as if the victim delivered it (the recipient's X25519 pubkey is public, so AEAD is no barrier, and the assignee/correlation gates only checked the claimed plaintext DID). The wire now mirrors receiveShare: replay-dedups on the seal nonce (durable SeenLedger), rejects unless opened.signerDid === opened.fromAgentId (sender_binding_mismatch), resolves+pins the sender DID, and hands importMissionResult a real DidVerifier bound to the pinned key so the Ed25519 signature is verified against the pinned key (a non-matching signature is untrusted_source). All authorization gates remain enforced by the importer."
15
+ ]
16
+ },
4
17
  {
5
18
  "version": "0.1.0-alpha.672",
6
19
  "changes": [
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.endpointForCard = endpointForCard;
4
4
  exports.fetchA2AAgentCard = fetchA2AAgentCard;
5
5
  exports.sendA2AMessage = sendA2AMessage;
6
+ exports.postA2AMessageEnvelope = postA2AMessageEnvelope;
6
7
  exports.getA2ATask = getA2ATask;
7
8
  const node_crypto_1 = require("node:crypto");
8
9
  const runtime_1 = require("../nerves/runtime");
@@ -113,6 +114,42 @@ async function sendA2AMessage(input) {
113
114
  });
114
115
  return task;
115
116
  }
117
+ /**
118
+ * POST a pre-built friends A2A message (a `wrapInDataPart` sealed-envelope message)
119
+ * to a peer endpoint as a JSON-RPC `message/send`. This is the `direct`-rung wire
120
+ * the harness `A2ATransport` uses: friends' `sendShare` builds the sealed message and
121
+ * hands it to the transport, which delivers it here. Unlike `sendA2AMessage` (which
122
+ * builds a text part), this carries the caller's exact `message` (the sealed DataPart)
123
+ * untouched. Throws on a non-2xx response (transport failure).
124
+ */
125
+ async function postA2AMessageEnvelope(input) {
126
+ const fetchImpl = input.fetchImpl ?? fetch;
127
+ const request = {
128
+ jsonrpc: "2.0",
129
+ id: (0, node_crypto_1.randomUUID)(),
130
+ method: "message/send",
131
+ params: { message: input.message },
132
+ };
133
+ (0, runtime_1.emitNervesEvent)({
134
+ component: "channels",
135
+ event: "channel.a2a_envelope_post_start",
136
+ message: "posting sealed A2A message envelope (direct rung)",
137
+ meta: { endpointUrl: input.endpointUrl },
138
+ });
139
+ let rpc = await postJsonRpc(input.endpointUrl, request, fetchImpl, "0.3");
140
+ if (methodNotFound(rpc)) {
141
+ rpc = await postJsonRpc(input.endpointUrl, { ...request, method: "SendMessage" }, fetchImpl);
142
+ }
143
+ if ("error" in rpc) {
144
+ throw new Error(`A2A error ${rpc.error.code}: ${rpc.error.message}`);
145
+ }
146
+ (0, runtime_1.emitNervesEvent)({
147
+ component: "channels",
148
+ event: "channel.a2a_envelope_post_end",
149
+ message: "posted sealed A2A message envelope",
150
+ meta: { endpointUrl: input.endpointUrl },
151
+ });
152
+ }
116
153
  async function getA2ATask(input) {
117
154
  const fetchImpl = input.fetchImpl ?? fetch;
118
155
  const metadata = senderMetadata(input);
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.delegationStoresFor = delegationStoresFor;
37
+ const path = __importStar(require("node:path"));
38
+ const friends_1 = require("@ouro.bot/friends");
39
+ const runtime_1 = require("../nerves/runtime");
40
+ function delegationStoresFor(agentRoot) {
41
+ const friendsDir = path.join(agentRoot, "friends");
42
+ const missionsDir = (0, friends_1.missionsDirFor)(friendsDir);
43
+ const grantsDir = (0, friends_1.grantsDirFor)(friendsDir);
44
+ (0, runtime_1.emitNervesEvent)({
45
+ component: "channels",
46
+ event: "channel.a2a_delegation_stores_init",
47
+ message: "initialized A2A delegation stores (mission + grant)",
48
+ meta: { missionsDir, grantsDir },
49
+ });
50
+ return {
51
+ missionStore: new friends_1.FileMissionStore(missionsDir),
52
+ grantStore: new friends_1.FileGrantStore(grantsDir),
53
+ missionsDir,
54
+ grantsDir,
55
+ };
56
+ }
@@ -35,9 +35,13 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.readStoredA2ASeed = readStoredA2ASeed;
37
37
  exports.loadOrMintA2AIdentity = loadOrMintA2AIdentity;
38
+ exports.loadSelfA2AIdentity = loadSelfA2AIdentity;
38
39
  const crypto = __importStar(require("node:crypto"));
40
+ const os = __importStar(require("node:os"));
39
41
  const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
40
42
  const runtime_1 = require("../nerves/runtime");
43
+ const runtime_credentials_1 = require("../heart/runtime-credentials");
44
+ const machine_identity_1 = require("../heart/machine-identity");
41
45
  /** The number of raw bytes in an Ed25519 seed. */
42
46
  const ED25519_SEED_BYTES = 32;
43
47
  /** The machine-config key path for the persisted seed: `a2a.identity.ed25519Seed`. */
@@ -135,3 +139,25 @@ async function loadOrMintA2AIdentity(input) {
135
139
  });
136
140
  return identity;
137
141
  }
142
+ /**
143
+ * Load (or mint-on-first-use) THIS agent's own A2A identity from the machine-local
144
+ * runtime config — the convenience entry the main-process tools use to obtain the
145
+ * self identity that signs outbound sealed envelopes. Mirrors the a2a sense
146
+ * entrypoint's load (read machine config → `loadOrMintA2AIdentity` → persist a fresh
147
+ * seed under the right machine id). Reads the in-memory machine-config cache (no
148
+ * remote vault round-trip per call). `sodium` is optional (defaults to `ready()`).
149
+ */
150
+ async function loadSelfA2AIdentity(input) {
151
+ const sodium = input.sodium ?? await (0, a2a_client_1.ready)();
152
+ const machineId = (0, machine_identity_1.loadOrCreateMachineIdentity)({ homeDir: os.homedir() }).machineId;
153
+ const read = (0, runtime_credentials_1.readMachineRuntimeCredentialConfig)(input.agentName);
154
+ const config = read.ok ? read.config : {};
155
+ return loadOrMintA2AIdentity({
156
+ agentName: input.agentName,
157
+ sodium,
158
+ config,
159
+ upsert: async (next) => {
160
+ await (0, runtime_credentials_1.upsertMachineRuntimeCredentialConfig)(input.agentName, machineId, next);
161
+ },
162
+ });
163
+ }
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.wrapMissionResultDataPart = wrapMissionResultDataPart;
4
+ exports.isMissionResultDataPart = isMissionResultDataPart;
5
+ exports.receiveInboundMissionResult = receiveInboundMissionResult;
6
+ const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
7
+ const friends_1 = require("@ouro.bot/friends");
8
+ const runtime_1 = require("../nerves/runtime");
9
+ /**
10
+ * THE HARNESS-OWNED RESULT WIRE.
11
+ *
12
+ * A mission RESULT is NOT a `FriendsKind` (`FriendsKind` = profile_share |
13
+ * mission_share | coordination) — `prepareMissionResult`/`importMissionResult` are
14
+ * transport-agnostic + store-only, and the result does NOT ride `sendShare`/
15
+ * `receiveShare`. So the HARNESS owns the wire: the result envelope is sealed (reusing
16
+ * the sign-then-seal crypto) and wrapped in a DataPart carrying a distinct `ouroKind:
17
+ * "mission_result"` discriminator. The inbound bridge recognizes that tag and routes
18
+ * it to `importMissionResult` — NEVER to `receiveShare`, which would mis-route a
19
+ * non-`FriendsKind` envelope. This is "a DataPart variant + an inbound route branch"
20
+ * (the carrier is the friends `unwrapDataPart` shape + one extra tag field — no
21
+ * structural change beyond that).
22
+ *
23
+ * SECURITY — the wire AUTHENTICATES the sender exactly like the share path
24
+ * (`receiveShare` in friends' `adapter.ts`). `openSealedEnvelope` does AEAD
25
+ * decryption ONLY — it does NOT verify the signature (its docstring names the
26
+ * adapter's `DidVerifier` as "the single authentication gate"). The decrypted
27
+ * `envelope.fromAgentId` is therefore attacker-controlled plaintext: the recipient's
28
+ * X25519 pubkey is public (derived from the published DID), so any party can SEAL a
29
+ * forged result to us. Authentication is the ONLY barrier. Before trusting the
30
+ * claimed sender we therefore replicate `receiveShare`'s gate:
31
+ * 1. binding check — `opened.signerDid === opened.fromAgentId` (and non-empty),
32
+ * 2. resolve + pin the sender DID (TOFU pin + signed-rotation evaluation),
33
+ * 3. hand `importMissionResult` a REAL `DidVerifier` bound to the pinned key, so
34
+ * `verifier.verify(fromAgentId, proof)` runs the Ed25519 signature check against
35
+ * the pinned key — NOT the no-op `tofuVerifier` the importer defaults to.
36
+ * Replay is deduped on the seal nonce (durable `SeenLedger`), matching the share path.
37
+ */
38
+ const OURO_RESULT_KIND = "mission_result";
39
+ /** Wrap a sealed result envelope into the harness-owned `mission_result` DataPart. */
40
+ function wrapMissionResultDataPart(input) {
41
+ return {
42
+ messageId: `result-${Math.random().toString(36).slice(2)}`,
43
+ role: "agent",
44
+ parts: [{
45
+ kind: "data",
46
+ data: {
47
+ v: input.sealedEnvelope.v,
48
+ sealed: input.sealedEnvelope.sealed,
49
+ recipientDid: input.recipientDid,
50
+ ouroKind: OURO_RESULT_KIND,
51
+ },
52
+ }],
53
+ };
54
+ }
55
+ /** Whether an inbound message carries the harness-owned `mission_result` DataPart. */
56
+ function isMissionResultDataPart(message) {
57
+ if (!Array.isArray(message.parts))
58
+ return false;
59
+ return message.parts.some((p) => p && p.kind === "data" && typeof p.data === "object" && p.data !== null
60
+ && p.data.ouroKind === OURO_RESULT_KIND);
61
+ }
62
+ /** Extract the sealed envelope from a `mission_result` DataPart, or null. */
63
+ function unwrapMissionResultDataPart(message) {
64
+ if (!Array.isArray(message.parts) || message.parts.length !== 1)
65
+ return null;
66
+ const part = message.parts[0];
67
+ if (!part || part.kind !== "data" || !part.data)
68
+ return null;
69
+ const data = part.data;
70
+ if (data.ouroKind !== OURO_RESULT_KIND)
71
+ return null;
72
+ if (typeof data.v !== "number" || typeof data.recipientDid !== "string" || !data.sealed)
73
+ return null;
74
+ return { sealed: { v: data.v, sealed: data.sealed }, recipientDid: data.recipientDid };
75
+ }
76
+ /**
77
+ * Route an inbound `mission_result` DataPart to `importMissionResult`, AUTHENTICATING
78
+ * the sender exactly like `receiveShare` does for every other inbound kind.
79
+ *
80
+ * Ordering (mirrors the share bridge's trust trap + authentication gate):
81
+ * 1. recognize + unwrap the harness `mission_result` carrier (else `not-a-result`).
82
+ * 2. replay dedup on the seal nonce (durable `SeenLedger`) — before any state change.
83
+ * 3. `openSealedEnvelope` → AEAD-decrypt only (unseal/recipient failures → reject).
84
+ * 4. binding check — reject unless `opened.signerDid === opened.fromAgentId` (and
85
+ * non-empty): the advisory `signerDid` (outer, unsigned) must match the claimed
86
+ * sender. A divergence is a forged/spoofed bundle (`sender_binding_mismatch`).
87
+ * 5. `resolveAndPin` the sender DID (TOFU pin / signed-rotation) → the pinned key
88
+ * (unresolvable/failed-binding/failed-rotation → `resolve_failed`).
89
+ * 6. build a `DidVerifier` bound to THIS envelope + the pinned key, mark the nonce
90
+ * seen, then call `importMissionResult({ envelope, fromAgentId: <signed DID>,
91
+ * trustOfSource }, { verifier })`. The verifier runs the REAL signature check
92
+ * against the pinned key — a result signed with a key that is not the pinned key
93
+ * for the claimed DID is rejected (`untrusted_source`). Every authorization gate
94
+ * (no_mission / no_delegation / assignee_mismatch, fail-closed on a legacy
95
+ * assignee-less delegation) is still enforced by the importer.
96
+ */
97
+ async function receiveInboundMissionResult(message, deps) {
98
+ const payload = unwrapMissionResultDataPart(message);
99
+ if (!payload)
100
+ return { outcome: "not-a-result" };
101
+ // Replay dedup BEFORE any state change, keyed on the seal nonce (matches receiveShare).
102
+ if (deps.seen.isSeen(payload.sealed.sealed.n)) {
103
+ (0, runtime_1.emitNervesEvent)({
104
+ component: "channels",
105
+ event: "channel.a2a_result_rejected",
106
+ message: "rejected inbound mission_result (replayed seal nonce)",
107
+ meta: { reason: "replayed" },
108
+ });
109
+ return { outcome: "rejected", reason: "replayed" };
110
+ }
111
+ const recipientIdentity = { x25519Priv: deps.identity.x25519Priv, x25519Pub: deps.identity.x25519Pub };
112
+ const opened = (0, a2a_client_1.openSealedEnvelope)({
113
+ sodium: deps.sodium,
114
+ sealedEnvelope: payload.sealed,
115
+ recipientDid: deps.identity.did,
116
+ recipientIdentity,
117
+ });
118
+ if (!opened.ok) {
119
+ (0, runtime_1.emitNervesEvent)({
120
+ component: "channels",
121
+ event: "channel.a2a_result_rejected",
122
+ message: "rejected inbound mission_result (unseal/recipient)",
123
+ meta: { reason: opened.error },
124
+ });
125
+ return { outcome: "rejected", reason: opened.error };
126
+ }
127
+ // SECURITY (binding): the trustworthy sender identity is the SIGNED `fromAgentId`.
128
+ // `openSealedEnvelope` does NOT verify the signature, so `fromAgentId` is untrusted
129
+ // until the DidVerifier confirms the pinned key signed THIS envelope. The advisory
130
+ // `signerDid` (from the outer, unsigned sealed plaintext) must match the claimed
131
+ // sender; a divergence is a forged/spoofed bundle (e.g. an attacker signing with its
132
+ // OWN key while claiming a victim's DID). Reject before resolving or importing.
133
+ const senderDid = opened.fromAgentId;
134
+ if (senderDid.length === 0 || opened.signerDid !== senderDid) {
135
+ (0, runtime_1.emitNervesEvent)({
136
+ component: "channels",
137
+ event: "channel.a2a_result_rejected",
138
+ message: "rejected inbound mission_result (sender binding mismatch — forged sender)",
139
+ meta: { reason: "sender_binding_mismatch", claimedDid: senderDid, signerDid: opened.signerDid },
140
+ });
141
+ return { outcome: "rejected", reason: "sender_binding_mismatch" };
142
+ }
143
+ // Read trust from the friend store BY the verified DID — never defaulted, never from
144
+ // the envelope. Unknown (or empty) DID ⇒ stranger.
145
+ const record = await (0, friends_1.findFriendByDid)(deps.store, senderDid);
146
+ const trust = record?.trustLevel ?? "stranger";
147
+ // Resolve + pin the SENDER's DID (async — BEFORE the sync verifier/importer).
148
+ const resolved = await deps.didResolution.resolveAndPin({
149
+ fromAgentId: senderDid,
150
+ did: senderDid,
151
+ pinStore: deps.pinStore,
152
+ trustOfSource: trust,
153
+ });
154
+ if (!resolved) {
155
+ (0, runtime_1.emitNervesEvent)({
156
+ component: "channels",
157
+ event: "channel.a2a_result_rejected",
158
+ message: "rejected inbound mission_result (sender DID resolve/pin failed)",
159
+ meta: { reason: "resolve_failed", verifiedDid: senderDid, trust },
160
+ });
161
+ return { outcome: "rejected", reason: "resolve_failed" };
162
+ }
163
+ // Build the sync verifier bound to THIS envelope + the pinned sender key. This is the
164
+ // single authentication gate (the importer would otherwise default to the no-op TOFU
165
+ // verifier). `verify` confirms the agentId===did binding AND the Ed25519 signature
166
+ // over the envelope against the pinned key.
167
+ const verifier = new a2a_client_1.DidVerifier({
168
+ sodium: deps.sodium,
169
+ pinnedEd25519Pub: resolved.ed25519Pub,
170
+ pinnedDid: senderDid,
171
+ envelope: opened.envelope,
172
+ });
173
+ // Mark seen now (idempotent imports + the replay guard above keep this safe).
174
+ deps.seen.markSeen(payload.sealed.sealed.n);
175
+ const result = await (0, friends_1.importMissionResult)(deps.missionStore, {
176
+ envelope: opened.envelope,
177
+ fromAgentId: senderDid,
178
+ trustOfSource: trust,
179
+ }, { verifier });
180
+ if (result.ok) {
181
+ (0, runtime_1.emitNervesEvent)({
182
+ component: "channels",
183
+ event: "channel.a2a_result_imported",
184
+ message: "imported inbound mission_result (authenticated, quarantined, attributed)",
185
+ meta: { verifiedDid: senderDid, trust, status: result.status },
186
+ });
187
+ return { outcome: "imported", verifiedDid: senderDid };
188
+ }
189
+ (0, runtime_1.emitNervesEvent)({
190
+ component: "channels",
191
+ event: "channel.a2a_result_rejected",
192
+ message: "rejected inbound mission_result (import gate)",
193
+ meta: { reason: result.status, verifiedDid: senderDid, trust },
194
+ });
195
+ return { outcome: "rejected", reason: result.status };
196
+ }
@@ -38,6 +38,7 @@ const path = __importStar(require("node:path"));
38
38
  const identity_1 = require("../heart/identity");
39
39
  const runtime_1 = require("../nerves/runtime");
40
40
  const friends_1 = require("@ouro.bot/friends");
41
+ const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
41
42
  const client_1 = require("./client");
42
43
  function storeFor(options) {
43
44
  if (options.store)
@@ -45,23 +46,53 @@ function storeFor(options) {
45
46
  /* v8 ignore next -- default bundle root fallback is owned by identity path tests; CLI and onboarding tests inject explicit roots @preserve */
46
47
  return new friends_1.FileFriendStore(path.join(options.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)(), `${options.agentName}.ouro`, "friends"));
47
48
  }
48
- function agentIdFor(_card, cardUrl) {
49
+ /** The normalized card URL (legacy URL-keyed identity for did-less cards). */
50
+ function urlAgentId(cardUrl) {
49
51
  const parsed = new URL(cardUrl);
50
52
  parsed.hash = "";
51
53
  return parsed.toString();
52
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
+ }
53
60
  async function onboardA2APeer(options) {
54
61
  // HTTP/URL glue stays harness-side: fetch the agent card, resolve its JSON-RPC
55
62
  // endpoint, and derive the stable agentId. The record-shaping (mint/update the
56
63
  // agent-peer friend) is delegated to @ouro.bot/friends' upsertAgentPeer.
57
64
  const card = await (0, client_1.fetchA2AAgentCard)(options.cardUrl, options.fetchImpl);
58
65
  const store = storeFor(options);
59
- const externalId = agentIdFor(card, options.cardUrl);
60
66
  /* v8 ignore next -- fetchA2AAgentCard validates a usable endpoint or legacy url before returning a card @preserve */
61
67
  const endpointUrl = (0, client_1.endpointForCard)(card) ?? options.cardUrl;
62
68
  const protocolVersion = card.supportedInterfaces?.find((entry) => entry.url === endpointUrl)?.protocolVersion
63
69
  ?? card.protocolVersion;
64
70
  const name = options.name ?? card.name;
71
+ // THE FOOTGUN FIX: a DID-bearing card keys the record on its VERIFIED did:key
72
+ // (so an inbound sealed envelope — keyed on the signed DID — resolves to THIS
73
+ // record, not a stranger). A did-less (legacy) card keeps the URL-keyed identity
74
+ // unchanged (backward compatible). For did:key the binding is self-contained
75
+ // (`card.did === did`, `didDoc: null`); we ALSO require the served did:key to be a
76
+ // well-formed, parseable Ed25519 key (a malformed did:key could never key/verify
77
+ // an inbound envelope, so onboarding it would silently break the resolve later). A
78
+ // card whose DID is unparseable or fails the binding is REFUSED (no record
79
+ // written) — never TOFU-trust a mis-bound or malformed card.
80
+ const did = cardDid(card);
81
+ if (did) {
82
+ const parsed = (0, a2a_client_1.parseDidKey)(did);
83
+ const bound = parsed !== null && (0, a2a_client_1.verifyCardDidBinding)({ card: { did, url: options.cardUrl }, did, didDoc: null });
84
+ if (!bound) {
85
+ (0, runtime_1.emitNervesEvent)({
86
+ component: "friends",
87
+ event: "friends.a2a_card_binding_rejected",
88
+ message: "refused A2A onboarding: card↔DID binding failed",
89
+ meta: { agentName: options.agentName, cardUrl: options.cardUrl, did, parseable: parsed !== null },
90
+ });
91
+ throw new Error(`A2A card↔DID binding failed for ${did}`);
92
+ }
93
+ }
94
+ // DID-keyed when present (externalId === did, a2a.did === did), else URL-keyed.
95
+ const externalId = did ?? urlAgentId(options.cardUrl);
65
96
  const record = await (0, friends_1.upsertAgentPeer)(store, {
66
97
  name,
67
98
  agentId: externalId,
@@ -71,13 +102,14 @@ async function onboardA2APeer(options) {
71
102
  endpointUrl,
72
103
  agentId: externalId,
73
104
  protocolVersion,
105
+ ...(did ? { did } : {}),
74
106
  },
75
107
  });
76
108
  (0, runtime_1.emitNervesEvent)({
77
109
  component: "friends",
78
110
  event: "friends.a2a_peer_onboarded",
79
111
  message: "onboarded A2A peer into friend model",
80
- meta: { agentName: options.agentName, friendId: record.id, peerName: record.name, trustLevel: record.trustLevel },
112
+ meta: { agentName: options.agentName, friendId: record.id, peerName: record.name, trustLevel: record.trustLevel, ...(did ? { did } : {}) },
81
113
  });
82
114
  return record;
83
115
  }
@@ -47,6 +47,8 @@ const pin_store_1 = require("./pin-store");
47
47
  const seen_ledger_1 = require("./seen-ledger");
48
48
  const did_resolution_1 = require("./did-resolution");
49
49
  const inbound_share_1 = require("./inbound-share");
50
+ const mission_result_wire_1 = require("./mission-result-wire");
51
+ const delegation_stores_1 = require("./delegation-stores");
50
52
  const task_store_1 = require("./task-store");
51
53
  const MAX_A2A_REQUEST_BYTES = 128 * 1024;
52
54
  const MAX_A2A_MESSAGE_TEXT_CHARS = 16_000;
@@ -349,7 +351,9 @@ async function startA2AServer(options) {
349
351
  inboundShareDeps = {
350
352
  sodium: await (0, a2a_client_1.ready)(),
351
353
  store: new friends_1.FileFriendStore(friendsDir),
352
- missionStore: new friends_1.FileMissionStore((0, friends_1.missionsDirFor)(friendsDir)),
354
+ // The SAME canonical mission store the Slice-4 delegation tools use, so an
355
+ // inbound `importCoordination` write lands where the read/prepare surface reads.
356
+ missionStore: (0, delegation_stores_1.delegationStoresFor)(agentRoot).missionStore,
353
357
  pinStore: new pin_store_1.FileA2APinStore(agentRoot),
354
358
  seen: new seen_ledger_1.FileA2ASeenLedger(agentRoot),
355
359
  didResolution: (0, did_resolution_1.makeDidResolution)({ sodium: await (0, a2a_client_1.ready)() }),
@@ -393,6 +397,40 @@ async function startA2AServer(options) {
393
397
  const responseStyle = rpc.method === "message/send" ? "legacy" : "latest";
394
398
  const parsedInbound = messageFromParams(rpc.params);
395
399
  const inbound = parsedInbound ? { ...parsedInbound, kind: parsedInbound.kind ?? "message" } : null;
400
+ // HARNESS-OWNED RESULT WIRE (runs BEFORE the friends-share branch). A
401
+ // `mission_result`-tagged DataPart is NOT a FriendsKind — it routes to
402
+ // `importMissionResult` (assignee/correlation-gated, store-only, NO turn), never
403
+ // to `receiveShare`. Recognized by the `ouroKind` discriminator.
404
+ if (inbound && inboundShareDeps && (0, mission_result_wire_1.isMissionResultDataPart)(inbound)) {
405
+ const imported = await (0, mission_result_wire_1.receiveInboundMissionResult)(inbound, {
406
+ sodium: inboundShareDeps.sodium,
407
+ store: inboundShareDeps.store,
408
+ missionStore: inboundShareDeps.missionStore,
409
+ // The SAME authentication seam the share bridge uses — so a forged-sender
410
+ // result is rejected by the DidVerifier exactly like a forged share.
411
+ pinStore: inboundShareDeps.pinStore,
412
+ seen: inboundShareDeps.seen,
413
+ didResolution: inboundShareDeps.didResolution,
414
+ identity: inboundShareDeps.identity,
415
+ });
416
+ if (imported.outcome === "rejected") {
417
+ writeJson(res, 200, errorResponse(rpc.id, rejectionErrorCode(imported.reason), `A2A result rejected: ${imported.reason}`));
418
+ return;
419
+ }
420
+ // imported (store-only, no turn): acknowledge with a minimal completed task.
421
+ /* v8 ignore next -- "not-a-result" is unreachable here (gated by isMissionResultDataPart) @preserve */
422
+ const ackText = imported.outcome === "imported"
423
+ ? `[a2a] imported mission result from ${imported.verifiedDid}`
424
+ /* v8 ignore next -- defensive: the gate above guarantees imported|rejected @preserve */
425
+ : "[a2a] mission result";
426
+ const contextId = inbound.contextId ?? "default";
427
+ const taskId = (0, node_crypto_1.randomUUID)();
428
+ const accessToken = (0, node_crypto_1.randomUUID)();
429
+ const ackTask = taskFor({ taskId, accessToken, contextId, inbound, state: "TASK_STATE_COMPLETED", response: ackText });
430
+ taskStore.put(ackTask, accessTokenScope(accessToken));
431
+ writeJson(res, 200, jsonResponse(rpc.id, publicTask(ackTask, accessToken, responseStyle, true)));
432
+ return;
433
+ }
396
434
  // Inbound friends-DataPart branch (runs BEFORE the legacy text path). A
397
435
  // sealed DataPart is unwrapped + verified through the bridge; the turn is
398
436
  // then keyed on the VERIFIED sender DID (replacing the unauthenticated
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.makeA2ATransport = makeA2ATransport;
4
+ const runtime_1 = require("../nerves/runtime");
5
+ const client_1 = require("./client");
6
+ /** The not-wired marker for the relay/mailbox rungs (the stubbed seam). */
7
+ const RELAY_NOT_WIRED = "A2A relay/mailbox transport is not wired — see ourostack/friends-relay";
8
+ /**
9
+ * Build the harness `A2ATransport` (the outbound delivery seam friends' `sendShare`
10
+ * calls). Only the `direct` rung is wired in this slice: it HTTP-POSTs the sealed,
11
+ * `wrapInDataPart`-wrapped message to the peer's `endpointUrl` (the `address`) as a
12
+ * JSON-RPC `message/send`, reusing the harness `client.ts` plumbing. The `relay` and
13
+ * `mailbox` rungs are a TYPED-but-STUBBED seam: they throw a clear not-wired error
14
+ * naming the `ourostack/friends-relay` dependency, so a future relay slice has an
15
+ * exact insertion point without this slice shipping an untrusted relay.
16
+ */
17
+ function makeA2ATransport(input = {}) {
18
+ return {
19
+ async send(target, message) {
20
+ if (target.rung === "direct") {
21
+ (0, runtime_1.emitNervesEvent)({
22
+ component: "channels",
23
+ event: "channel.a2a_transport_direct_send",
24
+ message: "delivering sealed A2A message over the direct rung",
25
+ meta: { address: target.address },
26
+ });
27
+ await (0, client_1.postA2AMessageEnvelope)({
28
+ endpointUrl: target.address,
29
+ message,
30
+ ...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
31
+ });
32
+ return;
33
+ }
34
+ // relay / mailbox: the typed-but-stubbed seam. Loud refusal (security-relevant:
35
+ // we never silently fall through to an untrusted relay).
36
+ (0, runtime_1.emitNervesEvent)({
37
+ component: "channels",
38
+ event: "channel.a2a_transport_not_wired",
39
+ message: "refused A2A transport over a non-direct rung (not wired)",
40
+ meta: { rung: target.rung, address: target.address, dependency: "ourostack/friends-relay" },
41
+ });
42
+ throw new Error(`${RELAY_NOT_WIRED} (rung: ${target.rung})`);
43
+ },
44
+ };
45
+ }
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.isLibraryKind = isLibraryKind;
37
37
  exports.listAllBundleAgents = listAllBundleAgents;
38
38
  exports.listEnabledBundleAgents = listEnabledBundleAgents;
39
+ exports.isInnerDialogAutoStartEnabled = isInnerDialogAutoStartEnabled;
39
40
  exports.listBundleSyncRows = listBundleSyncRows;
40
41
  const fs = __importStar(require("fs"));
41
42
  const path = __importStar(require("path"));
@@ -116,6 +117,44 @@ function listEnabledBundleAgents(options = {}) {
116
117
  .filter((row) => row.enabled && !isLibraryKind(row.kind))
117
118
  .map((row) => row.name);
118
119
  }
120
+ /**
121
+ * Per-agent runtime policy for the always-on inner-dialog worker. Agents
122
+ * default to the historic behavior (auto-start on daemon boot), but a bundle
123
+ * can opt out with:
124
+ *
125
+ * "innerDialog": { "autoStart": false }
126
+ *
127
+ * This keeps the agent enabled for sync/status/senses while preventing an
128
+ * autonomous boot turn from spending model credits.
129
+ */
130
+ function isInnerDialogAutoStartEnabled(agent, options = {}) {
131
+ const bundlesRoot = options.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)();
132
+ const readFileSync = options.readFileSync ?? fs.readFileSync;
133
+ const configPath = path.join(bundlesRoot, `${agent}.ouro`, "agent.json");
134
+ try {
135
+ const raw = readFileSync(configPath, "utf-8");
136
+ const parsed = JSON.parse(raw);
137
+ const innerDialog = parsed.innerDialog;
138
+ if (innerDialog && typeof innerDialog === "object" && !Array.isArray(innerDialog)) {
139
+ return innerDialog.autoStart !== false;
140
+ }
141
+ return true;
142
+ }
143
+ catch (error) {
144
+ (0, runtime_1.emitNervesEvent)({
145
+ level: "warn",
146
+ component: "daemon",
147
+ event: "daemon.inner_dialog_policy_read_failed",
148
+ message: "failed to read inner-dialog auto-start policy; refusing autonomous worker start",
149
+ meta: {
150
+ agent,
151
+ configPath,
152
+ error: error instanceof Error ? error.message : String(error),
153
+ },
154
+ });
155
+ return false;
156
+ }
157
+ }
119
158
  /**
120
159
  * Read the per-agent sync block from each enabled bundle's agent.json.
121
160
  * Used by the daemon (and stopped-state status renderer) to build per-agent
@@ -123,11 +123,19 @@ const processManager = new process_manager_1.DaemonProcessManager({
123
123
  name: agent,
124
124
  entry: "heart/agent-entry.js",
125
125
  channel: "inner-dialog",
126
- autoStart: true,
126
+ autoStart: (0, agent_discovery_1.isInnerDialogAutoStartEnabled)(agent),
127
127
  })),
128
128
  existsSync: fs.existsSync,
129
129
  /* v8 ignore next 4 -- wiring: delegates to checkAgentConfigWithProviderHealth which has full unit tests @preserve */
130
130
  configCheck: async (agent) => {
131
+ if (!(0, agent_discovery_1.isInnerDialogAutoStartEnabled)(agent)) {
132
+ return {
133
+ ok: true,
134
+ skip: true,
135
+ error: "inner-dialog auto-start is disabled in agent.json",
136
+ fix: "Set innerDialog.autoStart to true and rerun `ouro up` to resume autonomous inner-dialog turns.",
137
+ };
138
+ }
131
139
  const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
132
140
  return (0, agent_config_check_1.checkAgentConfigWithProviderHealth)(agent, bundlesRoot);
133
141
  },
@@ -266,7 +274,7 @@ const degradedComponents = [];
266
274
  function buildDaemonHealthState() {
267
275
  const snapshots = processManager.listAgentSnapshots();
268
276
  const agentDegradedComponents = snapshots
269
- .filter((snapshot) => snapshot.status !== "running")
277
+ .filter((snapshot) => snapshot.status !== "running" && snapshot.autoStart !== false)
270
278
  .map((snapshot) => {
271
279
  const reasonParts = [
272
280
  snapshot.errorReason ?? `${snapshot.channel} is ${snapshot.status}`,
@@ -305,7 +313,9 @@ function buildDaemonHealthState() {
305
313
  const rollupStatus = (0, daemon_rollup_1.computeDaemonRollup)({
306
314
  enabledAgents: snapshots.map((snapshot) => ({
307
315
  name: snapshot.name,
308
- status: snapshot.status,
316
+ // A parked non-autostart worker is intentionally not serving; it should
317
+ // remain visible in worker status but should not degrade daemon health.
318
+ status: snapshot.autoStart === false ? "running" : snapshot.status,
309
319
  })),
310
320
  bootstrapDegraded: degradedComponents,
311
321
  safeMode: false,
@@ -69,7 +69,8 @@ class HealthMonitor {
69
69
  async runChecks() {
70
70
  const results = [];
71
71
  const snapshots = this.processManager.listAgentSnapshots();
72
- const unhealthy = snapshots.filter((snapshot) => snapshot.status !== "running");
72
+ const unhealthy = snapshots.filter((snapshot) => snapshot.status !== "running" && snapshot.autoStart !== false);
73
+ const parked = snapshots.filter((snapshot) => snapshot.status !== "running" && snapshot.autoStart === false);
73
74
  if (unhealthy.length > 0) {
74
75
  const unhealthySummary = unhealthy.map((item) => {
75
76
  const detail = [
@@ -105,7 +106,10 @@ class HealthMonitor {
105
106
  }
106
107
  }
107
108
  else {
108
- results.push({ name: "agent-processes", status: "ok", message: "all managed agents running" });
109
+ const message = parked.length > 0
110
+ ? `all auto-start managed agents running; parked agents: ${parked.map((item) => item.name).join(", ")}`
111
+ : "all managed agents running";
112
+ results.push({ name: "agent-processes", status: "ok", message });
109
113
  }
110
114
  const jobs = this.scheduler.listJobs();
111
115
  const neverRan = jobs.filter((job) => !job.lastRun);
@@ -172,6 +172,7 @@ class DaemonProcessManager {
172
172
  snapshot: {
173
173
  name: agent.name,
174
174
  channel: agent.channel,
175
+ autoStart: agent.autoStart,
175
176
  status: "stopped",
176
177
  pid: null,
177
178
  restartCount: 0,
@@ -38,8 +38,14 @@ const path = __importStar(require("node:path"));
38
38
  const identity_1 = require("../heart/identity");
39
39
  const runtime_credentials_1 = require("../heart/runtime-credentials");
40
40
  const friends_1 = require("@ouro.bot/friends");
41
+ const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
41
42
  const runtime_1 = require("../nerves/runtime");
42
43
  const client_1 = require("../a2a/client");
44
+ const onboarding_1 = require("../a2a/onboarding");
45
+ const identity_2 = require("../a2a/identity");
46
+ const transport_1 = require("../a2a/transport");
47
+ const delegation_stores_1 = require("../a2a/delegation-stores");
48
+ const mission_result_wire_1 = require("../a2a/mission-result-wire");
43
49
  const outboundTaskTokens = new Map();
44
50
  function tokenKey(ctx, friendId, taskId) {
45
51
  return `${ctx?.agentRoot ?? "ambient"}\n${friendId}\n${taskId}`;
@@ -138,7 +144,176 @@ async function resolveA2AEndpoint(friend) {
138
144
  }
139
145
  throw new Error("A2A peer has no endpointUrl or cardUrl in agentMeta.a2a");
140
146
  }
147
+ /** The management sense the current turn arrived through, defaulting to a non-`local`
148
+ * value when unknown so the owner-only gate fails CLOSED on an unexpected origin. */
149
+ function senseTypeOf(ctx) {
150
+ /* v8 ignore next -- in-turn tool calls always carry a resolved channel context; the open-default fail-closed fallback is defensive @preserve */
151
+ return ctx?.context?.channel?.senseType ?? "open";
152
+ }
153
+ /** The pinned DID a peer record carries (its durable A2A identity), or undefined for a
154
+ * legacy (non-DID-keyed) peer. */
155
+ function peerDid(peer) {
156
+ const did = peer.agentMeta?.a2a?.did;
157
+ return typeof did === "string" && did.trim() ? did.trim() : undefined;
158
+ }
159
+ /** Locate an imported delegation by its correlation `requestId` across all missions:
160
+ * who delegated it (`fromAgentId`) + which local mission (`missionId`). The delegation
161
+ * lives under `importedDelegations[fromAgentId][requestId]` — what the inbound bridge
162
+ * quarantined. Returns undefined when no mission carries that requestId. */
163
+ function findImportedDelegation(missions, requestId) {
164
+ for (const mission of missions) {
165
+ for (const [fromAgentId, byRequest] of Object.entries(mission.importedDelegations ?? {})) {
166
+ if (byRequest[requestId])
167
+ return { fromAgentId, missionId: mission.id };
168
+ }
169
+ }
170
+ return undefined;
171
+ }
172
+ /**
173
+ * Seal an arbitrary friends plaintext envelope to a DID-keyed peer and deliver it over
174
+ * the `direct` rung via friends' `sendShare`. `sendShare` builds the SAME SealedEnvelope
175
+ * regardless of rung, `wrapInDataPart`-wraps it, and calls the transport itself; the
176
+ * recipient X25519 is derived from the peer's pinned did:key. Throws when the peer's DID
177
+ * is unparseable (cannot seal) or the rung is unreachable.
178
+ */
179
+ async function sealEnvelopeToPeer(input) {
180
+ const parsed = (0, a2a_client_1.parseDidKey)(input.did);
181
+ if (!parsed)
182
+ throw new Error(`cannot seal: peer DID is not a parseable did:key (${input.did})`);
183
+ const reachability = (0, a2a_client_1.resolveReachability)(input.peer.agentMeta?.a2a, input.peer.agentMeta?.mailbox);
184
+ if (reachability.rung === "unreachable") {
185
+ throw new Error("cannot seal: peer has no reachable A2A endpoint");
186
+ }
187
+ (0, runtime_1.emitNervesEvent)({
188
+ component: "channels",
189
+ event: "channel.a2a_outbound_seal",
190
+ message: "sealing outbound A2A envelope to a DID-keyed peer",
191
+ meta: { selfDid: input.self.did, peerDid: input.did, rung: reachability.rung, friendsKind: input.friendsKind },
192
+ });
193
+ const result = await (0, a2a_client_1.sendShare)({
194
+ sodium: input.sodium,
195
+ transport: (0, transport_1.makeA2ATransport)(),
196
+ fromIdentity: input.self,
197
+ toPeer: { a2a: input.peer.agentMeta?.a2a },
198
+ recipientDid: input.did,
199
+ recipientX25519Pub: (0, a2a_client_1.keyAgreementFromDidKey)({ sodium: input.sodium, ed25519Pub: parsed.ed25519Pub }),
200
+ plaintextEnvelope: input.envelope,
201
+ friendsKind: input.friendsKind,
202
+ });
203
+ /* v8 ignore next 3 -- unreachable behind the resolveReachability pre-check above: sendShare only returns ok:false on the SAME `unreachable` plan we already rejected; a redundant guard. @preserve */
204
+ if (!result.ok) {
205
+ throw new Error(`sealed send failed (${result.reason})`);
206
+ }
207
+ }
208
+ /**
209
+ * Seal an outbound free-text message to a DID-keyed peer over the `direct` rung. The
210
+ * text rides a minimal `coordination` envelope (`intent:"offer"`, the text as `note`) —
211
+ * the kind the Slice-1 inbound bridge verifies (signed sender DID) and turns into a
212
+ * peer turn.
213
+ */
214
+ async function sealAndSendToPeer(input) {
215
+ const sodium = await (0, a2a_client_1.ready)();
216
+ const self = await (0, identity_2.loadSelfA2AIdentity)({ agentName: input.agentName, sodium });
217
+ const envelope = {
218
+ subject: { missionKey: `dm:${self.did}:${input.did}`, title: "Direct message" },
219
+ fromAgentId: self.did,
220
+ intent: "offer",
221
+ note: input.message,
222
+ issuedAt: new Date().toISOString(),
223
+ };
224
+ await sealEnvelopeToPeer({
225
+ self: { did: self.did, keyId: self.keyId, ed25519Priv: self.ed25519Priv },
226
+ sodium, peer: input.peer, did: input.did, envelope, friendsKind: "coordination",
227
+ });
228
+ }
141
229
  exports.a2aToolDefinitions = [
230
+ {
231
+ tool: {
232
+ type: "function",
233
+ function: {
234
+ name: "connect_to",
235
+ description: "Owner-only: link an A2A agent (by its agent-card URL) into your fleet as a family-trusted, DID-keyed peer. Reachable only from your own local/CLI management sense — not exposed to network peers.",
236
+ parameters: {
237
+ type: "object",
238
+ properties: {
239
+ card_url: { type: "string", description: "The agent-card URL of the agent to connect to (its /.well-known/agent-card.json)." },
240
+ name: { type: "string", description: "Optional display name for the linked peer (defaults to the card name)." },
241
+ },
242
+ required: ["card_url"],
243
+ },
244
+ },
245
+ },
246
+ handler: async (args, ctx) => {
247
+ (0, runtime_1.emitNervesEvent)({
248
+ component: "repertoire",
249
+ event: "repertoire.tool_connect_to",
250
+ message: "connect_to invoked",
251
+ meta: { tool: "connect_to" },
252
+ });
253
+ const guard = requireTrustedRequester(ctx);
254
+ if (guard)
255
+ return guard;
256
+ // OWNER-ONLY GATE (fail-closed): authorizeConnect commits ONLY on the `local`
257
+ // (owner stdio/CLI) management sense. Any network/group/internal sense
258
+ // downgrades — refused BEFORE any card fetch or record write, so this tool is
259
+ // never reachable as a general in-turn capability a network peer could exercise.
260
+ // No `membership` is computed (roster auto-family is OUT of scope), so a `closed`
261
+ // sense ALSO downgrades — never a blanket-allow without the real roster verifier.
262
+ const senseType = senseTypeOf(ctx);
263
+ const authorization = (0, friends_1.authorizeConnect)({ senseType });
264
+ if (authorization.decision !== "commit") {
265
+ (0, runtime_1.emitNervesEvent)({
266
+ component: "repertoire",
267
+ event: "repertoire.tool_connect_to_refused",
268
+ message: "connect_to refused (non-owner management sense)",
269
+ meta: { tool: "connect_to", senseType, reason: authorization.reason },
270
+ });
271
+ return `connect_to is available only from your own local management sense (the CLI you launched). This sense (${senseType}) is not permitted to link agents.`;
272
+ }
273
+ const agentName = agentNameFromRoot(ctx?.agentRoot);
274
+ // ONE store for both halves — the DID-keyed onboarding write and the
275
+ // connectAgents resolve/link MUST point at the same store, or the link can't
276
+ // resolve the record the onboarding just wrote.
277
+ const store = storeFor(ctx);
278
+ try {
279
+ // 1) DID-keyed onboarding (the footgun fix): fetch the card, verify the
280
+ // card↔DID binding, and write the record keyed on the verified DID. This
281
+ // is what makes the peer resolvable BY DID for connectAgents below (and for
282
+ // the inbound resolve path). A mis-bound card throws here → no link.
283
+ const onboarded = await (0, onboarding_1.onboardA2APeer)({
284
+ /* v8 ignore next -- in-turn connect_to always carries an agentRoot; the ambient-name fallback is defensive @preserve */
285
+ agentName: agentName ?? "ouro-agent",
286
+ cardUrl: args.card_url,
287
+ trustLevel: "family",
288
+ ...(args.name ? { name: args.name } : {}),
289
+ store,
290
+ });
291
+ const did = onboarded.agentMeta?.a2a?.did;
292
+ // onboardA2APeer always sets a2a.agentId (== the DID when DID-keyed, else the
293
+ // card URL), so this is the resolvable handle connectAgents disambiguates on.
294
+ /* v8 ignore next -- upsertAgentPeer always writes a2a.agentId; the record-id fallback guards a future store-shape regression @preserve */
295
+ const peerHandle = onboarded.agentMeta?.a2a?.agentId ?? onboarded.id;
296
+ // 2) The authority-gated link + control-plane audit. connectAgents re-runs the
297
+ // `local` gate, resolves the just-written record (by DID when present, else
298
+ // by its agent-peer handle), links at family, and records action:"connect".
299
+ const result = await (0, friends_1.connectAgents)(store, {
300
+ peer: did ? { did } : { agentId: peerHandle },
301
+ senseType,
302
+ trustLevel: "family",
303
+ }, { actor: "owner:local", originSense: senseType });
304
+ /* v8 ignore next 3 -- onboardA2APeer wrote a DID/agentId-keyed record, so connectAgents always resolves it (ok:true); this guards a future store-shape regression @preserve */
305
+ if (!result.ok) {
306
+ return `connect_to could not complete the link (${result.status}).`;
307
+ }
308
+ return `connected ${result.record.name} (${did ?? result.record.id}) at family trust.`;
309
+ }
310
+ catch (error) {
311
+ return `connect_to error: ${error instanceof Error ? error.message : /* v8 ignore next -- defensive non-Error failures @preserve */ String(error)}`;
312
+ }
313
+ },
314
+ summaryKeys: ["card_url", "name"],
315
+ riskProfile: { mutates: "durable_state_write", risk: "high", reason: "links a new agent peer into the friend model at family trust" },
316
+ },
142
317
  {
143
318
  tool: {
144
319
  type: "function",
@@ -208,6 +383,25 @@ exports.a2aToolDefinitions = [
208
383
  return `A2A peer not found: ${args.friend_id}`;
209
384
  if (!(0, friends_1.isTrustedLevel)(peer.trustLevel))
210
385
  return "target A2A peer must be friend or family trust before outbound messages.";
386
+ // ROUTING (explicit, no impl judgment): a DID-keyed peer → seal+sign via
387
+ // sendShare over the direct rung (E2E, verified-DID inbound). A legacy peer
388
+ // with no pinned DID → the existing text send, byte-for-byte unchanged.
389
+ const did = peerDid(peer);
390
+ if (did) {
391
+ try {
392
+ await sealAndSendToPeer({
393
+ /* v8 ignore next -- in-turn a2a_send_message always carries a bundle agentRoot; the ambient-name fallback is defensive @preserve */
394
+ agentName: agentNameFromRoot(ctx?.agentRoot) ?? "ouro-agent",
395
+ peer,
396
+ did,
397
+ message: args.message,
398
+ });
399
+ return `sealed message delivered to ${peer.name} (${did}) over the direct rung.`;
400
+ }
401
+ catch (error) {
402
+ return `A2A sealed send error: ${error instanceof Error ? error.message : /* v8 ignore next -- defensive non-Error transport failures @preserve */ String(error)}`;
403
+ }
404
+ }
211
405
  try {
212
406
  const endpoint = await resolveA2AEndpoint(peer);
213
407
  const task = await (0, client_1.sendA2AMessage)({
@@ -279,4 +473,227 @@ exports.a2aToolDefinitions = [
279
473
  },
280
474
  summaryKeys: ["friend_id", "task_id"],
281
475
  },
476
+ {
477
+ tool: {
478
+ type: "function",
479
+ function: {
480
+ name: "coordinate",
481
+ description: "Delegate a task to a trusted (friend/family) A2A agent peer over a named shared mission. Mints a correlation requestId, records your first-party delegation, and seals the coordination to the peer over the direct rung. The peer reads it via its quarantined imported delegations and returns a result with send_result.",
482
+ parameters: {
483
+ type: "object",
484
+ properties: {
485
+ friend_id: { type: "string", description: "Friend record ID for the A2A agent peer to delegate to." },
486
+ mission_key: { type: "string", description: "The shared mission join key both agents can name (e.g. a stable project key)." },
487
+ mission_title: { type: "string", description: "Human title for the mission (used when first creating it)." },
488
+ task_summary: { type: "string", description: "What the peer is being asked to do." },
489
+ task_details: { type: "string", description: "Optional fuller description of the task." },
490
+ },
491
+ required: ["friend_id", "mission_key", "mission_title", "task_summary"],
492
+ },
493
+ },
494
+ },
495
+ handler: async (args, ctx) => {
496
+ (0, runtime_1.emitNervesEvent)({
497
+ component: "repertoire",
498
+ event: "repertoire.tool_coordinate",
499
+ message: "coordinate invoked",
500
+ meta: { tool: "coordinate", friendId: args.friend_id, missionKey: args.mission_key },
501
+ });
502
+ const guard = requireTrustedRequester(ctx);
503
+ if (guard)
504
+ return guard;
505
+ const peer = await storeFor(ctx).get(args.friend_id);
506
+ if (!peer || !isA2APeer(peer))
507
+ return `A2A peer not found: ${args.friend_id}`;
508
+ if (!(0, friends_1.isTrustedLevel)(peer.trustLevel))
509
+ return "target A2A peer must be friend or family trust before delegating.";
510
+ const did = peerDid(peer);
511
+ if (!did)
512
+ return "coordinate requires a DID-keyed peer (connect_to it first to pin its did:key).";
513
+ /* v8 ignore next -- in-turn coordinate always carries a bundle agentRoot; the ambient-name fallback is defensive (same pattern as connect_to) @preserve */
514
+ const agentName = agentNameFromRoot(ctx?.agentRoot) ?? "ouro-agent";
515
+ const store = storeFor(ctx);
516
+ /* v8 ignore next -- in-turn tool calls always carry an agentRoot; the getAgentRoot() ambient fallback depends on process argv (same pattern as storeFor) @preserve */
517
+ const { missionStore, grantStore } = (0, delegation_stores_1.delegationStoresFor)(ctx?.agentRoot ?? (0, identity_1.getAgentRoot)());
518
+ try {
519
+ const sodium = await (0, a2a_client_1.ready)();
520
+ const self = await (0, identity_2.loadSelfA2AIdentity)({ agentName, sodium });
521
+ // Ensure the local mission exists (first-party), then prepare the coordination:
522
+ // mints the requestId + records the first-party delegation (assignee = the peer).
523
+ const mission = await (0, friends_1.recordMission)(missionStore, { missionKey: args.mission_key, title: args.mission_title });
524
+ const prepared = await (0, friends_1.prepareCoordination)(missionStore, store, grantStore, {
525
+ missionId: mission.id,
526
+ toAgentId: did,
527
+ intent: "request",
528
+ selfAgentId: self.did,
529
+ task: { summary: args.task_summary, ...(args.task_details ? { details: args.task_details } : {}) },
530
+ });
531
+ /* v8 ignore next 3 -- unreachable for a trusted DID-keyed peer: recordMission guarantees the mission exists (no not_found) and the isTrustedLevel guard above guarantees trust≥friend, which the tiered consent default always approves (no no_consent). Defensive only. @preserve */
532
+ if (!prepared.ok) {
533
+ return `coordinate refused (${prepared.status}).`;
534
+ }
535
+ const requestId = prepared.envelope.task?.requestId;
536
+ // Seal + send the coordination envelope to the peer over direct.
537
+ await sealEnvelopeToPeer({
538
+ self: { did: self.did, keyId: self.keyId, ed25519Priv: self.ed25519Priv },
539
+ sodium, peer, did,
540
+ envelope: prepared.envelope,
541
+ friendsKind: "coordination",
542
+ });
543
+ (0, runtime_1.emitNervesEvent)({
544
+ component: "repertoire",
545
+ event: "repertoire.tool_coordinate_sent",
546
+ message: "coordination delegated + sealed to peer",
547
+ meta: { tool: "coordinate", peerDid: did, missionKey: args.mission_key, requestId },
548
+ });
549
+ return `coordinated: delegated "${args.task_summary}" to ${peer.name} on mission ${args.mission_key} (requestId ${requestId}).`;
550
+ }
551
+ catch (error) {
552
+ return `coordinate error: ${error instanceof Error ? error.message : /* v8 ignore next -- defensive non-Error failures @preserve */ String(error)}`;
553
+ }
554
+ },
555
+ summaryKeys: ["friend_id", "mission_key"],
556
+ riskProfile: { mutates: "external_side_effect", risk: "high", reason: "delegates a task to a remote agent peer" },
557
+ },
558
+ {
559
+ tool: {
560
+ type: "function",
561
+ function: {
562
+ name: "list_delegations",
563
+ description: "List delegations other agents have sent you (quarantined imported delegations). Each entry names the requesting agent, the mission, the correlation requestId, and the task summary. Use this to see what work peers have asked you to do, then send_result when done.",
564
+ parameters: { type: "object", properties: {} },
565
+ },
566
+ },
567
+ handler: async (_args, ctx) => {
568
+ (0, runtime_1.emitNervesEvent)({
569
+ component: "repertoire",
570
+ event: "repertoire.tool_list_delegations",
571
+ message: "list_delegations invoked",
572
+ meta: { tool: "list_delegations" },
573
+ });
574
+ const guard = requireTrustedRequester(ctx);
575
+ if (guard)
576
+ return guard;
577
+ /* v8 ignore next -- in-turn tool calls always carry an agentRoot; the getAgentRoot() ambient fallback depends on process argv (same pattern as storeFor) @preserve */
578
+ const { missionStore } = (0, delegation_stores_1.delegationStoresFor)(ctx?.agentRoot ?? (0, identity_1.getAgentRoot)());
579
+ /* v8 ignore next -- FileMissionStore always implements listAll; the [] fallback guards a future store-shape regression @preserve */
580
+ const missions = missionStore.listAll ? await missionStore.listAll() : [];
581
+ const rows = [];
582
+ for (const mission of missions) {
583
+ for (const [fromAgentId, byRequest] of Object.entries(mission.importedDelegations ?? {})) {
584
+ for (const [requestId, delegation] of Object.entries(byRequest)) {
585
+ rows.push({
586
+ requestId,
587
+ fromAgentId,
588
+ missionKey: mission.missionKey,
589
+ missionTitle: mission.title,
590
+ summary: delegation.task.summary,
591
+ ...(delegation.task.details ? { details: delegation.task.details } : {}),
592
+ });
593
+ }
594
+ }
595
+ }
596
+ return JSON.stringify(rows, null, 2);
597
+ },
598
+ summaryKeys: [],
599
+ },
600
+ {
601
+ tool: {
602
+ type: "function",
603
+ function: {
604
+ name: "send_result",
605
+ description: "Return a result for a delegation another agent sent you (by its requestId, from list_delegations). Seals the result and delivers it back to the delegating agent over the direct rung; they import it (assignee/correlation-gated). Deliver-back is explicit — do the work in your turn, then call this.",
606
+ parameters: {
607
+ type: "object",
608
+ properties: {
609
+ request_id: { type: "string", description: "The delegation correlation requestId (from list_delegations)." },
610
+ summary: { type: "string", description: "A summary of the result/deliverable." },
611
+ artifact: { type: "string", description: "Optional artifact reference (e.g. a PR or document URL)." },
612
+ },
613
+ required: ["request_id", "summary"],
614
+ },
615
+ },
616
+ },
617
+ handler: async (args, ctx) => {
618
+ (0, runtime_1.emitNervesEvent)({
619
+ component: "repertoire",
620
+ event: "repertoire.tool_send_result",
621
+ message: "send_result invoked",
622
+ meta: { tool: "send_result", requestId: args.request_id },
623
+ });
624
+ const guard = requireTrustedRequester(ctx);
625
+ if (guard)
626
+ return guard;
627
+ const store = storeFor(ctx);
628
+ /* v8 ignore next -- in-turn tool calls always carry an agentRoot; the getAgentRoot() ambient fallback depends on process argv (same pattern as storeFor) @preserve */
629
+ const { missionStore, grantStore } = (0, delegation_stores_1.delegationStoresFor)(ctx?.agentRoot ?? (0, identity_1.getAgentRoot)());
630
+ // Find the imported delegation this result answers (who delegated + which mission).
631
+ /* v8 ignore next -- FileMissionStore always implements listAll; the [] fallback guards a future store-shape regression @preserve */
632
+ const missions = missionStore.listAll ? await missionStore.listAll() : [];
633
+ const found = findImportedDelegation(missions, args.request_id);
634
+ if (!found)
635
+ return `no imported delegation found for requestId ${args.request_id} (check list_delegations).`;
636
+ // The delegator (the recipient of the result) must be a trusted, reachable,
637
+ // DID-keyed peer in our store.
638
+ const delegator = await (0, friends_1.findFriendByDid)(store, found.fromAgentId);
639
+ if (!delegator || !isA2APeer(delegator))
640
+ return `delegating peer not found for ${found.fromAgentId}.`;
641
+ if (!(0, friends_1.isTrustedLevel)(delegator.trustLevel))
642
+ return "delegating peer must be friend or family trust to return a result.";
643
+ const delegatorDid = peerDid(delegator);
644
+ /* v8 ignore next -- unreachable: findFriendByDid matched this record BY its DID (via resolveAgentIdentity, which omits empty DIDs), so a2a.did is always present here. Defensive. @preserve */
645
+ if (!delegatorDid)
646
+ return "delegating peer is not DID-keyed (cannot seal the result).";
647
+ const parsedDelegator = (0, a2a_client_1.parseDidKey)(delegatorDid);
648
+ if (!parsedDelegator)
649
+ return `cannot seal: delegating peer DID is unparseable (${delegatorDid}).`;
650
+ const endpointUrl = delegator.agentMeta?.a2a?.endpointUrl;
651
+ if (!endpointUrl)
652
+ return "delegating peer has no reachable A2A endpoint to return the result to.";
653
+ /* v8 ignore next -- in-turn send_result always carries a bundle agentRoot; the ambient-name fallback is defensive (same pattern as connect_to) @preserve */
654
+ const agentName = agentNameFromRoot(ctx?.agentRoot) ?? "ouro-agent";
655
+ try {
656
+ const sodium = await (0, a2a_client_1.ready)();
657
+ const self = await (0, identity_2.loadSelfA2AIdentity)({ agentName, sodium });
658
+ // prepareMissionResult records the result first-party (results[requestId]) +
659
+ // returns the MissionResultEnvelope (consent-gated: trust ≥ friend recipient).
660
+ const prepared = await (0, friends_1.prepareMissionResult)(missionStore, store, grantStore, {
661
+ missionId: found.missionId,
662
+ toAgentId: delegatorDid,
663
+ requestId: args.request_id,
664
+ selfAgentId: self.did,
665
+ result: { summary: args.summary, ...(args.artifact ? { artifact: args.artifact } : {}) },
666
+ });
667
+ /* v8 ignore next 3 -- unreachable for a trusted DID-keyed delegator: the mission exists (it carried the imported delegation we matched) and the isTrustedLevel guard guarantees trust≥friend, which the tiered consent default approves. Defensive only. @preserve */
668
+ if (!prepared.ok) {
669
+ return `send_result refused (${prepared.status}).`;
670
+ }
671
+ // HARNESS-OWNED RESULT WIRE: seal the envelope (reusing the seal crypto), wrap
672
+ // it in the mission_result-tagged DataPart, and POST it to the delegator over
673
+ // direct. The inbound side routes it to importMissionResult (NOT receiveShare).
674
+ const sealed = (0, a2a_client_1.sealEnvelope)({
675
+ sodium,
676
+ envelope: prepared.envelope,
677
+ friendsKind: "coordination",
678
+ fromIdentity: { did: self.did, keyId: self.keyId, ed25519Priv: self.ed25519Priv },
679
+ recipientDid: delegatorDid,
680
+ recipientX25519Pub: (0, a2a_client_1.keyAgreementFromDidKey)({ sodium, ed25519Pub: parsedDelegator.ed25519Pub }),
681
+ });
682
+ const message = (0, mission_result_wire_1.wrapMissionResultDataPart)({ sealedEnvelope: sealed, recipientDid: delegatorDid });
683
+ (0, runtime_1.emitNervesEvent)({
684
+ component: "channels",
685
+ event: "channel.a2a_result_send",
686
+ message: "delivering sealed mission result over the direct rung",
687
+ meta: { selfDid: self.did, toDid: delegatorDid, requestId: args.request_id },
688
+ });
689
+ await (0, client_1.postA2AMessageEnvelope)({ endpointUrl, message });
690
+ return `result sent to ${delegator.name} (${delegatorDid}) for requestId ${args.request_id}.`;
691
+ }
692
+ catch (error) {
693
+ return `send_result error: ${error instanceof Error ? error.message : /* v8 ignore next -- defensive non-Error failures @preserve */ String(error)}`;
694
+ }
695
+ },
696
+ summaryKeys: ["request_id"],
697
+ riskProfile: { mutates: "external_side_effect", risk: "high", reason: "returns a result to a remote agent peer" },
698
+ },
282
699
  ];
@@ -42,6 +42,7 @@ const identity_1 = require("../heart/identity");
42
42
  const session_events_1 = require("../heart/session-events");
43
43
  const runtime_1 = require("../nerves/runtime");
44
44
  const socket_client_1 = require("../heart/daemon/socket-client");
45
+ const agent_discovery_1 = require("../heart/daemon/agent-discovery");
45
46
  const thoughts_1 = require("../heart/daemon/thoughts");
46
47
  const manager_1 = require("../heart/bridges/manager");
47
48
  const session_transcript_1 = require("../heart/session-transcript");
@@ -711,6 +712,14 @@ exports.sessionToolDefinitions = [
711
712
  wakeResponse = null;
712
713
  }
713
714
  if (!wakeResponse?.ok) {
715
+ if (!(0, agent_discovery_1.isInnerDialogAutoStartEnabled)(agentName)) {
716
+ return renderInnerProgressStatus({
717
+ queue: "queued to inner/dialog",
718
+ wake: "parked by innerDialog.autoStart=false",
719
+ processing: "pending",
720
+ surfaced: "nothing yet",
721
+ });
722
+ }
714
723
  const { runInnerDialogTurn } = await Promise.resolve().then(() => __importStar(require("../senses/inner-dialog")));
715
724
  if (ctx?.context?.channel?.channel === "inner") {
716
725
  queueMicrotask(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.672",
3
+ "version": "0.1.0-alpha.674",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",