@ouro.bot/cli 0.1.0-alpha.833 → 0.1.0-alpha.835
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 +151 -33
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +69 -0
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/a2a/client.js +56 -0
- package/dist/a2a/identity.js +34 -0
- package/dist/a2a/inbound-share.js +3 -0
- package/dist/a2a/onboarding.js +1 -6
- package/dist/a2a/sealed-chat.js +112 -0
- package/dist/a2a/server.js +62 -5
- package/dist/heart/daemon/cli-exec.js +27 -0
- package/dist/heart/daemon/cli-parse.js +52 -0
- package/dist/heart/daemon/sanctuary-authority-epoch.js +25 -0
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +282 -22
- package/dist/repertoire/tools.js +16 -4
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
|
@@ -32,8 +32,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
32
32
|
return result;
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
|
+
var _a;
|
|
35
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.SanctuaryAuthorityRootLifecycle = void 0;
|
|
37
|
+
exports.SanctuaryAuthorityRootLifecycle = exports.SANCTUARY_UPGRADE_STEPS = void 0;
|
|
38
|
+
exports.recordSanctuaryRootLifecycleFailure = recordSanctuaryRootLifecycleFailure;
|
|
37
39
|
exports.runSanctuaryAuthorityRootCli = runSanctuaryAuthorityRootCli;
|
|
38
40
|
const node_child_process_1 = require("node:child_process");
|
|
39
41
|
const node_crypto_1 = require("node:crypto");
|
|
@@ -57,7 +59,32 @@ const CGROUP = "/sys/fs/cgroup/ouro-authority";
|
|
|
57
59
|
const BOOT = "/boot/config/custom/ouro-authority/start.sh";
|
|
58
60
|
const BOOT_LINE = `/bin/sh ${BOOT} --boot & # ouro-authority`;
|
|
59
61
|
const DIGEST = /^sha256:[a-f0-9]{64}$/u;
|
|
62
|
+
const TEMPLATE_JOURNAL = "/boot/config/custom/ouro-butler/docker-man-template-transaction.json";
|
|
63
|
+
// In-place upgrade of an installed authority: same epoch (token, issuer, cursor,
|
|
64
|
+
// gateway state), new reviewed package and resident image. Every root record it
|
|
65
|
+
// rewrites is backed up first so an interrupted or failed upgrade rolls back exactly.
|
|
66
|
+
const UPGRADE = `${ROOT}/upgrade.json`;
|
|
67
|
+
const PREVIOUS = `${ROOT}/upgrade-previous`;
|
|
68
|
+
const NEXT_PACKAGE = `${ROOT}/package-next`;
|
|
69
|
+
const INCOMING_REQUEST = `${ROOT}/incoming-request.json`;
|
|
70
|
+
const INCOMING_MANIFEST = `${ROOT}/incoming-package-manifest.json`;
|
|
71
|
+
const EVENTS = "/boot/config/custom/ouro-events/spool";
|
|
72
|
+
const ICON = "https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png";
|
|
73
|
+
const IMAGE_REFERENCE = /^ghcr\.io\/ourostack\/ouroboros-butler:[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/u;
|
|
74
|
+
exports.SANCTUARY_UPGRADE_STEPS = ["stop", "switch", "resident", "migrate", "start"];
|
|
75
|
+
const ROOT_RECORDS = [["request.json", 1024 * 1024], ["package-manifest.json", 8 * 1024 * 1024], ["active.json", 1024 * 1024], ["activation.json", 1024 * 1024]];
|
|
76
|
+
const EPOCH_RECORDS = ["migration.json", "stage.json", "configured.json"];
|
|
60
77
|
const digest = (value) => `sha256:${(0, node_crypto_1.createHash)("sha256").update(value).digest("hex")}`;
|
|
78
|
+
function validateRequest(value) {
|
|
79
|
+
const request = value;
|
|
80
|
+
if (!request || typeof request !== "object" || Object.keys(request).sort().join(",") !== "botId,epochId,nodeDigest,ownerChatId,ownerUserId,packageDigest,prlimitDigest,schemaVersion,setsidDigest,shellDigest"
|
|
81
|
+
|| request.schemaVersion !== 1 || !/^[A-Za-z0-9_-]{1,128}$/u.test(request.epochId)
|
|
82
|
+
|| ![request.botId, request.ownerUserId, request.ownerChatId].every((id) => typeof id === "string" && /^[1-9][0-9]*$/u.test(id))
|
|
83
|
+
|| request.ownerUserId !== request.ownerChatId
|
|
84
|
+
|| ![request.packageDigest, request.nodeDigest, request.prlimitDigest, request.setsidDigest, request.shellDigest].every((pin) => typeof pin === "string" && DIGEST.test(pin)))
|
|
85
|
+
throw new Error("Sanctuary root lifecycle request is invalid");
|
|
86
|
+
return request;
|
|
87
|
+
}
|
|
61
88
|
// The prefix is a filesystem-fixture seam; the production CLI never accepts it.
|
|
62
89
|
class SanctuaryAuthorityRootLifecycle {
|
|
63
90
|
#request;
|
|
@@ -66,7 +93,9 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
66
93
|
#uid;
|
|
67
94
|
#gid;
|
|
68
95
|
#socketGid;
|
|
96
|
+
#options;
|
|
69
97
|
constructor(transaction, options = {}) {
|
|
98
|
+
this.#options = options;
|
|
70
99
|
this.#prefix = options.prefix ?? "";
|
|
71
100
|
this.#uid = options.expectedUid ?? 0;
|
|
72
101
|
this.#gid = options.expectedGid ?? 0;
|
|
@@ -77,15 +106,8 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
77
106
|
throw new Error("Sanctuary root lifecycle image identity is invalid");
|
|
78
107
|
this.#transaction = { targetImageId: transaction.targetImageId, rollbackImageId: transaction.rollbackImageId };
|
|
79
108
|
this.#directory(ROOT);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|| request.schemaVersion !== 1 || !/^[A-Za-z0-9_-]{1,128}$/u.test(request.epochId)
|
|
83
|
-
|| ![request.botId, request.ownerUserId, request.ownerChatId].every((id) => typeof id === "string" && /^[1-9][0-9]*$/u.test(id))
|
|
84
|
-
|| request.ownerUserId !== request.ownerChatId
|
|
85
|
-
|| ![request.packageDigest, request.nodeDigest, request.prlimitDigest, request.setsidDigest, request.shellDigest].every((pin) => typeof pin === "string" && DIGEST.test(pin)))
|
|
86
|
-
throw new Error("Sanctuary root lifecycle request is invalid");
|
|
87
|
-
this.#request = request;
|
|
88
|
-
(0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_root_lifecycle_loaded", message: "Sanctuary root lifecycle identity loaded", meta: { epochId: request.epochId, targetImageId: transaction.targetImageId } });
|
|
109
|
+
this.#request = validateRequest(JSON.parse(this.#private(`${ROOT}/request.json`)));
|
|
110
|
+
(0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_root_lifecycle_loaded", message: "Sanctuary root lifecycle identity loaded", meta: { epochId: this.#request.epochId, targetImageId: transaction.targetImageId } });
|
|
89
111
|
}
|
|
90
112
|
plan() {
|
|
91
113
|
const { epochId, botId, ownerUserId, ownerChatId, packageDigest } = this.#request;
|
|
@@ -94,6 +116,10 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
94
116
|
async boot() {
|
|
95
117
|
if (!fs.existsSync(this.#p(`${ROOT}/activation.json`)))
|
|
96
118
|
return false;
|
|
119
|
+
// An upgrade that never finished (process killed, host rebooted) is rolled back
|
|
120
|
+
// here, so the Butler comes back on its known-good version without a human.
|
|
121
|
+
if (fs.existsSync(this.#p(UPGRADE)))
|
|
122
|
+
return this.rollbackUpgrade();
|
|
97
123
|
this.#record(`${ROOT}/activation.json`, { ...this.#transaction, state: "active", epochId: this.#request.epochId });
|
|
98
124
|
if (fs.existsSync(this.#p("/boot/config/custom/ouro-butler/docker-man-template-transaction.json")))
|
|
99
125
|
throw new Error("Sanctuary pending installation requires recovery, not boot");
|
|
@@ -234,20 +260,20 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
234
260
|
if (!stat.isDirectory() || stat.uid !== this.#uid || stat.gid !== gid || (stat.mode & 0o7777) !== mode || fs.realpathSync(directory) !== directory)
|
|
235
261
|
throw new Error("Sanctuary root lifecycle directory is unsafe");
|
|
236
262
|
}
|
|
237
|
-
#verifyPackage(packageRoot = `${ROOT}/package`, hostExecution = true) {
|
|
263
|
+
#verifyPackage(packageRoot = `${ROOT}/package`, hostExecution = true, manifestPath = `${ROOT}/package-manifest.json`, request = this.#request) {
|
|
238
264
|
(0, sanctuary_authority_installation_1.verifySanctuaryAuthorityInstallation)({
|
|
239
|
-
packageRoot: this.#p(packageRoot), manifestPath: this.#p(
|
|
265
|
+
packageRoot: this.#p(packageRoot), manifestPath: this.#p(manifestPath), manifestDigest: request.packageDigest,
|
|
240
266
|
stateRoot: this.#p(this.#epochRoot()), stagingRoot: this.#p(STAGING), socketRoot: this.#p(SOCKET), cgroupRoot: this.#p(CGROUP),
|
|
241
267
|
expectedUid: this.#uid, expectedGid: this.#gid, socketGroupId: this.#socketGid, mountInfo: fs.readFileSync(this.#p("/proc/self/mountinfo"), "utf8"),
|
|
242
268
|
});
|
|
243
|
-
const manifest = JSON.parse(this.#private(
|
|
269
|
+
const manifest = JSON.parse(this.#private(manifestPath, 0o600, 8 * 1024 * 1024));
|
|
244
270
|
for (const program of ["dist/heart/daemon/sanctuary-telegram-authority-entry.js", "dist/heart/daemon/sanctuary-authority-root-lifecycle.js", "dist/heart/daemon/sanctuary-host-supervisor-entry.js", "deploy/unraid/sanctuary-host-launcher.sh", "deploy/unraid/sanctuary-authority-service.sh"]) {
|
|
245
271
|
if (!Object.hasOwn(manifest.files, program))
|
|
246
272
|
throw new Error("Sanctuary authority package program is absent");
|
|
247
273
|
}
|
|
248
274
|
for (const [file, expected] of [
|
|
249
|
-
["/usr/local/bin/node",
|
|
250
|
-
...(hostExecution ? [["/usr/bin/prlimit",
|
|
275
|
+
["/usr/local/bin/node", request.nodeDigest], ["/bin/sh", request.shellDigest],
|
|
276
|
+
...(hostExecution ? [["/usr/bin/prlimit", request.prlimitDigest], ["/usr/bin/setsid", request.setsidDigest]] : []),
|
|
251
277
|
]) {
|
|
252
278
|
this.#verifyPrimitive(file, expected);
|
|
253
279
|
}
|
|
@@ -626,6 +652,215 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
626
652
|
throw new Error("Sanctuary rollback readback failed");
|
|
627
653
|
this.#write(`${this.#epochRoot()}/rollback.json`, JSON.stringify({ epochId: this.#request.epochId, state: "retired" }));
|
|
628
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* Upgrade the installed authority in place to a reviewed package and resident image.
|
|
657
|
+
* The epoch (token, issuer, cursor, gateway state) is kept; only the package, its
|
|
658
|
+
* pins and the resident image change. Resumable from its journal; any failure is
|
|
659
|
+
* left for `rollbackUpgrade` (or the next boot) to undo exactly.
|
|
660
|
+
*/
|
|
661
|
+
async upgrade(input) {
|
|
662
|
+
if (!DIGEST.test(input.targetImageId) || !IMAGE_REFERENCE.test(input.imageReference)
|
|
663
|
+
|| (input.failAfter !== undefined && !exports.SANCTUARY_UPGRADE_STEPS.includes(input.failAfter)))
|
|
664
|
+
throw new Error("Sanctuary upgrade request is invalid");
|
|
665
|
+
if (fs.existsSync(this.#p(TEMPLATE_JOURNAL)))
|
|
666
|
+
throw new Error("Finish the pending installation before upgrading");
|
|
667
|
+
let journal = fs.existsSync(this.#p(UPGRADE)) ? this.#upgradeJournal() : this.#beginUpgrade(input);
|
|
668
|
+
if (journal.to.imageId !== input.targetImageId || journal.to.imageReference !== input.imageReference)
|
|
669
|
+
throw new Error("A different Sanctuary upgrade is pending; roll it back first");
|
|
670
|
+
const target = () => new _a({ targetImageId: journal.to.imageId, rollbackImageId: journal.from.imageId }, this.#options);
|
|
671
|
+
const steps = {
|
|
672
|
+
stop: () => this.#halt(),
|
|
673
|
+
switch: async () => { await this.#halt(); this.#switchPackage(journal); },
|
|
674
|
+
resident: () => this.#recreateResident(journal.to.imageReference, journal.to.imageId),
|
|
675
|
+
migrate: () => target().#bundle(["--operation", "migrate", "--rollback-image-id", journal.from.imageId, "--target-image-id", journal.to.imageId]),
|
|
676
|
+
start: () => target().#startUpgraded(),
|
|
677
|
+
};
|
|
678
|
+
for (const step of exports.SANCTUARY_UPGRADE_STEPS) {
|
|
679
|
+
if (!journal.completed.includes(step)) {
|
|
680
|
+
await steps[step]();
|
|
681
|
+
journal = { ...journal, completed: [...journal.completed, step] };
|
|
682
|
+
this.#write(UPGRADE, JSON.stringify(journal));
|
|
683
|
+
(0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_upgrade_step", message: "Sanctuary upgrade step completed", meta: { step, to: journal.to.imageReference } });
|
|
684
|
+
}
|
|
685
|
+
if (input.failAfter === step)
|
|
686
|
+
throw new Error(`Sanctuary upgrade rehearsal stopped after ${step}`);
|
|
687
|
+
}
|
|
688
|
+
target().#bundle(["--operation", "commit"]);
|
|
689
|
+
// A copy of the live token must not linger as if it were a fresh rotation (D-044).
|
|
690
|
+
this.#remove(`${ROOT}/incoming-token`);
|
|
691
|
+
this.#remove(UPGRADE);
|
|
692
|
+
this.#removeTree(PREVIOUS);
|
|
693
|
+
(0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_upgraded", message: "Sanctuary authority upgraded in place", meta: { from: journal.from.imageReference, to: journal.to.imageReference } });
|
|
694
|
+
}
|
|
695
|
+
/** Undo a pending in-place upgrade exactly, back to the recorded predecessor. */
|
|
696
|
+
async rollbackUpgrade() {
|
|
697
|
+
if (!fs.existsSync(this.#p(UPGRADE)))
|
|
698
|
+
return false;
|
|
699
|
+
let journal = this.#upgradeJournal();
|
|
700
|
+
await this.#halt();
|
|
701
|
+
if (journal.completed.includes("switch")) {
|
|
702
|
+
// A migration interrupted before the journal recorded it may or may not be
|
|
703
|
+
// pending; only a recorded migration must roll back. Once the bundle is back,
|
|
704
|
+
// record that so a retried rollback does not demand it again.
|
|
705
|
+
try {
|
|
706
|
+
this.#bundle(["--operation", "finalize-rollback"]);
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
if (journal.completed.includes("migrate"))
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
journal = { ...journal, completed: journal.completed.filter((step) => step !== "migrate") };
|
|
713
|
+
this.#write(UPGRADE, JSON.stringify(journal));
|
|
714
|
+
}
|
|
715
|
+
if (fs.existsSync(this.#p(`${PREVIOUS}/package`))) {
|
|
716
|
+
this.#removeTree(`${ROOT}/package`);
|
|
717
|
+
fs.renameSync(this.#p(`${PREVIOUS}/package`), this.#p(`${ROOT}/package`));
|
|
718
|
+
}
|
|
719
|
+
this.#removeTree(NEXT_PACKAGE);
|
|
720
|
+
for (const [name, max] of ROOT_RECORDS)
|
|
721
|
+
this.#write(`${ROOT}/${name}`, this.#private(`${PREVIOUS}/${name}`, 0o600, max));
|
|
722
|
+
(0, sanctuary_authority_epoch_1.rebindSanctuaryAuthorityEpochPackage)(this.#p(this.#epochRoot()), { expectedUid: this.#uid, expectedGid: this.#gid, from: journal.to.packageDigest, to: journal.from.packageDigest });
|
|
723
|
+
for (const name of EPOCH_RECORDS)
|
|
724
|
+
this.#write(`${this.#epochRoot()}/${name}`, this.#private(`${PREVIOUS}/epoch/${name}`));
|
|
725
|
+
const predecessor = new _a({ targetImageId: journal.from.imageId, rollbackImageId: journal.from.rollbackImageId }, this.#options);
|
|
726
|
+
predecessor.#publishBoot();
|
|
727
|
+
if (this.#containers().find((container) => container.Name === "/ouro-butler")?.Image !== journal.from.imageId) {
|
|
728
|
+
predecessor.#recreateResident(journal.from.imageReference, journal.from.imageId);
|
|
729
|
+
}
|
|
730
|
+
await predecessor.#startUpgraded();
|
|
731
|
+
this.#remove(UPGRADE);
|
|
732
|
+
this.#removeTree(PREVIOUS);
|
|
733
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "daemon", event: "daemon.sanctuary_upgrade_rolled_back", message: "Sanctuary upgrade rolled back to its predecessor", meta: { from: journal.to.imageReference, to: journal.from.imageReference } });
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
#upgradeJournal() {
|
|
737
|
+
const journal = JSON.parse(this.#private(UPGRADE));
|
|
738
|
+
const sides = [journal?.from, journal?.to];
|
|
739
|
+
if (!journal || journal.schemaVersion !== 1 || journal.epochId !== this.#request.epochId || !Array.isArray(journal.completed)
|
|
740
|
+
|| !journal.completed.every((step) => exports.SANCTUARY_UPGRADE_STEPS.includes(step))
|
|
741
|
+
|| !sides.every((side) => side && DIGEST.test(side.imageId) && DIGEST.test(side.packageDigest) && IMAGE_REFERENCE.test(side.imageReference))
|
|
742
|
+
|| !DIGEST.test(journal.from.rollbackImageId))
|
|
743
|
+
throw new Error("Sanctuary upgrade journal is invalid");
|
|
744
|
+
return journal;
|
|
745
|
+
}
|
|
746
|
+
#beginUpgrade(input) {
|
|
747
|
+
this.#record(`${ROOT}/activation.json`, { ...this.#transaction, state: "active", epochId: this.#request.epochId });
|
|
748
|
+
const current = this.#target();
|
|
749
|
+
if (input.targetImageId === this.#transaction.targetImageId)
|
|
750
|
+
throw new Error("Sanctuary already runs the requested image");
|
|
751
|
+
if (this.#docker(["image", "inspect", "--format", "{{.Id}}", input.imageReference]).trim() !== input.targetImageId)
|
|
752
|
+
throw new Error("Sanctuary upgrade image identity does not match its reference");
|
|
753
|
+
const request = validateRequest(JSON.parse(this.#private(INCOMING_REQUEST)));
|
|
754
|
+
if (["epochId", "botId", "ownerUserId", "ownerChatId"].some((key) => request[key] !== this.#request[key]))
|
|
755
|
+
throw new Error("Sanctuary upgrade would change the authority identity");
|
|
756
|
+
if (request.packageDigest === this.#request.packageDigest)
|
|
757
|
+
throw new Error("Sanctuary upgrade package is already installed");
|
|
758
|
+
this.#verifyPackage(`${ROOT}/incoming-package`, true, INCOMING_MANIFEST, request);
|
|
759
|
+
this.#removeTree(PREVIOUS);
|
|
760
|
+
this.#directory(PREVIOUS);
|
|
761
|
+
this.#directory(`${PREVIOUS}/epoch`);
|
|
762
|
+
for (const [name, max] of ROOT_RECORDS)
|
|
763
|
+
this.#write(`${PREVIOUS}/${name}`, this.#private(`${ROOT}/${name}`, 0o600, max));
|
|
764
|
+
for (const name of EPOCH_RECORDS)
|
|
765
|
+
this.#write(`${PREVIOUS}/epoch/${name}`, this.#private(`${this.#epochRoot()}/${name}`));
|
|
766
|
+
const journal = {
|
|
767
|
+
schemaVersion: 1, epochId: this.#request.epochId, completed: [],
|
|
768
|
+
from: { imageId: this.#transaction.targetImageId, imageReference: current.Config.Image, packageDigest: this.#request.packageDigest, rollbackImageId: this.#transaction.rollbackImageId },
|
|
769
|
+
to: { imageId: input.targetImageId, imageReference: input.imageReference, packageDigest: request.packageDigest },
|
|
770
|
+
};
|
|
771
|
+
if (!IMAGE_REFERENCE.test(journal.from.imageReference))
|
|
772
|
+
throw new Error("Sanctuary current resident image reference is not a reviewed release");
|
|
773
|
+
this.#write(UPGRADE, JSON.stringify(journal));
|
|
774
|
+
return journal;
|
|
775
|
+
}
|
|
776
|
+
/** Stop the resident, then the gateway, without retiring anything. */
|
|
777
|
+
async #halt() {
|
|
778
|
+
this.#assertNoForeignPoller();
|
|
779
|
+
for (const container of this.#containers())
|
|
780
|
+
if (container.State.Running)
|
|
781
|
+
this.#docker(["stop", "--time", "30", container.Name.slice(1)]);
|
|
782
|
+
if (this.#containers().some((container) => container.State.Running))
|
|
783
|
+
throw new Error("Sanctuary resident did not stop");
|
|
784
|
+
const pid = this.#pid();
|
|
785
|
+
if (pid !== null) {
|
|
786
|
+
process.kill(pid, "SIGTERM");
|
|
787
|
+
await this.#wait(() => this.#pid() === null);
|
|
788
|
+
}
|
|
789
|
+
this.#remove(`${this.#epochRoot()}/readiness.json`);
|
|
790
|
+
}
|
|
791
|
+
#switchPackage(journal) {
|
|
792
|
+
const manifestText = this.#private(INCOMING_MANIFEST, 0o600, 8 * 1024 * 1024);
|
|
793
|
+
if (!fs.existsSync(this.#p(`${PREVIOUS}/package`))) {
|
|
794
|
+
this.#populate(NEXT_PACKAGE, `${ROOT}/incoming-package`, JSON.parse(manifestText));
|
|
795
|
+
fs.renameSync(this.#p(`${ROOT}/package`), this.#p(`${PREVIOUS}/package`));
|
|
796
|
+
}
|
|
797
|
+
if (!fs.existsSync(this.#p(`${ROOT}/package`)))
|
|
798
|
+
fs.renameSync(this.#p(NEXT_PACKAGE), this.#p(`${ROOT}/package`));
|
|
799
|
+
this.#write(`${ROOT}/package-manifest.json`, manifestText);
|
|
800
|
+
this.#write(`${ROOT}/request.json`, this.#private(INCOMING_REQUEST));
|
|
801
|
+
(0, sanctuary_authority_epoch_1.rebindSanctuaryAuthorityEpochPackage)(this.#p(this.#epochRoot()), { expectedUid: this.#uid, expectedGid: this.#gid, from: journal.from.packageDigest, to: journal.to.packageDigest });
|
|
802
|
+
const target = new _a({ targetImageId: journal.to.imageId, rollbackImageId: journal.from.imageId }, this.#options);
|
|
803
|
+
target.#rebindRecords();
|
|
804
|
+
}
|
|
805
|
+
/** Copy a reviewed package into a fresh private tree, pin by pin. */
|
|
806
|
+
#populate(destinationRoot, sourceRoot, manifest) {
|
|
807
|
+
this.#removeTree(destinationRoot);
|
|
808
|
+
this.#directory(destinationRoot);
|
|
809
|
+
for (const [relative, pin] of Object.entries(manifest.files)) {
|
|
810
|
+
const destination = `${destinationRoot}/${relative}`;
|
|
811
|
+
this.#directory(path.dirname(destination));
|
|
812
|
+
fs.copyFileSync(this.#p(`${sourceRoot}/${relative}`), this.#p(destination), fs.constants.COPYFILE_EXCL);
|
|
813
|
+
fs.chmodSync(this.#p(destination), pin.mode);
|
|
814
|
+
if (digest(fs.readFileSync(this.#p(destination))) !== pin.digest)
|
|
815
|
+
throw new Error("Sanctuary upgrade package conflicts with its pin");
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
/** Point every epoch record at this lifecycle's (new) package and image, keeping the cursor. */
|
|
819
|
+
#rebindRecords() {
|
|
820
|
+
const migration = JSON.parse(this.#private(`${this.#epochRoot()}/migration.json`));
|
|
821
|
+
if (!Number.isSafeInteger(migration.predecessorCursor) || migration.predecessorCursor < 0)
|
|
822
|
+
throw new Error("Sanctuary root migration cursor is invalid");
|
|
823
|
+
this.#write(`${this.#epochRoot()}/migration.json`, JSON.stringify({ ...this.plan(), ...this.#transaction, predecessorCursor: migration.predecessorCursor }));
|
|
824
|
+
this.#write(`${this.#epochRoot()}/stage.json`, JSON.stringify({ packageDigest: this.#request.packageDigest }));
|
|
825
|
+
this.#write(`${this.#epochRoot()}/configured.json`, JSON.stringify({ epochId: this.#request.epochId, targetImageId: this.#transaction.targetImageId }));
|
|
826
|
+
this.#verifyPackage();
|
|
827
|
+
this.#publishBoot();
|
|
828
|
+
this.#write(`${ROOT}/active.json`, JSON.stringify(this.#configuration()));
|
|
829
|
+
this.#write(`${ROOT}/activation.json`, JSON.stringify({ ...this.#transaction, state: "active", epochId: this.#request.epochId }));
|
|
830
|
+
}
|
|
831
|
+
/** Point the boot service script at the installed package. On Unraid the host keeper
|
|
832
|
+
* (not a direct go line) invokes it, so the go file is left as the operator has it. */
|
|
833
|
+
#publishBoot() {
|
|
834
|
+
this.#write(BOOT, fs.readFileSync(this.#p(`${ROOT}/package/deploy/unraid/sanctuary-authority-service.sh`), "utf8"));
|
|
835
|
+
}
|
|
836
|
+
#recreateResident(reference, imageId) {
|
|
837
|
+
if (this.#containers().some((container) => container.Name === "/ouro-butler"))
|
|
838
|
+
this.#docker(["rm", "-f", "ouro-butler"]);
|
|
839
|
+
this.#docker(["create", "--name", "ouro-butler", "--network", "host", "--restart", "unless-stopped", "--user", "10001:10001",
|
|
840
|
+
"-l", "net.unraid.docker.managed=dockerman", "-l", `net.unraid.docker.icon=${ICON}`, "-l", "org.opencontainers.image.source=https://github.com/ourostack/ouroboros",
|
|
841
|
+
"-v", `${RUNTIME}:/home/ouro/.ouro-cli:rw`, "-v", `${BUNDLE}:/home/ouro/AgentBundles/sanctuary.ouro:rw`,
|
|
842
|
+
"-v", `${EVENTS}:/run/ouro-events:ro`, "-v", `${SOCKET}:${SOCKET}:ro`, reference]);
|
|
843
|
+
const resident = this.#containers().find((container) => container.Name === "/ouro-butler");
|
|
844
|
+
if (resident?.Image !== imageId || resident.State.Running)
|
|
845
|
+
throw new Error("Sanctuary resident recreation readback failed");
|
|
846
|
+
}
|
|
847
|
+
/** Run the installed package's bundle migration CLI, then return the bundle to the resident. */
|
|
848
|
+
#bundle(operation) {
|
|
849
|
+
(0, node_child_process_1.execFileSync)(this.#p("/usr/local/bin/node"), [this.#p(`${ROOT}/package/deploy/unraid/migrate-sanctuary-bundle.mjs`),
|
|
850
|
+
"--package-root", this.#p(`${ROOT}/package/deploy/unraid/sanctuary.ouro`), "--agent-root", this.#p(BUNDLE), ...operation], {
|
|
851
|
+
encoding: "utf8", timeout: 300_000, stdio: ["ignore", "pipe", "pipe"], env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }, cwd: "/",
|
|
852
|
+
});
|
|
853
|
+
(0, node_child_process_1.execFileSync)("/bin/chown", ["-R", "10001:10001", this.#p(BUNDLE)], { stdio: "ignore" });
|
|
854
|
+
}
|
|
855
|
+
/** Start the gateway, then the resident, and prove both. */
|
|
856
|
+
async #startUpgraded() {
|
|
857
|
+
await this.#startGateway();
|
|
858
|
+
this.#docker(["start", "ouro-butler"]);
|
|
859
|
+
await this.#wait(async () => this.#target().State.Running && this.#target().State.Health?.Status === "healthy" && await this.#ready(), 300_000);
|
|
860
|
+
}
|
|
861
|
+
#removeTree(absolute) {
|
|
862
|
+
fs.rmSync(this.#p(absolute), { recursive: true, force: true });
|
|
863
|
+
}
|
|
629
864
|
#remove(absolute) {
|
|
630
865
|
if (!fs.existsSync(this.#p(absolute)))
|
|
631
866
|
return;
|
|
@@ -772,6 +1007,21 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
772
1007
|
}
|
|
773
1008
|
}
|
|
774
1009
|
exports.SanctuaryAuthorityRootLifecycle = SanctuaryAuthorityRootLifecycle;
|
|
1010
|
+
_a = SanctuaryAuthorityRootLifecycle;
|
|
1011
|
+
/**
|
|
1012
|
+
* Keep the real failure where only root can read it (D-034). Stderr stays generic
|
|
1013
|
+
* because a parse error can quote private bytes; the authority root is as private
|
|
1014
|
+
* as the token it already holds.
|
|
1015
|
+
*/
|
|
1016
|
+
function recordSanctuaryRootLifecycleFailure(error, file = `${ROOT}/lifecycle-failure.log`) {
|
|
1017
|
+
try {
|
|
1018
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${error instanceof Error ? error.stack : String(error)}\n`, { mode: 0o600 });
|
|
1019
|
+
return ` Details (root-only): ${file}`;
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
return "";
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
775
1025
|
async function runSanctuaryAuthorityRootCli(argv, write = (text) => process.stdout.write(text)) {
|
|
776
1026
|
if (process.getuid() !== 0 || process.getgid() !== 0)
|
|
777
1027
|
throw new Error("Sanctuary root lifecycle requires root");
|
|
@@ -792,12 +1042,15 @@ async function runSanctuaryAuthorityRootCli(argv, write = (text) => process.stdo
|
|
|
792
1042
|
return;
|
|
793
1043
|
}
|
|
794
1044
|
const repin = argv.length === 3 && argv[0] === "repin-execution";
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1045
|
+
const upgrade = argv[0] === "upgrade" && (argv.length === 3 || (argv.length === 5 && argv[3] === "--fail-after"));
|
|
1046
|
+
const rollback = argv.length === 1 && argv[0] === "upgrade-rollback";
|
|
1047
|
+
if (!repin && !upgrade && !rollback && (argv.length !== 1 || argv[0] !== "boot"))
|
|
1048
|
+
throw new Error("Usage: sanctuary-authority-root-lifecycle <boot|repin-execution <prlimit-sha256> <setsid-sha256>|upgrade <target-image-id> <image-reference> [--fail-after <step>]|upgrade-rollback|vault snapshot|vault presence|vault remove|vault restore>");
|
|
1049
|
+
// Boot never waits: the host keeper retries it. An operator upgrade waits out a boot in flight.
|
|
1050
|
+
await (0, session_transaction_1.withSessionTurnLease)(TEMPLATE_JOURNAL, async () => {
|
|
798
1051
|
const activationPath = `${ROOT}/activation.json`;
|
|
799
1052
|
if (!fs.existsSync(activationPath)) {
|
|
800
|
-
if (repin)
|
|
1053
|
+
if (repin || upgrade || rollback)
|
|
801
1054
|
throw new Error("Sanctuary authority is not active");
|
|
802
1055
|
return;
|
|
803
1056
|
}
|
|
@@ -807,13 +1060,20 @@ async function runSanctuaryAuthorityRootCli(argv, write = (text) => process.stdo
|
|
|
807
1060
|
lifecycle.repinExecution(argv[1], argv[2]);
|
|
808
1061
|
write('{"repinned":true,"gatewayRestartRequired":true}\n');
|
|
809
1062
|
}
|
|
1063
|
+
else if (upgrade) {
|
|
1064
|
+
await lifecycle.upgrade({ targetImageId: argv[1], imageReference: argv[2], ...(argv[4] ? { failAfter: argv[4] } : {}) });
|
|
1065
|
+
write(`${JSON.stringify({ upgraded: argv[2] })}\n`);
|
|
1066
|
+
}
|
|
1067
|
+
else if (rollback) {
|
|
1068
|
+
write(`${JSON.stringify({ rolledBack: await lifecycle.rollbackUpgrade() })}\n`);
|
|
1069
|
+
}
|
|
810
1070
|
else
|
|
811
1071
|
await lifecycle.boot();
|
|
812
|
-
}, { timeoutMs: 0, confinementRoot: "/boot/config/custom/ouro-butler" });
|
|
1072
|
+
}, { timeoutMs: upgrade || rollback ? 600_000 : 0, confinementRoot: "/boot/config/custom/ouro-butler" });
|
|
813
1073
|
}
|
|
814
1074
|
if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process.argv[1]) === __filename) {
|
|
815
|
-
void runSanctuaryAuthorityRootCli(process.argv.slice(2)).catch(() => {
|
|
816
|
-
process.stderr.write(
|
|
1075
|
+
void runSanctuaryAuthorityRootCli(process.argv.slice(2)).catch((error) => {
|
|
1076
|
+
process.stderr.write(`Sanctuary root lifecycle failed; inspect the root transaction and repair its failed boundary.${recordSanctuaryRootLifecycleFailure(error)}\n`);
|
|
817
1077
|
process.exitCode = 1;
|
|
818
1078
|
});
|
|
819
1079
|
}
|
package/dist/repertoire/tools.js
CHANGED
|
@@ -74,6 +74,8 @@ exports.SANCTUARY_MCP_PROVIDED_TOOLS = new Set([
|
|
|
74
74
|
"media_search", "media_request", "media_request_status",
|
|
75
75
|
"media_diagnose_and_fix", "media_chain_health", "media_play_or_resolve",
|
|
76
76
|
]);
|
|
77
|
+
/** Channels a Sanctuary turn can arrive on from the host itself, not from a remote party. */
|
|
78
|
+
const SANCTUARY_LOCAL_CHANNELS = new Set(["inner", "cli", "mcp"]);
|
|
77
79
|
exports.SANCTUARY_OWNER_ADDITIONS = new Set([
|
|
78
80
|
"shell", "shell_status", "shell_tail", "read_file", "write_file", "edit_file", "glob", "grep",
|
|
79
81
|
"web_search", "search_facts", "consult_diary", "consult_notes", "get_friend_note",
|
|
@@ -125,10 +127,20 @@ function applyPreference(tool, pref) {
|
|
|
125
127
|
function selectToolsForChannel(capabilities, toolPreferences, _context, providerCapabilities, mcpManager, _chatModel, context) {
|
|
126
128
|
const relationship = context?.relationshipAuthorization;
|
|
127
129
|
const profileId = relationship?.profileId;
|
|
128
|
-
|
|
130
|
+
// Every Sanctuary turn that reaches the agent from outside the host (Telegram, A2A,
|
|
131
|
+
// any future remote sense) is relationship-scoped: without a known capability
|
|
132
|
+
// profile it gets no tools at all. Only the Butler's own local channels (its inner
|
|
133
|
+
// loop and the host-local CLI/MCP) keep the unscoped tool set.
|
|
134
|
+
const channel = capabilities?.channel;
|
|
135
|
+
const remote = channel !== undefined && !SANCTUARY_LOCAL_CHANNELS.has(channel);
|
|
136
|
+
const sanctuary = (context?.agentName === "sanctuary" && (relationship !== undefined || remote))
|
|
129
137
|
|| profileId?.startsWith("sanctuary-") === true;
|
|
130
138
|
const owner = profileId === "sanctuary-owner";
|
|
131
|
-
|
|
139
|
+
// A trusted peer agent acting for the owner gets the owner's chat tool menu. Host
|
|
140
|
+
// execution and approval-gated routine actions stay Telegram-owner-only: the host
|
|
141
|
+
// tool is only ever added on the Telegram channel, and the peer profile omits them.
|
|
142
|
+
const ownerLike = owner || profileId === "sanctuary-agent-peer";
|
|
143
|
+
const knownSanctuaryProfile = relationship !== undefined && (ownerLike || profileId === "sanctuary-household" || profileId === "sanctuary-event");
|
|
132
144
|
const mcp = mcpManager ? (0, mcp_tools_1.mcpToolsAsDefinitions)(mcpManager) : [];
|
|
133
145
|
const native = [...tools_base_1.baseToolDefinitions, ...additionalDefinitions];
|
|
134
146
|
assertUniqueToolSchemas([
|
|
@@ -140,8 +152,8 @@ function selectToolsForChannel(capabilities, toolPreferences, _context, provider
|
|
|
140
152
|
ordinary = knownSanctuaryProfile
|
|
141
153
|
? [
|
|
142
154
|
...tools_base_1.baseToolDefinitions.filter((definition) => SANCTUARY_RELATIONSHIP_BASE_TOOLS.has(definition.tool.function.name)
|
|
143
|
-
|| (
|
|
144
|
-
...tools_unraid_1.unraidToolDefinitions, tools_steward_policy_1.stewardPolicyToolDefinition, ...(
|
|
155
|
+
|| (ownerLike && exports.SANCTUARY_OWNER_ADDITIONS.has(definition.tool.function.name))),
|
|
156
|
+
...tools_unraid_1.unraidToolDefinitions, tools_steward_policy_1.stewardPolicyToolDefinition, ...(ownerLike ? mcp : []),
|
|
145
157
|
]
|
|
146
158
|
: [];
|
|
147
159
|
}
|
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.835",
|
|
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.835",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"@microsoft/teams.common": "2.0.11",
|
|
19
19
|
"@microsoft/teams.dev": "2.0.11",
|
|
20
20
|
"@microsoft/teams.graph": "2.0.11",
|
|
21
|
-
"@ouro.bot/friends": "0.1.0-alpha.
|
|
21
|
+
"@ouro.bot/friends": "0.1.0-alpha.12",
|
|
22
22
|
"@types/react": "^17.0.91",
|
|
23
23
|
"@types/ws": "^8.18.1",
|
|
24
24
|
"ajv": "^8.20.0",
|
|
@@ -1357,9 +1357,9 @@
|
|
|
1357
1357
|
}
|
|
1358
1358
|
},
|
|
1359
1359
|
"node_modules/@ouro.bot/friends": {
|
|
1360
|
-
"version": "0.1.0-alpha.
|
|
1361
|
-
"resolved": "https://registry.npmjs.org/@ouro.bot/friends/-/friends-0.1.0-alpha.
|
|
1362
|
-
"integrity": "sha512-
|
|
1360
|
+
"version": "0.1.0-alpha.12",
|
|
1361
|
+
"resolved": "https://registry.npmjs.org/@ouro.bot/friends/-/friends-0.1.0-alpha.12.tgz",
|
|
1362
|
+
"integrity": "sha512-pzV2PQvi1eUkcog3iDDHIvwSBDwpsB8/ykrJpoZQ/wB84qQa0xGKJSDnTt45r8H4j/68Cc5LU6vcjlwI5GG47g==",
|
|
1363
1363
|
"license": "Apache-2.0",
|
|
1364
1364
|
"dependencies": {
|
|
1365
1365
|
"libsodium-wrappers": "0.8.4"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.835",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=22"
|
|
6
6
|
},
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"@microsoft/teams.common": "2.0.11",
|
|
66
66
|
"@microsoft/teams.dev": "2.0.11",
|
|
67
67
|
"@microsoft/teams.graph": "2.0.11",
|
|
68
|
-
"@ouro.bot/friends": "0.1.0-alpha.
|
|
68
|
+
"@ouro.bot/friends": "0.1.0-alpha.12",
|
|
69
69
|
"@types/react": "^17.0.91",
|
|
70
70
|
"@types/ws": "^8.18.1",
|
|
71
71
|
"ajv": "^8.20.0",
|