@ouro.bot/cli 0.1.0-alpha.92 → 0.1.0-alpha.920

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.
Files changed (716) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -23
  3. package/RepairGuide.ouro/agent.json +5 -0
  4. package/RepairGuide.ouro/psyche/IDENTITY.md +19 -0
  5. package/RepairGuide.ouro/psyche/SOUL.md +55 -0
  6. package/RepairGuide.ouro/skills/diagnose-broken-remote.md +63 -0
  7. package/RepairGuide.ouro/skills/diagnose-stacked-typed-issues.md +35 -0
  8. package/RepairGuide.ouro/skills/diagnose-sync-blocked.md +54 -0
  9. package/RepairGuide.ouro/skills/diagnose-vault-expired.md +60 -0
  10. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/agent.json +4 -2
  11. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/SOUL.md +2 -2
  12. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-serpent.md +1 -1
  13. package/assets/bluebubbles-host +264 -0
  14. package/assets/sanctuary-host-launcher.sh +16 -0
  15. package/changelog.json +5819 -16
  16. package/deploy/unraid/Dockerfile +58 -0
  17. package/deploy/unraid/README.txt +2997 -0
  18. package/deploy/unraid/audit-container-spec.sh +4 -0
  19. package/deploy/unraid/container-runtime.json +1 -0
  20. package/deploy/unraid/docker-man-template-transaction.mjs +711 -0
  21. package/deploy/unraid/docker-man-template-xml.cjs +105 -0
  22. package/deploy/unraid/migrate-sanctuary-bundle.mjs +60 -0
  23. package/deploy/unraid/ouro-events/bootstrap-spool.sh +92 -0
  24. package/deploy/unraid/ouro-events/emit-event.mjs +310 -0
  25. package/deploy/unraid/ouro-events/emit-usenet-event.sh +54 -0
  26. package/deploy/unraid/ouro-events/install-usenet-guard.sh +435 -0
  27. package/deploy/unraid/ouro-events/usenet-health.sh +176 -0
  28. package/deploy/unraid/sanctuary-acceptance-adapter.sh +43 -0
  29. package/deploy/unraid/sanctuary-acceptance-contract.json +198 -0
  30. package/deploy/unraid/sanctuary-acceptance-harness.sh +23 -0
  31. package/deploy/unraid/sanctuary-authority-installation.json +36 -0
  32. package/deploy/unraid/sanctuary-authority-service.sh +30 -0
  33. package/deploy/unraid/sanctuary-butler-upgrade.mjs +1254 -0
  34. package/deploy/unraid/sanctuary-deployment-target.mjs +710 -0
  35. package/deploy/unraid/sanctuary-replay-gate.mjs +1564 -0
  36. package/deploy/unraid/sanctuary-safe-rename.py +24 -0
  37. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +2086 -0
  38. package/deploy/unraid/sanctuary-unit16-run.sh +560 -0
  39. package/deploy/unraid/sanctuary-unit18-target-audit.sh +27 -0
  40. package/deploy/unraid/sanctuary.ouro/agent.json +66 -0
  41. package/deploy/unraid/sanctuary.ouro/arc/README.md +3 -0
  42. package/deploy/unraid/sanctuary.ouro/awaiting/house-care-sweep.md +17 -0
  43. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +5 -0
  44. package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +8 -0
  45. package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +2231 -0
  46. package/deploy/unraid/sanctuary.ouro/provider-readiness.json +11 -0
  47. package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +5 -0
  48. package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +5 -0
  49. package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +33 -0
  50. package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +27 -0
  51. package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +22 -0
  52. package/deploy/unraid/sanctuary.ouro/state/policy/steward.json +7 -0
  53. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +232 -0
  54. package/deploy/unraid/sanctuary.xml +30 -0
  55. package/dist/a2a/card.js +57 -0
  56. package/dist/a2a/client.js +250 -0
  57. package/dist/a2a/config.js +50 -0
  58. package/dist/a2a/delegated-command-grants.js +93 -0
  59. package/dist/a2a/delegated-command.js +197 -0
  60. package/dist/a2a/delegation-stores.js +56 -0
  61. package/dist/a2a/did-resolution.js +93 -0
  62. package/dist/a2a/escalation-grants.js +84 -0
  63. package/dist/a2a/escalation-wiring.js +60 -0
  64. package/dist/a2a/identity.js +258 -0
  65. package/dist/a2a/inbound-share.js +110 -0
  66. package/dist/a2a/mission-result-wire.js +196 -0
  67. package/dist/a2a/onboarding.js +133 -0
  68. package/dist/a2a/operator-trust.js +382 -0
  69. package/dist/a2a/outbox-client.js +57 -0
  70. package/dist/a2a/outbox-store.js +188 -0
  71. package/dist/a2a/outbox-wire.js +95 -0
  72. package/dist/a2a/pin-store.js +132 -0
  73. package/dist/a2a/replay-grant-holders.js +33 -0
  74. package/dist/a2a/replay-harness.js +188 -0
  75. package/dist/a2a/resolution-proof.js +53 -0
  76. package/dist/a2a/sealed-chat.js +113 -0
  77. package/dist/a2a/seen-ledger.js +120 -0
  78. package/dist/a2a/server.js +771 -0
  79. package/dist/a2a/task-store.js +69 -0
  80. package/dist/a2a/transport.js +45 -0
  81. package/dist/a2a/trust-commands.js +203 -0
  82. package/dist/a2a/trusted-files.js +185 -0
  83. package/dist/a2a/types.js +3 -0
  84. package/dist/arc/attention-types.js +8 -0
  85. package/dist/arc/cares.js +360 -0
  86. package/dist/arc/episodes.js +118 -0
  87. package/dist/arc/evolution.js +490 -0
  88. package/dist/arc/flight-recorder.js +1034 -0
  89. package/dist/arc/intentions.js +134 -0
  90. package/dist/arc/json-store.js +117 -0
  91. package/dist/arc/obligations.js +386 -0
  92. package/dist/arc/packets.js +336 -0
  93. package/dist/arc/presence.js +185 -0
  94. package/dist/arc/task-lifecycle.js +57 -0
  95. package/dist/commerce/store.js +755 -0
  96. package/dist/commerce/types.js +3 -0
  97. package/dist/heart/active-work.js +798 -40
  98. package/dist/heart/agent-entry.js +252 -3
  99. package/dist/heart/approval-files.js +113 -0
  100. package/dist/heart/approval-store.js +960 -0
  101. package/dist/heart/attachments/image-normalize.js +194 -0
  102. package/dist/heart/attachments/materialize.js +97 -0
  103. package/dist/heart/attachments/originals.js +88 -0
  104. package/dist/heart/attachments/render.js +29 -0
  105. package/dist/heart/attachments/sources/bluebubbles.js +156 -0
  106. package/dist/heart/attachments/sources/cli-local-file.js +78 -0
  107. package/dist/heart/attachments/sources/index.js +18 -0
  108. package/dist/heart/attachments/sources/telegram.js +71 -0
  109. package/dist/heart/attachments/store.js +132 -0
  110. package/dist/heart/attachments/types.js +93 -0
  111. package/dist/heart/auth/auth-flow.js +495 -0
  112. package/dist/heart/autonomy-budget.js +478 -0
  113. package/dist/heart/awaiting/a2a-await-delivery.js +109 -0
  114. package/dist/heart/awaiting/await-alert.js +152 -0
  115. package/dist/heart/awaiting/await-expiry.js +143 -0
  116. package/dist/heart/awaiting/await-loader.js +91 -0
  117. package/dist/heart/awaiting/await-parser.js +152 -0
  118. package/dist/heart/awaiting/await-runtime-state.js +111 -0
  119. package/dist/heart/awaiting/await-scheduler.js +391 -0
  120. package/dist/heart/background-operations.js +281 -0
  121. package/dist/heart/bluebubbles-host-account.js +78 -0
  122. package/dist/heart/bridges/manager.js +137 -17
  123. package/dist/heart/bridges/store.js +14 -2
  124. package/dist/heart/bundle-state.js +168 -0
  125. package/dist/heart/commitments.js +44 -18
  126. package/dist/heart/config-registry.js +331 -0
  127. package/dist/heart/config.js +174 -131
  128. package/dist/heart/context-loss-gauntlet.js +387 -0
  129. package/dist/heart/context-loss-sentinel.js +1159 -0
  130. package/dist/heart/core.js +2364 -578
  131. package/dist/heart/cross-chat-delivery.js +9 -22
  132. package/dist/heart/daemon/agent-config-check.js +451 -0
  133. package/dist/heart/daemon/agent-discovery.js +283 -3
  134. package/dist/heart/daemon/agent-home.js +319 -0
  135. package/dist/heart/daemon/agent-service.js +542 -0
  136. package/dist/heart/daemon/agentic-repair.js +547 -0
  137. package/dist/heart/daemon/await-private-wake.js +59 -0
  138. package/dist/heart/daemon/bluebubbles-cli-integration.js +71 -0
  139. package/dist/heart/daemon/bluebubbles-health-diagnostics.js +122 -0
  140. package/dist/heart/daemon/bluebubbles-host-protocol.js +433 -0
  141. package/dist/heart/daemon/bluebubbles-host.js +273 -0
  142. package/dist/heart/daemon/boot-sync-probe.js +197 -0
  143. package/dist/heart/daemon/cadence.js +180 -0
  144. package/dist/heart/daemon/cli-arg-redaction.js +26 -0
  145. package/dist/heart/daemon/cli-defaults.js +876 -0
  146. package/dist/heart/daemon/cli-desk.js +322 -0
  147. package/dist/heart/daemon/cli-exec.js +9199 -0
  148. package/dist/heart/daemon/cli-help.js +735 -0
  149. package/dist/heart/daemon/cli-parse.js +3211 -0
  150. package/dist/heart/daemon/cli-render-doctor.js +57 -0
  151. package/dist/heart/daemon/cli-render.js +819 -0
  152. package/dist/heart/daemon/cli-types.js +8 -0
  153. package/dist/heart/daemon/connect-bay.js +323 -0
  154. package/dist/heart/daemon/container-credential-bootstrap.js +273 -0
  155. package/dist/heart/daemon/container-healthcheck.js +112 -0
  156. package/dist/heart/daemon/container-runtime.js +97 -0
  157. package/dist/heart/daemon/container-spec-auditor-main.js +171 -0
  158. package/dist/heart/daemon/container-spec-auditor.js +235 -0
  159. package/dist/heart/daemon/daemon-bootstrap-startup.js +115 -0
  160. package/dist/heart/daemon/daemon-cli.js +29 -2350
  161. package/dist/heart/daemon/daemon-entry.js +953 -11
  162. package/dist/heart/daemon/daemon-health.js +186 -0
  163. package/dist/heart/daemon/daemon-rollup.js +57 -0
  164. package/dist/heart/daemon/daemon-runtime-sync.js +88 -13
  165. package/dist/heart/daemon/daemon-tombstone.js +236 -0
  166. package/dist/heart/daemon/daemon.js +2084 -105
  167. package/dist/heart/daemon/dns-workflow.js +394 -0
  168. package/dist/heart/daemon/doctor-types.js +8 -0
  169. package/dist/heart/daemon/doctor.js +1766 -0
  170. package/dist/heart/daemon/freshness.js +345 -0
  171. package/dist/heart/daemon/habit-private-wake.js +61 -0
  172. package/dist/heart/daemon/health-monitor.js +154 -11
  173. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  174. package/dist/heart/daemon/hooks/bundle-meta.js +135 -1
  175. package/dist/heart/daemon/http-health-probe.js +80 -0
  176. package/dist/heart/daemon/human-command-screens.js +234 -0
  177. package/dist/heart/daemon/human-readiness.js +114 -0
  178. package/dist/heart/daemon/inner-status.js +78 -0
  179. package/dist/heart/daemon/interactive-repair.js +394 -0
  180. package/dist/heart/daemon/launchd.js +37 -8
  181. package/dist/heart/daemon/log-tailer.js +79 -10
  182. package/dist/heart/daemon/logs-prune.js +132 -0
  183. package/dist/heart/daemon/mcp-canary.js +524 -0
  184. package/dist/heart/daemon/message-router.js +65 -5
  185. package/dist/heart/daemon/migrate-to-desk.js +848 -0
  186. package/dist/heart/daemon/os-cron-deps.js +135 -0
  187. package/dist/heart/daemon/os-cron.js +22 -14
  188. package/dist/heart/daemon/ouro-bot-entry.js +6 -3
  189. package/dist/heart/daemon/ouro-entry.js +5 -2
  190. package/dist/heart/daemon/plugin-cli.js +432 -0
  191. package/dist/heart/daemon/process-manager.js +760 -61
  192. package/dist/heart/daemon/provider-discovery.js +137 -0
  193. package/dist/heart/daemon/provider-ping-progress.js +83 -0
  194. package/dist/heart/daemon/prunable-bundle.js +144 -0
  195. package/dist/heart/daemon/pulse.js +511 -0
  196. package/dist/heart/daemon/readiness-repair.js +365 -0
  197. package/dist/heart/daemon/run-hooks.js +2 -0
  198. package/dist/heart/daemon/runtime-logging.js +47 -14
  199. package/dist/heart/daemon/runtime-metadata.js +2 -30
  200. package/dist/heart/daemon/runtime-mode.js +13 -2
  201. package/dist/heart/daemon/safe-mode.js +161 -0
  202. package/dist/heart/daemon/sanctuary-acceptance-adapter.js +2654 -0
  203. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1830 -0
  204. package/dist/heart/daemon/sanctuary-acceptance-marker.js +267 -0
  205. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +955 -0
  206. package/dist/heart/daemon/sanctuary-authority-codec.js +86 -0
  207. package/dist/heart/daemon/sanctuary-authority-epoch.js +281 -0
  208. package/dist/heart/daemon/sanctuary-authority-installation.js +161 -0
  209. package/dist/heart/daemon/sanctuary-authority-ledger.js +241 -0
  210. package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +1267 -0
  211. package/dist/heart/daemon/sanctuary-authority-vault-migration.js +79 -0
  212. package/dist/heart/daemon/sanctuary-bundle-migration.js +730 -0
  213. package/dist/heart/daemon/sanctuary-host-authority.js +1008 -0
  214. package/dist/heart/daemon/sanctuary-host-detached-supervisor.js +493 -0
  215. package/dist/heart/daemon/sanctuary-host-executor.js +670 -0
  216. package/dist/heart/daemon/sanctuary-host-linux-kernel.js +334 -0
  217. package/dist/heart/daemon/sanctuary-host-supervisor-entry.js +207 -0
  218. package/dist/heart/daemon/sanctuary-host-supervisor.js +95 -0
  219. package/dist/heart/daemon/sanctuary-package-management.js +103 -0
  220. package/dist/heart/daemon/sanctuary-scheduler-liveness.js +227 -0
  221. package/dist/heart/daemon/sanctuary-scheduler-origin.js +132 -0
  222. package/dist/heart/daemon/sanctuary-telegram-authority-entry.js +475 -0
  223. package/dist/heart/daemon/sanctuary-telegram-authority-gateway.js +585 -0
  224. package/dist/heart/daemon/sanctuary-telegram-authority-service.js +676 -0
  225. package/dist/heart/daemon/sense-manager.js +733 -48
  226. package/dist/heart/daemon/session-id-resolver.js +131 -0
  227. package/dist/heart/daemon/skill-management-installer.js +1 -1
  228. package/dist/heart/daemon/socket-client.js +180 -12
  229. package/dist/heart/daemon/stale-bundle-prune.js +113 -0
  230. package/dist/heart/daemon/startup-tui.js +339 -0
  231. package/dist/heart/daemon/supercronic-supervisor.js +282 -0
  232. package/dist/heart/daemon/task-scheduler.js +117 -39
  233. package/dist/heart/daemon/terminal-ui.js +503 -0
  234. package/dist/heart/daemon/thoughts.js +138 -54
  235. package/dist/heart/daemon/up-progress.js +366 -0
  236. package/dist/heart/daemon/vault-items.js +56 -0
  237. package/dist/heart/delegation.js +1 -4
  238. package/dist/heart/external-events/router.js +1516 -0
  239. package/dist/heart/failure-reports.js +336 -0
  240. package/dist/heart/file-lock.js +109 -0
  241. package/dist/heart/frontend-approval-runtime.js +506 -0
  242. package/dist/heart/frontend-journal.js +215 -0
  243. package/dist/heart/frontend-session-service.js +237 -0
  244. package/dist/heart/frontend-socket-client.js +164 -0
  245. package/dist/heart/frontend-socket.js +400 -0
  246. package/dist/heart/habits/habit-cancel.js +810 -0
  247. package/dist/heart/habits/habit-lifecycle.js +1327 -0
  248. package/dist/heart/habits/habit-migration.js +194 -0
  249. package/dist/heart/habits/habit-parser.js +278 -0
  250. package/dist/heart/habits/habit-runtime-state.js +93 -0
  251. package/dist/heart/habits/habit-scheduler.js +653 -0
  252. package/dist/heart/habits/habit-session-summary.js +318 -0
  253. package/dist/heart/habits/habit-session.js +636 -0
  254. package/dist/heart/{daemon → hatch}/hatch-flow.js +55 -62
  255. package/dist/heart/{daemon → hatch}/hatch-specialist.js +6 -8
  256. package/dist/heart/{daemon → hatch}/specialist-prompt.js +12 -9
  257. package/dist/heart/{daemon → hatch}/specialist-tools.js +53 -18
  258. package/dist/heart/identity.js +230 -59
  259. package/dist/heart/kept-notes.js +289 -0
  260. package/dist/heart/kicks.js +1 -1
  261. package/dist/heart/machine-identity.js +175 -0
  262. package/dist/heart/mail-import-discovery.js +356 -0
  263. package/dist/heart/mailbox/mailbox-http-hooks.js +98 -0
  264. package/dist/heart/mailbox/mailbox-http-response.js +7 -0
  265. package/dist/heart/mailbox/mailbox-http-routes.js +398 -0
  266. package/dist/heart/mailbox/mailbox-http-static.js +103 -0
  267. package/dist/heart/mailbox/mailbox-http-transport.js +272 -0
  268. package/dist/heart/mailbox/mailbox-http.js +99 -0
  269. package/dist/heart/mailbox/mailbox-read.js +39 -0
  270. package/dist/heart/mailbox/mailbox-types.js +27 -0
  271. package/dist/heart/mailbox/mailbox-view.js +197 -0
  272. package/dist/heart/mailbox/readers/agent-machine.js +428 -0
  273. package/dist/heart/mailbox/readers/continuity-readers.js +329 -0
  274. package/dist/heart/mailbox/readers/mail.js +375 -0
  275. package/dist/heart/mailbox/readers/runtime-readers.js +823 -0
  276. package/dist/heart/mailbox/readers/sessions.js +231 -0
  277. package/dist/heart/mailbox/readers/shared.js +111 -0
  278. package/dist/heart/mcp/mcp-server.js +696 -0
  279. package/dist/heart/migrate-config.js +100 -0
  280. package/dist/heart/model-capabilities.js +83 -16
  281. package/dist/heart/orientation-frame.js +345 -0
  282. package/dist/heart/platform.js +81 -0
  283. package/dist/heart/primitives.js +1 -1
  284. package/dist/heart/private-runtime/decision-renderer.js +158 -0
  285. package/dist/heart/private-runtime/index.js +18 -0
  286. package/dist/heart/private-runtime/ledger.js +221 -0
  287. package/dist/heart/private-runtime/policy.js +260 -0
  288. package/dist/heart/private-runtime/types.js +2 -0
  289. package/dist/heart/progress-story.js +1 -1
  290. package/dist/heart/provider-attempt.js +137 -0
  291. package/dist/heart/provider-binding-resolver.js +273 -0
  292. package/dist/heart/provider-credentials.js +449 -0
  293. package/dist/heart/provider-failover.js +311 -0
  294. package/dist/heart/provider-models.js +96 -0
  295. package/dist/heart/provider-ping.js +265 -0
  296. package/dist/heart/provider-readiness-cache.js +119 -0
  297. package/dist/heart/provider-visibility.js +188 -0
  298. package/dist/heart/providers/anthropic-token.js +131 -0
  299. package/dist/heart/providers/anthropic.js +173 -60
  300. package/dist/heart/providers/azure.js +23 -11
  301. package/dist/heart/providers/error-classification.js +127 -0
  302. package/dist/heart/providers/github-copilot.js +38 -39
  303. package/dist/heart/providers/minimax-vlm.js +189 -0
  304. package/dist/heart/providers/minimax.js +26 -8
  305. package/dist/heart/providers/openai-codex-token.js +349 -0
  306. package/dist/heart/providers/openai-codex.js +55 -40
  307. package/dist/heart/providers/openai-compatible.js +177 -0
  308. package/dist/heart/reply-style.js +52 -0
  309. package/dist/heart/run-ledger.js +247 -0
  310. package/dist/heart/runtime-capability-check.js +170 -0
  311. package/dist/heart/runtime-credentials.js +607 -0
  312. package/dist/heart/runtime-cwd.js +87 -0
  313. package/dist/heart/sense-truth.js +24 -4
  314. package/dist/heart/session-activity.js +92 -24
  315. package/dist/heart/session-events.js +1504 -0
  316. package/dist/heart/session-playback-cli-main.js +5 -0
  317. package/dist/heart/session-playback-cli.js +36 -0
  318. package/dist/heart/session-playback.js +231 -0
  319. package/dist/heart/session-redaction-repair-cli-main.js +31 -0
  320. package/dist/heart/session-redaction-repair.js +552 -0
  321. package/dist/heart/session-stats-cli-main.js +5 -0
  322. package/dist/heart/session-stats.js +183 -0
  323. package/dist/heart/session-transcript.js +133 -0
  324. package/dist/heart/shelf-claims.js +78 -0
  325. package/dist/heart/shepherd-config.js +113 -0
  326. package/dist/heart/source-grounding.js +297 -0
  327. package/dist/heart/start-of-turn-packet.js +372 -0
  328. package/dist/heart/steward-policy.js +656 -0
  329. package/dist/heart/streaming.js +609 -207
  330. package/dist/heart/structured-output.js +196 -0
  331. package/dist/heart/sync-classification.js +176 -0
  332. package/dist/heart/sync-health.js +152 -0
  333. package/dist/heart/sync.js +726 -0
  334. package/dist/heart/target-resolution.js +11 -7
  335. package/dist/heart/tempo.js +93 -0
  336. package/dist/heart/temporal-view.js +41 -0
  337. package/dist/heart/timeouts.js +101 -0
  338. package/dist/heart/tool-activity-callbacks.js +59 -0
  339. package/dist/heart/tool-approval.js +382 -0
  340. package/dist/heart/tool-description.js +155 -0
  341. package/dist/heart/tool-friction.js +55 -0
  342. package/dist/heart/tool-loop.js +206 -0
  343. package/dist/heart/turn-context.js +496 -0
  344. package/dist/heart/turn-execution-lease.js +30 -0
  345. package/dist/heart/{daemon → versioning}/ouro-bot-global-installer.js +6 -5
  346. package/dist/heart/{daemon → versioning}/ouro-bot-wrapper.js +3 -2
  347. package/dist/heart/versioning/ouro-path-installer.js +432 -0
  348. package/dist/heart/versioning/ouro-recovery-launcher.js +76 -0
  349. package/dist/heart/versioning/ouro-version-manager.js +432 -0
  350. package/dist/heart/versioning/release-sources.js +173 -0
  351. package/dist/heart/versioning/self-update-defaults.js +294 -0
  352. package/dist/heart/versioning/self-update.js +196 -0
  353. package/dist/heart/{daemon → versioning}/staged-restart.js +40 -8
  354. package/dist/heart/{daemon → versioning}/update-checker.js +6 -1
  355. package/dist/heart/versioning/update-hooks.js +154 -0
  356. package/dist/heart/versioning/update-state.js +126 -0
  357. package/dist/heart/versioning/updater-launchd.js +150 -0
  358. package/dist/heart/versioning/version-intent.js +114 -0
  359. package/dist/heart/work-card.js +380 -0
  360. package/dist/mailbox-ui/assets/index-Cyv4mw3Y.js +1 -0
  361. package/dist/mailbox-ui/assets/index-Du_9G9WO.css +1 -0
  362. package/dist/mailbox-ui/assets/vendor-CcN1XpQ9.js +61 -0
  363. package/dist/mailbox-ui/index.html +16 -0
  364. package/dist/mailroom/attention.js +167 -0
  365. package/dist/mailroom/autonomy.js +209 -0
  366. package/dist/mailroom/blob-store.js +889 -0
  367. package/dist/mailroom/body-cache.js +61 -0
  368. package/dist/mailroom/cache-sync-cli.js +58 -0
  369. package/dist/mailroom/core.js +803 -0
  370. package/dist/mailroom/entry.js +160 -0
  371. package/dist/mailroom/file-store.js +568 -0
  372. package/dist/mailroom/hosted-cache-sync.js +384 -0
  373. package/dist/mailroom/mbox-import.js +393 -0
  374. package/dist/mailroom/migration.js +164 -0
  375. package/dist/mailroom/outbound.js +380 -0
  376. package/dist/mailroom/policy.js +263 -0
  377. package/dist/mailroom/reader.js +292 -0
  378. package/dist/mailroom/search-cache.js +513 -0
  379. package/dist/mailroom/search-relevance.js +319 -0
  380. package/dist/mailroom/smtp-ingress.js +176 -0
  381. package/dist/mailroom/source-state.js +176 -0
  382. package/dist/mailroom/thread.js +109 -0
  383. package/dist/mailroom/travel-extract.js +89 -0
  384. package/dist/mind/bundle-manifest.js +24 -2
  385. package/dist/mind/context.js +307 -139
  386. package/dist/mind/desk-section.js +362 -0
  387. package/dist/mind/diary-integrity.js +60 -0
  388. package/dist/mind/{memory.js → diary.js} +68 -77
  389. package/dist/mind/embedding-provider.js +60 -0
  390. package/dist/mind/file-state.js +179 -0
  391. package/dist/mind/friend-link-codes.js +209 -0
  392. package/dist/mind/friend-merge.js +330 -0
  393. package/dist/mind/{associative-recall.js → note-search.js} +47 -58
  394. package/dist/mind/obligation-steering.js +199 -3
  395. package/dist/mind/pending.js +36 -10
  396. package/dist/mind/prompt-budget.js +479 -0
  397. package/dist/mind/prompt-refresh.js +3 -2
  398. package/dist/mind/prompt.js +1085 -198
  399. package/dist/mind/provenance-trust.js +26 -0
  400. package/dist/mind/record-paths.js +312 -0
  401. package/dist/mind/scrutiny.js +173 -0
  402. package/dist/mind/session-transaction.js +564 -0
  403. package/dist/nerves/cli-logging.js +7 -1
  404. package/dist/nerves/coverage/audit-rules.js +15 -6
  405. package/dist/nerves/coverage/audit.js +28 -2
  406. package/dist/nerves/coverage/cli.js +1 -1
  407. package/dist/nerves/coverage/contract.js +5 -5
  408. package/dist/nerves/coverage/file-completeness.js +139 -5
  409. package/dist/nerves/coverage/run-artifacts.js +55 -35
  410. package/dist/nerves/coverage/source-scanner.js +4 -4
  411. package/dist/nerves/event-buffer.js +111 -0
  412. package/dist/nerves/index.js +293 -9
  413. package/dist/nerves/observation.js +20 -0
  414. package/dist/nerves/redact.js +79 -0
  415. package/dist/nerves/review/cli-main.js +5 -0
  416. package/dist/nerves/review/cli.js +156 -0
  417. package/dist/nerves/review/core.js +159 -0
  418. package/dist/nerves/runtime.js +17 -1
  419. package/dist/repertoire/ado-client.js +15 -56
  420. package/dist/repertoire/ado-semantic.js +16 -10
  421. package/dist/repertoire/api-client.js +97 -0
  422. package/dist/repertoire/bitwarden-store.js +1201 -0
  423. package/dist/repertoire/bundle-templates.js +71 -0
  424. package/dist/repertoire/bw-installer.js +231 -0
  425. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  426. package/dist/repertoire/coding/context-pack.js +331 -0
  427. package/dist/repertoire/coding/feedback.js +174 -32
  428. package/dist/repertoire/coding/index.js +15 -3
  429. package/dist/repertoire/coding/manager.js +159 -10
  430. package/dist/repertoire/coding/spawner.js +40 -11
  431. package/dist/repertoire/coding/tools.js +182 -18
  432. package/dist/repertoire/coding/workbench-client.js +272 -0
  433. package/dist/repertoire/coding/workbench-manager.js +346 -0
  434. package/dist/repertoire/commerce-errors.js +109 -0
  435. package/dist/repertoire/commerce-self-test.js +156 -0
  436. package/dist/repertoire/credential-access.js +183 -0
  437. package/dist/repertoire/desk/classifier.js +362 -0
  438. package/dist/repertoire/duffel-client.js +185 -0
  439. package/dist/repertoire/github-client.js +14 -55
  440. package/dist/repertoire/graph-client.js +11 -52
  441. package/dist/repertoire/guardrails.js +968 -20
  442. package/dist/repertoire/house-sweep.js +469 -0
  443. package/dist/repertoire/mcp-client.js +58 -5
  444. package/dist/repertoire/mcp-manager.js +427 -139
  445. package/dist/repertoire/mcp-tools.js +132 -0
  446. package/dist/repertoire/mcp-write-guard.js +46 -0
  447. package/dist/repertoire/plugin-mcp.js +175 -0
  448. package/dist/repertoire/plugins.js +253 -0
  449. package/dist/repertoire/relationship-authorization.js +322 -0
  450. package/dist/repertoire/sanctuary-container-policy.js +46 -0
  451. package/dist/repertoire/shell-sessions.js +134 -0
  452. package/dist/repertoire/skills.js +48 -4
  453. package/dist/repertoire/stripe-client.js +131 -0
  454. package/dist/repertoire/tool-arguments.js +182 -0
  455. package/dist/repertoire/tool-results.js +29 -0
  456. package/dist/repertoire/tools-a2a.js +699 -0
  457. package/dist/repertoire/tools-attachments.js +322 -0
  458. package/dist/repertoire/tools-awaiting.js +872 -0
  459. package/dist/repertoire/tools-base.js +83 -1168
  460. package/dist/repertoire/tools-bluebubbles.js +2 -0
  461. package/dist/repertoire/tools-bridge.js +144 -0
  462. package/dist/repertoire/tools-bundle.js +995 -0
  463. package/dist/repertoire/tools-commerce.js +253 -0
  464. package/dist/repertoire/tools-config.js +186 -0
  465. package/dist/repertoire/tools-continuity.js +575 -0
  466. package/dist/repertoire/tools-credential.js +383 -0
  467. package/dist/repertoire/tools-escalation.js +84 -0
  468. package/dist/repertoire/tools-evolution.js +527 -0
  469. package/dist/repertoire/tools-files.js +344 -0
  470. package/dist/repertoire/tools-flight.js +290 -0
  471. package/dist/repertoire/tools-flow.js +119 -0
  472. package/dist/repertoire/tools-friend-link.js +57 -0
  473. package/dist/repertoire/tools-github.js +3 -8
  474. package/dist/repertoire/tools-habits.js +103 -0
  475. package/dist/repertoire/tools-house-care.js +174 -0
  476. package/dist/repertoire/tools-mail.js +1828 -0
  477. package/dist/repertoire/tools-notes.js +496 -0
  478. package/dist/repertoire/tools-obligations.js +182 -0
  479. package/dist/repertoire/tools-orientation.js +31 -0
  480. package/dist/repertoire/tools-record.js +482 -0
  481. package/dist/repertoire/tools-rsvp.js +139 -0
  482. package/dist/repertoire/tools-runtime.js +238 -0
  483. package/dist/repertoire/tools-sanctuary-host.js +202 -0
  484. package/dist/repertoire/tools-session.js +1133 -0
  485. package/dist/repertoire/tools-shell.js +149 -0
  486. package/dist/repertoire/tools-shepherd.js +262 -0
  487. package/dist/repertoire/tools-steward-policy.js +147 -0
  488. package/dist/repertoire/tools-stripe.js +224 -0
  489. package/dist/repertoire/tools-surface.js +369 -0
  490. package/dist/repertoire/tools-teams.js +12 -39
  491. package/dist/repertoire/tools-telegram-contacts.js +37 -0
  492. package/dist/repertoire/tools-travel.js +125 -0
  493. package/dist/repertoire/tools-trip.js +982 -0
  494. package/dist/repertoire/tools-unraid.js +556 -0
  495. package/dist/repertoire/tools-user-profile.js +146 -0
  496. package/dist/repertoire/tools-vault.js +40 -0
  497. package/dist/repertoire/tools-voice-local.js +198 -0
  498. package/dist/repertoire/tools-voice.js +145 -0
  499. package/dist/repertoire/tools.js +514 -156
  500. package/dist/repertoire/travel-api-client.js +360 -0
  501. package/dist/repertoire/unraid-client.js +155 -0
  502. package/dist/repertoire/unraid-restart.js +339 -0
  503. package/dist/repertoire/user-profile.js +131 -0
  504. package/dist/repertoire/vault-setup.js +246 -0
  505. package/dist/repertoire/vault-unlock.js +686 -0
  506. package/dist/rsvp/aisleplanner-client.js +354 -0
  507. package/dist/rsvp/cli.js +936 -0
  508. package/dist/rsvp/config.js +327 -0
  509. package/dist/rsvp/cutover.js +433 -0
  510. package/dist/rsvp/diagnostics.js +232 -0
  511. package/dist/rsvp/diff-renderer.js +176 -0
  512. package/dist/rsvp/habit-policy.js +110 -0
  513. package/dist/rsvp/habit-stage.js +123 -0
  514. package/dist/rsvp/incident-bundle.js +97 -0
  515. package/dist/rsvp/migration.js +204 -0
  516. package/dist/rsvp/native-habit-runner.js +630 -0
  517. package/dist/rsvp/outbound-state.js +628 -0
  518. package/dist/rsvp/query.js +118 -0
  519. package/dist/rsvp/replay.js +233 -0
  520. package/dist/rsvp/snapshot.js +232 -0
  521. package/dist/rsvp/spend-ledger.js +175 -0
  522. package/dist/scripts/claude-code-hook.js +41 -0
  523. package/dist/scripts/claude-code-stop-hook.js +47 -0
  524. package/dist/senses/a2a-entry.js +132 -0
  525. package/dist/senses/acp-server.js +386 -0
  526. package/dist/senses/attention-queue.js +186 -0
  527. package/dist/senses/await-turn-message.js +65 -0
  528. package/dist/senses/bluebubbles/active-turns.js +216 -0
  529. package/dist/senses/bluebubbles/attachment-cache.js +53 -0
  530. package/dist/senses/bluebubbles/attachment-download.js +137 -0
  531. package/dist/senses/{bluebubbles-client.js → bluebubbles/client.js} +379 -41
  532. package/dist/senses/bluebubbles/context-packet.js +207 -0
  533. package/dist/senses/bluebubbles/context-smoke.js +144 -0
  534. package/dist/senses/bluebubbles/entry.js +90 -0
  535. package/dist/senses/bluebubbles/host-account-verifier.js +88 -0
  536. package/dist/senses/{bluebubbles-inbound-log.js → bluebubbles/inbound-log.js} +21 -3
  537. package/dist/senses/bluebubbles/index.js +3878 -0
  538. package/dist/senses/bluebubbles/latest-turn.js +311 -0
  539. package/dist/senses/{bluebubbles-media.js → bluebubbles/media.js} +121 -71
  540. package/dist/senses/{bluebubbles-model.js → bluebubbles/model.js} +159 -28
  541. package/dist/senses/{bluebubbles-mutation-log.js → bluebubbles/mutation-log.js} +24 -13
  542. package/dist/senses/bluebubbles/outbound-state.js +308 -0
  543. package/dist/senses/bluebubbles/processed-log.js +133 -0
  544. package/dist/senses/bluebubbles/reaction-policy.js +47 -0
  545. package/dist/senses/bluebubbles/replay.js +284 -0
  546. package/dist/senses/{bluebubbles-runtime-state.js → bluebubbles/runtime-state.js} +30 -2
  547. package/dist/senses/bluebubbles/semantic-receipts.js +1986 -0
  548. package/dist/senses/{bluebubbles-session-cleanup.js → bluebubbles/session-cleanup.js} +1 -1
  549. package/dist/senses/bluebubbles/webhook-registration.js +257 -0
  550. package/dist/senses/bluebubbles-meta-guard.js +39 -0
  551. package/dist/senses/cli/bracketed-paste.js +82 -0
  552. package/dist/senses/cli/image-paste.js +287 -0
  553. package/dist/senses/cli/image-ref-navigation.js +75 -0
  554. package/dist/senses/cli/ink-app.js +156 -0
  555. package/dist/senses/cli/inline-diff.js +64 -0
  556. package/dist/senses/cli/input-keys.js +174 -0
  557. package/dist/senses/cli/kill-ring.js +86 -0
  558. package/dist/senses/cli/message-list.js +51 -0
  559. package/dist/senses/cli/ouro-tui.js +607 -0
  560. package/dist/senses/cli/spinner-imperative.js +135 -0
  561. package/dist/senses/cli/spinner.js +101 -0
  562. package/dist/senses/cli/status-line.js +60 -0
  563. package/dist/senses/cli/streaming-markdown.js +526 -0
  564. package/dist/senses/cli/tool-display.js +85 -0
  565. package/dist/senses/cli/tool-render.js +85 -0
  566. package/dist/senses/cli/tui-store.js +241 -0
  567. package/dist/senses/cli/virtual-list.js +35 -0
  568. package/dist/senses/cli-entry.js +60 -8
  569. package/dist/senses/cli.js +601 -304
  570. package/dist/senses/commands.js +66 -3
  571. package/dist/senses/context-packet-ledger.js +371 -0
  572. package/dist/senses/context-packets.js +154 -0
  573. package/dist/senses/habit-lifecycle-guard.js +24 -0
  574. package/dist/senses/habit-turn-message.js +160 -0
  575. package/dist/senses/ingress-evidence.js +2 -0
  576. package/dist/senses/mail-entry.js +66 -0
  577. package/dist/senses/mail.js +524 -0
  578. package/dist/senses/pipeline.js +1158 -204
  579. package/dist/senses/private-runtime-worker.js +859 -0
  580. package/dist/senses/private-runtime.js +1627 -0
  581. package/dist/senses/proactive-content-guard.js +51 -0
  582. package/dist/senses/root-host-approval-port.js +345 -0
  583. package/dist/senses/root-host-approval-runtime.js +393 -0
  584. package/dist/senses/sanctuary-authority-resident.js +102 -0
  585. package/dist/senses/sanctuary-download-credit-presentation.js +31 -0
  586. package/dist/senses/sanctuary-full-visibility-contract.js +302 -0
  587. package/dist/senses/sanctuary-grounding.js +118 -0
  588. package/dist/senses/sanctuary-health-acceptance-probe-entry.js +12 -0
  589. package/dist/senses/sanctuary-health-acceptance-probe.js +774 -0
  590. package/dist/senses/sanctuary-health-runner.js +104 -0
  591. package/dist/senses/sanctuary-health.js +422 -0
  592. package/dist/senses/sanctuary-install-state-contract.js +169 -0
  593. package/dist/senses/sanctuary-interactive-control.js +410 -0
  594. package/dist/senses/sanctuary-media-catalog-contract.js +259 -0
  595. package/dist/senses/sanctuary-media-optimization.js +514 -0
  596. package/dist/senses/sanctuary-runtime.js +369 -0
  597. package/dist/senses/sanctuary-sab.js +85 -0
  598. package/dist/senses/sanctuary-storage-optimization-contract.js +32 -0
  599. package/dist/senses/shared-turn.js +793 -0
  600. package/dist/senses/shepherd/client.js +196 -0
  601. package/dist/senses/shepherd/cmux.js +344 -0
  602. package/dist/senses/shepherd/herdr.js +315 -0
  603. package/dist/senses/shepherd/host.js +14 -0
  604. package/dist/senses/shepherd/judge.js +137 -0
  605. package/dist/senses/shepherd/owner-notice.js +245 -0
  606. package/dist/senses/shepherd/redact.js +66 -0
  607. package/dist/senses/shepherd/returns.js +79 -0
  608. package/dist/senses/shepherd/sense.js +309 -0
  609. package/dist/senses/shepherd-entry.js +78 -0
  610. package/dist/senses/surface-tool.js +109 -0
  611. package/dist/senses/teams-entry.js +68 -8
  612. package/dist/senses/teams.js +598 -233
  613. package/dist/senses/telegram-admission.js +718 -0
  614. package/dist/senses/telegram-approval-runtime.js +793 -0
  615. package/dist/senses/telegram-attachments.js +186 -0
  616. package/dist/senses/telegram-audit-ledger.js +220 -0
  617. package/dist/senses/telegram-authority-transport.js +231 -0
  618. package/dist/senses/telegram-client.js +1749 -0
  619. package/dist/senses/telegram-effect-adapter.js +900 -0
  620. package/dist/senses/telegram-entry.js +83 -0
  621. package/dist/senses/telegram-handoff.js +80 -0
  622. package/dist/senses/telegram.js +2405 -0
  623. package/dist/senses/trust-gate.js +104 -9
  624. package/dist/senses/voice/audio-playback.js +237 -0
  625. package/dist/senses/voice/audio-routing.js +119 -0
  626. package/dist/senses/voice/call-auth.js +250 -0
  627. package/dist/senses/voice/elevenlabs.js +202 -0
  628. package/dist/senses/voice/floor-control.js +431 -0
  629. package/dist/senses/voice/floor-controller.js +115 -0
  630. package/dist/senses/voice/golden-path.js +116 -0
  631. package/dist/senses/voice/index.js +29 -0
  632. package/dist/senses/voice/local-audio-control.js +344 -0
  633. package/dist/senses/voice/local-audio-devices.js +516 -0
  634. package/dist/senses/voice/local-audio-disclosure.js +174 -0
  635. package/dist/senses/voice/local-audio-identity.js +103 -0
  636. package/dist/senses/voice/local-audio-join.js +325 -0
  637. package/dist/senses/voice/local-audio-mute.js +199 -0
  638. package/dist/senses/voice/local-audio-notify.js +121 -0
  639. package/dist/senses/voice/local-audio-prompts.js +116 -0
  640. package/dist/senses/voice/local-audio-transport.js +427 -0
  641. package/dist/senses/voice/local-audio.js +281 -0
  642. package/dist/senses/voice/local-media-stream.js +145 -0
  643. package/dist/senses/voice/meeting.js +113 -0
  644. package/dist/senses/voice/outbound.js +190 -0
  645. package/dist/senses/voice/phone.js +33 -0
  646. package/dist/senses/voice/playback.js +139 -0
  647. package/dist/senses/voice/process-guards.js +51 -0
  648. package/dist/senses/voice/realtime-eval.js +496 -0
  649. package/dist/senses/voice/realtime-tool-schema.js +195 -0
  650. package/dist/senses/voice/realtime-trace.js +531 -0
  651. package/dist/senses/voice/transcript.js +70 -0
  652. package/dist/senses/voice/turn.js +191 -0
  653. package/dist/senses/voice/twilio-phone-runtime.js +836 -0
  654. package/dist/senses/voice/twilio-phone.js +5454 -0
  655. package/dist/senses/voice/types.js +2 -0
  656. package/dist/senses/voice/whisper.js +161 -0
  657. package/dist/senses/voice-entry.js +83 -0
  658. package/dist/senses/voice-realtime-eval-command.js +99 -0
  659. package/dist/senses/voice-realtime-eval-entry.js +21 -0
  660. package/dist/senses/voice-twilio-entry.js +87 -0
  661. package/dist/trips/core.js +138 -0
  662. package/dist/trips/store.js +265 -0
  663. package/dist/util/frontmatter.js +69 -0
  664. package/npm-shrinkwrap.json +8100 -0
  665. package/package.json +74 -13
  666. package/skills/agent-commerce.md +113 -0
  667. package/skills/browser-navigation.md +117 -0
  668. package/skills/commerce-setup-guide.md +116 -0
  669. package/skills/commerce-setup.md +84 -0
  670. package/skills/configure-dev-tools.md +99 -0
  671. package/skills/travel-planning.md +138 -0
  672. package/dist/heart/daemon/auth-flow.js +0 -430
  673. package/dist/heart/daemon/ouro-path-installer.js +0 -260
  674. package/dist/heart/daemon/ouro-version-manager.js +0 -171
  675. package/dist/heart/daemon/update-hooks.js +0 -138
  676. package/dist/heart/obligations.js +0 -191
  677. package/dist/heart/safe-workspace.js +0 -362
  678. package/dist/heart/session-recall.js +0 -116
  679. package/dist/mind/friends/channel.js +0 -84
  680. package/dist/mind/friends/group-context.js +0 -144
  681. package/dist/mind/friends/resolver.js +0 -84
  682. package/dist/mind/friends/store-file.js +0 -190
  683. package/dist/mind/friends/store.js +0 -4
  684. package/dist/mind/friends/tokens.js +0 -26
  685. package/dist/mind/friends/trust-explanation.js +0 -74
  686. package/dist/mind/friends/types.js +0 -29
  687. package/dist/repertoire/tasks/board.js +0 -134
  688. package/dist/repertoire/tasks/index.js +0 -224
  689. package/dist/repertoire/tasks/lifecycle.js +0 -80
  690. package/dist/repertoire/tasks/middleware.js +0 -65
  691. package/dist/repertoire/tasks/parser.js +0 -173
  692. package/dist/repertoire/tasks/scanner.js +0 -132
  693. package/dist/repertoire/tasks/transitions.js +0 -144
  694. package/dist/senses/bluebubbles-entry.js +0 -13
  695. package/dist/senses/bluebubbles.js +0 -1181
  696. package/dist/senses/debug-activity.js +0 -154
  697. package/dist/senses/inner-dialog-worker.js +0 -90
  698. package/dist/senses/inner-dialog.js +0 -525
  699. package/subagents/README.md +0 -7
  700. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
  701. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
  702. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
  703. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
  704. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
  705. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +0 -0
  706. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
  707. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
  708. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
  709. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
  710. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
  711. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
  712. /package/dist/{repertoire/tasks/types.js → heart/attachments/sources/adapter.js} +0 -0
  713. /package/dist/heart/{daemon → hatch}/hatch-animation.js +0 -0
  714. /package/dist/heart/{daemon → hatch}/specialist-orchestrator.js +0 -0
  715. /package/dist/heart/{daemon → versioning}/ouro-uti.js +0 -0
  716. /package/dist/heart/{daemon → versioning}/wrapper-publish-guard.js +0 -0
