@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.
@@ -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
- const request = JSON.parse(this.#private(`${ROOT}/request.json`));
81
- if (!request || Object.keys(request).sort().join(",") !== "botId,epochId,nodeDigest,ownerChatId,ownerUserId,packageDigest,prlimitDigest,schemaVersion,setsidDigest,shellDigest"
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(`${ROOT}/package-manifest.json`), manifestDigest: this.#request.packageDigest,
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(`${ROOT}/package-manifest.json`, 0o600, 8 * 1024 * 1024));
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", this.#request.nodeDigest], ["/bin/sh", this.#request.shellDigest],
250
- ...(hostExecution ? [["/usr/bin/prlimit", this.#request.prlimitDigest], ["/usr/bin/setsid", this.#request.setsidDigest]] : []),
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
- if (!repin && (argv.length !== 1 || argv[0] !== "boot"))
796
- throw new Error("Usage: sanctuary-authority-root-lifecycle <boot|repin-execution <prlimit-sha256> <setsid-sha256>|vault snapshot|vault presence|vault remove|vault restore>");
797
- await (0, session_transaction_1.withSessionTurnLease)("/boot/config/custom/ouro-butler/docker-man-template-transaction.json", async () => {
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("Sanctuary root lifecycle failed; inspect the root transaction and repair its failed boundary.\n");
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
  }
@@ -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
- const sanctuary = (context?.agentName === "sanctuary" && (relationship !== undefined || capabilities?.channel === "telegram"))
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
- const knownSanctuaryProfile = relationship !== undefined && (owner || profileId === "sanctuary-household" || profileId === "sanctuary-event");
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
- || (owner && exports.SANCTUARY_OWNER_ADDITIONS.has(definition.tool.function.name))),
144
- ...tools_unraid_1.unraidToolDefinitions, tools_steward_policy_1.stewardPolicyToolDefinition, ...(owner ? mcp : []),
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
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.833",
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.833",
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.10",
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.10",
1361
- "resolved": "https://registry.npmjs.org/@ouro.bot/friends/-/friends-0.1.0-alpha.10.tgz",
1362
- "integrity": "sha512-HxKCXDv74pWnVPcyEYyWn4L1Okg4otanHo3o80J1yofN2ycb56Nreimj+IPSTf7d3lthIsoSbksgIpDyE11OGw==",
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.833",
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.10",
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",