@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
|
@@ -0,0 +1,60 @@
|
|
|
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.RUNNING_HARNESS_VERSION = void 0;
|
|
37
|
+
exports.escalationOptionsFor = escalationOptionsFor;
|
|
38
|
+
exports.sendOwnerNoticeViaTelegram = sendOwnerNoticeViaTelegram;
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
const path = __importStar(require("node:path"));
|
|
41
|
+
const runtime_1 = require("../nerves/runtime");
|
|
42
|
+
/**
|
|
43
|
+
* The version of the harness that is running, read from its own package.json once when this module loads. A fix is
|
|
44
|
+
* "live" only when this process carries it, so the value must not follow whatever an upgrade later writes to disk.
|
|
45
|
+
*/
|
|
46
|
+
exports.RUNNING_HARNESS_VERSION = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../package.json"), "utf8")).version;
|
|
47
|
+
/**
|
|
48
|
+
* What every A2A server entry point passes as `escalation`: confirm-when-live notices go to the owner through the same
|
|
49
|
+
* owner-notice path as delegated commands. Only the Butler files and confirms failure reports.
|
|
50
|
+
*/
|
|
51
|
+
function escalationOptionsFor(agentName, sendOwnerNotice) {
|
|
52
|
+
if (agentName !== "sanctuary")
|
|
53
|
+
return {};
|
|
54
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.a2a_escalation_wired", message: "A2A server will confirm resolved failure reports to the owner", meta: { agentName, runningVersion: exports.RUNNING_HARNESS_VERSION } });
|
|
55
|
+
return { escalation: { runningVersion: exports.RUNNING_HARNESS_VERSION, notifyOwner: sendOwnerNotice } };
|
|
56
|
+
}
|
|
57
|
+
async function sendOwnerNoticeViaTelegram(agentName, notice) {
|
|
58
|
+
const { sendTelegramOwnerNotice } = await Promise.resolve().then(() => __importStar(require("../senses/telegram")));
|
|
59
|
+
await sendTelegramOwnerNotice(agentName, { ...notice, signal: AbortSignal.timeout(30_000) });
|
|
60
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.callOutboxMethod = callOutboxMethod;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
|
|
6
|
+
const client_1 = require("./client");
|
|
7
|
+
const outbox_wire_1 = require("./outbox-wire");
|
|
8
|
+
const sealed_chat_1 = require("./sealed-chat");
|
|
9
|
+
const runtime_1 = require("../nerves/runtime");
|
|
10
|
+
/**
|
|
11
|
+
* Calls one outbox method on an A2A agent as a verified friend. The request is sealed to the agent's card DID and
|
|
12
|
+
* signed by `identity`, and the reply is opened only if it was sealed by that same card key, so neither direction
|
|
13
|
+
* travels in plaintext. Throws with the agent's refusal text when the agent answers with a JSON-RPC error.
|
|
14
|
+
*/
|
|
15
|
+
async function callOutboxMethod(input) {
|
|
16
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
17
|
+
const sodium = input.sodium ?? await (0, a2a_client_1.ready)();
|
|
18
|
+
const card = await (0, client_1.fetchA2AAgentCard)(input.cardUrl, fetchImpl);
|
|
19
|
+
const peerDid = (0, client_1.cardDid)(card);
|
|
20
|
+
if (!peerDid)
|
|
21
|
+
throw new Error(`A2A card ${input.cardUrl} serves no DID; outbox calls need one`);
|
|
22
|
+
const parsed = (0, a2a_client_1.parseDidKey)(peerDid);
|
|
23
|
+
if (!parsed || !(0, a2a_client_1.verifyCardDidBinding)({ card: { did: peerDid, url: input.cardUrl }, did: peerDid, didDoc: null })) {
|
|
24
|
+
throw new Error(`A2A card↔DID binding failed for ${peerDid}`);
|
|
25
|
+
}
|
|
26
|
+
const endpointUrl = (0, client_1.endpointForCard)(card);
|
|
27
|
+
const conversationId = (0, node_crypto_1.randomUUID)();
|
|
28
|
+
const sealed = (0, sealed_chat_1.sealChatMessage)({
|
|
29
|
+
sodium, from: input.identity, recipientDid: peerDid, recipientEd25519Pub: parsed.ed25519Pub,
|
|
30
|
+
text: (0, outbox_wire_1.encodeOutboxCommand)(input.method, input.params), conversationId,
|
|
31
|
+
});
|
|
32
|
+
const request = {
|
|
33
|
+
jsonrpc: "2.0", id: (0, node_crypto_1.randomUUID)(), method: input.method,
|
|
34
|
+
params: { message: { kind: "message", role: "ROLE_USER", messageId: (0, node_crypto_1.randomUUID)(), contextId: conversationId, parts: sealed.parts } },
|
|
35
|
+
};
|
|
36
|
+
(0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_outbox_call_start", message: "calling an A2A outbox method", meta: { endpointUrl, peerDid, method: input.method } });
|
|
37
|
+
const rpc = await (0, client_1.postJsonRpc)(endpointUrl, request, fetchImpl);
|
|
38
|
+
if ("error" in rpc)
|
|
39
|
+
throw new Error(`A2A error ${rpc.error.code}: ${rpc.error.message}`);
|
|
40
|
+
const reply = rpc.result;
|
|
41
|
+
if (!reply || !Array.isArray(reply.parts))
|
|
42
|
+
throw new Error("A2A outbox call returned no reply message");
|
|
43
|
+
const opened = await (0, sealed_chat_1.openChatMessage)({ sodium, self: input.identity, message: reply, senderDid: peerDid, senderEd25519Pub: parsed.ed25519Pub });
|
|
44
|
+
if (!opened.ok)
|
|
45
|
+
throw new Error(`A2A outbox reply rejected: ${opened.reason}`);
|
|
46
|
+
let result;
|
|
47
|
+
try {
|
|
48
|
+
result = JSON.parse(opened.text);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error("A2A outbox reply was not JSON");
|
|
52
|
+
}
|
|
53
|
+
if (!result || typeof result !== "object" || Array.isArray(result))
|
|
54
|
+
throw new Error("A2A outbox reply was not a JSON object");
|
|
55
|
+
(0, runtime_1.emitNervesEvent)({ component: "channels", event: "channel.a2a_outbox_call_end", message: "received an A2A outbox reply", meta: { endpointUrl, peerDid, method: input.method } });
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
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.FileOutboxStore = exports.OUTBOX_LIST_MAX_CHARS = exports.OUTBOX_LIST_MAX_LIMIT = exports.OUTBOX_LIST_DEFAULT_LIMIT = exports.OUTBOX_MAX_BODY_CHARS = exports.OUTBOX_MAX_ENTRIES = void 0;
|
|
37
|
+
exports.isOutboxEntryId = isOutboxEntryId;
|
|
38
|
+
exports.outboxRoot = outboxRoot;
|
|
39
|
+
const node_crypto_1 = require("node:crypto");
|
|
40
|
+
const fs = __importStar(require("node:fs"));
|
|
41
|
+
const path = __importStar(require("node:path"));
|
|
42
|
+
const file_lock_1 = require("../heart/file-lock");
|
|
43
|
+
const runtime_1 = require("../nerves/runtime");
|
|
44
|
+
exports.OUTBOX_MAX_ENTRIES = 500;
|
|
45
|
+
exports.OUTBOX_MAX_BODY_CHARS = 6_000;
|
|
46
|
+
exports.OUTBOX_LIST_DEFAULT_LIMIT = 20;
|
|
47
|
+
exports.OUTBOX_LIST_MAX_LIMIT = 50;
|
|
48
|
+
/** One listing reply is a single sealed message of at most 16,000 characters, so a listing stops filling well before that. */
|
|
49
|
+
exports.OUTBOX_LIST_MAX_CHARS = 12_000;
|
|
50
|
+
const FRIEND_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
51
|
+
const ENTRY_ID = /^\d{13}-[0-9a-f]{6}$/u;
|
|
52
|
+
function isOutboxEntryId(value) {
|
|
53
|
+
return typeof value === "string" && ENTRY_ID.test(value);
|
|
54
|
+
}
|
|
55
|
+
function entryDir(agentRoot, friendId) {
|
|
56
|
+
if (!FRIEND_ID.test(friendId))
|
|
57
|
+
throw new Error("outbox friend id is invalid");
|
|
58
|
+
return path.join(agentRoot, "state", "outbox", friendId);
|
|
59
|
+
}
|
|
60
|
+
function outboxRoot(agentRoot) {
|
|
61
|
+
return path.join(agentRoot, "state", "outbox");
|
|
62
|
+
}
|
|
63
|
+
function readEntry(file) {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
66
|
+
if (!isOutboxEntryId(parsed.id) || typeof parsed.kind !== "string" || typeof parsed.createdAt !== "string" || typeof parsed.body !== "string")
|
|
67
|
+
return null;
|
|
68
|
+
return { id: parsed.id, kind: parsed.kind, createdAt: parsed.createdAt, body: parsed.body, ...(parsed.meta ? { meta: parsed.meta } : {}) };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function sortedIds(dir) {
|
|
75
|
+
let names;
|
|
76
|
+
try {
|
|
77
|
+
names = fs.readdirSync(dir);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
return names.filter((name) => name.endsWith(".json") && isOutboxEntryId(name.slice(0, -5))).map((name) => name.slice(0, -5)).sort();
|
|
83
|
+
}
|
|
84
|
+
class FileOutboxStore {
|
|
85
|
+
agentRoot;
|
|
86
|
+
constructor(agentRoot) {
|
|
87
|
+
this.agentRoot = agentRoot;
|
|
88
|
+
}
|
|
89
|
+
/** Adds one entry for `friendId`. The oldest entries beyond the cap are dropped so a peer that never reads cannot fill the disk. */
|
|
90
|
+
append(friendId, input, now = Date.now()) {
|
|
91
|
+
const dir = entryDir(this.agentRoot, friendId);
|
|
92
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
93
|
+
const id = `${String(now).padStart(13, "0")}-${(0, node_crypto_1.randomBytes)(3).toString("hex")}`;
|
|
94
|
+
const entry = {
|
|
95
|
+
id,
|
|
96
|
+
kind: input.kind,
|
|
97
|
+
createdAt: new Date(now).toISOString(),
|
|
98
|
+
body: input.body.slice(0, exports.OUTBOX_MAX_BODY_CHARS),
|
|
99
|
+
...(input.meta ? { meta: input.meta } : {}),
|
|
100
|
+
};
|
|
101
|
+
const file = path.join(dir, `${id}.json`);
|
|
102
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
103
|
+
fs.writeFileSync(tmp, `${JSON.stringify(entry, null, 2)}\n`, { mode: 0o600 });
|
|
104
|
+
fs.renameSync(tmp, file);
|
|
105
|
+
const ids = sortedIds(dir);
|
|
106
|
+
const evicted = ids.slice(0, Math.max(0, ids.length - exports.OUTBOX_MAX_ENTRIES));
|
|
107
|
+
for (const stale of evicted)
|
|
108
|
+
fs.rmSync(path.join(dir, `${stale}.json`), { force: true });
|
|
109
|
+
if (evicted.length > 0) {
|
|
110
|
+
(0, runtime_1.emitNervesEvent)({
|
|
111
|
+
level: "warn",
|
|
112
|
+
component: "senses",
|
|
113
|
+
event: "senses.a2a_outbox_evicted",
|
|
114
|
+
message: "a peer outbox is full, so its oldest unread entries were dropped",
|
|
115
|
+
meta: { friendId, evicted: evicted.length, oldestDropped: evicted[0], cap: exports.OUTBOX_MAX_ENTRIES },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
(0, runtime_1.emitNervesEvent)({
|
|
119
|
+
component: "senses",
|
|
120
|
+
event: "senses.a2a_outbox_appended",
|
|
121
|
+
message: "queued a message in a peer outbox",
|
|
122
|
+
meta: { friendId, entryId: id, kind: input.kind },
|
|
123
|
+
});
|
|
124
|
+
return entry;
|
|
125
|
+
}
|
|
126
|
+
/** Like `append`, but a second call with the same `dedupeKey` while the first entry is still unacked returns that entry, so a retried delivery cannot double-post. */
|
|
127
|
+
async appendOnce(friendId, dedupeKey, input, now = Date.now()) {
|
|
128
|
+
const dir = entryDir(this.agentRoot, friendId);
|
|
129
|
+
// The check and the append happen under one cross-process lock, so two senses retrying the same delivery cannot both miss it.
|
|
130
|
+
return (0, file_lock_1.withFileLock)(path.join(this.agentRoot, "state", "outbox", ".locks"), `once-${friendId}`, () => {
|
|
131
|
+
for (const id of sortedIds(dir)) {
|
|
132
|
+
const entry = readEntry(path.join(dir, `${id}.json`));
|
|
133
|
+
if (entry?.meta?.dedupeKey === dedupeKey)
|
|
134
|
+
return entry;
|
|
135
|
+
}
|
|
136
|
+
return this.append(friendId, { ...input, meta: { ...input.meta, dedupeKey } }, now);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/** Entries after `since` (exclusive), oldest first, within the count and size limits. */
|
|
140
|
+
list(friendId, options = {}) {
|
|
141
|
+
const dir = entryDir(this.agentRoot, friendId);
|
|
142
|
+
const limit = Math.min(Math.max(1, Math.floor(options.limit ?? exports.OUTBOX_LIST_DEFAULT_LIMIT)), exports.OUTBOX_LIST_MAX_LIMIT);
|
|
143
|
+
const since = options.since ?? "";
|
|
144
|
+
const ids = sortedIds(dir).filter((id) => id > since);
|
|
145
|
+
const entries = [];
|
|
146
|
+
let chars = 0;
|
|
147
|
+
let more = false;
|
|
148
|
+
for (const id of ids) {
|
|
149
|
+
const entry = readEntry(path.join(dir, `${id}.json`));
|
|
150
|
+
if (!entry)
|
|
151
|
+
continue;
|
|
152
|
+
const size = JSON.stringify(entry).length;
|
|
153
|
+
if (entries.length >= limit || (entries.length > 0 && chars + size > exports.OUTBOX_LIST_MAX_CHARS)) {
|
|
154
|
+
more = true;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
entries.push(entry);
|
|
158
|
+
chars += size;
|
|
159
|
+
}
|
|
160
|
+
return { entries, nextCursor: entries.length > 0 ? entries[entries.length - 1].id : null, more };
|
|
161
|
+
}
|
|
162
|
+
/** Removes the named entries from this friend's outbox only; ids that are malformed or not there come back as unknown. */
|
|
163
|
+
ack(friendId, ids) {
|
|
164
|
+
const dir = entryDir(this.agentRoot, friendId);
|
|
165
|
+
const acked = [];
|
|
166
|
+
const unknown = [];
|
|
167
|
+
for (const id of ids) {
|
|
168
|
+
const file = isOutboxEntryId(id) ? path.join(dir, `${id}.json`) : null;
|
|
169
|
+
if (file && fs.existsSync(file)) {
|
|
170
|
+
fs.rmSync(file, { force: true });
|
|
171
|
+
acked.push(id);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
unknown.push(id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (acked.length > 0) {
|
|
178
|
+
(0, runtime_1.emitNervesEvent)({
|
|
179
|
+
component: "senses",
|
|
180
|
+
event: "senses.a2a_outbox_acked",
|
|
181
|
+
message: "a peer cleared entries from its outbox",
|
|
182
|
+
meta: { friendId, count: acked.length },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return { acked, unknown };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
exports.FileOutboxStore = FileOutboxStore;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OUTBOX_ERROR_INVALID = exports.OUTBOX_ERROR_REFUSED = exports.OUTBOX_METHODS = void 0;
|
|
4
|
+
exports.isOutboxMethod = isOutboxMethod;
|
|
5
|
+
exports.encodeOutboxCommand = encodeOutboxCommand;
|
|
6
|
+
exports.parseOutboxCommand = parseOutboxCommand;
|
|
7
|
+
exports.handleOutboxCommand = handleOutboxCommand;
|
|
8
|
+
const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
|
|
9
|
+
const escalation_grants_1 = require("./escalation-grants");
|
|
10
|
+
const resolution_proof_1 = require("./resolution-proof");
|
|
11
|
+
const outbox_store_1 = require("./outbox-store");
|
|
12
|
+
const failure_reports_1 = require("../heart/failure-reports");
|
|
13
|
+
const runtime_1 = require("../nerves/runtime");
|
|
14
|
+
/**
|
|
15
|
+
* The outbox methods ride the same sealed, signed chat envelope as `a2a message`: the request is a `message` whose
|
|
16
|
+
* signed text is `{"ouro":"a2a-method","method":...,"params":...}`, posted under the JSON-RPC method of the same name.
|
|
17
|
+
* The server verifies the sender exactly as it does for a chat (unseal, pinned key, signature, replay guard, friend
|
|
18
|
+
* record), requires the signed method to equal the JSON-RPC method, and then decides access itself from the verified
|
|
19
|
+
* friend record. A sender can only ever name its own outbox: no parameter selects another peer's.
|
|
20
|
+
*
|
|
21
|
+
* outbox/list { since?: string, limit?: number } -> { entries, nextCursor, more } any active friend, own outbox
|
|
22
|
+
* outbox/ack { ids: string[] } -> { acked, unknown } any active friend, own outbox
|
|
23
|
+
* report/resolve { id, version, note, resolvedAt, proof } -> { id, status: "resolved" } escalation holders, own reports
|
|
24
|
+
*/
|
|
25
|
+
exports.OUTBOX_METHODS = ["outbox/list", "outbox/ack", "report/resolve"];
|
|
26
|
+
exports.OUTBOX_ERROR_REFUSED = -32003;
|
|
27
|
+
exports.OUTBOX_ERROR_INVALID = -32602;
|
|
28
|
+
const MAX_ACK_IDS = 100;
|
|
29
|
+
function isOutboxMethod(method) {
|
|
30
|
+
return typeof method === "string" && exports.OUTBOX_METHODS.includes(method);
|
|
31
|
+
}
|
|
32
|
+
function encodeOutboxCommand(method, params) {
|
|
33
|
+
return JSON.stringify({ ouro: "a2a-method", method, params });
|
|
34
|
+
}
|
|
35
|
+
function parseOutboxCommand(text) {
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(text);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
44
|
+
return null;
|
|
45
|
+
const command = parsed;
|
|
46
|
+
if (command.ouro !== "a2a-method" || !isOutboxMethod(command.method))
|
|
47
|
+
return null;
|
|
48
|
+
const params = command.params === undefined ? {} : command.params;
|
|
49
|
+
if (!params || typeof params !== "object" || Array.isArray(params))
|
|
50
|
+
return null;
|
|
51
|
+
return { method: command.method, params: params };
|
|
52
|
+
}
|
|
53
|
+
const refuse = (message) => ({ ok: false, code: exports.OUTBOX_ERROR_REFUSED, message });
|
|
54
|
+
const invalid = (message) => ({ ok: false, code: exports.OUTBOX_ERROR_INVALID, message });
|
|
55
|
+
/** Runs one verified outbox command for `friend`. `friend` must be the record found by the signed sender DID. */
|
|
56
|
+
async function handleOutboxCommand(input) {
|
|
57
|
+
const { agentRoot, friend, method, params } = input;
|
|
58
|
+
if (!friend || friend.admissionState !== "active") {
|
|
59
|
+
(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
|
+
return refuse("only an active friend can use its outbox");
|
|
61
|
+
}
|
|
62
|
+
const outbox = new outbox_store_1.FileOutboxStore(agentRoot);
|
|
63
|
+
if (method === "outbox/list") {
|
|
64
|
+
const { since, limit } = params;
|
|
65
|
+
if (since !== undefined && typeof since !== "string")
|
|
66
|
+
return invalid("since must be a string cursor");
|
|
67
|
+
if (limit !== undefined && (typeof limit !== "number" || !Number.isFinite(limit)))
|
|
68
|
+
return invalid("limit must be a number");
|
|
69
|
+
return { ok: true, result: { ...outbox.list(friend.id, { ...(since !== undefined ? { since } : {}), ...(limit !== undefined ? { limit: Math.min(limit, outbox_store_1.OUTBOX_LIST_MAX_LIMIT) } : {}) }) } };
|
|
70
|
+
}
|
|
71
|
+
if (method === "outbox/ack") {
|
|
72
|
+
const { ids } = params;
|
|
73
|
+
if (!Array.isArray(ids) || ids.length === 0 || ids.length > MAX_ACK_IDS || ids.some((id) => typeof id !== "string"))
|
|
74
|
+
return invalid(`ids must be 1 to ${MAX_ACK_IDS} strings`);
|
|
75
|
+
return { ok: true, result: { ...outbox.ack(friend.id, ids) } };
|
|
76
|
+
}
|
|
77
|
+
if (!(0, escalation_grants_1.holdsEscalation)(agentRoot, friend)) {
|
|
78
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.a2a_report_resolve_refused", message: "refused report/resolve from a peer without the escalation grant", meta: { friendId: friend.id } });
|
|
79
|
+
return refuse("report/resolve needs the escalation grant");
|
|
80
|
+
}
|
|
81
|
+
const { id, version, note, resolvedAt, proof } = params;
|
|
82
|
+
if (typeof id !== "string" || typeof version !== "string" || typeof note !== "string" || typeof resolvedAt !== "string")
|
|
83
|
+
return invalid("id, version, note and resolvedAt must be strings");
|
|
84
|
+
// The key is the DID the operator pinned in the root-owned grant, never the friend record; and the A2A-verified caller must be that same DID.
|
|
85
|
+
const holderDid = (0, escalation_grants_1.pinnedHolderDid)(agentRoot, friend.id);
|
|
86
|
+
if (!holderDid || input.verifiedDid !== holderDid) {
|
|
87
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.a2a_report_resolve_refused", message: "refused report/resolve from a caller whose verified DID is not the pinned holder DID", meta: { friendId: friend.id } });
|
|
88
|
+
return refuse("report not resolved: the caller is not the pinned escalation holder");
|
|
89
|
+
}
|
|
90
|
+
const checked = (0, resolution_proof_1.verifyResolution)({ sodium: await (0, a2a_client_1.ready)(), claim: { reportId: id, version, note, resolvedAt }, proof, holderDid });
|
|
91
|
+
if (!checked.ok)
|
|
92
|
+
return refuse(`report not resolved: the resolution is not signed by your key (${checked.reason})`);
|
|
93
|
+
const resolved = await (0, failure_reports_1.resolveFailureReport)(agentRoot, { id, version, note, byFriendId: friend.id, resolvedAt, proof }, input.now);
|
|
94
|
+
return resolved.ok ? { ok: true, result: { id: resolved.id, status: resolved.status } } : refuse(`report not resolved: ${resolved.reason}`);
|
|
95
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.signResolution = signResolution;
|
|
4
|
+
exports.friendDid = friendDid;
|
|
5
|
+
exports.verifyResolution = verifyResolution;
|
|
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 proof that an escalation holder, and nobody who can write the Butler's files, said a failure report is fixed.
|
|
11
|
+
* The holder signs `{reportId, version, note, resolvedAt}` with its DID key (canonical JSON, detached Ed25519), and
|
|
12
|
+
* the Butler keeps the signature next to the resolution. The holder's private key never leaves the holder's machine,
|
|
13
|
+
* so a forged report file cannot carry a valid one.
|
|
14
|
+
*/
|
|
15
|
+
const PURPOSE = "ouro.report-resolve.v1";
|
|
16
|
+
function envelopeOf(claim) {
|
|
17
|
+
return { purpose: PURPOSE, reportId: claim.reportId, version: claim.version, note: claim.note, resolvedAt: claim.resolvedAt };
|
|
18
|
+
}
|
|
19
|
+
function signResolution(input) {
|
|
20
|
+
const { claim } = input;
|
|
21
|
+
return (0, a2a_client_1.signEnvelope)({
|
|
22
|
+
sodium: input.sodium,
|
|
23
|
+
envelope: envelopeOf(claim),
|
|
24
|
+
signerEd25519Priv: input.identity.ed25519Priv,
|
|
25
|
+
signerDid: input.identity.did,
|
|
26
|
+
signerKeyId: input.identity.keyId,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** The DID a friend record names. Used only by the operator command that pins it into the grant; never to verify a resolution. */
|
|
30
|
+
function friendDid(friend) {
|
|
31
|
+
return (0, friends_1.resolveAgentIdentity)(friend.agentMeta).did ?? null;
|
|
32
|
+
}
|
|
33
|
+
function verifyResolution(input) {
|
|
34
|
+
const fail = (reason) => {
|
|
35
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.a2a_resolution_proof_rejected", message: "rejected a report resolution without a valid holder signature", meta: { reportId: input.claim.reportId, reason } });
|
|
36
|
+
return { ok: false, reason };
|
|
37
|
+
};
|
|
38
|
+
const proof = input.proof;
|
|
39
|
+
if (!proof || typeof proof !== "object" || Array.isArray(proof))
|
|
40
|
+
return fail("no_proof");
|
|
41
|
+
if (!input.holderDid)
|
|
42
|
+
return fail("no_holder_did");
|
|
43
|
+
const parsed = (0, a2a_client_1.parseDidKey)(input.holderDid);
|
|
44
|
+
if (!parsed || proof.signerDid !== input.holderDid)
|
|
45
|
+
return fail("wrong_signer");
|
|
46
|
+
const valid = (0, a2a_client_1.verifyEnvelopeSignature)({
|
|
47
|
+
sodium: input.sodium,
|
|
48
|
+
envelope: envelopeOf(input.claim),
|
|
49
|
+
proof: proof,
|
|
50
|
+
signerEd25519Pub: parsed.ed25519Pub,
|
|
51
|
+
});
|
|
52
|
+
return valid ? { ok: true } : fail("bad_signature");
|
|
53
|
+
}
|
package/dist/a2a/server.js
CHANGED
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ESCALATION_CONFIRM_INTERVAL_MS = void 0;
|
|
36
37
|
exports.a2aChatRelationship = a2aChatRelationship;
|
|
37
38
|
exports.startA2AServer = startA2AServer;
|
|
38
39
|
const http = __importStar(require("node:http"));
|
|
@@ -55,8 +56,11 @@ const mission_result_wire_1 = require("./mission-result-wire");
|
|
|
55
56
|
const delegation_stores_1 = require("./delegation-stores");
|
|
56
57
|
const task_store_1 = require("./task-store");
|
|
57
58
|
const delegated_command_1 = require("./delegated-command");
|
|
59
|
+
const outbox_wire_1 = require("./outbox-wire");
|
|
60
|
+
const failure_reports_1 = require("../heart/failure-reports");
|
|
58
61
|
const MAX_A2A_REQUEST_BYTES = 128 * 1024;
|
|
59
62
|
const MAX_A2A_MESSAGE_TEXT_CHARS = 16_000;
|
|
63
|
+
exports.ESCALATION_CONFIRM_INTERVAL_MS = 5 * 60 * 1000;
|
|
60
64
|
class A2ARequestError extends Error {
|
|
61
65
|
status;
|
|
62
66
|
code;
|
|
@@ -394,6 +398,19 @@ async function startA2AServer(options) {
|
|
|
394
398
|
identity: options.identity,
|
|
395
399
|
};
|
|
396
400
|
}
|
|
401
|
+
// Resolved failure reports are confirmed to the owner once the running version reaches the fix: now, after each
|
|
402
|
+
// resolution, and on a timer, so a fix that lands with a restart is announced without anyone calling again.
|
|
403
|
+
const confirmReports = async () => {
|
|
404
|
+
if (!options.escalation)
|
|
405
|
+
return;
|
|
406
|
+
try {
|
|
407
|
+
await (0, failure_reports_1.confirmResolvedReports)(agentRoot, { ...options.escalation, ...(inboundShareDeps ? { friends: inboundShareDeps.store } : {}) });
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "channels", event: "channel.a2a_escalation_confirm_error", message: "confirming resolved failure reports failed", meta: { agentName: options.agentName, error: error instanceof Error ? error.message : String(error) } });
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
let confirmTimer;
|
|
397
414
|
let publicBaseUrl = options.baseUrl;
|
|
398
415
|
const server = http.createServer(async (req, res) => {
|
|
399
416
|
/* v8 ignore next -- Node always supplies url/host for accepted HTTP requests; fallback keeps malformed local calls safe @preserve */
|
|
@@ -427,6 +444,48 @@ async function startA2AServer(options) {
|
|
|
427
444
|
return;
|
|
428
445
|
}
|
|
429
446
|
try {
|
|
447
|
+
if ((0, outbox_wire_1.isOutboxMethod)(rpc.method)) {
|
|
448
|
+
// Peer outbox methods: signed and sealed like a chat, access decided here from the verified friend record.
|
|
449
|
+
const outboxInbound = messageFromParams(rpc.params);
|
|
450
|
+
if (!outboxInbound || !inboundShareDeps) {
|
|
451
|
+
writeJson(res, 200, errorResponse(rpc.id, outbox_wire_1.OUTBOX_ERROR_REFUSED, "outbox calls need a signed message and an A2A identity"));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const bridged = await (0, inbound_share_1.receiveInboundShare)({ ...outboxInbound, kind: outboxInbound.kind ?? "message" }, inboundShareDeps);
|
|
455
|
+
if (bridged.outcome === "rejected") {
|
|
456
|
+
writeJson(res, 200, errorResponse(rpc.id, rejectionErrorCode(bridged.reason), `A2A outbox call rejected: ${bridged.reason}`));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (bridged.outcome !== "completed" || !bridged.message) {
|
|
460
|
+
writeJson(res, 200, errorResponse(rpc.id, outbox_wire_1.OUTBOX_ERROR_REFUSED, "outbox calls must be a signed chat message"));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const command = (0, outbox_wire_1.parseOutboxCommand)(bridged.message.text);
|
|
464
|
+
if (!command || command.method !== rpc.method) {
|
|
465
|
+
writeJson(res, 200, errorResponse(rpc.id, outbox_wire_1.OUTBOX_ERROR_INVALID, "the signed method does not match the requested method"));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const outcome = await (0, outbox_wire_1.handleOutboxCommand)({ agentRoot, friend: bridged.friend, method: command.method, params: command.params, verifiedDid: bridged.verifiedDid });
|
|
469
|
+
if (!outcome.ok) {
|
|
470
|
+
writeJson(res, 200, errorResponse(rpc.id, outcome.code, outcome.message));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (command.method === "report/resolve")
|
|
474
|
+
await confirmReports();
|
|
475
|
+
/* v8 ignore next -- receiveShare pins the verified sender before it reports a completed message @preserve */
|
|
476
|
+
const outboxPinned = inboundShareDeps.pinStore.get(bridged.verifiedDid);
|
|
477
|
+
const contextId = outboxInbound.contextId ?? "default";
|
|
478
|
+
const reply = (0, sealed_chat_1.sealChatMessage)({
|
|
479
|
+
sodium: inboundShareDeps.sodium,
|
|
480
|
+
from: options.identity,
|
|
481
|
+
recipientDid: bridged.verifiedDid,
|
|
482
|
+
recipientEd25519Pub: outboxPinned.ed25519Pub,
|
|
483
|
+
text: JSON.stringify(outcome.result),
|
|
484
|
+
conversationId: contextId,
|
|
485
|
+
});
|
|
486
|
+
writeJson(res, 200, jsonResponse(rpc.id, { kind: "message", role: "ROLE_AGENT", messageId: (0, node_crypto_1.randomUUID)(), contextId, parts: reply.parts }));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
430
489
|
if (rpc.method === "SendMessage" || rpc.method === "message/send") {
|
|
431
490
|
const responseStyle = rpc.method === "message/send" ? "legacy" : "latest";
|
|
432
491
|
const parsedInbound = messageFromParams(rpc.params);
|
|
@@ -671,6 +730,11 @@ async function startA2AServer(options) {
|
|
|
671
730
|
endpointUrl.pathname = a2aPath;
|
|
672
731
|
endpointUrl.search = "";
|
|
673
732
|
endpointUrl.hash = "";
|
|
733
|
+
if (options.escalation) {
|
|
734
|
+
await confirmReports();
|
|
735
|
+
confirmTimer = setInterval(() => { void confirmReports(); }, options.escalation.confirmIntervalMs ?? exports.ESCALATION_CONFIRM_INTERVAL_MS);
|
|
736
|
+
confirmTimer.unref();
|
|
737
|
+
}
|
|
674
738
|
(0, runtime_1.emitNervesEvent)({
|
|
675
739
|
component: "channels",
|
|
676
740
|
event: "channel.a2a_server_started",
|
|
@@ -682,6 +746,8 @@ async function startA2AServer(options) {
|
|
|
682
746
|
url: publicBaseUrl,
|
|
683
747
|
endpointUrl: endpointUrl.toString(),
|
|
684
748
|
close: () => new Promise((resolve, reject) => {
|
|
749
|
+
if (confirmTimer)
|
|
750
|
+
clearInterval(confirmTimer);
|
|
685
751
|
server.close((error) => {
|
|
686
752
|
if (error) {
|
|
687
753
|
reject(error);
|
|
@@ -0,0 +1,98 @@
|
|
|
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.TRUSTED_UID = void 0;
|
|
37
|
+
exports.overrideTrustedUidForTests = overrideTrustedUidForTests;
|
|
38
|
+
exports.isTrustedDirectory = isTrustedDirectory;
|
|
39
|
+
exports.readTrustedJson = readTrustedJson;
|
|
40
|
+
const fs = __importStar(require("node:fs"));
|
|
41
|
+
const runtime_1 = require("../nerves/runtime");
|
|
42
|
+
/**
|
|
43
|
+
* Operator-set state lives in the agent bundle, which the agent's own process (and anything a prompt-injected model can
|
|
44
|
+
* run through its shell) can write. Such state only counts when root owns it and nobody else can write it, the same
|
|
45
|
+
* rule the replay window follows. Anything else reads as "not there", so every consumer fails closed.
|
|
46
|
+
*/
|
|
47
|
+
exports.TRUSTED_UID = 0;
|
|
48
|
+
let uidOverride;
|
|
49
|
+
/** Only a test runner sets VITEST; the running Butler (and its prompt-injected shell) cannot reach this process's environment. */
|
|
50
|
+
const underTestRunner = () => process.env.VITEST !== undefined;
|
|
51
|
+
/** Tests run as an ordinary user, so they declare that user trusted. Refused outside a test runner, and ignored there too. */
|
|
52
|
+
function overrideTrustedUidForTests(uid) {
|
|
53
|
+
if (!underTestRunner())
|
|
54
|
+
throw new Error("overrideTrustedUidForTests is for tests only");
|
|
55
|
+
uidOverride = uid;
|
|
56
|
+
}
|
|
57
|
+
const effectiveTrustedUid = () => (underTestRunner() ? uidOverride : undefined) ?? exports.TRUSTED_UID;
|
|
58
|
+
/** A real directory (no symlink), owned by the trusted uid, writable by neither group nor other. */
|
|
59
|
+
function isTrustedDirectory(target, trustedUid = effectiveTrustedUid()) {
|
|
60
|
+
let stat;
|
|
61
|
+
try {
|
|
62
|
+
stat = fs.lstatSync(target);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const trusted = stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === trustedUid && (stat.mode & 0o022) === 0;
|
|
68
|
+
if (!trusted)
|
|
69
|
+
rejected(target, "directory is not a real directory owned by the trusted uid and closed to group and other writes");
|
|
70
|
+
return trusted;
|
|
71
|
+
}
|
|
72
|
+
function rejected(target, reason) {
|
|
73
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.a2a_trusted_file_rejected", message: "ignored operator-set state the agent could have written", meta: { target, reason } });
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Parses a JSON file only if the file actually opened is trusted. The path is opened once with O_NOFOLLOW, then the
|
|
77
|
+
* descriptor itself is checked (regular file, trusted owner, not group- or other-writable) and read, so a rename loop
|
|
78
|
+
* cannot swap a different file in between the check and the read. Any error is `undefined`.
|
|
79
|
+
*/
|
|
80
|
+
function readTrustedJson(file, trustedUid = effectiveTrustedUid()) {
|
|
81
|
+
let fd;
|
|
82
|
+
try {
|
|
83
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
84
|
+
const stat = fs.fstatSync(fd);
|
|
85
|
+
if (!stat.isFile() || stat.uid !== trustedUid || (stat.mode & 0o022) !== 0) {
|
|
86
|
+
rejected(file, "file is not a regular file owned by the trusted uid and closed to group and other writes");
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
if (fd !== undefined)
|
|
96
|
+
fs.closeSync(fd);
|
|
97
|
+
}
|
|
98
|
+
}
|