@ouro.bot/cli 0.1.0-alpha.864 → 0.1.0-alpha.866
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 +17 -0
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +179 -24
- package/deploy/unraid/sanctuary-replay-gate.mjs +533 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/a2a/delegated-command.js +9 -3
- package/dist/a2a/replay-harness.js +147 -0
- package/dist/heart/awaiting/a2a-await-delivery.js +6 -0
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +71 -22
- package/dist/heart/daemon/sanctuary-telegram-authority-entry.js +31 -1
- package/dist/heart/daemon/sanctuary-telegram-authority-service.js +25 -12
- package/dist/senses/a2a-entry.js +2 -0
- package/dist/senses/private-runtime.js +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,147 @@
|
|
|
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.REPLAY_WINDOW_MAX_MS = void 0;
|
|
37
|
+
exports.replayWindowPath = replayWindowPath;
|
|
38
|
+
exports.replaySinkPath = replaySinkPath;
|
|
39
|
+
exports.isReplayWindowOpen = isReplayWindowOpen;
|
|
40
|
+
exports.appendReplayNotice = appendReplayNotice;
|
|
41
|
+
exports.replayNoticeRecorded = replayNoticeRecorded;
|
|
42
|
+
const fs = __importStar(require("node:fs"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
44
|
+
const runtime_1 = require("../nerves/runtime");
|
|
45
|
+
/**
|
|
46
|
+
* The replay gate drives the Butler over its real A2A path to check its behaviour after an upgrade. Every delegated
|
|
47
|
+
* command and every A2A-filed await would otherwise post to the owner's Telegram chat. While the host's gate holds a
|
|
48
|
+
* replay window open for a peer, those notices are appended to a local sink instead.
|
|
49
|
+
*
|
|
50
|
+
* The window narrows where a notice goes, never whether a command is authorized: the grant, family trust, sole owner
|
|
51
|
+
* and notify-before-run checks are unchanged, and "notify" means "the sink write succeeded". The window file lives
|
|
52
|
+
* under the bundle's state directory, is written by the host's gate and cleared when its run ends, and every entry
|
|
53
|
+
* expires on its own (and is ignored if it claims to last longer than the cap).
|
|
54
|
+
*/
|
|
55
|
+
exports.REPLAY_WINDOW_MAX_MS = 2 * 60 * 60 * 1000;
|
|
56
|
+
function replayDir(agentRoot) {
|
|
57
|
+
return path.join(agentRoot, "state", "replay");
|
|
58
|
+
}
|
|
59
|
+
function replayWindowPath(agentRoot) {
|
|
60
|
+
return path.join(replayDir(agentRoot), "window.json");
|
|
61
|
+
}
|
|
62
|
+
function replaySinkPath(agentRoot) {
|
|
63
|
+
return path.join(replayDir(agentRoot), "notices.ndjson");
|
|
64
|
+
}
|
|
65
|
+
/** A root-controlled directory: a real directory (no symlink), owned by the trusted uid, writable by neither group nor other. */
|
|
66
|
+
function isTrustedDirectory(target, trustedUid) {
|
|
67
|
+
try {
|
|
68
|
+
const stat = fs.lstatSync(target);
|
|
69
|
+
return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === trustedUid && (stat.mode & 0o022) === 0;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Reads the window 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 on
|
|
78
|
+
* the Butler's own state directory cannot swap a different file in between the check and the read. Any error is "no window".
|
|
79
|
+
*/
|
|
80
|
+
function readTrustedWindow(file, trustedUid) {
|
|
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
|
+
return undefined;
|
|
87
|
+
return JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (fd !== undefined)
|
|
94
|
+
fs.closeSync(fd);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* True only when the window file lists `friendId` with an expiry that is in the future and within the cap. Any unreadable or malformed file means "closed".
|
|
99
|
+
* The state directory is the Butler's own, so it could swap in a directory and window of its own making: only a replay
|
|
100
|
+
* directory and window file that root owns, and nobody else can write, count; anything else fails toward Telegram.
|
|
101
|
+
*/
|
|
102
|
+
function isReplayWindowOpen(agentRoot, friendId, now = Date.now(), trustedUid = 0) {
|
|
103
|
+
if (!isTrustedDirectory(replayDir(agentRoot), trustedUid))
|
|
104
|
+
return false;
|
|
105
|
+
const parsed = readTrustedWindow(replayWindowPath(agentRoot), trustedUid);
|
|
106
|
+
const friends = parsed?.friends;
|
|
107
|
+
if (!friends || typeof friends !== "object")
|
|
108
|
+
return false;
|
|
109
|
+
const entry = friends[friendId];
|
|
110
|
+
const expiresAt = typeof entry?.expiresAt === "string"
|
|
111
|
+
? Date.parse(entry.expiresAt)
|
|
112
|
+
: Number.NaN;
|
|
113
|
+
return Number.isFinite(expiresAt) && expiresAt > now && expiresAt <= now + exports.REPLAY_WINDOW_MAX_MS;
|
|
114
|
+
}
|
|
115
|
+
/** Appends one notice to the sink; throws when it cannot be written, so the caller refuses exactly as a failed Telegram send does. */
|
|
116
|
+
function appendReplayNotice(agentRoot, notice, now = Date.now()) {
|
|
117
|
+
fs.mkdirSync(replayDir(agentRoot), { recursive: true });
|
|
118
|
+
const line = JSON.stringify({ at: new Date(now).toISOString(), ...notice });
|
|
119
|
+
fs.appendFileSync(replaySinkPath(agentRoot), `${line}\n`, { mode: 0o600 });
|
|
120
|
+
(0, runtime_1.emitNervesEvent)({
|
|
121
|
+
component: "senses",
|
|
122
|
+
event: "senses.a2a_replay_notice_sunk",
|
|
123
|
+
message: "wrote an owner notice to the replay sink instead of Telegram",
|
|
124
|
+
meta: { noticeId: notice.noticeId, friendId: notice.friendId },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** True when the sink holds a notice with this id for this friend (a missing sink is "no"). The sink is Butler-writable: callers must also require a trusted window. */
|
|
128
|
+
function replayNoticeRecorded(agentRoot, noticeId, friendId) {
|
|
129
|
+
let raw;
|
|
130
|
+
try {
|
|
131
|
+
raw = fs.readFileSync(replaySinkPath(agentRoot), "utf8");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
return raw.split("\n").some((line) => {
|
|
137
|
+
if (!line)
|
|
138
|
+
return false;
|
|
139
|
+
try {
|
|
140
|
+
const entry = JSON.parse(line);
|
|
141
|
+
return entry.noticeId === noticeId && entry.friendId === friendId;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -37,6 +37,7 @@ exports.createA2AAwaitOwnerDeliverer = createA2AAwaitOwnerDeliverer;
|
|
|
37
37
|
const path = __importStar(require("path"));
|
|
38
38
|
const friends_1 = require("@ouro.bot/friends");
|
|
39
39
|
const identity_1 = require("../identity");
|
|
40
|
+
const replay_harness_1 = require("../../a2a/replay-harness");
|
|
40
41
|
const runtime_1 = require("../../nerves/runtime");
|
|
41
42
|
/**
|
|
42
43
|
* An A2A peer is a client that calls this agent's server; there is no channel back to it. When an await filed from
|
|
@@ -52,6 +53,11 @@ function createA2AAwaitOwnerDeliverer(agentName, notifyOwner = defaultNotifyOwne
|
|
|
52
53
|
}
|
|
53
54
|
try {
|
|
54
55
|
const peer = await new friends_1.FileFriendStore(path.join((0, identity_1.getAgentRoot)(agentName), "friends")).get(request.friendId);
|
|
56
|
+
const agentRoot = (0, identity_1.getAgentRoot)(agentName);
|
|
57
|
+
if ((0, replay_harness_1.isReplayWindowOpen)(agentRoot, request.friendId)) {
|
|
58
|
+
(0, replay_harness_1.appendReplayNotice)(agentRoot, { noticeId: request.deliveryId, text: request.content, friendId: request.friendId });
|
|
59
|
+
return { status: "delivered_now", detail: "written to the replay sink (replay window open for this peer)" };
|
|
60
|
+
}
|
|
55
61
|
const from = peer?.name ?? "a connected agent";
|
|
56
62
|
await notifyOwner({ noticeId: request.deliveryId, text: `Follow-up on a request from ${from}: ${request.content}` });
|
|
57
63
|
return { status: "delivered_now", detail: "sent to the owner's Telegram chat as the agent" };
|
|
@@ -73,6 +73,10 @@ const DIGEST = /^sha256:[a-f0-9]{64}$/u;
|
|
|
73
73
|
// page cache on Unraid's array that takes minutes, so readiness gets a generous budget
|
|
74
74
|
// (a 120 s budget failed a real rollback on 2026-09-24).
|
|
75
75
|
const GATEWAY_READY_TIMEOUT_MS = 900_000;
|
|
76
|
+
const GATEWAY_START_BACKOFF_MS = [2_000, 10_000, 30_000];
|
|
77
|
+
const GATEWAY_LOG_TAIL_CHARS = 2_000;
|
|
78
|
+
// Read more than is shown, so masking sees whole tokens before the tail is cut.
|
|
79
|
+
const GATEWAY_LOG_READ_BYTES = 4_096;
|
|
76
80
|
const GATEWAY_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
77
81
|
const TEMPLATE_JOURNAL = "/boot/config/custom/ouro-butler/docker-man-template-transaction.json";
|
|
78
82
|
// In-place upgrade of an installed authority: same epoch (token, issuer, cursor,
|
|
@@ -536,29 +540,69 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
536
540
|
if (!this.#tokensAbsent() || !this.#rotated())
|
|
537
541
|
throw new Error("Sanctuary root token custody is not exclusive");
|
|
538
542
|
if (this.#pid() === null) {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
543
|
+
// The gateway can exit within seconds of launch (a transient Telegram network error at
|
|
544
|
+
// getMe did, 863 -> 864). Retry the launch with backoff, and when every attempt fails
|
|
545
|
+
// say so now, with the gateway's own output, instead of waiting out the readiness budget.
|
|
546
|
+
// One deadline bounds every launch: a relaunch only gets the budget that remains.
|
|
547
|
+
const deadline = Date.now() + GATEWAY_READY_TIMEOUT_MS;
|
|
548
|
+
for (let attempt = 0;; attempt += 1) {
|
|
549
|
+
const failure = await this.#launchGatewayOnce(deadline - Date.now());
|
|
550
|
+
if (failure === null)
|
|
551
|
+
return;
|
|
552
|
+
if (attempt >= GATEWAY_START_BACKOFF_MS.length || Date.now() + GATEWAY_START_BACKOFF_MS[attempt] >= deadline) {
|
|
553
|
+
throw new Error(`Sanctuary gateway ${failure} before readiness after ${attempt + 1} attempts; gateway.log tail:\n${this.#gatewayLogTail()}`);
|
|
554
|
+
}
|
|
555
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "daemon", event: "daemon.sanctuary_gateway_start_retry", message: "Sanctuary gateway exited before readiness; retrying launch", meta: { attempt: attempt + 1, delayMs: GATEWAY_START_BACKOFF_MS[attempt], failure } });
|
|
556
|
+
await new Promise((resolve) => setTimeout(resolve, GATEWAY_START_BACKOFF_MS[attempt]));
|
|
549
557
|
}
|
|
550
|
-
child.unref();
|
|
551
|
-
let failed = false;
|
|
552
|
-
child.once("error", () => { failed = true; });
|
|
553
|
-
await this.#wait(async () => {
|
|
554
|
-
if (failed)
|
|
555
|
-
throw new Error("Sanctuary gateway launch failed");
|
|
556
|
-
return this.#ready();
|
|
557
|
-
}, GATEWAY_READY_TIMEOUT_MS);
|
|
558
558
|
}
|
|
559
559
|
else
|
|
560
560
|
await this.#wait(() => this.#ready(), GATEWAY_READY_TIMEOUT_MS);
|
|
561
561
|
}
|
|
562
|
+
/** One launch: resolves null once the gateway is ready, or "exited"/"failed to launch" if the process ends first. */
|
|
563
|
+
async #launchGatewayOnce(budgetMs) {
|
|
564
|
+
this.#remove(`${this.#epochRoot()}/readiness.json`);
|
|
565
|
+
const log = this.#gatewayLog();
|
|
566
|
+
let child;
|
|
567
|
+
try {
|
|
568
|
+
child = (0, node_child_process_1.spawn)(this.#p("/usr/local/bin/node"), [this.#p(`${ROOT}/package/dist/heart/daemon/sanctuary-telegram-authority-entry.js`), "--config", this.#p(`${ROOT}/active.json`)], {
|
|
569
|
+
cwd: "/", env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }, detached: true, stdio: ["ignore", log, log],
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
finally {
|
|
573
|
+
fs.closeSync(log);
|
|
574
|
+
}
|
|
575
|
+
child.unref();
|
|
576
|
+
let ended = null;
|
|
577
|
+
child.once("error", () => { ended = "failed to launch"; });
|
|
578
|
+
child.once("exit", (code, signal) => { ended = `exited (${signal ?? `code ${code}`})`; });
|
|
579
|
+
let readiness = "ready";
|
|
580
|
+
await this.#wait(async () => {
|
|
581
|
+
if (ended !== null) {
|
|
582
|
+
readiness = "ended";
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
return this.#ready();
|
|
586
|
+
}, budgetMs);
|
|
587
|
+
return readiness === "ended" ? ended : null;
|
|
588
|
+
}
|
|
589
|
+
/** The last of gateway.log, with anything token-shaped masked, for an error message. */
|
|
590
|
+
#gatewayLogTail() {
|
|
591
|
+
const file = this.#p(`${ROOT}/gateway.log`);
|
|
592
|
+
if (!fs.existsSync(file))
|
|
593
|
+
return "(no gateway.log)";
|
|
594
|
+
const size = fs.statSync(file).size;
|
|
595
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
596
|
+
try {
|
|
597
|
+
const length = Math.min(size, GATEWAY_LOG_READ_BYTES);
|
|
598
|
+
const buffer = Buffer.alloc(length);
|
|
599
|
+
fs.readSync(fd, buffer, 0, length, size - length);
|
|
600
|
+
return buffer.toString("utf8").replace(/[0-9]{5,}:[A-Za-z0-9_-]{20,}/gu, "[token]").slice(-GATEWAY_LOG_TAIL_CHARS).trim() || "(gateway.log is empty)";
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
fs.closeSync(fd);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
562
606
|
/** The gateway's own output, kept root-only beside the token it guards and capped at two
|
|
563
607
|
* generations. It used to go nowhere, so a gateway that exited at startup said nothing
|
|
564
608
|
* (D-036: finding "host cgroup entry is invalid" took a hand-run gateway). */
|
|
@@ -765,6 +809,10 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
765
809
|
if (input.failAfter === step)
|
|
766
810
|
throw new Error(`Sanctuary upgrade rehearsal stopped after ${step}`);
|
|
767
811
|
}
|
|
812
|
+
// A held upgrade is live but uncommitted: its journal and the predecessor's records stay, so `upgrade-rollback`
|
|
813
|
+
// still undoes it exactly. Running `upgrade` again without the hold commits it. A completed upgrade cannot be rolled back.
|
|
814
|
+
if (input.holdCommit)
|
|
815
|
+
return;
|
|
768
816
|
target().#bundle(["--operation", "commit"]);
|
|
769
817
|
// A copy of the live token must not linger as if it were a fresh rotation (D-044).
|
|
770
818
|
this.#remove(`${ROOT}/incoming-token`);
|
|
@@ -1128,10 +1176,11 @@ async function runSanctuaryAuthorityRootCli(argv, write = (text) => process.stdo
|
|
|
1128
1176
|
return;
|
|
1129
1177
|
}
|
|
1130
1178
|
const repin = argv.length === 3 && argv[0] === "repin-execution";
|
|
1131
|
-
const
|
|
1179
|
+
const holdCommit = argv[0] === "upgrade" && argv.length === 4 && argv[3] === "--hold-commit";
|
|
1180
|
+
const upgrade = argv[0] === "upgrade" && (argv.length === 3 || holdCommit || (argv.length === 5 && argv[3] === "--fail-after"));
|
|
1132
1181
|
const rollback = argv.length === 1 && argv[0] === "upgrade-rollback";
|
|
1133
1182
|
if (!repin && !upgrade && !rollback && (argv.length !== 1 || argv[0] !== "boot"))
|
|
1134
|
-
throw new Error("Usage: sanctuary-authority-root-lifecycle <boot|repin-execution <prlimit-sha256> <setsid-sha256>|upgrade <target-image-id> <image-reference> [--fail-after <step
|
|
1183
|
+
throw new Error("Usage: sanctuary-authority-root-lifecycle <boot|repin-execution <prlimit-sha256> <setsid-sha256>|upgrade <target-image-id> <image-reference> [--fail-after <step>|--hold-commit]|upgrade-rollback|vault snapshot|vault presence|vault remove|vault restore>");
|
|
1135
1184
|
// Boot never waits: the host keeper retries it. An operator upgrade waits out a boot in flight.
|
|
1136
1185
|
await (0, session_transaction_1.withSessionTurnLease)(TEMPLATE_JOURNAL, async () => {
|
|
1137
1186
|
const activationPath = `${ROOT}/activation.json`;
|
|
@@ -1147,8 +1196,8 @@ async function runSanctuaryAuthorityRootCli(argv, write = (text) => process.stdo
|
|
|
1147
1196
|
write('{"repinned":true,"gatewayRestartRequired":true}\n');
|
|
1148
1197
|
}
|
|
1149
1198
|
else if (upgrade) {
|
|
1150
|
-
await lifecycle.upgrade({ targetImageId: argv[1], imageReference: argv[2], ...(argv[4] ? { failAfter: argv[4] } : {}) });
|
|
1151
|
-
write(`${JSON.stringify({ upgraded: argv[2] })}\n`);
|
|
1199
|
+
await lifecycle.upgrade({ targetImageId: argv[1], imageReference: argv[2], ...(argv[4] ? { failAfter: argv[4] } : {}), ...(holdCommit ? { holdCommit: true } : {}) });
|
|
1200
|
+
write(`${JSON.stringify({ upgraded: argv[2], ...(holdCommit ? { held: true } : {}) })}\n`);
|
|
1152
1201
|
}
|
|
1153
1202
|
else if (rollback) {
|
|
1154
1203
|
write(`${JSON.stringify({ rolledBack: await lifecycle.rollbackUpgrade() })}\n`);
|
|
@@ -199,6 +199,36 @@ async function readBoundedFileResponse(response) {
|
|
|
199
199
|
const contentType = response.headers.get("content-type");
|
|
200
200
|
return { body: Buffer.concat(chunks, total), ...(contentType ? { contentType } : {}) };
|
|
201
201
|
}
|
|
202
|
+
const GET_ME_BACKOFF_MS = [5_000, 10_000, 20_000, 30_000];
|
|
203
|
+
const NETWORK_FAILURE = /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|EPIPE|socket hang up|network|timed out/iu;
|
|
204
|
+
/** A getMe failure worth another try: Telegram 5xx, or no HTTP answer at all (the client reports a
|
|
205
|
+
* transport failure as a status-less TelegramApiError). Auth and other 4xx answers are never retried. */
|
|
206
|
+
function isTransientTelegramFailure(error) {
|
|
207
|
+
if (!(error instanceof telegram_client_1.TelegramApiError))
|
|
208
|
+
return false;
|
|
209
|
+
if (error.status !== null)
|
|
210
|
+
return error.status >= 500;
|
|
211
|
+
return error.errorCode === null && NETWORK_FAILURE.test(error.message);
|
|
212
|
+
}
|
|
213
|
+
async function getMeWithRetry(api, sleep) {
|
|
214
|
+
for (let attempt = 0;; attempt += 1) {
|
|
215
|
+
try {
|
|
216
|
+
return await api.request("getMe", {});
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (attempt >= GET_ME_BACKOFF_MS.length || !isTransientTelegramFailure(error))
|
|
220
|
+
throw error;
|
|
221
|
+
(0, runtime_1.emitNervesEvent)({
|
|
222
|
+
level: "warn",
|
|
223
|
+
component: "daemon",
|
|
224
|
+
event: "daemon.sanctuary_authority_getme_retry",
|
|
225
|
+
message: "Telegram getMe failed with a transient error; retrying",
|
|
226
|
+
meta: { attempt: attempt + 1, delayMs: GET_ME_BACKOFF_MS[attempt] },
|
|
227
|
+
});
|
|
228
|
+
await sleep(GET_ME_BACKOFF_MS[attempt]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
202
232
|
async function startSanctuaryTelegramAuthority(options) {
|
|
203
233
|
(0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_authority_boot_requested", message: "Sanctuary authority boot requested; prerequisite verification pending" });
|
|
204
234
|
const expectedUid = options.expectedUid ?? 0;
|
|
@@ -245,7 +275,7 @@ async function startSanctuaryTelegramAuthority(options) {
|
|
|
245
275
|
const publicKey = (0, node_crypto_1.createPublicKey)(privateKey);
|
|
246
276
|
if (!options.retireOnly) {
|
|
247
277
|
api = (options.createApi ?? ((value) => (0, telegram_client_1.createTelegramBotApi)({ token: value })))(token);
|
|
248
|
-
const identity = await api.
|
|
278
|
+
const identity = await getMeWithRetry(api, options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))));
|
|
249
279
|
if (!isObject(identity) || String(identity.id) !== config.botId) {
|
|
250
280
|
throw new Error("Sanctuary Telegram authority bot identity changed");
|
|
251
281
|
}
|
|
@@ -520,6 +520,17 @@ function createSanctuaryTelegramAuthorityServer(options) {
|
|
|
520
520
|
connection.setEncoding("utf8");
|
|
521
521
|
connection.setTimeout(connectionTimeoutMs, () => connection.destroy());
|
|
522
522
|
connection.once("close", () => connections.delete(connection));
|
|
523
|
+
// A client that vanishes mid-response (reset, timeout, crash) surfaces as EPIPE/ECONNRESET on this
|
|
524
|
+
// socket; unhandled, that killed the whole gateway. Log it and let the connection close.
|
|
525
|
+
connection.on("error", (error) => {
|
|
526
|
+
(0, runtime_1.emitNervesEvent)({
|
|
527
|
+
level: "warn",
|
|
528
|
+
component: "daemon",
|
|
529
|
+
event: "daemon.sanctuary_authority_connection_error",
|
|
530
|
+
message: "Sanctuary authority client connection failed; dropping it",
|
|
531
|
+
meta: { code: error.code ?? "unknown" },
|
|
532
|
+
});
|
|
533
|
+
});
|
|
523
534
|
let buffer = "";
|
|
524
535
|
connection.on("data", (chunk) => {
|
|
525
536
|
buffer += chunk;
|
|
@@ -573,20 +584,22 @@ function createSanctuaryTelegramAuthorityServer(options) {
|
|
|
573
584
|
const response = dispatchTail.then(async () => {
|
|
574
585
|
try {
|
|
575
586
|
const result = await options.dispatch(frame.method, frame.params);
|
|
576
|
-
connection.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
587
|
+
if (!connection.destroyed)
|
|
588
|
+
connection.write(`${JSON.stringify({
|
|
589
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
590
|
+
id: frame.id,
|
|
591
|
+
ok: true,
|
|
592
|
+
result,
|
|
593
|
+
})}\n`);
|
|
582
594
|
}
|
|
583
595
|
catch {
|
|
584
|
-
connection.
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
596
|
+
if (!connection.destroyed)
|
|
597
|
+
connection.write(`${JSON.stringify({
|
|
598
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
599
|
+
id: frame.id,
|
|
600
|
+
ok: false,
|
|
601
|
+
error: { message: "Sanctuary authority request failed" },
|
|
602
|
+
})}\n`);
|
|
590
603
|
}
|
|
591
604
|
});
|
|
592
605
|
dispatchTail = response;
|
package/dist/senses/a2a-entry.js
CHANGED
|
@@ -47,6 +47,7 @@ function argValue(name) {
|
|
|
47
47
|
const runtime_logging_1 = require("../heart/daemon/runtime-logging");
|
|
48
48
|
const runtime_1 = require("../nerves/runtime");
|
|
49
49
|
const delegated_command_1 = require("../a2a/delegated-command");
|
|
50
|
+
const identity_1 = require("../heart/identity");
|
|
50
51
|
(0, runtime_logging_1.configureDaemonRuntimeLogger)("a2a");
|
|
51
52
|
(0, runtime_1.emitNervesEvent)({
|
|
52
53
|
component: "senses",
|
|
@@ -101,6 +102,7 @@ Promise.all([
|
|
|
101
102
|
...(agentName === "sanctuary" ? {
|
|
102
103
|
delegation: {
|
|
103
104
|
principalProfileId: delegated_command_1.A2A_PRINCIPAL_PROFILE_ID,
|
|
105
|
+
agentRoot: (0, identity_1.getAgentRoot)(agentName),
|
|
104
106
|
notifyPrincipal: async (notice) => {
|
|
105
107
|
const { sendTelegramOwnerNotice } = await Promise.resolve().then(() => __importStar(require("./telegram")));
|
|
106
108
|
await sendTelegramOwnerNotice(agentName, { ...notice, signal: AbortSignal.timeout(30_000) });
|
|
@@ -166,7 +166,7 @@ async function resolveA2APrincipal(agentRoot, store, peer, requestId) {
|
|
|
166
166
|
if (owners.length !== 1 || owners[0].id === peer.id)
|
|
167
167
|
throw new StaleRelationshipAwaitError("delegating owner cannot be resolved");
|
|
168
168
|
const owner = owners[0];
|
|
169
|
-
if (!(0, delegated_command_1.delegatedCommandWasNoticed)(agentRoot, requestId, owner.id))
|
|
169
|
+
if (!(0, delegated_command_1.delegatedCommandWasNoticed)(agentRoot, requestId, owner.id, peer.id))
|
|
170
170
|
throw new StaleRelationshipAwaitError("request was not a delegated command the owner was notified of");
|
|
171
171
|
return owner;
|
|
172
172
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.866",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.866",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|