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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,17 @@
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.990",
6
+ "changes": [
7
+ "An agent can now reach a friend that is offline. A sealed A2A message goes direct first; if the friend does not answer, the same sealed message is queued at the friend's friends-relay inbox and the friend handles it when it comes back, through the same checks as a direct message. The relay only holds ciphertext.",
8
+ "An agent learns where to queue mail for a friend by asking it over a sealed call after a direct message goes through; only active friend or family peers get the answer. The tools say when a message, delegation or result was queued rather than delivered, and when a failed direct attempt may still have arrived.",
9
+ "A sender keeps a friend's relay contact only when the friend's own DID answered with it, and counts a message as queued only when the relay returns a queue id. An agent moved to another relay drains the old inbox until it is empty before removing it, and a move never waits for an older relay.",
10
+ "To make an agent reachable while offline, set `a2a.relay.url` and `a2a.relay.inviteToken` in its machine vault config and restart; the A2A sense registers, keeps the registration in the vault and polls the inbox. To stop waiting for every old relay at once after a move, set `a2a.relay.previousRegistrations` to an empty value. See docs/a2a-relay.md.",
11
+ "Compatibility: agents without a relay behave as before. A peer on an older harness refuses the contact request; that is cached as no relay and asked again a day later.",
12
+ "Fixes: an old relay no longer holds up a move. The agent retires it once the relay itself says it no longer knows the agent, or once attempts on it have failed for more than 7 days in a row; one failed attempt never deletes its credentials or tells the owner anything was lost. Relay requests time out after 30 seconds, and a relay answer that is not the relay's JSON counts as a failure, not an empty inbox. The inbox keeps running when the settings could not be read for retiring. A direct reply that is not a JSON-RPC answer to the message now counts as not delivered, so the relay is tried. A broken entry in the inbox no longer stops the messages behind it, a peer whose contact request fails is asked again only once a day, and the setup recipe now prompts for the invite instead of putting it on the command line. Each old-relay retirement record the agent writes is now logged, with the relay address but never its credentials."
13
+ ]
14
+ },
4
15
  {
5
16
  "version": "0.1.0-alpha.989",
6
17
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.989",
2
+ "runtimeVersion": "0.1.0-alpha.990",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -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.989</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.990</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,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.A2ARpcError = void 0;
3
+ exports.A2AHttpStatusError = exports.A2ARpcError = void 0;
4
4
  exports.cardDid = cardDid;
5
5
  exports.endpointForCard = endpointForCard;
6
6
  exports.fetchA2AAgentCard = fetchA2AAgentCard;
@@ -70,16 +70,48 @@ class A2ARpcError extends Error {
70
70
  }
71
71
  }
72
72
  exports.A2ARpcError = A2ARpcError;
