@ouro.bot/cli 0.1.0-alpha.877 → 0.1.0-alpha.879
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 +12 -0
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +23 -0
- package/deploy/unraid/sanctuary-replay-gate.mjs +182 -22
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +2 -0
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +4 -2
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/a2a/client.js +1 -0
- package/dist/a2a/escalation-grants.js +122 -0
- package/dist/a2a/escalation-wiring.js +60 -0
- package/dist/a2a/outbox-client.js +57 -0
- package/dist/a2a/outbox-store.js +188 -0
- package/dist/a2a/outbox-wire.js +95 -0
- package/dist/a2a/resolution-proof.js +53 -0
- package/dist/a2a/server.js +66 -0
- package/dist/a2a/trusted-files.js +98 -0
- package/dist/heart/awaiting/a2a-await-delivery.js +18 -0
- package/dist/heart/daemon/cli-exec.js +56 -2
- package/dist/heart/daemon/cli-parse.js +65 -1
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +33 -0
- package/dist/heart/failure-reports.js +335 -0
- package/dist/heart/file-lock.js +109 -0
- package/dist/repertoire/guardrails.js +4 -0
- package/dist/repertoire/tools-awaiting.js +33 -2
- package/dist/repertoire/tools-base.js +2 -0
- package/dist/repertoire/tools-escalation.js +84 -0
- package/dist/repertoire/tools.js +1 -0
- package/dist/senses/a2a-entry.js +3 -1
- package/dist/senses/private-runtime.js +31 -14
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.PEER_ASKED_OWNER_TEXT = void 0;
|
|
36
37
|
exports.askedOwnerText = askedOwnerText;
|
|
37
38
|
exports.createA2AAwaitOwnerDeliverer = createA2AAwaitOwnerDeliverer;
|
|
38
39
|
exports.defaultNotifyOwner = defaultNotifyOwner;
|
|
@@ -40,14 +41,22 @@ const path = __importStar(require("path"));
|
|
|
40
41
|
const friends_1 = require("@ouro.bot/friends");
|
|
41
42
|
const identity_1 = require("../identity");
|
|
42
43
|
const replay_harness_1 = require("../../a2a/replay-harness");
|
|
44
|
+
const outbox_store_1 = require("../../a2a/outbox-store");
|
|
43
45
|
const runtime_1 = require("../../nerves/runtime");
|
|
44
46
|
/**
|
|
45
47
|
* An A2A peer is a client that calls this agent's server; there is no channel back to it. When an await filed from
|
|
46
48
|
* an A2A conversation resolves or expires, the outcome goes to the agent's owner in the owner's own Telegram chat,
|
|
47
49
|
* written by the agent as itself (an owner notice, the same path that announces delegated commands). Nothing is
|
|
48
50
|
* sent as the owner or to the peer, and the notice id is the await's delivery id, so a retry cannot double-send.
|
|
51
|
+
*
|
|
52
|
+
* An await filed in a plain A2A conversation (no request id) was not delegated by the owner, so the owner hears nothing
|
|
53
|
+
* and the outcome goes to the peer instead: into its own outbox, which it reads with `outbox/list`. A question that needs
|
|
54
|
+
* the owner's decision is the exception, because only the owner can answer it: the peer is told, in one fixed
|
|
55
|
+
* sentence that never repeats the question, that the owner was asked, and the owner is asked as before.
|
|
49
56
|
*/
|
|
50
57
|
const NOTICE_ID_MAX_BYTES = 512;
|
|
58
|
+
/** The owner's question can carry anything the Butler read or wrote, so a plain peer is told only that the owner was asked. */
|
|
59
|
+
exports.PEER_ASKED_OWNER_TEXT = "I've asked my owner and will let you know.";
|
|
51
60
|
/** A question for the owner reads as the owner's own question, with a pointer to the request it is about; every other outcome is a follow-up report. */
|
|
52
61
|
function askedOwnerText(question, about) {
|
|
53
62
|
return `${question}\n\n(about the request from ${about})`;
|
|
@@ -64,6 +73,15 @@ function createA2AAwaitOwnerDeliverer(agentName, notifyOwner = defaultNotifyOwne
|
|
|
64
73
|
const peer = await new friends_1.FileFriendStore(path.join((0, identity_1.getAgentRoot)(agentName), "friends")).get(request.friendId);
|
|
65
74
|
const agentRoot = (0, identity_1.getAgentRoot)(agentName);
|
|
66
75
|
const from = peer?.name ?? "a connected agent";
|
|
76
|
+
const peerAwait = !request.requestId;
|
|
77
|
+
if (peerAwait) {
|
|
78
|
+
const body = request.noticeKind === "asked_owner"
|
|
79
|
+
? exports.PEER_ASKED_OWNER_TEXT
|
|
80
|
+
: request.content;
|
|
81
|
+
await new outbox_store_1.FileOutboxStore(agentRoot).appendOnce(request.friendId, request.deliveryId, { kind: "await_outcome", body, meta: { outcome: request.noticeKind ?? "follow_up" } });
|
|
82
|
+
if (request.noticeKind !== "asked_owner")
|
|
83
|
+
return { status: "delivered_now", detail: "posted to the peer's outbox" };
|
|
84
|
+
}
|
|
67
85
|
if ((0, replay_harness_1.isReplayWindowOpen)(agentRoot, request.friendId)) {
|
|
68
86
|
(0, replay_harness_1.appendReplayNotice)(agentRoot, { noticeId: request.deliveryId, text: request.noticeKind === "asked_owner" ? ownerNoticeText(request, from) : request.content, friendId: request.friendId });
|
|
69
87
|
return { status: "delivered_now", detail: "written to the replay sink (replay window open for this peer)" };
|
|
@@ -525,6 +525,7 @@ function agentResolutionFailureMode(command) {
|
|
|
525
525
|
case "a2a.card":
|
|
526
526
|
case "a2a.onboard":
|
|
527
527
|
case "a2a.serve":
|
|
528
|
+
case "a2a.escalation":
|
|
528
529
|
return "throw";
|
|
529
530
|
case "provider.use":
|
|
530
531
|
case "provider.check":
|
|
@@ -6098,6 +6099,26 @@ async function executeA2AClientCommand(command, deps) {
|
|
|
6098
6099
|
deps.writeStdout(message);
|
|
6099
6100
|
return message;
|
|
6100
6101
|
}
|
|
6102
|
+
if (command.kind === "a2a.outbox") {
|
|
6103
|
+
const { callOutboxMethod } = await Promise.resolve().then(() => __importStar(require("../../a2a/outbox-client")));
|
|
6104
|
+
const call = command.action === "list" ? { method: "outbox/list", params: { ...(command.since ? { since: command.since } : {}) } }
|
|
6105
|
+
: command.action === "ack" ? { method: "outbox/ack", params: { ids: command.ids } }
|
|
6106
|
+
: await (async () => {
|
|
6107
|
+
// The resolution is signed with this machine's key: the Butler tells its owner "fixed" only for a holder's signature.
|
|
6108
|
+
const { signResolution } = await Promise.resolve().then(() => __importStar(require("../../a2a/resolution-proof")));
|
|
6109
|
+
const { ready } = await Promise.resolve().then(() => __importStar(require("@ouro.bot/friends/a2a-client")));
|
|
6110
|
+
const claim = { reportId: command.reportId, version: command.version, note: command.note, resolvedAt: new Date().toISOString() };
|
|
6111
|
+
return { method: "report/resolve", params: { id: claim.reportId, version: claim.version, note: claim.note, resolvedAt: claim.resolvedAt, proof: signResolution({ sodium: await ready(), identity, claim }) } };
|
|
6112
|
+
})();
|
|
6113
|
+
const result = await callOutboxMethod({
|
|
6114
|
+
cardUrl: command.to, ...call, identity,
|
|
6115
|
+
/* v8 ignore next -- production CLI uses global fetch; tests inject fetch for a hermetic peer @preserve */
|
|
6116
|
+
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
6117
|
+
});
|
|
6118
|
+
const message = command.json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
6119
|
+
deps.writeStdout(message);
|
|
6120
|
+
return message;
|
|
6121
|
+
}
|
|
6101
6122
|
const { sendSealedA2AChat } = await Promise.resolve().then(() => __importStar(require("../../a2a/client")));
|
|
6102
6123
|
const reply = await sendSealedA2AChat({
|
|
6103
6124
|
cardUrl: command.to,
|
|
@@ -6132,6 +6153,37 @@ async function executeA2ACommand(command, deps) {
|
|
|
6132
6153
|
deps.writeStdout(message);
|
|
6133
6154
|
return message;
|
|
6134
6155
|
}
|
|
6156
|
+
if (command.kind === "a2a.escalation") {
|
|
6157
|
+
const { readEscalationGrants, setEscalationGrant } = await Promise.resolve().then(() => __importStar(require("../../a2a/escalation-grants")));
|
|
6158
|
+
/* v8 ignore next -- production default bundles root; tests inject bundlesRoot @preserve */
|
|
6159
|
+
const agentRoot = path.join(deps.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)(), `${command.agent}.ouro`);
|
|
6160
|
+
if (command.action === "list") {
|
|
6161
|
+
const grants = Object.entries(readEscalationGrants(agentRoot));
|
|
6162
|
+
const message = grants.length === 0 ? "no escalation grants" : grants.map(([id, grant]) => `${id} granted ${grant.grantedAt} ${grant.did} ${grant.source}`).join("\n");
|
|
6163
|
+
deps.writeStdout(message);
|
|
6164
|
+
return message;
|
|
6165
|
+
}
|
|
6166
|
+
const friend = await new friends_1.FileFriendStore(path.join(agentRoot, "friends")).get(command.friendId);
|
|
6167
|
+
if (!friend)
|
|
6168
|
+
throw new Error(`friend not found: ${command.friendId}`);
|
|
6169
|
+
const at = new Date(deps.now?.() ?? Date.now());
|
|
6170
|
+
const { friendDid } = await Promise.resolve().then(() => __importStar(require("../../a2a/resolution-proof")));
|
|
6171
|
+
const holderDid = command.action === "grant" ? friendDid(friend) : null;
|
|
6172
|
+
if (command.action === "grant" && !holderDid)
|
|
6173
|
+
throw new Error(`${friend.name} has no pinned DID yet; onboard it with a DID before granting escalation`);
|
|
6174
|
+
const change = command.action === "grant"
|
|
6175
|
+
? setEscalationGrant(agentRoot, friend.id, { grant: true, source: command.source ?? `ouro a2a escalation grant, ${at.toISOString()}`, did: holderDid }, at)
|
|
6176
|
+
: setEscalationGrant(agentRoot, friend.id, { grant: false }, at);
|
|
6177
|
+
const message = [
|
|
6178
|
+
`${command.action === "grant" ? "granted" : "revoked"} escalation: ${friend.name} (${friend.id})${change.changed ? "" : " (no change)"}`,
|
|
6179
|
+
...(holderDid ? [`pinned DID: ${holderDid} (compare it with the holder's real DID)`] : []),
|
|
6180
|
+
...(change.backup ? [`backup: ${change.backup}`] : []),
|
|
6181
|
+
...(command.action === "grant" && readEscalationGrants(agentRoot)[friend.id] === undefined ? [`WARNING: the grant is written but will not be honoured: ${path.join(agentRoot, "state", "a2a")} and escalation-grants.json must be owned by root and writable by no one else. Run this command as root (docker exec as root) or chown root:root and chmod 755/644 them.`] : []),
|
|
6182
|
+
...(command.action === "grant" && (friend.trustLevel !== "family" || friend.admissionState !== "active") ? [`note: ${friend.name} only holds the grant while it is active family (now ${friend.trustLevel}, ${friend.admissionState})`] : []),
|
|
6183
|
+
].join("\n");
|
|
6184
|
+
deps.writeStdout(message);
|
|
6185
|
+
return message;
|
|
6186
|
+
}
|
|
6135
6187
|
/* v8 ignore next -- false branch is foreground serve, intentionally ignored below because it waits for signals @preserve */
|
|
6136
6188
|
if (command.kind === "a2a.onboard") {
|
|
6137
6189
|
if (command.did) {
|
|
@@ -6179,8 +6231,10 @@ async function executeA2ACommand(command, deps) {
|
|
|
6179
6231
|
}
|
|
6180
6232
|
/* v8 ignore start -- foreground serve intentionally waits for process signals; a2a/server has route-level coverage @preserve */
|
|
6181
6233
|
const { startA2AServer } = await Promise.resolve().then(() => __importStar(require("../../a2a/server")));
|
|
6234
|
+
const { escalationOptionsFor, sendOwnerNoticeViaTelegram } = await Promise.resolve().then(() => __importStar(require("../../a2a/escalation-wiring")));
|
|
6182
6235
|
const handle = await startA2AServer({
|
|
6183
6236
|
agentName: command.agent,
|
|
6237
|
+
...escalationOptionsFor(command.agent, (notice) => sendOwnerNoticeViaTelegram(command.agent, notice)),
|
|
6184
6238
|
...(command.host ? { host: command.host } : {}),
|
|
6185
6239
|
...(command.port ? { port: command.port } : {}),
|
|
6186
6240
|
...(command.baseUrl ? { baseUrl: command.baseUrl } : {}),
|
|
@@ -7204,7 +7258,7 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
7204
7258
|
return message;
|
|
7205
7259
|
}
|
|
7206
7260
|
// ── a2a client commands (this machine talks to an agent as a verified friend; no agent, no daemon) ──
|
|
7207
|
-
if (command.kind === "a2a.identity" || command.kind === "a2a.message") {
|
|
7261
|
+
if (command.kind === "a2a.identity" || command.kind === "a2a.message" || command.kind === "a2a.outbox") {
|
|
7208
7262
|
return executeA2AClientCommand(command, deps);
|
|
7209
7263
|
}
|
|
7210
7264
|
// ── versions command (local install list + published update truth, no daemon socket needed) ──
|
|
@@ -7908,7 +7962,7 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
7908
7962
|
deps.writeStdout(message);
|
|
7909
7963
|
return message;
|
|
7910
7964
|
}
|
|
7911
|
-
if (command.kind === "a2a.card" || command.kind === "a2a.onboard" || command.kind === "a2a.serve") {
|
|
7965
|
+
if (command.kind === "a2a.card" || command.kind === "a2a.onboard" || command.kind === "a2a.serve" || command.kind === "a2a.escalation") {
|
|
7912
7966
|
return executeA2ACommand(command, deps);
|
|
7913
7967
|
}
|
|
7914
7968
|
// ── provider commands (local, no daemon socket needed) ──
|
|
@@ -154,6 +154,11 @@ function usage() {
|
|
|
154
154
|
" ouro a2a serve [--agent <name>] [--host <host>] [--port <port>] [--base-url <url>] [--path <path>]",
|
|
155
155
|
" ouro a2a identity [--identity-file <path>] [--json]",
|
|
156
156
|
" ouro a2a message --to <card-url> --text <text> [--context <id>] [--delegated] [--identity-file <path>] [--json]",
|
|
157
|
+
" ouro a2a outbox list --to <card-url> [--since <cursor>] [--identity-file <path>] [--json]",
|
|
158
|
+
" ouro a2a outbox ack --to <card-url> --ids <id,id,...> [--identity-file <path>] [--json]",
|
|
159
|
+
" ouro a2a outbox resolve --to <card-url> --report <id> --version <version> --note <text> [--identity-file <path>] [--json]",
|
|
160
|
+
" ouro a2a escalation <grant|revoke> --friend <id> [--source <text>] [--agent <name>]",
|
|
161
|
+
" ouro a2a escalation list [--agent <name>]",
|
|
157
162
|
" ouro whoami [--agent <name>]",
|
|
158
163
|
" ouro session list [--agent <name>]",
|
|
159
164
|
" ouro mcp list",
|
|
@@ -1550,6 +1555,65 @@ function parseA2ACommand(args) {
|
|
|
1550
1555
|
throw new Error(usageText);
|
|
1551
1556
|
return { kind: "a2a.message", to, text, ...(conversationId ? { conversationId } : {}), ...(delegated ? { delegated: true } : {}), ...(identityFile ? { identityFile } : {}), ...(json ? { json: true } : {}) };
|
|
1552
1557
|
}
|
|
1558
|
+
if (sub === "outbox") {
|
|
1559
|
+
const usageText = "Usage: ouro a2a outbox <list|ack|resolve> --to <card-url> [--since <cursor>] [--ids <id,id,...>] [--report <id> --version <version> --note <text>] [--identity-file <path>] [--json]";
|
|
1560
|
+
const action = rest[0];
|
|
1561
|
+
if (action !== "list" && action !== "ack" && action !== "resolve")
|
|
1562
|
+
throw new Error(usageText);
|
|
1563
|
+
const values = {};
|
|
1564
|
+
let json = false;
|
|
1565
|
+
for (let i = 1; i < rest.length; i += 1) {
|
|
1566
|
+
if (rest[i] === "--json") {
|
|
1567
|
+
json = true;
|
|
1568
|
+
continue;
|
|
1569
|
+
}
|
|
1570
|
+
const flag = rest[i];
|
|
1571
|
+
if (["--to", "--since", "--ids", "--report", "--version", "--note", "--identity-file"].includes(flag) && rest[i + 1]) {
|
|
1572
|
+
values[flag] = rest[++i];
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
throw new Error(usageText);
|
|
1576
|
+
}
|
|
1577
|
+
const allowed = { list: ["--to", "--since", "--identity-file"], ack: ["--to", "--ids", "--identity-file"], resolve: ["--to", "--report", "--version", "--note", "--identity-file"] };
|
|
1578
|
+
const required = { list: ["--to"], ack: ["--to", "--ids"], resolve: ["--to", "--report", "--version", "--note"] };
|
|
1579
|
+
if (Object.keys(values).some((flag) => !allowed[action].includes(flag)) || required[action].some((flag) => !values[flag]))
|
|
1580
|
+
throw new Error(usageText);
|
|
1581
|
+
const ids = values["--ids"]?.split(",").map((id) => id.trim()).filter(Boolean);
|
|
1582
|
+
if (action === "ack" && (!ids || ids.length === 0))
|
|
1583
|
+
throw new Error(usageText);
|
|
1584
|
+
return {
|
|
1585
|
+
kind: "a2a.outbox", action, to: values["--to"],
|
|
1586
|
+
...(values["--since"] ? { since: values["--since"] } : {}),
|
|
1587
|
+
...(action === "ack" ? { ids: ids } : {}),
|
|
1588
|
+
...(values["--report"] ? { reportId: values["--report"] } : {}),
|
|
1589
|
+
...(values["--version"] ? { version: values["--version"] } : {}),
|
|
1590
|
+
...(values["--note"] ? { note: values["--note"] } : {}),
|
|
1591
|
+
...(values["--identity-file"] ? { identityFile: values["--identity-file"] } : {}),
|
|
1592
|
+
...(json ? { json: true } : {}),
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
if (sub === "escalation") {
|
|
1596
|
+
const usageText = "Usage: ouro a2a escalation <grant|revoke> --friend <id> [--source <text>] [--agent <name>] | ouro a2a escalation list [--agent <name>]";
|
|
1597
|
+
const action = rest[0];
|
|
1598
|
+
if (action !== "grant" && action !== "revoke" && action !== "list")
|
|
1599
|
+
throw new Error(usageText);
|
|
1600
|
+
let friendId;
|
|
1601
|
+
let source;
|
|
1602
|
+
for (let i = 1; i < rest.length; i += 1) {
|
|
1603
|
+
if (rest[i] === "--friend" && rest[i + 1]) {
|
|
1604
|
+
friendId = rest[++i];
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
if (rest[i] === "--source" && rest[i + 1]) {
|
|
1608
|
+
source = rest[++i];
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
throw new Error(usageText);
|
|
1612
|
+
}
|
|
1613
|
+
if ((action === "list") === Boolean(friendId) || (source && action !== "grant"))
|
|
1614
|
+
throw new Error(usageText);
|
|
1615
|
+
return { kind: "a2a.escalation", action, ...(agent ? { agent } : {}), ...(friendId ? { friendId } : {}), ...(source ? { source } : {}) };
|
|
1616
|
+
}
|
|
1553
1617
|
if (sub === "onboard") {
|
|
1554
1618
|
let cardUrl;
|
|
1555
1619
|
let did;
|
|
@@ -1612,7 +1676,7 @@ function parseA2ACommand(args) {
|
|
|
1612
1676
|
}
|
|
1613
1677
|
return { kind: "a2a.serve", ...(agent ? { agent } : {}), ...(host ? { host } : {}), ...(port ? { port } : {}), ...(baseUrl ? { baseUrl } : {}), ...(path ? { path } : {}) };
|
|
1614
1678
|
}
|
|
1615
|
-
throw new Error("Usage: ouro a2a card|onboard|serve ...");
|
|
1679
|
+
throw new Error("Usage: ouro a2a card|onboard|serve|message|identity|outbox|escalation ...");
|
|
1616
1680
|
}
|
|
1617
1681
|
function parseConfigCommand(args) {
|
|
1618
1682
|
const { agent, rest: afterAgent } = extractAgentFlag(args);
|
|
@@ -60,7 +60,7 @@ function exactSanctuaryContainmentProfileBoundaries(value) {
|
|
|
60
60
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
61
61
|
return false;
|
|
62
62
|
const boundaries = value;
|
|
63
|
-
const versions = { "sanctuary-owner":
|
|
63
|
+
const versions = { "sanctuary-owner": 16, "sanctuary-household": 5, "sanctuary-event": 4 };
|
|
64
64
|
if (!(0, node_util_1.isDeepStrictEqual)(Object.keys(boundaries).sort(), Object.keys(versions).sort()))
|
|
65
65
|
return false;
|
|
66
66
|
const packageRoot = path.resolve(__dirname, "../../../deploy/unraid/sanctuary.ouro");
|
|
@@ -986,6 +986,7 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
986
986
|
});
|
|
987
987
|
(0, node_child_process_1.execFileSync)("/bin/chown", ["-R", "10001:10001", this.#p(BUNDLE)], { stdio: "ignore" });
|
|
988
988
|
this.#restoreReplayRoot();
|
|
989
|
+
this.#restoreEscalationGrantRoot();
|
|
989
990
|
}
|
|
990
991
|
/**
|
|
991
992
|
* The recursive restore above hands the whole bundle to the resident, including state/replay. That directory is the
|
|
@@ -1009,6 +1010,38 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
1009
1010
|
fs.chmodSync(directory, 0o755);
|
|
1010
1011
|
fs.rmSync(path.join(directory, "window.json"), { force: true, recursive: true });
|
|
1011
1012
|
}
|
|
1013
|
+
/**
|
|
1014
|
+
* The same recursive restore hands state/a2a and the escalation grant file to the resident. The Butler honours a grant
|
|
1015
|
+
* only while the file and its directory are root-owned and closed to everyone else (src/a2a/trusted-files.ts), so
|
|
1016
|
+
* after every restore they go back under root, and a prompt-injected model running as the resident cannot mint one.
|
|
1017
|
+
* The subdirectories the resident writes (tasks, pins, seen) are created first and stay with it, so it never needs to
|
|
1018
|
+
* create anything inside the root-owned directory. Missing this is what made every report_failure and outbox case of
|
|
1019
|
+
* the alpha.878 replay gate fail: no peer held the grant, so no peer was an escalation holder.
|
|
1020
|
+
*/
|
|
1021
|
+
#restoreEscalationGrantRoot() {
|
|
1022
|
+
const directory = this.#p(`${BUNDLE}/state/a2a`);
|
|
1023
|
+
let stat;
|
|
1024
|
+
try {
|
|
1025
|
+
stat = fs.lstatSync(directory);
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (!stat.isDirectory())
|
|
1031
|
+
return;
|
|
1032
|
+
for (const sub of ["tasks", "pins", "seen"]) {
|
|
1033
|
+
fs.mkdirSync(path.join(directory, sub), { recursive: true });
|
|
1034
|
+
(0, node_child_process_1.execFileSync)("/bin/chown", ["-R", "10001:10001", path.join(directory, sub)], { stdio: "ignore" });
|
|
1035
|
+
}
|
|
1036
|
+
// Ownership first, before anything else can fail: nothing the Butler left in the directory may stop it going back under root.
|
|
1037
|
+
(0, node_child_process_1.execFileSync)("/bin/chown", ["-h", "0:0", directory], { stdio: "ignore" });
|
|
1038
|
+
fs.chmodSync(directory, 0o755);
|
|
1039
|
+
const grants = path.join(directory, "escalation-grants.json");
|
|
1040
|
+
if (fs.existsSync(grants)) {
|
|
1041
|
+
(0, node_child_process_1.execFileSync)("/bin/chown", ["-h", "0:0", grants], { stdio: "ignore" });
|
|
1042
|
+
fs.chmodSync(grants, 0o644);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1012
1045
|
/** Start the gateway, then the resident, and prove both. */
|
|
1013
1046
|
async #startUpgraded() {
|
|
1014
1047
|
this.#runtimeDirectories();
|
|
@@ -0,0 +1,335 @@
|
|
|
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.CLOSED_REPORT_RETENTION_MS = exports.REPORTS_PER_ORIGIN_DAY = exports.REPORTS_PER_ORIGIN_HOUR = exports.REPORTS_PER_DAY = exports.REPORTS_PER_HOUR = void 0;
|
|
37
|
+
exports.reportsDir = reportsDir;
|
|
38
|
+
exports.errorClass = errorClass;
|
|
39
|
+
exports.failureFingerprint = failureFingerprint;
|
|
40
|
+
exports.readFailureReport = readFailureReport;
|
|
41
|
+
exports.listFailureReports = listFailureReports;
|
|
42
|
+
exports.reportBody = reportBody;
|
|
43
|
+
exports.pruneClosedReports = pruneClosedReports;
|
|
44
|
+
exports.fileFailureReport = fileFailureReport;
|
|
45
|
+
exports.resolveFailureReport = resolveFailureReport;
|
|
46
|
+
exports.fixLiveNoticeText = fixLiveNoticeText;
|
|
47
|
+
exports.confirmResolvedReports = confirmResolvedReports;
|
|
48
|
+
const node_crypto_1 = require("node:crypto");
|
|
49
|
+
const fs = __importStar(require("node:fs"));
|
|
50
|
+
const path = __importStar(require("node:path"));
|
|
51
|
+
const semver = __importStar(require("semver"));
|
|
52
|
+
const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
|
|
53
|
+
const escalation_grants_1 = require("../a2a/escalation-grants");
|
|
54
|
+
const outbox_store_1 = require("../a2a/outbox-store");
|
|
55
|
+
const resolution_proof_1 = require("../a2a/resolution-proof");
|
|
56
|
+
const replay_harness_1 = require("../a2a/replay-harness");
|
|
57
|
+
const runtime_1 = require("../nerves/runtime");
|
|
58
|
+
const file_lock_1 = require("./file-lock");
|
|
59
|
+
exports.REPORTS_PER_HOUR = 5;
|
|
60
|
+
exports.REPORTS_PER_DAY = 20;
|
|
61
|
+
exports.REPORTS_PER_ORIGIN_HOUR = 3;
|
|
62
|
+
exports.REPORTS_PER_ORIGIN_DAY = 10;
|
|
63
|
+
exports.CLOSED_REPORT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
64
|
+
const FIELD_MAX_CHARS = 2_000;
|
|
65
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
66
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
67
|
+
const REPORT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
68
|
+
function reportsDir(agentRoot) {
|
|
69
|
+
return path.join(agentRoot, "state", "reports");
|
|
70
|
+
}
|
|
71
|
+
function reportFile(agentRoot, id) {
|
|
72
|
+
return path.join(reportsDir(agentRoot), `${id}.json`);
|
|
73
|
+
}
|
|
74
|
+
function cap(value) {
|
|
75
|
+
return value.trim().slice(0, FIELD_MAX_CHARS);
|
|
76
|
+
}
|
|
77
|
+
/** The same failure reads the same: numbers, ids and paths are blanked so a retry with a new request id folds in. */
|
|
78
|
+
function errorClass(error) {
|
|
79
|
+
return error.toLowerCase()
|
|
80
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gu, "<id>")
|
|
81
|
+
.replace(/(?:\/[\w.@-]+){2,}/gu, "<path>")
|
|
82
|
+
.replace(/\d+/gu, "<n>")
|
|
83
|
+
.replace(/\s+/gu, " ").trim().slice(0, 80);
|
|
84
|
+
}
|
|
85
|
+
function failureFingerprint(failedTool, error) {
|
|
86
|
+
return (0, node_crypto_1.createHash)("sha256").update(`${(failedTool ?? "").trim().toLowerCase()}\0${errorClass(error)}`).digest("hex").slice(0, 16);
|
|
87
|
+
}
|
|
88
|
+
function readFailureReport(agentRoot, id) {
|
|
89
|
+
if (!REPORT_ID.test(id))
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(fs.readFileSync(reportFile(agentRoot, id), "utf8"));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function writeReport(agentRoot, record) {
|
|
99
|
+
fs.mkdirSync(reportsDir(agentRoot), { recursive: true });
|
|
100
|
+
const file = reportFile(agentRoot, record.id);
|
|
101
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
102
|
+
fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
103
|
+
fs.renameSync(tmp, file);
|
|
104
|
+
}
|
|
105
|
+
function listFailureReports(agentRoot) {
|
|
106
|
+
let names;
|
|
107
|
+
try {
|
|
108
|
+
names = fs.readdirSync(reportsDir(agentRoot));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
return names.filter((name) => name.endsWith(".json")).flatMap((name) => {
|
|
114
|
+
const record = readFailureReport(agentRoot, name.slice(0, -5));
|
|
115
|
+
return record ? [record] : [];
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function reportBody(record) {
|
|
119
|
+
const { origin } = record;
|
|
120
|
+
return [
|
|
121
|
+
`Failure report ${record.shortId} (${record.severity})${origin.ownerOrigin ? "" : " - UNTRUSTED ORIGIN: not the owner's session"}`,
|
|
122
|
+
origin.ownerOrigin ? `Ari asked: ${record.ariWords}` : `A session with ${origin.friendName ?? "an unknown friend"} (${origin.trustLevel ?? "unknown trust"}) reported: ${record.ariWords}`,
|
|
123
|
+
`I tried: ${record.tried}`,
|
|
124
|
+
`${record.failedTool ? `${record.failedTool} failed` : "I gave up"}: ${record.error}`,
|
|
125
|
+
`Conversation: ${origin.channel ?? "unknown"}/${origin.key ?? "unknown"}`,
|
|
126
|
+
`Origin: friend ${origin.friendId ?? "unknown"}, trust ${origin.trustLevel ?? "unknown"}, owner ${origin.ownerOrigin ? "yes" : "no"}`,
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
129
|
+
/** The origin friend, read from the friend store: only an active family friend on the owner profile is the owner. */
|
|
130
|
+
async function resolveOrigin(store, origin) {
|
|
131
|
+
const friend = origin.friendId ? await store.get(origin.friendId) : null;
|
|
132
|
+
const ownerOrigin = Boolean(friend && friend.admissionState === "active" && friend.trustLevel === "family" && friend.capabilityProfileId === "sanctuary-owner");
|
|
133
|
+
return { ...origin, friendName: friend ? friend.name : null, trustLevel: friend ? String(friend.trustLevel) : null, ownerOrigin };
|
|
134
|
+
}
|
|
135
|
+
/** Closed reports are history; they go after a month so the directory does not grow without bound. */
|
|
136
|
+
function pruneClosedReports(agentRoot, now = Date.now()) {
|
|
137
|
+
let pruned = 0;
|
|
138
|
+
for (const record of listFailureReports(agentRoot)) {
|
|
139
|
+
if (record.status === "closed" && record.closedAt && now - Date.parse(record.closedAt) > exports.CLOSED_REPORT_RETENTION_MS) {
|
|
140
|
+
fs.rmSync(reportFile(agentRoot, record.id), { force: true });
|
|
141
|
+
pruned += 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return pruned;
|
|
145
|
+
}
|
|
146
|
+
function lockDir(agentRoot) {
|
|
147
|
+
return path.join(reportsDir(agentRoot), ".locks");
|
|
148
|
+
}
|
|
149
|
+
function overBudget(created, now, perHour, perDay) {
|
|
150
|
+
return created.filter((at) => now - at < HOUR_MS).length >= perHour || created.filter((at) => now - at < DAY_MS).length >= perDay;
|
|
151
|
+
}
|
|
152
|
+
async function fileFailureReport(agentRoot, store, input, now = Date.now()) {
|
|
153
|
+
const ariWords = cap(input.ariWords);
|
|
154
|
+
const tried = cap(input.tried);
|
|
155
|
+
const error = cap(input.error);
|
|
156
|
+
if (!ariWords || !tried || !error)
|
|
157
|
+
return { ok: false, reason: "invalid", detail: "ari_words, tried and error are all required" };
|
|
158
|
+
const failedTool = input.failedTool?.trim() ? input.failedTool.trim().slice(0, 120) : null;
|
|
159
|
+
const fingerprint = failureFingerprint(failedTool ?? undefined, error);
|
|
160
|
+
const origin = await resolveOrigin(store, input.origin);
|
|
161
|
+
const replay = origin.friendId !== null && (0, replay_harness_1.isReplayWindowOpen)(agentRoot, origin.friendId, now);
|
|
162
|
+
// A replay identity speaks only inside its window; once the window closes it must not reach a real outbox.
|
|
163
|
+
if (!replay && origin.friendId !== null && (0, replay_harness_1.isReplayIdentity)(agentRoot, origin.friendId))
|
|
164
|
+
return { ok: false, reason: "invalid", detail: "a replay identity can file only while its replay window is open" };
|
|
165
|
+
// One lock around read, rate check and write, so two processes cannot both pass the budget or file the same failure twice.
|
|
166
|
+
return (0, file_lock_1.withFileLock)(lockDir(agentRoot), "file", async () => {
|
|
167
|
+
pruneClosedReports(agentRoot, now);
|
|
168
|
+
const existing = listFailureReports(agentRoot);
|
|
169
|
+
// A replay conversation never folds: each gate run must leave a fresh report to read back.
|
|
170
|
+
const open = replay ? undefined : existing.find((record) => !record.replay && record.fingerprint === fingerprint && record.status !== "closed");
|
|
171
|
+
if (open) {
|
|
172
|
+
const folded = await foldRepeat(agentRoot, open.id, { error, failedTool, origin }, now);
|
|
173
|
+
if (folded)
|
|
174
|
+
return folded;
|
|
175
|
+
}
|
|
176
|
+
const holders = (await (0, escalation_grants_1.escalationHolders)(agentRoot, store)).filter((holder) => replay ? (0, replay_harness_1.isReplayWindowOpen)(agentRoot, holder.id, now) : !(0, replay_harness_1.isReplayIdentity)(agentRoot, holder.id));
|
|
177
|
+
if (holders.length === 0)
|
|
178
|
+
return { ok: false, reason: "no_escalation_peer", detail: replay ? "no replay escalation peer" : "no friend holds the escalation grant" };
|
|
179
|
+
const sameKind = existing.filter((record) => record.replay === replay);
|
|
180
|
+
if (overBudget(sameKind.map((record) => Date.parse(record.createdAt)), now, exports.REPORTS_PER_HOUR, exports.REPORTS_PER_DAY)) {
|
|
181
|
+
return { ok: false, reason: "rate_limited", detail: `at most ${exports.REPORTS_PER_HOUR} new reports an hour and ${exports.REPORTS_PER_DAY} a day` };
|
|
182
|
+
}
|
|
183
|
+
if (overBudget(sameKind.filter((record) => record.origin.friendId === origin.friendId).map((record) => Date.parse(record.createdAt)), now, exports.REPORTS_PER_ORIGIN_HOUR, exports.REPORTS_PER_ORIGIN_DAY)) {
|
|
184
|
+
return { ok: false, reason: "rate_limited", detail: `at most ${exports.REPORTS_PER_ORIGIN_HOUR} new reports an hour and ${exports.REPORTS_PER_ORIGIN_DAY} a day from one origin` };
|
|
185
|
+
}
|
|
186
|
+
const id = (0, node_crypto_1.randomUUID)();
|
|
187
|
+
const record = {
|
|
188
|
+
id, shortId: id.slice(0, 8), fingerprint, createdAt: new Date(now).toISOString(), lastSeenAt: new Date(now).toISOString(), occurrences: 1,
|
|
189
|
+
severity: input.severity, ariWords, tried, error, failedTool, origin, replay,
|
|
190
|
+
recipients: holders.map((holder) => holder.id), outboxEntries: {}, status: "open",
|
|
191
|
+
};
|
|
192
|
+
const outbox = new outbox_store_1.FileOutboxStore(agentRoot);
|
|
193
|
+
for (const holder of holders) {
|
|
194
|
+
record.outboxEntries[holder.id] = outbox.append(holder.id, {
|
|
195
|
+
kind: "failure_report",
|
|
196
|
+
body: reportBody(record),
|
|
197
|
+
meta: {
|
|
198
|
+
reportId: id, shortId: record.shortId, severity: record.severity, failedTool, fingerprint, ariWords: record.ariWords, conversation: { channel: origin.channel, key: origin.key },
|
|
199
|
+
origin: { friendId: origin.friendId, friendName: origin.friendName, trustLevel: origin.trustLevel, ownerOrigin: origin.ownerOrigin },
|
|
200
|
+
},
|
|
201
|
+
}, now).id;
|
|
202
|
+
}
|
|
203
|
+
writeReport(agentRoot, record);
|
|
204
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.failure_report_filed", message: "filed a failure report", meta: { reportId: id, severity: record.severity, recipients: holders.length, replay, ownerOrigin: origin.ownerOrigin } });
|
|
205
|
+
return { ok: true, id, shortId: record.shortId, duplicate: false, recipients: holders.length };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** Counts a repeat on the open report (re-read under the report's own lock) and tells the recipients, at most once a day per report. */
|
|
209
|
+
async function foldRepeat(agentRoot, id, repeat, now) {
|
|
210
|
+
return (0, file_lock_1.withFileLock)(lockDir(agentRoot), `report-${id}`, () => {
|
|
211
|
+
const fresh = readFailureReport(agentRoot, id);
|
|
212
|
+
if (!fresh || fresh.status === "closed")
|
|
213
|
+
return null;
|
|
214
|
+
const occurrences = fresh.occurrences + 1;
|
|
215
|
+
const notify = fresh.lastRepeatNoticeAt === undefined || now - Date.parse(fresh.lastRepeatNoticeAt) >= DAY_MS;
|
|
216
|
+
if (notify) {
|
|
217
|
+
const outbox = new outbox_store_1.FileOutboxStore(agentRoot);
|
|
218
|
+
for (const recipient of fresh.recipients) {
|
|
219
|
+
outbox.append(recipient, {
|
|
220
|
+
kind: "report_repeat",
|
|
221
|
+
body: `Failure report ${fresh.shortId} happened again (${occurrences} times so far)${repeat.origin.ownerOrigin ? "" : " - UNTRUSTED ORIGIN: not the owner's session"}. ${repeat.failedTool ? `${repeat.failedTool} failed` : "I gave up"}: ${repeat.error}`,
|
|
222
|
+
meta: { reportId: fresh.id, shortId: fresh.shortId, occurrences, status: fresh.status, origin: { friendId: repeat.origin.friendId, friendName: repeat.origin.friendName, trustLevel: repeat.origin.trustLevel, ownerOrigin: repeat.origin.ownerOrigin } },
|
|
223
|
+
}, now);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
writeReport(agentRoot, { ...fresh, occurrences, lastSeenAt: new Date(now).toISOString(), ...(notify ? { lastRepeatNoticeAt: new Date(now).toISOString() } : {}) });
|
|
227
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.failure_report_folded", message: "folded a repeat failure into an open report", meta: { reportId: fresh.id, occurrences, notified: notify } });
|
|
228
|
+
return { ok: true, id: fresh.id, shortId: fresh.shortId, duplicate: true, recipients: fresh.recipients.length };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** Records the fix a recipient says is live from `version`; the owner is told only once the running version reaches it. */
|
|
232
|
+
async function resolveFailureReport(agentRoot, input, now = Date.now()) {
|
|
233
|
+
if (!readFailureReport(agentRoot, input.id))
|
|
234
|
+
return { ok: false, reason: "unknown_report" };
|
|
235
|
+
return (0, file_lock_1.withFileLock)(lockDir(agentRoot), `report-${input.id}`, () => resolveLocked(agentRoot, input, now));
|
|
236
|
+
}
|
|
237
|
+
function resolveLocked(agentRoot, input, now) {
|
|
238
|
+
const record = readFailureReport(agentRoot, input.id);
|
|
239
|
+
/* v8 ignore next -- only a report pruned between the check above and taking its lock can be missing here @preserve */
|
|
240
|
+
if (!record)
|
|
241
|
+
return { ok: false, reason: "unknown_report" };
|
|
242
|
+
if (!record.recipients.includes(input.byFriendId))
|
|
243
|
+
return { ok: false, reason: "not_recipient" };
|
|
244
|
+
if (record.status === "closed")
|
|
245
|
+
return { ok: false, reason: "already_closed" };
|
|
246
|
+
const version = semver.valid(input.version);
|
|
247
|
+
if (!version || version !== input.version)
|
|
248
|
+
return { ok: false, reason: "bad_version" };
|
|
249
|
+
const note = cap(input.note);
|
|
250
|
+
if (!note)
|
|
251
|
+
return { ok: false, reason: "bad_note" };
|
|
252
|
+
writeReport(agentRoot, { ...record, status: "resolved", resolution: { version, note, by: input.byFriendId, at: new Date(now).toISOString(), resolvedAt: input.resolvedAt, signedNote: input.note, proof: input.proof } });
|
|
253
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.failure_report_resolved", message: "an escalation peer resolved a failure report", meta: { reportId: record.id, version } });
|
|
254
|
+
return { ok: true, id: record.id, status: "resolved" };
|
|
255
|
+
}
|
|
256
|
+
function fixLiveNoticeText(record) {
|
|
257
|
+
const resolution = record.resolution;
|
|
258
|
+
if (!record.origin.ownerOrigin) {
|
|
259
|
+
return `The fix for something I couldn't do earlier for ${record.origin.friendName ?? "a connected friend"} (report ${record.shortId}) is live now, in version ${resolution.version}. ${resolution.note}`;
|
|
260
|
+
}
|
|
261
|
+
return `The fix for what I couldn't do for you earlier (report ${record.shortId}) is live now, in version ${resolution.version}. ${resolution.note}\n\nYou asked: ${record.ariWords}`;
|
|
262
|
+
}
|
|
263
|
+
// Reports are rate limited and pruned, so this set stays small.
|
|
264
|
+
const warnedSkips = new Set();
|
|
265
|
+
/** The owner hears "fixed" only if the escalation holder signed it and still holds escalation; a file the Butler's own uid can write proves nothing. */
|
|
266
|
+
async function resolutionIsSigned(agentRoot, deps, record) {
|
|
267
|
+
const resolution = record.resolution;
|
|
268
|
+
const skip = (reason) => {
|
|
269
|
+
// The confirm tick runs every few minutes, so each report warns once per process, not once per tick.
|
|
270
|
+
const key = `${record.id}:${reason}`;
|
|
271
|
+
if (warnedSkips.has(key))
|
|
272
|
+
return false;
|
|
273
|
+
warnedSkips.add(key);
|
|
274
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.failure_report_resolution_unverified", message: "skipped a resolved report that the holder's signature does not vouch for", meta: { reportId: record.id, by: resolution.by, reason } });
|
|
275
|
+
return false;
|
|
276
|
+
};
|
|
277
|
+
// A report that was ever closed already told the owner; a resolved status on it is a forgery or a replay.
|
|
278
|
+
if (record.closedAt !== undefined)
|
|
279
|
+
return skip("report was closed before");
|
|
280
|
+
// The owner is told exactly what was signed: the stored note must be the signed note, and the version its canonical form.
|
|
281
|
+
if (typeof resolution.signedNote !== "string" || resolution.note !== cap(resolution.signedNote))
|
|
282
|
+
return skip("note is not the signed note");
|
|
283
|
+
if (semver.valid(resolution.version) !== resolution.version)
|
|
284
|
+
return skip("version is not canonical");
|
|
285
|
+
const friend = deps.friends ? await deps.friends.get(resolution.by) : null;
|
|
286
|
+
if (!friend || !(0, escalation_grants_1.holdsEscalation)(agentRoot, friend))
|
|
287
|
+
return skip("holder no longer holds escalation");
|
|
288
|
+
// The key comes from the root-owned grant. The friend record is writable by the Butler's own uid and is never consulted for it.
|
|
289
|
+
const checked = (0, resolution_proof_1.verifyResolution)({
|
|
290
|
+
sodium: await (0, a2a_client_1.ready)(),
|
|
291
|
+
claim: { reportId: record.id, version: resolution.version, note: resolution.signedNote, resolvedAt: resolution.resolvedAt ?? "" },
|
|
292
|
+
proof: resolution.proof,
|
|
293
|
+
holderDid: (0, escalation_grants_1.pinnedHolderDid)(agentRoot, resolution.by),
|
|
294
|
+
});
|
|
295
|
+
return checked.ok;
|
|
296
|
+
}
|
|
297
|
+
/** Tells the owner about each resolved report whose fix has reached the running version, then closes it. */
|
|
298
|
+
async function confirmResolvedReports(agentRoot, deps, now = Date.now()) {
|
|
299
|
+
const result = { closed: [], waiting: [], failed: [] };
|
|
300
|
+
for (const listed of listFailureReports(agentRoot)) {
|
|
301
|
+
if (listed.status !== "resolved" || !listed.resolution)
|
|
302
|
+
continue;
|
|
303
|
+
if (!semver.valid(deps.runningVersion) || !semver.gte(deps.runningVersion, listed.resolution.version)) {
|
|
304
|
+
result.waiting.push(listed.id);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
// The report is read again under its lock: a resolution or repeat may have landed since the listing.
|
|
309
|
+
const outcome = await (0, file_lock_1.withFileLock)(lockDir(agentRoot), `report-${listed.id}`, async () => {
|
|
310
|
+
const record = readFailureReport(agentRoot, listed.id);
|
|
311
|
+
if (!record || record.status !== "resolved" || !record.resolution)
|
|
312
|
+
return "skipped";
|
|
313
|
+
if (!semver.gte(deps.runningVersion, record.resolution.version))
|
|
314
|
+
return "waiting";
|
|
315
|
+
if (!(await resolutionIsSigned(agentRoot, deps, record)))
|
|
316
|
+
return "skipped";
|
|
317
|
+
if (!record.replay)
|
|
318
|
+
await deps.notifyOwner({ noticeId: `failure-fixed:${record.id}`, text: fixLiveNoticeText(record) });
|
|
319
|
+
writeReport(agentRoot, { ...record, status: "closed", closedAt: new Date(now).toISOString() });
|
|
320
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.failure_report_closed", message: "closed a failure report after its fix went live", meta: { reportId: record.id, version: record.resolution.version } });
|
|
321
|
+
return "closed";
|
|
322
|
+
});
|
|
323
|
+
if (outcome === "closed")
|
|
324
|
+
result.closed.push(listed.id);
|
|
325
|
+
else if (outcome === "waiting")
|
|
326
|
+
result.waiting.push(listed.id);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.failure_report_notice_failed", message: "could not tell the owner a fix is live; will retry", meta: { reportId: listed.id, error: error instanceof Error ? error.message : String(error) } });
|
|
330
|
+
result.failed.push(listed.id);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
pruneClosedReports(agentRoot, now);
|
|
334
|
+
return result;
|
|
335
|
+
}
|