@@ -0,0 +1,2997 @@
1
+ Mendelow Cloud Butler operator runbook
2
+
3
+ The production container is ouro-butler. It runs as UID/GID 10001, publishes no ports, uses host networking only so its loopback-only Unraid GraphQL client can reach 127.0.0.1, mounts the runtime and sanctuary.ouro bundle read-write, the bundle psyche folder read-only over it so the Butler can neither edit nor replace its psyche, plus the privileged event spool read-only, and uses restart policy unless-stopped. The canonical-gateway image appends /run/ouro-authority -> /run/ouro-authority, readonly, rprivate, and the psyche mount (<bundle>/psyche -> /home/ouro/AgentBundles/sanctuary.ouro/psyche, readonly, rprivate) and the operator trust mount (/mnt/user/appdata/ouro-butler/trust/sanctuary -> /etc/ouro/trust/sanctuary, readonly, rprivate), a root-owned directory outside the bundle that holds the grants the Butler honours. The pinned alpha.816 predecessor has only the first three mounts. Root token, issuer, package and authority state are never mounted into the resident.
4
+
5
+ Root authority lifecycle
6
+
7
+ The existing DockerMan transaction owns authority-install, authority-activate, authority-retire and authority-restore. A complete exact-image package, including dist and production dependencies, must remain at its package-relative paths; extracting only the old five deployment assets cannot execute this lifecycle. The reviewed root-only inputs are /mnt/user/appdata/ouro-authority/incoming-package, package-manifest.json, request.json and incoming-token. They are not a new resident credential policy. The human rotates the existing bot token directly into root custody; the root owner proves the previous token returns 401 and the fresh token belongs to the same numeric bot before continuing.
8
+
9
+ First rollout of the psyche mount: the live container before this release has four mounts, but the canonical-gateway contract now requires six (the psyche folder read-only over the bundle and the operator trust directory). assert_update_source audits the live source container against its image's contract, so on that first update it fails with "effective mounts do not match the selected Sanctuary mount contract". The failure is expected and changes nothing. Recreate the live resident once by hand with the six-mount create (the canonical-gateway line in create_sanctuary_container, including the psyche and trust mounts), leave it stopped, then rerun the update. Psyche files stay owned by uid 10001 at mode 0600 because the resident's boot check requires exactly that; the read-only mount, not ownership, keeps the resident from editing or replacing them.
10
+
11
+ Operator trust directory. Delegated-command and escalation grants live on the host in /mnt/user/appdata/ouro-butler/trust/sanctuary, owned by root:root, mode 0755, with every file in it root-owned and mode 0644, and the directory is outside the bundle so the upgrade's recursive chown cannot reach it. The container sees it read-only at /etc/ouro/trust/sanctuary; the Butler honours a grant only when the file and every directory above it up to / are real, root-owned and closed to group and other writes. Create it once as root before the first create: `install -d -o root -g root -m 0755 /mnt/user/appdata/ouro-butler /mnt/user/appdata/ouro-butler/trust /mnt/user/appdata/ouro-butler/trust/sanctuary` (this also makes /mnt/user/appdata/ouro-butler itself root-owned 0755, which the replay gate needs for its host folder below; the agent and runtime folders inside it keep their own owners). Manage grants on the host as root with a one-off container that mounts the directory read-write (the resident's mount is read-only): `docker run --rm --user 0:0 --entrypoint node --pull=never --network none -v /mnt/user/appdata/ouro-butler/trust/sanctuary:/etc/ouro/trust/sanctuary -v /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro:/home/ouro/AgentBundles/sanctuary.ouro:ro <butler image> /opt/ouro/dist/heart/daemon/ouro-entry.js a2a delegated-commands grant --agent sanctuary --friend <friend id> --did <did:key> [--expires <ISO date>] [--source <text>]`, and likewise `a2a delegated-commands revoke|list` and `a2a escalation grant --friend <id> --did <did:key>|revoke|list`. Setting OURO_OPERATOR_TRUST_DIR replaces the trust root (/etc/ouro/trust) and keeps the per-agent folder, so the files sit in <dir>/<agent>/. Grant commands take an exclusive .lock file in the directory (the lock records the writer's pid and host; one older than two minutes is taken over only when that pid is no longer running on the same host, and one written on another host only after an hour; remove a stuck one by hand) and flush the directory after each rename. A one-off container cannot read the agent's own DID, so grant then compares --did with every friend record and refuses a DID a second record also carries, and prints a note saying so; revoke works by friend id even when the friend record is gone. Revoke never writes the friend record (it runs as root); when the record still carries a legacy delegationGrant it prints a REQUIRED FOLLOW-UP docker exec -u 10001 line that clears the legacy delegationGrant field as the resident user (friend records are never written as root). The replay gate grants its replay principal and escalation peer with an expiry equal to the replay window and removes them when it closes; the replay gate keeps a root-owned list of its replay peers in the trust directory (replay-identities.json), the Butler refuses any grant without an expiry for a friend on that list (the grant's source text and the bundle's own replay registry decide nothing), and the grant commands refuse a no-expiry grant, and any escalation grant, for a listed friend. Replay seeds and the provisioning record live outside the bundle, in the root-owned host folder /mnt/user/appdata/ouro-butler/replay-client (the gate's --client-dir overrides it), created by provision as root 0700 under a parent that is root-owned and closed to group and other writes (by default only that parent, /mnt/user/appdata/ouro-butler, is checked, because Unraid's shared /mnt/user/appdata is usually nobody:users 0777); the gate refuses a folder, a parent or a seed that root did not make, and never takes over one the Butler's user could have written. Only root-owned state may supply replay friend ids and DIDs: that folder and the trust directory's replay-identities.json. Nothing in the bundle's state/replay-client or state/replay does, and a provision.json found in the bundle is ignored (its friend ids are only printed, as unverified). A run copies the seeds into a root-only directory it makes inside the container with mktemp -d, drives the CLI as root from there, and deletes it when the window closes. An existing state/replay in the bundle that root does not own is moved aside as replay.untrusted-<time> and made fresh. One-time step on the upgrade that moves the replay seeds out of the bundle: run `node sanctuary-replay-gate.mjs provision --rotate-replay-identities` as root. Earlier replay seeds were written by the Butler's user and must be treated as known to it, so this mints three new seeds (root-owned 0600, in the host folder above), moves any old seed in that folder aside as *.retired-<time>, removes the retired peers' trusted grants, marks their friend records revoked, demotes them to stranger and drops their DID pin and A2A identity (the last two as the resident user, never as root), onboards the new peers and rewrites state/replay/identities.json from the root-owned list; the old friend ids stay in that registry so they stay blocked from owner-policy writes. The peers it retires are the ones the root-owned record and replay list name. The earlier bundle copy of provision.json is not trusted, so the first rotation prints the friend ids it names as unverified; for each one you have checked is a replay peer, add `--retire-friend <id>` (repeatable). Always pass the DID the holder really signs with (`ouro a2a identity --json` on the holder's side); the Butler refuses a message signed by any other DID. First rollout of the trust mount: the live container has five mounts, so the first update fails the six-mount audit exactly as the psyche rollout did; recreate the resident once by hand with the six-mount create and rerun the update.
12
+
13
+ During installation the resident is stopped, the root gateway is installed and proven, tokenless readiness runs, and authority-activate starts the exact six-mount target. On failure, authority-retire must finish before restoring bundle/config/mounts or starting the exact three-mount predecessor; authority-restore proves that rollback before the outer template journal is removed. An uncertain or interrupted handoff leaves autostart disabled and preserves both journals and root inputs for the same reviewed recovery, never a raw old-template start. Retired keys, policy, history and Jellyfin remain untouched. Re-enabling authority requires a new human token rotation and issuer epoch, not reuse of retired authority.
14
+
15
+ On installed cold boot, the original unless-stopped and DockerMan autostart remain unchanged: /boot/config/custom/ouro-authority/start.sh --boot waits for the array and responsive Docker, then starts the fenced root gateway. Docker may already have started the tokenless resident; it fails closed until the gateway is ready. There is no pre-Docker gate, whole-array gate, resident direct poller or resident token fallback.
16
+
17
+ Bounded Jellyfin stewardship
18
+
19
+ Do not start a weak predecessor after policy exists. Once live A-006 authority has been provisioned, repair by forward-fixing and installing another policy-aware image through the existing exact-image update procedure. Its transaction mechanics are unchanged; a stopped older image is not permission to resume weaker policy enforcement.
20
+
21
+ Packaged desired states and grants remain empty. Build and installation create no authority and do not restart Jellyfin. Package migration preserves the live policy, audit and action-receipt bytes, including on a repeated migration.
22
+
23
+ A standing restart requires the intersection of an applied owner-stated positive desired state, an applied owner-stated grant and a fresh authorized request or exact current health event. Values on, always_on and expected_on permit either request-driven or event-driven evaluation; on_demand permits current owner or household requests only. Active off, disabled, paused, intentionally_off or intentionally_paused states deny mutation and make an unchanged expected health condition quiet. Missing, expired, inferred, unaudited, conflicted or mismatched positive state cannot authorize a standing restart. Installation provenance is not owner-stated authority.
24
+
25
+ Provision only after the policy-aware image is healthy and its package and running-image identities agree. Ari must supply a fresh authenticated owner Telegram message after installation. This runbook, old messages, agent-written setup text and installation records are not that authorization. The approved setup terms are container:jellyfin=on with no desired-state expiry; grant key unraid.restart:jellyfin, action unraid.container.restart, target jellyfin, 2 attempts per 1800000 ms, verificationRequired=true, exclusions=[], provenance=stated, and grant expiry exactly 365 days after authorization.
26
+
27
+ Use the existing steward_policy_manage actions sequentially: set_desired_state -> read -> grant_routine_action -> read. Carry the current readback version into each write. The first write must be applied and read back before the grant; desired state alone cannot authorize action. The final read must show both exact keys and versions, the stated provenance, rate, verification requirement, exclusions and calculated expiry. The two writes must have two linked applied v2 rows in state/policy/policy-audit.ndjson. Do not edit policy files directly or fabricate the authorizing event.
28
+
29
+ Use steward_policy_manage read for current policy and state/policy/action-receipts.ndjson for reservation and outcome evidence. Durable reservations count toward the limit, not only successful restarts. A reservation or attempt is not proof of a successful effect: retain definite no-effect, failed, indeterminate and verified-success outcomes without replaying an uncertain attempt. Fresh relationship, request, policy, grant, reservation and exact target checks still apply immediately before mutation.
30
+
31
+ When an owner request has only a missing or expired grant and no active negative desired state, the existing one-time owner approval path remains available. A conflicting negative state requires clarification or a policy update, not an approval workaround. Household and event denials never create a self-approval card.
32
+
33
+ Never manufacture a Jellyfin outage to demonstrate the grant. Record a natural event or ordinary request if one occurs; otherwise report organic operation as unobserved, separately from installed and provisioned state.
34
+
35
+ Start/stop:
36
+ docker start ouro-butler
37
+ docker stop ouro-butler
38
+
39
+ Status and health:
40
+ docker inspect --format '{{.State.Status}} {{.State.Health.Status}}' ouro-butler
41
+ docker logs --tail 200 ouro-butler
42
+
43
+ Effective-spec audit helper:
44
+ Run Update, Backup, and Restore command sequences from a root shell with `set -eu`.
45
+ Before any sequence, define this helper. It captures one actual container
46
+ inspect and its reviewed image inspect without printing either, invokes the
47
+ packaged auditor from the explicit validated runner image argument, and
48
+ removes its mode-0600 inputs on success or shell exit:
49
+ audit_effective() {
50
+ (
51
+ AUDIT_CONTAINER=$1
52
+ AUDIT_EXPECTED_IMAGE=$2
53
+ AUDIT_RUNNER_IMAGE_ID=$3
54
+ AUDIT_MOUNT_CONTRACT=${4-}
55
+ AUDIT_EXPECTED_IMAGE_REFERENCE=${5-}
56
+ AUDIT_EXPECTED_ICON=${6-}
57
+ validate_exact_image_id "$AUDIT_EXPECTED_IMAGE" || return $?
58
+ validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID" || return $?
59
+ test "$AUDIT_RUNNER_IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d || return $?
60
+ case "$AUDIT_MOUNT_CONTRACT" in
61
+ canonical-pre-gateway|canonical-gateway)
62
+ test -n "$AUDIT_EXPECTED_IMAGE_REFERENCE" || return 1
63
+ test "$AUDIT_EXPECTED_ICON" = https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png || return 1
64
+ set -- --mount-contract "$AUDIT_MOUNT_CONTRACT" --expected-image-reference "$AUDIT_EXPECTED_IMAGE_REFERENCE" --expected-icon "$AUDIT_EXPECTED_ICON"
65
+ ;;
66
+ legacy-alpha742) set -- --mount-contract "$AUDIT_MOUNT_CONTRACT" ;;
67
+ *) return 1 ;;
68
+ esac
69
+ INSPECT_DIR=$(mktemp -d /mnt/user/appdata/ouro-butler/staging/inspect.XXXXXX) || return $?
70
+ trap 'rm -f -- "$INSPECT_DIR/container.json" "$INSPECT_DIR/image.json"; rmdir -- "$INSPECT_DIR"' EXIT || return $?
71
+ chmod 0700 "$INSPECT_DIR" || return $?
72
+ umask 077 || return $?
73
+ docker inspect "$AUDIT_CONTAINER" >"$INSPECT_DIR/container.json" || return $?
74
+ docker image inspect "$AUDIT_EXPECTED_IMAGE" >"$INSPECT_DIR/image.json" || return $?
75
+ chmod 0600 "$INSPECT_DIR/container.json" "$INSPECT_DIR/image.json" || return $?
76
+ docker run --rm --pull=never --network=none \
77
+ --user 0:0 --read-only --cap-drop=ALL --security-opt=no-new-privileges \
78
+ --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh \
79
+ --mount "type=bind,src=$INSPECT_DIR/container.json,dst=/audit/container.json,readonly" \
80
+ --mount "type=bind,src=$INSPECT_DIR/image.json,dst=/audit/image.json,readonly" \
81
+ "$AUDIT_RUNNER_IMAGE_ID" --inspect /audit/container.json --image-inspect /audit/image.json --expected-image "$AUDIT_EXPECTED_IMAGE" "$@" || return $?
82
+ rm -f -- "$INSPECT_DIR/container.json" "$INSPECT_DIR/image.json" || return $?
83
+ rmdir -- "$INSPECT_DIR" || return $?
84
+ trap - EXIT || return $?
85
+ )
86
+ }
87
+ inspect_registry_manifest_digest() {
88
+ (
89
+ REGISTRY_MANIFEST_REFERENCE=$1
90
+ REGISTRY_MANIFEST_PATH=$(mktemp /tmp/ouro-registry-manifest.XXXXXX) || return $?
91
+ trap 'rm -f -- "$REGISTRY_MANIFEST_PATH"' EXIT || return $?
92
+ docker buildx imagetools inspect "$REGISTRY_MANIFEST_REFERENCE" --raw >"$REGISTRY_MANIFEST_PATH" || return $?
93
+ REGISTRY_MANIFEST_SHA256=$(sha256sum -- "$REGISTRY_MANIFEST_PATH") || return $?
94
+ REGISTRY_MANIFEST_SHA256=${REGISTRY_MANIFEST_SHA256%% *}
95
+ printf 'sha256:%s\n' "$REGISTRY_MANIFEST_SHA256"
96
+ )
97
+ }
98
+ Define these helpers in the same root shell. Each profile change calls Unraid's
99
+ own root-local Docker manager backend, after proving exact durable identities.
100
+ A missing Butler entry is a no-op when disabling; this avoids the backend's
101
+ false-to-index-zero behavior. The helper preserves every non-Butler row byte
102
+ for byte and restores the complete captured file atomically on any failure:
103
+ butler_autostart_counts() {
104
+ (
105
+ AUTOSTART_FILE=/var/lib/docker/unraid-autostart
106
+ test -f "$AUTOSTART_FILE" || return $?
107
+ awk '
108
+ $1 == "ouro-butler" { production++ }
109
+ $1 == "ouro-butler-staging" { staging++ }
110
+ $1 == "ouro-butler-rollback" { rollback++ }
111
+ $1 == "ouro-butler-legacy-evidence" { legacy++ }
112
+ END { printf "%d %d %d %d", production + 0, staging + 0, rollback + 0, legacy + 0 }
113
+ ' "$AUTOSTART_FILE"
114
+ )
115
+ }
116
+ snapshot_nonbutler_autostart() {
117
+ awk '
118
+ $1 != "ouro-butler" && $1 != "ouro-butler-staging" && $1 != "ouro-butler-rollback" && $1 != "ouro-butler-legacy-evidence" { print }
119
+ ' /var/lib/docker/unraid-autostart
120
+ }
121
+ run_unraid_autostart_backend() {
122
+ (
123
+ AUTOSTART_ACTION=$1
124
+ AUTOSTART_CONTAINER=$2
125
+ AUTOSTART_VALUE=$3
126
+ case "$AUTOSTART_ACTION:$AUTOSTART_VALUE" in wait:0|autostart:true|autostart:false) ;; *) return 1 ;; esac
127
+ case "$AUTOSTART_CONTAINER" in
128
+ ouro-butler|ouro-butler-staging|ouro-butler-rollback|ouro-butler-legacy-evidence) ;;
129
+ *) return 1 ;;
130
+ esac
131
+ timeout -s KILL 20 /usr/bin/php -r '
132
+ [$action, $container, $value] = array_slice($argv, 1);
133
+ $allowed = ["ouro-butler", "ouro-butler-staging", "ouro-butler-rollback", "ouro-butler-legacy-evidence"];
134
+ if (!in_array($container, $allowed, true)) exit(2);
135
+ if ($action === "wait" && $value === "0") {
136
+ $_POST = ["action" => "wait", "container" => $container, "wait" => "0"];
137
+ } elseif ($action === "autostart" && ($value === "true" || $value === "false")) {
138
+ $_POST = ["action" => "autostart", "container" => $container, "auto" => $value, "wait" => "0"];
139
+ } else exit(2);
140
+ $_SERVER["DOCUMENT_ROOT"] = "/usr/local/emhttp";
141
+ $_SERVER["REQUEST_URI"] = "docker";
142
+ require "/usr/local/emhttp/plugins/dynamix.docker.manager/include/UpdateConfig.php";
143
+ ' "$AUTOSTART_ACTION" "$AUTOSTART_CONTAINER" "$AUTOSTART_VALUE"
144
+ )
145
+ }
146
+ mutate_butler_autostart() {
147
+ (
148
+ AUTOSTART_CONTAINER=$1
149
+ AUTOSTART_ENABLED=$2
150
+ case "$AUTOSTART_CONTAINER" in
151
+ ouro-butler|ouro-butler-staging|ouro-butler-rollback|ouro-butler-legacy-evidence) ;;
152
+ *) return 1 ;;
153
+ esac
154
+ case "$AUTOSTART_ENABLED" in true|false) ;; *) return 1 ;; esac
155
+ AUTOSTART_COUNT=$(awk -v name="$AUTOSTART_CONTAINER" '$1 == name { count++ } END { print count + 0 }' /var/lib/docker/unraid-autostart) || return $?
156
+ case "$AUTOSTART_COUNT" in 0|1) ;; *) return 1 ;; esac
157
+ if test "$AUTOSTART_ENABLED" = false && test "$AUTOSTART_COUNT" -eq 0; then
158
+ return 0
159
+ fi
160
+ if test "$AUTOSTART_COUNT" -eq 1; then
161
+ run_unraid_autostart_backend wait "$AUTOSTART_CONTAINER" 0 || return $?
162
+ fi
163
+ if test "$AUTOSTART_ENABLED" = false; then
164
+ run_unraid_autostart_backend autostart "$AUTOSTART_CONTAINER" false || return $?
165
+ AUTOSTART_EXPECTED_COUNT=0
166
+ else
167
+ if test "$AUTOSTART_COUNT" -eq 0; then
168
+ run_unraid_autostart_backend autostart "$AUTOSTART_CONTAINER" true || return $?
169
+ fi
170
+ AUTOSTART_EXPECTED_COUNT=1
171
+ fi
172
+ AUTOSTART_COUNT=$(awk -v name="$AUTOSTART_CONTAINER" '$1 == name { count++ } END { print count + 0 }' /var/lib/docker/unraid-autostart) || return $?
173
+ test "$AUTOSTART_COUNT" -eq "$AUTOSTART_EXPECTED_COUNT" || return $?
174
+ )
175
+ }
176
+ set_butler_autostart() {
177
+ (
178
+ AUTOSTART_PROFILE=$1
179
+ case "$AUTOSTART_PROFILE" in disabled|production|staging) ;; *) return 1 ;; esac
180
+ test "$(id -u)" -eq 0 || return $?
181
+ AUTOSTART_FILE=/var/lib/docker/unraid-autostart
182
+ AUTOSTART_INCLUDE=/usr/local/emhttp/plugins/dynamix.docker.manager/include/UpdateConfig.php
183
+ test -f "$AUTOSTART_FILE" && test ! -L "$AUTOSTART_FILE" || return 1
184
+ test "$(stat -c '%u:%g:%a' "$AUTOSTART_FILE")" = 0:0:644 || return $?
185
+ test -f "$AUTOSTART_INCLUDE" && test ! -L "$AUTOSTART_INCLUDE" || return 1
186
+ test "$(stat -c '%u:%g:%a' "$AUTOSTART_INCLUDE")" = 0:0:644 || return $?
187
+ AUTOSTART_COUNTS=$(butler_autostart_counts) || return $?
188
+ set -- $AUTOSTART_COUNTS
189
+ test "$#" -eq 4 || return $?
190
+ for AUTOSTART_COUNT in "$@"; do case "$AUTOSTART_COUNT" in 0|1) ;; *) return 1 ;; esac; done
191
+ case "$AUTOSTART_PROFILE" in
192
+ production) docker container inspect ouro-butler >/dev/null 2>&1 || return $? ;;
193
+ staging) docker container inspect ouro-butler-staging >/dev/null 2>&1 || return $? ;;
194
+ esac
195
+ AUTOSTART_BACKUP=$(mktemp /var/lib/docker/.unraid-autostart.ouro.XXXXXX) || return $?
196
+ AUTOSTART_NONBUTLER_BEFORE=$(mktemp /run/unraid-autostart.nonbutler-before.XXXXXX) || { rm -f -- "$AUTOSTART_BACKUP"; return 1; }
197
+ AUTOSTART_NONBUTLER_AFTER=$(mktemp /run/unraid-autostart.nonbutler-after.XXXXXX) || { rm -f -- "$AUTOSTART_BACKUP" "$AUTOSTART_NONBUTLER_BEFORE"; return 1; }
198
+ AUTOSTART_COMMITTED=no
199
+ AUTOSTART_BACKUP_READY=no
200
+ AUTOSTART_RESTORE_TMP=
201
+ autostart_cleanup() {
202
+ AUTOSTART_STATUS=$?
203
+ trap - EXIT HUP INT TERM
204
+ if test "$AUTOSTART_COMMITTED" = yes; then
205
+ rm -f -- "$AUTOSTART_BACKUP" || AUTOSTART_STATUS=1
206
+ elif test "$AUTOSTART_BACKUP_READY" = yes; then
207
+ AUTOSTART_RESTORE_TMP=$(mktemp /var/lib/docker/.unraid-autostart.restore.XXXXXX) || AUTOSTART_RESTORE_TMP=
208
+ if test -n "$AUTOSTART_RESTORE_TMP" \
209
+ && cp -p -- "$AUTOSTART_BACKUP" "$AUTOSTART_RESTORE_TMP" \
210
+ && sync -f "$AUTOSTART_RESTORE_TMP" \
211
+ && mv -f -- "$AUTOSTART_RESTORE_TMP" "$AUTOSTART_FILE" \
212
+ && sync -f "$AUTOSTART_FILE" \
213
+ && sync -f /var/lib/docker; then
214
+ rm -f -- "$AUTOSTART_BACKUP" || AUTOSTART_STATUS=1
215
+ else
216
+ test -z "$AUTOSTART_RESTORE_TMP" || rm -f -- "$AUTOSTART_RESTORE_TMP"
217
+ printf 'CRITICAL: Butler autostart rollback failed; recovery copy preserved at %s\n' "$AUTOSTART_BACKUP" >&2
218
+ AUTOSTART_STATUS=1
219
+ fi
220
+ else
221
+ rm -f -- "$AUTOSTART_BACKUP" || AUTOSTART_STATUS=1
222
+ fi
223
+ rm -f -- "$AUTOSTART_NONBUTLER_BEFORE" "$AUTOSTART_NONBUTLER_AFTER" || AUTOSTART_STATUS=1
224
+ exit "$AUTOSTART_STATUS"
225
+ }
226
+ trap autostart_cleanup EXIT
227
+ trap 'exit 129' HUP
228
+ trap 'exit 130' INT
229
+ trap 'exit 143' TERM
230
+ cp -p -- "$AUTOSTART_FILE" "$AUTOSTART_BACKUP" || return $?
231
+ cmp -s -- "$AUTOSTART_FILE" "$AUTOSTART_BACKUP" || return 1
232
+ sync -f "$AUTOSTART_BACKUP" || return $?
233
+ sync -f /var/lib/docker || return $?
234
+ AUTOSTART_BACKUP_READY=yes
235
+ snapshot_nonbutler_autostart >"$AUTOSTART_NONBUTLER_BEFORE" || return $?
236
+ case "$AUTOSTART_PROFILE" in
237
+ production) AUTOSTART_TARGET=ouro-butler ;;
238
+ staging) AUTOSTART_TARGET=ouro-butler-staging ;;
239
+ disabled) AUTOSTART_TARGET=- ;;
240
+ esac
241
+ for AUTOSTART_CONTAINER in ouro-butler ouro-butler-staging ouro-butler-rollback ouro-butler-legacy-evidence; do
242
+ test "$AUTOSTART_CONTAINER" = "$AUTOSTART_TARGET" && continue
243
+ mutate_butler_autostart "$AUTOSTART_CONTAINER" false || return $?
244
+ done
245
+ test "$AUTOSTART_TARGET" = - || mutate_butler_autostart "$AUTOSTART_TARGET" true || return $?
246
+ case "$AUTOSTART_PROFILE" in
247
+ production) AUTOSTART_EXPECTED_COUNTS="1 0 0 0" ;;
248
+ staging) AUTOSTART_EXPECTED_COUNTS="0 1 0 0" ;;
249
+ disabled) AUTOSTART_EXPECTED_COUNTS="0 0 0 0" ;;
250
+ esac
251
+ verify_butler_autostart "$AUTOSTART_EXPECTED_COUNTS" || return $?
252
+ snapshot_nonbutler_autostart >"$AUTOSTART_NONBUTLER_AFTER" || return $?
253
+ cmp -s -- "$AUTOSTART_NONBUTLER_BEFORE" "$AUTOSTART_NONBUTLER_AFTER" || return 1
254
+ sync -f "$AUTOSTART_FILE" || return $?
255
+ sync -f /var/lib/docker || return $?
256
+ AUTOSTART_COMMITTED=yes
257
+ )
258
+ }
259
+ verify_butler_autostart() {
260
+ (
261
+ EXPECTED_AUTOSTART_COUNTS=$1
262
+ AUTOSTART_COUNTS=$(butler_autostart_counts) || return $?
263
+ test "$AUTOSTART_COUNTS" = "$EXPECTED_AUTOSTART_COUNTS" || return $?
264
+ )
265
+ }
266
+ disable_butler_autostart() {
267
+ set_butler_autostart disabled || return $?
268
+ verify_butler_autostart "0 0 0 0" || return $?
269
+ }
270
+ enable_butler_autostart() {
271
+ set_butler_autostart production || return $?
272
+ verify_butler_autostart "1 0 0 0" || return $?
273
+ }
274
+ This bounded wait accepts only Docker's healthy state. The image healthcheck
275
+ verifies fresh daemon state plus exactly one managed Telegram process and one
276
+ managed scheduler process. It fails immediately on an exited, dead, or
277
+ unhealthy container and times out after four minutes:
278
+ wait_butler_ready() {
279
+ WAIT_CONTAINER=$1
280
+ WAIT_NOW=$(date +%s) || return $?
281
+ WAIT_DEADLINE=$(( WAIT_NOW + 240 ))
282
+ while :; do
283
+ WAIT_NOW=$(date +%s) || return $?
284
+ test "$WAIT_NOW" -lt "$WAIT_DEADLINE" || break
285
+ WAIT_STATE=$(docker inspect --format '{{.State.Status}} {{.State.Health.Status}}' "$WAIT_CONTAINER") || return $?
286
+ if test "$WAIT_STATE" = "running healthy"; then
287
+ return 0
288
+ fi
289
+ case "$WAIT_STATE" in
290
+ exited\ *|dead\ *|*\ unhealthy) return 1 ;;
291
+ esac
292
+ sleep 5 || return $?
293
+ done
294
+ return 1
295
+ }
296
+ Define these fail-closed image and topology helpers in the same shell. They use
297
+ exact Docker image IDs and container identity, image provenance, and writable
298
+ root overlap; no mutation is performed:
299
+ validate_exact_image_id() {
300
+ VALIDATE_IMAGE_ID=$1
301
+ case "$VALIDATE_IMAGE_ID" in
302
+ sha256:*) VALIDATE_IMAGE_HEX=${VALIDATE_IMAGE_ID#sha256:} ;;
303
+ *) return 1 ;;
304
+ esac
305
+ test "${#VALIDATE_IMAGE_HEX}" -eq 64 || return $?
306
+ case "$VALIDATE_IMAGE_HEX" in
307
+ *[!0-9a-f]*) return 1 ;;
308
+ esac
309
+ docker image inspect "$VALIDATE_IMAGE_ID" >/dev/null || return $?
310
+ }
311
+ sanctuary_image_mount_contract() {
312
+ (
313
+ CONTRACT_IMAGE_ID=$1
314
+ validate_exact_image_id "$CONTRACT_IMAGE_ID" || return $?
315
+ CONTRACT_LABEL=$(docker image inspect --format '{{with .Config.Labels}}{{index . "bot.ouro.sanctuary.mount-contract"}}{{end}}' "$CONTRACT_IMAGE_ID") || return $?
316
+ case "$CONTRACT_IMAGE_ID:$CONTRACT_LABEL" in
317
+ sha256:589b7cf8f96d139ee9fd86204a183126aadcbd3fbfb406063709be303397b1b2:) printf '%s\n' canonical-pre-gateway ;;
318
+ sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d:) printf '%s\n' legacy-alpha742 ;;
319
+ sha256:589b7cf8f96d139ee9fd86204a183126aadcbd3fbfb406063709be303397b1b2:*|sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d:*) return 1 ;;
320
+ *:canonical-gateway) printf '%s\n' canonical-gateway ;;
321
+ *) printf '%s\n' 'Unreviewed image mount contract; refusing version-only classification.' >&2; return 1 ;;
322
+ esac
323
+ )
324
+ }
325
+ create_sanctuary_container() {
326
+ (
327
+ CREATE_IMAGE_ID=$1
328
+ CREATE_IMAGE_REFERENCE=$2
329
+ CREATE_MOUNT_CONTRACT=$3
330
+ test "$(sanctuary_image_mount_contract "$CREATE_IMAGE_ID")" = "$CREATE_MOUNT_CONTRACT" || return 1
331
+ test "$(docker image inspect --format '{{.Id}}' "$CREATE_IMAGE_REFERENCE")" = "$CREATE_IMAGE_ID" || return 1
332
+ case "$CREATE_MOUNT_CONTRACT" in
333
+ canonical-gateway) set -- --mount "type=bind,src=/run/ouro-authority,dst=/run/ouro-authority,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche,dst=/home/ouro/AgentBundles/sanctuary.ouro/psyche,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/trust/sanctuary,dst=/etc/ouro/trust/sanctuary,readonly" ;;
334
+ canonical-pre-gateway) set -- ;;
335
+ *) return 1 ;;
336
+ esac
337
+ docker create --pull=never --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
338
+ --label net.unraid.docker.managed=dockerman \
339
+ --label "net.unraid.docker.icon=https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png" \
340
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
341
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
342
+ --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
343
+ "$@" "$CREATE_IMAGE_REFERENCE" || return $?
344
+ )
345
+ }
346
+ assert_only_running_butler() {
347
+ (
348
+ EXPECTED_RUNNING_BUTLER=$1
349
+ case "$EXPECTED_RUNNING_BUTLER" in
350
+ -) EXPECTED_RUNNING_BUTLER_COUNT=0 ;;
351
+ ouro-butler|ouro-butler-staging) EXPECTED_RUNNING_BUTLER_COUNT=1 ;;
352
+ *) return 1 ;;
353
+ esac
354
+ RUNNING_CONTAINER_IDS=$(docker container ls -q) || return $?
355
+ MATCHING_RUNNING_BUTLERS=0
356
+ for RUNNING_CONTAINER_ID in $RUNNING_CONTAINER_IDS; do
357
+ RUNNING_CONTAINER_NAME=$(docker container inspect --format '{{.Name}}' "$RUNNING_CONTAINER_ID") || return $?
358
+ RUNNING_CONTAINER_NAME=${RUNNING_CONTAINER_NAME#/}
359
+ if test "$RUNNING_CONTAINER_NAME" = "$EXPECTED_RUNNING_BUTLER"; then
360
+ MATCHING_RUNNING_BUTLERS=$(( MATCHING_RUNNING_BUTLERS + 1 ))
361
+ continue
362
+ fi
363
+ RUNNING_CONTAINER_IMAGE_ID=$(docker container inspect --format '{{.Image}}' "$RUNNING_CONTAINER_ID") || return $?
364
+ RUNNING_CONTAINER_IMAGE_REF=$(docker container inspect --format '{{.Config.Image}}' "$RUNNING_CONTAINER_ID") || return $?
365
+ RUNNING_CONTAINER_IMAGE_SOURCE=$(docker image inspect --format '{{with .Config.Labels}}{{index . "org.opencontainers.image.source"}}{{end}}' "$RUNNING_CONTAINER_IMAGE_ID") || return $?
366
+ test "$RUNNING_CONTAINER_IMAGE_SOURCE" != https://github.com/ourostack/ouroboros || return 1
367
+ test "$RUNNING_CONTAINER_IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d || return 1
368
+ case "$RUNNING_CONTAINER_IMAGE_REF" in
369
+ ouro-butler|ouro-butler:*|ouro-butler@sha256:*|ouroboros-butler|ouroboros-butler:*|ouroboros-butler@sha256:*|ghcr.io/ourostack/ouroboros-butler|ghcr.io/ourostack/ouroboros-butler:*|ghcr.io/ourostack/ouroboros-butler@sha256:*) return 1 ;;
370
+ esac
371
+ RUNNING_WRITABLE_MOUNT_SOURCES=$(docker container inspect --format '{{range .Mounts}}{{if .RW}}{{println .Source}}{{end}}{{end}}' "$RUNNING_CONTAINER_ID") || return $?
372
+ if printf '%s\n' "$RUNNING_WRITABLE_MOUNT_SOURCES" | awk '
373
+ function overlaps(path, root) {
374
+ return length(path) > 0 && (path == root || index(path, root "/") == 1 || index(root, path "/") == 1)
375
+ }
376
+ overlaps($0, "/mnt/user/appdata/ouro-butler/runtime") || overlaps($0, "/mnt/user/appdata/ouro-butler/runtime/.ouro-cli") || overlaps($0, "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro") || overlaps($0, "/mnt/user/appdata/ouro-butler/AgentBundles") { found = 1 }
377
+ END { exit found ? 0 : 1 }
378
+ '; then
379
+ return 1
380
+ fi
381
+ done
382
+ test "$MATCHING_RUNNING_BUTLERS" -eq "$EXPECTED_RUNNING_BUTLER_COUNT" || return $?
383
+ )
384
+ }
385
+ assert_update_topology() {
386
+ EXPECTED_ROLLBACK_IMAGE_ID=$1
387
+ UPDATE_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
388
+ UPDATE_NAME_COUNTS=$(printf '%s\n' "$UPDATE_CONTAINER_NAMES" | awk '
389
+ $0 == "ouro-butler" { production++ }
390
+ $0 == "ouro-butler-staging" { staging++ }
391
+ $0 == "ouro-butler-rollback" { rollback++ }
392
+ $0 == "ouro-butler-legacy-evidence" { legacy++ }
393
+ END {
394
+ printf "%d %d %d %d", production + 0, staging + 0, rollback + 0, legacy + 0
395
+ }
396
+ ') || return $?
397
+ case "$UPDATE_NAME_COUNTS" in
398
+ "1 0 0 0"|"1 0 1 0"|"1 0 0 1"|"1 0 1 1") ;;
399
+ *) return 1 ;;
400
+ esac
401
+ UPDATE_PRODUCTION_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler) || return $?
402
+ test "$UPDATE_PRODUCTION_RUNNING" = true || return $?
403
+ UPDATE_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
404
+ test "$UPDATE_PRODUCTION_IMAGE_ID" = "$EXPECTED_ROLLBACK_IMAGE_ID" || return $?
405
+ case "$UPDATE_NAME_COUNTS" in
406
+ "1 0 1 0"|"1 0 1 1")
407
+ UPDATE_ROLLBACK_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback) || return $?
408
+ test "$UPDATE_ROLLBACK_RUNNING" = false || return $?
409
+ UPDATE_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
410
+ test "$UPDATE_ROLLBACK_IMAGE_ID" = "$EXPECTED_ROLLBACK_IMAGE_ID" || return $?
411
+ ;;
412
+ esac
413
+ case "$UPDATE_NAME_COUNTS" in
414
+ "1 0 0 1"|"1 0 1 1")
415
+ UPDATE_LEGACY_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence) || return $?
416
+ test "$UPDATE_LEGACY_RUNNING" = false || return $?
417
+ UPDATE_LEGACY_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
418
+ validate_exact_image_id "$UPDATE_LEGACY_IMAGE_ID" || return $?
419
+ ;;
420
+ esac
421
+ assert_only_running_butler ouro-butler || return $?
422
+ }
423
+ assert_sanctuary_update_source_pin() {
424
+ (
425
+ PIN_SOURCE_CONTAINER=$1
426
+ PIN_SOURCE_IMAGE_ID=$2
427
+ validate_exact_image_id "$PIN_SOURCE_IMAGE_ID" || return $?
428
+ test "$(docker inspect --format '{{.Image}}' "$PIN_SOURCE_CONTAINER")" = "$PIN_SOURCE_IMAGE_ID" || return $?
429
+ case "$PIN_SOURCE_CONTAINER $PIN_SOURCE_IMAGE_ID" in
430
+ "ouro-butler sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d") return 0 ;;
431
+ "ouro-butler "*) ;;
432
+ *) return 1 ;;
433
+ esac
434
+ PIN_SOURCE_IMAGE_REFERENCE=$(docker inspect --format '{{.Config.Image}}' "$PIN_SOURCE_CONTAINER") || return $?
435
+ printf '%s\n' "$PIN_SOURCE_IMAGE_REFERENCE" | grep -Eq '^ghcr\.io/ourostack/ouroboros-butler:[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || return $?
436
+ test "$(docker image inspect --format '{{.Id}}' "$PIN_SOURCE_IMAGE_REFERENCE")" = "$PIN_SOURCE_IMAGE_ID" || return $?
437
+ PIN_SOURCE_IMAGE_ORIGIN=$(docker image inspect --format '{{with .Config.Labels}}{{index . "org.opencontainers.image.source"}}{{end}}' "$PIN_SOURCE_IMAGE_ID") || return $?
438
+ test "$PIN_SOURCE_IMAGE_ORIGIN" = https://github.com/ourostack/ouroboros || return $?
439
+ test "$(docker inspect --format '{{with .Config.Labels}}{{index . "net.unraid.docker.managed"}}{{end}}' "$PIN_SOURCE_CONTAINER")" = dockerman || return $?
440
+ test "$(docker inspect --format '{{with .Config.Labels}}{{index . "net.unraid.docker.icon"}}{{end}}' "$PIN_SOURCE_CONTAINER")" = https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png
441
+ )
442
+ }
443
+ assert_legacy_alpha742_source() {
444
+ EXPECTED_SOURCE_IMAGE_ID=$1
445
+ AUDIT_RUNNER_IMAGE_ID=$2
446
+ assert_sanctuary_update_source_pin ouro-butler "$EXPECTED_SOURCE_IMAGE_ID" || return $?
447
+ audit_effective ouro-butler "$EXPECTED_SOURCE_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" legacy-alpha742
448
+ }
449
+ assert_update_source() {
450
+ EXPECTED_SOURCE_IMAGE_ID=$1
451
+ AUDIT_RUNNER_IMAGE_ID=$2
452
+ if test "$EXPECTED_SOURCE_IMAGE_ID" = sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d; then
453
+ assert_legacy_alpha742_source "$EXPECTED_SOURCE_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
454
+ else
455
+ assert_sanctuary_update_source_pin ouro-butler "$EXPECTED_SOURCE_IMAGE_ID" || return $?
456
+ EXPECTED_SOURCE_IMAGE_REFERENCE=$(docker inspect --format '{{.Config.Image}}' ouro-butler) || return $?
457
+ EXPECTED_SOURCE_MOUNT_CONTRACT=$(sanctuary_image_mount_contract "$EXPECTED_SOURCE_IMAGE_ID") || return $?
458
+ audit_effective ouro-butler "$EXPECTED_SOURCE_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" "$EXPECTED_SOURCE_MOUNT_CONTRACT" "$EXPECTED_SOURCE_IMAGE_REFERENCE" https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png
459
+ fi
460
+ }
461
+ validate_sanctuary_roots() {
462
+ (
463
+ VALIDATE_RUNTIME_ROOT=$1
464
+ VALIDATE_AGENT_ROOT=$2
465
+ VALIDATE_CONTEXT=${3:-strict}
466
+ case "$VALIDATE_CONTEXT" in strict|live-precutover) ;; *) return 1 ;; esac
467
+ test -d "$VALIDATE_RUNTIME_ROOT" || return $?
468
+ test -d "$VALIDATE_AGENT_ROOT" || return $?
469
+ VALIDATE_CONTROL_SOCKET=$VALIDATE_AGENT_ROOT/state/acceptance/telegram-control.sock
470
+ if test "$VALIDATE_CONTEXT" = live-precutover; then
471
+ test ! -L "$VALIDATE_CONTROL_SOCKET" || return 1
472
+ test -S "$VALIDATE_CONTROL_SOCKET" || return 1
473
+ test "$(stat -c '%u:%g:%a' "$VALIDATE_CONTROL_SOCKET")" = 10001:10001:600 || return $?
474
+ VALIDATE_BAD_SHAPE=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev \( -type l -o \( ! -type d -a ! -type f -a ! \( -type s -a -path "$VALIDATE_CONTROL_SOCKET" \) \) \) -print -quit) || return $?
475
+ VALIDATE_UNEXPECTED_SOCKET=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev -type s ! -path "$VALIDATE_CONTROL_SOCKET" -print -quit) || return $?
476
+ else
477
+ test ! -S "$VALIDATE_AGENT_ROOT/state/acceptance/telegram-control.sock" || return $?
478
+ test ! -e "$VALIDATE_CONTROL_SOCKET" || return $?
479
+ VALIDATE_BAD_SHAPE=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev \( -type l -o \( ! -type d -a ! -type f \) \) -print -quit) || return $?
480
+ VALIDATE_UNEXPECTED_SOCKET=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev -type s -print -quit) || return $?
481
+ fi
482
+ test -z "$VALIDATE_BAD_SHAPE" || return $?
483
+ test -z "$VALIDATE_UNEXPECTED_SOCKET" || return $?
484
+ for VALIDATE_REQUIRED_FILE in \
485
+ agent.json bundle-meta.json provider-readiness.json tool-profiles.json \
486
+ psyche/SOUL.md habits/sanctuary-health.md; do
487
+ test -s "$VALIDATE_AGENT_ROOT/$VALIDATE_REQUIRED_FILE" || return $?
488
+ done
489
+ test -d "$VALIDATE_RUNTIME_ROOT/vault-unlock" || return $?
490
+ VALIDATE_UNLOCK_FILES=$(find "$VALIDATE_RUNTIME_ROOT/vault-unlock" -xdev -mindepth 1 -maxdepth 1 -type f -name '*.secret' -size +0c -print) || return $?
491
+ test "$(printf '%s\n' "$VALIDATE_UNLOCK_FILES" | awk 'NF { count++ } END { print count + 0 }')" = 1 || return $?
492
+ test ! -e "$VALIDATE_RUNTIME_ROOT/container-credentials.json" || return $?
493
+ test ! -e "$VALIDATE_RUNTIME_ROOT/container-credentials.json.consuming" || return $?
494
+ VALIDATE_WRONG_OWNER=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev \( ! -user 10001 -o ! -group 10001 \) -print -quit) || return $?
495
+ test -z "$VALIDATE_WRONG_OWNER" || return $?
496
+ VALIDATE_WRONG_RUNTIME_DIR_MODE=$(find "$VALIDATE_RUNTIME_ROOT" -xdev -type d ! -perm 0700 \
497
+ ! \( -perm 0755 \( \
498
+ -path "$VALIDATE_RUNTIME_ROOT/scheduler" -o -path "$VALIDATE_RUNTIME_ROOT/scheduler/*" -o \
499
+ -path "$VALIDATE_RUNTIME_ROOT/daemon/logs" -o -path "$VALIDATE_RUNTIME_ROOT/daemon/logs/*" -o \
500
+ -path "$VALIDATE_RUNTIME_ROOT/daemon/external-events" -o -path "$VALIDATE_RUNTIME_ROOT/daemon/external-events/*" \
501
+ \) \) -print -quit) || return $?
502
+ test -z "$VALIDATE_WRONG_RUNTIME_DIR_MODE" || return $?
503
+ VALIDATE_WRONG_AGENT_DIR_MODE=$(find "$VALIDATE_AGENT_ROOT" -xdev -type d ! -perm 0700 \
504
+ ! \( -perm 0755 \( \
505
+ -path "$VALIDATE_AGENT_ROOT/arc/flight-recorder" -o -path "$VALIDATE_AGENT_ROOT/arc/flight-recorder/*" -o \
506
+ -path "$VALIDATE_AGENT_ROOT/state/health" -o -path "$VALIDATE_AGENT_ROOT/state/health/*" -o \
507
+ -path "$VALIDATE_AGENT_ROOT/state/logs" -o -path "$VALIDATE_AGENT_ROOT/state/logs/*" -o \
508
+ -path "$VALIDATE_AGENT_ROOT/state/habits" -o -path "$VALIDATE_AGENT_ROOT/state/habits/*" \
509
+ \) \) -print -quit) || return $?
510
+ test -z "$VALIDATE_WRONG_AGENT_DIR_MODE" || return $?
511
+ VALIDATE_WRONG_RUNTIME_FILE_MODE=$(find "$VALIDATE_RUNTIME_ROOT" -xdev -type f ! -perm 0600 \
512
+ ! \( -perm 0644 \( \
513
+ -path "$VALIDATE_RUNTIME_ROOT/daemon/logs/*" -o \
514
+ -path "$VALIDATE_RUNTIME_ROOT/scheduler/*" -o \
515
+ -path "$VALIDATE_RUNTIME_ROOT/pulse.json" -o \
516
+ -path "$VALIDATE_RUNTIME_ROOT/pulse-delivered.json" -o \
517
+ -path "$VALIDATE_RUNTIME_ROOT/daemon.pids" -o \
518
+ -path "$VALIDATE_RUNTIME_ROOT/daemon-health.json" \
519
+ \) \) -print -quit) || return $?
520
+ test -z "$VALIDATE_WRONG_RUNTIME_FILE_MODE" || return $?
521
+ # Alpha.753 and earlier wrote content-free autonomy receipts as 0644 below a 0700 tree.
522
+ # Preserve those audit records; current writers create and normalize this exact format to 0600.
523
+ VALIDATE_WRONG_AGENT_FILE_MODE=$(find "$VALIDATE_AGENT_ROOT" -xdev -type f ! -perm 0600 \
524
+ ! \( -perm 0644 \( \
525
+ -path "$VALIDATE_AGENT_ROOT/arc/flight-recorder/*" -o \
526
+ -path "$VALIDATE_AGENT_ROOT/state/health/*" -o \
527
+ -path "$VALIDATE_AGENT_ROOT/state/logs/*" -o \
528
+ -path "$VALIDATE_AGENT_ROOT/state/habits/*" -o \
529
+ -path "$VALIDATE_AGENT_ROOT/state/arc/context-loss-sentinel-watermark.json" -o \
530
+ -exec /bin/sh -c '
531
+ VALIDATE_AUTONOMY_RECEIPT=$1
532
+ VALIDATE_AUTONOMY_RECEIPTS_ROOT=$2
533
+ test "${VALIDATE_AUTONOMY_RECEIPT%/*}" = "$VALIDATE_AUTONOMY_RECEIPTS_ROOT" || exit 1
534
+ VALIDATE_AUTONOMY_RECEIPT_NAME=${VALIDATE_AUTONOMY_RECEIPT##*/}
535
+ case "$VALIDATE_AUTONOMY_RECEIPT_NAME" in
536
+ autr_????????????????????????????????.json) ;;
537
+ *) exit 1 ;;
538
+ esac
539
+ VALIDATE_AUTONOMY_RECEIPT_ID=${VALIDATE_AUTONOMY_RECEIPT_NAME#autr_}
540
+ VALIDATE_AUTONOMY_RECEIPT_ID=${VALIDATE_AUTONOMY_RECEIPT_ID%.json}
541
+ case "$VALIDATE_AUTONOMY_RECEIPT_ID" in *[!0-9a-f]*) exit 1 ;; esac
542
+ ' sanctuary-autonomy-receipt-validator {} "$VALIDATE_AGENT_ROOT/state/autonomy/receipts" \; \
543
+ \) \) -print -quit) || return $?
544
+ test -z "$VALIDATE_WRONG_AGENT_FILE_MODE" || return $?
545
+ )
546
+ }
547
+ normalize_sanctuary_private_permissions() {
548
+ (
549
+ NORMALIZE_RUNTIME_ROOT=$1
550
+ NORMALIZE_AGENT_ROOT=$2
551
+ NORMALIZE_IMAGE_ID=$3
552
+ test -d "$NORMALIZE_RUNTIME_ROOT" || return $?
553
+ test -d "$NORMALIZE_AGENT_ROOT" || return $?
554
+ validate_exact_image_id "$NORMALIZE_IMAGE_ID" || return $?
555
+ docker run --rm --pull=never --network none --user 10001:10001 \
556
+ --read-only --cap-drop ALL --security-opt no-new-privileges \
557
+ --mount "type=bind,src=$NORMALIZE_RUNTIME_ROOT,dst=/normalize/runtime" \
558
+ --mount "type=bind,src=$NORMALIZE_AGENT_ROOT,dst=/normalize/agent" \
559
+ --entrypoint /bin/sh "$NORMALIZE_IMAGE_ID" -eu -c '
560
+ find /normalize/runtime /normalize/agent -xdev -type d -user 10001 -group 10001 -perm 0755 \
561
+ ! -path "/normalize/runtime/scheduler" ! -path "/normalize/runtime/scheduler/*" \
562
+ ! -path "/normalize/runtime/daemon/logs" ! -path "/normalize/runtime/daemon/logs/*" \
563
+ ! -path "/normalize/runtime/daemon/external-events" ! -path "/normalize/runtime/daemon/external-events/*" \
564
+ ! -path "/normalize/agent/arc/flight-recorder" ! -path "/normalize/agent/arc/flight-recorder/*" \
565
+ ! -path "/normalize/agent/state/health" ! -path "/normalize/agent/state/health/*" \
566
+ ! -path "/normalize/agent/state/logs" ! -path "/normalize/agent/state/logs/*" \
567
+ ! -path "/normalize/agent/state/habits" ! -path "/normalize/agent/state/habits/*" \
568
+ -exec chmod 0700 {} +
569
+ find /normalize/runtime /normalize/agent -xdev -type f -links 1 -user 10001 -group 10001 -perm 0644 \
570
+ ! -path "/normalize/runtime/daemon/logs/*" \
571
+ ! -path "/normalize/runtime/scheduler/*" \
572
+ ! -path "/normalize/runtime/pulse.json" \
573
+ ! -path "/normalize/runtime/pulse-delivered.json" \
574
+ ! -path "/normalize/runtime/daemon.pids" \
575
+ ! -path "/normalize/runtime/daemon-health.json" \
576
+ ! -path "/normalize/agent/arc/flight-recorder/*" \
577
+ ! -path "/normalize/agent/state/health/*" \
578
+ ! -path "/normalize/agent/state/logs/*" \
579
+ ! -path "/normalize/agent/state/habits/*" \
580
+ ! -path "/normalize/agent/state/arc/context-loss-sentinel-watermark.json" \
581
+ -exec chmod 0600 {} +
582
+ ' || return $?
583
+ )
584
+ }
585
+ verify_sanctuary_snapshot_provenance() {
586
+ (
587
+ SNAPSHOT_ROOT=$1
588
+ EXPECTED_SNAPSHOT_IMAGE_ID=$2
589
+ SNAPSHOT_PROVENANCE_ROOT=$SNAPSHOT_ROOT/provenance
590
+ for SNAPSHOT_TOP_LEVEL_DIRECTORY in runtime agent host provenance; do
591
+ test -d "$SNAPSHOT_ROOT/$SNAPSHOT_TOP_LEVEL_DIRECTORY" || return $?
592
+ test ! -L "$SNAPSHOT_ROOT/$SNAPSHOT_TOP_LEVEL_DIRECTORY" || return 1
593
+ done
594
+ test "$(find "$SNAPSHOT_ROOT" -xdev -mindepth 1 -maxdepth 1 -print | wc -l)" -eq 4 || return $?
595
+ test -d "$SNAPSHOT_PROVENANCE_ROOT" || return $?
596
+ test ! -L "$SNAPSHOT_PROVENANCE_ROOT" || return 1
597
+ test "$(stat -c '%u:%g:%a' "$SNAPSHOT_PROVENANCE_ROOT")" = 0:0:700 || return $?
598
+ SNAPSHOT_BAD_PROVENANCE_ENTRY=$(find "$SNAPSHOT_PROVENANCE_ROOT" -xdev -mindepth 1 ! -type f -print -quit) || return $?
599
+ test -z "$SNAPSHOT_BAD_PROVENANCE_ENTRY" || return $?
600
+ for SNAPSHOT_PROVENANCE_FILE in image-id container-inspect.json image-inspect.json package-version manifest.sha256; do
601
+ test -f "$SNAPSHOT_PROVENANCE_ROOT/$SNAPSHOT_PROVENANCE_FILE" || return $?
602
+ test ! -L "$SNAPSHOT_PROVENANCE_ROOT/$SNAPSHOT_PROVENANCE_FILE" || return 1
603
+ test "$(stat -c '%u:%g:%a' "$SNAPSHOT_PROVENANCE_ROOT/$SNAPSHOT_PROVENANCE_FILE")" = 0:0:600 || return $?
604
+ done
605
+ test "$(find "$SNAPSHOT_PROVENANCE_ROOT" -xdev -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 5 || return $?
606
+ test "$(wc -l <"$SNAPSHOT_PROVENANCE_ROOT/image-id")" -eq 1 || return $?
607
+ IFS= read -r SNAPSHOT_IMAGE_ID <"$SNAPSHOT_PROVENANCE_ROOT/image-id" || return $?
608
+ test "$SNAPSHOT_IMAGE_ID" = "$EXPECTED_SNAPSHOT_IMAGE_ID" || return $?
609
+ case "$SNAPSHOT_IMAGE_ID" in sha256:????????????????????????????????????????????????????????????????) ;; *) return 1 ;; esac
610
+ case "${SNAPSHOT_IMAGE_ID#sha256:}" in *[!0-9a-f]*) return 1 ;; esac
611
+ /usr/local/bin/node -e '
612
+ const fs = require("node:fs");
613
+ const [containerPath, imagePath, imageId, hostPath] = process.argv.slice(1);
614
+ const container = JSON.parse(fs.readFileSync(containerPath, "utf8"));
615
+ const image = JSON.parse(fs.readFileSync(imagePath, "utf8"));
616
+ if (!Array.isArray(container) || container.length !== 1 || !container[0] || container[0].Image !== imageId) process.exit(1);
617
+ if (!Array.isArray(image) || image.length !== 1 || !image[0] || image[0].Id !== imageId) process.exit(1);
618
+ const source = image[0].Config?.Labels?.["org.opencontainers.image.source"];
619
+ const legacyAlpha742 = "sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d";
620
+ if (source !== "https://github.com/ourostack/ouroboros" && !(source === undefined && imageId === legacyAlpha742)) process.exit(1);
621
+ const expected = ["custom/usenet_health.sh", "custom/ouro-events/bootstrap-spool.sh", "custom/ouro-events/emit-event.mjs", "custom/ouro-events/emit-usenet-event.sh", "custom/ouro-events/install-usenet-guard.sh", "go.butler-lines", "crontab.butler-lines", "global-state"];
622
+ const expectedUid = 0;
623
+ const expectedGid = 0;
624
+ const hostRoot = fs.lstatSync(hostPath);
625
+ if (!hostRoot.isDirectory() || hostRoot.isSymbolicLink() || hostRoot.uid !== expectedUid || hostRoot.gid !== expectedGid || (hostRoot.mode & 0o777) !== 0o700) process.exit(1);
626
+ const rows = fs.readFileSync(`${hostPath}/inventory`, "utf8").trim().split("\n").map(line => line.split("\t"));
627
+ if (rows.length !== expected.length || rows.some((row, index) => row.length !== 2 || row[1] !== expected[index] || !["present", "absent"].includes(row[0]))) process.exit(1);
628
+ const present = rows.filter(row => row[0] === "present").map(row => row[1]);
629
+ if (!["go.butler-lines", "crontab.butler-lines", "global-state"].every(relative => present.includes(relative))) process.exit(1);
630
+ const globalRows = fs.readFileSync(`${hostPath}/global-state`, "utf8").trim().split("\n").map(line => line.split("\t"));
631
+ if (globalRows.length !== 2 || globalRows[0][0] !== "go" || globalRows[1][0] !== "crontab") process.exit(1);
632
+ for (const [name, state, digest, count] of globalRows) {
633
+ if (!["go", "crontab"].includes(name) || !["present", "absent"].includes(state) || !/^(?:[0-9a-f]{64}|-)$/.test(digest) || !/^(?:0|[1-9][0-9]*)$/.test(count)) process.exit(1);
634
+ if ((state === "absent") !== (digest === "-")) process.exit(1);
635
+ const fragmentLines = fs.readFileSync(`${hostPath}/${name}.butler-lines`, "utf8").split("\n").filter(Boolean);
636
+ if (fragmentLines.length !== Number(count)) process.exit(1);
637
+ }
638
+ for (const [state, relative] of rows) {
639
+ if (fs.existsSync(`${hostPath}/${relative}`) !== (state === "present")) process.exit(1);
640
+ }
641
+ const files = [];
642
+ const directories = [];
643
+ const walk = (directory, prefix = "") => {
644
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
645
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
646
+ const metadata = fs.lstatSync(`${directory}/${entry.name}`);
647
+ if (metadata.uid !== expectedUid || metadata.gid !== expectedGid) process.exit(1);
648
+ if (entry.isDirectory() && (metadata.mode & 0o777) === 0o700) { directories.push(relative); walk(`${directory}/${entry.name}`, relative); }
649
+ else if (entry.isFile() && (metadata.mode & 0o777) === 0o600) files.push(relative);
650
+ else process.exit(1);
651
+ }
652
+ };
653
+ walk(hostPath);
654
+ if (JSON.stringify(directories.sort()) !== JSON.stringify(["custom", "custom/ouro-events"])) process.exit(1);
655
+ if (JSON.stringify(files.sort()) !== JSON.stringify(["inventory", ...present].sort())) process.exit(1);
656
+ ' "$SNAPSHOT_PROVENANCE_ROOT/container-inspect.json" "$SNAPSHOT_PROVENANCE_ROOT/image-inspect.json" "$SNAPSHOT_IMAGE_ID" "$SNAPSHOT_ROOT/host" || return $?
657
+ test "$(wc -l <"$SNAPSHOT_PROVENANCE_ROOT/package-version")" -eq 1 || return $?
658
+ grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' "$SNAPSHOT_PROVENANCE_ROOT/package-version" || return $?
659
+ test "$(stat -c '%u:%g:%a' "$SNAPSHOT_ROOT/host")" = 0:0:700 || return $?
660
+ SNAPSHOT_BAD_HOST_ENTRY=$(find "$SNAPSHOT_ROOT/host" -xdev \( \( -type d ! -perm 0700 \) -o \( -type f ! -perm 0600 \) -o \( ! -type d -a ! -type f \) \) -print -quit) || return $?
661
+ test -z "$SNAPSHOT_BAD_HOST_ENTRY" || return $?
662
+ test -f "$SNAPSHOT_ROOT/host/inventory" || return $?
663
+ test ! -e "$SNAPSHOT_ROOT/host/notify.conf" || return 1
664
+ test ! -e "$SNAPSHOT_ROOT/host/sabnzbd.ini" || return 1
665
+ SNAPSHOT_VERIFY_ROOT=$(mktemp -d /tmp/ouro-snapshot-verify.XXXXXX) || return $?
666
+ trap 'rm -f -- "$SNAPSHOT_VERIFY_ROOT/current-files" "$SNAPSHOT_VERIFY_ROOT/manifest-files"; rmdir -- "$SNAPSHOT_VERIFY_ROOT"' EXIT || return $?
667
+ (
668
+ cd "$SNAPSHOT_ROOT" || return $?
669
+ find runtime agent host provenance -xdev -type f ! -path provenance/manifest.sha256 -print | LC_ALL=C sort >"$SNAPSHOT_VERIFY_ROOT/current-files" || return $?
670
+ grep -E '^[0-9a-f]{64} [^/].+$' provenance/manifest.sha256 >/dev/null || return $?
671
+ ! grep -Ev '^[0-9a-f]{64} [^/].+$' provenance/manifest.sha256 >/dev/null || return 1
672
+ sed 's/^[0-9a-f]\{64\} //' provenance/manifest.sha256 | LC_ALL=C sort >"$SNAPSHOT_VERIFY_ROOT/manifest-files" || return $?
673
+ cmp -s "$SNAPSHOT_VERIFY_ROOT/current-files" "$SNAPSHOT_VERIFY_ROOT/manifest-files" || return $?
674
+ sha256sum -c -- provenance/manifest.sha256 >/dev/null || return $?
675
+ ) || return $?
676
+ rm -f -- "$SNAPSHOT_VERIFY_ROOT/current-files" "$SNAPSHOT_VERIFY_ROOT/manifest-files" || return $?
677
+ rmdir -- "$SNAPSHOT_VERIFY_ROOT" || return $?
678
+ trap - EXIT || return $?
679
+ )
680
+ }
681
+ assert_restore_preflight() {
682
+ (
683
+ test -n "${BACKUP_ROOT-}" || return 1
684
+ test -n "${IMAGE_ID-}" || return 1
685
+ test -n "${AUDIT_RUNNER_IMAGE_ID-}" || return 1
686
+ test -n "${RESTORE_VERSION_IMAGE-}" || return 1
687
+ case "$BACKUP_ROOT" in
688
+ /*) ;;
689
+ *) return 1 ;;
690
+ esac
691
+ test "$BACKUP_ROOT" != / || return $?
692
+ RESTORE_BACKUP_ROOT=$(cd -- "$BACKUP_ROOT" && pwd -P) || return $?
693
+ test "$RESTORE_BACKUP_ROOT" = "$BACKUP_ROOT" || return $?
694
+ test -d "$BACKUP_ROOT/runtime/.ouro-cli" || return $?
695
+ test -d "$BACKUP_ROOT/agent/sanctuary.ouro" || return $?
696
+ verify_sanctuary_snapshot_provenance "$BACKUP_ROOT" "$IMAGE_ID" || return $?
697
+ validate_sanctuary_roots "$BACKUP_ROOT/runtime/.ouro-cli" "$BACKUP_ROOT/agent/sanctuary.ouro" || return $?
698
+ validate_exact_image_id "$IMAGE_ID" || return $?
699
+ validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID" || return $?
700
+ test "$IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d || return $?
701
+ test "$IMAGE_ID" != sha256:e337dff04c92d116b269052f473b26a47eea933d017d1befc73af50dd37bb08d || return $?
702
+ test "$AUDIT_RUNNER_IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d || return $?
703
+ test "$AUDIT_RUNNER_IMAGE_ID" != sha256:e337dff04c92d116b269052f473b26a47eea933d017d1befc73af50dd37bb08d || return $?
704
+ printf '%s\n' "$RESTORE_VERSION_IMAGE" | grep -Eq '^ghcr\.io/ourostack/ouroboros-butler:[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || return $?
705
+ test "$(docker image inspect --format '{{.Id}}' "$RESTORE_VERSION_IMAGE")" = "$IMAGE_ID" || return $?
706
+ RESTORE_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
707
+ RESTORE_NAME_COUNTS=$(printf '%s\n' "$RESTORE_CONTAINER_NAMES" | awk '
708
+ $0 == "ouro-butler" { production++ }
709
+ $0 == "ouro-butler-staging" { staging++ }
710
+ $0 == "ouro-butler-rollback" { rollback++ }
711
+ $0 == "ouro-butler-legacy-evidence" { legacy++ }
712
+ END {
713
+ printf "%d %d %d %d", production + 0, staging + 0, rollback + 0, legacy + 0
714
+ }
715
+ ') || return $?
716
+ case "$RESTORE_NAME_COUNTS" in
717
+ "1 0 0 0"|"1 0 0 1") ;;
718
+ *) return 1 ;;
719
+ esac
720
+ RESTORE_PRODUCTION_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler) || return $?
721
+ test "$RESTORE_PRODUCTION_RUNNING" = true || return $?
722
+ RESTORE_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
723
+ validate_exact_image_id "$RESTORE_PRODUCTION_IMAGE_ID" || return $?
724
+ if test "$RESTORE_NAME_COUNTS" = "1 0 0 1"; then
725
+ RESTORE_LEGACY_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence) || return $?
726
+ test "$RESTORE_LEGACY_RUNNING" = false || return $?
727
+ RESTORE_LEGACY_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
728
+ validate_exact_image_id "$RESTORE_LEGACY_IMAGE_ID" || return $?
729
+ fi
730
+ assert_only_running_butler ouro-butler || return $?
731
+ assert_update_source "$RESTORE_PRODUCTION_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" || return $?
732
+ )
733
+ }
734
+ ensure_sanctuary_machine_identity() {
735
+ MACHINE_PATH=$1
736
+ test ! -L "$MACHINE_PATH" || return 1
737
+ /usr/local/bin/node -e '
738
+ const fs = require("node:fs");
739
+ const path = process.argv[1];
740
+ const valid = value => value && value.schemaVersion === 1 && value.machineId === "sanctuary"
741
+ && typeof value.createdAt === "string" && typeof value.updatedAt === "string"
742
+ && Array.isArray(value.hostnameAliases);
743
+ if (fs.existsSync(path)) {
744
+ if (!valid(JSON.parse(fs.readFileSync(path, "utf8")))) process.exit(1);
745
+ process.exit(0);
746
+ }
747
+ const now = new Date().toISOString();
748
+ const value = { schemaVersion: 1, machineId: "sanctuary", createdAt: now, updatedAt: now, hostnameAliases: [] };
749
+ const temporary = `${path}.tmp.${process.pid}`;
750
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" });
751
+ fs.renameSync(temporary, path);
752
+ ' "$MACHINE_PATH" || return $?
753
+ chown 10001:10001 "$MACHINE_PATH" || return $?
754
+ chmod 0600 "$MACHINE_PATH" || return $?
755
+ sync -f "$MACHINE_PATH" || return $?
756
+ }
757
+ migrate_sanctuary_package_managed_bundle() {
758
+ MIGRATE_IMAGE_ID=$1
759
+ MIGRATE_OPERATION=$2
760
+ MIGRATE_ROLLBACK_IMAGE_ID=${3-}
761
+ validate_exact_image_id "$MIGRATE_IMAGE_ID" || return $?
762
+ case "$MIGRATE_OPERATION" in migrate|rollback|finalize-rollback|commit|status|inspect) ;; *) return 1 ;; esac
763
+ if test "$MIGRATE_OPERATION" = migrate; then
764
+ validate_exact_image_id "$MIGRATE_ROLLBACK_IMAGE_ID" || return $?
765
+ test "$MIGRATE_IMAGE_ID" != "$MIGRATE_ROLLBACK_IMAGE_ID" || return 1
766
+ set -- --rollback-image-id "$MIGRATE_ROLLBACK_IMAGE_ID" --target-image-id "$MIGRATE_IMAGE_ID"
767
+ else
768
+ set --
769
+ fi
770
+ docker run --rm --pull=never --network=none --read-only --user 10001:10001 \
771
+ --entrypoint /usr/local/bin/node \
772
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
773
+ "$MIGRATE_IMAGE_ID" /opt/ouro/deploy/unraid/migrate-sanctuary-bundle.mjs \
774
+ --package-root /opt/ouro/deploy/unraid/sanctuary.ouro \
775
+ --agent-root /home/ouro/AgentBundles/sanctuary.ouro \
776
+ --operation "$MIGRATE_OPERATION" "$@" || return $?
777
+ }
778
+ read_sanctuary_bundle_transaction_status() {
779
+ READ_BUNDLE_IMAGE_ID=$1
780
+ READ_BUNDLE_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
781
+ READ_BUNDLE_EXPECTED_UID=10001
782
+ READ_BUNDLE_EXPECTED_GID=10001
783
+ READ_BUNDLE_ROLLBACK_RECORD=$READ_BUNDLE_AGENT_ROOT/.sanctuary-package-managed-rollback.json
784
+ READ_BUNDLE_COMMITTING_RECORD=$READ_BUNDLE_ROLLBACK_RECORD.committing
785
+ validate_exact_image_id "$READ_BUNDLE_IMAGE_ID" || return $?
786
+ READ_BUNDLE_ROOT_STATE=$(/usr/local/bin/node -e '
787
+ const fs = require("node:fs");
788
+ const path = require("node:path");
789
+ const [rootPath, expectedUid, expectedGid] = process.argv.slice(1);
790
+ if (!path.isAbsolute(rootPath) || path.normalize(rootPath) !== rootPath) process.exit(1);
791
+ let current = path.parse(rootPath).root;
792
+ let rootStat;
793
+ for (const segment of rootPath.slice(current.length).split(path.sep)) {
794
+ current = path.join(current, segment);
795
+ let stat;
796
+ try { stat = fs.lstatSync(current); } catch (error) {
797
+ if (error?.code === "ENOENT") { process.stdout.write("missing"); process.exit(0); }
798
+ process.exit(1);
799
+ }
800
+ if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(current) !== current) process.exit(1);
801
+ rootStat = stat;
802
+ }
803
+ if (!rootStat || rootStat.uid !== Number(expectedUid) || rootStat.gid !== Number(expectedGid) || (rootStat.mode & 0o777) !== 0o700) process.exit(1);
804
+ process.stdout.write("present");
805
+ ' "$READ_BUNDLE_AGENT_ROOT" "$READ_BUNDLE_EXPECTED_UID" "$READ_BUNDLE_EXPECTED_GID") || return $?
806
+ if test "$READ_BUNDLE_ROOT_STATE" = missing; then
807
+ printf 'null\n'
808
+ return 0
809
+ fi
810
+ test "$READ_BUNDLE_ROOT_STATE" = present || return 1
811
+ READ_BUNDLE_STAGING_RESIDUE=$(find "$READ_BUNDLE_AGENT_ROOT" -mindepth 1 -maxdepth 1 \( \
812
+ -name '.sanctuary-package-managed-rollback.json.package-migration.*' -o \
813
+ -name '.sanctuary-package-managed-rollback.json.committing.package-migration.*' \
814
+ \) -print -quit) || return $?
815
+ test -z "$READ_BUNDLE_STAGING_RESIDUE" || return 1
816
+ READ_BUNDLE_RECORD_COUNT=0
817
+ for READ_BUNDLE_CANDIDATE in "$READ_BUNDLE_ROLLBACK_RECORD" "$READ_BUNDLE_COMMITTING_RECORD"; do
818
+ test ! -L "$READ_BUNDLE_CANDIDATE" || return 1
819
+ if test -e "$READ_BUNDLE_CANDIDATE"; then
820
+ test -f "$READ_BUNDLE_CANDIDATE" || return 1
821
+ READ_BUNDLE_RECORD_COUNT=$((READ_BUNDLE_RECORD_COUNT + 1))
822
+ fi
823
+ done
824
+ test "$READ_BUNDLE_RECORD_COUNT" -le 1 || return 1
825
+ if test "$READ_BUNDLE_RECORD_COUNT" -eq 0; then
826
+ printf 'null\n'
827
+ return 0
828
+ fi
829
+ migrate_sanctuary_package_managed_bundle "$READ_BUNDLE_IMAGE_ID" status || return $?
830
+ }
831
+ verify_dockerman_and_community_apps() {
832
+ VERIFY_VERSION_IMAGE=$1
833
+ VERIFY_INSTALL_PROOF_PATH=$2
834
+ VERIFY_TEMPLATE_PATH=/boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml
835
+ test "$(stat -c '%u:%g:%a' "$VERIFY_TEMPLATE_PATH")" = 0:0:600 || return $?
836
+ test ! -e "$VERIFY_INSTALL_PROOF_PATH" || return 1
837
+ timeout -s KILL 20 /usr/bin/php -r '
838
+ [$expectedImage, $outputPath] = array_slice($argv, 1);
839
+ $expectedPath = "/boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml";
840
+ $expectedUrl = "https://raw.githubusercontent.com/ourostack/ouroboros/main/deploy/unraid/sanctuary.xml";
841
+ $expectedIcon = "https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png";
842
+ $communityAppsEntryPath = "/usr/local/emhttp/plugins/community.applications/include/exec.php";
843
+ $communityAppsHelperPath = "/usr/local/emhttp/plugins/community.applications/include/previous_apps_helpers.php";
844
+ $communityAppsDependencies = ["/usr/local/emhttp/plugins/community.applications/include/paths.php", "/usr/local/emhttp/plugins/community.applications/include/plugin_identity.php", "/usr/local/emhttp/plugins/community.applications/include/helpers.php"];
845
+ if (!preg_match("#^ghcr\\.io/ourostack/ouroboros-butler:[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$#", $expectedImage)) exit(2);
846
+ $readActivePhp = static function($path) {
847
+ if (!is_file($path) || is_link($path) || fileowner($path) !== 0 || (fileperms($path) & 0022) !== 0 || realpath($path) !== $path) exit(3);
848
+ $lint = proc_open(["/usr/bin/php", "-l", $path], [0 => ["file", "/dev/null", "r"], 1 => ["file", "/dev/null", "w"], 2 => ["file", "/dev/null", "w"]], $pipes);
849
+ if (!is_resource($lint) || proc_close($lint) !== 0) exit(4);
850
+ $source = php_strip_whitespace($path);
851
+ if (!is_string($source) || $source === "") exit(4);
852
+ return $source;
853
+ };
854
+ $functionSlice = static function($source, $start, $next) {
855
+ if (substr_count($source, $start) !== 1 || substr_count($source, $next) !== 1) return false;
856
+ $startAt = strpos($source, $start);
857
+ $nextAt = strpos($source, $next, $startAt + strlen($start));
858
+ return $nextAt !== false && $nextAt > $startAt ? substr($source, $startAt, $nextAt - $startAt) : false;
859
+ };
860
+ $entrySource = $readActivePhp($communityAppsEntryPath);
861
+ $inlineBody = $functionSlice($entrySource, "function previous_apps(", "function remove_application(");
862
+ $delegatedBody = $functionSlice($entrySource, "function previous_apps(", "function installedAndPreviousCounts(");
863
+ $isInline = $inlineBody !== false && hash("sha256", $inlineBody) === "1914b9c6b8910b7bb116dad48fe76d4790a069becbf0d68b3822ee3df7f22b1c";
864
+ $isDelegated = $delegatedBody !== false && hash("sha256", $delegatedBody) === "16b52cd87cc3f74915bf172f8a6eb46f981529208766916d4aeedf4b3344a3a4";
865
+ if (($isInline ? 1 : 0) + ($isDelegated ? 1 : 0) !== 1) exit(4);
866
+ $_SERVER["DOCUMENT_ROOT"] = "/usr/local/emhttp";
867
+ $docroot = $_SERVER["DOCUMENT_ROOT"];
868
+ require_once "$docroot/plugins/dynamix/include/Wrappers.php";
869
+ require_once "$docroot/plugins/dynamix.docker.manager/include/DockerClient.php";
870
+ $driver = DockerUtil::driver();
871
+ $client = new DockerClient();
872
+ $templates = new DockerTemplates();
873
+ $resolved = $templates->getUserTemplate("ouro-butler");
874
+ if ($resolved !== $expectedPath) exit(5);
875
+ $xml = @simplexml_load_file($resolved);
876
+ if (!$xml || (string)$xml->Name !== "ouro-butler" || (string)$xml->Repository !== $expectedImage || (string)$xml->TemplateURL !== $expectedUrl || (string)$xml->Icon !== $expectedIcon || trim((string)$xml->WebUI) !== "") exit(6);
877
+ $containers = $client->getDockerContainers();
878
+ $matches = array_values(array_filter($containers, fn($container) => $container["Name"] === "ouro-butler"));
879
+ if (count($matches) !== 1) exit(7);
880
+ $container = $matches[0];
881
+ if ($container["Image"] !== $expectedImage || $container["Manager"] !== "dockerman" || $container["Icon"] !== $expectedIcon || !empty($container["Url"])) exit(8);
882
+ $communityAppsInstalled = $container["Name"] === (string)$xml->Name && (str_starts_with(str_replace("library/", "", $container["Image"]), (string)$xml->Repository) || str_starts_with($container["Image"], (string)$xml->Repository));
883
+ if (!$communityAppsInstalled) exit(9);
884
+ if ($isDelegated) {
885
+ $readActivePhp($communityAppsHelperPath);
886
+ foreach ($communityAppsDependencies as $dependencyPath) $readActivePhp($dependencyPath);
887
+ require_once "$docroot/plugins/community.applications/include/helpers.php";
888
+ require_once $communityAppsHelperPath;
889
+ if (!is_callable(["PreviousAppsHelpers", "collectDockerApplications"])) exit(4);
890
+ $helperUpdateCount = 0;
891
+ $helperRows = PreviousAppsHelpers::collectDockerApplications(true, "true", "docker", $containers, $helperUpdateCount, [], [], [], []);
892
+ if (!is_array($helperRows)) exit(9);
893
+ $helperMatches = array_values(array_filter($helperRows, fn($row) => is_array($row) && ($row["InstallPath"] ?? null) === $expectedPath && ($row["Name"] ?? null) === "ouro-butler" && ($row["Repository"] ?? null) === $expectedImage && ($row["TemplateURL"] ?? null) === $expectedUrl && ($row["Icon"] ?? null) === $expectedIcon));
894
+ if (count($helperMatches) !== 1) exit(9);
895
+ $communityAppsStateModel = "previous-apps-helper-v1";
896
+ $communityAppsImplementationPath = $communityAppsHelperPath;
897
+ $communityAppsImplementationSymbol = "PreviousAppsHelpers::collectDockerApplications";
898
+ } else {
899
+ $communityAppsStateModel = "previous-apps-inline-v1";
900
+ $communityAppsImplementationPath = $communityAppsEntryPath;
901
+ $communityAppsImplementationSymbol = "previous_apps";
902
+ }
903
+ $proof = [
904
+ "dockerMan" => ["templatePath" => $resolved, "name" => (string)$xml->Name, "repository" => (string)$xml->Repository, "templateUrl" => (string)$xml->TemplateURL, "icon" => (string)$xml->Icon],
905
+ "communityApps" => ["installed" => true, "name" => (string)$xml->Name, "repository" => (string)$xml->Repository, "templateUrl" => (string)$xml->TemplateURL, "stateModel" => $communityAppsStateModel, "entryPath" => $communityAppsEntryPath, "entryFunction" => "previous_apps", "implementationPath" => $communityAppsImplementationPath, "implementationSymbol" => $communityAppsImplementationSymbol],
906
+ ];
907
+ $temporary = $outputPath . ".tmp";
908
+ if (file_exists($temporary) || is_link($temporary) || file_exists($outputPath) || is_link($outputPath)) exit(10);
909
+ $payload = json_encode($proof, JSON_UNESCAPED_SLASHES) . "\n";
910
+ $handle = @fopen($temporary, "x");
911
+ if ($handle === false || fwrite($handle, $payload) !== strlen($payload) || !fflush($handle) || !fsync($handle) || !fclose($handle) || !chmod($temporary, 0600) || !chown($temporary, 0) || !chgrp($temporary, 0) || !rename($temporary, $outputPath)) exit(11);
912
+ ' "$VERIFY_VERSION_IMAGE" "$VERIFY_INSTALL_PROOF_PATH" || return $?
913
+ test "$(stat -c '%u:%g:%a' "$VERIFY_INSTALL_PROOF_PATH")" = 0:0:600 || return $?
914
+ }
915
+ audit_registered_dockerman_template() {
916
+ REGISTERED_TEMPLATE_RUNNER_IMAGE_ID=$1
917
+ REGISTERED_TEMPLATE_VERSION_IMAGE=$2
918
+ REGISTERED_TEMPLATE_MOUNT_CONTRACT=$3
919
+ REGISTERED_TEMPLATE_PATH=/boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml
920
+ validate_exact_image_id "$REGISTERED_TEMPLATE_RUNNER_IMAGE_ID" || return $?
921
+ printf '%s\n' "$REGISTERED_TEMPLATE_VERSION_IMAGE" | grep -Eq '^ghcr\.io/ourostack/ouroboros-butler:[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || return $?
922
+ test -f "$REGISTERED_TEMPLATE_PATH" && test ! -L "$REGISTERED_TEMPLATE_PATH" || return 1
923
+ docker run --rm --pull=never --network=none --user 0:0 --read-only --cap-drop=ALL --security-opt=no-new-privileges --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh --mount "type=bind,src=$REGISTERED_TEMPLATE_PATH,dst=/audit/sanctuary.xml,readonly" "$REGISTERED_TEMPLATE_RUNNER_IMAGE_ID" --persistent-template /audit/sanctuary.xml --runtime-policy /opt/ouro/deploy/unraid/container-runtime.json --expected-image-reference "$REGISTERED_TEMPLATE_VERSION_IMAGE" --mount-contract "$REGISTERED_TEMPLATE_MOUNT_CONTRACT" || return $?
924
+ }
925
+ verify_known_good_rollback_artifact() {
926
+ EXPECTED_KNOWN_GOOD_IMAGE_ID=$1
927
+ validate_exact_image_id "$EXPECTED_KNOWN_GOOD_IMAGE_ID" || return $?
928
+ MATCHING_KNOWN_GOOD_COUNT=0
929
+ for KNOWN_GOOD_NAME in ouro-butler-rollback ouro-butler-legacy-evidence; do
930
+ if docker container inspect "$KNOWN_GOOD_NAME" >/dev/null 2>&1; then
931
+ KNOWN_GOOD_IMAGE_ID=$(docker inspect --format '{{.Image}}' "$KNOWN_GOOD_NAME") || return $?
932
+ if test "$KNOWN_GOOD_IMAGE_ID" = "$EXPECTED_KNOWN_GOOD_IMAGE_ID"; then
933
+ test "$(docker inspect --format '{{.State.Running}}' "$KNOWN_GOOD_NAME")" = false || return 1
934
+ MATCHING_KNOWN_GOOD_COUNT=$((MATCHING_KNOWN_GOOD_COUNT + 1))
935
+ fi
936
+ fi
937
+ done
938
+ test "$MATCHING_KNOWN_GOOD_COUNT" -eq 1 || return 1
939
+ }
940
+ write_dockerman_final_proof() {
941
+ FINAL_PROOF_IMAGE_ID=$1
942
+ FINAL_PROOF_VERSION_IMAGE=$2
943
+ FINAL_PROOF_ICON=$3
944
+ FINAL_PROOF_ROOT=$4
945
+ validate_exact_image_id "$FINAL_PROOF_IMAGE_ID" || return $?
946
+ test "$FINAL_PROOF_VERSION_IMAGE" = "ghcr.io/ourostack/ouroboros-butler:$PACKAGE_VERSION" || return 1
947
+ install -d -m 0700 -o 0 -g 0 "$FINAL_PROOF_ROOT" || return $?
948
+ FINAL_CONTAINER_PATH=$FINAL_PROOF_ROOT/container.json
949
+ FINAL_BUNDLE_PATH=$FINAL_PROOF_ROOT/bundle.json
950
+ FINAL_INSTALL_PATH=$FINAL_PROOF_ROOT/install.json
951
+ FINAL_JELLYFIN_PATH=$FINAL_PROOF_ROOT/jellyfin.json
952
+ FINAL_PROOF_PATH=$FINAL_PROOF_ROOT/final-proof.json
953
+ test ! -e "$FINAL_CONTAINER_PATH" && test ! -e "$FINAL_BUNDLE_PATH" && test ! -e "$FINAL_INSTALL_PATH" && test ! -e "$FINAL_JELLYFIN_PATH" && test ! -e "$FINAL_PROOF_PATH" || return 1
954
+ docker inspect ouro-butler >"$FINAL_CONTAINER_PATH" || return $?
955
+ migrate_sanctuary_package_managed_bundle "$FINAL_PROOF_IMAGE_ID" inspect >"$FINAL_BUNDLE_PATH" || return $?
956
+ verify_dockerman_and_community_apps "$FINAL_PROOF_VERSION_IMAGE" "$FINAL_INSTALL_PATH" || return $?
957
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" jellyfin-status >"$FINAL_JELLYFIN_PATH" || return $?
958
+ chown 0:0 "$FINAL_CONTAINER_PATH" "$FINAL_BUNDLE_PATH" "$FINAL_JELLYFIN_PATH" || return $?
959
+ chmod 0600 "$FINAL_CONTAINER_PATH" "$FINAL_BUNDLE_PATH" "$FINAL_JELLYFIN_PATH" || return $?
960
+ FINAL_AUTOSTART_COUNT=$(awk '$1 == "ouro-butler" { count++ } END { print count + 0 }' /var/lib/docker/unraid-autostart) || return $?
961
+ /usr/local/bin/node -e '
962
+ const fs = require("node:fs");
963
+ const [containerPath, bundlePath, installPath, jellyfinPath, outputPath, imageId, imageReference, icon, autostartCount] = process.argv.slice(1);
964
+ const containers = JSON.parse(fs.readFileSync(containerPath, "utf8"));
965
+ const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
966
+ const install = JSON.parse(fs.readFileSync(installPath, "utf8"));
967
+ const jellyfin = JSON.parse(fs.readFileSync(jellyfinPath, "utf8"));
968
+ if (!Array.isArray(containers) || containers.length !== 1) process.exit(1);
969
+ const source = containers[0];
970
+ const labels = source.Config?.Labels;
971
+ const health = source.State?.Health?.Status;
972
+ if (source.Name !== "/ouro-butler" || source.Image !== imageId || source.Config?.Image !== imageReference || source.State?.Running !== true || health !== "healthy" || autostartCount !== "1" || labels?.["net.unraid.docker.managed"] !== "dockerman" || labels?.["net.unraid.docker.icon"] !== icon || Object.prototype.hasOwnProperty.call(labels ?? {}, "net.unraid.docker.webui")) process.exit(1);
973
+ if (bundle?.ok !== true || bundle?.data?.parity !== "exact" || bundle?.data?.journalState !== "absent" || bundle?.data?.ready !== true) process.exit(1);
974
+ const proof = { container: { name: source.Name, imageId: source.Image, imageReference: source.Config.Image, running: source.State.Running, healthy: health === "healthy", autostart: true, labels: { "net.unraid.docker.managed": labels["net.unraid.docker.managed"], "net.unraid.docker.icon": labels["net.unraid.docker.icon"] } }, bundle, ...install, jellyfin };
975
+ fs.writeFileSync(outputPath, `${JSON.stringify(proof)}\n`, { mode: 0o600, flag: "wx" });
976
+ ' "$FINAL_CONTAINER_PATH" "$FINAL_BUNDLE_PATH" "$FINAL_INSTALL_PATH" "$FINAL_JELLYFIN_PATH" "$FINAL_PROOF_PATH" "$FINAL_PROOF_IMAGE_ID" "$FINAL_PROOF_VERSION_IMAGE" "$FINAL_PROOF_ICON" "$FINAL_AUTOSTART_COUNT" || return $?
977
+ chown 0:0 "$FINAL_PROOF_PATH" || return $?
978
+ chmod 0600 "$FINAL_PROOF_PATH" || return $?
979
+ printf '%s\n' "$FINAL_PROOF_PATH"
980
+ }
981
+ start_only_butler_for_recovery() {
982
+ RECOVERY_AUTHORITY_STATE=$(read_sanctuary_authority_state) || return $?
983
+ if test "$RECOVERY_AUTHORITY_STATE" != none; then
984
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" authority-restore >/dev/null || return $?
985
+ assert_only_running_butler ouro-butler || return $?
986
+ return 0
987
+ fi
988
+ RECOVERY_START_STATE=$(docker inspect --format '{{.State.Running}}' ouro-butler) || return $?
989
+ case "$RECOVERY_START_STATE" in
990
+ true)
991
+ assert_only_running_butler ouro-butler || return $?
992
+ ;;
993
+ false)
994
+ assert_only_running_butler - || return $?
995
+ docker start ouro-butler || return $?
996
+ assert_only_running_butler ouro-butler || return $?
997
+ ;;
998
+ *) return 1 ;;
999
+ esac
1000
+ }
1001
+ recover_pending_sanctuary_bundle_migration() {
1002
+ RECOVERY_IMAGE_ID=$1
1003
+ RECOVERY_STATUS=$(read_sanctuary_bundle_transaction_status "$RECOVERY_IMAGE_ID") || return $?
1004
+ test "$RECOVERY_STATUS" != null || return 0
1005
+ RECOVERY_IDENTITIES=$(printf '%s' "$RECOVERY_STATUS" | /usr/local/bin/node -e '
1006
+ let input = "";
1007
+ process.stdin.setEncoding("utf8");
1008
+ process.stdin.on("data", chunk => { input += chunk; });
1009
+ process.stdin.on("end", () => {
1010
+ const value = JSON.parse(input);
1011
+ if (!value || !["rollback", "committing"].includes(value.state)
1012
+ || typeof value.rollbackImageId !== "string" || typeof value.targetImageId !== "string") process.exit(1);
1013
+ process.stdout.write(`${value.state} ${value.rollbackImageId} ${value.targetImageId}`);
1014
+ });
1015
+ ') || return $?
1016
+ set -- $RECOVERY_IDENTITIES
1017
+ test "$#" -eq 3 || return 1
1018
+ RECOVERY_STATE=$1
1019
+ RECOVERY_ROLLBACK_IMAGE_ID=$2
1020
+ RECOVERY_TARGET_IMAGE_ID=$3
1021
+ validate_exact_image_id "$RECOVERY_ROLLBACK_IMAGE_ID" || return $?
1022
+ validate_exact_image_id "$RECOVERY_TARGET_IMAGE_ID" || return $?
1023
+ test "$RECOVERY_TARGET_IMAGE_ID" != "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1024
+ disable_butler_autostart || return $?
1025
+ if test "$RECOVERY_STATE" = committing; then
1026
+ ! docker container inspect ouro-butler-staging >/dev/null 2>&1 || return 1
1027
+ RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
1028
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler)" = true || return 1
1029
+ test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_TARGET_IMAGE_ID" || return 1
1030
+ RECOVERY_CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
1031
+ test "$RECOVERY_CURRENT_ROLLBACK_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1032
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false || return 1
1033
+ assert_only_running_butler ouro-butler || return $?
1034
+ assert_update_source "$RECOVERY_PRODUCTION_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" || return $?
1035
+ wait_butler_ready ouro-butler || return $?
1036
+ enable_butler_autostart || return $?
1037
+ migrate_sanctuary_package_managed_bundle "$RECOVERY_IMAGE_ID" commit || return $?
1038
+ return 0
1039
+ fi
1040
+ retire_sanctuary_authority_if_pending || return $?
1041
+ if docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
1042
+ RECOVERY_CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
1043
+ test "$RECOVERY_CURRENT_ROLLBACK_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1044
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false || return 1
1045
+ if docker container inspect ouro-butler-staging >/dev/null 2>&1; then
1046
+ RECOVERY_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-staging) || return $?
1047
+ test "$RECOVERY_STAGING_IMAGE_ID" = "$RECOVERY_TARGET_IMAGE_ID" || return 1
1048
+ docker stop ouro-butler-staging >/dev/null 2>&1 || true
1049
+ docker rm --force ouro-butler-staging || return $?
1050
+ fi
1051
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
1052
+ RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
1053
+ test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_TARGET_IMAGE_ID" || return 1
1054
+ docker stop ouro-butler >/dev/null 2>&1 || true
1055
+ docker rm --force ouro-butler || return $?
1056
+ fi
1057
+ migrate_sanctuary_package_managed_bundle "$RECOVERY_IMAGE_ID" rollback || return $?
1058
+ docker rename ouro-butler-rollback ouro-butler || return $?
1059
+ elif docker container inspect ouro-butler >/dev/null 2>&1; then
1060
+ ! docker container inspect ouro-butler-staging >/dev/null 2>&1 || return 1
1061
+ RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
1062
+ test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1063
+ ! docker container inspect ouro-butler-rollback >/dev/null 2>&1 || return 1
1064
+ migrate_sanctuary_package_managed_bundle "$RECOVERY_IMAGE_ID" rollback || return $?
1065
+ else
1066
+ return 1
1067
+ fi
1068
+ RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
1069
+ test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1070
+ assert_update_source "$RECOVERY_ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" || return $?
1071
+ start_only_butler_for_recovery || return $?
1072
+ wait_butler_ready ouro-butler || return $?
1073
+ enable_butler_autostart || return $?
1074
+ migrate_sanctuary_package_managed_bundle "$RECOVERY_IMAGE_ID" finalize-rollback || return $?
1075
+ }
1076
+ rollback_sanctuary_bundle_if_pending() {
1077
+ OPTIONAL_ROLLBACK_IMAGE_ID=$1
1078
+ OPTIONAL_ROLLBACK_RECORD=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/.sanctuary-package-managed-rollback.json
1079
+ OPTIONAL_COMMITTING_RECORD=$OPTIONAL_ROLLBACK_RECORD.committing
1080
+ test ! -L "$OPTIONAL_ROLLBACK_RECORD" || return 1
1081
+ test ! -L "$OPTIONAL_COMMITTING_RECORD" || return 1
1082
+ test ! -e "$OPTIONAL_COMMITTING_RECORD" || return 1
1083
+ if test -e "$OPTIONAL_ROLLBACK_RECORD"; then
1084
+ test -f "$OPTIONAL_ROLLBACK_RECORD" || return 1
1085
+ migrate_sanctuary_package_managed_bundle "$OPTIONAL_ROLLBACK_IMAGE_ID" rollback || return $?
1086
+ fi
1087
+ }
1088
+ finalize_sanctuary_bundle_rollback_if_retained() {
1089
+ FINALIZE_ROLLBACK_IMAGE_ID=$1
1090
+ FINALIZE_ROLLBACK_STATUS=$(read_sanctuary_bundle_transaction_status "$FINALIZE_ROLLBACK_IMAGE_ID") || return $?
1091
+ test "$FINALIZE_ROLLBACK_STATUS" != null || return 0
1092
+ FINALIZE_ROLLBACK_STATE=$(printf '%s' "$FINALIZE_ROLLBACK_STATUS" | /usr/local/bin/node -e '
1093
+ let input = "";
1094
+ process.stdin.setEncoding("utf8");
1095
+ process.stdin.on("data", chunk => { input += chunk; });
1096
+ process.stdin.on("end", () => {
1097
+ const value = JSON.parse(input);
1098
+ if (!value || !["rollback", "committing"].includes(value.state)) process.exit(1);
1099
+ process.stdout.write(value.state);
1100
+ });
1101
+ ') || return $?
1102
+ test "$FINALIZE_ROLLBACK_STATE" = rollback || return 1
1103
+ migrate_sanctuary_package_managed_bundle "$FINALIZE_ROLLBACK_IMAGE_ID" finalize-rollback || return $?
1104
+ }
1105
+ write_dockerman_recovery_evidence() {
1106
+ RECOVERY_EVIDENCE_BUNDLE_STATE=$1
1107
+ RECOVERY_EVIDENCE_PRODUCTION=$2
1108
+ RECOVERY_EVIDENCE_INSPECTION_PATH=$3
1109
+ RECOVERY_EVIDENCE_PATH=$4
1110
+ test ! -e "$RECOVERY_EVIDENCE_PATH" || return 1
1111
+ /usr/local/bin/node -e '
1112
+ const fs = require("node:fs");
1113
+ const [bundleJournalState, production, inspectionPath, outputPath] = process.argv.slice(1);
1114
+ const evidence = { bundleJournalState, production };
1115
+ if (inspectionPath !== "-") evidence.inspection = JSON.parse(fs.readFileSync(inspectionPath, "utf8"));
1116
+ fs.writeFileSync(outputPath, `${JSON.stringify(evidence)}\n`, { mode: 0o600, flag: "wx" });
1117
+ ' "$RECOVERY_EVIDENCE_BUNDLE_STATE" "$RECOVERY_EVIDENCE_PRODUCTION" "$RECOVERY_EVIDENCE_INSPECTION_PATH" "$RECOVERY_EVIDENCE_PATH" || return $?
1118
+ chown 0:0 "$RECOVERY_EVIDENCE_PATH" || return $?
1119
+ chmod 0600 "$RECOVERY_EVIDENCE_PATH" || return $?
1120
+ }
1121
+ recover_dockerman_template_transaction() {
1122
+ TEMPLATE_RECOVERY_IDENTITY=$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-identity) || return $?
1123
+ if test "$TEMPLATE_RECOVERY_IDENTITY" = null; then
1124
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recover-status)" = null || return 1
1125
+ return 0
1126
+ fi
1127
+ TEMPLATE_RECOVERY_IDENTITIES=$(printf '%s' "$TEMPLATE_RECOVERY_IDENTITY" | /usr/local/bin/node -e '
1128
+ let input = "";
1129
+ process.stdin.setEncoding("utf8");
1130
+ process.stdin.on("data", chunk => { input += chunk; });
1131
+ process.stdin.on("end", () => {
1132
+ const value = JSON.parse(input);
1133
+ if (!value || !["rollback", "committing"].includes(value.state)) process.exit(1);
1134
+ process.stdout.write([value.state, value.rollbackImageId, value.targetImageId, value.canonicalVersionTag, value.reviewedManifestDigest].join(" "));
1135
+ });
1136
+ ') || return $?
1137
+ set -- $TEMPLATE_RECOVERY_IDENTITIES
1138
+ test "$#" -eq 5 || return 1
1139
+ TEMPLATE_RECOVERY_STATE=$1
1140
+ TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID=$2
1141
+ TEMPLATE_RECOVERY_TARGET_IMAGE_ID=$3
1142
+ TEMPLATE_RECOVERY_VERSION_IMAGE=$4
1143
+ TEMPLATE_RECOVERY_MANIFEST_DIGEST=$5
1144
+ test "$TEMPLATE_RECOVERY_TARGET_IMAGE_ID" = "$IMAGE_ID" || return 1
1145
+ test "$TEMPLATE_RECOVERY_VERSION_IMAGE" = "$VERSION_IMAGE" || return 1
1146
+ test "$TEMPLATE_RECOVERY_MANIFEST_DIGEST" = "$MANIFEST_DIGEST" || return 1
1147
+ validate_exact_image_id "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return $?
1148
+ TEMPLATE_RECOVERY_BUNDLE_STATUS=$(read_sanctuary_bundle_transaction_status "$IMAGE_ID") || return $?
1149
+ TEMPLATE_RECOVERY_BUNDLE_IDENTITIES=$(printf '%s' "$TEMPLATE_RECOVERY_BUNDLE_STATUS" | /usr/local/bin/node -e '
1150
+ let input = "";
1151
+ process.stdin.setEncoding("utf8");
1152
+ process.stdin.on("data", chunk => { input += chunk; });
1153
+ process.stdin.on("end", () => {
1154
+ const value = JSON.parse(input);
1155
+ if (value === null) process.stdout.write("absent - -");
1156
+ else if (value && ["rollback", "committing"].includes(value.state) && typeof value.rollbackImageId === "string" && typeof value.targetImageId === "string") process.stdout.write([value.state, value.rollbackImageId, value.targetImageId].join(" "));
1157
+ else process.exit(1);
1158
+ });
1159
+ ') || return $?
1160
+ set -- $TEMPLATE_RECOVERY_BUNDLE_IDENTITIES
1161
+ test "$#" -eq 3 || return 1
1162
+ TEMPLATE_RECOVERY_BUNDLE_STATE=$1
1163
+ TEMPLATE_RECOVERY_BUNDLE_ROLLBACK_IMAGE_ID=$2
1164
+ TEMPLATE_RECOVERY_BUNDLE_TARGET_IMAGE_ID=$3
1165
+ if test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = absent; then
1166
+ test "$TEMPLATE_RECOVERY_BUNDLE_ROLLBACK_IMAGE_ID" = - && test "$TEMPLATE_RECOVERY_BUNDLE_TARGET_IMAGE_ID" = - || return 1
1167
+ else
1168
+ validate_exact_image_id "$TEMPLATE_RECOVERY_BUNDLE_ROLLBACK_IMAGE_ID" || return $?
1169
+ validate_exact_image_id "$TEMPLATE_RECOVERY_BUNDLE_TARGET_IMAGE_ID" || return $?
1170
+ test "$TEMPLATE_RECOVERY_BUNDLE_ROLLBACK_IMAGE_ID" != "$TEMPLATE_RECOVERY_BUNDLE_TARGET_IMAGE_ID" || return 1
1171
+ test "$TEMPLATE_RECOVERY_BUNDLE_ROLLBACK_IMAGE_ID" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1172
+ test "$TEMPLATE_RECOVERY_BUNDLE_TARGET_IMAGE_ID" = "$TEMPLATE_RECOVERY_TARGET_IMAGE_ID" || return 1
1173
+ fi
1174
+ TEMPLATE_RECOVERY_STATUS=$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recover-status) || return $?
1175
+ test "$TEMPLATE_RECOVERY_STATUS" = "$TEMPLATE_RECOVERY_IDENTITY" || return 1
1176
+ TEMPLATE_RECOVERY_EVIDENCE=$EVENT_ASSET_STAGE/template-recovery-evidence.json
1177
+ TEMPLATE_RECOVERY_INSPECTION=$EVENT_ASSET_STAGE/template-recovery-inspection.json
1178
+ TEMPLATE_RECOVERY_FINAL_ROOT=$EVENT_ASSET_STAGE/template-recovery-final
1179
+ if test "$TEMPLATE_RECOVERY_STATE" = rollback; then
1180
+ disable_butler_autostart || return $?
1181
+ retire_sanctuary_authority_if_pending || return $?
1182
+ fi
1183
+ if test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = rollback; then
1184
+ recover_pending_sanctuary_bundle_migration "$IMAGE_ID" || return $?
1185
+ TEMPLATE_RECOVERY_POST_BUNDLE_STATUS=$(read_sanctuary_bundle_transaction_status "$IMAGE_ID") || return $?
1186
+ test "$TEMPLATE_RECOVERY_POST_BUNDLE_STATUS" = null || return 1
1187
+ test "$(docker inspect --format '{{.Image}}' ouro-butler)" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1188
+ write_dockerman_recovery_evidence absent rollback-exact - "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1189
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"restore-prior-template"}' || return 1
1190
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null || return $?
1191
+ return 0
1192
+ fi
1193
+ TEMPLATE_RECOVERY_PRODUCTION_PRESENT=false
1194
+ TEMPLATE_RECOVERY_PRODUCTION_IMAGE_ID=
1195
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
1196
+ TEMPLATE_RECOVERY_PRODUCTION_PRESENT=true
1197
+ TEMPLATE_RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
1198
+ fi
1199
+ if test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = committing; then
1200
+ test "$TEMPLATE_RECOVERY_PRODUCTION_PRESENT" = true || return 1
1201
+ test "$TEMPLATE_RECOVERY_STATE" = committing || return 1
1202
+ test "$TEMPLATE_RECOVERY_PRODUCTION_IMAGE_ID" = "$IMAGE_ID" || return 1
1203
+ test "$(docker inspect --format '{{.Image}}' ouro-butler-rollback)" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1204
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false || return 1
1205
+ assert_only_running_butler ouro-butler || return $?
1206
+ audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" "$TARGET_MOUNT_CONTRACT" "$VERSION_IMAGE" "$TEMPLATE_ICON" || return $?
1207
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" inspect >"$TEMPLATE_RECOVERY_INSPECTION" || return $?
1208
+ chown 0:0 "$TEMPLATE_RECOVERY_INSPECTION" && chmod 0600 "$TEMPLATE_RECOVERY_INSPECTION" || return $?
1209
+ write_dockerman_recovery_evidence committing target-exact-committing "$TEMPLATE_RECOVERY_INSPECTION" "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1210
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"finish-bundle-commit"}' || return 1
1211
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit || return $?
1212
+ wait_butler_ready ouro-butler || return $?
1213
+ test "$(docker inspect --format '{{.Image}}' ouro-butler)" = "$IMAGE_ID" || return 1
1214
+ verify_butler_autostart "1 0 0 0" || return $?
1215
+ elif test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = absent && test "$TEMPLATE_RECOVERY_PRODUCTION_PRESENT" = false; then
1216
+ test "$TEMPLATE_RECOVERY_STATE" = rollback || return 1
1217
+ ! docker container inspect ouro-butler-staging >/dev/null 2>&1 || return 1
1218
+ assert_only_running_butler - || return $?
1219
+ if docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
1220
+ TEMPLATE_RECOVERY_CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
1221
+ test "$TEMPLATE_RECOVERY_CURRENT_ROLLBACK_IMAGE_ID" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1222
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false || return 1
1223
+ docker rename ouro-butler-rollback ouro-butler || return $?
1224
+ assert_update_source "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" || return $?
1225
+ start_only_butler_for_recovery || return $?
1226
+ wait_butler_ready ouro-butler || return $?
1227
+ enable_butler_autostart || return $?
1228
+ write_dockerman_recovery_evidence absent rollback-exact - "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1229
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"restore-prior-template"}' || return 1
1230
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null || return $?
1231
+ return 0
1232
+ fi
1233
+ TEMPLATE_RECOVERY_LEGACY_EVIDENCE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
1234
+ test "$TEMPLATE_RECOVERY_LEGACY_EVIDENCE_IMAGE_ID" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1235
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence)" = false || return 1
1236
+ write_dockerman_recovery_evidence absent adoption-evidence-exact-stopped - "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1237
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"quarantine-adoption"}' || return 1
1238
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null || return $?
1239
+ printf '%s\n' 'Legacy adoption recovery requires a reviewed retry; exact legacy evidence remains quarantined and stopped.' >&2
1240
+ return 1
1241
+ elif test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = absent && test "$TEMPLATE_RECOVERY_STATE" = rollback && test "$TEMPLATE_RECOVERY_PRODUCTION_IMAGE_ID" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID"; then
1242
+ ! docker container inspect ouro-butler-staging >/dev/null 2>&1 || return 1
1243
+ ! docker container inspect ouro-butler-rollback >/dev/null 2>&1 || return 1
1244
+ assert_update_source "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" || return $?
1245
+ start_only_butler_for_recovery || return $?
1246
+ wait_butler_ready ouro-butler || return $?
1247
+ enable_butler_autostart || return $?
1248
+ write_dockerman_recovery_evidence absent rollback-exact - "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1249
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"restore-prior-template"}' || return 1
1250
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null || return $?
1251
+ return 0
1252
+ elif test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = absent && test "$TEMPLATE_RECOVERY_STATE" = rollback && test "$TEMPLATE_RECOVERY_PRODUCTION_IMAGE_ID" = "$IMAGE_ID"; then
1253
+ ! docker container inspect ouro-butler-staging >/dev/null 2>&1 || return 1
1254
+ ! docker container inspect ouro-butler-rollback >/dev/null 2>&1 || return 1
1255
+ TEMPLATE_RECOVERY_LEGACY_EVIDENCE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
1256
+ test "$TEMPLATE_RECOVERY_LEGACY_EVIDENCE_IMAGE_ID" = "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return 1
1257
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence)" = false || return 1
1258
+ audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" "$TARGET_MOUNT_CONTRACT" "$VERSION_IMAGE" "$TEMPLATE_ICON" || return $?
1259
+ start_only_butler_for_recovery || return $?
1260
+ wait_butler_ready ouro-butler || return $?
1261
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" inspect >"$TEMPLATE_RECOVERY_INSPECTION" || return $?
1262
+ chown 0:0 "$TEMPLATE_RECOVERY_INSPECTION" && chmod 0600 "$TEMPLATE_RECOVERY_INSPECTION" || return $?
1263
+ write_dockerman_recovery_evidence absent adoption-target-exact-ready "$TEMPLATE_RECOVERY_INSPECTION" "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1264
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"roll-forward-adoption"}' || return 1
1265
+ enable_butler_autostart || return $?
1266
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" mark-committing >/dev/null || return $?
1267
+ elif test "$TEMPLATE_RECOVERY_BUNDLE_STATE" = absent && test "$TEMPLATE_RECOVERY_STATE" = committing && test "$TEMPLATE_RECOVERY_PRODUCTION_IMAGE_ID" = "$IMAGE_ID"; then
1268
+ audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" "$TARGET_MOUNT_CONTRACT" "$VERSION_IMAGE" "$TEMPLATE_ICON" || return $?
1269
+ wait_butler_ready ouro-butler || return $?
1270
+ verify_butler_autostart "1 0 0 0" || return $?
1271
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" inspect >"$TEMPLATE_RECOVERY_INSPECTION" || return $?
1272
+ chown 0:0 "$TEMPLATE_RECOVERY_INSPECTION" && chmod 0600 "$TEMPLATE_RECOVERY_INSPECTION" || return $?
1273
+ write_dockerman_recovery_evidence absent target-exact-ready "$TEMPLATE_RECOVERY_INSPECTION" "$TEMPLATE_RECOVERY_EVIDENCE" || return $?
1274
+ test "$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" recovery-action --evidence "$TEMPLATE_RECOVERY_EVIDENCE")" = '{"action":"finish-template-commit"}' || return 1
1275
+ else
1276
+ return 1
1277
+ fi
1278
+ TEMPLATE_RECOVERY_FINAL_PROOF=$(write_dockerman_final_proof "$IMAGE_ID" "$VERSION_IMAGE" "$TEMPLATE_ICON" "$TEMPLATE_RECOVERY_FINAL_ROOT") || return $?
1279
+ verify_known_good_rollback_artifact "$TEMPLATE_RECOVERY_ROLLBACK_IMAGE_ID" || return $?
1280
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" commit --proof "$TEMPLATE_RECOVERY_FINAL_PROOF" >/dev/null || return $?
1281
+ }
1282
+ # Package installation and migration never grant restart authority. The
1283
+ # migration is the pre-activation assertion: it fails before live mutation
1284
+ # if packaged steward.json carries any routine grant. It preserves an
1285
+ # existing live steward policy and its audit receipts byte for byte. On a
1286
+ # new installation, start the healthy private runtime, then use
1287
+ # an authenticated owner Telegram turn to grant each exact restart target
1288
+ # through steward_policy_manage. That existing tool binds the grant to the
1289
+ # verified owner relationship and durable session event. Do not seed grants
1290
+ # in the packaged steward.json, copy an old session-event ID, or edit the
1291
+ # live policy/audit files directly. A missing grant remains fail-closed until
1292
+ # that explicit owner ceremony succeeds.
1293
+ prepare_canonical_sanctuary_roots() {
1294
+ PREPARE_IMAGE_ID=$1
1295
+ validate_exact_image_id "$PREPARE_IMAGE_ID" || return $?
1296
+ PREPARE_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1297
+ PREPARE_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1298
+ install -d -m 0700 -o 10001 -g 10001 "$PREPARE_RUNTIME_ROOT" || return $?
1299
+ install -d -m 0700 -o 10001 -g 10001 "$PREPARE_AGENT_ROOT" || return $?
1300
+ ensure_sanctuary_machine_identity "$PREPARE_RUNTIME_ROOT/machine.json" || return $?
1301
+ if test ! -f "$PREPARE_AGENT_ROOT/agent.json"; then
1302
+ PREPARE_EXISTING_ENTRY=$(find "$PREPARE_AGENT_ROOT" -mindepth 1 -print -quit) || return $?
1303
+ test -z "$PREPARE_EXISTING_ENTRY" || return $?
1304
+ ! docker container inspect ouro-butler-bundle-bootstrap >/dev/null 2>&1 || return 1
1305
+ docker run --rm --pull=never --network=none --name ouro-butler-bundle-bootstrap --user 10001:10001 \
1306
+ --mount "type=bind,src=$PREPARE_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1307
+ --mount "type=bind,src=$PREPARE_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1308
+ --entrypoint /bin/sh "$PREPARE_IMAGE_ID" -ceu '
1309
+ cp -R /opt/ouro/deploy/unraid/sanctuary.ouro/. /home/ouro/AgentBundles/sanctuary.ouro/
1310
+ find /home/ouro/AgentBundles/sanctuary.ouro -type d -exec chmod 0700 {} +
1311
+ find /home/ouro/AgentBundles/sanctuary.ouro -type f -exec chmod 0600 {} +
1312
+ ' || return $?
1313
+ ! docker container inspect ouro-butler-bundle-bootstrap >/dev/null 2>&1 || return 1
1314
+ fi
1315
+ for PREPARE_REQUIRED_FILE in \
1316
+ agent.json bundle-meta.json provider-readiness.json tool-profiles.json \
1317
+ psyche/SOUL.md habits/sanctuary-health.md; do
1318
+ test -f "$PREPARE_AGENT_ROOT/$PREPARE_REQUIRED_FILE" || return $?
1319
+ done
1320
+ PREPARE_WRONG_OWNER=$(find "$PREPARE_RUNTIME_ROOT" "$PREPARE_AGENT_ROOT" \( ! -user 10001 -o ! -group 10001 \) -print -quit) || return $?
1321
+ test -z "$PREPARE_WRONG_OWNER" || return $?
1322
+ PREPARE_WRONG_DIR_MODE=$(find "$PREPARE_RUNTIME_ROOT" "$PREPARE_AGENT_ROOT" -type d ! -perm 0700 -print -quit) || return $?
1323
+ test -z "$PREPARE_WRONG_DIR_MODE" || return $?
1324
+ PREPARE_WRONG_FILE_MODE=$(find "$PREPARE_RUNTIME_ROOT" "$PREPARE_AGENT_ROOT" -type f ! -perm 0600 -print -quit) || return $?
1325
+ test -z "$PREPARE_WRONG_FILE_MODE" || return $?
1326
+ }
1327
+ classify_sanctuary_update_source() {
1328
+ UPDATE_ENTRY_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
1329
+ if printf '%s\n' "$UPDATE_ENTRY_CONTAINER_NAMES" | grep -Fxq ouro-butler-staging; then
1330
+ return 1
1331
+ else
1332
+ UPDATE_ENTRY_SOURCE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
1333
+ assert_update_topology "$UPDATE_ENTRY_SOURCE_IMAGE_ID" || return $?
1334
+ assert_sanctuary_update_source_pin ouro-butler "$UPDATE_ENTRY_SOURCE_IMAGE_ID"
1335
+ fi
1336
+ }
1337
+ admit_sanctuary_update_entry() {
1338
+ UPDATE_ENTRY_RECOVERY_JOURNAL=/boot/config/custom/ouro-butler/docker-man-template-transaction.json
1339
+ if test -e "$UPDATE_ENTRY_RECOVERY_JOURNAL" || test -L "$UPDATE_ENTRY_RECOVERY_JOURNAL"; then
1340
+ test -f "$UPDATE_ENTRY_RECOVERY_JOURNAL" && test ! -L "$UPDATE_ENTRY_RECOVERY_JOURNAL" || return 1
1341
+ test "$(stat -c '%u:%g:%a' "$UPDATE_ENTRY_RECOVERY_JOURNAL")" = 0:0:600
1342
+ else
1343
+ classify_sanctuary_update_source
1344
+ fi
1345
+ }
1346
+ validate_sanctuary_legacy_import_marker() {
1347
+ IMPORT_MARKER=$1
1348
+ IMPORT_SOURCE=$2
1349
+ test -f "$IMPORT_MARKER" && test ! -L "$IMPORT_MARKER" || return 1
1350
+ test "$(stat -c '%u:%g %a' "$IMPORT_MARKER")" = "10001:10001 600" || return 1
1351
+ /usr/local/bin/node -e '
1352
+ const crypto = require("node:crypto");
1353
+ const fs = require("node:fs");
1354
+ const marker = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
1355
+ const sourceDigest = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(process.argv[2])).digest("hex")}`;
1356
+ if (JSON.stringify(Object.keys(marker).sort()) !== JSON.stringify(["importedAt", "machineId", "schemaVersion", "sourceDigest"])) process.exit(1);
1357
+ if (marker.schemaVersion !== 1 || marker.machineId !== "sanctuary" || marker.sourceDigest !== sourceDigest) process.exit(1);
1358
+ if (!Number.isFinite(Date.parse(marker.importedAt))) process.exit(1);
1359
+ ' "$IMPORT_MARKER" "$IMPORT_SOURCE"
1360
+ }
1361
+ record_sanctuary_legacy_import_marker() {
1362
+ IMPORT_MARKER=$1
1363
+ IMPORT_SOURCE=$2
1364
+ test ! -e "$IMPORT_MARKER" || return 1
1365
+ IMPORT_MARKER_TMP=$(mktemp "${IMPORT_MARKER}.tmp.XXXXXX") || return $?
1366
+ trap 'rm -f -- "$IMPORT_MARKER_TMP"' EXIT || return $?
1367
+ /usr/local/bin/node -e '
1368
+ const crypto = require("node:crypto");
1369
+ const fs = require("node:fs");
1370
+ const sourceDigest = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(process.argv[2])).digest("hex")}`;
1371
+ const marker = { schemaVersion: 1, machineId: "sanctuary", sourceDigest, importedAt: new Date().toISOString() };
1372
+ fs.writeFileSync(process.argv[1], `${JSON.stringify(marker)}\n`, { mode: 0o600, flag: "w" });
1373
+ ' "$IMPORT_MARKER_TMP" "$IMPORT_SOURCE" || return $?
1374
+ chown 10001:10001 "$IMPORT_MARKER_TMP" || return $?
1375
+ chmod 0600 "$IMPORT_MARKER_TMP" || return $?
1376
+ mv -f -- "$IMPORT_MARKER_TMP" "$IMPORT_MARKER" || return $?
1377
+ sync -f "$IMPORT_MARKER" || return $?
1378
+ sync -f "$(dirname "$IMPORT_MARKER")" || return $?
1379
+ trap - EXIT || return $?
1380
+ }
1381
+ bootstrap_sanctuary_vault() {
1382
+ BOOTSTRAP_IMAGE_ID=$1
1383
+ BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE=${2-}
1384
+ BOOTSTRAP_SOURCE_MACHINE_ID=${3-}
1385
+ BOOTSTRAP_TARGET_MACHINE_ID=${4-}
1386
+ validate_exact_image_id "$BOOTSTRAP_IMAGE_ID" || return $?
1387
+ case "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" in
1388
+ "")
1389
+ test -z "$BOOTSTRAP_SOURCE_MACHINE_ID" || return 1
1390
+ test -z "$BOOTSTRAP_TARGET_MACHINE_ID" || return 1
1391
+ ;;
1392
+ /mnt/user/appdata/ouro-butler/runtime/container-credentials.json)
1393
+ test "$BOOTSTRAP_SOURCE_MACHINE_ID" = sanctuary-unraid || return 1
1394
+ test "$BOOTSTRAP_TARGET_MACHINE_ID" = sanctuary || return 1
1395
+ ;;
1396
+ *) return 1 ;;
1397
+ esac
1398
+ BOOTSTRAP_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1399
+ BOOTSTRAP_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1400
+ for BOOTSTRAP_CONTAINER in ouro-butler-vault-status ouro-butler-vault-bootstrap ouro-butler-credential-bootstrap ouro-butler-provider-readiness; do
1401
+ ! docker container inspect "$BOOTSTRAP_CONTAINER" >/dev/null 2>&1 || return 1
1402
+ done
1403
+ VAULT_STATUS=$(docker run --rm --pull=never --network host --name ouro-butler-vault-status --user 10001:10001 \
1404
+ --mount "type=bind,src=$BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1405
+ --mount "type=bind,src=$BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1406
+ --entrypoint node "$BOOTSTRAP_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
1407
+ vault status --agent sanctuary --store plaintext-file) || return $?
1408
+ ! docker container inspect ouro-butler-vault-status >/dev/null 2>&1 || return 1
1409
+ case "
1410
+ $VAULT_STATUS
1411
+ " in
1412
+ *"
1413
+ vault locator: not configured in agent.json
1414
+ "*) VAULT_ACTION=create ;;
1415
+ *"
1416
+ vault locator: agent.json
1417
+ "*)
1418
+ case "
1419
+ $VAULT_STATUS
1420
+ " in
1421
+ *"
1422
+ local unlock: available
1423
+ "*) VAULT_ACTION=none ;;
1424
+ *"
1425
+ local unlock: missing
1426
+ "*) VAULT_ACTION=unlock ;;
1427
+ *) return 1 ;;
1428
+ esac
1429
+ ;;
1430
+ *) return 1 ;;
1431
+ esac
1432
+ if test "$VAULT_ACTION" != none; then
1433
+ docker run --rm -it --pull=never --network host --name ouro-butler-vault-bootstrap --user 10001:10001 \
1434
+ --mount "type=bind,src=$BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1435
+ --mount "type=bind,src=$BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1436
+ --entrypoint node "$BOOTSTRAP_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
1437
+ vault "$VAULT_ACTION" --agent sanctuary --store plaintext-file || return $?
1438
+ ! docker container inspect ouro-butler-vault-bootstrap >/dev/null 2>&1 || return 1
1439
+ fi
1440
+ VERIFIED_VAULT_STATUS=$(docker run --rm --pull=never --network host --name ouro-butler-vault-status --user 10001:10001 \
1441
+ --mount "type=bind,src=$BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1442
+ --mount "type=bind,src=$BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1443
+ --entrypoint node "$BOOTSTRAP_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
1444
+ vault status --agent sanctuary --store plaintext-file) || return $?
1445
+ ! docker container inspect ouro-butler-vault-status >/dev/null 2>&1 || return 1
1446
+ case "
1447
+ $VERIFIED_VAULT_STATUS
1448
+ " in
1449
+ *"
1450
+ vault locator: agent.json
1451
+ "*) ;;
1452
+ *) return 1 ;;
1453
+ esac
1454
+ case "
1455
+ $VERIFIED_VAULT_STATUS
1456
+ " in
1457
+ *"
1458
+ local unlock: available
1459
+ "*) ;;
1460
+ *) return 1 ;;
1461
+ esac
1462
+ if test -n "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE"; then
1463
+ BOOTSTRAP_CREDENTIAL_SOURCE="$BOOTSTRAP_RUNTIME_ROOT/container-credentials.json"
1464
+ BOOTSTRAP_CREDENTIAL_CLAIM="$BOOTSTRAP_CREDENTIAL_SOURCE.consuming"
1465
+ test -f "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1466
+ test ! -L "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1467
+ BOOTSTRAP_LEGACY_METADATA=$(stat -c '%u:%g %a' "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE") || return $?
1468
+ test "$BOOTSTRAP_LEGACY_METADATA" = "10001:10001 600" || return $?
1469
+ BOOTSTRAP_IMPORT_MARKER="$BOOTSTRAP_RUNTIME_ROOT/legacy-credentials-imported.json"
1470
+ if test -e "$BOOTSTRAP_IMPORT_MARKER"; then
1471
+ validate_sanctuary_legacy_import_marker "$BOOTSTRAP_IMPORT_MARKER" "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1472
+ test ! -e "$BOOTSTRAP_CREDENTIAL_SOURCE" || return 1
1473
+ test ! -e "$BOOTSTRAP_CREDENTIAL_CLAIM" || return 1
1474
+ else
1475
+ if test -e "$BOOTSTRAP_CREDENTIAL_SOURCE"; then
1476
+ test -f "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
1477
+ test ! -L "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
1478
+ test "$(stat -c '%u:%g %a' "$BOOTSTRAP_CREDENTIAL_SOURCE")" = "10001:10001 600" || return $?
1479
+ cmp -s "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
1480
+ elif test -e "$BOOTSTRAP_CREDENTIAL_CLAIM"; then
1481
+ test -f "$BOOTSTRAP_CREDENTIAL_CLAIM" || return $?
1482
+ test ! -L "$BOOTSTRAP_CREDENTIAL_CLAIM" || return $?
1483
+ test "$(stat -c '%u:%g %a' "$BOOTSTRAP_CREDENTIAL_CLAIM")" = "10001:10001 600" || return $?
1484
+ cmp -s "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" "$BOOTSTRAP_CREDENTIAL_CLAIM" || return $?
1485
+ else
1486
+ BOOTSTRAP_CREDENTIAL_TMP=$(mktemp "$BOOTSTRAP_RUNTIME_ROOT/container-credentials.json.tmp.XXXXXX") || return $?
1487
+ trap 'rm -f -- "$BOOTSTRAP_CREDENTIAL_TMP"' EXIT || return $?
1488
+ install -m 0600 -o 10001 -g 10001 "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" "$BOOTSTRAP_CREDENTIAL_TMP" || return $?
1489
+ cmp -s "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" "$BOOTSTRAP_CREDENTIAL_TMP" || return $?
1490
+ sync -f "$BOOTSTRAP_CREDENTIAL_TMP" || return $?
1491
+ mv -f -- "$BOOTSTRAP_CREDENTIAL_TMP" "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
1492
+ sync -f "$BOOTSTRAP_RUNTIME_ROOT" || return $?
1493
+ trap - EXIT || return $?
1494
+ fi
1495
+ docker run --rm --pull=never --network host --name ouro-butler-credential-bootstrap --user 10001:10001 \
1496
+ --mount "type=bind,src=$BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1497
+ --mount "type=bind,src=$BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1498
+ --entrypoint node "$BOOTSTRAP_IMAGE_ID" -e '
1499
+ const { loadContainerCredentialBootstrap } = require("/opt/ouro/dist/heart/daemon/container-credential-bootstrap.js")
1500
+ loadContainerCredentialBootstrap(["sanctuary"], {
1501
+ machineIdMigration: {
1502
+ sourceMachineId: process.argv[1],
1503
+ targetMachineId: process.argv[2],
1504
+ },
1505
+ }).then(
1506
+ () => process.exit(0),
1507
+ () => { process.stderr.write("credential bootstrap failed\n"); process.exit(1) },
1508
+ )
1509
+ ' "$BOOTSTRAP_SOURCE_MACHINE_ID" "$BOOTSTRAP_TARGET_MACHINE_ID" || return $?
1510
+ # The path-locked Sanctuary migration imports the legacy MiniMax provider
1511
+ # record into the agent vault before strict validation and persistence.
1512
+ # The original legacy envelope remains byte-for-byte unchanged and is the
1513
+ # sole digest authority.
1514
+ ! docker container inspect ouro-butler-credential-bootstrap >/dev/null 2>&1 || return 1
1515
+ test ! -e "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
1516
+ test ! -e "$BOOTSTRAP_CREDENTIAL_CLAIM" || return $?
1517
+ record_sanctuary_legacy_import_marker "$BOOTSTRAP_IMPORT_MARKER" "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1518
+ validate_sanctuary_legacy_import_marker "$BOOTSTRAP_IMPORT_MARKER" "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1519
+ fi
1520
+ test -f "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1521
+ test ! -L "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1522
+ test "$(stat -c '%u:%g %a' "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE")" = "$BOOTSTRAP_LEGACY_METADATA" || return $?
1523
+ fi
1524
+ validate_sanctuary_roots "$BOOTSTRAP_RUNTIME_ROOT" "$BOOTSTRAP_AGENT_ROOT" || return $?
1525
+ }
1526
+ provision_sanctuary_sab_credential() {
1527
+ SAB_BOOTSTRAP_IMAGE_ID=$1
1528
+ validate_exact_image_id "$SAB_BOOTSTRAP_IMAGE_ID" || return $?
1529
+ SAB_BOOTSTRAP_SOURCE=/mnt/user/appdata/sabnzbd/sabnzbd.ini
1530
+ SAB_BOOTSTRAP_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1531
+ SAB_BOOTSTRAP_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1532
+ SAB_BOOTSTRAP_ENVELOPE="$SAB_BOOTSTRAP_RUNTIME_ROOT/container-credentials.json"
1533
+ SAB_BOOTSTRAP_CLAIM="$SAB_BOOTSTRAP_ENVELOPE.consuming"
1534
+ test -f "$SAB_BOOTSTRAP_SOURCE" || return $?
1535
+ test ! -L "$SAB_BOOTSTRAP_SOURCE" || return 1
1536
+ test ! -e "$SAB_BOOTSTRAP_ENVELOPE" || return 1
1537
+ test ! -e "$SAB_BOOTSTRAP_CLAIM" || return 1
1538
+ ! docker container inspect ouro-butler-sab-credential-bootstrap >/dev/null 2>&1 || return 1
1539
+ SAB_BOOTSTRAP_TMP=$(mktemp "$SAB_BOOTSTRAP_RUNTIME_ROOT/container-credentials.json.tmp.XXXXXX") || return $?
1540
+ trap 'rm -f -- "$SAB_BOOTSTRAP_TMP"' EXIT || return $?
1541
+ /usr/local/bin/node -e '
1542
+ const fs = require("node:fs");
1543
+ const [source, destination] = process.argv.slice(1);
1544
+ const match = fs.readFileSync(source, "utf8").match(/^\s*api_key\s*=\s*(\S+)\s*$/mu);
1545
+ if (!match || !match[1]) process.exit(1);
1546
+ const credential = { type: "ouro.runtimeCredentialBootstrap", agentName: "sanctuary", machineId: "sanctuary", machineRuntimeConfig: { sabnzbdApiKey: match[1] } };
1547
+ const envelope = { schemaVersion: 1, credentials: [credential] };
1548
+ fs.writeFileSync(destination, `${JSON.stringify(envelope)}\n`, { mode: 0o600, flag: "w" });
1549
+ ' "$SAB_BOOTSTRAP_SOURCE" "$SAB_BOOTSTRAP_TMP" || return $?
1550
+ chown 10001:10001 "$SAB_BOOTSTRAP_TMP" || return $?
1551
+ chmod 0600 "$SAB_BOOTSTRAP_TMP" || return $?
1552
+ sync -f "$SAB_BOOTSTRAP_TMP" || return $?
1553
+ mv -f -- "$SAB_BOOTSTRAP_TMP" "$SAB_BOOTSTRAP_ENVELOPE" || return $?
1554
+ sync -f "$SAB_BOOTSTRAP_RUNTIME_ROOT" || return $?
1555
+ trap - EXIT || return $?
1556
+ docker run --rm --pull=never --network host --name ouro-butler-sab-credential-bootstrap --user 10001:10001 \
1557
+ --mount "type=bind,src=$SAB_BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1558
+ --mount "type=bind,src=$SAB_BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1559
+ --entrypoint node "$SAB_BOOTSTRAP_IMAGE_ID" -e '
1560
+ const { loadContainerCredentialBootstrap } = require("/opt/ouro/dist/heart/daemon/container-credential-bootstrap.js");
1561
+ loadContainerCredentialBootstrap(["sanctuary"]).then(
1562
+ () => process.exit(0),
1563
+ () => { process.stderr.write("SAB credential bootstrap failed\n"); process.exit(1); },
1564
+ );
1565
+ ' || return $?
1566
+ ! docker container inspect ouro-butler-sab-credential-bootstrap >/dev/null 2>&1 || return 1
1567
+ test ! -e "$SAB_BOOTSTRAP_ENVELOPE" || return 1
1568
+ test ! -e "$SAB_BOOTSTRAP_CLAIM" || return 1
1569
+ }
1570
+ verify_sanctuary_sab_readiness() {
1571
+ SAB_READINESS_IMAGE_ID=$1
1572
+ validate_exact_image_id "$SAB_READINESS_IMAGE_ID" || return $?
1573
+ ! docker container inspect ouro-butler-sab-readiness >/dev/null 2>&1 || return 1
1574
+ docker run --rm --pull=never --network host --name ouro-butler-sab-readiness --user 10001:10001 \
1575
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
1576
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1577
+ --entrypoint node "$SAB_READINESS_IMAGE_ID" -e '
1578
+ (async () => {
1579
+ try {
1580
+ const { refreshMachineRuntimeCredentialConfig } = require("/opt/ouro/dist/heart/runtime-credentials.js");
1581
+ const { createSanctuarySabClient } = require("/opt/ouro/dist/senses/sanctuary-sab.js");
1582
+ const current = await refreshMachineRuntimeCredentialConfig("sanctuary", "sanctuary");
1583
+ if (!current.ok || typeof current.config.sabnzbdApiKey !== "string" || !current.config.sabnzbdApiKey.trim()) throw new Error();
1584
+ const snapshot = await createSanctuarySabClient({ loadApiKey: async () => current.config.sabnzbdApiKey }).readQueue();
1585
+ if (typeof snapshot.paused !== "boolean" || !Number.isSafeInteger(snapshot.queuedJobs) || !snapshot.observedAt) throw new Error();
1586
+ process.stdout.write("Sanctuary SAB queue readiness verified.\n");
1587
+ } catch {
1588
+ process.stderr.write("Sanctuary SAB queue readiness verification failed.\n");
1589
+ process.exitCode = 1;
1590
+ }
1591
+ })();
1592
+ ' || return $?
1593
+ ! docker container inspect ouro-butler-sab-readiness >/dev/null 2>&1 || return 1
1594
+ }
1595
+ authenticate_sanctuary_provider() {
1596
+ AUTH_IMAGE_ID=$1
1597
+ AUTH_PROVIDER=${2-}
1598
+ validate_exact_image_id "$AUTH_IMAGE_ID" || return $?
1599
+ case "$AUTH_PROVIDER" in
1600
+ openai-compatible) AUTH_CONTAINER=ouro-butler-provider-auth-glm ;;
1601
+ openai-compatible-gemini) AUTH_CONTAINER=ouro-butler-provider-auth-gemini ;;
1602
+ *) return 1 ;;
1603
+ esac
1604
+ AUTH_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1605
+ AUTH_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1606
+ ! docker container inspect "$AUTH_CONTAINER" >/dev/null 2>&1 || return 1
1607
+ docker run --rm -it --pull=never --network host --name "$AUTH_CONTAINER" --user 10001:10001 \
1608
+ --mount "type=bind,src=$AUTH_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1609
+ --mount "type=bind,src=$AUTH_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1610
+ --entrypoint node "$AUTH_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
1611
+ auth --agent sanctuary --provider "$AUTH_PROVIDER" || return $?
1612
+ ! docker container inspect "$AUTH_CONTAINER" >/dev/null 2>&1 || return 1
1613
+ }
1614
+ verify_sanctuary_provider_readiness() {
1615
+ READINESS_IMAGE_ID=$1
1616
+ validate_exact_image_id "$READINESS_IMAGE_ID" || return $?
1617
+ READINESS_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1618
+ READINESS_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1619
+ ! docker container inspect ouro-butler-provider-readiness >/dev/null 2>&1 || return 1
1620
+ docker run --rm --pull=never --network host --name ouro-butler-provider-readiness --user 10001:10001 \
1621
+ --mount "type=bind,src=$READINESS_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1622
+ --mount "type=bind,src=$READINESS_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1623
+ --entrypoint /bin/sh "$READINESS_IMAGE_ID" -ceu '
1624
+ umask 077
1625
+ node /opt/ouro/dist/heart/daemon/ouro-entry.js check --agent sanctuary --lane outward
1626
+ node /opt/ouro/dist/heart/daemon/ouro-entry.js check --agent sanctuary --lane inner
1627
+ node - <<'"'"'NODE'"'"'
1628
+ (async () => {
1629
+ try {
1630
+ const fs = require("node:fs");
1631
+ const root = "/home/ouro/AgentBundles/sanctuary.ouro";
1632
+ const agent = JSON.parse(fs.readFileSync(`${root}/agent.json`, "utf8"));
1633
+ const policy = JSON.parse(fs.readFileSync(`${root}/provider-readiness.json`, "utf8"));
1634
+ const expectedPolicy = { version: 1, selectionPolicy: "explicit-same-lane-only" };
1635
+ const expected = new Map([
1636
+ ["minimax", { provider: "minimax", model: "MiniMax-M3", vaultItem: "providers/minimax" }],
1637
+ ]);
1638
+ const exactLane = value => value && value.provider === "minimax" && value.model === "MiniMax-M3";
1639
+ if (!exactLane(agent.humanFacing) || !exactLane(agent.agentFacing)
1640
+ || policy.version !== expectedPolicy.version || policy.selectionPolicy !== expectedPolicy.selectionPolicy
1641
+ || !Array.isArray(policy.providers) || policy.providers.length !== expected.size) throw new Error();
1642
+ for (const entry of policy.providers) {
1643
+ const wanted = expected.get(entry.provider);
1644
+ if (!wanted || entry.model !== wanted.model || entry.vaultItem !== wanted.vaultItem) throw new Error();
1645
+ expected.delete(entry.provider);
1646
+ }
1647
+ if (expected.size !== 0) throw new Error();
1648
+ const { refreshProviderCredentialPool } = require("/opt/ouro/dist/heart/provider-credentials.js");
1649
+ const refreshed = await refreshProviderCredentialPool("sanctuary", {
1650
+ providers: ["minimax"],
1651
+ skipCache: true,
1652
+ });
1653
+ if (!refreshed.ok) throw new Error();
1654
+ const minimax = refreshed.pool.providers["minimax"];
1655
+ const exactRecord = (record, provider) => record && record.provider === provider
1656
+ && typeof record.revision === "string" && record.revision.length > 0
1657
+ && typeof record.credentials?.apiKey === "string" && record.credentials.apiKey.trim().length > 0;
1658
+ if (!exactRecord(minimax, "minimax")) throw new Error();
1659
+ process.stdout.write("Sanctuary provider readiness verified.\n");
1660
+ } catch {
1661
+ process.stderr.write("Sanctuary provider readiness verification failed.\n");
1662
+ process.exitCode = 1;
1663
+ }
1664
+ })();
1665
+ NODE
1666
+ ' || return $?
1667
+ ! docker container inspect ouro-butler-provider-readiness >/dev/null 2>&1 || return 1
1668
+ validate_sanctuary_roots "$READINESS_RUNTIME_ROOT" "$READINESS_AGENT_ROOT" || return $?
1669
+ }
1670
+ verify_sanctuary_telegram_readiness() {
1671
+ TELEGRAM_READINESS_IMAGE_ID=$1
1672
+ validate_exact_image_id "$TELEGRAM_READINESS_IMAGE_ID" || return $?
1673
+ TELEGRAM_READINESS_CONTRACT=$(sanctuary_image_mount_contract "$TELEGRAM_READINESS_IMAGE_ID") || return $?
1674
+ TELEGRAM_READINESS_CONTEXT=${2-}
1675
+ case "$TELEGRAM_READINESS_CONTRACT" in
1676
+ canonical-gateway) TELEGRAM_READINESS_CONTEXT=${TELEGRAM_READINESS_CONTEXT:-strict}; set -- --mount "type=bind,src=/run/ouro-authority,dst=/run/ouro-authority,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche,dst=/home/ouro/AgentBundles/sanctuary.ouro/psyche,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/trust/sanctuary,dst=/etc/ouro/trust/sanctuary,readonly" ;;
1677
+ canonical-pre-gateway) TELEGRAM_READINESS_CONTEXT=${TELEGRAM_READINESS_CONTEXT:-live-precutover}; set -- ;;
1678
+ *) return 1 ;;
1679
+ esac
1680
+ case "$TELEGRAM_READINESS_CONTRACT:$TELEGRAM_READINESS_CONTEXT" in
1681
+ canonical-gateway:strict|canonical-pre-gateway:strict|canonical-pre-gateway:live-precutover) ;;
1682
+ *) return 1 ;;
1683
+ esac
1684
+ TELEGRAM_READINESS_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1685
+ TELEGRAM_READINESS_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1686
+ ! docker container inspect ouro-butler-telegram-readiness >/dev/null 2>&1 || return 1
1687
+ docker run --rm --pull=never --network host --name ouro-butler-telegram-readiness --user 10001:10001 \
1688
+ --read-only --cap-drop ALL --security-opt no-new-privileges \
1689
+ --mount "type=bind,src=$TELEGRAM_READINESS_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1690
+ --mount "type=bind,src=$TELEGRAM_READINESS_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro,readonly" \
1691
+ --entrypoint /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh \
1692
+ "$@" "$TELEGRAM_READINESS_IMAGE_ID" telegram-readiness >/dev/null || return $?
1693
+ ! docker container inspect ouro-butler-telegram-readiness >/dev/null 2>&1 || return 1
1694
+ normalize_sanctuary_private_permissions "$TELEGRAM_READINESS_RUNTIME_ROOT" "$TELEGRAM_READINESS_AGENT_ROOT" "$TELEGRAM_READINESS_IMAGE_ID" || return $?
1695
+ validate_sanctuary_roots "$TELEGRAM_READINESS_RUNTIME_ROOT" "$TELEGRAM_READINESS_AGENT_ROOT" "$TELEGRAM_READINESS_CONTEXT" || return $?
1696
+ }
1697
+ read_sanctuary_authority_state() {
1698
+ (
1699
+ test -n "${STAGED_DOCKERMAN_TRANSACTION-}" || return 1
1700
+ AUTHORITY_STATUS=$(/usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" status) || return $?
1701
+ printf '%s' "$AUTHORITY_STATUS" | /usr/local/bin/node -e '
1702
+ let input = "";
1703
+ process.stdin.setEncoding("utf8");
1704
+ process.stdin.on("data", chunk => { input += chunk; });
1705
+ process.stdin.on("end", () => {
1706
+ const record = JSON.parse(input);
1707
+ if (record === null || (record && !Object.hasOwn(record, "authority"))) process.stdout.write("none");
1708
+ else if (["installing", "active", "retiring", "retired"].includes(record?.authority?.state)) process.stdout.write(record.authority.state);
1709
+ else process.exit(1);
1710
+ });
1711
+ ' || return $?
1712
+ )
1713
+ }
1714
+ retire_sanctuary_authority_if_pending() {
1715
+ (
1716
+ AUTHORITY_STATE=$(read_sanctuary_authority_state) || return $?
1717
+ test "$AUTHORITY_STATE" != none || return 0
1718
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" authority-retire >/dev/null || return $?
1719
+ )
1720
+ }
1721
+ receive_sanctuary_authority_token() {
1722
+ (
1723
+ test "$(id -u)" = 0 && test -t 0 || return 1
1724
+ test "$(stat -c '%u:%g:%a' /mnt/user/appdata/ouro-authority)" = 0:0:700 || return 1
1725
+ test ! -e /mnt/user/appdata/ouro-authority/incoming-token && test ! -L /mnt/user/appdata/ouro-authority/incoming-token || return 1
1726
+ TOKEN_TERMINAL_MODE=$(stty -g) || return $?
1727
+ trap 'stty "$TOKEN_TERMINAL_MODE"' EXIT
1728
+ trap 'exit 129' HUP
1729
+ trap 'exit 130' INT
1730
+ trap 'exit 143' TERM
1731
+ printf '%s\n' 'Human: paste the freshly rotated same-bot token, press Enter, then Ctrl-D. Input is hidden.' >&2
1732
+ stty -echo || return $?
1733
+ umask 077
1734
+ set -C
1735
+ dd status=none > /mnt/user/appdata/ouro-authority/incoming-token || return $?
1736
+ test -s /mnt/user/appdata/ouro-authority/incoming-token || return 1
1737
+ sync -f /mnt/user/appdata/ouro-authority/incoming-token || return $?
1738
+ )
1739
+ }
1740
+ prepare_sanctuary_authority_inputs() {
1741
+ (
1742
+ AUTHORITY_SOURCE=$1
1743
+ test "$(id -u)" = 0 || return 1
1744
+ /usr/local/bin/node - "$AUTHORITY_SOURCE" "$AUTHORITY_EPOCH_ID" "$AUTHORITY_BOT_ID" "$AUTHORITY_OWNER_ID" <<'NODE'
1745
+ const fs = require("node:fs");
1746
+ const path = require("node:path");
1747
+ const crypto = require("node:crypto");
1748
+ const [source, epochId, botId, ownerId] = process.argv.slice(2);
1749
+ const root = "/mnt/user/appdata/ouro-authority";
1750
+ const incoming = `${root}/incoming-package`;
1751
+ const sha = bytes => `sha256:${crypto.createHash("sha256").update(bytes).digest("hex")}`;
1752
+ const directory = target => {
1753
+ if (!fs.existsSync(target)) fs.mkdirSync(target, { mode: 0o700 });
1754
+ const stat = fs.lstatSync(target);
1755
+ if (!stat.isDirectory() || fs.realpathSync(target) !== target || stat.uid !== 0 || stat.gid !== 0 || (stat.mode & 0o7777) !== 0o700) throw new Error("unsafe authority input directory");
1756
+ };
1757
+ const owned = (target, mode) => {
1758
+ const stat = fs.lstatSync(target);
1759
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.uid !== 0 || stat.gid !== 0 || (stat.mode & 0o7777) !== mode) throw new Error("unsafe authority input file");
1760
+ return fs.readFileSync(target);
1761
+ };
1762
+ const persist = (target, bytes, mode) => {
1763
+ if (fs.existsSync(target)) {
1764
+ if (!owned(target, mode).equals(bytes)) throw new Error("authority input conflicts with retained recovery bytes");
1765
+ } else {
1766
+ const fd = fs.openSync(target, "wx", mode);
1767
+ try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
1768
+ }
1769
+ };
1770
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(epochId) || !/^[1-9][0-9]*$/.test(botId) || !/^[1-9][0-9]*$/.test(ownerId)) throw new Error("invalid reviewed identity");
1771
+ directory(root);
1772
+ directory(incoming);
1773
+ const sourceRoot = fs.realpathSync(source);
1774
+ const files = {};
1775
+ const walk = (relative, ancestors = []) => {
1776
+ const input = path.join(sourceRoot, relative);
1777
+ const resolved = fs.realpathSync(input);
1778
+ if (resolved !== sourceRoot && !resolved.startsWith(`${sourceRoot}/`)) throw new Error("package link escapes exact image payload");
1779
+ if (ancestors.includes(resolved)) throw new Error("package link cycle");
1780
+ const stat = fs.statSync(input);
1781
+ if (stat.isDirectory()) {
1782
+ directory(path.join(incoming, relative));
1783
+ for (const name of fs.readdirSync(input).sort()) {
1784
+ if (!/^[A-Za-z0-9_@.-]+$/.test(name) || name === "." || name === "..") throw new Error("unsupported package path");
1785
+ walk(path.join(relative, name), [...ancestors, resolved]);
1786
+ }
1787
+ } else {
1788
+ if (!stat.isFile()) throw new Error("non-file package payload");
1789
+ const bytes = fs.readFileSync(input);
1790
+ const mode = stat.mode & 0o111 ? 0o700 : 0o600;
1791
+ persist(path.join(incoming, relative), bytes, mode);
1792
+ files[relative] = { digest: sha(bytes), mode };
1793
+ }
1794
+ };
1795
+ walk("");
1796
+ const actual = [];
1797
+ const inventory = relative => {
1798
+ for (const entry of fs.readdirSync(path.join(incoming, relative), { withFileTypes: true })) {
1799
+ const name = path.join(relative, entry.name);
1800
+ if (entry.isDirectory()) inventory(name); else actual.push(name);
1801
+ }
1802
+ };
1803
+ inventory("");
1804
+ if (JSON.stringify(actual.sort()) !== JSON.stringify(Object.keys(files).sort())) throw new Error("unexpected incoming package residue");
1805
+ for (const required of ["package.json", "npm-shrinkwrap.json", "dist/heart/daemon/sanctuary-authority-root-lifecycle.js", "dist/heart/daemon/sanctuary-telegram-authority-entry.js", "dist/heart/daemon/sanctuary-host-supervisor-entry.js", "deploy/unraid/sanctuary-host-launcher.sh", "deploy/unraid/sanctuary-authority-service.sh"]) {
1806
+ if (!files[required]) throw new Error(`missing authority package asset: ${required}`);
1807
+ }
1808
+ if (!Object.keys(files).some(name => name.startsWith("node_modules/"))) throw new Error("production dependencies are absent");
1809
+ const manifest = Buffer.from(`${JSON.stringify({ schemaVersion: 1, files })}\n`);
1810
+ if (manifest.byteLength > 8388608) throw new Error("complete package manifest exceeds the root lifecycle 8388608-byte limit; stop before token rotation");
1811
+ persist(`${root}/package-manifest.json`, manifest, 0o600);
1812
+ const primitive = file => {
1813
+ const stat = fs.statSync(file);
1814
+ if (!stat.isFile() || stat.uid !== 0 || stat.gid !== 0 || (stat.mode & 0o022) || !(stat.mode & 0o111)) throw new Error("unsafe host primitive");
1815
+ return sha(fs.readFileSync(file));
1816
+ };
1817
+ const request = { schemaVersion: 1, epochId, botId, ownerUserId: ownerId, ownerChatId: ownerId, packageDigest: sha(manifest), nodeDigest: primitive("/usr/local/bin/node"), prlimitDigest: primitive("/usr/bin/prlimit"), setsidDigest: primitive("/usr/bin/setsid"), shellDigest: primitive("/bin/sh") };
1818
+ persist(`${root}/request.json`, Buffer.from(`${JSON.stringify(request)}\n`), 0o600);
1819
+ if (fs.existsSync(`${root}/incoming-token`)) owned(`${root}/incoming-token`, 0o600);
1820
+ const fd = fs.openSync(root, "r");
1821
+ try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
1822
+ NODE
1823
+ )
1824
+ }
1825
+ run_sanctuary_docker() {
1826
+ /usr/bin/timeout -s KILL 20 /usr/bin/docker "$@"
1827
+ }
1828
+ run_sanctuary_unraid_api() {
1829
+ /usr/bin/timeout -s KILL 20 /usr/local/sbin/unraid-api "$@"
1830
+ }
1831
+ run_sanctuary_node() {
1832
+ /usr/local/bin/node "$@"
1833
+ }
1834
+ resolve_sanctuary_unraid_key() {
1835
+ run_sanctuary_node -e '
1836
+ const fs = require("node:fs");
1837
+ const root = "/boot/config/plugins/dynamix.my.servers/keys";
1838
+ const expected = process.argv[1];
1839
+ const found = [];
1840
+ for (const name of fs.readdirSync(root)) {
1841
+ if (!/^[A-Za-z0-9._:-]+\.json$/.test(name)) process.exit(1);
1842
+ const path = `${root}/${name}`;
1843
+ const stat = fs.lstatSync(path);
1844
+ if (!stat.isFile() || stat.isSymbolicLink()) process.exit(1);
1845
+ const value = JSON.parse(fs.readFileSync(path, "utf8"));
1846
+ if (value.id === expected) found.push({ path, name: value.name });
1847
+ }
1848
+ if (found.length !== 1 || typeof found[0].name !== "string" || !/^[A-Za-z0-9 ._:-]+$/.test(found[0].name)) process.exit(1);
1849
+ process.stdout.write(`${found[0].path}\t${found[0].name}`);
1850
+ ' "$1"
1851
+ }
1852
+ sanctuary_revoked_key_recovery_path() {
1853
+ case "$1" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
1854
+ printf '%s/%s.json' /mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof "$1"
1855
+ }
1856
+ validate_sanctuary_revoked_key_recovery() {
1857
+ test -f "$1" && test ! -L "$1" || return 1
1858
+ test "$(stat -c '%u:%g %a' "$1")" = "0:0 600" || return 1
1859
+ }
1860
+ preserve_sanctuary_revoked_key() {
1861
+ KEY_ID=$1
1862
+ KEY_SOURCE=$2
1863
+ RECOVERY_ROOT=/mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof
1864
+ test -f "$KEY_SOURCE" && test ! -L "$KEY_SOURCE" || return 1
1865
+ if test ! -e "$RECOVERY_ROOT"; then
1866
+ install -d -m 0700 -o root -g root "$RECOVERY_ROOT" || return $?
1867
+ fi
1868
+ test -d "$RECOVERY_ROOT" && test ! -L "$RECOVERY_ROOT" || return 1
1869
+ test "$(stat -c '%u:%g %a' "$RECOVERY_ROOT")" = "0:0 700" || return 1
1870
+ RECOVERY_PATH=$(sanctuary_revoked_key_recovery_path "$KEY_ID") || return $?
1871
+ test ! -e "$RECOVERY_PATH" || return 1
1872
+ RECOVERY_TMP=$(mktemp "$RECOVERY_ROOT/$KEY_ID.json.tmp.XXXXXX") || return $?
1873
+ trap 'rm -f -- "$RECOVERY_TMP"' EXIT || return $?
1874
+ install -m 0600 -o root -g root "$KEY_SOURCE" "$RECOVERY_TMP" || return $?
1875
+ cmp -s "$KEY_SOURCE" "$RECOVERY_TMP" || return 1
1876
+ mv -f -- "$RECOVERY_TMP" "$RECOVERY_PATH" || return $?
1877
+ sync -f "$RECOVERY_ROOT" || return $?
1878
+ trap - EXIT || return $?
1879
+ }
1880
+ clear_sanctuary_revoked_key() {
1881
+ RECOVERY_PATH=$(sanctuary_revoked_key_recovery_path "$1") || return $?
1882
+ test -f "$RECOVERY_PATH" && test ! -L "$RECOVERY_PATH" || return 1
1883
+ test "$(stat -c '%u:%g %a' "$RECOVERY_PATH")" = "0:0 600" || return 1
1884
+ rm -f -- "$RECOVERY_PATH" || return $?
1885
+ sync -f "$(dirname "$RECOVERY_PATH")" || return $?
1886
+ }
1887
+ verify_vault_backed_unraid_key() {
1888
+ KEY_ID=$1
1889
+ CAPABILITY=$2
1890
+ case "$KEY_ID" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
1891
+ case "$CAPABILITY" in read-only|bounded-write) ;; *) return 1 ;; esac
1892
+ IMAGE_DIGEST=${IMAGE_ID#sha256:}
1893
+ test "$IMAGE_DIGEST" != "$IMAGE_ID" || return 1
1894
+ test "${#IMAGE_DIGEST}" -eq 64 || return 1
1895
+ case "$IMAGE_DIGEST" in *[!0-9a-f]*) return 1 ;; esac
1896
+ test "$(run_sanctuary_docker image inspect --format '{{.Id}}' "$IMAGE_ID")" = "$IMAGE_ID" || return $?
1897
+ KEY_TARGET=$(resolve_sanctuary_unraid_key "$KEY_ID") || return $?
1898
+ KEY_PATH=${KEY_TARGET%% *}
1899
+ test "$KEY_PATH" != "$KEY_TARGET" || return 1
1900
+ test -f "$KEY_PATH" && test ! -L "$KEY_PATH" || return 1
1901
+ VERIFY_RESULT=$(run_sanctuary_docker run --rm --pull=never --network host \
1902
+ --user 0:0 \
1903
+ --mount type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli,readonly \
1904
+ --mount type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro,readonly \
1905
+ --mount type=bind,src="$KEY_PATH",dst=/run/ouro-acceptance/unraid-key.json,readonly \
1906
+ --entrypoint /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh \
1907
+ "$IMAGE_ID" vault-probe "$KEY_ID" "$CAPABILITY") || return $?
1908
+ printf '%s' "$VERIFY_RESULT" | run_sanctuary_node -e '
1909
+ let input = "";
1910
+ process.stdin.setEncoding("utf8");
1911
+ process.stdin.on("data", chunk => { input += chunk; });
1912
+ process.stdin.on("end", () => {
1913
+ const value = JSON.parse(input);
1914
+ const expectedId = process.argv[1];
1915
+ const expectedCapability = process.argv[2];
1916
+ const expectedProof = expectedCapability === "read-only"
1917
+ ? "read-authorized-write-denied"
1918
+ : "read-authorized-write-reached-not-found";
1919
+ if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(["capability", "keyId", "proof", "valid"])) process.exit(1);
1920
+ if (value.valid !== true || value.keyId !== expectedId || value.capability !== expectedCapability) process.exit(1);
1921
+ if (value.proof !== expectedProof) process.exit(1);
1922
+ });
1923
+ ' "$KEY_ID" "$CAPABILITY" || return $?
1924
+ }
1925
+ inventory_unraid_key_ids() {
1926
+ IMAGE_DIGEST=${IMAGE_ID#sha256:}
1927
+ test "$IMAGE_DIGEST" != "$IMAGE_ID" || return 1
1928
+ test "${#IMAGE_DIGEST}" -eq 64 || return 1
1929
+ case "$IMAGE_DIGEST" in *[!0-9a-f]*) return 1 ;; esac
1930
+ test "$(run_sanctuary_docker image inspect --format '{{.Id}}' "$IMAGE_ID")" = "$IMAGE_ID" || return $?
1931
+ INVENTORY_RESULT=$(printf '%s' '{"operation":"closed-inventory"}' | \
1932
+ run_sanctuary_docker run --rm -i --pull=never --network none \
1933
+ --user 0:0 --read-only \
1934
+ --mount type=bind,src=/boot/config/plugins/dynamix.my.servers/keys,dst=/boot/config/plugins/dynamix.my.servers/keys,readonly \
1935
+ --entrypoint /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh "$IMAGE_ID") || return $?
1936
+ printf '%s' "$INVENTORY_RESULT" | run_sanctuary_node -e '
1937
+ let input = "";
1938
+ process.stdin.setEncoding("utf8");
1939
+ process.stdin.on("data", chunk => { input += chunk; });
1940
+ process.stdin.on("end", () => {
1941
+ const value = JSON.parse(input);
1942
+ if (!value || JSON.stringify(Object.keys(value)) !== JSON.stringify(["keys"]) || !Array.isArray(value.keys)) process.exit(1);
1943
+ for (const key of value.keys) {
1944
+ if (!key || JSON.stringify(Object.keys(key).sort()) !== JSON.stringify(["id", "roles", "scope"])) process.exit(1);
1945
+ if (!/^[A-Za-z0-9._:-]+$/.test(key.id)) process.exit(1);
1946
+ if (!["read-only", "bounded-write", "legacy-write"].includes(key.scope) || key.roles !== "none") process.exit(1);
1947
+ process.stdout.write(`${key.id}\t${key.scope}\t${key.roles}\n`);
1948
+ }
1949
+ });
1950
+ ' || return $?
1951
+ }
1952
+ revoke_unraid_key_exact() {
1953
+ KEY_ID=$1
1954
+ case "$KEY_ID" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
1955
+ test "${REVOKED_KEY_FD_OPEN:-no}" = no || return 1
1956
+ KEY_TARGET=$(resolve_sanctuary_unraid_key "$KEY_ID") || return $?
1957
+ KEY_PATH=${KEY_TARGET%% *}
1958
+ KEY_NAME=${KEY_TARGET#* }
1959
+ test "$KEY_PATH" != "$KEY_TARGET" || return 1
1960
+ test -f "$KEY_PATH" && test ! -L "$KEY_PATH" || return $?
1961
+ preserve_sanctuary_revoked_key "$KEY_ID" "$KEY_PATH" || return $?
1962
+ if ! REVOKE_RESULT=$(run_sanctuary_unraid_api apikey --name "$KEY_NAME" --delete --json); then
1963
+ return 1
1964
+ fi
1965
+ if ! printf '%s' "$REVOKE_RESULT" | run_sanctuary_node -e '
1966
+ let input = "";
1967
+ process.stdin.setEncoding("utf8");
1968
+ process.stdin.on("data", chunk => { input += chunk; });
1969
+ process.stdin.on("end", () => {
1970
+ const value = JSON.parse(input);
1971
+ const expectedId = process.argv[1];
1972
+ const expectedName = process.argv[2];
1973
+ if (value.deleted !== 1 || !Array.isArray(value.keys) || value.keys.length !== 1) process.exit(1);
1974
+ if (value.keys[0].id !== expectedId || value.keys[0].name !== expectedName) process.exit(1);
1975
+ });
1976
+ ' "$KEY_ID" "$KEY_NAME"; then
1977
+ return 1
1978
+ fi
1979
+ }
1980
+ verify_revoked_unraid_key_rejected() {
1981
+ KEY_ID=$1
1982
+ case "$KEY_ID" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
1983
+ REVOKED_KEY_RECOVERY_PATH=$(sanctuary_revoked_key_recovery_path "$KEY_ID") || return $?
1984
+ validate_sanctuary_revoked_key_recovery "$REVOKED_KEY_RECOVERY_PATH" || return $?
1985
+ IMAGE_DIGEST=${IMAGE_ID#sha256:}
1986
+ test "$IMAGE_DIGEST" != "$IMAGE_ID" || return 1
1987
+ test "${#IMAGE_DIGEST}" -eq 64 || return 1
1988
+ case "$IMAGE_DIGEST" in *[!0-9a-f]*) return 1 ;; esac
1989
+ test "$(run_sanctuary_docker image inspect --format '{{.Id}}' "$IMAGE_ID")" = "$IMAGE_ID" || return 1
1990
+ REJECT_ATTEMPT=0
1991
+ while test "$REJECT_ATTEMPT" -lt 3; do
1992
+ REJECT_ATTEMPT=$((REJECT_ATTEMPT + 1))
1993
+ if REJECT_RESULT=$(run_sanctuary_docker run --rm -i --pull=never --network host \
1994
+ --user 10001:10001 --read-only \
1995
+ --entrypoint /bin/sh "$IMAGE_ID" -ceu \
1996
+ 'exec 3<&0; exec /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh revoked-probe "$1" "$2" 3<&3' \
1997
+ sanctuary-revoked-probe "$KEY_ID" http://127.0.0.1/graphql <"$REVOKED_KEY_RECOVERY_PATH") \
1998
+ && printf '%s' "$REJECT_RESULT" | run_sanctuary_node -e '
1999
+ let input = "";
2000
+ process.stdin.setEncoding("utf8");
2001
+ process.stdin.on("data", chunk => { input += chunk; });
2002
+ process.stdin.on("end", () => {
2003
+ const value = JSON.parse(input);
2004
+ if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(["id", "rejected", "status"])) process.exit(1);
2005
+ if (value.rejected !== true || value.id !== process.argv[1] || ![401, 403].includes(value.status)) process.exit(1);
2006
+ });
2007
+ ' "$KEY_ID"; then
2008
+ clear_sanctuary_revoked_key "$KEY_ID" || return $?
2009
+ return 0
2010
+ fi
2011
+ test "$REJECT_ATTEMPT" -lt 3 || return 1
2012
+ sleep 2
2013
+ done
2014
+ return 1
2015
+ }
2016
+ Update:
2017
+ Before downloading or staging anything, classify the live production source with the existing topology gates unless the fixed root-owned DockerMan transaction journal signals that an interrupted update needs the exact packaged recovery tools. Staging-only adoption is retired. A recovery journal admits only image acquisition and private staging until the staged transaction implementation validates the full journal and restores a classifiable source:
2018
+ admit_sanctuary_update_entry
2019
+ Set the released package version, its reviewed release-manifest digest, and its reviewed platform-local image ID. The canonical package-version tag is immutable release identity, while the digest and local ID independently prove what the registry and this host resolved:
2020
+ PACKAGE_VERSION=<released-version>
2021
+ MANIFEST_DIGEST=sha256:<reviewed-release-manifest-digest>
2022
+ IMAGE_ID=sha256:<reviewed-local-image-id>
2023
+ VERSION_IMAGE="ghcr.io/ourostack/ouroboros-butler:$PACKAGE_VERSION"
2024
+ printf '%s\n' "$PACKAGE_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'
2025
+ test "$(inspect_registry_manifest_digest "$VERSION_IMAGE")" = "$MANIFEST_DIGEST"
2026
+ docker pull "$VERSION_IMAGE"
2027
+ validate_exact_image_id "$IMAGE_ID"
2028
+ test "$(docker image inspect --format '{{.Id}}' "$VERSION_IMAGE")" = "$IMAGE_ID"
2029
+ AUDIT_RUNNER_IMAGE_ID=$IMAGE_ID
2030
+ validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID"
2031
+ TARGET_MOUNT_CONTRACT=$(sanctuary_image_mount_contract "$IMAGE_ID")
2032
+ test "$TARGET_MOUNT_CONTRACT" = canonical-gateway
2033
+ TEMPLATE_ICON=https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png
2034
+ DOCKERMAN_TEMPLATE_PATH=/boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml
2035
+ DOCKERMAN_TEMPLATE_JOURNAL=/boot/config/custom/ouro-butler/docker-man-template-transaction.json
2036
+ test "$DOCKERMAN_TEMPLATE_PATH" = /boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml
2037
+ test "$DOCKERMAN_TEMPLATE_JOURNAL" = /boot/config/custom/ouro-butler/docker-man-template-transaction.json
2038
+ Before stopping, renaming, or creating any Butler container, extract the complete /opt/ouro package from that exact image ID, including production node_modules and package-relative dist. Do not copy these files from a checkout or another image. Use the package version extracted from the selected immutable image; version alone is not the mount contract. Only the reviewed new image's bot.ouro.sanctuary.mount-contract=canonical-gateway label admits the fourth mount. The immutable source alpha.816 ID above remains canonical-pre-gateway and must never be retagged as the gateway release.
2039
+ Stage the event assets, audit the original version-tagged template, create a separate temporary copy for exact local-image-ID auditing, and keep the private stage until the outer transaction commits:
2040
+ EVENT_ASSET_STAGE=$(mktemp -d /mnt/user/appdata/ouro-butler/staging/ouro-events.XXXXXX)
2041
+ chmod 0700 "$EVENT_ASSET_STAGE"
2042
+ EVENT_SCRIPT_STAGE="$EVENT_ASSET_STAGE/ouro-events"
2043
+ mkdir "$EVENT_SCRIPT_STAGE"
2044
+ STAGED_PACKAGE_ROOT="$EVENT_ASSET_STAGE/package"
2045
+ mkdir "$STAGED_PACKAGE_ROOT"
2046
+ STAGED_TEMPLATE="$EVENT_ASSET_STAGE/sanctuary.xml"
2047
+ STAGED_EXACT_TEMPLATE="$EVENT_ASSET_STAGE/sanctuary.exact-image.xml"
2048
+ STAGED_RUNTIME_POLICY="$EVENT_ASSET_STAGE/container-runtime.json"
2049
+ STAGED_DOCKERMAN_TRANSACTION="$STAGED_PACKAGE_ROOT/deploy/unraid/docker-man-template-transaction.mjs"
2050
+ STAGED_DOCKERMAN_XML_VALIDATOR="$STAGED_PACKAGE_ROOT/deploy/unraid/docker-man-template-xml.cjs"
2051
+ EVENT_ASSET_CONTAINER=
2052
+ cleanup_event_asset_stage() {
2053
+ if test -n "$EVENT_ASSET_CONTAINER"; then
2054
+ docker rm --force "$EVENT_ASSET_CONTAINER" >/dev/null 2>&1 || true
2055
+ fi
2056
+ rm -rf -- "$EVENT_ASSET_STAGE"
2057
+ }
2058
+ trap cleanup_event_asset_stage EXIT
2059
+ EVENT_ASSET_CONTAINER=$(docker create --pull=never --network none --read-only --entrypoint /bin/false "$IMAGE_ID")
2060
+ test "$(docker inspect --format '{{.Image}}' "$EVENT_ASSET_CONTAINER")" = "$IMAGE_ID"
2061
+ docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/." "$STAGED_PACKAGE_ROOT/"
2062
+ docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/ouro-events/." "$EVENT_SCRIPT_STAGE/"
2063
+ docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/sanctuary.xml" "$STAGED_TEMPLATE"
2064
+ docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/container-runtime.json" "$STAGED_RUNTIME_POLICY"
2065
+ docker rm "$EVENT_ASSET_CONTAINER"
2066
+ EVENT_ASSET_CONTAINER=
2067
+ EXPECTED_RELEASE_ASSETS=$(printf '%s\n' container-runtime.json ouro-events package sanctuary.xml)
2068
+ ACTUAL_RELEASE_ASSETS=$(find "$EVENT_ASSET_STAGE" -mindepth 1 -maxdepth 1 -exec basename {} \; | LC_ALL=C sort)
2069
+ test "$ACTUAL_RELEASE_ASSETS" = "$EXPECTED_RELEASE_ASSETS"
2070
+ test -d "$EVENT_SCRIPT_STAGE" && test ! -L "$EVENT_SCRIPT_STAGE"
2071
+ test -f "$STAGED_TEMPLATE" && test ! -L "$STAGED_TEMPLATE"
2072
+ test -f "$STAGED_RUNTIME_POLICY" && test ! -L "$STAGED_RUNTIME_POLICY"
2073
+ test -f "$STAGED_DOCKERMAN_TRANSACTION" && test ! -L "$STAGED_DOCKERMAN_TRANSACTION"
2074
+ test -f "$STAGED_DOCKERMAN_XML_VALIDATOR" && test ! -L "$STAGED_DOCKERMAN_XML_VALIDATOR"
2075
+ chown 0:0 "$STAGED_TEMPLATE" "$STAGED_RUNTIME_POLICY" "$STAGED_DOCKERMAN_TRANSACTION" "$STAGED_DOCKERMAN_XML_VALIDATOR"
2076
+ chmod 0600 "$STAGED_TEMPLATE" "$STAGED_RUNTIME_POLICY" "$STAGED_DOCKERMAN_TRANSACTION" "$STAGED_DOCKERMAN_XML_VALIDATOR"
2077
+ chown -R 0:0 "$STAGED_PACKAGE_ROOT"
2078
+ chmod 0700 "$STAGED_PACKAGE_ROOT"
2079
+ EXPECTED_EVENT_ASSETS=$(printf '%s\n' bootstrap-spool.sh emit-event.mjs emit-usenet-event.sh install-usenet-guard.sh usenet-health.sh)
2080
+ ACTUAL_EVENT_ASSETS=$(find "$EVENT_SCRIPT_STAGE" -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | LC_ALL=C sort)
2081
+ test "$ACTUAL_EVENT_ASSETS" = "$EXPECTED_EVENT_ASSETS"
2082
+ test -z "$(find "$EVENT_SCRIPT_STAGE" -mindepth 1 -maxdepth 1 \( -type l -o ! -type f \) -print -quit)"
2083
+ verify_installed_usenet_guard() {
2084
+ test "$(findmnt -n -o FSTYPE --target /boot/config/custom)" = vfat
2085
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/usenet_health.sh)" = 0:0:600
2086
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/bootstrap-spool.sh)" = 0:0:600
2087
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/emit-event.mjs)" = 0:0:600
2088
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/emit-usenet-event.sh)" = 0:0:600
2089
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/install-usenet-guard.sh)" = 0:0:600
2090
+ cmp -s "$EVENT_SCRIPT_STAGE/usenet-health.sh" /boot/config/custom/usenet_health.sh
2091
+ cmp -s "$EVENT_SCRIPT_STAGE/bootstrap-spool.sh" /boot/config/custom/ouro-events/bootstrap-spool.sh
2092
+ cmp -s "$EVENT_SCRIPT_STAGE/emit-event.mjs" /boot/config/custom/ouro-events/emit-event.mjs
2093
+ cmp -s "$EVENT_SCRIPT_STAGE/emit-usenet-event.sh" /boot/config/custom/ouro-events/emit-usenet-event.sh
2094
+ cmp -s "$EVENT_SCRIPT_STAGE/install-usenet-guard.sh" /boot/config/custom/ouro-events/install-usenet-guard.sh
2095
+ test "$(grep -Fxc '/bin/bash /boot/config/custom/ouro-events/install-usenet-guard.sh --boot --install-root /boot/config/custom' /boot/config/go)" = 1
2096
+ test "$(grep -Fxc '/boot/config/custom/ouro-events/bootstrap-spool.sh --mount' /boot/config/go || true)" = 0
2097
+ test "$(grep -Fxc '(crontab -l 2>/dev/null | grep -v usenet_health; echo "*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh") | crontab -' /boot/config/go || true)" = 0
2098
+ INSTALLED_GUARD_CRON=$(crontab -l)
2099
+ test "$(printf '%s\n' "$INSTALLED_GUARD_CRON" | grep -Fxc '*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh # ouro:usenet-health')" = 1
2100
+ INSTALLED_LEGACY_GUARD_COUNT=$(printf '%s\n' "$INSTALLED_GUARD_CRON" | grep -Fxc '*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh' || true)
2101
+ test "$INSTALLED_LEGACY_GUARD_COUNT" = 0
2102
+ test "$(printf '%s\n' "$INSTALLED_GUARD_CRON" | grep -Fc '/bin/bash /boot/config/custom/usenet_health.sh')" = 1
2103
+ test "$(findmnt -n -o FSTYPE --target /boot/config/custom/ouro-events/spool)" = tmpfs
2104
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/spool)" = 0:0:755
2105
+ INSTALLED_SPOOL_OPTIONS=$(findmnt -n -o OPTIONS --target /boot/config/custom/ouro-events/spool)
2106
+ for INSTALLED_SPOOL_OPTION in nodev nosuid noexec; do
2107
+ case ",$INSTALLED_SPOOL_OPTIONS," in *",$INSTALLED_SPOOL_OPTION,"*) ;; *) return 1 ;; esac
2108
+ done
2109
+ INSTALLED_SPOOL_SIZE=$(findmnt -bn -o SIZE --target /boot/config/custom/ouro-events/spool)
2110
+ test "$INSTALLED_SPOOL_SIZE" -le 4194304
2111
+ }
2112
+ docker run --rm --pull=never --network=none \
2113
+ --user 0:0 --read-only --cap-drop=ALL --security-opt=no-new-privileges \
2114
+ --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh \
2115
+ --mount "type=bind,src=$STAGED_TEMPLATE,dst=/audit/sanctuary.xml,readonly" \
2116
+ --mount "type=bind,src=$STAGED_RUNTIME_POLICY,dst=/audit/container-runtime.json,readonly" \
2117
+ "$IMAGE_ID" --persistent-template /audit/sanctuary.xml --runtime-policy /audit/container-runtime.json --expected-image-reference "$VERSION_IMAGE" --mount-contract "$TARGET_MOUNT_CONTRACT"
2118
+ /usr/local/bin/node -e '
2119
+ const fs = require("node:fs");
2120
+ const [sourcePath, destinationPath, imageId] = process.argv.slice(1);
2121
+ if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error("invalid exact image ID");
2122
+ const source = fs.readFileSync(sourcePath, "utf8");
2123
+ const repositories = [...source.matchAll(/<Repository>[^<]*<\/Repository>/g)];
2124
+ if (repositories.length !== 1) throw new Error("template must contain exactly one Repository element");
2125
+ const staged = source.replace(repositories[0][0], `<Repository>${imageId}</Repository>`);
2126
+ if ((staged.match(/<Repository>[^<]*<\/Repository>/g) ?? []).length !== 1
2127
+ || !staged.includes(`<Repository>${imageId}</Repository>`)) throw new Error("exact Repository staging failed");
2128
+ fs.writeFileSync(destinationPath, staged, { mode: 0o600, flag: "wx" });
2129
+ ' "$STAGED_TEMPLATE" "$STAGED_EXACT_TEMPLATE" "$IMAGE_ID"
2130
+ chown 0:0 "$STAGED_EXACT_TEMPLATE"
2131
+ chmod 0600 "$STAGED_EXACT_TEMPLATE"
2132
+ docker run --rm --pull=never --network=none \
2133
+ --user 0:0 --read-only --cap-drop=ALL --security-opt=no-new-privileges \
2134
+ --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh \
2135
+ --mount "type=bind,src=$STAGED_EXACT_TEMPLATE,dst=/audit/sanctuary.exact-image.xml,readonly" \
2136
+ --mount "type=bind,src=$STAGED_RUNTIME_POLICY,dst=/audit/container-runtime.json,readonly" \
2137
+ "$IMAGE_ID" --template /audit/sanctuary.exact-image.xml --runtime-policy /audit/container-runtime.json --expected-image "$IMAGE_ID" --mount-contract "$TARGET_MOUNT_CONTRACT"
2138
+ recover_dockerman_template_transaction
2139
+ recover_pending_sanctuary_bundle_migration "$IMAGE_ID"
2140
+ classify_sanctuary_update_source
2141
+ /bin/bash "$EVENT_SCRIPT_STAGE/install-usenet-guard.sh" --source-root "$EVENT_SCRIPT_STAGE"
2142
+ /bin/bash /boot/config/custom/ouro-events/install-usenet-guard.sh --boot --install-root /boot/config/custom
2143
+ verify_installed_usenet_guard
2144
+ If extraction or transactional installation fails, abort before changing any live Butler container. The installer restores the previous assets, go file, and cron; production stays running.
2145
+ If boot activation or verification fails after that transaction commits, leave production untouched, repair or rerun this exact-image installation, and stop. Container and DockerMan rollback begin only after the later production preflight succeeds.
2146
+ For normal updates, preflight accepts only the pinned alpha.742 two-mount source or an already package-managed canonical source before any autostart or live-container change. The pinned exception may validate an old source, but can never authorize creation of a new target. Provider authentication and standalone credential-readiness helpers are not normal-update prechecks; never place credentials in arguments, shell variables or history.
2147
+ The DockerMan transaction binds Jellyfin's container ID, image ID, state, and restart count. Update, rollback and recovery must preserve that checkpoint.
2148
+ For this authority migration, only the exact pinned alpha.816 predecessor may become rollback. The legacy-alpha742 digest remains supported for historical source audits, not a policy-weak authority rollback. Preserve one exact stopped alpha.816 predecessor, Jellyfin and every policy/history byte. Staging must be absent and production must be the only running resident. Capture the non-secret reviewed bot/owner/epoch inputs; OWNER_ID is the already authenticated private Telegram user/chat, never inferred from resident-written prose:
2149
+ AUTHORITY_EPOCH_ID=<new-reviewed-epoch-id>
2150
+ AUTHORITY_BOT_ID=8541786263
2151
+ AUTHORITY_OWNER_ID=<reviewed-private-owner-user-and-chat-id>
2152
+ install -d -m 0700 -o 0 -g 0 /mnt/user/appdata/ouro-authority
2153
+ Disable every Butler name in Unraid's array-autostart file before authority-install freezes the resident. First resolve and validate the exact known-good image while it is still running:
2154
+ /bin/bash /boot/config/custom/ouro-events/bootstrap-spool.sh --mount
2155
+ test "$(findmnt -n -o FSTYPE --target /boot/config/custom/ouro-events/spool)" = tmpfs
2156
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/spool)" = 0:0:755
2157
+ ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
2158
+ validate_exact_image_id "$ROLLBACK_IMAGE_ID"
2159
+ ROLLBACK_MOUNT_CONTRACT=$(sanctuary_image_mount_contract "$ROLLBACK_IMAGE_ID")
2160
+ test "$ROLLBACK_IMAGE_ID" = sha256:589b7cf8f96d139ee9fd86204a183126aadcbd3fbfb406063709be303397b1b2
2161
+ test "$ROLLBACK_MOUNT_CONTRACT" = canonical-pre-gateway
2162
+ if assert_update_topology "$ROLLBACK_IMAGE_ID"; then
2163
+ :
2164
+ else
2165
+ UPDATE_PREFLIGHT_STATUS=$?
2166
+ (exit "$UPDATE_PREFLIGHT_STATUS")
2167
+ fi
2168
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
2169
+ if ( provision_sanctuary_sab_credential "$IMAGE_ID" ) \
2170
+ && verify_sanctuary_sab_readiness "$IMAGE_ID"; then
2171
+ :
2172
+ else
2173
+ PRECUTOVER_READINESS_STATUS=$?
2174
+ (exit "$PRECUTOVER_READINESS_STATUS")
2175
+ fi
2176
+ Prepare the complete package and request before human token revocation. The input helper's manifest-size check must pass the root lifecycle reader's 8388608-byte manifest limit; ordinary private records retain their 1048576-byte limit. Do not prune dependencies or split the inventory to bypass either bound. If this check refuses the full payload, leave the existing token, autostart and resident unchanged:
2177
+ prepare_sanctuary_authority_inputs "$STAGED_PACKAGE_ROOT"
2178
+ The input helper copies every regular package file and materializes only package-internal dependency symlinks as independent files, hashes the complete inventory with exact modes, and writes mode-0600 manifest/request files. The request binds schemaVersion, epochId, botId, ownerUserId, ownerChatId, packageDigest and the actual root node/prlimit/setsid/shell hashes. Review those hashes against the approved package and host primitives before prepare. Repeated preparation accepts only identical retained inputs; unknown extra files, external links or changed bytes fail closed. No network token probe or resident start occurs in preparation.
2179
+ Prepare the durable transaction and disable autostart before the human rotates any token:
2180
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" prepare --source-template "$STAGED_TEMPLATE" --version-tag "$VERSION_IMAGE" --manifest-digest "$MANIFEST_DIGEST" --rollback-image-id "$ROLLBACK_IMAGE_ID" --target-image-id "$IMAGE_ID" >/dev/null
2181
+ Guard the atomic autostart disable separately. If it fails, the original token remains valid and production has not been touched; restore the template and propagate the captured status:
2182
+ if disable_butler_autostart; then
2183
+ :
2184
+ else
2185
+ AUTOSTART_DISABLE_STATUS=$?
2186
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null
2187
+ (exit "$AUTOSTART_DISABLE_STATUS")
2188
+ fi
2189
+ Stop the exact predecessor before its final Telegram and filesystem readiness check. Otherwise a concurrent private-runtime receipt can appear between permission normalization and strict validation. The full package and durable transaction are already prepared, and autostart is disabled. Keep the stopped-resident mode strict: no live control socket or mode exception is allowed. Before token rotation, any failure restores the same policy-aware predecessor and propagates the failure; an interrupted stop remains recoverable from the prepared transaction:
2190
+ if docker stop --time 30 ouro-butler \
2191
+ && assert_only_running_butler - \
2192
+ && verify_sanctuary_telegram_readiness "$ROLLBACK_IMAGE_ID" strict; then
2193
+ :
2194
+ else
2195
+ PRECUTOVER_QUIESCENCE_STATUS=$?
2196
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
2197
+ start_only_butler_for_recovery
2198
+ wait_butler_ready ouro-butler
2199
+ enable_butler_autostart
2200
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null
2201
+ (exit "$PRECUTOVER_QUIESCENCE_STATUS")
2202
+ fi
2203
+ Only after pinned stopped-image readiness, full-package preparation, durable transaction preparation and autostart disabling pass, the human rotates MendelowCloudButlerBot's token for that same numeric bot and uses the hidden root-terminal input helper. Do not echo the token, put it in argv/environment/history, copy it into the resident, or replace retained incoming-token during recovery. A failed or interrupted paste must be reconciled by the human before retry; retain the journal and disabled autostart, and do not invent a token:
2204
+ receive_sanctuary_authority_token
2205
+ test "$(stat -c '%u:%g:%a' /mnt/user/appdata/ouro-authority/incoming-token)" = 0:0:600
2206
+ Gateway installation and its tokenless readiness belong inside the stopped-resident transaction, never in the old live precheck. A failure attempts retirement and exact old-image restoration; failure of either keeps autostart disabled and the journals/root inputs intact:
2207
+ if /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" authority-install >/dev/null \
2208
+ && verify_sanctuary_telegram_readiness "$IMAGE_ID"; then
2209
+ :
2210
+ else
2211
+ AUTHORITY_INSTALL_STATUS=$?
2212
+ retire_sanctuary_authority_if_pending
2213
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
2214
+ start_only_butler_for_recovery
2215
+ wait_butler_ready ouro-butler
2216
+ enable_butler_autostart
2217
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null
2218
+ (exit "$AUTHORITY_INSTALL_STATUS")
2219
+ fi
2220
+ Define the stale-rollback cleanup used by the preparation guard:
2221
+ remove_stopped_rollback_if_present() {
2222
+ EXPECTED_STALE_ROLLBACK_IMAGE_ID=$1
2223
+ ROLLBACK_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
2224
+ case "
2225
+ $ROLLBACK_CONTAINER_NAMES
2226
+ " in
2227
+ *"
2228
+ ouro-butler-rollback
2229
+ "*)
2230
+ ROLLBACK_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback) || return $?
2231
+ test "$ROLLBACK_RUNNING" = false || return $?
2232
+ CURRENT_STALE_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
2233
+ test "$CURRENT_STALE_ROLLBACK_IMAGE_ID" = "$EXPECTED_STALE_ROLLBACK_IMAGE_ID" || return $?
2234
+ docker rm ouro-butler-rollback || return $?
2235
+ ;;
2236
+ esac
2237
+ }
2238
+ Stop production, remove only a stopped stale rollback, rename the known-good container, verify it remains stopped, and apply the exact target image's package-managed bundle migration in one explicit preparation guard.
2239
+ Package-managed files are exactly `provider-readiness.json`, `tool-profiles.json`, `habits/sanctuary-health.md`, `awaiting/house-care-sweep.md` (the Butler's daily house-care sweep, a never-resolving await), and the five canonical files under `psyche/`. The migration also merges the three bundle-meta version fields. It never installs or mutates steward policy authority. It preserves agent.json, all steward policy and audit bytes, relationships, sessions, and every other state path. Repeating it is a no-op.
2240
+ Before its first managed write, the migrator atomically fsyncs one mode-0600 rollback record at `.sanctuary-package-managed-rollback.json`. That record binds the verified prior bytes, modes, and parent existence for every managed file plus bundle-meta to distinct exact rollback and target image IDs. It is the migration receipt, not a second bundle authority.
2241
+ A managed-file failure restores the prior bytes immediately. Every later container rollback also restores those bytes before the old container restarts, while retaining the receipt; only after the old production container is audited, ready, and back on autostart does `finalize-rollback` remove it without marking the new release committed.
2242
+ After an interrupted update, the same receipt removes partial new containers, discards only validated interrupted-write stages, restores the prior bundle idempotently, and permits only the recorded rollback image to resume as production. A malformed receipt, wrong image, or restore failure leaves autostart disabled and stops safely.
2243
+ Once target readiness and autostart pass, `commit` durably marks the receipt as committing. If interruption happens then, the target stays in place and the next run rechecks its exact topology and readiness before finishing. Migration failure enters the same container rollback arm before target production starts:
2244
+ if docker stop ouro-butler \
2245
+ && remove_stopped_rollback_if_present "$ROLLBACK_IMAGE_ID" \
2246
+ && docker rename ouro-butler ouro-butler-rollback \
2247
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false \
2248
+ && assert_only_running_butler - \
2249
+ && migrate_sanctuary_package_managed_bundle "$IMAGE_ID" migrate "$ROLLBACK_IMAGE_ID"; then
2250
+ :
2251
+ else
2252
+ PRODUCTION_PREPARATION_STATUS=$?
2253
+ PRODUCTION_PREPARATION_CLEANUP_STATUS=0
2254
+ PRODUCTION_PREPARATION_RECOVERY_SAFE=true
2255
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" verify-jellyfin >/dev/null
2256
+ retire_sanctuary_authority_if_pending
2257
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
2258
+ if docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
2259
+ docker stop ouro-butler-rollback >/dev/null 2>&1 || true
2260
+ CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback)
2261
+ test "$CURRENT_ROLLBACK_IMAGE_ID" = "$ROLLBACK_IMAGE_ID"
2262
+ docker rm --force ouro-butler-rollback >/dev/null 2>&1 || true
2263
+ fi
2264
+ if docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
2265
+ PRODUCTION_PREPARATION_CLEANUP_STATUS=1
2266
+ if test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" != false; then
2267
+ PRODUCTION_PREPARATION_RECOVERY_SAFE=false
2268
+ fi
2269
+ fi
2270
+ rollback_sanctuary_bundle_if_pending "$IMAGE_ID"
2271
+ if test "$PRODUCTION_PREPARATION_RECOVERY_SAFE" = true; then
2272
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
2273
+ start_only_butler_for_recovery
2274
+ wait_butler_ready ouro-butler
2275
+ enable_butler_autostart
2276
+ finalize_sanctuary_bundle_rollback_if_retained "$IMAGE_ID"
2277
+ fi
2278
+ elif docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
2279
+ docker stop ouro-butler-rollback >/dev/null 2>&1 || true
2280
+ test "$(docker inspect --format '{{.Image}}' ouro-butler-rollback)" = "$ROLLBACK_IMAGE_ID"
2281
+ rollback_sanctuary_bundle_if_pending "$IMAGE_ID"
2282
+ docker rename ouro-butler-rollback ouro-butler
2283
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
2284
+ start_only_butler_for_recovery
2285
+ wait_butler_ready ouro-butler
2286
+ enable_butler_autostart
2287
+ finalize_sanctuary_bundle_rollback_if_retained "$IMAGE_ID"
2288
+ fi
2289
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null
2290
+ if test "$PRODUCTION_PREPARATION_CLEANUP_STATUS" -ne 0; then
2291
+ exit "$PRODUCTION_PREPARATION_CLEANUP_STATUS"
2292
+ fi
2293
+ (exit "$PRODUCTION_PREPARATION_STATUS")
2294
+ fi
2295
+ Preparation failure therefore either restores the still-named exact production after attempting to remove any stale rollback, or renames the exact stopped rollback back. Both safe paths revalidate and start the old container, wait within the fixed bound, restore production-only autostart atomically, and return the original failure. If stale rollback removal fails, restore production only when the leftover is proven stopped; otherwise leave both containers untouched, roll back the journals, and return the cleanup failure for repair.
2296
+ If neither exact container can be found, the failure returns with Butler autostart disabled.
2297
+ Do not start a target-image daemon between the production rename and final production activation. The exact-image static audit and download-queue readiness have passed. The old image's pinned pre-gateway readiness ran before cutover; the new image's socket-mounted tokenless readiness ran only after authority-install proved the root gateway.
2298
+ Provider and complete daemon readiness are exercised only by the transactional production activation below. Its failure arm restores and revalidates the exact prior production, so a disposable daemon cannot reconcile or claim live external-event state before cutover.
2299
+ Create and activate production from the same exact image ID and exact authority in one explicit conditional so `set -eu` cannot exit before rollback. Only a successful create, effective audit, start, stopped-rollback assertion, and bounded readiness wait may enable production autostart.
2300
+ On failure, capture the activation status, remove only a partially created new production container, restore and audit the stopped rollback against its exact old image ID, prove it ready, restore production-only autostart atomically, and return the original failure. If the partial target cannot be removed, preserve its matching target bundle and retained migration receipt, roll back the DockerMan journal without attempting the colliding rename, then return the cleanup failure:
2301
+ if test "$(inspect_registry_manifest_digest "$VERSION_IMAGE")" = "$MANIFEST_DIGEST" \
2302
+ && test "$(docker image inspect --format '{{.Id}}' "$VERSION_IMAGE")" = "$IMAGE_ID" \
2303
+ && create_sanctuary_container "$IMAGE_ID" "$VERSION_IMAGE" "$TARGET_MOUNT_CONTRACT" \
2304
+ && audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" "$TARGET_MOUNT_CONTRACT" "$VERSION_IMAGE" "$TEMPLATE_ICON" \
2305
+ && assert_only_running_butler - \
2306
+ && /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" authority-activate >/dev/null \
2307
+ && assert_only_running_butler ouro-butler \
2308
+ && test "$(docker inspect --format '{{.Image}}' ouro-butler)" = "$IMAGE_ID" \
2309
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false \
2310
+ && wait_butler_ready ouro-butler \
2311
+ && enable_butler_autostart \
2312
+ && /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" mark-committing >/dev/null; then
2313
+ :
2314
+ else
2315
+ PRODUCTION_ACTIVATION_STATUS=$?
2316
+ PRODUCTION_ACTIVATION_CLEANUP_STATUS=0
2317
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" verify-jellyfin >/dev/null
2318
+ retire_sanctuary_authority_if_pending
2319
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
2320
+ docker stop ouro-butler >/dev/null 2>&1 || true
2321
+ PARTIAL_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
2322
+ test "$PARTIAL_PRODUCTION_IMAGE_ID" = "$IMAGE_ID"
2323
+ docker rm --force ouro-butler >/dev/null 2>&1 || true
2324
+ fi
2325
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
2326
+ PRODUCTION_ACTIVATION_CLEANUP_STATUS=1
2327
+ fi
2328
+ if test "$PRODUCTION_ACTIVATION_CLEANUP_STATUS" -eq 0; then
2329
+ CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback)
2330
+ test "$CURRENT_ROLLBACK_IMAGE_ID" = "$ROLLBACK_IMAGE_ID"
2331
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" rollback
2332
+ docker rename ouro-butler-rollback ouro-butler
2333
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
2334
+ start_only_butler_for_recovery
2335
+ wait_butler_ready ouro-butler
2336
+ enable_butler_autostart
2337
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" finalize-rollback
2338
+ fi
2339
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" rollback >/dev/null
2340
+ if test "$PRODUCTION_ACTIVATION_CLEANUP_STATUS" -ne 0; then
2341
+ exit "$PRODUCTION_ACTIVATION_CLEANUP_STATUS"
2342
+ fi
2343
+ (exit "$PRODUCTION_ACTIVATION_STATUS")
2344
+ fi
2345
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
2346
+ FINAL_PROOF_ROOT="$EVENT_ASSET_STAGE/final-proof"
2347
+ FINAL_PROOF_PATH=$(write_dockerman_final_proof "$IMAGE_ID" "$VERSION_IMAGE" "$TEMPLATE_ICON" "$FINAL_PROOF_ROOT")
2348
+ verify_known_good_rollback_artifact "$ROLLBACK_IMAGE_ID"
2349
+ /usr/local/bin/node "$STAGED_DOCKERMAN_TRANSACTION" commit --proof "$FINAL_PROOF_PATH" >/dev/null
2350
+ cleanup_event_asset_stage
2351
+ trap - EXIT
2352
+ Keep ouro-butler-rollback stopped until the new production container is proven or the explicit rollback arm restores it. The rollback arm first retires root registrations and executions, stops the gateway, ends the epoch and restores the current valid token/cursor. Only then does it restore the prior bundle and rename the untouched three-mount alpha.816 container. start_only_butler_for_recovery invokes authority-restore while the authority journal exists; it cannot bypass retirement with docker start. Never create production from a mutable tag, a bare tag, or a bare local image ID.
2353
+ Docker tab Update, Force Update, Edit/Apply, Update All, and CA Action Centre updates remain visible but are unsupported because stock recreation deletes reviewed rollback evidence. Visibility, start, stop, and autostart remain supported; use only this reviewed version-tag transaction for updates.
2354
+ Community Apps determines installed state from the DockerMan template plus the live container name and image. The helper proves that same relationship without calling the endpoint that refreshes Community Apps' UI cache; the later live UI smoke confirms what Ari sees.
2355
+
2356
+ Execution-only primitive refresh:
2357
+
2358
+ A reviewed Unraid update to prlimit or setsid makes new host execution unavailable, but the installed gateway can still boot for Telegram and retire for rollback. Package, Node and shell pins remain mandatory. Gateway launch uses Node's native detached process support; it does not execute an unreviewed setsid binary.
2359
+
2360
+ Root maintainer: stop the verified gateway process using its exact root-owned lock/PID identity, then confirm there is no pending DockerMan transaction or unresolved execution, supervisor or cgroup state. Review the current prlimit and setsid file hashes and ownership before supplying their full SHA-256 digests to the package-owned command below. The command verifies those exact bytes and metadata; it never adopts current hashes implicitly. It changes no token, issuer, owner, cursor, policy or grant. An interrupted two-file publication remains fail-closed and can be completed by repeating the same reviewed command.
2361
+
2362
+ /usr/local/bin/node /mnt/user/appdata/ouro-authority/package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js repin-execution sha256:REVIEWED_PRLIMIT_SHA256 sha256:REVIEWED_SETSID_SHA256
2363
+ /usr/local/bin/node /mnt/user/appdata/ouro-authority/package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js boot
2364
+
2365
+ Replace each REVIEWED_*_SHA256 placeholder with its reviewed 64 hexadecimal digits. After restart, verify the signed host.status health result before proposing a new host command. Changed package, Node or shell pins require repair of that separate boundary, not this execution-only refresh.
2366
+
2367
+ Backup:
2368
+ Set BACKUP_ROOT to a new absolute snapshot path on the destination filesystem.
2369
+ Set AUDIT_RUNNER_IMAGE_TAG to the reviewed new image containing the
2370
+ legacy-aware auditor; the alpha.742 source image cannot be the runner.
2371
+ Capture and validate the exact source image before stopping ouro-butler, then
2372
+ build a temporary snapshot with root-only provenance and atomically rename it into place:
2373
+ test -n "${BACKUP_ROOT-}"
2374
+ case "$BACKUP_ROOT" in /*) ;; *) exit 1 ;; esac
2375
+ test "$BACKUP_ROOT" != /
2376
+ test ! -e "$BACKUP_ROOT"
2377
+ BACKUP_PARENT=$(dirname -- "$BACKUP_ROOT")
2378
+ test "$(cd -- "$BACKUP_PARENT" && pwd -P)" = "$BACKUP_PARENT"
2379
+ BACKUP_TMP=$BACKUP_ROOT.tmp.$$
2380
+ test ! -e "$BACKUP_TMP"
2381
+ AUDIT_RUNNER_IMAGE_TAG=ouro-butler:<new-version>
2382
+ AUDIT_RUNNER_IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$AUDIT_RUNNER_IMAGE_TAG")
2383
+ validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID"
2384
+ test "$AUDIT_RUNNER_IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d
2385
+ BACKUP_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
2386
+ validate_exact_image_id "$BACKUP_IMAGE_ID"
2387
+ assert_update_source "$BACKUP_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
2388
+ assert_only_running_butler ouro-butler
2389
+ wait_butler_ready ouro-butler
2390
+ BACKUP_AUTOSTART_COUNTS=$(butler_autostart_counts)
2391
+ case "$BACKUP_AUTOSTART_COUNTS" in "0 0 0 0"|"1 0 0 0") ;; *) exit 1 ;; esac
2392
+ backup_host_file() {
2393
+ BACKUP_HOST_SOURCE=$1
2394
+ BACKUP_HOST_RELATIVE=$2
2395
+ case "$BACKUP_HOST_RELATIVE" in
2396
+ custom/usenet_health.sh|custom/ouro-events/bootstrap-spool.sh|custom/ouro-events/emit-event.mjs|custom/ouro-events/emit-usenet-event.sh|custom/ouro-events/install-usenet-guard.sh) ;;
2397
+ *) return 1 ;;
2398
+ esac
2399
+ if test -e "$BACKUP_HOST_SOURCE" || test -L "$BACKUP_HOST_SOURCE"; then
2400
+ test -f "$BACKUP_HOST_SOURCE" && test ! -L "$BACKUP_HOST_SOURCE" || return 1
2401
+ ! host_file_contains_inline_credential "$BACKUP_HOST_SOURCE" || return 1
2402
+ install -D -m 0600 -o 0 -g 0 "$BACKUP_HOST_SOURCE" "$BACKUP_TMP/host/$BACKUP_HOST_RELATIVE" || return $?
2403
+ printf 'present\t%s\n' "$BACKUP_HOST_RELATIVE" >>"$BACKUP_TMP/host/inventory" || return $?
2404
+ else
2405
+ printf 'absent\t%s\n' "$BACKUP_HOST_RELATIVE" >>"$BACKUP_TMP/host/inventory" || return $?
2406
+ fi
2407
+ }
2408
+ host_file_contains_inline_credential() {
2409
+ LC_ALL=C awk '
2410
+ BEGIN { IGNORECASE = 1; found = 0 }
2411
+ /[0-9]{6,}:[A-Za-z0-9_-]{20,}/ { found = 1 }
2412
+ /Authorization:[[:space:]]*(Bearer|Basic)[[:space:]]+["\047]?[A-Za-z0-9][A-Za-z0-9._~+\/=:-]{11,}/ { found = 1 }
2413
+ /--(token|api-key|api_key|secret|password)(=|[[:space:]]+)["\047]?[A-Za-z0-9][A-Za-z0-9._~+\/=:-]{11,}/ { found = 1 }
2414
+ /[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\/@[:space:]]+:[^\/@[:space:]]+@/ { found = 1 }
2415
+ /-----BEGIN ([A-Z0-9]+ )*PRIVATE KEY-----/ { found = 1 }
2416
+ /(token|secret|password|pass|api[_-]?key)[A-Za-z0-9_-]*["\047[:space:]]*[:=][[:space:]]*["\047]?[A-Za-z0-9][A-Za-z0-9._~+\/=:-]{11,}/ { found = 1 }
2417
+ END { exit found ? 0 : 1 }
2418
+ ' "$1"
2419
+ }
2420
+ snapshot_butler_go_fragments() {
2421
+ BACKUP_GO_SOURCE=$1
2422
+ BACKUP_GO_TARGET=$2
2423
+ : >"$BACKUP_GO_TARGET" || return $?
2424
+ while IFS= read -r BACKUP_GO_LINE || test -n "$BACKUP_GO_LINE"; do
2425
+ case "$BACKUP_GO_LINE" in
2426
+ "/boot/config/custom/ouro-events/bootstrap-spool.sh --mount"|"/bin/bash /boot/config/custom/ouro-events/bootstrap-spool.sh --mount"|"/bin/bash /boot/config/custom/ouro-events/install-usenet-guard.sh --boot"|"/bin/bash /boot/config/custom/ouro-events/install-usenet-guard.sh --boot --install-root /boot/config/custom")
2427
+ printf '%s\n' "$BACKUP_GO_LINE" >>"$BACKUP_GO_TARGET" || return $?
2428
+ ;;
2429
+ esac
2430
+ done <"$BACKUP_GO_SOURCE"
2431
+ }
2432
+ snapshot_butler_cron_fragments() {
2433
+ BACKUP_CRON_INPUT=$1
2434
+ BACKUP_CRON_TARGET=$2
2435
+ : >"$BACKUP_CRON_TARGET" || return $?
2436
+ while IFS= read -r BACKUP_CRON_LINE || test -n "$BACKUP_CRON_LINE"; do
2437
+ case "$BACKUP_CRON_LINE" in
2438
+ "*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh"|"*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh # ouro:usenet-health")
2439
+ printf '%s\n' "$BACKUP_CRON_LINE" >>"$BACKUP_CRON_TARGET" || return $?
2440
+ ;;
2441
+ esac
2442
+ done <"$BACKUP_CRON_INPUT"
2443
+ }
2444
+ create_private_cron_capture() {
2445
+ BACKUP_CRON_ROOT=$(mktemp -d /tmp/ouro-backup-cron.XXXXXX) || return $?
2446
+ BACKUP_CRON_SOURCE=$BACKUP_CRON_ROOT/stdout
2447
+ BACKUP_CRON_ERROR=$BACKUP_CRON_ROOT/stderr
2448
+ test -d "$BACKUP_CRON_ROOT" && test ! -L "$BACKUP_CRON_ROOT" || return 1
2449
+ test "$(stat -c '%u:%a' "$BACKUP_CRON_ROOT" 2>/dev/null || stat -f '%u:%Lp' "$BACKUP_CRON_ROOT")" = "$(id -u):700" || return 1
2450
+ install -m 0600 /dev/null "$BACKUP_CRON_SOURCE" || return $?
2451
+ install -m 0600 /dev/null "$BACKUP_CRON_ERROR" || return $?
2452
+ test -f "$BACKUP_CRON_SOURCE" && test ! -L "$BACKUP_CRON_SOURCE" || return 1
2453
+ test -f "$BACKUP_CRON_ERROR" && test ! -L "$BACKUP_CRON_ERROR" || return 1
2454
+ test "$(stat -c '%u:%a' "$BACKUP_CRON_SOURCE" 2>/dev/null || stat -f '%u:%Lp' "$BACKUP_CRON_SOURCE")" = "$(id -u):600" || return 1
2455
+ test "$(stat -c '%u:%a' "$BACKUP_CRON_ERROR" 2>/dev/null || stat -f '%u:%Lp' "$BACKUP_CRON_ERROR")" = "$(id -u):600" || return 1
2456
+ }
2457
+ cleanup_private_cron_capture() {
2458
+ rm -f -- "$BACKUP_CRON_SOURCE" "$BACKUP_CRON_ERROR" || return $?
2459
+ rmdir -- "$BACKUP_CRON_ROOT"
2460
+ }
2461
+ snapshot_butler_host_fragments() {
2462
+ create_private_cron_capture || return $?
2463
+ trap 'cleanup_private_cron_capture' RETURN
2464
+ if test -f /boot/config/go && test ! -L /boot/config/go; then
2465
+ BACKUP_GO_STATE=present
2466
+ BACKUP_GO_DIGEST=$(sha256sum /boot/config/go | awk '{print $1}') || return $?
2467
+ snapshot_butler_go_fragments /boot/config/go "$BACKUP_TMP/host/go.butler-lines" || return $?
2468
+ elif test -e /boot/config/go || test -L /boot/config/go; then
2469
+ return 1
2470
+ else
2471
+ BACKUP_GO_STATE=absent
2472
+ BACKUP_GO_DIGEST=-
2473
+ : >"$BACKUP_TMP/host/go.butler-lines" || return $?
2474
+ fi
2475
+ BACKUP_CRON_USER=$(id -un) || return $?
2476
+ if crontab -l >"$BACKUP_CRON_SOURCE" 2>"$BACKUP_CRON_ERROR"; then
2477
+ BACKUP_CRON_STATE=present
2478
+ BACKUP_CRON_DIGEST=$(sha256sum "$BACKUP_CRON_SOURCE" | awk '{print $1}') || return $?
2479
+ else
2480
+ BACKUP_CRON_STATUS=$?
2481
+ if test "$BACKUP_CRON_STATUS" -eq 1 && grep -Fxq "no crontab for $BACKUP_CRON_USER" "$BACKUP_CRON_ERROR"; then
2482
+ BACKUP_CRON_STATE=absent
2483
+ BACKUP_CRON_DIGEST=-
2484
+ : >"$BACKUP_CRON_SOURCE" || return $?
2485
+ else
2486
+ return "$BACKUP_CRON_STATUS"
2487
+ fi
2488
+ fi
2489
+ rm -f -- "$BACKUP_CRON_ERROR" || return $?
2490
+ snapshot_butler_cron_fragments "$BACKUP_CRON_SOURCE" "$BACKUP_TMP/host/crontab.butler-lines" || return $?
2491
+ BACKUP_GO_COUNT=$(awk 'END { print NR + 0 }' "$BACKUP_TMP/host/go.butler-lines") || return $?
2492
+ BACKUP_CRON_COUNT=$(awk 'END { print NR + 0 }' "$BACKUP_TMP/host/crontab.butler-lines") || return $?
2493
+ printf 'go\t%s\t%s\t%s\ncrontab\t%s\t%s\t%s\n' "$BACKUP_GO_STATE" "$BACKUP_GO_DIGEST" "$BACKUP_GO_COUNT" "$BACKUP_CRON_STATE" "$BACKUP_CRON_DIGEST" "$BACKUP_CRON_COUNT" >"$BACKUP_TMP/host/global-state" || return $?
2494
+ chown 0:0 "$BACKUP_TMP/host/go.butler-lines" "$BACKUP_TMP/host/crontab.butler-lines" "$BACKUP_TMP/host/global-state" || return $?
2495
+ chmod 0600 "$BACKUP_TMP/host/go.butler-lines" "$BACKUP_TMP/host/crontab.butler-lines" "$BACKUP_TMP/host/global-state" || return $?
2496
+ printf 'present\t%s\n' go.butler-lines crontab.butler-lines global-state >>"$BACKUP_TMP/host/inventory" || return $?
2497
+ cleanup_private_cron_capture || return $?
2498
+ trap - RETURN
2499
+ }
2500
+ Snapshot both of these directories together:
2501
+ /mnt/user/appdata/ouro-butler/runtime/.ouro-cli
2502
+ /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
2503
+ A routine backup must not contain container-credentials.json or
2504
+ container-credentials.json.consuming. If either exists, credential migration
2505
+ is pending or failed and must be reconciled before taking the backup.
2506
+ The Telegram control socket is live process state, not durable data. Exclude
2507
+ exactly that socket while preserving every regular file byte-for-byte, then
2508
+ require it to be absent from the stopped backup:
2509
+ BACKUP_OPERATION_STATUS=0
2510
+ if docker stop ouro-butler \
2511
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler)" = false \
2512
+ && install -d -m 0700 -o 0 -g 0 "$BACKUP_TMP" "$BACKUP_TMP/runtime" "$BACKUP_TMP/agent" "$BACKUP_TMP/host" "$BACKUP_TMP/provenance" \
2513
+ && install -d -m 0700 -o 0 -g 0 "$BACKUP_TMP/host/custom" "$BACKUP_TMP/host/custom/ouro-events" \
2514
+ && install -m 0600 -o 0 -g 0 /dev/null "$BACKUP_TMP/host/inventory" \
2515
+ && backup_host_file /boot/config/custom/usenet_health.sh custom/usenet_health.sh \
2516
+ && backup_host_file /boot/config/custom/ouro-events/bootstrap-spool.sh custom/ouro-events/bootstrap-spool.sh \
2517
+ && backup_host_file /boot/config/custom/ouro-events/emit-event.mjs custom/ouro-events/emit-event.mjs \
2518
+ && backup_host_file /boot/config/custom/ouro-events/emit-usenet-event.sh custom/ouro-events/emit-usenet-event.sh \
2519
+ && backup_host_file /boot/config/custom/ouro-events/install-usenet-guard.sh custom/ouro-events/install-usenet-guard.sh \
2520
+ && snapshot_butler_host_fragments \
2521
+ && test ! -e "$BACKUP_TMP/host/notify.conf" \
2522
+ && test ! -e "$BACKUP_TMP/host/sabnzbd.ini" \
2523
+ && rsync -a --exclude='/state/acceptance/telegram-control.sock' /mnt/user/appdata/ouro-butler/runtime/.ouro-cli "$BACKUP_TMP/runtime/" \
2524
+ && rsync -a --exclude='/state/acceptance/telegram-control.sock' /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro "$BACKUP_TMP/agent/" \
2525
+ && test ! -S "$BACKUP_TMP/agent/sanctuary.ouro/state/acceptance/telegram-control.sock" \
2526
+ && validate_sanctuary_roots "$BACKUP_TMP/runtime/.ouro-cli" "$BACKUP_TMP/agent/sanctuary.ouro" \
2527
+ && docker container inspect ouro-butler >"$BACKUP_TMP/provenance/container-inspect.json" \
2528
+ && docker image inspect "$BACKUP_IMAGE_ID" >"$BACKUP_TMP/provenance/image-inspect.json" \
2529
+ && docker run --rm --pull=never --network=none --read-only --entrypoint /usr/local/bin/node "$BACKUP_IMAGE_ID" -p 'require("/opt/ouro/package.json").version' >"$BACKUP_TMP/provenance/package-version" \
2530
+ && printf '%s\n' "$BACKUP_IMAGE_ID" >"$BACKUP_TMP/provenance/image-id" \
2531
+ && chown 0:0 "$BACKUP_TMP/provenance/container-inspect.json" "$BACKUP_TMP/provenance/image-inspect.json" "$BACKUP_TMP/provenance/package-version" "$BACKUP_TMP/provenance/image-id" \
2532
+ && chmod 0600 "$BACKUP_TMP/provenance/container-inspect.json" "$BACKUP_TMP/provenance/image-inspect.json" "$BACKUP_TMP/provenance/package-version" "$BACKUP_TMP/provenance/image-id" \
2533
+ && (cd "$BACKUP_TMP" && find runtime agent host provenance -xdev -type f ! -path provenance/manifest.sha256 -exec sha256sum -- {} + | LC_ALL=C sort -k2 >provenance/manifest.sha256) \
2534
+ && chown 0:0 "$BACKUP_TMP/provenance/manifest.sha256" \
2535
+ && chmod 0600 "$BACKUP_TMP/provenance/manifest.sha256" \
2536
+ && verify_sanctuary_snapshot_provenance "$BACKUP_TMP" "$BACKUP_IMAGE_ID" \
2537
+ && sync -f "$BACKUP_TMP/provenance/manifest.sha256" \
2538
+ && mv -- "$BACKUP_TMP" "$BACKUP_ROOT" \
2539
+ && sync -f "$BACKUP_PARENT"; then
2540
+ :
2541
+ else
2542
+ BACKUP_OPERATION_STATUS=$?
2543
+ fi
2544
+ if assert_update_source "$BACKUP_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" \
2545
+ && assert_only_running_butler - \
2546
+ && docker start ouro-butler \
2547
+ && assert_only_running_butler ouro-butler \
2548
+ && wait_butler_ready ouro-butler \
2549
+ && verify_butler_autostart "$BACKUP_AUTOSTART_COUNTS"; then
2550
+ :
2551
+ else
2552
+ BACKUP_RECOVERY_STATUS=$?
2553
+ if test -d "$BACKUP_ROOT"; then
2554
+ printf '%s\n' "CRITICAL: production recovery failed after backup; completed snapshot remains intact at $BACKUP_ROOT" >&2
2555
+ else
2556
+ printf '%s\n' "CRITICAL: production recovery failed and no completed snapshot was published; inspect $BACKUP_TMP" >&2
2557
+ fi
2558
+ (exit "$BACKUP_RECOVERY_STATUS")
2559
+ fi
2560
+ if test "$BACKUP_OPERATION_STATUS" -ne 0; then
2561
+ if test -d "$BACKUP_ROOT"; then
2562
+ printf '%s\n' "Backup publication was not durably confirmed; production was recovered; completed snapshot remains intact at $BACKUP_ROOT" >&2
2563
+ else
2564
+ printf '%s\n' "Backup failed before atomic publication; production was recovered; inspect $BACKUP_TMP" >&2
2565
+ fi
2566
+ (exit "$BACKUP_OPERATION_STATUS")
2567
+ fi
2568
+
2569
+ Restore:
2570
+ Set BACKUP_ROOT to the exact verified snapshot containing `runtime/.ouro-cli`
2571
+ and `agent/sanctuary.ouro`, set IMAGE_ID to its recorded local image ID, and
2572
+ set AUDIT_RUNNER_IMAGE_ID to the exact reviewed new image containing the
2573
+ legacy-aware auditor. The runner is independent from the restored image.
2574
+ AUDIT_RUNNER_IMAGE_TAG=ouro-butler:<new-version>
2575
+ AUDIT_RUNNER_IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$AUDIT_RUNNER_IMAGE_TAG")
2576
+ RESTORE_VERSION_IMAGE=$(/usr/local/bin/node -e '
2577
+ const fs = require("node:fs");
2578
+ const [containerPath, versionPath] = process.argv.slice(1);
2579
+ const containers = JSON.parse(fs.readFileSync(containerPath, "utf8"));
2580
+ const version = fs.readFileSync(versionPath, "utf8").trim();
2581
+ const expected = `ghcr.io/ourostack/ouroboros-butler:${version}`;
2582
+ if (!Array.isArray(containers) || containers.length !== 1 || containers[0]?.Config?.Image !== expected) process.exit(1);
2583
+ process.stdout.write(expected);
2584
+ ' "$BACKUP_ROOT/provenance/container-inspect.json" "$BACKUP_ROOT/provenance/package-version")
2585
+ test "$(docker image inspect --format '{{.Id}}' "$RESTORE_VERSION_IMAGE")" = "$IMAGE_ID"
2586
+ RESTORE_MOUNT_CONTRACT=$(sanctuary_image_mount_contract "$IMAGE_ID")
2587
+ audit_registered_dockerman_template "$AUDIT_RUNNER_IMAGE_ID" "$RESTORE_VERSION_IMAGE" "$RESTORE_MOUNT_CONTRACT"
2588
+ This data-snapshot Restore is not authority rollback. It must not overwrite a current gateway cursor, reintroduce a revoked resident token or remove a gateway mount behind the root owner. Refuse before mutation if a root epoch/config or deployment journal exists; use the retained Update transaction's authority-retire/authority-restore recovery instead. After a committed authority installation, obtain a reviewed root-aware restore transaction rather than applying this historical data snapshot:
2589
+ test ! -e /mnt/user/appdata/ouro-authority/active.json && test ! -L /mnt/user/appdata/ouro-authority/active.json
2590
+ test ! -e /boot/config/custom/ouro-butler/docker-man-template-transaction.json && test ! -L /boot/config/custom/ouro-butler/docker-man-template-transaction.json
2591
+ test "$RESTORE_MOUNT_CONTRACT" = canonical-pre-gateway
2592
+ Before changing autostart, durable roots, or live containers, run the nounset-safe input, backup-root, image, and topology preflight. It requires a nonempty canonical absolute BACKUP_ROOT other than /, both required directories, an exact local sha256 image ID, canonical production as the only running Butler, no staging or rollback, and at most one exact stopped legacy-evidence container. It also audits the live source container with the reviewed runner.
2593
+ Restore never rewrites DockerMan registration outside the reviewed template transaction. The preflight therefore stops unless the persistent template already names the exact snapshot version, and the post-start check proves DockerMan and Community Apps still recognize it.
2594
+ If the template names another version, first run the same reviewed version-tag update transaction for the snapshot version, then rerun Restore. A mismatch stops before autostart, durable-root, or production-container changes:
2595
+ if assert_restore_preflight; then
2596
+ :
2597
+ else
2598
+ RESTORE_PREFLIGHT_STATUS=$?
2599
+ (exit "$RESTORE_PREFLIGHT_STATUS")
2600
+ fi
2601
+ The verified host snapshot is restored only by this explicit Restore path.
2602
+ Ordinary update failure continues to use the installer's short-lived
2603
+ transaction rollback. Extract the installer from the reviewed runner image,
2604
+ then let that fixed allowlist transaction restore exact presence/absence for
2605
+ the five guard assets and reapply only the captured Butler-owned go/crontab
2606
+ fragments. Unrelated current go/crontab lines are never stored in the durable
2607
+ snapshot and remain untouched by restore. The transaction deactivates the
2608
+ current owned cron before swapping files and restores the entire pre-restore
2609
+ host state if any swap or final crontab activation fails:
2610
+ HOST_RESTORE_INSTALLER=$(mktemp /tmp/ouro-usenet-host-restore.XXXXXX)
2611
+ docker run --rm --pull=never --network=none --entrypoint /bin/cat "$AUDIT_RUNNER_IMAGE_ID" /opt/ouro/deploy/unraid/ouro-events/install-usenet-guard.sh >"$HOST_RESTORE_INSTALLER"
2612
+ chmod 0500 "$HOST_RESTORE_INSTALLER"
2613
+ Guard the atomic autostart disable before stopping or removing any Butler
2614
+ container. Failure propagates while the existing production remains untouched:
2615
+ /bin/bash /boot/config/custom/ouro-events/bootstrap-spool.sh --mount
2616
+ test "$(findmnt -n -o FSTYPE --target /boot/config/custom/ouro-events/spool)" = tmpfs
2617
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/spool)" = 0:0:755
2618
+ RESTORE_INSTALL_PROOF_ROOT=$(mktemp -d /mnt/user/appdata/ouro-butler/staging/restore-install-proof.XXXXXX)
2619
+ chmod 0700 "$RESTORE_INSTALL_PROOF_ROOT"
2620
+ if disable_butler_autostart; then
2621
+ :
2622
+ else
2623
+ RESTORE_AUTOSTART_DISABLE_STATUS=$?
2624
+ rmdir -- "$RESTORE_INSTALL_PROOF_ROOT"
2625
+ (exit "$RESTORE_AUTOSTART_DISABLE_STATUS")
2626
+ fi
2627
+ Stop and remove only ouro-butler, restore both roots without stale files,
2628
+ restore exact ownership, validate the image, and create/audit/start/bounded-wait
2629
+ the restored production inside one explicit condition. The initial tolerant
2630
+ stop plus inspected force-removal safely handles an already absent or stopped
2631
+ container:
2632
+ if { docker stop ouro-butler >/dev/null 2>&1 || true; } \
2633
+ && {
2634
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
2635
+ docker rm --force ouro-butler
2636
+ else
2637
+ :
2638
+ fi
2639
+ } \
2640
+ && ! docker container inspect ouro-butler >/dev/null 2>&1 \
2641
+ && rsync -a --delete "$BACKUP_ROOT/runtime/.ouro-cli/" /mnt/user/appdata/ouro-butler/runtime/.ouro-cli/ \
2642
+ && rsync -a --delete "$BACKUP_ROOT/agent/sanctuary.ouro/" /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/ \
2643
+ && chown -R 10001:10001 /mnt/user/appdata/ouro-butler/runtime/.ouro-cli /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro \
2644
+ && validate_sanctuary_roots /mnt/user/appdata/ouro-butler/runtime/.ouro-cli /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro \
2645
+ && /bin/bash "$HOST_RESTORE_INSTALLER" --restore-root "$BACKUP_ROOT/host" \
2646
+ && rm -f "$HOST_RESTORE_INSTALLER" \
2647
+ && docker image inspect "$IMAGE_ID" >/dev/null \
2648
+ && create_sanctuary_container "$IMAGE_ID" "$RESTORE_VERSION_IMAGE" "$RESTORE_MOUNT_CONTRACT" \
2649
+ && audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" "$RESTORE_MOUNT_CONTRACT" "$RESTORE_VERSION_IMAGE" https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png \
2650
+ && assert_only_running_butler - \
2651
+ && docker start ouro-butler \
2652
+ && assert_only_running_butler ouro-butler \
2653
+ && test "$(docker inspect --format '{{.Image}}' ouro-butler)" = "$IMAGE_ID" \
2654
+ && wait_butler_ready ouro-butler \
2655
+ && verify_dockerman_and_community_apps "$RESTORE_VERSION_IMAGE" "$RESTORE_INSTALL_PROOF_ROOT/install.json" \
2656
+ && rm -f -- "$RESTORE_INSTALL_PROOF_ROOT/install.json" \
2657
+ && rmdir -- "$RESTORE_INSTALL_PROOF_ROOT" \
2658
+ && enable_butler_autostart; then
2659
+ :
2660
+ else
2661
+ RESTORE_ACTIVATION_STATUS=$?
2662
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
2663
+ docker stop ouro-butler >/dev/null 2>&1 || true
2664
+ PARTIAL_RESTORE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
2665
+ test "$PARTIAL_RESTORE_IMAGE_ID" = "$IMAGE_ID"
2666
+ docker rm --force ouro-butler >/dev/null 2>&1 || true
2667
+ fi
2668
+ ! docker container inspect ouro-butler >/dev/null 2>&1 || exit 1
2669
+ rm -f "$HOST_RESTORE_INSTALLER"
2670
+ rm -f -- "$RESTORE_INSTALL_PROOF_ROOT/install.json"
2671
+ rmdir -- "$RESTORE_INSTALL_PROOF_ROOT"
2672
+ (exit "$RESTORE_ACTIVATION_STATUS")
2673
+ fi
2674
+ A restore has no retained old container or pre-restore data root to roll back
2675
+ to. Any stop/remove, data replacement, ownership, image validation, create,
2676
+ audit, start, or readiness failure therefore reaches the same cleanup arm,
2677
+ leaves all Butler autostart entries disabled, and propagates the captured
2678
+ failure for operator repair or a verified restore retry.
2679
+
2680
+ Credential recovery:
2681
+ The one canonical Telegram identity is display name `Mendelow Cloud Butler`,
2682
+ username `MendelowCloudButlerBot`, numeric bot ID `8541786263`. Treat any
2683
+ different display name, username, numeric ID, or fallback handle as a hard
2684
+ preflight failure; do not start polling or send a test message.
2685
+ Run `bootstrap_sanctuary_vault "$IMAGE_ID"` interactively before installing
2686
+ credentials. It uses only fresh `--rm` containers from that exact image as
2687
+ UID/GID 10001 with the canonical two binds; create/unlock is the sole `-it`
2688
+ action so the master secret remains on the hidden terminal prompt.
2689
+ Its first read-only `vault status` is authoritative: it runs `vault create`
2690
+ only when the locator is absent, runs `vault unlock` only when the locator
2691
+ exists but local unlock is missing, and does not prompt when local unlock is
2692
+ already available. It then requires the configured locator, available local
2693
+ unlock, and successful auth verification. Never guess from a failed status
2694
+ command.
2695
+ Initial legacy adoption passes the exact legacy envelope path as the helper's
2696
+ second argument. After vault availability, the helper validates and atomically
2697
+ copies that envelope into the canonical runtime root, imports it with a fresh
2698
+ same-image one-shot container, and requires both canonical source and claim to
2699
+ be absent before provider readiness. The exact legacy source remains untouched
2700
+ as rollback evidence until canonical acceptance completes.
2701
+ Restore or unlock the Sanctuary agent vault, then run provider refresh.
2702
+ /mnt/user/appdata/ouro-butler/runtime/.ouro-cli/container-credentials.json
2703
+ exists only for a one-time migration: if
2704
+ used, install it atomically as root with ownership 10001:10001 and mode 0600.
2705
+ Startup atomically claims it as container-credentials.json.consuming and
2706
+ writes every credential class into the unlocked Sanctuary vault before deleting the claimed envelope.
2707
+ Only then does it refresh the daemon's in-memory credentials.
2708
+ If validation or any vault write fails, startup fails closed and preserves
2709
+ container-credentials.json.consuming for the next startup to reconcile.
2710
+ Restore or create/unlock the configured agent vault, then restart the same
2711
+ reviewed image and let it retry the preserved claim. If both the source and
2712
+ claimed envelope exist and they are safe regular 0600 files owned by the
2713
+ runtime user, startup resumes automatically only when they are byte-for-byte identical:
2714
+ it durably removes the redundant unclaimed source and continues the existing claim.
2715
+ If they differ, the repair is human-required: stop the Butler, securely compare and quarantine
2716
+ the two envelopes, then restore exactly one reviewed envelope at the source path.
2717
+ Never print either envelope's contents or place them in logs or command arguments.
2718
+ After successful import, both files
2719
+ are absent and the vault is the only credential source of truth.
2720
+ `provision_sanctuary_sab_credential "$IMAGE_ID"` is the one-shot migration for
2721
+ the existing SAB key. Root reads the fixed host INI, writes only a mode-0600
2722
+ `ouro.runtimeCredentialBootstrap` envelope owned by 10001:10001, and a
2723
+ same-image one-shot container merges `sabnzbdApiKey` into
2724
+ `runtime/machines/sanctuary/config`; the key never appears in argv, the
2725
+ environment, logs, the Unraid template, or the Butler mounts. The bootstrap
2726
+ reconciler is idempotent for the same value and fails closed on a conflict.
2727
+ `verify_sanctuary_sab_readiness "$IMAGE_ID"` then reloads that machine item
2728
+ from the vault and performs a real bounded queue read before any poller
2729
+ cutover. The runtime and agent roots are persistent binds and package-managed
2730
+ updates do not replace the machine vault item, so the credential survives
2731
+ template and image updates.
2732
+ Bootstrap is not the rotation path. After SAB rotates its key, use the
2733
+ existing hidden-prompt merge command
2734
+ `ouro vault config set --agent sanctuary --key sabnzbdApiKey --scope machine`,
2735
+ restart the reviewed Butler container so its machine-config cache refreshes,
2736
+ and rerun `verify_sanctuary_sab_readiness "$IMAGE_ID"`; a conflicting
2737
+ bootstrap is expected to stop rather than overwrite canonical vault state.
2738
+ Never print or place credential values in logs, templates, command arguments,
2739
+ backups, or this runbook.
2740
+
2741
+ Packaged deployment-target containment gates:
2742
+ Unit 16 is fixed to the `staging` profile and therefore targets only
2743
+ `ouro-butler-staging`; it does not require `ouro-butler` to exist. Before the
2744
+ broker starts, the packaged deployment auditor captures all present canonical
2745
+ production/staging/rollback identities in one Docker inspect, verifies the
2746
+ target image, `unless-stopped`, host networking, matching GraphQL and durable
2747
+ file autostart identity/state for every present canonical container,
2748
+ and exactly one running Butler, then repeats the canonical snapshot to reject
2749
+ races. It binds the target PID and exact Docker cgroup-v2 path to the inspected
2750
+ container ID; Unit 16 carries that immutable ID into every launcher and broker
2751
+ Docker operation and binds it to the GraphQL PrefixedID suffix. It scans every
2752
+ cgroup process and requires stable before/after process, owned-socket, and
2753
+ listener inventories. Between the two canonical topology captures, it verifies
2754
+ the exact immutable-ID target is running and unpaused, pauses that ID without
2755
+ changing restart or autostart state, and performs every cgroup, all-thread
2756
+ descriptor, TCP, UDP, and Unix observation inside that one quiesced window,
2757
+ including two matching complete terminal snapshots; nonconvergence fails
2758
+ closed after eight attempts. It always makes bounded exact-ID unpause attempts,
2759
+ verifies the same ID and PID resumed their original running, unpaused state,
2760
+ and only then performs the final full-profile topology capture. It
2761
+ rejects every owned UDP listener and every externally reachable owned TCP listener.
2762
+ Only loopback Mailbox port 6876 plus stream-listening endpoints at /tmp/ouroboros-daemon.sock, /tmp/ouroboros-daemon.sock.frontend, and /home/ouro/AgentBundles/sanctuary.ouro/state/acceptance/telegram-control.sock are permitted; all other named Unix endpoint types (including datagrams) fail closed.
2763
+ Inherited descriptors for the same socket inode are deduplicated, while unknown loopback ports, wildcard/host-address listeners, process/socket/listener drift, or ambiguous ownership fail closed.
2764
+ Unit 18 uses the separately packaged fixed final command after activation.
2765
+ It accepts either the legacy-adoption topology with no canonical rollback or
2766
+ exactly one stopped, non-autostarted rollback; running/autostarted rollback,
2767
+ staging presence, and ambiguous canonical identities always fail closed:
2768
+ UNIT18_TARGET_TMP=$(mktemp /run/sanctuary-unit18-target-audit.sh.XXXXXX)
2769
+ /usr/bin/timeout -s KILL 20 /usr/bin/docker run --rm --pull=never --network none \
2770
+ --entrypoint /bin/cat "$IMAGE_ID" /opt/ouro/deploy/unraid/sanctuary-unit18-target-audit.sh >"$UNIT18_TARGET_TMP"
2771
+ chmod 0500 "$UNIT18_TARGET_TMP"
2772
+ "$UNIT18_TARGET_TMP" "$IMAGE_ID"
2773
+ rm -f -- "$UNIT18_TARGET_TMP"
2774
+ Neither packaged command accepts a container name or an open-ended profile.
2775
+
2776
+ Packaged Unit 16 acceptance execution:
2777
+ Use only the launcher packaged in the exact reviewed image. Install it without
2778
+ network access, then create the two private roots with the runtime UID/GID:
2779
+ UNIT16_ROOT=/mnt/user/appdata/ouro-butler/acceptance
2780
+ install -d -m 0700 -o 10001 -g 10001 "$UNIT16_ROOT/configs" "$UNIT16_ROOT/evidence"
2781
+ UNIT16_LAUNCHER_TMP=$(mktemp "$UNIT16_ROOT/sanctuary-unit16-run.sh.tmp.XXXXXX")
2782
+ /usr/bin/timeout -s KILL 20 /usr/bin/docker run --rm --pull=never --network none \
2783
+ --entrypoint /bin/cat "$IMAGE_ID" /opt/ouro/deploy/unraid/sanctuary-unit16-run.sh >"$UNIT16_LAUNCHER_TMP"
2784
+ install -m 0755 -o root -g root "$UNIT16_LAUNCHER_TMP" "$UNIT16_ROOT/sanctuary-unit16-run.sh"
2785
+ rm -f -- "$UNIT16_LAUNCHER_TMP"
2786
+ After final activation, create the disjoint final-profile roots and invoke the
2787
+ same immutable launcher with its only non-staging selector. The selector is a
2788
+ closed `staging|final` enum; it never accepts a container name, ID, or path:
2789
+ UNIT18_ROOT=/mnt/user/appdata/ouro-butler/acceptance/final
2790
+ install -d -m 0700 -o 10001 -g 10001 "$UNIT18_ROOT/configs" "$UNIT18_ROOT/evidence"
2791
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-snapshot
2792
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-snapshot evidence-snapshot.json
2793
+ Use `--profile final` on every final pre-reboot and post-reboot materialize/run
2794
+ command. Omitting it remains the byte-for-byte Unit 16 staging path. Never mix
2795
+ staging and final config/evidence roots or reuse a config across profiles.
2796
+ Never hand-author a Unit 16 config. Materialize it from the exact image, then
2797
+ execute it in the order below. Before every execution the launcher regenerates
2798
+ the config from the packaged fixed contract and requires byte-for-byte equality.
2799
+ Unit 16d-2 stops at the pre-model quarantine boundary: use a genuinely distinct private Telegram sender, confirm the fixed acknowledgement and owner admission card, and do not approve the contact during this scenario. The production-identical allow-to-one-turn continuation is covered by the Telegram admission integration suite when a second live account is unavailable. Unit 16h is acceptance-only: it exercises the delivery path against isolated state, restores exact health and cron bytes, and does not activate a production daily digest.
2800
+ The cursor snapshot is deliberately materialized and executed twice around the live scenario. Every telegram-offset proof uses the fresh signed telegram.cursor.snapshot logical progress digest, excluding snapshot time/signature rather than hashing a fixed resident offset file. Stale, absent or frozen resident offsets cannot satisfy cursor evidence. Telegram bootstrap confirms the already pinned root owner through gateway poll/settle; it performs no token acquisition, vault credential write or direct getUpdates. Its one-shot has network=none and a readonly runtime mount. Callback injection alone maps reviewed saved callback-update JSON from host fd 3 through Docker stdin to in-container fd 3.
2801
+ Allow the 90-second config materializer budget and the existing 65-second gateway socket wait; do not replace timeout failure with resident token readiness. Unit16b is explicitly retired as a production containment assertion and remains runtime-vault-readiness. Unit16e is the exact five-mount production snapshot with readOnlyRoot=false; mountsExact remains strict.
2802
+ Stage the reviewed callback JSON at the fixed path below in the root-owned
2803
+ tmpfs inbox, then use this single fail-closed helper. It opens the input once,
2804
+ validates the opened descriptor and its original path refer to the same
2805
+ root-owned mode-0600 regular file, and invokes the launcher only afterward.
2806
+ Success overwrites, fsyncs, and unlinks the raw input. Any failure allocates a
2807
+ collision-free quarantine file atomically, copies and fsyncs the input into it,
2808
+ locks it to mode 000, then overwrites and removes the tmpfs source;
2809
+ no raw callback update remains loose in `/root` or the inbox.
2810
+ UNIT16_CALLBACK_INBOX=/run/ouro-unit16-callback-input
2811
+ UNIT16_CALLBACK_FILE=$UNIT16_CALLBACK_INBOX/callback-update.json
2812
+ UNIT16_CALLBACK_QUARANTINE_ROOT=$UNIT16_ROOT/callback-quarantine
2813
+ install -d -m 0700 -o root -g root "$UNIT16_CALLBACK_INBOX" "$UNIT16_CALLBACK_QUARANTINE_ROOT"
2814
+ run_unit16_callback_inject() {
2815
+ (
2816
+ UNIT16_CALLBACK_VALIDATED=no
2817
+ UNIT16_CALLBACK_STATUS=1
2818
+ unit16_callback_cleanup() {
2819
+ UNIT16_CALLBACK_STATUS=$?
2820
+ trap - EXIT HUP INT TERM
2821
+ if test -e "$UNIT16_CALLBACK_FILE" || test -L "$UNIT16_CALLBACK_FILE"; then
2822
+ if test "$UNIT16_CALLBACK_STATUS" -eq 0 && test "$UNIT16_CALLBACK_VALIDATED" = yes; then
2823
+ if ! truncate -s 0 /proc/self/fd/3 || ! sync -f /proc/self/fd/3 \
2824
+ || ! rm -f -- "$UNIT16_CALLBACK_FILE" || ! sync -f "$UNIT16_CALLBACK_INBOX"; then
2825
+ UNIT16_CALLBACK_STATUS=1
2826
+ fi
2827
+ elif test -f "$UNIT16_CALLBACK_FILE" && test ! -L "$UNIT16_CALLBACK_FILE"; then
2828
+ UNIT16_CALLBACK_QUARANTINE_FILE=$(mktemp "$UNIT16_CALLBACK_QUARANTINE_ROOT/failed.XXXXXX") || UNIT16_CALLBACK_QUARANTINE_FILE=
2829
+ if test -n "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2830
+ && chmod 0600 "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2831
+ && cp -- "$UNIT16_CALLBACK_FILE" "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2832
+ && sync -f "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2833
+ && chmod 000 "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2834
+ && sync -f "$UNIT16_CALLBACK_QUARANTINE_ROOT"; then
2835
+ :
2836
+ else
2837
+ test -z "${UNIT16_CALLBACK_QUARANTINE_FILE-}" || rm -f -- "$UNIT16_CALLBACK_QUARANTINE_FILE"
2838
+ UNIT16_CALLBACK_STATUS=1
2839
+ fi
2840
+ if ! truncate -s 0 "$UNIT16_CALLBACK_FILE" || ! sync -f "$UNIT16_CALLBACK_FILE" \
2841
+ || ! rm -f -- "$UNIT16_CALLBACK_FILE" || ! sync -f "$UNIT16_CALLBACK_INBOX"; then
2842
+ UNIT16_CALLBACK_STATUS=1
2843
+ fi
2844
+ else
2845
+ rm -f -- "$UNIT16_CALLBACK_FILE" || UNIT16_CALLBACK_STATUS=1
2846
+ sync -f "$UNIT16_CALLBACK_INBOX" || UNIT16_CALLBACK_STATUS=1
2847
+ fi
2848
+ fi
2849
+ exec 3<&-
2850
+ exit "$UNIT16_CALLBACK_STATUS"
2851
+ }
2852
+ trap unit16_callback_cleanup EXIT
2853
+ trap 'exit 129' HUP
2854
+ trap 'exit 130' INT
2855
+ trap 'exit 143' TERM
2856
+ test -d "$UNIT16_CALLBACK_INBOX" && test ! -L "$UNIT16_CALLBACK_INBOX" || exit 1
2857
+ test "$(stat -c '%F %u:%g %a' "$UNIT16_CALLBACK_INBOX")" = "directory 0:0 700" || exit 1
2858
+ test -d "$UNIT16_CALLBACK_QUARANTINE_ROOT" && test ! -L "$UNIT16_CALLBACK_QUARANTINE_ROOT" || exit 1
2859
+ test "$(stat -c '%F %u:%g %a' "$UNIT16_CALLBACK_QUARANTINE_ROOT")" = "directory 0:0 700" || exit 1
2860
+ test -f "$UNIT16_CALLBACK_FILE" && test ! -L "$UNIT16_CALLBACK_FILE" || exit 1
2861
+ exec 3<"$UNIT16_CALLBACK_FILE"
2862
+ test "$(stat -Lc '%F' /proc/self/fd/3)" = "regular file" || exit 1
2863
+ test "$(stat -c '%d:%i' "$UNIT16_CALLBACK_FILE")" = "$(stat -Lc '%d:%i' /proc/self/fd/3)" || exit 1
2864
+ UNIT16_CALLBACK_VALIDATED=yes
2865
+ test "$(stat -Lc '%F %u:%g %a' /proc/self/fd/3)" = "regular file 0:0 600" || exit 1
2866
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final callback-inject callback-inject.json 3<&3
2867
+ )
2868
+ }
2869
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize telegram-bootstrap
2870
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final telegram-bootstrap telegram-bootstrap.json
2871
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize cursor-snapshot before
2872
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final cursor-snapshot cursor-snapshot.json
2873
+ # Perform the live scenario whose cursor movement is being measured.
2874
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize cursor-snapshot after
2875
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final cursor-snapshot cursor-snapshot.json
2876
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize cursor-delta
2877
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final cursor-delta cursor-delta.json
2878
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize callback-inject
2879
+ run_unit16_callback_inject
2880
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize unraid-key-rotate
2881
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final unraid-key-rotate unraid-key-rotate.json
2882
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize reboot-request
2883
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final reboot-request reboot-request.json
2884
+ # The preceding command captures and seals preflight evidence before it asks
2885
+ # the broker to stage the reboot, then seals the requested-phase evidence and
2886
+ # fsyncs the requested checkpoint before invoking the host reboot. Reconnect
2887
+ # only after Unraid and Docker are ready.
2888
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize reboot-resume
2889
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final reboot-resume reboot-resume.json
2890
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-snapshot
2891
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-snapshot evidence-snapshot.json
2892
+ # reboot-resume seals the changed boot identity and recovery milestones.
2893
+ # evidence-snapshot then runs the remaining scenarios sequentially with a
2894
+ # 72-minute aggregate bound; each completed scenario records provenance at
2895
+ # capture time and always clears its public gate, private marker, and receipt.
2896
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-bundle-index
2897
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-bundle-index evidence-bundle-index.json
2898
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-bundle-verify
2899
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-bundle-verify evidence-bundle-verify.json
2900
+ Each execution revalidates the immutable image and the production container's
2901
+ exact image ID. The main one-shot gets a read-only root filesystem, one writable
2902
+ evidence mount, narrowly selected runtime/bundle modes, and individual read-only
2903
+ image/container/health/boot fact files. Host-only operations cross an ephemeral
2904
+ root-owned private Unix socket (root:10001 mode 0660) to a root broker extracted
2905
+ from the same exact image. The broker accepts only fixed Unraid inventory/create/
2906
+ exact-revoke/rejection operations, exact fixed-target snapshots, and a
2907
+ two-phase reboot reservation/final commit. Reservation drains queued owner
2908
+ mutations and rejects new ones; after the requested checkpoint is fsynced, the
2909
+ broker repeats the array/parity/mover/durable-health preflight and invokes the
2910
+ fixed host reboot itself exactly once. The Unit 16 broker inspects only the
2911
+ deployment profile's attested immutable container ID (staging is
2912
+ `ouro-butler-staging`), requires its immutable image ID, writes that exact typed/redacted
2913
+ snapshot before accepting requests, and exposes a nonnegative Docker restart
2914
+ count. Both initial and refreshed snapshots share the same exact schema. The
2915
+ broker derives `autostartExact` from both the Unraid GraphQL `autoStart` value
2916
+ and exactly one staging entry in `/var/lib/docker/unraid-autostart`, derives
2917
+ `updaterDisabled` from `/opt/ouro/container-runtime.json` inside the exact image,
2918
+ and derives `vaultUnlocked`/`manualAuthRequired` from a bounded live Sanctuary
2919
+ vault-status command only when its non-secret summary proves the required
2920
+ Telegram runtime fields and both configured provider records were freshly read.
2921
+ Scenario handles remain private to the scenario adapter. The main one-shot
2922
+ never receives the Docker socket, Unraid key directory, or a host-root mount.
2923
+ Telegram bootstrap brackets its one-shot with a host-controlled quiescence guard: it verifies the exact healthy profile-selected resident, assumes recovery responsibility before stopping it with a 30-second grace bound, and proves it is stopped while one live root gateway owns polling. The broker proves the exact root lock/command/start identity and revoked predecessor token epoch; it does not accept a zero-poller file or a resident offset as authority. Every separately launched gateway consumer mounts only /run/ouro-authority readonly. The exit/signal trap restarts the same resident and waits up to 120 seconds for healthy recovery on success or failure without changing Unraid autostart. Every command receives a fresh redacted typed container snapshot, never raw environment or credential values.
2924
+ Callback injection requires two stable zero observations from a durable callback playback journal under
2925
+ `state/approvals`; the journal is keyed only by the full callback-coordinate
2926
+ digest and stores no raw update, callback data, user, chat, message, or query ID.
2927
+ Evidence-snapshot keeps the bundle mount read-only and overlays only the exact
2928
+ canonical `state/acceptance` directory as writable. It first stops the exact
2929
+ healthy staging container under recovery responsibility, then opens the
2930
+ directory with `O_DIRECTORY|O_NOFOLLOW` and bind-mounts from the inherited fd
2931
+ while no Sanctuary UID process is running. The launcher bind-pins that inode
2932
+ behind a root-only alias and back onto the canonical path against rename swaps,
2933
+ restarts the same exact image healthy, and compares the alias, canonical path,
2934
+ and staging container's inode before and after capture. Cleanup unmounts the
2935
+ canonical pin and then the alias. The staging daemon therefore sees the same
2936
+ inode used by the one-shot for marker correlation.
2937
+
2938
+ Audit and safety verification:
2939
+ Inspect AgentBundles/sanctuary.ouro/state/approvals for durable approval and restart-attempt receipts. Confirm with docker inspect that the container has no Docker socket, device, host-root mount, or published port. Confirm `Config.Image` equals the canonical package-version tag and `.Image` equals the exact reviewed local image ID. The read key must reject Docker stop and restart mutations; only the separate write key may perform the one typed approved restart action.
2940
+ The packaged `unraid-key-rotate` command is the only canonical key-rotation
2941
+ authority. Its host launcher stops the exact production container and assumes
2942
+ trap-backed recovery responsibility before key mutation. It inventories the
2943
+ two occupied canonical names by exact immutable ID, creates collision-safe
2944
+ `Butler RO Rotation <suffix>` and
2945
+ `Butler RW Rotation <suffix>` keys, stores and capability-probes both through
2946
+ the Sanctuary machine vault, and on the uninterrupted path requires an exact
2947
+ four-key inventory. Because
2948
+ Unraid 7.2.3 cannot create a role-free key through its CLI, the root broker
2949
+ creates each key as provisional `GUEST` with the intended scope plus only
2950
+ `API_KEY:UPDATE_ANY`, then immediately uses that in-memory credential to
2951
+ self-downgrade the same immutable ID to no roles and the exact intended scope.
2952
+ Disk and live-auth proofs must pass before the credential may reach the vault.
2953
+ The provisional credential never reaches argv, logs, evidence, the bundle, or
2954
+ the model. It then
2955
+ revokes both old IDs and proves each old credential receives a 401 or 403.
2956
+ Only after an exact inventory contains the temporary pair alone does it create,
2957
+ store, and probe the canonical `Butler RO` and `Butler RW` pair. It requires an
2958
+ exact four-key temporary-plus-canonical inventory on the uninterrupted path,
2959
+ revokes and rejection-probes
2960
+ both temporary IDs, and finally requires exactly the canonical pair. Every
2961
+ unexpected key, duplicate, malformed permission set, role, name, inventory
2962
+ delta, or probe response fails closed. A resumed path may observe fewer keys
2963
+ only when the transaction checkpoint plus a durable rejection receipt proves
2964
+ that the missing immutable ID was already revoked; it never treats absence
2965
+ alone as success.
2966
+
2967
+ A failed invocation resumes from the durable redacted transaction checkpoint
2968
+ instead of reminting known keys. The checkpoint binds every created immutable
2969
+ ID to its name, scope, vault field, temporary/canonical class, and attestation
2970
+ state. Recovery reconciles exact live inventory and rejection proofs, skips
2971
+ already-attested vault writes after canonical fields advance, and never
2972
+ recreates a temporary key already known revoked. Root-owned recovery ownership
2973
+ survives broker response loss and is cleared only after the harness has
2974
+ checkpointed the immutable ID and the vault has read back the exact binding.
2975
+
2976
+ Record only IDs, names, and permission sets in evidence; never raw key values.
2977
+ Raw credentials must not appear in argv, shell history, stdout, logs, or
2978
+ acceptance evidence. They may cross only the ephemeral private Unit 16 Unix
2979
+ socket in process memory. The narrow revocation-retry recovery records under
2980
+ `/mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof` are the sole file
2981
+ exception: they are root-owned mode 0600 beneath a root-owned mode 0700
2982
+ directory, excluded from backup/evidence, and readable only by the root broker.
2983
+ Recovery state is retained across failed retries.
2984
+ Creation intent is fsynced before the CLI side effect so an exact provisional
2985
+ key can be adopted after process death. Raw recovery proof is durably deleted
2986
+ only after the exact authentication rejection and its redacted replayable
2987
+ receipt are fsynced. Never substitute names for IDs when revoking keys.
2988
+
2989
+ Shipping a Butler release (one command)
2990
+
2991
+ From the Mac, after the release PR has merged to main:
2992
+
2993
+ npm run butler:ship -- <version> [--host sanctuary] [--no-gate] [--plant <case>] [--dry-run]
2994
+
2995
+ It resolves the release merge commit, waits for that commit's coverage-gate and for the GHCR image to resolve on the host, backs up the host's /tmp/butler-upgrade.mjs and /tmp/sanctuary-replay-gate.mjs to .bak-<previous>, stages the release's own copies, runs preflight, starts the gated upgrade detached with its log at /var/log/ouro-upgrade-<version>.log, follows the case lines to a terminal line and runs verify. The last stdout line is one JSON result: {version, result: kept|rolled_back|refused|failed, running_image, cases, verify, log}. Exit 0 only when the result is kept and verify is green. --dry-run runs only the read-only steps. BUTLER_HOST overrides the default host alias.
2996
+
2997
+ Manual fallback (the script automates exactly these): wait for coverage-gate and `docker manifest inspect ghcr.io/ourostack/ouroboros-butler:<version>` on the host; copy deploy/unraid/sanctuary-butler-upgrade.mjs to /tmp/butler-upgrade.mjs and deploy/unraid/sanctuary-replay-gate.mjs to /tmp/sanctuary-replay-gate.mjs; run `node /tmp/butler-upgrade.mjs preflight <version>`; start `setsid nohup /usr/local/bin/node /tmp/butler-upgrade.mjs upgrade <version> > /var/log/ouro-upgrade-<version>.log 2>&1 < /dev/null &`; read the log for GATE PASS, GATE FAIL (rolled back), UPGRADE done, REFUSING or FAILED; then run `node /tmp/butler-upgrade.mjs verify`. Check for a live upgrade with pgrep -f "[b]utler-upgrade.mjs upgrade" so the check does not match its own shell.