73
- async function postJsonRpc(endpointUrl, request, fetchImpl, protocolVersion = "1.0") {
73
+ /**
74
+ * POST one JSON-RPC request. `strict` (the sealed delivery path) accepts a 2xx reply only when it is a JSON-RPC response
75
+ * to this request (`jsonrpc: "2.0"`, the same id, a result or an error); anything else (`{}`, a parked-domain page,
76
+ * another service on the same host) throws `A2AHttpStatusError`, because the peer was not reached.
77
+ */
78
+ async function postJsonRpc(endpointUrl, request, fetchImpl, protocolVersion = "1.0", options = {}) {
74
79
  const response = await fetchImpl(endpointUrl, {
75
80
  method: "POST",
76
81
  headers: { "content-type": "application/json", "A2A-Version": protocolVersion },
77
82
  body: JSON.stringify(request),
78
83
  });
79
84
  if (!response.ok) {
80
- throw new Error(`A2A JSON-RPC request failed (${response.status} ${response.statusText})`);
85
+ // A JSON-RPC body on a non-2xx status is still the peer's answer (the server sends 400 and 413 that way).
86
+ const answer = parseJsonRpcEnvelope(await response.text().catch(() => ""));
87
+ if (answer)
88
+ return answer;
89
+ throw new A2AHttpStatusError(response.status, response.statusText);
90
+ }
91
+ if (!options.strict)
92
+ return await response.json();
93
+ const answer = parseJsonRpcEnvelope(await response.text());
94
+ if (!answer || answer.id !== request.id)
95
+ throw new A2AHttpStatusError(response.status, `${response.statusText}, not a JSON-RPC answer`);
96
+ return answer;
97
+ }
98
+ /** A non-2xx HTTP reply that is not a JSON-RPC answer: something other than the agent (a tunnel, a proxy) replied. */
99
+ class A2AHttpStatusError extends Error {
100
+ status;
101
+ constructor(status, statusText) {
102
+ super(`A2A JSON-RPC request failed (${status} ${statusText})`);
103
+ this.status = status;
104
+ }
105
+ }
106
+ exports.A2AHttpStatusError = A2AHttpStatusError;
107
+ function parseJsonRpcEnvelope(text) {
108
+ try {
109
+ const parsed = JSON.parse(text);
110
+ return parsed && parsed.jsonrpc === "2.0" && ("error" in parsed || "result" in parsed) ? parsed : null;
111
+ }
112
+ catch {
113
+ return null;
81
114
  }
82
- return await response.json();
83
115
  }
84
116
  function methodNotFound(response) {
85
117
  return "error" in response && response.error.code === -32601;
@@ -166,9 +198,9 @@ async function postA2AMessageEnvelope(input) {
166
198
  message: "posting sealed A2A message envelope (direct rung)",
167
199
  meta: { endpointUrl: input.endpointUrl },
168
200
  });
169
- let rpc = await postJsonRpc(input.endpointUrl, request, fetchImpl, "0.3");
201
+ let rpc = await postJsonRpc(input.endpointUrl, request, fetchImpl, "0.3", { strict: true });
170
202
  if (methodNotFound(rpc)) {
171
- rpc = await postJsonRpc(input.endpointUrl, { ...request, method: "SendMessage" }, fetchImpl);
203
+ rpc = await postJsonRpc(input.endpointUrl, { ...request, method: "SendMessage" }, fetchImpl, "1.0", { strict: true });
172
204
  }
173
205
  if ("error" in rpc) {
174
206
  throw new A2ARpcError(rpc.error);
@@ -19,6 +19,8 @@ async function callOutboxMethod(input) {
19
19
  const peerDid = (0, client_1.cardDid)(card);
20
20
  if (!peerDid)
21
21
  throw new Error(`A2A card ${input.cardUrl} serves no DID; outbox calls need one`);
22
+ if (input.expectedDid && peerDid !== input.expectedDid)
23
+ throw new Error(`A2A card ${input.cardUrl} serves ${peerDid}, not ${input.expectedDid}`);
22
24
  const parsed = (0, a2a_client_1.parseDidKey)(peerDid);
23
25
  if (!parsed || !(0, a2a_client_1.verifyCardDidBinding)({ card: { did: peerDid, url: input.cardUrl }, did: peerDid, didDoc: null })) {
24
26
  throw new Error(`A2A card↔DID binding failed for ${peerDid}`);
@@ -21,8 +21,9 @@ const runtime_1 = require("../nerves/runtime");
21
21
  * outbox/list { since?: string, limit?: number } -> { entries, nextCursor, more } any active friend, own outbox
22
22
  * outbox/ack { ids: string[] } -> { acked, unknown } any active friend, own outbox
23
23
  * report/resolve { id, version, note, resolvedAt, proof } -> { id, status: "resolved" } escalation holders, own reports
24
+ * relay/contact {} -> { contact: { url, handle, sendCredential } | null } active friend or family
24
25
  */
25
- exports.OUTBOX_METHODS = ["outbox/list", "outbox/ack", "report/resolve"];
26
+ exports.OUTBOX_METHODS = ["outbox/list", "outbox/ack", "report/resolve", "relay/contact"];
26
27
  exports.OUTBOX_ERROR_REFUSED = -32003;
27
28
  exports.OUTBOX_ERROR_INVALID = -32602;
28
29
  const MAX_ACK_IDS = 100;
@@ -59,6 +60,16 @@ async function handleOutboxCommand(input) {
59
60
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.a2a_outbox_refused", message: "refused an outbox call from a peer that is not an active friend", meta: { method, friendId: friend?.id ?? null } });
60
61
  return refuse("only an active friend can use its outbox");
61
62
  }
63
+ if (method === "relay/contact") {
64
+ // The send credential lets its holder queue messages here, so only peers trusted as friend or family get it.
65
+ if (friend.trustLevel !== "friend" && friend.trustLevel !== "family") {
66
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.a2a_relay_contact_refused", message: "refused relay/contact to a peer below friend trust", meta: { friendId: friend.id, trustLevel: friend.trustLevel ?? null } });
67
+ return refuse("relay/contact needs friend or family trust");
68
+ }
69
+ const contact = input.relayContact ?? null;
70
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.a2a_relay_contact_shared", message: "answered relay/contact", meta: { friendId: friend.id, hasRelay: contact !== null } });
71
+ return { ok: true, result: { contact: contact ? { url: contact.url, handle: contact.handle, sendCredential: contact.sendCredential } : null } };
72
+ }
62
73
  const outbox = new outbox_store_1.FileOutboxStore(agentRoot);
63
74
  if (method === "outbox/list") {
64
75
  const { since, limit } = params;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.receiveRefusal = receiveRefusal;
4
4
  exports.receiveRefusalError = receiveRefusalError;
5
+ exports.isRetryableReceiveRefusal = isRetryableReceiveRefusal;
5
6
  const runtime_1 = require("../nerves/runtime");
6
7
  const UPGRADE = "Upgrade the sending agent's harness (ouro) and send again.";
7
8
  const RESEND = "Send it again as a new message.";
@@ -35,9 +36,18 @@ const UNKNOWN = { message: "the message was refused", next: "Check the agent's l
35
36
  function receiveRefusal(reason) {
36
37
  return Object.hasOwn(REFUSALS, reason) ? REFUSALS[reason] : UNKNOWN;
37
38
  }
39
+ /** Every `data` object `receiveRefusalError` made in this process, so a caller can tell a real receive refusal from an
40
+ * error that only looks like one (a delegated-command refusal also carries `retry`). */
41
+ const ISSUED = new WeakSet();
38
42
  /** The JSON-RPC error text and data for a refused inbound message: `A2A <what> rejected: <reason>. <message>. Next: <next>`. */
39
43
  function receiveRefusalError(what, reason) {
40
44
  const refusal = receiveRefusal(reason);
41
45
  (0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_receive_refused", message: "refused an inbound A2A message", meta: { what, reason, retry: refusal.retry } });
42
- return { message: `A2A ${what} rejected: ${reason}. ${refusal.message}. Next: ${refusal.next}`, data: { reason, retry: refusal.retry, next: refusal.next } };
46
+ const data = { reason, retry: refusal.retry, next: refusal.next };
47
+ ISSUED.add(data);
48
+ return { message: `A2A ${what} rejected: ${reason}. ${refusal.message}. Next: ${refusal.next}`, data };
49
+ }
50
+ /** True only for the `data` of a refusal made here that says the same message can succeed later (such as `in_flight`). */
51
+ function isRetryableReceiveRefusal(data) {
52
+ return typeof data === "object" && data !== null && ISSUED.has(data) && data.retry === true;
43
53
  }
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RELAY_REQUEST_TIMEOUT_MS = exports.RelayHttpError = void 0;
4
+ exports.relayRegister = relayRegister;
5
+ exports.relaySend = relaySend;
6
+ exports.relayPull = relayPull;
7
+ exports.relayAck = relayAck;
8
+ exports.relayDeregister = relayDeregister;
9
+ const runtime_1 = require("../nerves/runtime");
10
+ /**
11
+ * The four friends-relay calls the harness makes (ourostack/friends-relay `src/server/http.ts`): register a handle,
12
+ * send an opaque sealed message to a handle, pull a handle's inbox and ack one queued message. The relay is untrusted
13
+ * and content-blind: it only ever carries the sealed A2A message, byte for byte. Every non-2xx answer throws a
14
+ * `RelayHttpError` carrying the relay's status and error code. A 2xx answer that is not a JSON object (the relay
15
+ * always answers one), and a request with no answer within `RELAY_REQUEST_TIMEOUT_MS`, throw too.
16
+ */
17
+ class RelayHttpError extends Error {
18
+ status;
19
+ code;
20
+ /** The answer was the relay's own JSON error (`{ error: "<code>" }`), not a proxy's or a load balancer's page. */
21
+ fromRelay;
22
+ constructor(status, code, fromRelay = false) {
23
+ super(`relay ${status} ${code}`);
24
+ this.status = status;
25
+ this.code = code;
26
+ this.fromRelay = fromRelay;
27
+ }
28
+ }
29
+ exports.RelayHttpError = RelayHttpError;
30
+ exports.RELAY_REQUEST_TIMEOUT_MS = 30_000;
31
+ async function relayRequest(input) {
32
+ const fetchImpl = input.fetchImpl ?? fetch;
33
+ const headers = { "content-type": "application/json" };
34
+ if (input.bearer)
35
+ headers.authorization = `Bearer ${input.bearer}`;
36
+ const response = await fetchImpl(`${input.url.replace(/\/+$/, "")}${input.path}`, {
37
+ method: input.method,
38
+ headers,
39
+ ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),
40
+ signal: AbortSignal.timeout(input.timeoutMs ?? exports.RELAY_REQUEST_TIMEOUT_MS),
41
+ });
42
+ const parsed = await response.json().catch(() => undefined);
43
+ const payload = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
44
+ if (!response.ok) {
45
+ const fromRelay = typeof payload?.error === "string";
46
+ const code = fromRelay ? payload.error : "error";
47
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "channels", event: "channel.a2a_relay_http_error", message: "the A2A relay refused a request", meta: { path: input.path.split("/")[1], status: response.status, code } });
48
+ throw new RelayHttpError(response.status, code, fromRelay);
49
+ }
50
+ if (!payload)
51
+ throw new Error(`the relay answered ${response.status} without its JSON body`);
52
+ return payload;
53
+ }
54
+ /** Register (or re-register, which rotates both credentials) `handle` for `did`. */
55
+ async function relayRegister(input) {
56
+ const grant = await relayRequest({
57
+ ...input,
58
+ method: "POST",
59
+ path: "/register",
60
+ body: { handle: input.handle, did: input.did, agentCard: input.agentCard, ...(input.inviteToken ? { inviteToken: input.inviteToken } : {}) },
61
+ });
62
+ if (typeof grant.inboxAuth !== "string" || typeof grant.sendCredential !== "string")
63
+ throw new Error("relay registration returned no credentials");
64
+ (0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_relay_registered", message: "registered an A2A relay inbox", meta: { relayUrl: input.url } });
65
+ return { handle: input.handle, inboxAuth: grant.inboxAuth, sendCredential: grant.sendCredential };
66
+ }
67
+ /** Queue `message` (already sealed and signed) in `handle`'s inbox. Returns the relay's queue id. */
68
+ async function relaySend(input) {
69
+ const result = await relayRequest({ ...input, method: "POST", path: `/a2a/${encodeURIComponent(input.handle)}`, bearer: input.sendCredential, body: input.message });
70
+ // Something other than the relay (a captive portal, a proxy) can answer 2xx; only a queue id means it was queued.
71
+ if (typeof result.taskId !== "string")
72
+ throw new Error("the relay did not confirm the message was queued");
73
+ (0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_relay_sent", message: "queued a sealed A2A message at the relay", meta: { relayUrl: input.url } });
74
+ return result.taskId;
75
+ }
76
+ /**
77
+ * The queued messages in `handle`'s inbox (still sealed). An answer with no message list throws: it is not the relay's,
78
+ * so it says nothing about the inbox. An entry without a queue id cannot be acked, so it is logged
79
+ * (without its content) and skipped, and the rest of the queue is still handled. The relay has no way to drop an entry
80
+ * without its id, so a skipped entry stays queued until the relay's own expiry removes it.
81
+ */
82
+ async function relayPull(input) {
83
+ const result = await relayRequest({ ...input, method: "GET", path: `/inbox/${encodeURIComponent(input.handle)}`, bearer: input.inboxAuth });
84
+ if (!Array.isArray(result.messages))
85
+ throw new Error("the relay's inbox answer has no message list");
86
+ const entries = result.messages;
87
+ const valid = [];
88
+ entries.forEach((entry, position) => {
89
+ const queueId = entry?.queueId;
90
+ if (entry && typeof entry === "object" && typeof queueId === "string" && queueId.length > 0) {
91
+ valid.push({ queueId, message: entry.message });
92
+ return;
93
+ }
94
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "channels", event: "channel.a2a_relay_entry_skipped", message: "skipped a relay inbox entry with no queue id; it cannot be acked and stays at the relay until it expires", meta: { relayUrl: input.url, position } });
95
+ });
96
+ return valid;
97
+ }
98
+ /** Remove one delivered message from `handle`'s inbox. Re-acking is harmless. */
99
+ async function relayAck(input) {
100
+ await relayRequest({ ...input, method: "POST", path: `/inbox/${encodeURIComponent(input.handle)}/ack/${encodeURIComponent(input.queueId)}`, bearer: input.inboxAuth });
101
+ }
102
+ /** Remove `handle` and its queue from the relay (used when the agent moves to another relay). */
103
+ async function relayDeregister(input) {
104
+ await relayRequest({ ...input, method: "DELETE", path: `/register/${encodeURIComponent(input.handle)}`, bearer: input.inboxAuth });
105
+ (0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_relay_deregistered", message: "deregistered an A2A relay inbox", meta: { relayUrl: input.url } });
106
+ }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.relayConfigFrom = relayConfigFrom;
4
+ exports.vaultRelayConfigStore = vaultRelayConfigStore;
5
+ const runtime_1 = require("../nerves/runtime");
6
+ const runtime_credentials_1 = require("../heart/runtime-credentials");
7
+ function record(value) {
8
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
9
+ }
10
+ function text(value) {
11
+ return typeof value === "string" && value.length > 0 ? value : undefined;
12
+ }
13
+ function registrationFrom(value) {
14
+ const r = record(value);
15
+ const relayUrl = text(r?.relayUrl);
16
+ const handle = text(r?.handle);
17
+ const inboxAuth = text(r?.inboxAuth);
18
+ const sendCredential = text(r?.sendCredential);
19
+ return relayUrl && handle && inboxAuth && sendCredential ? { relayUrl, handle, inboxAuth, sendCredential } : undefined;
20
+ }
21
+ /** The `a2a.relay` settings in a machine runtime config, keeping only well-formed fields. */
22
+ function relayConfigFrom(config) {
23
+ const relay = record(record(config.a2a)?.relay);
24
+ if (!relay)
25
+ return {};
26
+ const url = text(relay.url);
27
+ const inviteToken = text(relay.inviteToken);
28
+ const registration = registrationFrom(relay.registration);
29
+ const pending = record(relay.pendingRegistration);
30
+ const pendingRelayUrl = text(pending?.relayUrl);
31
+ const pendingHandle = text(pending?.handle);
32
+ const previousRegistrations = (Array.isArray(relay.previousRegistrations) ? relay.previousRegistrations : [])
33
+ .map(registrationFrom)
34
+ .filter((registration) => registration !== undefined);
35
+ return {
36
+ ...(url ? { url } : {}),
37
+ ...(inviteToken ? { inviteToken } : {}),
38
+ ...(registration ? { registration } : {}),
39
+ ...(pendingRelayUrl && pendingHandle ? { pendingRegistration: { relayUrl: pendingRelayUrl, handle: pendingHandle } } : {}),
40
+ ...(previousRegistrations.length > 0 ? { previousRegistrations } : {}),
41
+ };
42
+ }
43
+ /**
44
+ * The relay settings, read from and written to this machine's runtime config in the vault (beside the A2A identity
45
+ * seed). Every read goes to the vault, so a write decided on a fresh read (clearing the invite only if it is unchanged)
46
+ * sees an operator's newer value. A patch merges into `a2a.relay`; a key set to `undefined` is dropped when the merged
47
+ * config is serialized.
48
+ */
49
+ function vaultRelayConfigStore(agentName, machineId) {
50
+ return {
51
+ async read() {
52
+ const result = await (0, runtime_credentials_1.refreshMachineRuntimeCredentialConfig)(agentName, machineId);
53
+ if (result.ok)
54
+ return relayConfigFrom(result.config);
55
+ if (result.reason === "missing")
56
+ return {};
57
+ throw new Error(`cannot read the A2A relay settings at ${result.itemPath}: ${result.error}`);
58
+ },
59
+ async write(patch) {
60
+ await (0, runtime_credentials_1.mergeMachineRuntimeCredentialConfig)(agentName, machineId, { a2a: { relay: patch } });
61
+ (0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_relay_config_saved", message: "saved A2A relay settings to the vault", meta: { agentName, keys: Object.keys(patch) } });
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,120 @@
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.RELAY_CONTACT_FETCH_TIMEOUT_MS = exports.RELAY_CONTACT_REFRESH_MS = void 0;
37
+ exports.readRelayContact = readRelayContact;
38
+ exports.writeRelayContact = writeRelayContact;
39
+ exports.markRelayContactStale = markRelayContactStale;
40
+ exports.relayContactNeedsRefresh = relayContactNeedsRefresh;
41
+ exports.refreshRelayContact = refreshRelayContact;
42
+ const node_crypto_1 = require("node:crypto");
43
+ const fs = __importStar(require("node:fs"));
44
+ const path = __importStar(require("node:path"));
45
+ const runtime_1 = require("../nerves/runtime");
46
+ const outbox_client_1 = require("./outbox-client");
47
+ exports.RELAY_CONTACT_REFRESH_MS = 24 * 60 * 60 * 1000;
48
+ function contactFile(agentRoot, did) {
49
+ return path.join(agentRoot, "state", "a2a", "relay-contacts", `${(0, node_crypto_1.createHash)("sha256").update(did).digest("hex")}.json`);
50
+ }
51
+ function isContact(value) {
52
+ const c = value;
53
+ return !!c && typeof c === "object" && typeof c.url === "string" && typeof c.handle === "string" && typeof c.sendCredential === "string";
54
+ }
55
+ function readRelayContact(agentRoot, did) {
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(fs.readFileSync(contactFile(agentRoot, did), "utf8"));
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ if (!parsed || parsed.did !== did || typeof parsed.fetchedAt !== "string")
64
+ return null;
65
+ return {
66
+ did,
67
+ fetchedAt: parsed.fetchedAt,
68
+ ...(isContact(parsed.contact) ? { contact: { url: parsed.contact.url, handle: parsed.contact.handle, sendCredential: parsed.contact.sendCredential } } : {}),
69
+ ...(parsed.stale === true ? { stale: true } : {}),
70
+ ...(typeof parsed.attemptedAt === "string" ? { attemptedAt: parsed.attemptedAt } : {}),
71
+ };
72
+ }
73
+ function writeRelayContact(agentRoot, entry) {
74
+ const file = contactFile(agentRoot, entry.did);
75
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
76
+ const temp = `${file}.${(0, node_crypto_1.randomBytes)(6).toString("hex")}.tmp`;
77
+ fs.writeFileSync(temp, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
78
+ fs.renameSync(temp, file);
79
+ (0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_relay_contact_saved", message: "saved a peer's A2A relay contact", meta: { peerDid: entry.did, hasRelay: entry.contact !== undefined, stale: entry.stale === true } });
80
+ }
81
+ function markRelayContactStale(agentRoot, did) {
82
+ const entry = readRelayContact(agentRoot, did);
83
+ if (!entry)
84
+ return;
85
+ // A new refusal asks again at the next direct send, even if a refresh failed earlier today.
86
+ writeRelayContact(agentRoot, { did: entry.did, fetchedAt: entry.fetchedAt, ...(entry.contact ? { contact: entry.contact } : {}), stale: true });
87
+ }
88
+ /** True when the entry is missing, or its last fetch or failed attempt is more than a day old (at once when stale and not yet retried). */
89
+ function relayContactNeedsRefresh(entry, now) {
90
+ if (!entry)
91
+ return true;
92
+ const attemptedAt = entry.attemptedAt === undefined ? Number.NaN : Date.parse(entry.attemptedAt);
93
+ if (entry.stale && !Number.isFinite(attemptedAt))
94
+ return true;
95
+ const fetchedAt = Date.parse(entry.fetchedAt);
96
+ const last = Math.max(Number.isFinite(fetchedAt) ? fetchedAt : -Infinity, Number.isFinite(attemptedAt) ? attemptedAt : -Infinity);
97
+ return now - last > exports.RELAY_CONTACT_REFRESH_MS;
98
+ }
99
+ exports.RELAY_CONTACT_FETCH_TIMEOUT_MS = 10_000;
100
+ /**
101
+ * Ask a peer, over the sealed `relay/contact` method, where to queue messages for it while it is offline, and cache the
102
+ * answer. Runs after a successful direct send (the peer is online then), at most once a day per peer. A failure never
103
+ * throws: it keeps a contact already cached and records when it tried, and otherwise records "no relay", so either
104
+ * way the peer is not asked again today (a peer on an older harness answers "unknown method").
105
+ */
106
+ async function refreshRelayContact(input) {
107
+ const fetchedAt = new Date(input.now ?? Date.now()).toISOString();
108
+ const baseFetch = input.fetchImpl ?? fetch;
109
+ const fetchImpl = ((url, init) => baseFetch(url, { ...init, signal: AbortSignal.timeout(exports.RELAY_CONTACT_FETCH_TIMEOUT_MS) }));
110
+ try {
111
+ const result = await (0, outbox_client_1.callOutboxMethod)({ cardUrl: input.cardUrl, method: "relay/contact", params: {}, identity: input.identity, fetchImpl, expectedDid: input.peerDid });
112
+ writeRelayContact(input.agentRoot, { did: input.peerDid, fetchedAt, ...(isContact(result.contact) ? { contact: { url: result.contact.url, handle: result.contact.handle, sendCredential: result.contact.sendCredential } } : {}) });
113
+ }
114
+ catch (error) {
115
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "channels", event: "channel.a2a_relay_contact_refresh_failed", message: "could not fetch a peer's relay contact", meta: { peerDid: input.peerDid, error: error instanceof Error ? error.message : /* v8 ignore next -- the outbox client only throws Error instances @preserve */ String(error) } });
116
+ // Keep a cached contact (it may still work) but record the attempt, so a failing peer is asked at most once a day.
117
+ const existing = readRelayContact(input.agentRoot, input.peerDid);
118
+ writeRelayContact(input.agentRoot, existing?.contact ? { ...existing, attemptedAt: fetchedAt } : { did: input.peerDid, fetchedAt });
119
+ }
120
+ }