@ouro.bot/cli 0.1.0-alpha.79 → 0.1.0-alpha.791

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 (610) 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/changelog.json +5060 -16
  15. package/deploy/unraid/Dockerfile +48 -0
  16. package/deploy/unraid/README.txt +2523 -0
  17. package/deploy/unraid/audit-container-spec.sh +4 -0
  18. package/deploy/unraid/container-runtime.json +1 -0
  19. package/deploy/unraid/migrate-sanctuary-bundle.mjs +21 -0
  20. package/deploy/unraid/ouro-events/bootstrap-spool.sh +92 -0
  21. package/deploy/unraid/ouro-events/emit-event.mjs +310 -0
  22. package/deploy/unraid/ouro-events/emit-usenet-event.sh +54 -0
  23. package/deploy/unraid/ouro-events/install-usenet-guard.sh +435 -0
  24. package/deploy/unraid/ouro-events/usenet-health.sh +176 -0
  25. package/deploy/unraid/sanctuary-acceptance-adapter.sh +43 -0
  26. package/deploy/unraid/sanctuary-acceptance-contract.json +198 -0
  27. package/deploy/unraid/sanctuary-acceptance-harness.sh +23 -0
  28. package/deploy/unraid/sanctuary-deployment-target.mjs +709 -0
  29. package/deploy/unraid/sanctuary-safe-rename.py +24 -0
  30. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +2030 -0
  31. package/deploy/unraid/sanctuary-unit16-run.sh +560 -0
  32. package/deploy/unraid/sanctuary-unit18-target-audit.sh +27 -0
  33. package/deploy/unraid/sanctuary.ouro/agent.json +24 -0
  34. package/deploy/unraid/sanctuary.ouro/arc/README.md +3 -0
  35. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +5 -0
  36. package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +8 -0
  37. package/deploy/unraid/sanctuary.ouro/provider-readiness.json +11 -0
  38. package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +5 -0
  39. package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +5 -0
  40. package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +11 -0
  41. package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +11 -0
  42. package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +11 -0
  43. package/deploy/unraid/sanctuary.ouro/state/policy/steward.json +7 -0
  44. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +23 -0
  45. package/deploy/unraid/sanctuary.xml +27 -0
  46. package/dist/a2a/card.js +57 -0
  47. package/dist/a2a/client.js +180 -0
  48. package/dist/a2a/config.js +50 -0
  49. package/dist/a2a/delegation-stores.js +56 -0
  50. package/dist/a2a/did-resolution.js +93 -0
  51. package/dist/a2a/identity.js +170 -0
  52. package/dist/a2a/inbound-share.js +107 -0
  53. package/dist/a2a/mission-result-wire.js +196 -0
  54. package/dist/a2a/onboarding.js +115 -0
  55. package/dist/a2a/pin-store.js +132 -0
  56. package/dist/a2a/seen-ledger.js +120 -0
  57. package/dist/a2a/server.js +604 -0
  58. package/dist/a2a/task-store.js +69 -0
  59. package/dist/a2a/transport.js +45 -0
  60. package/dist/a2a/types.js +3 -0
  61. package/dist/arc/attention-types.js +8 -0
  62. package/dist/arc/cares.js +360 -0
  63. package/dist/arc/episodes.js +118 -0
  64. package/dist/arc/evolution.js +490 -0
  65. package/dist/arc/flight-recorder.js +1034 -0
  66. package/dist/arc/intentions.js +134 -0
  67. package/dist/arc/json-store.js +117 -0
  68. package/dist/arc/obligations.js +386 -0
  69. package/dist/arc/packets.js +336 -0
  70. package/dist/arc/presence.js +185 -0
  71. package/dist/arc/task-lifecycle.js +57 -0
  72. package/dist/commerce/store.js +755 -0
  73. package/dist/commerce/types.js +3 -0
  74. package/dist/heart/active-work.js +820 -29
  75. package/dist/heart/agent-entry.js +252 -3
  76. package/dist/heart/approval-files.js +113 -0
  77. package/dist/heart/approval-store.js +950 -0
  78. package/dist/heart/attachments/image-normalize.js +194 -0
  79. package/dist/heart/attachments/materialize.js +97 -0
  80. package/dist/heart/attachments/originals.js +88 -0
  81. package/dist/heart/attachments/render.js +29 -0
  82. package/dist/heart/attachments/sources/bluebubbles.js +156 -0
  83. package/dist/heart/attachments/sources/cli-local-file.js +78 -0
  84. package/dist/heart/attachments/sources/index.js +18 -0
  85. package/dist/heart/attachments/sources/telegram.js +71 -0
  86. package/dist/heart/attachments/store.js +132 -0
  87. package/dist/heart/attachments/types.js +93 -0
  88. package/dist/heart/auth/auth-flow.js +495 -0
  89. package/dist/heart/autonomy-budget.js +478 -0
  90. package/dist/heart/awaiting/await-alert.js +148 -0
  91. package/dist/heart/awaiting/await-expiry.js +143 -0
  92. package/dist/heart/awaiting/await-loader.js +91 -0
  93. package/dist/heart/awaiting/await-parser.js +149 -0
  94. package/dist/heart/awaiting/await-runtime-state.js +100 -0
  95. package/dist/heart/awaiting/await-scheduler.js +382 -0
  96. package/dist/heart/background-operations.js +281 -0
  97. package/dist/heart/bridges/manager.js +137 -17
  98. package/dist/heart/bridges/store.js +14 -2
  99. package/dist/heart/bundle-state.js +168 -0
  100. package/dist/heart/commitments.js +64 -18
  101. package/dist/heart/config-registry.js +331 -0
  102. package/dist/heart/config.js +142 -129
  103. package/dist/heart/context-loss-gauntlet.js +387 -0
  104. package/dist/heart/context-loss-sentinel.js +1157 -0
  105. package/dist/heart/core.js +2014 -469
  106. package/dist/heart/cross-chat-delivery.js +9 -22
  107. package/dist/heart/daemon/agent-config-check.js +451 -0
  108. package/dist/heart/daemon/agent-discovery.js +185 -3
  109. package/dist/heart/daemon/agent-service.js +542 -0
  110. package/dist/heart/daemon/agentic-repair.js +547 -0
  111. package/dist/heart/daemon/await-private-wake.js +59 -0
  112. package/dist/heart/daemon/bluebubbles-cli-integration.js +71 -0
  113. package/dist/heart/daemon/bluebubbles-health-diagnostics.js +122 -0
  114. package/dist/heart/daemon/bluebubbles-host-protocol.js +433 -0
  115. package/dist/heart/daemon/bluebubbles-host.js +273 -0
  116. package/dist/heart/daemon/boot-sync-probe.js +197 -0
  117. package/dist/heart/daemon/cadence.js +180 -0
  118. package/dist/heart/daemon/cli-defaults.js +836 -0
  119. package/dist/heart/daemon/cli-desk.js +322 -0
  120. package/dist/heart/daemon/cli-exec.js +8730 -0
  121. package/dist/heart/daemon/cli-help.js +710 -0
  122. package/dist/heart/daemon/cli-parse.js +2719 -0
  123. package/dist/heart/daemon/cli-render-doctor.js +57 -0
  124. package/dist/heart/daemon/cli-render.js +777 -0
  125. package/dist/heart/daemon/cli-types.js +8 -0
  126. package/dist/heart/daemon/connect-bay.js +323 -0
  127. package/dist/heart/daemon/container-credential-bootstrap.js +273 -0
  128. package/dist/heart/daemon/container-healthcheck.js +96 -0
  129. package/dist/heart/daemon/container-runtime.js +97 -0
  130. package/dist/heart/daemon/container-spec-auditor-main.js +160 -0
  131. package/dist/heart/daemon/container-spec-auditor.js +176 -0
  132. package/dist/heart/daemon/daemon-bootstrap-startup.js +77 -0
  133. package/dist/heart/daemon/daemon-cli.js +29 -2284
  134. package/dist/heart/daemon/daemon-entry.js +907 -10
  135. package/dist/heart/daemon/daemon-health.js +186 -0
  136. package/dist/heart/daemon/daemon-rollup.js +57 -0
  137. package/dist/heart/daemon/daemon-runtime-sync.js +88 -13
  138. package/dist/heart/daemon/daemon-tombstone.js +236 -0
  139. package/dist/heart/daemon/daemon.js +2010 -88
  140. package/dist/heart/daemon/dns-workflow.js +394 -0
  141. package/dist/heart/daemon/doctor-types.js +8 -0
  142. package/dist/heart/daemon/doctor.js +1755 -0
  143. package/dist/heart/daemon/freshness.js +345 -0
  144. package/dist/heart/daemon/habit-private-wake.js +61 -0
  145. package/dist/heart/daemon/health-monitor.js +142 -11
  146. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  147. package/dist/heart/daemon/hooks/bundle-meta.js +135 -1
  148. package/dist/heart/daemon/http-health-probe.js +80 -0
  149. package/dist/heart/daemon/human-command-screens.js +234 -0
  150. package/dist/heart/daemon/human-readiness.js +114 -0
  151. package/dist/heart/daemon/inner-status.js +78 -0
  152. package/dist/heart/daemon/interactive-repair.js +394 -0
  153. package/dist/heart/daemon/launchd.js +37 -8
  154. package/dist/heart/daemon/log-tailer.js +79 -10
  155. package/dist/heart/daemon/logs-prune.js +132 -0
  156. package/dist/heart/daemon/mcp-canary.js +524 -0
  157. package/dist/heart/daemon/message-router.js +65 -5
  158. package/dist/heart/daemon/migrate-to-desk.js +848 -0
  159. package/dist/heart/daemon/os-cron-deps.js +135 -0
  160. package/dist/heart/daemon/os-cron.js +22 -14
  161. package/dist/heart/daemon/ouro-bot-entry.js +4 -2
  162. package/dist/heart/daemon/ouro-entry.js +3 -1
  163. package/dist/heart/daemon/plugin-cli.js +432 -0
  164. package/dist/heart/daemon/process-manager.js +733 -61
  165. package/dist/heart/daemon/provider-discovery.js +137 -0
  166. package/dist/heart/daemon/provider-ping-progress.js +83 -0
  167. package/dist/heart/daemon/prunable-bundle.js +144 -0
  168. package/dist/heart/daemon/pulse.js +511 -0
  169. package/dist/heart/daemon/readiness-repair.js +365 -0
  170. package/dist/heart/daemon/run-hooks.js +2 -0
  171. package/dist/heart/daemon/runtime-logging.js +47 -14
  172. package/dist/heart/daemon/runtime-metadata.js +2 -30
  173. package/dist/heart/daemon/runtime-mode.js +13 -2
  174. package/dist/heart/daemon/safe-mode.js +161 -0
  175. package/dist/heart/daemon/sanctuary-acceptance-adapter.js +2609 -0
  176. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1866 -0
  177. package/dist/heart/daemon/sanctuary-acceptance-marker.js +267 -0
  178. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +976 -0
  179. package/dist/heart/daemon/sanctuary-bundle-migration.js +398 -0
  180. package/dist/heart/daemon/sanctuary-scheduler-liveness.js +227 -0
  181. package/dist/heart/daemon/sanctuary-scheduler-origin.js +132 -0
  182. package/dist/heart/daemon/sense-manager.js +689 -48
  183. package/dist/heart/daemon/session-id-resolver.js +131 -0
  184. package/dist/heart/daemon/skill-management-installer.js +1 -1
  185. package/dist/heart/daemon/socket-client.js +180 -12
  186. package/dist/heart/daemon/stale-bundle-prune.js +113 -0
  187. package/dist/heart/daemon/startup-tui.js +339 -0
  188. package/dist/heart/daemon/supercronic-supervisor.js +282 -0
  189. package/dist/heart/daemon/task-scheduler.js +117 -39
  190. package/dist/heart/daemon/terminal-ui.js +499 -0
  191. package/dist/heart/daemon/thoughts.js +138 -54
  192. package/dist/heart/daemon/up-progress.js +366 -0
  193. package/dist/heart/daemon/vault-items.js +56 -0
  194. package/dist/heart/delegation.js +1 -4
  195. package/dist/heart/external-events/router.js +1414 -0
  196. package/dist/heart/habits/habit-cancel.js +810 -0
  197. package/dist/heart/habits/habit-lifecycle.js +1327 -0
  198. package/dist/heart/habits/habit-migration.js +194 -0
  199. package/dist/heart/habits/habit-parser.js +278 -0
  200. package/dist/heart/habits/habit-runtime-state.js +93 -0
  201. package/dist/heart/habits/habit-scheduler.js +653 -0
  202. package/dist/heart/habits/habit-session-summary.js +318 -0
  203. package/dist/heart/habits/habit-session.js +636 -0
  204. package/dist/heart/{daemon → hatch}/hatch-flow.js +55 -62
  205. package/dist/heart/{daemon → hatch}/hatch-specialist.js +6 -8
  206. package/dist/heart/{daemon → hatch}/specialist-prompt.js +12 -9
  207. package/dist/heart/{daemon → hatch}/specialist-tools.js +53 -18
  208. package/dist/heart/identity.js +184 -59
  209. package/dist/heart/kept-notes.js +289 -0
  210. package/dist/heart/kicks.js +1 -1
  211. package/dist/heart/machine-identity.js +161 -0
  212. package/dist/heart/mail-import-discovery.js +353 -0
  213. package/dist/heart/mailbox/mailbox-http-hooks.js +98 -0
  214. package/dist/heart/mailbox/mailbox-http-response.js +7 -0
  215. package/dist/heart/mailbox/mailbox-http-routes.js +398 -0
  216. package/dist/heart/mailbox/mailbox-http-static.js +103 -0
  217. package/dist/heart/mailbox/mailbox-http-transport.js +116 -0
  218. package/dist/heart/mailbox/mailbox-http.js +99 -0
  219. package/dist/heart/mailbox/mailbox-read.js +39 -0
  220. package/dist/heart/mailbox/mailbox-types.js +27 -0
  221. package/dist/heart/mailbox/mailbox-view.js +197 -0
  222. package/dist/heart/mailbox/readers/agent-machine.js +428 -0
  223. package/dist/heart/mailbox/readers/continuity-readers.js +329 -0
  224. package/dist/heart/mailbox/readers/mail.js +375 -0
  225. package/dist/heart/mailbox/readers/runtime-readers.js +823 -0
  226. package/dist/heart/mailbox/readers/sessions.js +232 -0
  227. package/dist/heart/mailbox/readers/shared.js +111 -0
  228. package/dist/heart/mcp/mcp-server.js +696 -0
  229. package/dist/heart/migrate-config.js +100 -0
  230. package/dist/heart/model-capabilities.js +11 -0
  231. package/dist/heart/orientation-frame.js +345 -0
  232. package/dist/heart/platform.js +81 -0
  233. package/dist/heart/primitives.js +1 -1
  234. package/dist/heart/private-runtime/decision-renderer.js +158 -0
  235. package/dist/heart/private-runtime/index.js +18 -0
  236. package/dist/heart/private-runtime/ledger.js +221 -0
  237. package/dist/heart/private-runtime/policy.js +252 -0
  238. package/dist/heart/private-runtime/types.js +2 -0
  239. package/dist/heart/progress-story.js +1 -1
  240. package/dist/heart/provider-attempt.js +137 -0
  241. package/dist/heart/provider-binding-resolver.js +273 -0
  242. package/dist/heart/provider-credentials.js +449 -0
  243. package/dist/heart/provider-failover.js +311 -0
  244. package/dist/heart/provider-models.js +96 -0
  245. package/dist/heart/provider-ping.js +265 -0
  246. package/dist/heart/provider-readiness-cache.js +119 -0
  247. package/dist/heart/provider-visibility.js +188 -0
  248. package/dist/heart/providers/anthropic-token.js +131 -0
  249. package/dist/heart/providers/anthropic.js +173 -60
  250. package/dist/heart/providers/azure.js +23 -11
  251. package/dist/heart/providers/error-classification.js +127 -0
  252. package/dist/heart/providers/github-copilot.js +34 -38
  253. package/dist/heart/providers/minimax-vlm.js +189 -0
  254. package/dist/heart/providers/minimax.js +26 -8
  255. package/dist/heart/providers/openai-codex-token.js +349 -0
  256. package/dist/heart/providers/openai-codex.js +55 -40
  257. package/dist/heart/providers/openai-compatible.js +177 -0
  258. package/dist/heart/run-ledger.js +247 -0
  259. package/dist/heart/runtime-capability-check.js +170 -0
  260. package/dist/heart/runtime-credentials.js +607 -0
  261. package/dist/heart/runtime-cwd.js +87 -0
  262. package/dist/heart/sense-truth.js +22 -4
  263. package/dist/heart/session-activity.js +90 -24
  264. package/dist/heart/session-events.js +1318 -0
  265. package/dist/heart/session-playback-cli-main.js +5 -0
  266. package/dist/heart/session-playback-cli.js +36 -0
  267. package/dist/heart/session-playback.js +231 -0
  268. package/dist/heart/session-stats-cli-main.js +5 -0
  269. package/dist/heart/session-stats.js +182 -0
  270. package/dist/heart/session-transcript.js +133 -0
  271. package/dist/heart/start-of-turn-packet.js +372 -0
  272. package/dist/heart/steward-policy.js +302 -0
  273. package/dist/heart/streaming.js +604 -207
  274. package/dist/heart/structured-output.js +196 -0
  275. package/dist/heart/sync-classification.js +176 -0
  276. package/dist/heart/sync.js +449 -0
  277. package/dist/heart/target-resolution.js +11 -7
  278. package/dist/heart/tempo.js +93 -0
  279. package/dist/heart/temporal-view.js +41 -0
  280. package/dist/heart/timeouts.js +101 -0
  281. package/dist/heart/tool-activity-callbacks.js +59 -0
  282. package/dist/heart/tool-approval.js +342 -0
  283. package/dist/heart/tool-description.js +155 -0
  284. package/dist/heart/tool-friction.js +55 -0
  285. package/dist/heart/tool-loop.js +200 -0
  286. package/dist/heart/turn-context.js +481 -0
  287. package/dist/heart/{daemon → versioning}/ouro-bot-global-installer.js +6 -5
  288. package/dist/heart/{daemon → versioning}/ouro-bot-wrapper.js +1 -1
  289. package/dist/heart/versioning/ouro-path-installer.js +432 -0
  290. package/dist/heart/versioning/ouro-recovery-launcher.js +76 -0
  291. package/dist/heart/versioning/ouro-version-manager.js +409 -0
  292. package/dist/heart/{daemon → versioning}/staged-restart.js +40 -8
  293. package/dist/heart/{daemon → versioning}/update-checker.js +6 -1
  294. package/dist/heart/versioning/update-hooks.js +154 -0
  295. package/dist/heart/versioning/version-intent.js +114 -0
  296. package/dist/heart/work-card.js +380 -0
  297. package/dist/mailbox-ui/assets/index-Du_9G9WO.css +1 -0
  298. package/dist/mailbox-ui/assets/index-J8zDwHmE.js +1 -0
  299. package/dist/mailbox-ui/assets/vendor-CcN1XpQ9.js +61 -0
  300. package/dist/mailbox-ui/index.html +16 -0
  301. package/dist/mailroom/attention.js +167 -0
  302. package/dist/mailroom/autonomy.js +209 -0
  303. package/dist/mailroom/blob-store.js +889 -0
  304. package/dist/mailroom/body-cache.js +61 -0
  305. package/dist/mailroom/cache-sync-cli.js +58 -0
  306. package/dist/mailroom/core.js +803 -0
  307. package/dist/mailroom/entry.js +160 -0
  308. package/dist/mailroom/file-store.js +568 -0
  309. package/dist/mailroom/hosted-cache-sync.js +384 -0
  310. package/dist/mailroom/mbox-import.js +393 -0
  311. package/dist/mailroom/migration.js +164 -0
  312. package/dist/mailroom/outbound.js +380 -0
  313. package/dist/mailroom/policy.js +263 -0
  314. package/dist/mailroom/reader.js +292 -0
  315. package/dist/mailroom/search-cache.js +513 -0
  316. package/dist/mailroom/search-relevance.js +319 -0
  317. package/dist/mailroom/smtp-ingress.js +176 -0
  318. package/dist/mailroom/source-state.js +176 -0
  319. package/dist/mailroom/thread.js +109 -0
  320. package/dist/mailroom/travel-extract.js +89 -0
  321. package/dist/mind/bundle-manifest.js +24 -2
  322. package/dist/mind/context.js +278 -111
  323. package/dist/mind/desk-section.js +362 -0
  324. package/dist/mind/diary-integrity.js +60 -0
  325. package/dist/mind/{memory.js → diary.js} +68 -77
  326. package/dist/mind/embedding-provider.js +60 -0
  327. package/dist/mind/file-state.js +179 -0
  328. package/dist/mind/{associative-recall.js → note-search.js} +47 -58
  329. package/dist/mind/obligation-steering.js +227 -0
  330. package/dist/mind/pending.js +34 -8
  331. package/dist/mind/prompt-budget.js +479 -0
  332. package/dist/mind/prompt-refresh.js +3 -2
  333. package/dist/mind/prompt.js +1056 -187
  334. package/dist/mind/provenance-trust.js +26 -0
  335. package/dist/mind/record-paths.js +312 -0
  336. package/dist/mind/scrutiny.js +173 -0
  337. package/dist/mind/session-transaction.js +415 -0
  338. package/dist/nerves/cli-logging.js +7 -1
  339. package/dist/nerves/coverage/audit-rules.js +15 -6
  340. package/dist/nerves/coverage/audit.js +28 -2
  341. package/dist/nerves/coverage/cli.js +1 -1
  342. package/dist/nerves/coverage/contract.js +5 -5
  343. package/dist/nerves/coverage/file-completeness.js +139 -5
  344. package/dist/nerves/coverage/run-artifacts.js +55 -35
  345. package/dist/nerves/coverage/source-scanner.js +4 -4
  346. package/dist/nerves/event-buffer.js +111 -0
  347. package/dist/nerves/index.js +293 -9
  348. package/dist/nerves/observation.js +20 -0
  349. package/dist/nerves/redact.js +79 -0
  350. package/dist/nerves/review/cli-main.js +5 -0
  351. package/dist/nerves/review/cli.js +156 -0
  352. package/dist/nerves/review/core.js +159 -0
  353. package/dist/nerves/runtime.js +17 -1
  354. package/dist/repertoire/ado-client.js +15 -56
  355. package/dist/repertoire/ado-semantic.js +16 -10
  356. package/dist/repertoire/api-client.js +97 -0
  357. package/dist/repertoire/bitwarden-store.js +1045 -0
  358. package/dist/repertoire/bundle-templates.js +71 -0
  359. package/dist/repertoire/bw-installer.js +180 -0
  360. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  361. package/dist/repertoire/coding/context-pack.js +331 -0
  362. package/dist/repertoire/coding/feedback.js +218 -30
  363. package/dist/repertoire/coding/index.js +15 -3
  364. package/dist/repertoire/coding/manager.js +166 -10
  365. package/dist/repertoire/coding/spawner.js +58 -11
  366. package/dist/repertoire/coding/tools.js +242 -14
  367. package/dist/repertoire/coding/workbench-client.js +272 -0
  368. package/dist/repertoire/coding/workbench-manager.js +346 -0
  369. package/dist/repertoire/commerce-errors.js +109 -0
  370. package/dist/repertoire/commerce-self-test.js +156 -0
  371. package/dist/repertoire/credential-access.js +178 -0
  372. package/dist/repertoire/desk/classifier.js +362 -0
  373. package/dist/repertoire/duffel-client.js +185 -0
  374. package/dist/repertoire/github-client.js +14 -55
  375. package/dist/repertoire/graph-client.js +11 -52
  376. package/dist/repertoire/guardrails.js +811 -18
  377. package/dist/repertoire/mcp-client.js +44 -3
  378. package/dist/repertoire/mcp-manager.js +259 -20
  379. package/dist/repertoire/mcp-tools.js +83 -0
  380. package/dist/repertoire/plugin-mcp.js +175 -0
  381. package/dist/repertoire/plugins.js +253 -0
  382. package/dist/repertoire/relationship-authorization.js +194 -0
  383. package/dist/repertoire/shell-sessions.js +133 -0
  384. package/dist/repertoire/skills.js +48 -4
  385. package/dist/repertoire/stripe-client.js +131 -0
  386. package/dist/repertoire/tool-arguments.js +148 -0
  387. package/dist/repertoire/tool-results.js +29 -0
  388. package/dist/repertoire/tools-a2a.js +699 -0
  389. package/dist/repertoire/tools-attachments.js +322 -0
  390. package/dist/repertoire/tools-awaiting.js +575 -0
  391. package/dist/repertoire/tools-base.js +82 -1139
  392. package/dist/repertoire/tools-bluebubbles.js +2 -0
  393. package/dist/repertoire/tools-bridge.js +144 -0
  394. package/dist/repertoire/tools-bundle.js +993 -0
  395. package/dist/repertoire/tools-commerce.js +253 -0
  396. package/dist/repertoire/tools-config.js +186 -0
  397. package/dist/repertoire/tools-continuity.js +575 -0
  398. package/dist/repertoire/tools-credential.js +383 -0
  399. package/dist/repertoire/tools-evolution.js +527 -0
  400. package/dist/repertoire/tools-files.js +344 -0
  401. package/dist/repertoire/tools-flight.js +290 -0
  402. package/dist/repertoire/tools-flow.js +119 -0
  403. package/dist/repertoire/tools-github.js +3 -8
  404. package/dist/repertoire/tools-habits.js +103 -0
  405. package/dist/repertoire/tools-mail.js +1828 -0
  406. package/dist/repertoire/tools-notes.js +485 -0
  407. package/dist/repertoire/tools-obligations.js +182 -0
  408. package/dist/repertoire/tools-orientation.js +31 -0
  409. package/dist/repertoire/tools-record.js +482 -0
  410. package/dist/repertoire/tools-rsvp.js +139 -0
  411. package/dist/repertoire/tools-runtime.js +150 -0
  412. package/dist/repertoire/tools-session.js +1108 -0
  413. package/dist/repertoire/tools-shell.js +133 -0
  414. package/dist/repertoire/tools-steward-policy.js +95 -0
  415. package/dist/repertoire/tools-stripe.js +224 -0
  416. package/dist/repertoire/tools-surface.js +369 -0
  417. package/dist/repertoire/tools-teams.js +12 -39
  418. package/dist/repertoire/tools-telegram-contacts.js +37 -0
  419. package/dist/repertoire/tools-travel.js +125 -0
  420. package/dist/repertoire/tools-trip.js +982 -0
  421. package/dist/repertoire/tools-unraid.js +483 -0
  422. package/dist/repertoire/tools-user-profile.js +146 -0
  423. package/dist/repertoire/tools-vault.js +40 -0
  424. package/dist/repertoire/tools-voice.js +145 -0
  425. package/dist/repertoire/tools.js +377 -80
  426. package/dist/repertoire/travel-api-client.js +360 -0
  427. package/dist/repertoire/unraid-client.js +155 -0
  428. package/dist/repertoire/unraid-restart.js +212 -0
  429. package/dist/repertoire/user-profile.js +131 -0
  430. package/dist/repertoire/vault-setup.js +246 -0
  431. package/dist/repertoire/vault-unlock.js +628 -0
  432. package/dist/rsvp/aisleplanner-client.js +354 -0
  433. package/dist/rsvp/cli.js +926 -0
  434. package/dist/rsvp/config.js +327 -0
  435. package/dist/rsvp/cutover.js +433 -0
  436. package/dist/rsvp/diagnostics.js +232 -0
  437. package/dist/rsvp/diff-renderer.js +176 -0
  438. package/dist/rsvp/habit-policy.js +110 -0
  439. package/dist/rsvp/habit-stage.js +123 -0
  440. package/dist/rsvp/incident-bundle.js +97 -0
  441. package/dist/rsvp/migration.js +204 -0
  442. package/dist/rsvp/native-habit-runner.js +630 -0
  443. package/dist/rsvp/outbound-state.js +628 -0
  444. package/dist/rsvp/query.js +118 -0
  445. package/dist/rsvp/replay.js +233 -0
  446. package/dist/rsvp/snapshot.js +232 -0
  447. package/dist/rsvp/spend-ledger.js +175 -0
  448. package/dist/scripts/claude-code-hook.js +41 -0
  449. package/dist/scripts/claude-code-stop-hook.js +47 -0
  450. package/dist/senses/a2a-entry.js +115 -0
  451. package/dist/senses/attention-queue.js +186 -0
  452. package/dist/senses/await-turn-message.js +58 -0
  453. package/dist/senses/bluebubbles/active-turns.js +216 -0
  454. package/dist/senses/bluebubbles/attachment-cache.js +53 -0
  455. package/dist/senses/bluebubbles/attachment-download.js +137 -0
  456. package/dist/senses/{bluebubbles-client.js → bluebubbles/client.js} +355 -40
  457. package/dist/senses/bluebubbles/context-packet.js +207 -0
  458. package/dist/senses/bluebubbles/context-smoke.js +144 -0
  459. package/dist/senses/bluebubbles/entry.js +90 -0
  460. package/dist/senses/{bluebubbles-inbound-log.js → bluebubbles/inbound-log.js} +21 -3
  461. package/dist/senses/bluebubbles/index.js +3839 -0
  462. package/dist/senses/bluebubbles/latest-turn.js +311 -0
  463. package/dist/senses/{bluebubbles-media.js → bluebubbles/media.js} +121 -71
  464. package/dist/senses/{bluebubbles-model.js → bluebubbles/model.js} +159 -28
  465. package/dist/senses/{bluebubbles-mutation-log.js → bluebubbles/mutation-log.js} +24 -13
  466. package/dist/senses/bluebubbles/outbound-state.js +308 -0
  467. package/dist/senses/bluebubbles/processed-log.js +133 -0
  468. package/dist/senses/bluebubbles/reaction-policy.js +47 -0
  469. package/dist/senses/bluebubbles/replay.js +284 -0
  470. package/dist/senses/{bluebubbles-runtime-state.js → bluebubbles/runtime-state.js} +30 -2
  471. package/dist/senses/bluebubbles/semantic-receipts.js +1986 -0
  472. package/dist/senses/{bluebubbles-session-cleanup.js → bluebubbles/session-cleanup.js} +1 -1
  473. package/dist/senses/bluebubbles/webhook-registration.js +228 -0
  474. package/dist/senses/bluebubbles-meta-guard.js +39 -0
  475. package/dist/senses/cli/bracketed-paste.js +82 -0
  476. package/dist/senses/cli/image-paste.js +287 -0
  477. package/dist/senses/cli/image-ref-navigation.js +75 -0
  478. package/dist/senses/cli/ink-app.js +156 -0
  479. package/dist/senses/cli/inline-diff.js +64 -0
  480. package/dist/senses/cli/input-keys.js +174 -0
  481. package/dist/senses/cli/kill-ring.js +86 -0
  482. package/dist/senses/cli/message-list.js +51 -0
  483. package/dist/senses/cli/ouro-tui.js +607 -0
  484. package/dist/senses/cli/spinner-imperative.js +135 -0
  485. package/dist/senses/cli/spinner.js +101 -0
  486. package/dist/senses/cli/status-line.js +60 -0
  487. package/dist/senses/cli/streaming-markdown.js +526 -0
  488. package/dist/senses/cli/tool-display.js +85 -0
  489. package/dist/senses/cli/tool-render.js +85 -0
  490. package/dist/senses/cli/tui-store.js +241 -0
  491. package/dist/senses/cli/virtual-list.js +35 -0
  492. package/dist/senses/cli-entry.js +60 -8
  493. package/dist/senses/cli.js +600 -317
  494. package/dist/senses/commands.js +66 -3
  495. package/dist/senses/context-packet-ledger.js +371 -0
  496. package/dist/senses/context-packets.js +154 -0
  497. package/dist/senses/habit-lifecycle-guard.js +24 -0
  498. package/dist/senses/habit-turn-message.js +160 -0
  499. package/dist/senses/ingress-evidence.js +2 -0
  500. package/dist/senses/mail-entry.js +66 -0
  501. package/dist/senses/mail.js +524 -0
  502. package/dist/senses/pipeline.js +1110 -204
  503. package/dist/senses/private-runtime-worker.js +859 -0
  504. package/dist/senses/private-runtime.js +1496 -0
  505. package/dist/senses/proactive-content-guard.js +51 -0
  506. package/dist/senses/sanctuary-download-credit-presentation.js +31 -0
  507. package/dist/senses/sanctuary-full-visibility-contract.js +302 -0
  508. package/dist/senses/sanctuary-grounding.js +118 -0
  509. package/dist/senses/sanctuary-health-acceptance-probe-entry.js +12 -0
  510. package/dist/senses/sanctuary-health-acceptance-probe.js +774 -0
  511. package/dist/senses/sanctuary-health-runner.js +101 -0
  512. package/dist/senses/sanctuary-health.js +408 -0
  513. package/dist/senses/sanctuary-interactive-control.js +306 -0
  514. package/dist/senses/sanctuary-media-catalog-contract.js +39 -0
  515. package/dist/senses/sanctuary-media-optimization.js +501 -0
  516. package/dist/senses/sanctuary-runtime.js +331 -0
  517. package/dist/senses/sanctuary-sab.js +85 -0
  518. package/dist/senses/sanctuary-storage-optimization-contract.js +32 -0
  519. package/dist/senses/shared-turn.js +494 -0
  520. package/dist/senses/surface-tool.js +109 -0
  521. package/dist/senses/teams-entry.js +60 -8
  522. package/dist/senses/teams.js +515 -217
  523. package/dist/senses/telegram-admission.js +711 -0
  524. package/dist/senses/telegram-approval-runtime.js +566 -0
  525. package/dist/senses/telegram-attachments.js +182 -0
  526. package/dist/senses/telegram-audit-ledger.js +220 -0
  527. package/dist/senses/telegram-client.js +1676 -0
  528. package/dist/senses/telegram-effect-adapter.js +845 -0
  529. package/dist/senses/telegram-entry.js +83 -0
  530. package/dist/senses/telegram.js +2026 -0
  531. package/dist/senses/trust-gate.js +104 -9
  532. package/dist/senses/voice/audio-playback.js +237 -0
  533. package/dist/senses/voice/audio-routing.js +119 -0
  534. package/dist/senses/voice/elevenlabs.js +202 -0
  535. package/dist/senses/voice/floor-control.js +431 -0
  536. package/dist/senses/voice/floor-controller.js +115 -0
  537. package/dist/senses/voice/golden-path.js +116 -0
  538. package/dist/senses/voice/index.js +29 -0
  539. package/dist/senses/voice/meeting.js +113 -0
  540. package/dist/senses/voice/outbound.js +190 -0
  541. package/dist/senses/voice/phone.js +33 -0
  542. package/dist/senses/voice/playback.js +139 -0
  543. package/dist/senses/voice/realtime-eval.js +496 -0
  544. package/dist/senses/voice/realtime-trace.js +531 -0
  545. package/dist/senses/voice/transcript.js +70 -0
  546. package/dist/senses/voice/turn.js +191 -0
  547. package/dist/senses/voice/twilio-phone-runtime.js +807 -0
  548. package/dist/senses/voice/twilio-phone.js +5081 -0
  549. package/dist/senses/voice/types.js +2 -0
  550. package/dist/senses/voice/whisper.js +161 -0
  551. package/dist/senses/voice-entry.js +81 -0
  552. package/dist/senses/voice-realtime-eval-command.js +99 -0
  553. package/dist/senses/voice-realtime-eval-entry.js +21 -0
  554. package/dist/senses/voice-twilio-entry.js +87 -0
  555. package/dist/trips/core.js +138 -0
  556. package/dist/trips/store.js +265 -0
  557. package/dist/util/frontmatter.js +69 -0
  558. package/npm-shrinkwrap.json +8144 -0
  559. package/package.json +72 -13
  560. package/skills/agent-commerce.md +113 -0
  561. package/skills/browser-navigation.md +117 -0
  562. package/skills/commerce-setup-guide.md +116 -0
  563. package/skills/commerce-setup.md +84 -0
  564. package/skills/configure-dev-tools.md +99 -0
  565. package/skills/travel-planning.md +138 -0
  566. package/dist/heart/daemon/auth-flow.js +0 -430
  567. package/dist/heart/daemon/ouro-path-installer.js +0 -260
  568. package/dist/heart/daemon/ouro-version-manager.js +0 -164
  569. package/dist/heart/daemon/update-hooks.js +0 -138
  570. package/dist/heart/obligations.js +0 -141
  571. package/dist/heart/safe-workspace.js +0 -228
  572. package/dist/heart/session-recall.js +0 -116
  573. package/dist/mind/friends/channel.js +0 -84
  574. package/dist/mind/friends/group-context.js +0 -144
  575. package/dist/mind/friends/resolver.js +0 -84
  576. package/dist/mind/friends/store-file.js +0 -190
  577. package/dist/mind/friends/store.js +0 -4
  578. package/dist/mind/friends/tokens.js +0 -26
  579. package/dist/mind/friends/trust-explanation.js +0 -74
  580. package/dist/mind/friends/types.js +0 -29
  581. package/dist/repertoire/tasks/board.js +0 -134
  582. package/dist/repertoire/tasks/index.js +0 -224
  583. package/dist/repertoire/tasks/lifecycle.js +0 -80
  584. package/dist/repertoire/tasks/middleware.js +0 -65
  585. package/dist/repertoire/tasks/parser.js +0 -173
  586. package/dist/repertoire/tasks/scanner.js +0 -132
  587. package/dist/repertoire/tasks/transitions.js +0 -144
  588. package/dist/senses/bluebubbles-entry.js +0 -13
  589. package/dist/senses/bluebubbles.js +0 -1181
  590. package/dist/senses/debug-activity.js +0 -154
  591. package/dist/senses/inner-dialog-worker.js +0 -90
  592. package/dist/senses/inner-dialog.js +0 -525
  593. package/subagents/README.md +0 -7
  594. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
  595. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
  596. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
  597. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
  598. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
  599. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +0 -0
  600. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
  601. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
  602. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
  603. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
  604. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
  605. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
  606. /package/dist/{repertoire/tasks/types.js → heart/attachments/sources/adapter.js} +0 -0
  607. /package/dist/heart/{daemon → hatch}/hatch-animation.js +0 -0
  608. /package/dist/heart/{daemon → hatch}/specialist-orchestrator.js +0 -0
  609. /package/dist/heart/{daemon → versioning}/ouro-uti.js +0 -0
  610. /package/dist/heart/{daemon → versioning}/wrapper-publish-guard.js +0 -0
@@ -0,0 +1,2523 @@
1
+ Mendelow Cloud Butler operator runbook
2
+
3
+ The production container is ouro-butler. It runs as UID/GID 10001, publishes no
4
+ ports, uses host networking only so its loopback-only Unraid GraphQL client can
5
+ reach 127.0.0.1, mounts the runtime and sanctuary.ouro bundle read-write plus
6
+ the privileged event spool and SAB configuration read-only, and uses restart
7
+ policy unless-stopped.
8
+
9
+ Start/stop:
10
+ docker start ouro-butler
11
+ docker stop ouro-butler
12
+
13
+ Status and health:
14
+ docker inspect --format '{{.State.Status}} {{.State.Health.Status}}' ouro-butler
15
+ docker logs --tail 200 ouro-butler
16
+
17
+ Effective-spec audit helper:
18
+ Run Update, Backup, and Restore command sequences from a root shell with `set -eu`.
19
+ Before any sequence, define this helper. It captures one actual container
20
+ inspect and its reviewed image inspect without printing either, invokes the
21
+ packaged auditor from the explicit validated runner image argument, and
22
+ removes its mode-0600 inputs on success or shell exit:
23
+ audit_effective() {
24
+ (
25
+ AUDIT_CONTAINER=$1
26
+ AUDIT_EXPECTED_IMAGE=$2
27
+ AUDIT_RUNNER_IMAGE_ID=$3
28
+ AUDIT_MOUNT_CONTRACT=${4-canonical}
29
+ validate_exact_image_id "$AUDIT_EXPECTED_IMAGE" || return $?
30
+ validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID" || return $?
31
+ test "$AUDIT_RUNNER_IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d || return $?
32
+ case "$AUDIT_MOUNT_CONTRACT" in
33
+ canonical) set -- ;;
34
+ legacy-alpha742) set -- --mount-contract legacy-alpha742 ;;
35
+ *) return 1 ;;
36
+ esac
37
+ INSPECT_DIR=$(mktemp -d /mnt/user/appdata/ouro-butler/staging/inspect.XXXXXX) || return $?
38
+ trap 'rm -f -- "$INSPECT_DIR/container.json" "$INSPECT_DIR/image.json"; rmdir -- "$INSPECT_DIR"' EXIT || return $?
39
+ chmod 0700 "$INSPECT_DIR" || return $?
40
+ umask 077 || return $?
41
+ docker inspect "$AUDIT_CONTAINER" >"$INSPECT_DIR/container.json" || return $?
42
+ docker image inspect "$AUDIT_EXPECTED_IMAGE" >"$INSPECT_DIR/image.json" || return $?
43
+ chmod 0600 "$INSPECT_DIR/container.json" "$INSPECT_DIR/image.json" || return $?
44
+ docker run --rm --pull=never --network=none \
45
+ --user 0:0 --read-only --cap-drop=ALL --security-opt=no-new-privileges \
46
+ --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh \
47
+ --mount "type=bind,src=$INSPECT_DIR/container.json,dst=/audit/container.json,readonly" \
48
+ --mount "type=bind,src=$INSPECT_DIR/image.json,dst=/audit/image.json,readonly" \
49
+ "$AUDIT_RUNNER_IMAGE_ID" --inspect /audit/container.json --image-inspect /audit/image.json --expected-image "$AUDIT_EXPECTED_IMAGE" "$@" || return $?
50
+ rm -f -- "$INSPECT_DIR/container.json" "$INSPECT_DIR/image.json" || return $?
51
+ rmdir -- "$INSPECT_DIR" || return $?
52
+ trap - EXIT || return $?
53
+ )
54
+ }
55
+ Define these helpers in the same root shell. Each profile change calls Unraid's
56
+ own root-local Docker manager backend, after proving exact durable identities.
57
+ A missing Butler entry is a no-op when disabling; this avoids the backend's
58
+ false-to-index-zero behavior. The helper preserves every non-Butler row byte
59
+ for byte and restores the complete captured file atomically on any failure:
60
+ butler_autostart_counts() {
61
+ (
62
+ AUTOSTART_FILE=/var/lib/docker/unraid-autostart
63
+ test -f "$AUTOSTART_FILE" || return $?
64
+ awk '
65
+ $1 == "ouro-butler" { production++ }
66
+ $1 == "ouro-butler-staging" { staging++ }
67
+ $1 == "ouro-butler-rollback" { rollback++ }
68
+ $1 == "ouro-butler-legacy-evidence" { legacy++ }
69
+ END { printf "%d %d %d %d", production + 0, staging + 0, rollback + 0, legacy + 0 }
70
+ ' "$AUTOSTART_FILE"
71
+ )
72
+ }
73
+ snapshot_nonbutler_autostart() {
74
+ awk '
75
+ $1 != "ouro-butler" && $1 != "ouro-butler-staging" && $1 != "ouro-butler-rollback" && $1 != "ouro-butler-legacy-evidence" { print }
76
+ ' /var/lib/docker/unraid-autostart
77
+ }
78
+ run_unraid_autostart_backend() {
79
+ (
80
+ AUTOSTART_ACTION=$1
81
+ AUTOSTART_CONTAINER=$2
82
+ AUTOSTART_VALUE=$3
83
+ case "$AUTOSTART_ACTION:$AUTOSTART_VALUE" in wait:0|autostart:true|autostart:false) ;; *) return 1 ;; esac
84
+ case "$AUTOSTART_CONTAINER" in
85
+ ouro-butler|ouro-butler-staging|ouro-butler-rollback|ouro-butler-legacy-evidence) ;;
86
+ *) return 1 ;;
87
+ esac
88
+ timeout -s KILL 20 /usr/bin/php -r '
89
+ [$action, $container, $value] = array_slice($argv, 1);
90
+ $allowed = ["ouro-butler", "ouro-butler-staging", "ouro-butler-rollback", "ouro-butler-legacy-evidence"];
91
+ if (!in_array($container, $allowed, true)) exit(2);
92
+ if ($action === "wait" && $value === "0") {
93
+ $_POST = ["action" => "wait", "container" => $container, "wait" => "0"];
94
+ } elseif ($action === "autostart" && ($value === "true" || $value === "false")) {
95
+ $_POST = ["action" => "autostart", "container" => $container, "auto" => $value, "wait" => "0"];
96
+ } else exit(2);
97
+ $_SERVER["DOCUMENT_ROOT"] = "/usr/local/emhttp";
98
+ $_SERVER["REQUEST_URI"] = "docker";
99
+ require "/usr/local/emhttp/plugins/dynamix.docker.manager/include/UpdateConfig.php";
100
+ ' "$AUTOSTART_ACTION" "$AUTOSTART_CONTAINER" "$AUTOSTART_VALUE"
101
+ )
102
+ }
103
+ mutate_butler_autostart() {
104
+ (
105
+ AUTOSTART_CONTAINER=$1
106
+ AUTOSTART_ENABLED=$2
107
+ case "$AUTOSTART_CONTAINER" in
108
+ ouro-butler|ouro-butler-staging|ouro-butler-rollback|ouro-butler-legacy-evidence) ;;
109
+ *) return 1 ;;
110
+ esac
111
+ case "$AUTOSTART_ENABLED" in true|false) ;; *) return 1 ;; esac
112
+ AUTOSTART_COUNT=$(awk -v name="$AUTOSTART_CONTAINER" '$1 == name { count++ } END { print count + 0 }' /var/lib/docker/unraid-autostart) || return $?
113
+ case "$AUTOSTART_COUNT" in 0|1) ;; *) return 1 ;; esac
114
+ if test "$AUTOSTART_ENABLED" = false && test "$AUTOSTART_COUNT" -eq 0; then
115
+ return 0
116
+ fi
117
+ if test "$AUTOSTART_COUNT" -eq 1; then
118
+ run_unraid_autostart_backend wait "$AUTOSTART_CONTAINER" 0 || return $?
119
+ fi
120
+ if test "$AUTOSTART_ENABLED" = false; then
121
+ run_unraid_autostart_backend autostart "$AUTOSTART_CONTAINER" false || return $?
122
+ AUTOSTART_EXPECTED_COUNT=0
123
+ else
124
+ if test "$AUTOSTART_COUNT" -eq 0; then
125
+ run_unraid_autostart_backend autostart "$AUTOSTART_CONTAINER" true || return $?
126
+ fi
127
+ AUTOSTART_EXPECTED_COUNT=1
128
+ fi
129
+ AUTOSTART_COUNT=$(awk -v name="$AUTOSTART_CONTAINER" '$1 == name { count++ } END { print count + 0 }' /var/lib/docker/unraid-autostart) || return $?
130
+ test "$AUTOSTART_COUNT" -eq "$AUTOSTART_EXPECTED_COUNT" || return $?
131
+ )
132
+ }
133
+ set_butler_autostart() {
134
+ (
135
+ AUTOSTART_PROFILE=$1
136
+ case "$AUTOSTART_PROFILE" in disabled|production) ;; *) return 1 ;; esac
137
+ test "$(id -u)" -eq 0 || return $?
138
+ AUTOSTART_FILE=/var/lib/docker/unraid-autostart
139
+ AUTOSTART_INCLUDE=/usr/local/emhttp/plugins/dynamix.docker.manager/include/UpdateConfig.php
140
+ test -f "$AUTOSTART_FILE" && test ! -L "$AUTOSTART_FILE" || return 1
141
+ test "$(stat -c '%u:%g:%a' "$AUTOSTART_FILE")" = 0:0:644 || return $?
142
+ test -f "$AUTOSTART_INCLUDE" && test ! -L "$AUTOSTART_INCLUDE" || return 1
143
+ test "$(stat -c '%u:%g:%a' "$AUTOSTART_INCLUDE")" = 0:0:644 || return $?
144
+ AUTOSTART_COUNTS=$(butler_autostart_counts) || return $?
145
+ set -- $AUTOSTART_COUNTS
146
+ test "$#" -eq 4 || return $?
147
+ for AUTOSTART_COUNT in "$@"; do case "$AUTOSTART_COUNT" in 0|1) ;; *) return 1 ;; esac; done
148
+ if test "$AUTOSTART_PROFILE" = production; then
149
+ docker container inspect ouro-butler >/dev/null 2>&1 || return $?
150
+ fi
151
+ AUTOSTART_BACKUP=$(mktemp /var/lib/docker/.unraid-autostart.ouro.XXXXXX) || return $?
152
+ AUTOSTART_NONBUTLER_BEFORE=$(mktemp /run/unraid-autostart.nonbutler-before.XXXXXX) || { rm -f -- "$AUTOSTART_BACKUP"; return 1; }
153
+ AUTOSTART_NONBUTLER_AFTER=$(mktemp /run/unraid-autostart.nonbutler-after.XXXXXX) || { rm -f -- "$AUTOSTART_BACKUP" "$AUTOSTART_NONBUTLER_BEFORE"; return 1; }
154
+ AUTOSTART_COMMITTED=no
155
+ AUTOSTART_BACKUP_READY=no
156
+ AUTOSTART_RESTORE_TMP=
157
+ autostart_cleanup() {
158
+ AUTOSTART_STATUS=$?
159
+ trap - EXIT HUP INT TERM
160
+ if test "$AUTOSTART_COMMITTED" = yes; then
161
+ rm -f -- "$AUTOSTART_BACKUP" || AUTOSTART_STATUS=1
162
+ elif test "$AUTOSTART_BACKUP_READY" = yes; then
163
+ AUTOSTART_RESTORE_TMP=$(mktemp /var/lib/docker/.unraid-autostart.restore.XXXXXX) || AUTOSTART_RESTORE_TMP=
164
+ if test -n "$AUTOSTART_RESTORE_TMP" \
165
+ && cp -p -- "$AUTOSTART_BACKUP" "$AUTOSTART_RESTORE_TMP" \
166
+ && sync -f "$AUTOSTART_RESTORE_TMP" \
167
+ && mv -f -- "$AUTOSTART_RESTORE_TMP" "$AUTOSTART_FILE" \
168
+ && sync -f "$AUTOSTART_FILE" \
169
+ && sync -f /var/lib/docker; then
170
+ rm -f -- "$AUTOSTART_BACKUP" || AUTOSTART_STATUS=1
171
+ else
172
+ test -z "$AUTOSTART_RESTORE_TMP" || rm -f -- "$AUTOSTART_RESTORE_TMP"
173
+ printf 'CRITICAL: Butler autostart rollback failed; recovery copy preserved at %s\n' "$AUTOSTART_BACKUP" >&2
174
+ AUTOSTART_STATUS=1
175
+ fi
176
+ else
177
+ rm -f -- "$AUTOSTART_BACKUP" || AUTOSTART_STATUS=1
178
+ fi
179
+ rm -f -- "$AUTOSTART_NONBUTLER_BEFORE" "$AUTOSTART_NONBUTLER_AFTER" || AUTOSTART_STATUS=1
180
+ exit "$AUTOSTART_STATUS"
181
+ }
182
+ trap autostart_cleanup EXIT
183
+ trap 'exit 129' HUP
184
+ trap 'exit 130' INT
185
+ trap 'exit 143' TERM
186
+ cp -p -- "$AUTOSTART_FILE" "$AUTOSTART_BACKUP" || return $?
187
+ cmp -s -- "$AUTOSTART_FILE" "$AUTOSTART_BACKUP" || return 1
188
+ sync -f "$AUTOSTART_BACKUP" || return $?
189
+ sync -f /var/lib/docker || return $?
190
+ AUTOSTART_BACKUP_READY=yes
191
+ snapshot_nonbutler_autostart >"$AUTOSTART_NONBUTLER_BEFORE" || return $?
192
+ mutate_butler_autostart ouro-butler-staging false || return $?
193
+ mutate_butler_autostart ouro-butler-rollback false || return $?
194
+ mutate_butler_autostart ouro-butler-legacy-evidence false || return $?
195
+ if test "$AUTOSTART_PROFILE" = production; then
196
+ mutate_butler_autostart ouro-butler true || return $?
197
+ AUTOSTART_EXPECTED_COUNTS="1 0 0 0"
198
+ else
199
+ mutate_butler_autostart ouro-butler false || return $?
200
+ AUTOSTART_EXPECTED_COUNTS="0 0 0 0"
201
+ fi
202
+ verify_butler_autostart "$AUTOSTART_EXPECTED_COUNTS" || return $?
203
+ snapshot_nonbutler_autostart >"$AUTOSTART_NONBUTLER_AFTER" || return $?
204
+ cmp -s -- "$AUTOSTART_NONBUTLER_BEFORE" "$AUTOSTART_NONBUTLER_AFTER" || return 1
205
+ sync -f "$AUTOSTART_FILE" || return $?
206
+ sync -f /var/lib/docker || return $?
207
+ AUTOSTART_COMMITTED=yes
208
+ )
209
+ }
210
+ verify_butler_autostart() {
211
+ (
212
+ EXPECTED_AUTOSTART_COUNTS=$1
213
+ AUTOSTART_COUNTS=$(butler_autostart_counts) || return $?
214
+ test "$AUTOSTART_COUNTS" = "$EXPECTED_AUTOSTART_COUNTS" || return $?
215
+ )
216
+ }
217
+ disable_butler_autostart() {
218
+ set_butler_autostart disabled || return $?
219
+ verify_butler_autostart "0 0 0 0" || return $?
220
+ }
221
+ enable_butler_autostart() {
222
+ set_butler_autostart production || return $?
223
+ verify_butler_autostart "1 0 0 0" || return $?
224
+ }
225
+ This bounded wait accepts only Docker's healthy state. The image healthcheck
226
+ verifies fresh daemon state plus exactly one managed Telegram process and one
227
+ managed scheduler process. It fails immediately on an exited, dead, or
228
+ unhealthy container and times out after four minutes:
229
+ wait_butler_ready() {
230
+ WAIT_CONTAINER=$1
231
+ WAIT_NOW=$(date +%s) || return $?
232
+ WAIT_DEADLINE=$(( WAIT_NOW + 240 ))
233
+ while :; do
234
+ WAIT_NOW=$(date +%s) || return $?
235
+ test "$WAIT_NOW" -lt "$WAIT_DEADLINE" || break
236
+ WAIT_STATE=$(docker inspect --format '{{.State.Status}} {{.State.Health.Status}}' "$WAIT_CONTAINER") || return $?
237
+ if test "$WAIT_STATE" = "running healthy"; then
238
+ return 0
239
+ fi
240
+ case "$WAIT_STATE" in
241
+ exited\ *|dead\ *|*\ unhealthy) return 1 ;;
242
+ esac
243
+ sleep 5 || return $?
244
+ done
245
+ return 1
246
+ }
247
+ Define these fail-closed image and topology helpers in the same shell. They use
248
+ exact Docker image IDs and container identity, image provenance, and writable
249
+ root overlap; no mutation is performed:
250
+ validate_exact_image_id() {
251
+ VALIDATE_IMAGE_ID=$1
252
+ case "$VALIDATE_IMAGE_ID" in
253
+ sha256:*) VALIDATE_IMAGE_HEX=${VALIDATE_IMAGE_ID#sha256:} ;;
254
+ *) return 1 ;;
255
+ esac
256
+ test "${#VALIDATE_IMAGE_HEX}" -eq 64 || return $?
257
+ case "$VALIDATE_IMAGE_HEX" in
258
+ *[!0-9a-f]*) return 1 ;;
259
+ esac
260
+ docker image inspect "$VALIDATE_IMAGE_ID" >/dev/null || return $?
261
+ }
262
+ assert_only_running_butler() {
263
+ (
264
+ EXPECTED_RUNNING_BUTLER=$1
265
+ case "$EXPECTED_RUNNING_BUTLER" in
266
+ -) EXPECTED_RUNNING_BUTLER_COUNT=0 ;;
267
+ ouro-butler|ouro-butler-staging) EXPECTED_RUNNING_BUTLER_COUNT=1 ;;
268
+ *) return 1 ;;
269
+ esac
270
+ RUNNING_CONTAINER_IDS=$(docker container ls -q) || return $?
271
+ MATCHING_RUNNING_BUTLERS=0
272
+ for RUNNING_CONTAINER_ID in $RUNNING_CONTAINER_IDS; do
273
+ RUNNING_CONTAINER_NAME=$(docker container inspect --format '{{.Name}}' "$RUNNING_CONTAINER_ID") || return $?
274
+ RUNNING_CONTAINER_NAME=${RUNNING_CONTAINER_NAME#/}
275
+ if test "$RUNNING_CONTAINER_NAME" = "$EXPECTED_RUNNING_BUTLER"; then
276
+ MATCHING_RUNNING_BUTLERS=$(( MATCHING_RUNNING_BUTLERS + 1 ))
277
+ continue
278
+ fi
279
+ RUNNING_CONTAINER_IMAGE_ID=$(docker container inspect --format '{{.Image}}' "$RUNNING_CONTAINER_ID") || return $?
280
+ RUNNING_CONTAINER_IMAGE_REF=$(docker container inspect --format '{{.Config.Image}}' "$RUNNING_CONTAINER_ID") || return $?
281
+ RUNNING_CONTAINER_IMAGE_SOURCE=$(docker image inspect --format '{{with .Config.Labels}}{{index . "org.opencontainers.image.source"}}{{end}}' "$RUNNING_CONTAINER_IMAGE_ID") || return $?
282
+ test "$RUNNING_CONTAINER_IMAGE_SOURCE" != https://github.com/ourostack/ouroboros || return 1
283
+ test "$RUNNING_CONTAINER_IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d || return 1
284
+ case "$RUNNING_CONTAINER_IMAGE_REF" in
285
+ 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 ;;
286
+ esac
287
+ RUNNING_WRITABLE_MOUNT_SOURCES=$(docker container inspect --format '{{range .Mounts}}{{if .RW}}{{println .Source}}{{end}}{{end}}' "$RUNNING_CONTAINER_ID") || return $?
288
+ if printf '%s\n' "$RUNNING_WRITABLE_MOUNT_SOURCES" | awk '
289
+ function overlaps(path, root) {
290
+ return length(path) > 0 && (path == root || index(path, root "/") == 1 || index(root, path "/") == 1)
291
+ }
292
+ 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 }
293
+ END { exit found ? 0 : 1 }
294
+ '; then
295
+ return 1
296
+ fi
297
+ done
298
+ test "$MATCHING_RUNNING_BUTLERS" -eq "$EXPECTED_RUNNING_BUTLER_COUNT" || return $?
299
+ )
300
+ }
301
+ assert_update_topology() {
302
+ EXPECTED_ROLLBACK_IMAGE_ID=$1
303
+ UPDATE_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
304
+ UPDATE_NAME_COUNTS=$(printf '%s\n' "$UPDATE_CONTAINER_NAMES" | awk '
305
+ $0 == "ouro-butler" { production++ }
306
+ $0 == "ouro-butler-staging" { staging++ }
307
+ $0 == "ouro-butler-rollback" { rollback++ }
308
+ $0 == "ouro-butler-legacy-evidence" { legacy++ }
309
+ END {
310
+ printf "%d %d %d %d", production + 0, staging + 0, rollback + 0, legacy + 0
311
+ }
312
+ ') || return $?
313
+ case "$UPDATE_NAME_COUNTS" in
314
+ "1 0 0 0"|"1 0 1 0"|"1 0 0 1"|"1 0 1 1") ;;
315
+ *) return 1 ;;
316
+ esac
317
+ UPDATE_PRODUCTION_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler) || return $?
318
+ test "$UPDATE_PRODUCTION_RUNNING" = true || return $?
319
+ UPDATE_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
320
+ test "$UPDATE_PRODUCTION_IMAGE_ID" = "$EXPECTED_ROLLBACK_IMAGE_ID" || return $?
321
+ case "$UPDATE_NAME_COUNTS" in
322
+ "1 0 1 0"|"1 0 1 1")
323
+ UPDATE_ROLLBACK_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback) || return $?
324
+ test "$UPDATE_ROLLBACK_RUNNING" = false || return $?
325
+ UPDATE_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
326
+ test "$UPDATE_ROLLBACK_IMAGE_ID" = "$EXPECTED_ROLLBACK_IMAGE_ID" || return $?
327
+ ;;
328
+ esac
329
+ case "$UPDATE_NAME_COUNTS" in
330
+ "1 0 0 1"|"1 0 1 1")
331
+ UPDATE_LEGACY_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence) || return $?
332
+ test "$UPDATE_LEGACY_RUNNING" = false || return $?
333
+ UPDATE_LEGACY_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
334
+ validate_exact_image_id "$UPDATE_LEGACY_IMAGE_ID" || return $?
335
+ ;;
336
+ esac
337
+ assert_only_running_butler ouro-butler || return $?
338
+ }
339
+ assert_legacy_alpha742_source() {
340
+ EXPECTED_SOURCE_IMAGE_ID=$1
341
+ AUDIT_RUNNER_IMAGE_ID=$2
342
+ test "$EXPECTED_SOURCE_IMAGE_ID" = sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d || return $?
343
+ audit_effective ouro-butler "$EXPECTED_SOURCE_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" legacy-alpha742
344
+ }
345
+ assert_update_source() {
346
+ EXPECTED_SOURCE_IMAGE_ID=$1
347
+ AUDIT_RUNNER_IMAGE_ID=$2
348
+ if test "$EXPECTED_SOURCE_IMAGE_ID" = sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d; then
349
+ assert_legacy_alpha742_source "$EXPECTED_SOURCE_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
350
+ else
351
+ audit_effective ouro-butler "$EXPECTED_SOURCE_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
352
+ fi
353
+ }
354
+ validate_sanctuary_roots() {
355
+ (
356
+ VALIDATE_RUNTIME_ROOT=$1
357
+ VALIDATE_AGENT_ROOT=$2
358
+ VALIDATE_CONTEXT=${3:-strict}
359
+ case "$VALIDATE_CONTEXT" in strict|live-precutover) ;; *) return 1 ;; esac
360
+ test -d "$VALIDATE_RUNTIME_ROOT" || return $?
361
+ test -d "$VALIDATE_AGENT_ROOT" || return $?
362
+ VALIDATE_CONTROL_SOCKET=$VALIDATE_AGENT_ROOT/state/acceptance/telegram-control.sock
363
+ if test "$VALIDATE_CONTEXT" = live-precutover; then
364
+ test ! -L "$VALIDATE_CONTROL_SOCKET" || return 1
365
+ test -S "$VALIDATE_CONTROL_SOCKET" || return 1
366
+ test "$(stat -c '%u:%g:%a' "$VALIDATE_CONTROL_SOCKET")" = 10001:10001:600 || return $?
367
+ 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 $?
368
+ VALIDATE_UNEXPECTED_SOCKET=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev -type s ! -path "$VALIDATE_CONTROL_SOCKET" -print -quit) || return $?
369
+ else
370
+ test ! -S "$VALIDATE_AGENT_ROOT/state/acceptance/telegram-control.sock" || return $?
371
+ test ! -e "$VALIDATE_CONTROL_SOCKET" || return $?
372
+ VALIDATE_BAD_SHAPE=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev \( -type l -o \( ! -type d -a ! -type f \) \) -print -quit) || return $?
373
+ VALIDATE_UNEXPECTED_SOCKET=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev -type s -print -quit) || return $?
374
+ fi
375
+ test -z "$VALIDATE_BAD_SHAPE" || return $?
376
+ test -z "$VALIDATE_UNEXPECTED_SOCKET" || return $?
377
+ for VALIDATE_REQUIRED_FILE in \
378
+ agent.json bundle-meta.json provider-readiness.json tool-profiles.json \
379
+ psyche/SOUL.md habits/sanctuary-health.md; do
380
+ test -s "$VALIDATE_AGENT_ROOT/$VALIDATE_REQUIRED_FILE" || return $?
381
+ done
382
+ test -d "$VALIDATE_RUNTIME_ROOT/vault-unlock" || return $?
383
+ VALIDATE_UNLOCK_FILES=$(find "$VALIDATE_RUNTIME_ROOT/vault-unlock" -xdev -mindepth 1 -maxdepth 1 -type f -name '*.secret' -size +0c -print) || return $?
384
+ test "$(printf '%s\n' "$VALIDATE_UNLOCK_FILES" | awk 'NF { count++ } END { print count + 0 }')" = 1 || return $?
385
+ test ! -e "$VALIDATE_RUNTIME_ROOT/container-credentials.json" || return $?
386
+ test ! -e "$VALIDATE_RUNTIME_ROOT/container-credentials.json.consuming" || return $?
387
+ VALIDATE_WRONG_OWNER=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev \( ! -user 10001 -o ! -group 10001 \) -print -quit) || return $?
388
+ test -z "$VALIDATE_WRONG_OWNER" || return $?
389
+ VALIDATE_WRONG_RUNTIME_DIR_MODE=$(find "$VALIDATE_RUNTIME_ROOT" -xdev -type d ! -perm 0700 \
390
+ ! \( -perm 0755 \( \
391
+ -path "$VALIDATE_RUNTIME_ROOT/scheduler" -o -path "$VALIDATE_RUNTIME_ROOT/scheduler/*" -o \
392
+ -path "$VALIDATE_RUNTIME_ROOT/daemon/logs" -o -path "$VALIDATE_RUNTIME_ROOT/daemon/logs/*" -o \
393
+ -path "$VALIDATE_RUNTIME_ROOT/daemon/external-events" -o -path "$VALIDATE_RUNTIME_ROOT/daemon/external-events/*" \
394
+ \) \) -print -quit) || return $?
395
+ test -z "$VALIDATE_WRONG_RUNTIME_DIR_MODE" || return $?
396
+ VALIDATE_WRONG_AGENT_DIR_MODE=$(find "$VALIDATE_AGENT_ROOT" -xdev -type d ! -perm 0700 \
397
+ ! \( -perm 0755 \( \
398
+ -path "$VALIDATE_AGENT_ROOT/arc/flight-recorder" -o -path "$VALIDATE_AGENT_ROOT/arc/flight-recorder/*" -o \
399
+ -path "$VALIDATE_AGENT_ROOT/state/health" -o -path "$VALIDATE_AGENT_ROOT/state/health/*" -o \
400
+ -path "$VALIDATE_AGENT_ROOT/state/logs" -o -path "$VALIDATE_AGENT_ROOT/state/logs/*" -o \
401
+ -path "$VALIDATE_AGENT_ROOT/state/habits" -o -path "$VALIDATE_AGENT_ROOT/state/habits/*" \
402
+ \) \) -print -quit) || return $?
403
+ test -z "$VALIDATE_WRONG_AGENT_DIR_MODE" || return $?
404
+ VALIDATE_WRONG_RUNTIME_FILE_MODE=$(find "$VALIDATE_RUNTIME_ROOT" -xdev -type f ! -perm 0600 \
405
+ ! \( -perm 0644 \( \
406
+ -path "$VALIDATE_RUNTIME_ROOT/daemon/logs/*" -o \
407
+ -path "$VALIDATE_RUNTIME_ROOT/scheduler/*" -o \
408
+ -path "$VALIDATE_RUNTIME_ROOT/pulse.json" -o \
409
+ -path "$VALIDATE_RUNTIME_ROOT/pulse-delivered.json" -o \
410
+ -path "$VALIDATE_RUNTIME_ROOT/daemon.pids" -o \
411
+ -path "$VALIDATE_RUNTIME_ROOT/daemon-health.json" \
412
+ \) \) -print -quit) || return $?
413
+ test -z "$VALIDATE_WRONG_RUNTIME_FILE_MODE" || return $?
414
+ # Alpha.753 and earlier wrote content-free autonomy receipts as 0644 below a 0700 tree.
415
+ # Preserve those audit records; current writers create and normalize this exact format to 0600.
416
+ VALIDATE_WRONG_AGENT_FILE_MODE=$(find "$VALIDATE_AGENT_ROOT" -xdev -type f ! -perm 0600 \
417
+ ! \( -perm 0644 \( \
418
+ -path "$VALIDATE_AGENT_ROOT/arc/flight-recorder/*" -o \
419
+ -path "$VALIDATE_AGENT_ROOT/state/health/*" -o \
420
+ -path "$VALIDATE_AGENT_ROOT/state/logs/*" -o \
421
+ -path "$VALIDATE_AGENT_ROOT/state/habits/*" -o \
422
+ -path "$VALIDATE_AGENT_ROOT/state/arc/context-loss-sentinel-watermark.json" -o \
423
+ -exec /bin/sh -c '
424
+ VALIDATE_AUTONOMY_RECEIPT=$1
425
+ VALIDATE_AUTONOMY_RECEIPTS_ROOT=$2
426
+ test "${VALIDATE_AUTONOMY_RECEIPT%/*}" = "$VALIDATE_AUTONOMY_RECEIPTS_ROOT" || exit 1
427
+ VALIDATE_AUTONOMY_RECEIPT_NAME=${VALIDATE_AUTONOMY_RECEIPT##*/}
428
+ case "$VALIDATE_AUTONOMY_RECEIPT_NAME" in
429
+ autr_????????????????????????????????.json) ;;
430
+ *) exit 1 ;;
431
+ esac
432
+ VALIDATE_AUTONOMY_RECEIPT_ID=${VALIDATE_AUTONOMY_RECEIPT_NAME#autr_}
433
+ VALIDATE_AUTONOMY_RECEIPT_ID=${VALIDATE_AUTONOMY_RECEIPT_ID%.json}
434
+ case "$VALIDATE_AUTONOMY_RECEIPT_ID" in *[!0-9a-f]*) exit 1 ;; esac
435
+ ' sanctuary-autonomy-receipt-validator {} "$VALIDATE_AGENT_ROOT/state/autonomy/receipts" \; \
436
+ \) \) -print -quit) || return $?
437
+ test -z "$VALIDATE_WRONG_AGENT_FILE_MODE" || return $?
438
+ )
439
+ }
440
+ normalize_sanctuary_private_permissions() {
441
+ (
442
+ NORMALIZE_RUNTIME_ROOT=$1
443
+ NORMALIZE_AGENT_ROOT=$2
444
+ NORMALIZE_IMAGE_ID=$3
445
+ test -d "$NORMALIZE_RUNTIME_ROOT" || return $?
446
+ test -d "$NORMALIZE_AGENT_ROOT" || return $?
447
+ validate_exact_image_id "$NORMALIZE_IMAGE_ID" || return $?
448
+ docker run --rm --pull=never --network none --user 10001:10001 \
449
+ --read-only --cap-drop ALL --security-opt no-new-privileges \
450
+ --mount "type=bind,src=$NORMALIZE_RUNTIME_ROOT,dst=/normalize/runtime" \
451
+ --mount "type=bind,src=$NORMALIZE_AGENT_ROOT,dst=/normalize/agent" \
452
+ --entrypoint /bin/sh "$NORMALIZE_IMAGE_ID" -eu -c '
453
+ find /normalize/runtime /normalize/agent -xdev -type d -user 10001 -group 10001 -perm 0755 \
454
+ ! -path "/normalize/runtime/scheduler" ! -path "/normalize/runtime/scheduler/*" \
455
+ ! -path "/normalize/runtime/daemon/logs" ! -path "/normalize/runtime/daemon/logs/*" \
456
+ ! -path "/normalize/runtime/daemon/external-events" ! -path "/normalize/runtime/daemon/external-events/*" \
457
+ ! -path "/normalize/agent/arc/flight-recorder" ! -path "/normalize/agent/arc/flight-recorder/*" \
458
+ ! -path "/normalize/agent/state/health" ! -path "/normalize/agent/state/health/*" \
459
+ ! -path "/normalize/agent/state/logs" ! -path "/normalize/agent/state/logs/*" \
460
+ ! -path "/normalize/agent/state/habits" ! -path "/normalize/agent/state/habits/*" \
461
+ -exec chmod 0700 {} +
462
+ find /normalize/runtime /normalize/agent -xdev -type f -links 1 -user 10001 -group 10001 -perm 0644 \
463
+ ! -path "/normalize/runtime/daemon/logs/*" \
464
+ ! -path "/normalize/runtime/scheduler/*" \
465
+ ! -path "/normalize/runtime/pulse.json" \
466
+ ! -path "/normalize/runtime/pulse-delivered.json" \
467
+ ! -path "/normalize/runtime/daemon.pids" \
468
+ ! -path "/normalize/runtime/daemon-health.json" \
469
+ ! -path "/normalize/agent/arc/flight-recorder/*" \
470
+ ! -path "/normalize/agent/state/health/*" \
471
+ ! -path "/normalize/agent/state/logs/*" \
472
+ ! -path "/normalize/agent/state/habits/*" \
473
+ ! -path "/normalize/agent/state/arc/context-loss-sentinel-watermark.json" \
474
+ -exec chmod 0600 {} +
475
+ ' || return $?
476
+ )
477
+ }
478
+ verify_sanctuary_snapshot_provenance() {
479
+ (
480
+ SNAPSHOT_ROOT=$1
481
+ EXPECTED_SNAPSHOT_IMAGE_ID=$2
482
+ SNAPSHOT_PROVENANCE_ROOT=$SNAPSHOT_ROOT/provenance
483
+ for SNAPSHOT_TOP_LEVEL_DIRECTORY in runtime agent host provenance; do
484
+ test -d "$SNAPSHOT_ROOT/$SNAPSHOT_TOP_LEVEL_DIRECTORY" || return $?
485
+ test ! -L "$SNAPSHOT_ROOT/$SNAPSHOT_TOP_LEVEL_DIRECTORY" || return 1
486
+ done
487
+ test "$(find "$SNAPSHOT_ROOT" -xdev -mindepth 1 -maxdepth 1 -print | wc -l)" -eq 4 || return $?
488
+ test -d "$SNAPSHOT_PROVENANCE_ROOT" || return $?
489
+ test ! -L "$SNAPSHOT_PROVENANCE_ROOT" || return 1
490
+ test "$(stat -c '%u:%g:%a' "$SNAPSHOT_PROVENANCE_ROOT")" = 0:0:700 || return $?
491
+ SNAPSHOT_BAD_PROVENANCE_ENTRY=$(find "$SNAPSHOT_PROVENANCE_ROOT" -xdev -mindepth 1 ! -type f -print -quit) || return $?
492
+ test -z "$SNAPSHOT_BAD_PROVENANCE_ENTRY" || return $?
493
+ for SNAPSHOT_PROVENANCE_FILE in image-id container-inspect.json image-inspect.json package-version manifest.sha256; do
494
+ test -f "$SNAPSHOT_PROVENANCE_ROOT/$SNAPSHOT_PROVENANCE_FILE" || return $?
495
+ test ! -L "$SNAPSHOT_PROVENANCE_ROOT/$SNAPSHOT_PROVENANCE_FILE" || return 1
496
+ test "$(stat -c '%u:%g:%a' "$SNAPSHOT_PROVENANCE_ROOT/$SNAPSHOT_PROVENANCE_FILE")" = 0:0:600 || return $?
497
+ done
498
+ test "$(find "$SNAPSHOT_PROVENANCE_ROOT" -xdev -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 5 || return $?
499
+ test "$(wc -l <"$SNAPSHOT_PROVENANCE_ROOT/image-id")" -eq 1 || return $?
500
+ IFS= read -r SNAPSHOT_IMAGE_ID <"$SNAPSHOT_PROVENANCE_ROOT/image-id" || return $?
501
+ test "$SNAPSHOT_IMAGE_ID" = "$EXPECTED_SNAPSHOT_IMAGE_ID" || return $?
502
+ case "$SNAPSHOT_IMAGE_ID" in sha256:????????????????????????????????????????????????????????????????) ;; *) return 1 ;; esac
503
+ case "${SNAPSHOT_IMAGE_ID#sha256:}" in *[!0-9a-f]*) return 1 ;; esac
504
+ /usr/local/bin/node -e '
505
+ const fs = require("node:fs");
506
+ const [containerPath, imagePath, imageId, hostPath] = process.argv.slice(1);
507
+ const container = JSON.parse(fs.readFileSync(containerPath, "utf8"));
508
+ const image = JSON.parse(fs.readFileSync(imagePath, "utf8"));
509
+ if (!Array.isArray(container) || container.length !== 1 || !container[0] || container[0].Image !== imageId) process.exit(1);
510
+ if (!Array.isArray(image) || image.length !== 1 || !image[0] || image[0].Id !== imageId) process.exit(1);
511
+ const source = image[0].Config?.Labels?.["org.opencontainers.image.source"];
512
+ const legacyAlpha742 = "sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d";
513
+ if (source !== "https://github.com/ourostack/ouroboros" && !(source === undefined && imageId === legacyAlpha742)) process.exit(1);
514
+ 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"];
515
+ const expectedUid = 0;
516
+ const expectedGid = 0;
517
+ const hostRoot = fs.lstatSync(hostPath);
518
+ if (!hostRoot.isDirectory() || hostRoot.isSymbolicLink() || hostRoot.uid !== expectedUid || hostRoot.gid !== expectedGid || (hostRoot.mode & 0o777) !== 0o700) process.exit(1);
519
+ const rows = fs.readFileSync(`${hostPath}/inventory`, "utf8").trim().split("\n").map(line => line.split("\t"));
520
+ if (rows.length !== expected.length || rows.some((row, index) => row.length !== 2 || row[1] !== expected[index] || !["present", "absent"].includes(row[0]))) process.exit(1);
521
+ const present = rows.filter(row => row[0] === "present").map(row => row[1]);
522
+ if (!["go.butler-lines", "crontab.butler-lines", "global-state"].every(relative => present.includes(relative))) process.exit(1);
523
+ const globalRows = fs.readFileSync(`${hostPath}/global-state`, "utf8").trim().split("\n").map(line => line.split("\t"));
524
+ if (globalRows.length !== 2 || globalRows[0][0] !== "go" || globalRows[1][0] !== "crontab") process.exit(1);
525
+ for (const [name, state, digest, count] of globalRows) {
526
+ 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);
527
+ if ((state === "absent") !== (digest === "-")) process.exit(1);
528
+ const fragmentLines = fs.readFileSync(`${hostPath}/${name}.butler-lines`, "utf8").split("\n").filter(Boolean);
529
+ if (fragmentLines.length !== Number(count)) process.exit(1);
530
+ }
531
+ for (const [state, relative] of rows) {
532
+ if (fs.existsSync(`${hostPath}/${relative}`) !== (state === "present")) process.exit(1);
533
+ }
534
+ const files = [];
535
+ const directories = [];
536
+ const walk = (directory, prefix = "") => {
537
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
538
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
539
+ const metadata = fs.lstatSync(`${directory}/${entry.name}`);
540
+ if (metadata.uid !== expectedUid || metadata.gid !== expectedGid) process.exit(1);
541
+ if (entry.isDirectory() && (metadata.mode & 0o777) === 0o700) { directories.push(relative); walk(`${directory}/${entry.name}`, relative); }
542
+ else if (entry.isFile() && (metadata.mode & 0o777) === 0o600) files.push(relative);
543
+ else process.exit(1);
544
+ }
545
+ };
546
+ walk(hostPath);
547
+ if (JSON.stringify(directories.sort()) !== JSON.stringify(["custom", "custom/ouro-events"])) process.exit(1);
548
+ if (JSON.stringify(files.sort()) !== JSON.stringify(["inventory", ...present].sort())) process.exit(1);
549
+ ' "$SNAPSHOT_PROVENANCE_ROOT/container-inspect.json" "$SNAPSHOT_PROVENANCE_ROOT/image-inspect.json" "$SNAPSHOT_IMAGE_ID" "$SNAPSHOT_ROOT/host" || return $?
550
+ test "$(wc -l <"$SNAPSHOT_PROVENANCE_ROOT/package-version")" -eq 1 || return $?
551
+ grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' "$SNAPSHOT_PROVENANCE_ROOT/package-version" || return $?
552
+ test "$(stat -c '%u:%g:%a' "$SNAPSHOT_ROOT/host")" = 0:0:700 || return $?
553
+ 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 $?
554
+ test -z "$SNAPSHOT_BAD_HOST_ENTRY" || return $?
555
+ test -f "$SNAPSHOT_ROOT/host/inventory" || return $?
556
+ test ! -e "$SNAPSHOT_ROOT/host/notify.conf" || return 1
557
+ test ! -e "$SNAPSHOT_ROOT/host/sabnzbd.ini" || return 1
558
+ SNAPSHOT_VERIFY_ROOT=$(mktemp -d /tmp/ouro-snapshot-verify.XXXXXX) || return $?
559
+ trap 'rm -f -- "$SNAPSHOT_VERIFY_ROOT/current-files" "$SNAPSHOT_VERIFY_ROOT/manifest-files"; rmdir -- "$SNAPSHOT_VERIFY_ROOT"' EXIT || return $?
560
+ (
561
+ cd "$SNAPSHOT_ROOT" || return $?
562
+ find runtime agent host provenance -xdev -type f ! -path provenance/manifest.sha256 -print | LC_ALL=C sort >"$SNAPSHOT_VERIFY_ROOT/current-files" || return $?
563
+ grep -E '^[0-9a-f]{64} [^/].+$' provenance/manifest.sha256 >/dev/null || return $?
564
+ ! grep -Ev '^[0-9a-f]{64} [^/].+$' provenance/manifest.sha256 >/dev/null || return 1
565
+ sed 's/^[0-9a-f]\{64\} //' provenance/manifest.sha256 | LC_ALL=C sort >"$SNAPSHOT_VERIFY_ROOT/manifest-files" || return $?
566
+ cmp -s "$SNAPSHOT_VERIFY_ROOT/current-files" "$SNAPSHOT_VERIFY_ROOT/manifest-files" || return $?
567
+ sha256sum -c -- provenance/manifest.sha256 >/dev/null || return $?
568
+ ) || return $?
569
+ rm -f -- "$SNAPSHOT_VERIFY_ROOT/current-files" "$SNAPSHOT_VERIFY_ROOT/manifest-files" || return $?
570
+ rmdir -- "$SNAPSHOT_VERIFY_ROOT" || return $?
571
+ trap - EXIT || return $?
572
+ )
573
+ }
574
+ assert_restore_preflight() {
575
+ (
576
+ test -n "${BACKUP_ROOT-}" || return 1
577
+ test -n "${IMAGE_ID-}" || return 1
578
+ test -n "${AUDIT_RUNNER_IMAGE_ID-}" || return 1
579
+ case "$BACKUP_ROOT" in
580
+ /*) ;;
581
+ *) return 1 ;;
582
+ esac
583
+ test "$BACKUP_ROOT" != / || return $?
584
+ RESTORE_BACKUP_ROOT=$(cd -- "$BACKUP_ROOT" && pwd -P) || return $?
585
+ test "$RESTORE_BACKUP_ROOT" = "$BACKUP_ROOT" || return $?
586
+ test -d "$BACKUP_ROOT/runtime/.ouro-cli" || return $?
587
+ test -d "$BACKUP_ROOT/agent/sanctuary.ouro" || return $?
588
+ verify_sanctuary_snapshot_provenance "$BACKUP_ROOT" "$IMAGE_ID" || return $?
589
+ validate_sanctuary_roots "$BACKUP_ROOT/runtime/.ouro-cli" "$BACKUP_ROOT/agent/sanctuary.ouro" || return $?
590
+ validate_exact_image_id "$IMAGE_ID" || return $?
591
+ validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID" || return $?
592
+ test "$AUDIT_RUNNER_IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d || return $?
593
+ RESTORE_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
594
+ RESTORE_NAME_COUNTS=$(printf '%s\n' "$RESTORE_CONTAINER_NAMES" | awk '
595
+ $0 == "ouro-butler" { production++ }
596
+ $0 == "ouro-butler-staging" { staging++ }
597
+ $0 == "ouro-butler-rollback" { rollback++ }
598
+ $0 == "ouro-butler-legacy-evidence" { legacy++ }
599
+ END {
600
+ printf "%d %d %d %d", production + 0, staging + 0, rollback + 0, legacy + 0
601
+ }
602
+ ') || return $?
603
+ case "$RESTORE_NAME_COUNTS" in
604
+ "1 0 0 0"|"1 0 0 1") ;;
605
+ *) return 1 ;;
606
+ esac
607
+ RESTORE_PRODUCTION_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler) || return $?
608
+ test "$RESTORE_PRODUCTION_RUNNING" = true || return $?
609
+ RESTORE_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
610
+ validate_exact_image_id "$RESTORE_PRODUCTION_IMAGE_ID" || return $?
611
+ if test "$RESTORE_NAME_COUNTS" = "1 0 0 1"; then
612
+ RESTORE_LEGACY_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence) || return $?
613
+ test "$RESTORE_LEGACY_RUNNING" = false || return $?
614
+ RESTORE_LEGACY_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
615
+ validate_exact_image_id "$RESTORE_LEGACY_IMAGE_ID" || return $?
616
+ fi
617
+ assert_only_running_butler ouro-butler || return $?
618
+ assert_update_source "$RESTORE_PRODUCTION_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" || return $?
619
+ )
620
+ }
621
+ ensure_sanctuary_machine_identity() {
622
+ MACHINE_PATH=$1
623
+ test ! -L "$MACHINE_PATH" || return 1
624
+ /usr/local/bin/node -e '
625
+ const fs = require("node:fs");
626
+ const path = process.argv[1];
627
+ const valid = value => value && value.schemaVersion === 1 && value.machineId === "sanctuary"
628
+ && typeof value.createdAt === "string" && typeof value.updatedAt === "string"
629
+ && Array.isArray(value.hostnameAliases);
630
+ if (fs.existsSync(path)) {
631
+ if (!valid(JSON.parse(fs.readFileSync(path, "utf8")))) process.exit(1);
632
+ process.exit(0);
633
+ }
634
+ const now = new Date().toISOString();
635
+ const value = { schemaVersion: 1, machineId: "sanctuary", createdAt: now, updatedAt: now, hostnameAliases: [] };
636
+ const temporary = `${path}.tmp.${process.pid}`;
637
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" });
638
+ fs.renameSync(temporary, path);
639
+ ' "$MACHINE_PATH" || return $?
640
+ chown 10001:10001 "$MACHINE_PATH" || return $?
641
+ chmod 0600 "$MACHINE_PATH" || return $?
642
+ sync -f "$MACHINE_PATH" || return $?
643
+ }
644
+ migrate_sanctuary_package_managed_bundle() {
645
+ MIGRATE_IMAGE_ID=$1
646
+ MIGRATE_OPERATION=$2
647
+ MIGRATE_ROLLBACK_IMAGE_ID=${3-}
648
+ validate_exact_image_id "$MIGRATE_IMAGE_ID" || return $?
649
+ case "$MIGRATE_OPERATION" in migrate|rollback|commit|status) ;; *) return 1 ;; esac
650
+ if test "$MIGRATE_OPERATION" = migrate; then
651
+ validate_exact_image_id "$MIGRATE_ROLLBACK_IMAGE_ID" || return $?
652
+ test "$MIGRATE_IMAGE_ID" != "$MIGRATE_ROLLBACK_IMAGE_ID" || return 1
653
+ set -- --rollback-image-id "$MIGRATE_ROLLBACK_IMAGE_ID" --target-image-id "$MIGRATE_IMAGE_ID"
654
+ else
655
+ set --
656
+ fi
657
+ docker run --rm --pull=never --network=none --read-only --user 10001:10001 \
658
+ --entrypoint /usr/local/bin/node \
659
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
660
+ "$MIGRATE_IMAGE_ID" /opt/ouro/deploy/unraid/migrate-sanctuary-bundle.mjs \
661
+ --package-root /opt/ouro/deploy/unraid/sanctuary.ouro \
662
+ --agent-root /home/ouro/AgentBundles/sanctuary.ouro \
663
+ --operation "$MIGRATE_OPERATION" "$@" || return $?
664
+ }
665
+ recover_pending_sanctuary_bundle_migration() {
666
+ RECOVERY_IMAGE_ID=$1
667
+ RECOVERY_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
668
+ RECOVERY_RECORD=$RECOVERY_AGENT_ROOT/.sanctuary-package-managed-rollback.json
669
+ RECOVERY_COMMITTING_RECORD=$RECOVERY_RECORD.committing
670
+ RECOVERY_RECORD_COUNT=0
671
+ for RECOVERY_CANDIDATE in "$RECOVERY_RECORD" "$RECOVERY_COMMITTING_RECORD"; do
672
+ test ! -L "$RECOVERY_CANDIDATE" || return 1
673
+ if test -e "$RECOVERY_CANDIDATE"; then
674
+ test -f "$RECOVERY_CANDIDATE" || return 1
675
+ RECOVERY_RECORD_COUNT=$((RECOVERY_RECORD_COUNT + 1))
676
+ fi
677
+ done
678
+ test "$RECOVERY_RECORD_COUNT" -le 1 || return 1
679
+ test "$RECOVERY_RECORD_COUNT" -eq 1 || return 0
680
+ RECOVERY_STATUS=$(migrate_sanctuary_package_managed_bundle "$RECOVERY_IMAGE_ID" status) || return $?
681
+ RECOVERY_IDENTITIES=$(printf '%s' "$RECOVERY_STATUS" | /usr/local/bin/node -e '
682
+ let input = "";
683
+ process.stdin.setEncoding("utf8");
684
+ process.stdin.on("data", chunk => { input += chunk; });
685
+ process.stdin.on("end", () => {
686
+ const value = JSON.parse(input);
687
+ if (!value || !["rollback", "committing"].includes(value.state)
688
+ || typeof value.rollbackImageId !== "string" || typeof value.targetImageId !== "string") process.exit(1);
689
+ process.stdout.write(`${value.state} ${value.rollbackImageId} ${value.targetImageId}`);
690
+ });
691
+ ') || return $?
692
+ set -- $RECOVERY_IDENTITIES
693
+ test "$#" -eq 3 || return 1
694
+ RECOVERY_STATE=$1
695
+ RECOVERY_ROLLBACK_IMAGE_ID=$2
696
+ RECOVERY_TARGET_IMAGE_ID=$3
697
+ validate_exact_image_id "$RECOVERY_ROLLBACK_IMAGE_ID" || return $?
698
+ validate_exact_image_id "$RECOVERY_TARGET_IMAGE_ID" || return $?
699
+ test "$RECOVERY_TARGET_IMAGE_ID" != "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
700
+ disable_butler_autostart || return $?
701
+ if test "$RECOVERY_STATE" = committing; then
702
+ ! docker container inspect ouro-butler-staging >/dev/null 2>&1 || return 1
703
+ RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
704
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler)" = true || return 1
705
+ if test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_TARGET_IMAGE_ID"; then
706
+ RECOVERY_CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
707
+ test "$RECOVERY_CURRENT_ROLLBACK_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
708
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false || return 1
709
+ elif test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID"; then
710
+ ! docker container inspect ouro-butler-rollback >/dev/null 2>&1 || return 1
711
+ else
712
+ return 1
713
+ fi
714
+ assert_only_running_butler ouro-butler || return $?
715
+ assert_update_source "$RECOVERY_PRODUCTION_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" || return $?
716
+ wait_butler_ready ouro-butler || return $?
717
+ enable_butler_autostart || return $?
718
+ migrate_sanctuary_package_managed_bundle "$RECOVERY_IMAGE_ID" commit || return $?
719
+ return 0
720
+ fi
721
+ if docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
722
+ RECOVERY_CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
723
+ test "$RECOVERY_CURRENT_ROLLBACK_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
724
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false || return 1
725
+ if docker container inspect ouro-butler-staging >/dev/null 2>&1; then
726
+ RECOVERY_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-staging) || return $?
727
+ test "$RECOVERY_STAGING_IMAGE_ID" = "$RECOVERY_TARGET_IMAGE_ID" || return 1
728
+ docker stop ouro-butler-staging >/dev/null 2>&1 || true
729
+ docker rm --force ouro-butler-staging || return $?
730
+ fi
731
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
732
+ RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
733
+ test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_TARGET_IMAGE_ID" || return 1
734
+ docker stop ouro-butler >/dev/null 2>&1 || true
735
+ docker rm --force ouro-butler || return $?
736
+ fi
737
+ migrate_sanctuary_package_managed_bundle "$RECOVERY_IMAGE_ID" rollback || return $?
738
+ docker rename ouro-butler-rollback ouro-butler || return $?
739
+ elif docker container inspect ouro-butler >/dev/null 2>&1; then
740
+ ! docker container inspect ouro-butler-staging >/dev/null 2>&1 || return 1
741
+ RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
742
+ test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
743
+ ! docker container inspect ouro-butler-rollback >/dev/null 2>&1 || return 1
744
+ else
745
+ return 1
746
+ fi
747
+ RECOVERY_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
748
+ test "$RECOVERY_PRODUCTION_IMAGE_ID" = "$RECOVERY_ROLLBACK_IMAGE_ID" || return 1
749
+ assert_update_source "$RECOVERY_ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" || return $?
750
+ docker start ouro-butler || return $?
751
+ assert_only_running_butler ouro-butler || return $?
752
+ wait_butler_ready ouro-butler || return $?
753
+ enable_butler_autostart || return $?
754
+ migrate_sanctuary_package_managed_bundle "$RECOVERY_IMAGE_ID" commit || return $?
755
+ }
756
+ rollback_sanctuary_bundle_if_pending() {
757
+ OPTIONAL_ROLLBACK_IMAGE_ID=$1
758
+ OPTIONAL_ROLLBACK_RECORD=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/.sanctuary-package-managed-rollback.json
759
+ OPTIONAL_COMMITTING_RECORD=$OPTIONAL_ROLLBACK_RECORD.committing
760
+ test ! -L "$OPTIONAL_ROLLBACK_RECORD" || return 1
761
+ test ! -L "$OPTIONAL_COMMITTING_RECORD" || return 1
762
+ test ! -e "$OPTIONAL_COMMITTING_RECORD" || return 1
763
+ if test -e "$OPTIONAL_ROLLBACK_RECORD"; then
764
+ test -f "$OPTIONAL_ROLLBACK_RECORD" || return 1
765
+ migrate_sanctuary_package_managed_bundle "$OPTIONAL_ROLLBACK_IMAGE_ID" rollback || return $?
766
+ fi
767
+ }
768
+ # Package installation and migration never grant restart authority. The
769
+ # migration is the pre-activation assertion: it fails before live mutation
770
+ # if packaged steward.json carries any routine grant. It preserves an
771
+ # existing live steward policy and its audit receipts byte for byte. On a
772
+ # new installation, start the healthy private runtime, then use
773
+ # an authenticated owner Telegram turn to grant each exact restart target
774
+ # through steward_policy_manage. That existing tool binds the grant to the
775
+ # verified owner relationship and durable session event. Do not seed grants
776
+ # in the packaged steward.json, copy an old session-event ID, or edit the
777
+ # live policy/audit files directly. A missing grant remains fail-closed until
778
+ # that explicit owner ceremony succeeds.
779
+ prepare_canonical_sanctuary_roots() {
780
+ PREPARE_IMAGE_ID=$1
781
+ validate_exact_image_id "$PREPARE_IMAGE_ID" || return $?
782
+ PREPARE_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
783
+ PREPARE_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
784
+ install -d -m 0700 -o 10001 -g 10001 "$PREPARE_RUNTIME_ROOT" || return $?
785
+ install -d -m 0700 -o 10001 -g 10001 "$PREPARE_AGENT_ROOT" || return $?
786
+ ensure_sanctuary_machine_identity "$PREPARE_RUNTIME_ROOT/machine.json" || return $?
787
+ if test ! -f "$PREPARE_AGENT_ROOT/agent.json"; then
788
+ PREPARE_EXISTING_ENTRY=$(find "$PREPARE_AGENT_ROOT" -mindepth 1 -print -quit) || return $?
789
+ test -z "$PREPARE_EXISTING_ENTRY" || return $?
790
+ ! docker container inspect ouro-butler-bundle-bootstrap >/dev/null 2>&1 || return 1
791
+ docker run --rm --pull=never --network=none --name ouro-butler-bundle-bootstrap --user 10001:10001 \
792
+ --mount "type=bind,src=$PREPARE_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
793
+ --mount "type=bind,src=$PREPARE_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
794
+ --entrypoint /bin/sh "$PREPARE_IMAGE_ID" -ceu '
795
+ cp -R /opt/ouro/deploy/unraid/sanctuary.ouro/. /home/ouro/AgentBundles/sanctuary.ouro/
796
+ find /home/ouro/AgentBundles/sanctuary.ouro -type d -exec chmod 0700 {} +
797
+ find /home/ouro/AgentBundles/sanctuary.ouro -type f -exec chmod 0600 {} +
798
+ ' || return $?
799
+ ! docker container inspect ouro-butler-bundle-bootstrap >/dev/null 2>&1 || return 1
800
+ fi
801
+ for PREPARE_REQUIRED_FILE in \
802
+ agent.json bundle-meta.json provider-readiness.json tool-profiles.json \
803
+ psyche/SOUL.md habits/sanctuary-health.md; do
804
+ test -f "$PREPARE_AGENT_ROOT/$PREPARE_REQUIRED_FILE" || return $?
805
+ done
806
+ PREPARE_WRONG_OWNER=$(find "$PREPARE_RUNTIME_ROOT" "$PREPARE_AGENT_ROOT" \( ! -user 10001 -o ! -group 10001 \) -print -quit) || return $?
807
+ test -z "$PREPARE_WRONG_OWNER" || return $?
808
+ PREPARE_WRONG_DIR_MODE=$(find "$PREPARE_RUNTIME_ROOT" "$PREPARE_AGENT_ROOT" -type d ! -perm 0700 -print -quit) || return $?
809
+ test -z "$PREPARE_WRONG_DIR_MODE" || return $?
810
+ PREPARE_WRONG_FILE_MODE=$(find "$PREPARE_RUNTIME_ROOT" "$PREPARE_AGENT_ROOT" -type f ! -perm 0600 -print -quit) || return $?
811
+ test -z "$PREPARE_WRONG_FILE_MODE" || return $?
812
+ }
813
+ validate_sanctuary_legacy_staging() {
814
+ EXPECTED_LEGACY_CONTAINER_ID=${1-}
815
+ EXPECTED_LEGACY_IMAGE_ID=${2-}
816
+ LEGACY_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
817
+ LEGACY_NAME_COUNTS=$(printf '%s\n' "$LEGACY_CONTAINER_NAMES" | awk '
818
+ /butler/ { butlers++ }
819
+ $0 == "ouro-butler-staging" { staging++ }
820
+ END { printf "%d %d", butlers + 0, staging + 0 }
821
+ ') || return $?
822
+ test "$LEGACY_NAME_COUNTS" = "1 1" || return $?
823
+ assert_only_running_butler ouro-butler-staging || return $?
824
+ LEGACY_STAGING_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-staging) || return $?
825
+ test "$LEGACY_STAGING_RUNNING" = true || return $?
826
+ LEGACY_STAGING_CONTAINER_ID=$(docker inspect --format '{{.Id}}' ouro-butler-staging) || return $?
827
+ case "$LEGACY_STAGING_CONTAINER_ID" in *[!0-9a-f]*|'') return 1 ;; esac
828
+ test "${#LEGACY_STAGING_CONTAINER_ID}" -eq 64 || return 1
829
+ LEGACY_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-staging) || return $?
830
+ validate_exact_image_id "$LEGACY_STAGING_IMAGE_ID" || return $?
831
+ if test -n "$EXPECTED_LEGACY_CONTAINER_ID"; then
832
+ test "$LEGACY_STAGING_CONTAINER_ID" = "$EXPECTED_LEGACY_CONTAINER_ID" || return 1
833
+ test "$LEGACY_STAGING_IMAGE_ID" = "$EXPECTED_LEGACY_IMAGE_ID" || return 1
834
+ fi
835
+ }
836
+ prepare_sanctuary_legacy_adoption() {
837
+ IMAGE_ID=$1
838
+ validate_exact_image_id "$IMAGE_ID" || return $?
839
+ validate_sanctuary_legacy_staging || return $?
840
+ PREPARED_LEGACY_CONTAINER_ID=$LEGACY_STAGING_CONTAINER_ID
841
+ PREPARED_LEGACY_IMAGE_ID=$LEGACY_STAGING_IMAGE_ID
842
+ prepare_canonical_sanctuary_roots "$IMAGE_ID" || return $?
843
+ bootstrap_sanctuary_vault "$IMAGE_ID" \
844
+ /mnt/user/appdata/ouro-butler/runtime/container-credentials.json \
845
+ sanctuary-unraid sanctuary || return $?
846
+ provision_sanctuary_sab_credential "$IMAGE_ID" || return $?
847
+ verify_sanctuary_sab_readiness "$IMAGE_ID" || return $?
848
+ validate_sanctuary_legacy_staging "$PREPARED_LEGACY_CONTAINER_ID" "$PREPARED_LEGACY_IMAGE_ID" || return $?
849
+ LEGACY_STAGING_CONTAINER_ID=$PREPARED_LEGACY_CONTAINER_ID
850
+ LEGACY_STAGING_IMAGE_ID=$PREPARED_LEGACY_IMAGE_ID
851
+ }
852
+ capture_sanctuary_legacy_evidence() {
853
+ CAPTURED_LEGACY_CONTAINER_ID=$1
854
+ CAPTURED_LEGACY_IMAGE_ID=$2
855
+ LEGACY_EVIDENCE_ROOT=$3
856
+ case "$CAPTURED_LEGACY_CONTAINER_ID" in *[!0-9a-f]*|'') return 1 ;; esac
857
+ test "${#CAPTURED_LEGACY_CONTAINER_ID}" -eq 64 || return 1
858
+ validate_exact_image_id "$CAPTURED_LEGACY_IMAGE_ID" || return $?
859
+ case "$LEGACY_EVIDENCE_ROOT" in /*) ;; *) return 1 ;; esac
860
+ if test -e "$LEGACY_EVIDENCE_ROOT" || test -L "$LEGACY_EVIDENCE_ROOT"; then
861
+ /usr/local/bin/node -e '
862
+ const fs = require("node:fs");
863
+ const root = fs.lstatSync(process.argv[1]);
864
+ if (!root.isDirectory() || root.isSymbolicLink() || (root.mode & 0o777) !== 0o700) process.exit(1);
865
+ if (root.uid !== process.geteuid() || root.gid !== process.getegid()) process.exit(1);
866
+ ' "$LEGACY_EVIDENCE_ROOT" || return $?
867
+ else
868
+ install -d -m 0700 -o 0 -g 0 "$LEGACY_EVIDENCE_ROOT" || return $?
869
+ fi
870
+ LEGACY_EVIDENCE_DIR="$LEGACY_EVIDENCE_ROOT/${CAPTURED_LEGACY_IMAGE_ID#sha256:}"
871
+ if test -e "$LEGACY_EVIDENCE_DIR" || test -L "$LEGACY_EVIDENCE_DIR"; then
872
+ test -d "$LEGACY_EVIDENCE_DIR" && test ! -L "$LEGACY_EVIDENCE_DIR" || return 1
873
+ else
874
+ mkdir -m 0700 "$LEGACY_EVIDENCE_DIR" || return $?
875
+ fi
876
+ /usr/local/bin/node -e '
877
+ const fs = require("node:fs");
878
+ const [rootPath, directoryPath] = process.argv.slice(1);
879
+ const root = fs.lstatSync(rootPath);
880
+ const directory = fs.lstatSync(directoryPath);
881
+ const privateDirectory = value => value.isDirectory() && !value.isSymbolicLink() && (value.mode & 0o777) === 0o700;
882
+ if (!privateDirectory(root) || !privateDirectory(directory)) process.exit(1);
883
+ if (root.uid !== process.geteuid() || root.gid !== process.getegid()) process.exit(1);
884
+ if (root.uid !== directory.uid || root.gid !== directory.gid) process.exit(1);
885
+ const allowed = new Set(["container.json", "image.json"]);
886
+ if (fs.readdirSync(directoryPath).some(name => !allowed.has(name))) process.exit(1);
887
+ ' "$LEGACY_EVIDENCE_ROOT" "$LEGACY_EVIDENCE_DIR" || return $?
888
+ validate_legacy_evidence_entry() {
889
+ VALIDATED_EVIDENCE_KIND=$1
890
+ VALIDATED_EVIDENCE_PATH=$2
891
+ /usr/local/bin/node -e '
892
+ const fs = require("node:fs");
893
+ const [kind, filePath, rootPath, containerId, imageId] = process.argv.slice(1);
894
+ const root = fs.lstatSync(rootPath);
895
+ const entry = fs.lstatSync(filePath);
896
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== root.uid || entry.gid !== root.gid) process.exit(1);
897
+ const mode = entry.mode & 0o777;
898
+ if (mode !== 0o600 && mode !== 0o644) process.exit(1);
899
+ const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
900
+ if (!Array.isArray(value) || value.length !== 1 || !value[0] || typeof value[0] !== "object") process.exit(1);
901
+ if (kind === "container" && (value[0].Id !== containerId || value[0].Image !== imageId)) process.exit(1);
902
+ if (kind === "image" && value[0].Id !== imageId) process.exit(1);
903
+ ' "$VALIDATED_EVIDENCE_KIND" "$VALIDATED_EVIDENCE_PATH" "$LEGACY_EVIDENCE_ROOT" "$CAPTURED_LEGACY_CONTAINER_ID" "$CAPTURED_LEGACY_IMAGE_ID"
904
+ }
905
+ capture_missing_legacy_evidence_entry() {
906
+ MISSING_EVIDENCE_KIND=$1
907
+ MISSING_EVIDENCE_PATH=$2
908
+ LEGACY_EVIDENCE_TMP=$(mktemp "$LEGACY_EVIDENCE_ROOT/.capture.XXXXXXXX") || return $?
909
+ if test "$MISSING_EVIDENCE_KIND" = container; then
910
+ docker container inspect "$CAPTURED_LEGACY_CONTAINER_ID" >"$LEGACY_EVIDENCE_TMP" || {
911
+ CAPTURE_STATUS=$?
912
+ rm -f "$LEGACY_EVIDENCE_TMP"
913
+ return "$CAPTURE_STATUS"
914
+ }
915
+ else
916
+ docker image inspect "$CAPTURED_LEGACY_IMAGE_ID" >"$LEGACY_EVIDENCE_TMP" || {
917
+ CAPTURE_STATUS=$?
918
+ rm -f "$LEGACY_EVIDENCE_TMP"
919
+ return "$CAPTURE_STATUS"
920
+ }
921
+ fi
922
+ chmod 0600 "$LEGACY_EVIDENCE_TMP" || {
923
+ CAPTURE_STATUS=$?
924
+ rm -f "$LEGACY_EVIDENCE_TMP"
925
+ return "$CAPTURE_STATUS"
926
+ }
927
+ validate_legacy_evidence_entry "$MISSING_EVIDENCE_KIND" "$LEGACY_EVIDENCE_TMP" || {
928
+ CAPTURE_STATUS=$?
929
+ rm -f "$LEGACY_EVIDENCE_TMP"
930
+ return "$CAPTURE_STATUS"
931
+ }
932
+ sync -f "$LEGACY_EVIDENCE_TMP" || {
933
+ CAPTURE_STATUS=$?
934
+ rm -f "$LEGACY_EVIDENCE_TMP"
935
+ return "$CAPTURE_STATUS"
936
+ }
937
+ if ln "$LEGACY_EVIDENCE_TMP" "$MISSING_EVIDENCE_PATH"; then
938
+ rm -f "$LEGACY_EVIDENCE_TMP" || return $?
939
+ else
940
+ rm -f "$LEGACY_EVIDENCE_TMP" || return $?
941
+ validate_legacy_evidence_entry "$MISSING_EVIDENCE_KIND" "$MISSING_EVIDENCE_PATH" || return $?
942
+ fi
943
+ }
944
+ for LEGACY_EVIDENCE_KIND in container image; do
945
+ LEGACY_EVIDENCE_PATH="$LEGACY_EVIDENCE_DIR/$LEGACY_EVIDENCE_KIND.json"
946
+ if test -e "$LEGACY_EVIDENCE_PATH" || test -L "$LEGACY_EVIDENCE_PATH"; then
947
+ validate_legacy_evidence_entry "$LEGACY_EVIDENCE_KIND" "$LEGACY_EVIDENCE_PATH" || return $?
948
+ else
949
+ capture_missing_legacy_evidence_entry "$LEGACY_EVIDENCE_KIND" "$LEGACY_EVIDENCE_PATH" || return $?
950
+ fi
951
+ chmod 0600 "$LEGACY_EVIDENCE_PATH" || return $?
952
+ validate_legacy_evidence_entry "$LEGACY_EVIDENCE_KIND" "$LEGACY_EVIDENCE_PATH" || return $?
953
+ done
954
+ sync -f "$LEGACY_EVIDENCE_DIR" || return $?
955
+ }
956
+ install_from_legacy_staging() {
957
+ prepare_sanctuary_legacy_adoption "$IMAGE_ID" || return $?
958
+ ADOPTION_PREPARED_CONTAINER_ID=$LEGACY_STAGING_CONTAINER_ID
959
+ ADOPTION_PREPARED_IMAGE_ID=$LEGACY_STAGING_IMAGE_ID
960
+ verify_sanctuary_provider_readiness "$IMAGE_ID" || return $?
961
+ ADOPTION_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
962
+ ADOPTION_NAME_COUNTS=$(printf '%s\n' "$ADOPTION_CONTAINER_NAMES" | awk '
963
+ /butler/ { butlers++ }
964
+ $0 == "ouro-butler-staging" { staging++ }
965
+ END { printf "%d %d", butlers + 0, staging + 0 }
966
+ ') || return $?
967
+ test "$ADOPTION_NAME_COUNTS" = "1 1" || return $?
968
+ assert_only_running_butler ouro-butler-staging || return $?
969
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-staging)" = true || return $?
970
+ test "$(docker inspect --format '{{.Id}}' ouro-butler-staging)" = "$ADOPTION_PREPARED_CONTAINER_ID" || return $?
971
+ test "$(docker inspect --format '{{.Image}}' ouro-butler-staging)" = "$ADOPTION_PREPARED_IMAGE_ID" || return $?
972
+ LEGACY_STAGING_CONTAINER_ID=$ADOPTION_PREPARED_CONTAINER_ID
973
+ LEGACY_STAGING_IMAGE_ID=$ADOPTION_PREPARED_IMAGE_ID
974
+ LEGACY_EVIDENCE_ROOT=/mnt/user/appdata/ouro-butler/legacy-evidence
975
+ capture_sanctuary_legacy_evidence "$LEGACY_STAGING_CONTAINER_ID" "$LEGACY_STAGING_IMAGE_ID" "$LEGACY_EVIDENCE_ROOT" || return $?
976
+ validate_sanctuary_legacy_staging "$ADOPTION_PREPARED_CONTAINER_ID" "$ADOPTION_PREPARED_IMAGE_ID" || return $?
977
+ LEGACY_STAGING_CONTAINER_ID=$ADOPTION_PREPARED_CONTAINER_ID
978
+ LEGACY_STAGING_IMAGE_ID=$ADOPTION_PREPARED_IMAGE_ID
979
+ disable_butler_autostart || return $?
980
+ if docker stop "$LEGACY_STAGING_CONTAINER_ID" \
981
+ && CURRENT_LEGACY_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' "$LEGACY_STAGING_CONTAINER_ID") \
982
+ && test "$CURRENT_LEGACY_STAGING_IMAGE_ID" = "$LEGACY_STAGING_IMAGE_ID" \
983
+ && test "$(docker inspect --format '{{.State.Running}}' "$LEGACY_STAGING_CONTAINER_ID")" = false \
984
+ && docker rename "$LEGACY_STAGING_CONTAINER_ID" ouro-butler-legacy-evidence \
985
+ && CURRENT_LEGACY_EVIDENCE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) \
986
+ && test "$CURRENT_LEGACY_EVIDENCE_IMAGE_ID" = "$LEGACY_STAGING_IMAGE_ID" \
987
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence)" = false \
988
+ && assert_only_running_butler - \
989
+ && docker create --name ouro-butler-staging --network host --restart unless-stopped --user 10001:10001 \
990
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
991
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
992
+ --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
993
+ "$IMAGE_ID" \
994
+ && audit_effective ouro-butler-staging "$IMAGE_ID" "$IMAGE_ID" \
995
+ && docker start ouro-butler-staging \
996
+ && assert_only_running_butler ouro-butler-staging \
997
+ && wait_butler_ready ouro-butler-staging \
998
+ && docker stop ouro-butler-staging \
999
+ && test "$(docker inspect --format '{{.Image}}' ouro-butler-staging)" = "$IMAGE_ID" \
1000
+ && docker rm ouro-butler-staging \
1001
+ && assert_only_running_butler - \
1002
+ && docker create --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
1003
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
1004
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1005
+ --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
1006
+ "$IMAGE_ID" \
1007
+ && audit_effective ouro-butler "$IMAGE_ID" "$IMAGE_ID" \
1008
+ && docker start ouro-butler \
1009
+ && assert_only_running_butler ouro-butler \
1010
+ && wait_butler_ready ouro-butler \
1011
+ && enable_butler_autostart; then
1012
+ return 0
1013
+ else
1014
+ ADOPTION_STATUS=$?
1015
+ fi
1016
+ ADOPTION_RECOVERY_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
1017
+ case "
1018
+ $ADOPTION_RECOVERY_NAMES
1019
+ " in
1020
+ *"
1021
+ ouro-butler
1022
+ "*)
1023
+ ADOPTION_PARTIAL_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
1024
+ test "$ADOPTION_PARTIAL_IMAGE_ID" = "$IMAGE_ID" || return $?
1025
+ docker stop ouro-butler >/dev/null 2>&1 || true
1026
+ docker rm --force ouro-butler || return $?
1027
+ ;;
1028
+ esac
1029
+ ADOPTION_RECOVERY_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
1030
+ case "
1031
+ $ADOPTION_RECOVERY_NAMES
1032
+ " in
1033
+ *"
1034
+ ouro-butler-staging
1035
+ "*)
1036
+ CURRENT_LEGACY_STAGING_CONTAINER_ID=$(docker inspect --format '{{.Id}}' ouro-butler-staging) || return $?
1037
+ case "$CURRENT_LEGACY_STAGING_CONTAINER_ID" in *[!0-9a-f]*|'') return 1 ;; esac
1038
+ test "${#CURRENT_LEGACY_STAGING_CONTAINER_ID}" -eq 64 || return 1
1039
+ CURRENT_LEGACY_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-staging) || return $?
1040
+ case "$CURRENT_LEGACY_STAGING_IMAGE_ID" in
1041
+ "$IMAGE_ID")
1042
+ docker stop ouro-butler-staging >/dev/null 2>&1 || true
1043
+ docker rm --force ouro-butler-staging || return $?
1044
+ ;;
1045
+ "$LEGACY_STAGING_IMAGE_ID")
1046
+ test "$CURRENT_LEGACY_STAGING_CONTAINER_ID" = "$LEGACY_STAGING_CONTAINER_ID" || return 1
1047
+ docker stop "$LEGACY_STAGING_CONTAINER_ID" >/dev/null 2>&1 || return $?
1048
+ test "$(docker inspect --format '{{.State.Running}}' "$LEGACY_STAGING_CONTAINER_ID")" = false || return $?
1049
+ docker rename "$LEGACY_STAGING_CONTAINER_ID" ouro-butler-legacy-evidence || return $?
1050
+ ;;
1051
+ *) return 1 ;;
1052
+ esac
1053
+ ;;
1054
+ esac
1055
+ CURRENT_LEGACY_EVIDENCE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
1056
+ test "$CURRENT_LEGACY_EVIDENCE_IMAGE_ID" = "$LEGACY_STAGING_IMAGE_ID" || return $?
1057
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence)" = false || return $?
1058
+ return "$ADOPTION_STATUS"
1059
+ }
1060
+ validate_sanctuary_legacy_import_marker() {
1061
+ IMPORT_MARKER=$1
1062
+ IMPORT_SOURCE=$2
1063
+ test -f "$IMPORT_MARKER" && test ! -L "$IMPORT_MARKER" || return 1
1064
+ test "$(stat -c '%u:%g %a' "$IMPORT_MARKER")" = "10001:10001 600" || return 1
1065
+ /usr/local/bin/node -e '
1066
+ const crypto = require("node:crypto");
1067
+ const fs = require("node:fs");
1068
+ const marker = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
1069
+ const sourceDigest = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(process.argv[2])).digest("hex")}`;
1070
+ if (JSON.stringify(Object.keys(marker).sort()) !== JSON.stringify(["importedAt", "machineId", "schemaVersion", "sourceDigest"])) process.exit(1);
1071
+ if (marker.schemaVersion !== 1 || marker.machineId !== "sanctuary" || marker.sourceDigest !== sourceDigest) process.exit(1);
1072
+ if (!Number.isFinite(Date.parse(marker.importedAt))) process.exit(1);
1073
+ ' "$IMPORT_MARKER" "$IMPORT_SOURCE"
1074
+ }
1075
+ record_sanctuary_legacy_import_marker() {
1076
+ IMPORT_MARKER=$1
1077
+ IMPORT_SOURCE=$2
1078
+ test ! -e "$IMPORT_MARKER" || return 1
1079
+ IMPORT_MARKER_TMP=$(mktemp "${IMPORT_MARKER}.tmp.XXXXXX") || return $?
1080
+ trap 'rm -f -- "$IMPORT_MARKER_TMP"' EXIT || return $?
1081
+ /usr/local/bin/node -e '
1082
+ const crypto = require("node:crypto");
1083
+ const fs = require("node:fs");
1084
+ const sourceDigest = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(process.argv[2])).digest("hex")}`;
1085
+ const marker = { schemaVersion: 1, machineId: "sanctuary", sourceDigest, importedAt: new Date().toISOString() };
1086
+ fs.writeFileSync(process.argv[1], `${JSON.stringify(marker)}\n`, { mode: 0o600, flag: "w" });
1087
+ ' "$IMPORT_MARKER_TMP" "$IMPORT_SOURCE" || return $?
1088
+ chown 10001:10001 "$IMPORT_MARKER_TMP" || return $?
1089
+ chmod 0600 "$IMPORT_MARKER_TMP" || return $?
1090
+ mv -f -- "$IMPORT_MARKER_TMP" "$IMPORT_MARKER" || return $?
1091
+ sync -f "$IMPORT_MARKER" || return $?
1092
+ sync -f "$(dirname "$IMPORT_MARKER")" || return $?
1093
+ trap - EXIT || return $?
1094
+ }
1095
+ bootstrap_sanctuary_vault() {
1096
+ BOOTSTRAP_IMAGE_ID=$1
1097
+ BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE=${2-}
1098
+ BOOTSTRAP_SOURCE_MACHINE_ID=${3-}
1099
+ BOOTSTRAP_TARGET_MACHINE_ID=${4-}
1100
+ validate_exact_image_id "$BOOTSTRAP_IMAGE_ID" || return $?
1101
+ case "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" in
1102
+ "")
1103
+ test -z "$BOOTSTRAP_SOURCE_MACHINE_ID" || return 1
1104
+ test -z "$BOOTSTRAP_TARGET_MACHINE_ID" || return 1
1105
+ ;;
1106
+ /mnt/user/appdata/ouro-butler/runtime/container-credentials.json)
1107
+ test "$BOOTSTRAP_SOURCE_MACHINE_ID" = sanctuary-unraid || return 1
1108
+ test "$BOOTSTRAP_TARGET_MACHINE_ID" = sanctuary || return 1
1109
+ ;;
1110
+ *) return 1 ;;
1111
+ esac
1112
+ BOOTSTRAP_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1113
+ BOOTSTRAP_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1114
+ for BOOTSTRAP_CONTAINER in ouro-butler-vault-status ouro-butler-vault-bootstrap ouro-butler-credential-bootstrap ouro-butler-provider-readiness; do
1115
+ ! docker container inspect "$BOOTSTRAP_CONTAINER" >/dev/null 2>&1 || return 1
1116
+ done
1117
+ VAULT_STATUS=$(docker run --rm --pull=never --network host --name ouro-butler-vault-status --user 10001:10001 \
1118
+ --mount "type=bind,src=$BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1119
+ --mount "type=bind,src=$BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1120
+ --entrypoint node "$BOOTSTRAP_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
1121
+ vault status --agent sanctuary --store plaintext-file) || return $?
1122
+ ! docker container inspect ouro-butler-vault-status >/dev/null 2>&1 || return 1
1123
+ case "
1124
+ $VAULT_STATUS
1125
+ " in
1126
+ *"
1127
+ vault locator: not configured in agent.json
1128
+ "*) VAULT_ACTION=create ;;
1129
+ *"
1130
+ vault locator: agent.json
1131
+ "*)
1132
+ case "
1133
+ $VAULT_STATUS
1134
+ " in
1135
+ *"
1136
+ local unlock: available
1137
+ "*) VAULT_ACTION=none ;;
1138
+ *"
1139
+ local unlock: missing
1140
+ "*) VAULT_ACTION=unlock ;;
1141
+ *) return 1 ;;
1142
+ esac
1143
+ ;;
1144
+ *) return 1 ;;
1145
+ esac
1146
+ if test "$VAULT_ACTION" != none; then
1147
+ docker run --rm -it --pull=never --network host --name ouro-butler-vault-bootstrap --user 10001:10001 \
1148
+ --mount "type=bind,src=$BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1149
+ --mount "type=bind,src=$BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1150
+ --entrypoint node "$BOOTSTRAP_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
1151
+ vault "$VAULT_ACTION" --agent sanctuary --store plaintext-file || return $?
1152
+ ! docker container inspect ouro-butler-vault-bootstrap >/dev/null 2>&1 || return 1
1153
+ fi
1154
+ VERIFIED_VAULT_STATUS=$(docker run --rm --pull=never --network host --name ouro-butler-vault-status --user 10001:10001 \
1155
+ --mount "type=bind,src=$BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1156
+ --mount "type=bind,src=$BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1157
+ --entrypoint node "$BOOTSTRAP_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
1158
+ vault status --agent sanctuary --store plaintext-file) || return $?
1159
+ ! docker container inspect ouro-butler-vault-status >/dev/null 2>&1 || return 1
1160
+ case "
1161
+ $VERIFIED_VAULT_STATUS
1162
+ " in
1163
+ *"
1164
+ vault locator: agent.json
1165
+ "*) ;;
1166
+ *) return 1 ;;
1167
+ esac
1168
+ case "
1169
+ $VERIFIED_VAULT_STATUS
1170
+ " in
1171
+ *"
1172
+ local unlock: available
1173
+ "*) ;;
1174
+ *) return 1 ;;
1175
+ esac
1176
+ if test -n "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE"; then
1177
+ BOOTSTRAP_CREDENTIAL_SOURCE="$BOOTSTRAP_RUNTIME_ROOT/container-credentials.json"
1178
+ BOOTSTRAP_CREDENTIAL_CLAIM="$BOOTSTRAP_CREDENTIAL_SOURCE.consuming"
1179
+ test -f "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1180
+ test ! -L "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1181
+ BOOTSTRAP_LEGACY_METADATA=$(stat -c '%u:%g %a' "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE") || return $?
1182
+ test "$BOOTSTRAP_LEGACY_METADATA" = "10001:10001 600" || return $?
1183
+ BOOTSTRAP_IMPORT_MARKER="$BOOTSTRAP_RUNTIME_ROOT/legacy-credentials-imported.json"
1184
+ if test -e "$BOOTSTRAP_IMPORT_MARKER"; then
1185
+ validate_sanctuary_legacy_import_marker "$BOOTSTRAP_IMPORT_MARKER" "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1186
+ test ! -e "$BOOTSTRAP_CREDENTIAL_SOURCE" || return 1
1187
+ test ! -e "$BOOTSTRAP_CREDENTIAL_CLAIM" || return 1
1188
+ else
1189
+ if test -e "$BOOTSTRAP_CREDENTIAL_SOURCE"; then
1190
+ test -f "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
1191
+ test ! -L "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
1192
+ test "$(stat -c '%u:%g %a' "$BOOTSTRAP_CREDENTIAL_SOURCE")" = "10001:10001 600" || return $?
1193
+ cmp -s "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
1194
+ elif test -e "$BOOTSTRAP_CREDENTIAL_CLAIM"; then
1195
+ test -f "$BOOTSTRAP_CREDENTIAL_CLAIM" || return $?
1196
+ test ! -L "$BOOTSTRAP_CREDENTIAL_CLAIM" || return $?
1197
+ test "$(stat -c '%u:%g %a' "$BOOTSTRAP_CREDENTIAL_CLAIM")" = "10001:10001 600" || return $?
1198
+ cmp -s "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" "$BOOTSTRAP_CREDENTIAL_CLAIM" || return $?
1199
+ else
1200
+ BOOTSTRAP_CREDENTIAL_TMP=$(mktemp "$BOOTSTRAP_RUNTIME_ROOT/container-credentials.json.tmp.XXXXXX") || return $?
1201
+ trap 'rm -f -- "$BOOTSTRAP_CREDENTIAL_TMP"' EXIT || return $?
1202
+ install -m 0600 -o 10001 -g 10001 "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" "$BOOTSTRAP_CREDENTIAL_TMP" || return $?
1203
+ cmp -s "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" "$BOOTSTRAP_CREDENTIAL_TMP" || return $?
1204
+ sync -f "$BOOTSTRAP_CREDENTIAL_TMP" || return $?
1205
+ mv -f -- "$BOOTSTRAP_CREDENTIAL_TMP" "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
1206
+ sync -f "$BOOTSTRAP_RUNTIME_ROOT" || return $?
1207
+ trap - EXIT || return $?
1208
+ fi
1209
+ docker run --rm --pull=never --network host --name ouro-butler-credential-bootstrap --user 10001:10001 \
1210
+ --mount "type=bind,src=$BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1211
+ --mount "type=bind,src=$BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1212
+ --entrypoint node "$BOOTSTRAP_IMAGE_ID" -e '
1213
+ const { loadContainerCredentialBootstrap } = require("/opt/ouro/dist/heart/daemon/container-credential-bootstrap.js")
1214
+ loadContainerCredentialBootstrap(["sanctuary"], {
1215
+ machineIdMigration: {
1216
+ sourceMachineId: process.argv[1],
1217
+ targetMachineId: process.argv[2],
1218
+ },
1219
+ }).then(
1220
+ () => process.exit(0),
1221
+ () => { process.stderr.write("credential bootstrap failed\n"); process.exit(1) },
1222
+ )
1223
+ ' "$BOOTSTRAP_SOURCE_MACHINE_ID" "$BOOTSTRAP_TARGET_MACHINE_ID" || return $?
1224
+ # The path-locked Sanctuary migration imports the legacy MiniMax provider
1225
+ # record into the agent vault before strict validation and persistence.
1226
+ # The original legacy envelope remains byte-for-byte unchanged and is the
1227
+ # sole digest authority.
1228
+ ! docker container inspect ouro-butler-credential-bootstrap >/dev/null 2>&1 || return 1
1229
+ test ! -e "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
1230
+ test ! -e "$BOOTSTRAP_CREDENTIAL_CLAIM" || return $?
1231
+ record_sanctuary_legacy_import_marker "$BOOTSTRAP_IMPORT_MARKER" "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1232
+ validate_sanctuary_legacy_import_marker "$BOOTSTRAP_IMPORT_MARKER" "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1233
+ fi
1234
+ test -f "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1235
+ test ! -L "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
1236
+ test "$(stat -c '%u:%g %a' "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE")" = "$BOOTSTRAP_LEGACY_METADATA" || return $?
1237
+ fi
1238
+ validate_sanctuary_roots "$BOOTSTRAP_RUNTIME_ROOT" "$BOOTSTRAP_AGENT_ROOT" || return $?
1239
+ }
1240
+ provision_sanctuary_sab_credential() {
1241
+ SAB_BOOTSTRAP_IMAGE_ID=$1
1242
+ validate_exact_image_id "$SAB_BOOTSTRAP_IMAGE_ID" || return $?
1243
+ SAB_BOOTSTRAP_SOURCE=/mnt/user/appdata/sabnzbd/sabnzbd.ini
1244
+ SAB_BOOTSTRAP_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1245
+ SAB_BOOTSTRAP_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1246
+ SAB_BOOTSTRAP_ENVELOPE="$SAB_BOOTSTRAP_RUNTIME_ROOT/container-credentials.json"
1247
+ SAB_BOOTSTRAP_CLAIM="$SAB_BOOTSTRAP_ENVELOPE.consuming"
1248
+ test -f "$SAB_BOOTSTRAP_SOURCE" || return $?
1249
+ test ! -L "$SAB_BOOTSTRAP_SOURCE" || return 1
1250
+ test ! -e "$SAB_BOOTSTRAP_ENVELOPE" || return 1
1251
+ test ! -e "$SAB_BOOTSTRAP_CLAIM" || return 1
1252
+ ! docker container inspect ouro-butler-sab-credential-bootstrap >/dev/null 2>&1 || return 1
1253
+ SAB_BOOTSTRAP_TMP=$(mktemp "$SAB_BOOTSTRAP_RUNTIME_ROOT/container-credentials.json.tmp.XXXXXX") || return $?
1254
+ trap 'rm -f -- "$SAB_BOOTSTRAP_TMP"' EXIT || return $?
1255
+ /usr/local/bin/node -e '
1256
+ const fs = require("node:fs");
1257
+ const [source, destination] = process.argv.slice(1);
1258
+ const match = fs.readFileSync(source, "utf8").match(/^\s*api_key\s*=\s*(\S+)\s*$/mu);
1259
+ if (!match || !match[1]) process.exit(1);
1260
+ const credential = { type: "ouro.runtimeCredentialBootstrap", agentName: "sanctuary", machineId: "sanctuary", machineRuntimeConfig: { sabnzbdApiKey: match[1] } };
1261
+ const envelope = { schemaVersion: 1, credentials: [credential] };
1262
+ fs.writeFileSync(destination, `${JSON.stringify(envelope)}\n`, { mode: 0o600, flag: "w" });
1263
+ ' "$SAB_BOOTSTRAP_SOURCE" "$SAB_BOOTSTRAP_TMP" || return $?
1264
+ chown 10001:10001 "$SAB_BOOTSTRAP_TMP" || return $?
1265
+ chmod 0600 "$SAB_BOOTSTRAP_TMP" || return $?
1266
+ sync -f "$SAB_BOOTSTRAP_TMP" || return $?
1267
+ mv -f -- "$SAB_BOOTSTRAP_TMP" "$SAB_BOOTSTRAP_ENVELOPE" || return $?
1268
+ sync -f "$SAB_BOOTSTRAP_RUNTIME_ROOT" || return $?
1269
+ trap - EXIT || return $?
1270
+ docker run --rm --pull=never --network host --name ouro-butler-sab-credential-bootstrap --user 10001:10001 \
1271
+ --mount "type=bind,src=$SAB_BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1272
+ --mount "type=bind,src=$SAB_BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1273
+ --entrypoint node "$SAB_BOOTSTRAP_IMAGE_ID" -e '
1274
+ const { loadContainerCredentialBootstrap } = require("/opt/ouro/dist/heart/daemon/container-credential-bootstrap.js");
1275
+ loadContainerCredentialBootstrap(["sanctuary"]).then(
1276
+ () => process.exit(0),
1277
+ () => { process.stderr.write("SAB credential bootstrap failed\n"); process.exit(1); },
1278
+ );
1279
+ ' || return $?
1280
+ ! docker container inspect ouro-butler-sab-credential-bootstrap >/dev/null 2>&1 || return 1
1281
+ test ! -e "$SAB_BOOTSTRAP_ENVELOPE" || return 1
1282
+ test ! -e "$SAB_BOOTSTRAP_CLAIM" || return 1
1283
+ }
1284
+ verify_sanctuary_sab_readiness() {
1285
+ SAB_READINESS_IMAGE_ID=$1
1286
+ validate_exact_image_id "$SAB_READINESS_IMAGE_ID" || return $?
1287
+ ! docker container inspect ouro-butler-sab-readiness >/dev/null 2>&1 || return 1
1288
+ docker run --rm --pull=never --network host --name ouro-butler-sab-readiness --user 10001:10001 \
1289
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
1290
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1291
+ --entrypoint node "$SAB_READINESS_IMAGE_ID" -e '
1292
+ (async () => {
1293
+ try {
1294
+ const { refreshMachineRuntimeCredentialConfig } = require("/opt/ouro/dist/heart/runtime-credentials.js");
1295
+ const { createSanctuarySabClient } = require("/opt/ouro/dist/senses/sanctuary-sab.js");
1296
+ const current = await refreshMachineRuntimeCredentialConfig("sanctuary", "sanctuary");
1297
+ if (!current.ok || typeof current.config.sabnzbdApiKey !== "string" || !current.config.sabnzbdApiKey.trim()) throw new Error();
1298
+ const snapshot = await createSanctuarySabClient({ loadApiKey: async () => current.config.sabnzbdApiKey }).readQueue();
1299
+ if (typeof snapshot.paused !== "boolean" || !Number.isSafeInteger(snapshot.queuedJobs) || !snapshot.observedAt) throw new Error();
1300
+ process.stdout.write("Sanctuary SAB queue readiness verified.\n");
1301
+ } catch {
1302
+ process.stderr.write("Sanctuary SAB queue readiness verification failed.\n");
1303
+ process.exitCode = 1;
1304
+ }
1305
+ })();
1306
+ ' || return $?
1307
+ ! docker container inspect ouro-butler-sab-readiness >/dev/null 2>&1 || return 1
1308
+ }
1309
+ authenticate_sanctuary_provider() {
1310
+ AUTH_IMAGE_ID=$1
1311
+ AUTH_PROVIDER=${2-}
1312
+ validate_exact_image_id "$AUTH_IMAGE_ID" || return $?
1313
+ case "$AUTH_PROVIDER" in
1314
+ openai-compatible) AUTH_CONTAINER=ouro-butler-provider-auth-glm ;;
1315
+ openai-compatible-gemini) AUTH_CONTAINER=ouro-butler-provider-auth-gemini ;;
1316
+ *) return 1 ;;
1317
+ esac
1318
+ AUTH_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1319
+ AUTH_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1320
+ ! docker container inspect "$AUTH_CONTAINER" >/dev/null 2>&1 || return 1
1321
+ docker run --rm -it --pull=never --network host --name "$AUTH_CONTAINER" --user 10001:10001 \
1322
+ --mount "type=bind,src=$AUTH_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1323
+ --mount "type=bind,src=$AUTH_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1324
+ --entrypoint node "$AUTH_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
1325
+ auth --agent sanctuary --provider "$AUTH_PROVIDER" || return $?
1326
+ ! docker container inspect "$AUTH_CONTAINER" >/dev/null 2>&1 || return 1
1327
+ }
1328
+ verify_sanctuary_provider_readiness() {
1329
+ READINESS_IMAGE_ID=$1
1330
+ validate_exact_image_id "$READINESS_IMAGE_ID" || return $?
1331
+ READINESS_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1332
+ READINESS_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1333
+ ! docker container inspect ouro-butler-provider-readiness >/dev/null 2>&1 || return 1
1334
+ docker run --rm --pull=never --network host --name ouro-butler-provider-readiness --user 10001:10001 \
1335
+ --mount "type=bind,src=$READINESS_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1336
+ --mount "type=bind,src=$READINESS_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1337
+ --entrypoint /bin/sh "$READINESS_IMAGE_ID" -ceu '
1338
+ umask 077
1339
+ node /opt/ouro/dist/heart/daemon/ouro-entry.js check --agent sanctuary --lane outward
1340
+ node /opt/ouro/dist/heart/daemon/ouro-entry.js check --agent sanctuary --lane inner
1341
+ node - <<'"'"'NODE'"'"'
1342
+ (async () => {
1343
+ try {
1344
+ const fs = require("node:fs");
1345
+ const root = "/home/ouro/AgentBundles/sanctuary.ouro";
1346
+ const agent = JSON.parse(fs.readFileSync(`${root}/agent.json`, "utf8"));
1347
+ const policy = JSON.parse(fs.readFileSync(`${root}/provider-readiness.json`, "utf8"));
1348
+ const expectedPolicy = { version: 1, selectionPolicy: "explicit-same-lane-only" };
1349
+ const expected = new Map([
1350
+ ["minimax", { provider: "minimax", model: "MiniMax-M3", vaultItem: "providers/minimax" }],
1351
+ ]);
1352
+ const exactLane = value => value && value.provider === "minimax" && value.model === "MiniMax-M3";
1353
+ if (!exactLane(agent.humanFacing) || !exactLane(agent.agentFacing)
1354
+ || policy.version !== expectedPolicy.version || policy.selectionPolicy !== expectedPolicy.selectionPolicy
1355
+ || !Array.isArray(policy.providers) || policy.providers.length !== expected.size) throw new Error();
1356
+ for (const entry of policy.providers) {
1357
+ const wanted = expected.get(entry.provider);
1358
+ if (!wanted || entry.model !== wanted.model || entry.vaultItem !== wanted.vaultItem) throw new Error();
1359
+ expected.delete(entry.provider);
1360
+ }
1361
+ if (expected.size !== 0) throw new Error();
1362
+ const { refreshProviderCredentialPool } = require("/opt/ouro/dist/heart/provider-credentials.js");
1363
+ const refreshed = await refreshProviderCredentialPool("sanctuary", {
1364
+ providers: ["minimax"],
1365
+ skipCache: true,
1366
+ });
1367
+ if (!refreshed.ok) throw new Error();
1368
+ const minimax = refreshed.pool.providers["minimax"];
1369
+ const exactRecord = (record, provider) => record && record.provider === provider
1370
+ && typeof record.revision === "string" && record.revision.length > 0
1371
+ && typeof record.credentials?.apiKey === "string" && record.credentials.apiKey.trim().length > 0;
1372
+ if (!exactRecord(minimax, "minimax")) throw new Error();
1373
+ process.stdout.write("Sanctuary provider readiness verified.\n");
1374
+ } catch {
1375
+ process.stderr.write("Sanctuary provider readiness verification failed.\n");
1376
+ process.exitCode = 1;
1377
+ }
1378
+ })();
1379
+ NODE
1380
+ ' || return $?
1381
+ ! docker container inspect ouro-butler-provider-readiness >/dev/null 2>&1 || return 1
1382
+ validate_sanctuary_roots "$READINESS_RUNTIME_ROOT" "$READINESS_AGENT_ROOT" || return $?
1383
+ }
1384
+ verify_sanctuary_telegram_readiness() {
1385
+ TELEGRAM_READINESS_IMAGE_ID=$1
1386
+ validate_exact_image_id "$TELEGRAM_READINESS_IMAGE_ID" || return $?
1387
+ TELEGRAM_READINESS_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1388
+ TELEGRAM_READINESS_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1389
+ ! docker container inspect ouro-butler-telegram-readiness >/dev/null 2>&1 || return 1
1390
+ docker run --rm --pull=never --network host --name ouro-butler-telegram-readiness --user 10001:10001 \
1391
+ --read-only --cap-drop ALL --security-opt no-new-privileges \
1392
+ --mount "type=bind,src=$TELEGRAM_READINESS_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1393
+ --mount "type=bind,src=$TELEGRAM_READINESS_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro,readonly" \
1394
+ --entrypoint /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh \
1395
+ "$TELEGRAM_READINESS_IMAGE_ID" telegram-readiness >/dev/null || return $?
1396
+ ! docker container inspect ouro-butler-telegram-readiness >/dev/null 2>&1 || return 1
1397
+ normalize_sanctuary_private_permissions "$TELEGRAM_READINESS_RUNTIME_ROOT" "$TELEGRAM_READINESS_AGENT_ROOT" "$TELEGRAM_READINESS_IMAGE_ID" || return $?
1398
+ validate_sanctuary_roots "$TELEGRAM_READINESS_RUNTIME_ROOT" "$TELEGRAM_READINESS_AGENT_ROOT" live-precutover || return $?
1399
+ }
1400
+ run_sanctuary_docker() {
1401
+ /usr/bin/timeout -s KILL 20 /usr/bin/docker "$@"
1402
+ }
1403
+ run_sanctuary_unraid_api() {
1404
+ /usr/bin/timeout -s KILL 20 /usr/local/sbin/unraid-api "$@"
1405
+ }
1406
+ run_sanctuary_node() {
1407
+ /usr/local/bin/node "$@"
1408
+ }
1409
+ resolve_sanctuary_unraid_key() {
1410
+ run_sanctuary_node -e '
1411
+ const fs = require("node:fs");
1412
+ const root = "/boot/config/plugins/dynamix.my.servers/keys";
1413
+ const expected = process.argv[1];
1414
+ const found = [];
1415
+ for (const name of fs.readdirSync(root)) {
1416
+ if (!/^[A-Za-z0-9._:-]+\.json$/.test(name)) process.exit(1);
1417
+ const path = `${root}/${name}`;
1418
+ const stat = fs.lstatSync(path);
1419
+ if (!stat.isFile() || stat.isSymbolicLink()) process.exit(1);
1420
+ const value = JSON.parse(fs.readFileSync(path, "utf8"));
1421
+ if (value.id === expected) found.push({ path, name: value.name });
1422
+ }
1423
+ if (found.length !== 1 || typeof found[0].name !== "string" || !/^[A-Za-z0-9 ._:-]+$/.test(found[0].name)) process.exit(1);
1424
+ process.stdout.write(`${found[0].path}\t${found[0].name}`);
1425
+ ' "$1"
1426
+ }
1427
+ sanctuary_revoked_key_recovery_path() {
1428
+ case "$1" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
1429
+ printf '%s/%s.json' /mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof "$1"
1430
+ }
1431
+ validate_sanctuary_revoked_key_recovery() {
1432
+ test -f "$1" && test ! -L "$1" || return 1
1433
+ test "$(stat -c '%u:%g %a' "$1")" = "0:0 600" || return 1
1434
+ }
1435
+ preserve_sanctuary_revoked_key() {
1436
+ KEY_ID=$1
1437
+ KEY_SOURCE=$2
1438
+ RECOVERY_ROOT=/mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof
1439
+ test -f "$KEY_SOURCE" && test ! -L "$KEY_SOURCE" || return 1
1440
+ if test ! -e "$RECOVERY_ROOT"; then
1441
+ install -d -m 0700 -o root -g root "$RECOVERY_ROOT" || return $?
1442
+ fi
1443
+ test -d "$RECOVERY_ROOT" && test ! -L "$RECOVERY_ROOT" || return 1
1444
+ test "$(stat -c '%u:%g %a' "$RECOVERY_ROOT")" = "0:0 700" || return 1
1445
+ RECOVERY_PATH=$(sanctuary_revoked_key_recovery_path "$KEY_ID") || return $?
1446
+ test ! -e "$RECOVERY_PATH" || return 1
1447
+ RECOVERY_TMP=$(mktemp "$RECOVERY_ROOT/$KEY_ID.json.tmp.XXXXXX") || return $?
1448
+ trap 'rm -f -- "$RECOVERY_TMP"' EXIT || return $?
1449
+ install -m 0600 -o root -g root "$KEY_SOURCE" "$RECOVERY_TMP" || return $?
1450
+ cmp -s "$KEY_SOURCE" "$RECOVERY_TMP" || return 1
1451
+ mv -f -- "$RECOVERY_TMP" "$RECOVERY_PATH" || return $?
1452
+ sync -f "$RECOVERY_ROOT" || return $?
1453
+ trap - EXIT || return $?
1454
+ }
1455
+ clear_sanctuary_revoked_key() {
1456
+ RECOVERY_PATH=$(sanctuary_revoked_key_recovery_path "$1") || return $?
1457
+ test -f "$RECOVERY_PATH" && test ! -L "$RECOVERY_PATH" || return 1
1458
+ test "$(stat -c '%u:%g %a' "$RECOVERY_PATH")" = "0:0 600" || return 1
1459
+ rm -f -- "$RECOVERY_PATH" || return $?
1460
+ sync -f "$(dirname "$RECOVERY_PATH")" || return $?
1461
+ }
1462
+ verify_vault_backed_unraid_key() {
1463
+ KEY_ID=$1
1464
+ CAPABILITY=$2
1465
+ case "$KEY_ID" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
1466
+ case "$CAPABILITY" in read-only|bounded-write) ;; *) return 1 ;; esac
1467
+ IMAGE_DIGEST=${IMAGE_ID#sha256:}
1468
+ test "$IMAGE_DIGEST" != "$IMAGE_ID" || return 1
1469
+ test "${#IMAGE_DIGEST}" -eq 64 || return 1
1470
+ case "$IMAGE_DIGEST" in *[!0-9a-f]*) return 1 ;; esac
1471
+ test "$(run_sanctuary_docker image inspect --format '{{.Id}}' "$IMAGE_ID")" = "$IMAGE_ID" || return $?
1472
+ KEY_TARGET=$(resolve_sanctuary_unraid_key "$KEY_ID") || return $?
1473
+ KEY_PATH=${KEY_TARGET%% *}
1474
+ test "$KEY_PATH" != "$KEY_TARGET" || return 1
1475
+ test -f "$KEY_PATH" && test ! -L "$KEY_PATH" || return 1
1476
+ VERIFY_RESULT=$(run_sanctuary_docker run --rm --pull=never --network host \
1477
+ --user 0:0 \
1478
+ --mount type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli,readonly \
1479
+ --mount type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro,readonly \
1480
+ --mount type=bind,src="$KEY_PATH",dst=/run/ouro-acceptance/unraid-key.json,readonly \
1481
+ --entrypoint /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh \
1482
+ "$IMAGE_ID" vault-probe "$KEY_ID" "$CAPABILITY") || return $?
1483
+ printf '%s' "$VERIFY_RESULT" | run_sanctuary_node -e '
1484
+ let input = "";
1485
+ process.stdin.setEncoding("utf8");
1486
+ process.stdin.on("data", chunk => { input += chunk; });
1487
+ process.stdin.on("end", () => {
1488
+ const value = JSON.parse(input);
1489
+ const expectedId = process.argv[1];
1490
+ const expectedCapability = process.argv[2];
1491
+ const expectedProof = expectedCapability === "read-only"
1492
+ ? "read-authorized-write-denied"
1493
+ : "read-authorized-write-reached-not-found";
1494
+ if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(["capability", "keyId", "proof", "valid"])) process.exit(1);
1495
+ if (value.valid !== true || value.keyId !== expectedId || value.capability !== expectedCapability) process.exit(1);
1496
+ if (value.proof !== expectedProof) process.exit(1);
1497
+ });
1498
+ ' "$KEY_ID" "$CAPABILITY" || return $?
1499
+ }
1500
+ inventory_unraid_key_ids() {
1501
+ IMAGE_DIGEST=${IMAGE_ID#sha256:}
1502
+ test "$IMAGE_DIGEST" != "$IMAGE_ID" || return 1
1503
+ test "${#IMAGE_DIGEST}" -eq 64 || return 1
1504
+ case "$IMAGE_DIGEST" in *[!0-9a-f]*) return 1 ;; esac
1505
+ test "$(run_sanctuary_docker image inspect --format '{{.Id}}' "$IMAGE_ID")" = "$IMAGE_ID" || return $?
1506
+ INVENTORY_RESULT=$(printf '%s' '{"operation":"closed-inventory"}' | \
1507
+ run_sanctuary_docker run --rm -i --pull=never --network none \
1508
+ --user 0:0 --read-only \
1509
+ --mount type=bind,src=/boot/config/plugins/dynamix.my.servers/keys,dst=/boot/config/plugins/dynamix.my.servers/keys,readonly \
1510
+ --entrypoint /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh "$IMAGE_ID") || return $?
1511
+ printf '%s' "$INVENTORY_RESULT" | run_sanctuary_node -e '
1512
+ let input = "";
1513
+ process.stdin.setEncoding("utf8");
1514
+ process.stdin.on("data", chunk => { input += chunk; });
1515
+ process.stdin.on("end", () => {
1516
+ const value = JSON.parse(input);
1517
+ if (!value || JSON.stringify(Object.keys(value)) !== JSON.stringify(["keys"]) || !Array.isArray(value.keys)) process.exit(1);
1518
+ for (const key of value.keys) {
1519
+ if (!key || JSON.stringify(Object.keys(key).sort()) !== JSON.stringify(["id", "roles", "scope"])) process.exit(1);
1520
+ if (!/^[A-Za-z0-9._:-]+$/.test(key.id)) process.exit(1);
1521
+ if (!["read-only", "bounded-write", "legacy-write"].includes(key.scope) || key.roles !== "none") process.exit(1);
1522
+ process.stdout.write(`${key.id}\t${key.scope}\t${key.roles}\n`);
1523
+ }
1524
+ });
1525
+ ' || return $?
1526
+ }
1527
+ revoke_unraid_key_exact() {
1528
+ KEY_ID=$1
1529
+ case "$KEY_ID" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
1530
+ test "${REVOKED_KEY_FD_OPEN:-no}" = no || return 1
1531
+ KEY_TARGET=$(resolve_sanctuary_unraid_key "$KEY_ID") || return $?
1532
+ KEY_PATH=${KEY_TARGET%% *}
1533
+ KEY_NAME=${KEY_TARGET#* }
1534
+ test "$KEY_PATH" != "$KEY_TARGET" || return 1
1535
+ test -f "$KEY_PATH" && test ! -L "$KEY_PATH" || return $?
1536
+ preserve_sanctuary_revoked_key "$KEY_ID" "$KEY_PATH" || return $?
1537
+ if ! REVOKE_RESULT=$(run_sanctuary_unraid_api apikey --name "$KEY_NAME" --delete --json); then
1538
+ return 1
1539
+ fi
1540
+ if ! printf '%s' "$REVOKE_RESULT" | run_sanctuary_node -e '
1541
+ let input = "";
1542
+ process.stdin.setEncoding("utf8");
1543
+ process.stdin.on("data", chunk => { input += chunk; });
1544
+ process.stdin.on("end", () => {
1545
+ const value = JSON.parse(input);
1546
+ const expectedId = process.argv[1];
1547
+ const expectedName = process.argv[2];
1548
+ if (value.deleted !== 1 || !Array.isArray(value.keys) || value.keys.length !== 1) process.exit(1);
1549
+ if (value.keys[0].id !== expectedId || value.keys[0].name !== expectedName) process.exit(1);
1550
+ });
1551
+ ' "$KEY_ID" "$KEY_NAME"; then
1552
+ return 1
1553
+ fi
1554
+ }
1555
+ verify_revoked_unraid_key_rejected() {
1556
+ KEY_ID=$1
1557
+ case "$KEY_ID" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
1558
+ REVOKED_KEY_RECOVERY_PATH=$(sanctuary_revoked_key_recovery_path "$KEY_ID") || return $?
1559
+ validate_sanctuary_revoked_key_recovery "$REVOKED_KEY_RECOVERY_PATH" || return $?
1560
+ IMAGE_DIGEST=${IMAGE_ID#sha256:}
1561
+ test "$IMAGE_DIGEST" != "$IMAGE_ID" || return 1
1562
+ test "${#IMAGE_DIGEST}" -eq 64 || return 1
1563
+ case "$IMAGE_DIGEST" in *[!0-9a-f]*) return 1 ;; esac
1564
+ test "$(run_sanctuary_docker image inspect --format '{{.Id}}' "$IMAGE_ID")" = "$IMAGE_ID" || return 1
1565
+ REJECT_ATTEMPT=0
1566
+ while test "$REJECT_ATTEMPT" -lt 3; do
1567
+ REJECT_ATTEMPT=$((REJECT_ATTEMPT + 1))
1568
+ if REJECT_RESULT=$(run_sanctuary_docker run --rm -i --pull=never --network host \
1569
+ --user 10001:10001 --read-only \
1570
+ --entrypoint /bin/sh "$IMAGE_ID" -ceu \
1571
+ 'exec 3<&0; exec /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh revoked-probe "$1" "$2" 3<&3' \
1572
+ sanctuary-revoked-probe "$KEY_ID" http://127.0.0.1/graphql <"$REVOKED_KEY_RECOVERY_PATH") \
1573
+ && printf '%s' "$REJECT_RESULT" | run_sanctuary_node -e '
1574
+ let input = "";
1575
+ process.stdin.setEncoding("utf8");
1576
+ process.stdin.on("data", chunk => { input += chunk; });
1577
+ process.stdin.on("end", () => {
1578
+ const value = JSON.parse(input);
1579
+ if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(["id", "rejected", "status"])) process.exit(1);
1580
+ if (value.rejected !== true || value.id !== process.argv[1] || ![401, 403].includes(value.status)) process.exit(1);
1581
+ });
1582
+ ' "$KEY_ID"; then
1583
+ clear_sanctuary_revoked_key "$KEY_ID" || return $?
1584
+ return 0
1585
+ fi
1586
+ test "$REJECT_ATTEMPT" -lt 3 || return 1
1587
+ sleep 2
1588
+ done
1589
+ return 1
1590
+ }
1591
+ Update:
1592
+ Build and verify a new ouro-butler:<version> image first. The tag is only a
1593
+ lookup handle and never authorizes container creation. Resolve and validate
1594
+ the exact local Docker image ID before staging:
1595
+ IMAGE_TAG=ouro-butler:<version>
1596
+ IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$IMAGE_TAG")
1597
+ printf '%s\n' "$IMAGE_ID" | grep -Eq '^sha256:[0-9a-f]{64}$'
1598
+ docker image inspect "$IMAGE_ID" >/dev/null
1599
+ AUDIT_RUNNER_IMAGE_ID=$IMAGE_ID
1600
+ validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID"
1601
+ Before stopping, renaming, or creating any Butler container, extract the packaged event, template, and runtime-policy assets from that exact image ID. Install the event assets transactionally, bind the staged template to the exact local image ID, and audit both staged deployment files. Do not copy these files from a checkout or another image:
1602
+ EVENT_ASSET_STAGE=$(mktemp -d /mnt/user/appdata/ouro-butler/staging/ouro-events.XXXXXX)
1603
+ chmod 0700 "$EVENT_ASSET_STAGE"
1604
+ EVENT_SCRIPT_STAGE="$EVENT_ASSET_STAGE/ouro-events"
1605
+ mkdir "$EVENT_SCRIPT_STAGE"
1606
+ STAGED_TEMPLATE="$EVENT_ASSET_STAGE/sanctuary.xml"
1607
+ STAGED_RUNTIME_POLICY="$EVENT_ASSET_STAGE/container-runtime.json"
1608
+ EVENT_ASSET_CONTAINER=
1609
+ cleanup_event_asset_stage() {
1610
+ if test -n "$EVENT_ASSET_CONTAINER"; then
1611
+ docker rm --force "$EVENT_ASSET_CONTAINER" >/dev/null 2>&1 || true
1612
+ fi
1613
+ rm -rf -- "$EVENT_ASSET_STAGE"
1614
+ }
1615
+ trap cleanup_event_asset_stage EXIT
1616
+ EVENT_ASSET_CONTAINER=$(docker create --pull=never --network none --read-only --entrypoint /bin/false "$IMAGE_ID")
1617
+ test "$(docker inspect --format '{{.Image}}' "$EVENT_ASSET_CONTAINER")" = "$IMAGE_ID"
1618
+ docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/ouro-events/." "$EVENT_SCRIPT_STAGE/"
1619
+ docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/sanctuary.xml" "$STAGED_TEMPLATE"
1620
+ docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/container-runtime.json" "$STAGED_RUNTIME_POLICY"
1621
+ docker rm "$EVENT_ASSET_CONTAINER"
1622
+ EVENT_ASSET_CONTAINER=
1623
+ EXPECTED_RELEASE_ASSETS=$(printf '%s\n' container-runtime.json ouro-events sanctuary.xml)
1624
+ ACTUAL_RELEASE_ASSETS=$(find "$EVENT_ASSET_STAGE" -mindepth 1 -maxdepth 1 -exec basename {} \; | LC_ALL=C sort)
1625
+ test "$ACTUAL_RELEASE_ASSETS" = "$EXPECTED_RELEASE_ASSETS"
1626
+ test -d "$EVENT_SCRIPT_STAGE" && test ! -L "$EVENT_SCRIPT_STAGE"
1627
+ test -f "$STAGED_TEMPLATE" && test ! -L "$STAGED_TEMPLATE"
1628
+ test -f "$STAGED_RUNTIME_POLICY" && test ! -L "$STAGED_RUNTIME_POLICY"
1629
+ EXPECTED_EVENT_ASSETS=$(printf '%s\n' bootstrap-spool.sh emit-event.mjs emit-usenet-event.sh install-usenet-guard.sh usenet-health.sh)
1630
+ ACTUAL_EVENT_ASSETS=$(find "$EVENT_SCRIPT_STAGE" -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | LC_ALL=C sort)
1631
+ test "$ACTUAL_EVENT_ASSETS" = "$EXPECTED_EVENT_ASSETS"
1632
+ test -z "$(find "$EVENT_SCRIPT_STAGE" -mindepth 1 -maxdepth 1 \( -type l -o ! -type f \) -print -quit)"
1633
+ /bin/bash "$EVENT_SCRIPT_STAGE/install-usenet-guard.sh" --source-root "$EVENT_SCRIPT_STAGE"
1634
+ /bin/bash /boot/config/custom/ouro-events/install-usenet-guard.sh --boot --install-root /boot/config/custom
1635
+ verify_installed_usenet_guard() {
1636
+ test "$(findmnt -n -o FSTYPE --target /boot/config/custom)" = vfat
1637
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/usenet_health.sh)" = 0:0:600
1638
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/bootstrap-spool.sh)" = 0:0:600
1639
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/emit-event.mjs)" = 0:0:600
1640
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/emit-usenet-event.sh)" = 0:0:600
1641
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/install-usenet-guard.sh)" = 0:0:600
1642
+ cmp -s "$EVENT_SCRIPT_STAGE/usenet-health.sh" /boot/config/custom/usenet_health.sh
1643
+ cmp -s "$EVENT_SCRIPT_STAGE/bootstrap-spool.sh" /boot/config/custom/ouro-events/bootstrap-spool.sh
1644
+ cmp -s "$EVENT_SCRIPT_STAGE/emit-event.mjs" /boot/config/custom/ouro-events/emit-event.mjs
1645
+ cmp -s "$EVENT_SCRIPT_STAGE/emit-usenet-event.sh" /boot/config/custom/ouro-events/emit-usenet-event.sh
1646
+ cmp -s "$EVENT_SCRIPT_STAGE/install-usenet-guard.sh" /boot/config/custom/ouro-events/install-usenet-guard.sh
1647
+ test "$(grep -Fxc '/bin/bash /boot/config/custom/ouro-events/install-usenet-guard.sh --boot --install-root /boot/config/custom' /boot/config/go)" = 1
1648
+ test "$(grep -Fxc '/boot/config/custom/ouro-events/bootstrap-spool.sh --mount' /boot/config/go || true)" = 0
1649
+ 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
1650
+ INSTALLED_GUARD_CRON=$(crontab -l)
1651
+ test "$(printf '%s\n' "$INSTALLED_GUARD_CRON" | grep -Fxc '*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh # ouro:usenet-health')" = 1
1652
+ INSTALLED_LEGACY_GUARD_COUNT=$(printf '%s\n' "$INSTALLED_GUARD_CRON" | grep -Fxc '*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh' || true)
1653
+ test "$INSTALLED_LEGACY_GUARD_COUNT" = 0
1654
+ test "$(printf '%s\n' "$INSTALLED_GUARD_CRON" | grep -Fc '/bin/bash /boot/config/custom/usenet_health.sh')" = 1
1655
+ test "$(findmnt -n -o FSTYPE --target /boot/config/custom/ouro-events/spool)" = tmpfs
1656
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/spool)" = 0:0:755
1657
+ INSTALLED_SPOOL_OPTIONS=$(findmnt -n -o OPTIONS --target /boot/config/custom/ouro-events/spool)
1658
+ for INSTALLED_SPOOL_OPTION in nodev nosuid noexec; do
1659
+ case ",$INSTALLED_SPOOL_OPTIONS," in *",$INSTALLED_SPOOL_OPTION,"*) ;; *) return 1 ;; esac
1660
+ done
1661
+ INSTALLED_SPOOL_SIZE=$(findmnt -bn -o SIZE --target /boot/config/custom/ouro-events/spool)
1662
+ test "$INSTALLED_SPOOL_SIZE" -le 4194304
1663
+ }
1664
+ verify_installed_usenet_guard
1665
+ /usr/local/bin/node -e '
1666
+ const fs = require("node:fs");
1667
+ const [templatePath, imageId] = process.argv.slice(1);
1668
+ if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error("invalid exact image ID");
1669
+ const source = fs.readFileSync(templatePath, "utf8");
1670
+ const repositories = [...source.matchAll(/<Repository>[^<]*<\/Repository>/g)];
1671
+ if (repositories.length !== 1) throw new Error("template must contain exactly one Repository element");
1672
+ const staged = source.replace(repositories[0][0], `<Repository>${imageId}</Repository>`);
1673
+ if ((staged.match(/<Repository>[^<]*<\/Repository>/g) ?? []).length !== 1
1674
+ || !staged.includes(`<Repository>${imageId}</Repository>`)) throw new Error("exact Repository staging failed");
1675
+ fs.writeFileSync(templatePath, staged);
1676
+ ' "$STAGED_TEMPLATE" "$IMAGE_ID"
1677
+ If extraction, transactional installation, boot activation, or verification fails, abort the update before any Butler mutation. The installer restores the previous assets, go file, and cron on transactional failure; production stays running. If boot activation or verification fails after the transaction commits, leave production untouched, repair or rerun this exact-image installation, and do not continue. Container rollback begins only after the later production stop succeeds.
1678
+ Initial install/adoption is a separate terminal path for the verified live
1679
+ legacy state: no production or rollback, exactly one running (possibly
1680
+ unhealthy) ouro-butler-staging, and no legacy-evidence container. After the
1681
+ helper definitions above are loaded and IMAGE_ID is resolved, run this exact
1682
+ sequence. MiniMax credentials are imported from the byte-verified legacy
1683
+ bootstrap envelope; do not place credentials in arguments, shell variables, or
1684
+ history.
1685
+ These provider-readiness commands are adoption-only; do not use them as a
1686
+ normal-update precheck against an active canonical production daemon.
1687
+ Sanctuary legacy adoption commands:
1688
+ prepare_sanctuary_legacy_adoption "$IMAGE_ID"
1689
+ verify_sanctuary_provider_readiness "$IMAGE_ID"
1690
+ install_from_legacy_staging
1691
+ These commands are one terminal path; after install succeeds, stop and do not
1692
+ continue into the normal update below. The final install is noninteractive:
1693
+ it reruns resumable preparation plus fresh readiness, but never authentication.
1694
+ That function never applies the canonical auditor to the known-noncanonical
1695
+ legacy container or restarts it. It verifies and pins the legacy image,
1696
+ provisions the absent canonical roots from the exact target image's packaged
1697
+ Sanctuary skeleton, proves required files plus exact ownership/modes, and runs
1698
+ same-image vault bootstrap and provider readiness before durably snapshotting
1699
+ both exact container and image inspect records. Any preparation, vault, or
1700
+ readiness failure returns while legacy remains running with autostart
1701
+ untouched. It then stops and rechecks the legacy container and renames it to
1702
+ stopped ouro-butler-legacy-evidence. A fresh canonical staging container is
1703
+ created from exact IMAGE_ID and canonical binds, audited, started, proven to
1704
+ be the only running Butler, readiness-checked, then stopped and removed only
1705
+ after its image is rechecked. Only then is fresh canonical production created,
1706
+ audited, started, readiness-checked, and placed in bare production autostart.
1707
+ Failure removes only partial target-image staging/production containers,
1708
+ quarantines the exact legacy container without deletion or restart, leaves
1709
+ autostart disabled, and propagates the original status.
1710
+ For this cutover, preflight either the exact known alpha.742 two-mount source
1711
+ or an already-canonical source before any autostart or container mutation.
1712
+ Production must be the only running Butler poller;
1713
+ staging must be absent; rollback may be absent or one stopped container with
1714
+ the exact production image. A stopped legacy-evidence container is preserved.
1715
+ Disable every Butler name in Unraid's array-autostart file and verify that
1716
+ result before stopping production. First resolve and validate the exact image
1717
+ ID of the known-good production container while it is still running, so a
1718
+ lookup failure cannot strand a renamed container:
1719
+ recover_pending_sanctuary_bundle_migration "$IMAGE_ID"
1720
+ /bin/bash /boot/config/custom/ouro-events/bootstrap-spool.sh --mount
1721
+ test "$(findmnt -n -o FSTYPE --target /boot/config/custom/ouro-events/spool)" = tmpfs
1722
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/spool)" = 0:0:755
1723
+ ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
1724
+ validate_exact_image_id "$ROLLBACK_IMAGE_ID"
1725
+ if assert_update_topology "$ROLLBACK_IMAGE_ID"; then
1726
+ :
1727
+ else
1728
+ UPDATE_PREFLIGHT_STATUS=$?
1729
+ (exit "$UPDATE_PREFLIGHT_STATUS")
1730
+ fi
1731
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1732
+ The earlier exact-image asset stage has already replaced the template's sole
1733
+ Repository element with $IMAGE_ID. After the topology and source gates pass,
1734
+ audit the staged template and runtime policy with the packaged auditor, then
1735
+ remove the private stage before any Butler mutation:
1736
+ docker run --rm --pull=never --network=none \
1737
+ --user 0:0 --read-only --cap-drop=ALL --security-opt=no-new-privileges \
1738
+ --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh \
1739
+ --mount "type=bind,src=$STAGED_TEMPLATE,dst=/audit/sanctuary.xml,readonly" \
1740
+ --mount "type=bind,src=$STAGED_RUNTIME_POLICY,dst=/audit/container-runtime.json,readonly" \
1741
+ "$IMAGE_ID" --template /audit/sanctuary.xml --runtime-policy /audit/container-runtime.json --expected-image "$IMAGE_ID"
1742
+ cleanup_event_asset_stage
1743
+ trap - EXIT
1744
+ if provision_sanctuary_sab_credential "$IMAGE_ID" \
1745
+ && verify_sanctuary_sab_readiness "$IMAGE_ID" \
1746
+ && verify_sanctuary_telegram_readiness "$IMAGE_ID"; then
1747
+ :
1748
+ else
1749
+ PRECUTOVER_READINESS_STATUS=$?
1750
+ (exit "$PRECUTOVER_READINESS_STATUS")
1751
+ fi
1752
+ Guard the atomic autostart disable separately. If it fails, production has not
1753
+ been touched and the captured status is propagated:
1754
+ if disable_butler_autostart; then
1755
+ :
1756
+ else
1757
+ AUTOSTART_DISABLE_STATUS=$?
1758
+ (exit "$AUTOSTART_DISABLE_STATUS")
1759
+ fi
1760
+ Define the stale-rollback cleanup used by the preparation guard:
1761
+ remove_stopped_rollback_if_present() {
1762
+ EXPECTED_STALE_ROLLBACK_IMAGE_ID=$1
1763
+ ROLLBACK_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
1764
+ case "
1765
+ $ROLLBACK_CONTAINER_NAMES
1766
+ " in
1767
+ *"
1768
+ ouro-butler-rollback
1769
+ "*)
1770
+ ROLLBACK_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback) || return $?
1771
+ test "$ROLLBACK_RUNNING" = false || return $?
1772
+ CURRENT_STALE_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
1773
+ test "$CURRENT_STALE_ROLLBACK_IMAGE_ID" = "$EXPECTED_STALE_ROLLBACK_IMAGE_ID" || return $?
1774
+ docker rm ouro-butler-rollback || return $?
1775
+ ;;
1776
+ esac
1777
+ }
1778
+ Stop production, remove only a stopped stale rollback, rename the known-good
1779
+ container, verify it remains stopped, and apply the exact target image's
1780
+ package-managed bundle migration in one explicit preparation guard.
1781
+ Package-managed files are exactly `provider-readiness.json`,
1782
+ `tool-profiles.json`, `habits/sanctuary-health.md`, and the five canonical
1783
+ files under `psyche/`. The migration also merges the three bundle-meta version
1784
+ fields. It never installs or mutates steward policy authority. It preserves
1785
+ agent.json, all steward policy and audit bytes, relationships, sessions, and
1786
+ every other state path. Repeating it is a no-op.
1787
+ Before its first managed write, the migrator atomically fsyncs one mode-0600
1788
+ rollback record at `.sanctuary-package-managed-rollback.json`. That transient
1789
+ record contains a verified SHA-256 digest over the exact prior bytes, modes,
1790
+ and parent existence for those
1791
+ files plus bundle-meta, bound to distinct exact rollback and target image IDs.
1792
+ It is the migration transaction record, not a second bundle authority. Normal
1793
+ managed-file failure restores it immediately; every
1794
+ later container rollback restores it before the old container restarts, while
1795
+ successful production readiness and autostart commit it. A killed updater is
1796
+ reconciled from the same record before update topology preflight: partial new
1797
+ containers are removed, interrupted atomic-write stages are discarded, the
1798
+ exact prior bundle is restored idempotently, and only the image ID recorded in
1799
+ the transaction may resume as production. A malformed record, wrong image,
1800
+ or restore failure leaves autostart disabled and fails closed. Once target
1801
+ readiness and autostart have passed, commit first durably renames the record;
1802
+ a kill then keeps target production in place and the next run validates its
1803
+ exact topology/readiness before finishing commit. Migration failure then enters the same exact container
1804
+ rollback arm before target production starts:
1805
+ if docker stop ouro-butler \
1806
+ && remove_stopped_rollback_if_present "$ROLLBACK_IMAGE_ID" \
1807
+ && docker rename ouro-butler ouro-butler-rollback \
1808
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false \
1809
+ && assert_only_running_butler - \
1810
+ && migrate_sanctuary_package_managed_bundle "$IMAGE_ID" migrate "$ROLLBACK_IMAGE_ID"; then
1811
+ :
1812
+ else
1813
+ PRODUCTION_PREPARATION_STATUS=$?
1814
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
1815
+ if docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
1816
+ docker stop ouro-butler-rollback >/dev/null 2>&1 || true
1817
+ CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback)
1818
+ test "$CURRENT_ROLLBACK_IMAGE_ID" = "$ROLLBACK_IMAGE_ID"
1819
+ docker rm --force ouro-butler-rollback >/dev/null 2>&1 || true
1820
+ fi
1821
+ ! docker container inspect ouro-butler-rollback >/dev/null 2>&1
1822
+ rollback_sanctuary_bundle_if_pending "$IMAGE_ID"
1823
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1824
+ docker start ouro-butler
1825
+ assert_only_running_butler ouro-butler
1826
+ wait_butler_ready ouro-butler
1827
+ enable_butler_autostart
1828
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
1829
+ elif docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
1830
+ docker stop ouro-butler-rollback >/dev/null 2>&1 || true
1831
+ test "$(docker inspect --format '{{.Image}}' ouro-butler-rollback)" = "$ROLLBACK_IMAGE_ID"
1832
+ rollback_sanctuary_bundle_if_pending "$IMAGE_ID"
1833
+ docker rename ouro-butler-rollback ouro-butler
1834
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1835
+ docker start ouro-butler
1836
+ assert_only_running_butler ouro-butler
1837
+ wait_butler_ready ouro-butler
1838
+ enable_butler_autostart
1839
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
1840
+ fi
1841
+ (exit "$PRODUCTION_PREPARATION_STATUS")
1842
+ fi
1843
+ Preparation failure therefore either restores the still-named exact production
1844
+ after removing any stale rollback, or renames the exact stopped rollback back;
1845
+ both recoveries revalidate, start, bounded-wait, atomically restore production-only
1846
+ autostart, and propagate the original failure. If neither exact container can
1847
+ be found, the failure propagates with Butler autostart disabled.
1848
+ Do not start a target-image daemon between the production rename and final
1849
+ production activation. The exact-image static audit, SAB readiness, and
1850
+ vault-backed Telegram identity check have already passed before autostart or
1851
+ container mutation. Provider and complete daemon readiness are exercised only
1852
+ by the transactional production activation below, whose failure arm restores
1853
+ and revalidates the exact prior production. This prevents a disposable daemon
1854
+ from reconciling or claiming live external-event state before cutover.
1855
+ Create and activate production from the same exact image ID and exact authority
1856
+ in one explicit conditional so `set -eu` cannot exit before rollback. Only a
1857
+ successful create, effective audit, start, stopped rollback assertion, and
1858
+ bounded readiness wait may enable production autostart. Any failure captures
1859
+ the activation status, safely removes a partially created new production if it
1860
+ exists, restores the stopped rollback container, audits it against its exact
1861
+ old image ID, proves it ready, atomically enables only production autostart,
1862
+ and then propagates the original activation failure:
1863
+ if docker create --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
1864
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
1865
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1866
+ --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
1867
+ "$IMAGE_ID" \
1868
+ && audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" \
1869
+ && docker start ouro-butler \
1870
+ && assert_only_running_butler ouro-butler \
1871
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false \
1872
+ && wait_butler_ready ouro-butler \
1873
+ && enable_butler_autostart; then
1874
+ :
1875
+ else
1876
+ PRODUCTION_ACTIVATION_STATUS=$?
1877
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
1878
+ docker stop ouro-butler >/dev/null 2>&1 || true
1879
+ PARTIAL_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
1880
+ test "$PARTIAL_PRODUCTION_IMAGE_ID" = "$IMAGE_ID"
1881
+ docker rm --force ouro-butler >/dev/null 2>&1 || true
1882
+ fi
1883
+ ! docker container inspect ouro-butler >/dev/null 2>&1
1884
+ CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback)
1885
+ test "$CURRENT_ROLLBACK_IMAGE_ID" = "$ROLLBACK_IMAGE_ID"
1886
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" rollback
1887
+ docker rename ouro-butler-rollback ouro-butler
1888
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1889
+ docker start ouro-butler
1890
+ assert_only_running_butler ouro-butler
1891
+ wait_butler_ready ouro-butler
1892
+ enable_butler_autostart
1893
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
1894
+ (exit "$PRODUCTION_ACTIVATION_STATUS")
1895
+ fi
1896
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
1897
+ Keep ouro-butler-rollback stopped until the new production container is proven
1898
+ or the explicit rollback arm restores it.
1899
+ Never create a container from the mutable lookup tag.
1900
+
1901
+ Backup:
1902
+ Set BACKUP_ROOT to a new absolute snapshot path on the destination filesystem.
1903
+ Set AUDIT_RUNNER_IMAGE_TAG to the reviewed new image containing the
1904
+ legacy-aware auditor; the alpha.742 source image cannot be the runner.
1905
+ Capture and validate the exact source image before stopping ouro-butler, then
1906
+ build a temporary snapshot with root-only provenance and atomically rename it into place:
1907
+ test -n "${BACKUP_ROOT-}"
1908
+ case "$BACKUP_ROOT" in /*) ;; *) exit 1 ;; esac
1909
+ test "$BACKUP_ROOT" != /
1910
+ test ! -e "$BACKUP_ROOT"
1911
+ BACKUP_PARENT=$(dirname -- "$BACKUP_ROOT")
1912
+ test "$(cd -- "$BACKUP_PARENT" && pwd -P)" = "$BACKUP_PARENT"
1913
+ BACKUP_TMP=$BACKUP_ROOT.tmp.$$
1914
+ test ! -e "$BACKUP_TMP"
1915
+ AUDIT_RUNNER_IMAGE_TAG=ouro-butler:<new-version>
1916
+ AUDIT_RUNNER_IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$AUDIT_RUNNER_IMAGE_TAG")
1917
+ validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID"
1918
+ test "$AUDIT_RUNNER_IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d
1919
+ BACKUP_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
1920
+ validate_exact_image_id "$BACKUP_IMAGE_ID"
1921
+ assert_update_source "$BACKUP_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1922
+ assert_only_running_butler ouro-butler
1923
+ wait_butler_ready ouro-butler
1924
+ BACKUP_AUTOSTART_COUNTS=$(butler_autostart_counts)
1925
+ case "$BACKUP_AUTOSTART_COUNTS" in "0 0 0 0"|"1 0 0 0") ;; *) exit 1 ;; esac
1926
+ backup_host_file() {
1927
+ BACKUP_HOST_SOURCE=$1
1928
+ BACKUP_HOST_RELATIVE=$2
1929
+ case "$BACKUP_HOST_RELATIVE" in
1930
+ 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) ;;
1931
+ *) return 1 ;;
1932
+ esac
1933
+ if test -e "$BACKUP_HOST_SOURCE" || test -L "$BACKUP_HOST_SOURCE"; then
1934
+ test -f "$BACKUP_HOST_SOURCE" && test ! -L "$BACKUP_HOST_SOURCE" || return 1
1935
+ ! host_file_contains_inline_credential "$BACKUP_HOST_SOURCE" || return 1
1936
+ install -D -m 0600 -o 0 -g 0 "$BACKUP_HOST_SOURCE" "$BACKUP_TMP/host/$BACKUP_HOST_RELATIVE" || return $?
1937
+ printf 'present\t%s\n' "$BACKUP_HOST_RELATIVE" >>"$BACKUP_TMP/host/inventory" || return $?
1938
+ else
1939
+ printf 'absent\t%s\n' "$BACKUP_HOST_RELATIVE" >>"$BACKUP_TMP/host/inventory" || return $?
1940
+ fi
1941
+ }
1942
+ host_file_contains_inline_credential() {
1943
+ LC_ALL=C awk '
1944
+ BEGIN { IGNORECASE = 1; found = 0 }
1945
+ /[0-9]{6,}:[A-Za-z0-9_-]{20,}/ { found = 1 }
1946
+ /Authorization:[[:space:]]*(Bearer|Basic)[[:space:]]+["\047]?[A-Za-z0-9][A-Za-z0-9._~+\/=:-]{11,}/ { found = 1 }
1947
+ /--(token|api-key|api_key|secret|password)(=|[[:space:]]+)["\047]?[A-Za-z0-9][A-Za-z0-9._~+\/=:-]{11,}/ { found = 1 }
1948
+ /[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\/@[:space:]]+:[^\/@[:space:]]+@/ { found = 1 }
1949
+ /-----BEGIN ([A-Z0-9]+ )*PRIVATE KEY-----/ { found = 1 }
1950
+ /(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 }
1951
+ END { exit found ? 0 : 1 }
1952
+ ' "$1"
1953
+ }
1954
+ snapshot_butler_go_fragments() {
1955
+ BACKUP_GO_SOURCE=$1
1956
+ BACKUP_GO_TARGET=$2
1957
+ : >"$BACKUP_GO_TARGET" || return $?
1958
+ while IFS= read -r BACKUP_GO_LINE || test -n "$BACKUP_GO_LINE"; do
1959
+ case "$BACKUP_GO_LINE" in
1960
+ "/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")
1961
+ printf '%s\n' "$BACKUP_GO_LINE" >>"$BACKUP_GO_TARGET" || return $?
1962
+ ;;
1963
+ esac
1964
+ done <"$BACKUP_GO_SOURCE"
1965
+ }
1966
+ snapshot_butler_cron_fragments() {
1967
+ BACKUP_CRON_INPUT=$1
1968
+ BACKUP_CRON_TARGET=$2
1969
+ : >"$BACKUP_CRON_TARGET" || return $?
1970
+ while IFS= read -r BACKUP_CRON_LINE || test -n "$BACKUP_CRON_LINE"; do
1971
+ case "$BACKUP_CRON_LINE" in
1972
+ "*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh"|"*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh # ouro:usenet-health")
1973
+ printf '%s\n' "$BACKUP_CRON_LINE" >>"$BACKUP_CRON_TARGET" || return $?
1974
+ ;;
1975
+ esac
1976
+ done <"$BACKUP_CRON_INPUT"
1977
+ }
1978
+ create_private_cron_capture() {
1979
+ BACKUP_CRON_ROOT=$(mktemp -d /tmp/ouro-backup-cron.XXXXXX) || return $?
1980
+ BACKUP_CRON_SOURCE=$BACKUP_CRON_ROOT/stdout
1981
+ BACKUP_CRON_ERROR=$BACKUP_CRON_ROOT/stderr
1982
+ test -d "$BACKUP_CRON_ROOT" && test ! -L "$BACKUP_CRON_ROOT" || return 1
1983
+ test "$(stat -c '%u:%a' "$BACKUP_CRON_ROOT" 2>/dev/null || stat -f '%u:%Lp' "$BACKUP_CRON_ROOT")" = "$(id -u):700" || return 1
1984
+ install -m 0600 /dev/null "$BACKUP_CRON_SOURCE" || return $?
1985
+ install -m 0600 /dev/null "$BACKUP_CRON_ERROR" || return $?
1986
+ test -f "$BACKUP_CRON_SOURCE" && test ! -L "$BACKUP_CRON_SOURCE" || return 1
1987
+ test -f "$BACKUP_CRON_ERROR" && test ! -L "$BACKUP_CRON_ERROR" || return 1
1988
+ test "$(stat -c '%u:%a' "$BACKUP_CRON_SOURCE" 2>/dev/null || stat -f '%u:%Lp' "$BACKUP_CRON_SOURCE")" = "$(id -u):600" || return 1
1989
+ test "$(stat -c '%u:%a' "$BACKUP_CRON_ERROR" 2>/dev/null || stat -f '%u:%Lp' "$BACKUP_CRON_ERROR")" = "$(id -u):600" || return 1
1990
+ }
1991
+ cleanup_private_cron_capture() {
1992
+ rm -f -- "$BACKUP_CRON_SOURCE" "$BACKUP_CRON_ERROR" || return $?
1993
+ rmdir -- "$BACKUP_CRON_ROOT"
1994
+ }
1995
+ snapshot_butler_host_fragments() {
1996
+ create_private_cron_capture || return $?
1997
+ trap 'cleanup_private_cron_capture' RETURN
1998
+ if test -f /boot/config/go && test ! -L /boot/config/go; then
1999
+ BACKUP_GO_STATE=present
2000
+ BACKUP_GO_DIGEST=$(sha256sum /boot/config/go | awk '{print $1}') || return $?
2001
+ snapshot_butler_go_fragments /boot/config/go "$BACKUP_TMP/host/go.butler-lines" || return $?
2002
+ elif test -e /boot/config/go || test -L /boot/config/go; then
2003
+ return 1
2004
+ else
2005
+ BACKUP_GO_STATE=absent
2006
+ BACKUP_GO_DIGEST=-
2007
+ : >"$BACKUP_TMP/host/go.butler-lines" || return $?
2008
+ fi
2009
+ BACKUP_CRON_USER=$(id -un) || return $?
2010
+ if crontab -l >"$BACKUP_CRON_SOURCE" 2>"$BACKUP_CRON_ERROR"; then
2011
+ BACKUP_CRON_STATE=present
2012
+ BACKUP_CRON_DIGEST=$(sha256sum "$BACKUP_CRON_SOURCE" | awk '{print $1}') || return $?
2013
+ else
2014
+ BACKUP_CRON_STATUS=$?
2015
+ if test "$BACKUP_CRON_STATUS" -eq 1 && grep -Fxq "no crontab for $BACKUP_CRON_USER" "$BACKUP_CRON_ERROR"; then
2016
+ BACKUP_CRON_STATE=absent
2017
+ BACKUP_CRON_DIGEST=-
2018
+ : >"$BACKUP_CRON_SOURCE" || return $?
2019
+ else
2020
+ return "$BACKUP_CRON_STATUS"
2021
+ fi
2022
+ fi
2023
+ rm -f -- "$BACKUP_CRON_ERROR" || return $?
2024
+ snapshot_butler_cron_fragments "$BACKUP_CRON_SOURCE" "$BACKUP_TMP/host/crontab.butler-lines" || return $?
2025
+ BACKUP_GO_COUNT=$(awk 'END { print NR + 0 }' "$BACKUP_TMP/host/go.butler-lines") || return $?
2026
+ BACKUP_CRON_COUNT=$(awk 'END { print NR + 0 }' "$BACKUP_TMP/host/crontab.butler-lines") || return $?
2027
+ 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 $?
2028
+ chown 0:0 "$BACKUP_TMP/host/go.butler-lines" "$BACKUP_TMP/host/crontab.butler-lines" "$BACKUP_TMP/host/global-state" || return $?
2029
+ chmod 0600 "$BACKUP_TMP/host/go.butler-lines" "$BACKUP_TMP/host/crontab.butler-lines" "$BACKUP_TMP/host/global-state" || return $?
2030
+ printf 'present\t%s\n' go.butler-lines crontab.butler-lines global-state >>"$BACKUP_TMP/host/inventory" || return $?
2031
+ cleanup_private_cron_capture || return $?
2032
+ trap - RETURN
2033
+ }
2034
+ Snapshot both of these directories together:
2035
+ /mnt/user/appdata/ouro-butler/runtime/.ouro-cli
2036
+ /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
2037
+ A routine backup must not contain container-credentials.json or
2038
+ container-credentials.json.consuming. If either exists, credential migration
2039
+ is pending or failed and must be reconciled before taking the backup.
2040
+ The Telegram control socket is live process state, not durable data. Exclude
2041
+ exactly that socket while preserving every regular file byte-for-byte, then
2042
+ require it to be absent from the stopped backup:
2043
+ BACKUP_OPERATION_STATUS=0
2044
+ if docker stop ouro-butler \
2045
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler)" = false \
2046
+ && install -d -m 0700 -o 0 -g 0 "$BACKUP_TMP" "$BACKUP_TMP/runtime" "$BACKUP_TMP/agent" "$BACKUP_TMP/host" "$BACKUP_TMP/provenance" \
2047
+ && install -d -m 0700 -o 0 -g 0 "$BACKUP_TMP/host/custom" "$BACKUP_TMP/host/custom/ouro-events" \
2048
+ && install -m 0600 -o 0 -g 0 /dev/null "$BACKUP_TMP/host/inventory" \
2049
+ && backup_host_file /boot/config/custom/usenet_health.sh custom/usenet_health.sh \
2050
+ && backup_host_file /boot/config/custom/ouro-events/bootstrap-spool.sh custom/ouro-events/bootstrap-spool.sh \
2051
+ && backup_host_file /boot/config/custom/ouro-events/emit-event.mjs custom/ouro-events/emit-event.mjs \
2052
+ && backup_host_file /boot/config/custom/ouro-events/emit-usenet-event.sh custom/ouro-events/emit-usenet-event.sh \
2053
+ && backup_host_file /boot/config/custom/ouro-events/install-usenet-guard.sh custom/ouro-events/install-usenet-guard.sh \
2054
+ && snapshot_butler_host_fragments \
2055
+ && test ! -e "$BACKUP_TMP/host/notify.conf" \
2056
+ && test ! -e "$BACKUP_TMP/host/sabnzbd.ini" \
2057
+ && rsync -a --exclude='/state/acceptance/telegram-control.sock' /mnt/user/appdata/ouro-butler/runtime/.ouro-cli "$BACKUP_TMP/runtime/" \
2058
+ && rsync -a --exclude='/state/acceptance/telegram-control.sock' /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro "$BACKUP_TMP/agent/" \
2059
+ && test ! -S "$BACKUP_TMP/agent/sanctuary.ouro/state/acceptance/telegram-control.sock" \
2060
+ && validate_sanctuary_roots "$BACKUP_TMP/runtime/.ouro-cli" "$BACKUP_TMP/agent/sanctuary.ouro" \
2061
+ && docker container inspect ouro-butler >"$BACKUP_TMP/provenance/container-inspect.json" \
2062
+ && docker image inspect "$BACKUP_IMAGE_ID" >"$BACKUP_TMP/provenance/image-inspect.json" \
2063
+ && 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" \
2064
+ && printf '%s\n' "$BACKUP_IMAGE_ID" >"$BACKUP_TMP/provenance/image-id" \
2065
+ && 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" \
2066
+ && chmod 0600 "$BACKUP_TMP/provenance/container-inspect.json" "$BACKUP_TMP/provenance/image-inspect.json" "$BACKUP_TMP/provenance/package-version" "$BACKUP_TMP/provenance/image-id" \
2067
+ && (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) \
2068
+ && chown 0:0 "$BACKUP_TMP/provenance/manifest.sha256" \
2069
+ && chmod 0600 "$BACKUP_TMP/provenance/manifest.sha256" \
2070
+ && verify_sanctuary_snapshot_provenance "$BACKUP_TMP" "$BACKUP_IMAGE_ID" \
2071
+ && sync -f "$BACKUP_TMP/provenance/manifest.sha256" \
2072
+ && mv -- "$BACKUP_TMP" "$BACKUP_ROOT" \
2073
+ && sync -f "$BACKUP_PARENT"; then
2074
+ :
2075
+ else
2076
+ BACKUP_OPERATION_STATUS=$?
2077
+ fi
2078
+ if assert_update_source "$BACKUP_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" \
2079
+ && docker start ouro-butler \
2080
+ && assert_only_running_butler ouro-butler \
2081
+ && wait_butler_ready ouro-butler \
2082
+ && verify_butler_autostart "$BACKUP_AUTOSTART_COUNTS"; then
2083
+ :
2084
+ else
2085
+ BACKUP_RECOVERY_STATUS=$?
2086
+ if test -d "$BACKUP_ROOT"; then
2087
+ printf '%s\n' "CRITICAL: production recovery failed after backup; completed snapshot remains intact at $BACKUP_ROOT" >&2
2088
+ else
2089
+ printf '%s\n' "CRITICAL: production recovery failed and no completed snapshot was published; inspect $BACKUP_TMP" >&2
2090
+ fi
2091
+ (exit "$BACKUP_RECOVERY_STATUS")
2092
+ fi
2093
+ if test "$BACKUP_OPERATION_STATUS" -ne 0; then
2094
+ if test -d "$BACKUP_ROOT"; then
2095
+ printf '%s\n' "Backup publication was not durably confirmed; production was recovered; completed snapshot remains intact at $BACKUP_ROOT" >&2
2096
+ else
2097
+ printf '%s\n' "Backup failed before atomic publication; production was recovered; inspect $BACKUP_TMP" >&2
2098
+ fi
2099
+ (exit "$BACKUP_OPERATION_STATUS")
2100
+ fi
2101
+
2102
+ Restore:
2103
+ Set BACKUP_ROOT to the exact verified snapshot containing `runtime/.ouro-cli`
2104
+ and `agent/sanctuary.ouro`, set IMAGE_ID to its recorded local image ID, and
2105
+ set AUDIT_RUNNER_IMAGE_ID to the exact reviewed new image containing the
2106
+ legacy-aware auditor. The runner is independent from the restored image.
2107
+ AUDIT_RUNNER_IMAGE_TAG=ouro-butler:<new-version>
2108
+ AUDIT_RUNNER_IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$AUDIT_RUNNER_IMAGE_TAG")
2109
+ Before any autostart, root, or container mutation, run the nounset-safe input,
2110
+ backup-root, image, and topology preflight. It requires a nonempty canonical
2111
+ absolute BACKUP_ROOT (not /), both exact required directories, an exact local
2112
+ sha256 image ID, canonical production as the only running Butler poller, no
2113
+ staging or rollback, and at most one exact stopped legacy-evidence container.
2114
+ It also executes the reviewed runner against the live source container before
2115
+ any autostart, root, or container mutation:
2116
+ if assert_restore_preflight; then
2117
+ :
2118
+ else
2119
+ RESTORE_PREFLIGHT_STATUS=$?
2120
+ (exit "$RESTORE_PREFLIGHT_STATUS")
2121
+ fi
2122
+ The verified host snapshot is restored only by this explicit Restore path.
2123
+ Ordinary update failure continues to use the installer's short-lived
2124
+ transaction rollback. Extract the installer from the reviewed runner image,
2125
+ then let that fixed allowlist transaction restore exact presence/absence for
2126
+ the five guard assets and reapply only the captured Butler-owned go/crontab
2127
+ fragments. Unrelated current go/crontab lines are never stored in the durable
2128
+ snapshot and remain untouched by restore. The transaction deactivates the
2129
+ current owned cron before swapping files and restores the entire pre-restore
2130
+ host state if any swap or final crontab activation fails:
2131
+ HOST_RESTORE_INSTALLER=$(mktemp /tmp/ouro-usenet-host-restore.XXXXXX)
2132
+ 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"
2133
+ chmod 0500 "$HOST_RESTORE_INSTALLER"
2134
+ Guard the atomic autostart disable before stopping or removing any Butler
2135
+ container. Failure propagates while the existing production remains untouched:
2136
+ /bin/bash /boot/config/custom/ouro-events/bootstrap-spool.sh --mount
2137
+ test "$(findmnt -n -o FSTYPE --target /boot/config/custom/ouro-events/spool)" = tmpfs
2138
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/spool)" = 0:0:755
2139
+ if disable_butler_autostart; then
2140
+ :
2141
+ else
2142
+ RESTORE_AUTOSTART_DISABLE_STATUS=$?
2143
+ (exit "$RESTORE_AUTOSTART_DISABLE_STATUS")
2144
+ fi
2145
+ Stop and remove only ouro-butler, restore both roots without stale files,
2146
+ restore exact ownership, validate the image, and create/audit/start/bounded-wait
2147
+ the restored production inside one explicit condition. The initial tolerant
2148
+ stop plus inspected force-removal safely handles an already absent or stopped
2149
+ container:
2150
+ if { docker stop ouro-butler >/dev/null 2>&1 || true; } \
2151
+ && {
2152
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
2153
+ docker rm --force ouro-butler
2154
+ else
2155
+ :
2156
+ fi
2157
+ } \
2158
+ && ! docker container inspect ouro-butler >/dev/null 2>&1 \
2159
+ && rsync -a --delete "$BACKUP_ROOT/runtime/.ouro-cli/" /mnt/user/appdata/ouro-butler/runtime/.ouro-cli/ \
2160
+ && rsync -a --delete "$BACKUP_ROOT/agent/sanctuary.ouro/" /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/ \
2161
+ && chown -R 10001:10001 /mnt/user/appdata/ouro-butler/runtime/.ouro-cli /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro \
2162
+ && validate_sanctuary_roots /mnt/user/appdata/ouro-butler/runtime/.ouro-cli /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro \
2163
+ && /bin/bash "$HOST_RESTORE_INSTALLER" --restore-root "$BACKUP_ROOT/host" \
2164
+ && rm -f "$HOST_RESTORE_INSTALLER" \
2165
+ && docker image inspect "$IMAGE_ID" >/dev/null \
2166
+ && docker create --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
2167
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
2168
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
2169
+ --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
2170
+ "$IMAGE_ID" \
2171
+ && audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" \
2172
+ && docker start ouro-butler \
2173
+ && assert_only_running_butler ouro-butler \
2174
+ && wait_butler_ready ouro-butler \
2175
+ && enable_butler_autostart; then
2176
+ :
2177
+ else
2178
+ RESTORE_ACTIVATION_STATUS=$?
2179
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
2180
+ docker stop ouro-butler >/dev/null 2>&1 || true
2181
+ PARTIAL_RESTORE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
2182
+ test "$PARTIAL_RESTORE_IMAGE_ID" = "$IMAGE_ID"
2183
+ docker rm --force ouro-butler >/dev/null 2>&1 || true
2184
+ fi
2185
+ ! docker container inspect ouro-butler >/dev/null 2>&1
2186
+ rm -f "$HOST_RESTORE_INSTALLER"
2187
+ (exit "$RESTORE_ACTIVATION_STATUS")
2188
+ fi
2189
+ A restore has no retained old container or pre-restore data root to roll back
2190
+ to. Any stop/remove, data replacement, ownership, image validation, create,
2191
+ audit, start, or readiness failure therefore reaches the same cleanup arm,
2192
+ leaves all Butler autostart entries disabled, and propagates the captured
2193
+ failure for operator repair or a verified restore retry.
2194
+
2195
+ Credential recovery:
2196
+ The one canonical Telegram identity is display name `Mendelow Cloud Butler`,
2197
+ username `MendelowCloudButlerBot`, numeric bot ID `8541786263`. Treat any
2198
+ different display name, username, numeric ID, or fallback handle as a hard
2199
+ preflight failure; do not start polling or send a test message.
2200
+ Run `bootstrap_sanctuary_vault "$IMAGE_ID"` interactively before installing
2201
+ credentials. It uses only fresh `--rm` containers from that exact image as
2202
+ UID/GID 10001 with the canonical two binds; create/unlock is the sole `-it`
2203
+ action so the master secret remains on the hidden terminal prompt.
2204
+ Its first read-only `vault status` is authoritative: it runs `vault create`
2205
+ only when the locator is absent, runs `vault unlock` only when the locator
2206
+ exists but local unlock is missing, and does not prompt when local unlock is
2207
+ already available. It then requires the configured locator, available local
2208
+ unlock, and successful auth verification. Never guess from a failed status
2209
+ command.
2210
+ Initial legacy adoption passes the exact legacy envelope path as the helper's
2211
+ second argument. After vault availability, the helper validates and atomically
2212
+ copies that envelope into the canonical runtime root, imports it with a fresh
2213
+ same-image one-shot container, and requires both canonical source and claim to
2214
+ be absent before provider readiness. The exact legacy source remains untouched
2215
+ as rollback evidence until canonical acceptance completes.
2216
+ Restore or unlock the Sanctuary agent vault, then run provider refresh.
2217
+ /mnt/user/appdata/ouro-butler/runtime/.ouro-cli/container-credentials.json
2218
+ exists only for a one-time migration: if
2219
+ used, install it atomically as root with ownership 10001:10001 and mode 0600.
2220
+ Startup atomically claims it as container-credentials.json.consuming and
2221
+ writes every credential class into the unlocked Sanctuary vault before deleting the claimed envelope.
2222
+ Only then does it refresh the daemon's in-memory credentials.
2223
+ If validation or any vault write fails, startup fails closed and preserves
2224
+ container-credentials.json.consuming for the next startup to reconcile.
2225
+ Restore or create/unlock the configured agent vault, then restart the same
2226
+ reviewed image and let it retry the preserved claim. If both the source and
2227
+ claimed envelope exist and they are safe regular 0600 files owned by the
2228
+ runtime user, startup resumes automatically only when they are byte-for-byte identical:
2229
+ it durably removes the redundant unclaimed source and continues the existing claim.
2230
+ If they differ, the repair is human-required: stop the Butler, securely compare and quarantine
2231
+ the two envelopes, then restore exactly one reviewed envelope at the source path.
2232
+ Never print either envelope's contents or place them in logs or command arguments.
2233
+ After successful import, both files
2234
+ are absent and the vault is the only credential source of truth.
2235
+ `provision_sanctuary_sab_credential "$IMAGE_ID"` is the one-shot migration for
2236
+ the existing SAB key. Root reads the fixed host INI, writes only a mode-0600
2237
+ `ouro.runtimeCredentialBootstrap` envelope owned by 10001:10001, and a
2238
+ same-image one-shot container merges `sabnzbdApiKey` into
2239
+ `runtime/machines/sanctuary/config`; the key never appears in argv, the
2240
+ environment, logs, the Unraid template, or the Butler mounts. The bootstrap
2241
+ reconciler is idempotent for the same value and fails closed on a conflict.
2242
+ `verify_sanctuary_sab_readiness "$IMAGE_ID"` then reloads that machine item
2243
+ from the vault and performs a real bounded queue read before any poller
2244
+ cutover. The runtime and agent roots are persistent binds and package-managed
2245
+ updates do not replace the machine vault item, so the credential survives
2246
+ template and image updates.
2247
+ Bootstrap is not the rotation path. After SAB rotates its key, use the
2248
+ existing hidden-prompt merge command
2249
+ `ouro vault config set --agent sanctuary --key sabnzbdApiKey --scope machine`,
2250
+ restart the reviewed Butler container so its machine-config cache refreshes,
2251
+ and rerun `verify_sanctuary_sab_readiness "$IMAGE_ID"`; a conflicting
2252
+ bootstrap is expected to stop rather than overwrite canonical vault state.
2253
+ Never print or place credential values in logs, templates, command arguments,
2254
+ backups, or this runbook.
2255
+
2256
+ Packaged deployment-target containment gates:
2257
+ Unit 16 is fixed to the `staging` profile and therefore targets only
2258
+ `ouro-butler-staging`; it does not require `ouro-butler` to exist. Before the
2259
+ broker starts, the packaged deployment auditor captures all present canonical
2260
+ production/staging/rollback identities in one Docker inspect, verifies the
2261
+ target image, `unless-stopped`, host networking, matching GraphQL and durable
2262
+ file autostart identity/state for every present canonical container,
2263
+ and exactly one running Butler, then repeats the canonical snapshot to reject
2264
+ races. It binds the target PID and exact Docker cgroup-v2 path to the inspected
2265
+ container ID; Unit 16 carries that immutable ID into every launcher and broker
2266
+ Docker operation and binds it to the GraphQL PrefixedID suffix. It scans every
2267
+ cgroup process and requires stable before/after process, owned-socket, and
2268
+ listener inventories. Between the two canonical topology captures, it verifies
2269
+ the exact immutable-ID target is running and unpaused, pauses that ID without
2270
+ changing restart or autostart state, and performs every cgroup, all-thread
2271
+ descriptor, TCP, UDP, and Unix observation inside that one quiesced window,
2272
+ including two matching complete terminal snapshots; nonconvergence fails
2273
+ closed after eight attempts. It always makes bounded exact-ID unpause attempts,
2274
+ verifies the same ID and PID resumed their original running, unpaused state,
2275
+ and only then performs the final full-profile topology capture. It
2276
+ rejects every owned UDP listener and every externally reachable owned TCP listener. Only loopback
2277
+ Mailbox port 6876 plus stream-listening endpoints at the fixed daemon and
2278
+ acceptance Unix control paths are permitted; all other named Unix endpoint
2279
+ types (including datagrams) fail closed. Inherited descriptors for the same
2280
+ socket inode are deduplicated, while unknown loopback ports, wildcard/host-address
2281
+ listeners, process/socket/listener drift, or ambiguous ownership fail closed.
2282
+ Unit 18 uses the separately packaged fixed final command after activation.
2283
+ It accepts either the legacy-adoption topology with no canonical rollback or
2284
+ exactly one stopped, non-autostarted rollback; running/autostarted rollback,
2285
+ staging presence, and ambiguous canonical identities always fail closed:
2286
+ UNIT18_TARGET_TMP=$(mktemp /run/sanctuary-unit18-target-audit.sh.XXXXXX)
2287
+ /usr/bin/timeout -s KILL 20 /usr/bin/docker run --rm --pull=never --network none \
2288
+ --entrypoint /bin/cat "$IMAGE_ID" /opt/ouro/deploy/unraid/sanctuary-unit18-target-audit.sh >"$UNIT18_TARGET_TMP"
2289
+ chmod 0500 "$UNIT18_TARGET_TMP"
2290
+ "$UNIT18_TARGET_TMP" "$IMAGE_ID"
2291
+ rm -f -- "$UNIT18_TARGET_TMP"
2292
+ Neither packaged command accepts a container name or an open-ended profile.
2293
+
2294
+ Packaged Unit 16 acceptance execution:
2295
+ Use only the launcher packaged in the exact reviewed image. Install it without
2296
+ network access, then create the two private roots with the runtime UID/GID:
2297
+ UNIT16_ROOT=/mnt/user/appdata/ouro-butler/acceptance
2298
+ install -d -m 0700 -o 10001 -g 10001 "$UNIT16_ROOT/configs" "$UNIT16_ROOT/evidence"
2299
+ UNIT16_LAUNCHER_TMP=$(mktemp "$UNIT16_ROOT/sanctuary-unit16-run.sh.tmp.XXXXXX")
2300
+ /usr/bin/timeout -s KILL 20 /usr/bin/docker run --rm --pull=never --network none \
2301
+ --entrypoint /bin/cat "$IMAGE_ID" /opt/ouro/deploy/unraid/sanctuary-unit16-run.sh >"$UNIT16_LAUNCHER_TMP"
2302
+ install -m 0755 -o root -g root "$UNIT16_LAUNCHER_TMP" "$UNIT16_ROOT/sanctuary-unit16-run.sh"
2303
+ rm -f -- "$UNIT16_LAUNCHER_TMP"
2304
+ After final activation, create the disjoint final-profile roots and invoke the
2305
+ same immutable launcher with its only non-staging selector. The selector is a
2306
+ closed `staging|final` enum; it never accepts a container name, ID, or path:
2307
+ UNIT18_ROOT=/mnt/user/appdata/ouro-butler/acceptance/final
2308
+ install -d -m 0700 -o 10001 -g 10001 "$UNIT18_ROOT/configs" "$UNIT18_ROOT/evidence"
2309
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-snapshot
2310
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-snapshot evidence-snapshot.json
2311
+ Use `--profile final` on every final pre-reboot and post-reboot materialize/run
2312
+ command. Omitting it remains the byte-for-byte Unit 16 staging path. Never mix
2313
+ staging and final config/evidence roots or reuse a config across profiles.
2314
+ Never hand-author a Unit 16 config. Materialize it from the exact image, then
2315
+ execute it in the order below. Before every execution the launcher regenerates
2316
+ the config from the packaged fixed contract and requires byte-for-byte equality.
2317
+ 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.
2318
+ The cursor snapshot is deliberately materialized and executed twice around the
2319
+ live scenario. Telegram bootstrap refreshes the canonical agent vault
2320
+ `runtime/config` and keeps the bot token inside the consuming harness process.
2321
+ It never reads the retired container credential file or carries the token in a
2322
+ descriptor, argument, environment variable, shell variable, config, evidence,
2323
+ or output. Callback injection alone maps its reviewed saved callback-update
2324
+ JSON from host fd 3 through Docker stdin to in-container fd 3.
2325
+ Stage the reviewed callback JSON at the fixed path below in the root-owned
2326
+ tmpfs inbox, then use this single fail-closed helper. It opens the input once,
2327
+ validates the opened descriptor and its original path refer to the same
2328
+ root-owned mode-0600 regular file, and invokes the launcher only afterward.
2329
+ Success overwrites, fsyncs, and unlinks the raw input. Any failure allocates a
2330
+ collision-free quarantine file atomically, copies and fsyncs the input into it,
2331
+ locks it to mode 000, then overwrites and removes the tmpfs source;
2332
+ no raw callback update remains loose in `/root` or the inbox.
2333
+ UNIT16_CALLBACK_INBOX=/run/ouro-unit16-callback-input
2334
+ UNIT16_CALLBACK_FILE=$UNIT16_CALLBACK_INBOX/callback-update.json
2335
+ UNIT16_CALLBACK_QUARANTINE_ROOT=$UNIT16_ROOT/callback-quarantine
2336
+ install -d -m 0700 -o root -g root "$UNIT16_CALLBACK_INBOX" "$UNIT16_CALLBACK_QUARANTINE_ROOT"
2337
+ run_unit16_callback_inject() {
2338
+ (
2339
+ UNIT16_CALLBACK_VALIDATED=no
2340
+ UNIT16_CALLBACK_STATUS=1
2341
+ unit16_callback_cleanup() {
2342
+ UNIT16_CALLBACK_STATUS=$?
2343
+ trap - EXIT HUP INT TERM
2344
+ if test -e "$UNIT16_CALLBACK_FILE" || test -L "$UNIT16_CALLBACK_FILE"; then
2345
+ if test "$UNIT16_CALLBACK_STATUS" -eq 0 && test "$UNIT16_CALLBACK_VALIDATED" = yes; then
2346
+ if ! truncate -s 0 /proc/self/fd/3 || ! sync -f /proc/self/fd/3 \
2347
+ || ! rm -f -- "$UNIT16_CALLBACK_FILE" || ! sync -f "$UNIT16_CALLBACK_INBOX"; then
2348
+ UNIT16_CALLBACK_STATUS=1
2349
+ fi
2350
+ elif test -f "$UNIT16_CALLBACK_FILE" && test ! -L "$UNIT16_CALLBACK_FILE"; then
2351
+ UNIT16_CALLBACK_QUARANTINE_FILE=$(mktemp "$UNIT16_CALLBACK_QUARANTINE_ROOT/failed.XXXXXX") || UNIT16_CALLBACK_QUARANTINE_FILE=
2352
+ if test -n "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2353
+ && chmod 0600 "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2354
+ && cp -- "$UNIT16_CALLBACK_FILE" "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2355
+ && sync -f "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2356
+ && chmod 000 "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2357
+ && sync -f "$UNIT16_CALLBACK_QUARANTINE_ROOT"; then
2358
+ :
2359
+ else
2360
+ test -z "${UNIT16_CALLBACK_QUARANTINE_FILE-}" || rm -f -- "$UNIT16_CALLBACK_QUARANTINE_FILE"
2361
+ UNIT16_CALLBACK_STATUS=1
2362
+ fi
2363
+ if ! truncate -s 0 "$UNIT16_CALLBACK_FILE" || ! sync -f "$UNIT16_CALLBACK_FILE" \
2364
+ || ! rm -f -- "$UNIT16_CALLBACK_FILE" || ! sync -f "$UNIT16_CALLBACK_INBOX"; then
2365
+ UNIT16_CALLBACK_STATUS=1
2366
+ fi
2367
+ else
2368
+ rm -f -- "$UNIT16_CALLBACK_FILE" || UNIT16_CALLBACK_STATUS=1
2369
+ sync -f "$UNIT16_CALLBACK_INBOX" || UNIT16_CALLBACK_STATUS=1
2370
+ fi
2371
+ fi
2372
+ exec 3<&-
2373
+ exit "$UNIT16_CALLBACK_STATUS"
2374
+ }
2375
+ trap unit16_callback_cleanup EXIT
2376
+ trap 'exit 129' HUP
2377
+ trap 'exit 130' INT
2378
+ trap 'exit 143' TERM
2379
+ test -d "$UNIT16_CALLBACK_INBOX" && test ! -L "$UNIT16_CALLBACK_INBOX" || exit 1
2380
+ test "$(stat -c '%F %u:%g %a' "$UNIT16_CALLBACK_INBOX")" = "directory 0:0 700" || exit 1
2381
+ test -d "$UNIT16_CALLBACK_QUARANTINE_ROOT" && test ! -L "$UNIT16_CALLBACK_QUARANTINE_ROOT" || exit 1
2382
+ test "$(stat -c '%F %u:%g %a' "$UNIT16_CALLBACK_QUARANTINE_ROOT")" = "directory 0:0 700" || exit 1
2383
+ test -f "$UNIT16_CALLBACK_FILE" && test ! -L "$UNIT16_CALLBACK_FILE" || exit 1
2384
+ exec 3<"$UNIT16_CALLBACK_FILE"
2385
+ test "$(stat -Lc '%F' /proc/self/fd/3)" = "regular file" || exit 1
2386
+ test "$(stat -c '%d:%i' "$UNIT16_CALLBACK_FILE")" = "$(stat -Lc '%d:%i' /proc/self/fd/3)" || exit 1
2387
+ UNIT16_CALLBACK_VALIDATED=yes
2388
+ test "$(stat -Lc '%F %u:%g %a' /proc/self/fd/3)" = "regular file 0:0 600" || exit 1
2389
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final callback-inject callback-inject.json 3<&3
2390
+ )
2391
+ }
2392
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize telegram-bootstrap
2393
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final telegram-bootstrap telegram-bootstrap.json
2394
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize cursor-snapshot before
2395
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final cursor-snapshot cursor-snapshot.json
2396
+ # Perform the live scenario whose cursor movement is being measured.
2397
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize cursor-snapshot after
2398
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final cursor-snapshot cursor-snapshot.json
2399
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize cursor-delta
2400
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final cursor-delta cursor-delta.json
2401
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize callback-inject
2402
+ run_unit16_callback_inject
2403
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize unraid-key-rotate
2404
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final unraid-key-rotate unraid-key-rotate.json
2405
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize reboot-request
2406
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final reboot-request reboot-request.json
2407
+ # The preceding command captures and seals preflight evidence before it asks
2408
+ # the broker to stage the reboot, then seals the requested-phase evidence and
2409
+ # fsyncs the requested checkpoint before invoking the host reboot. Reconnect
2410
+ # only after Unraid and Docker are ready.
2411
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize reboot-resume
2412
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final reboot-resume reboot-resume.json
2413
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-snapshot
2414
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-snapshot evidence-snapshot.json
2415
+ # reboot-resume seals the changed boot identity and recovery milestones.
2416
+ # evidence-snapshot then runs the remaining scenarios sequentially with a
2417
+ # 72-minute aggregate bound; each completed scenario records provenance at
2418
+ # capture time and always clears its public gate, private marker, and receipt.
2419
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-bundle-index
2420
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-bundle-index evidence-bundle-index.json
2421
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-bundle-verify
2422
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-bundle-verify evidence-bundle-verify.json
2423
+ Each execution revalidates the immutable image and the production container's
2424
+ exact image ID. The main one-shot gets a read-only root filesystem, one writable
2425
+ evidence mount, narrowly selected runtime/bundle modes, and individual read-only
2426
+ image/container/health/boot fact files. Host-only operations cross an ephemeral
2427
+ root-owned private Unix socket (root:10001 mode 0660) to a root broker extracted
2428
+ from the same exact image. The broker accepts only fixed Unraid inventory/create/
2429
+ exact-revoke/rejection operations, exact fixed-target snapshots, and a
2430
+ two-phase reboot reservation/final commit. Reservation drains queued owner
2431
+ mutations and rejects new ones; after the requested checkpoint is fsynced, the
2432
+ broker repeats the array/parity/mover/durable-health preflight and invokes the
2433
+ fixed host reboot itself exactly once. The Unit 16 broker inspects only the
2434
+ deployment profile's attested immutable container ID (staging is
2435
+ `ouro-butler-staging`), requires its immutable image ID, writes that exact typed/redacted
2436
+ snapshot before accepting requests, and exposes a nonnegative Docker restart
2437
+ count. Both initial and refreshed snapshots share the same exact schema. The
2438
+ broker derives `autostartExact` from both the Unraid GraphQL `autoStart` value
2439
+ and exactly one staging entry in `/var/lib/docker/unraid-autostart`, derives
2440
+ `updaterDisabled` from `/opt/ouro/container-runtime.json` inside the exact image,
2441
+ and derives `vaultUnlocked`/`manualAuthRequired` from a bounded live Sanctuary
2442
+ vault-status command only when its non-secret summary proves the required
2443
+ Telegram runtime fields and both configured provider records were freshly read.
2444
+ Scenario handles remain private to the scenario adapter. The main one-shot
2445
+ never receives the Docker socket, Unraid key directory, or a host-root mount.
2446
+ Telegram bootstrap additionally brackets its one-shot with a host-controlled
2447
+ poller quiescence guard: it verifies the exact healthy staging container,
2448
+ assumes recovery responsibility before attempting the stop, stops it with a
2449
+ 30-second grace bound, proves it is stopped, and mounts a
2450
+ root-owned typed zero-poller fact. Its exit/signal trap restarts that same exact
2451
+ container and waits up to 120 seconds for healthy recovery on both success and
2452
+ failure. It never reads or changes Unraid autostart configuration. Every command
2453
+ also receives a freshly generated, redacted typed container-inspect snapshot;
2454
+ raw container environment or credential values are never captured.
2455
+ Callback injection requires two stable zero observations from a durable callback playback journal under
2456
+ `state/approvals`; the journal is keyed only by the full callback-coordinate
2457
+ digest and stores no raw update, callback data, user, chat, message, or query ID.
2458
+ Evidence-snapshot keeps the bundle mount read-only and overlays only the exact
2459
+ canonical `state/acceptance` directory as writable. It first stops the exact
2460
+ healthy staging container under recovery responsibility, then opens the
2461
+ directory with `O_DIRECTORY|O_NOFOLLOW` and bind-mounts from the inherited fd
2462
+ while no Sanctuary UID process is running. The launcher bind-pins that inode
2463
+ behind a root-only alias and back onto the canonical path against rename swaps,
2464
+ restarts the same exact image healthy, and compares the alias, canonical path,
2465
+ and staging container's inode before and after capture. Cleanup unmounts the
2466
+ canonical pin and then the alias. The staging daemon therefore sees the same
2467
+ inode used by the one-shot for marker correlation.
2468
+
2469
+ Audit and safety verification:
2470
+ Inspect AgentBundles/sanctuary.ouro/state/approvals for durable approval and
2471
+ restart-attempt receipts. Confirm no Docker socket/device/host-root mounts and
2472
+ no published ports with docker inspect. Confirm Config.Image equals the exact
2473
+ reviewed local image ID, not the build tag. The read key must reject Docker
2474
+ stop and restart mutations; only the separate write key may perform the one
2475
+ typed approved restart action.
2476
+ The packaged `unraid-key-rotate` command is the only canonical key-rotation
2477
+ authority. Its host launcher stops the exact production container and assumes
2478
+ trap-backed recovery responsibility before key mutation. It inventories the
2479
+ two occupied canonical names by exact immutable ID, creates collision-safe
2480
+ `Butler RO Rotation <suffix>` and
2481
+ `Butler RW Rotation <suffix>` keys, stores and capability-probes both through
2482
+ the Sanctuary machine vault, and on the uninterrupted path requires an exact
2483
+ four-key inventory. Because
2484
+ Unraid 7.2.3 cannot create a role-free key through its CLI, the root broker
2485
+ creates each key as provisional `GUEST` with the intended scope plus only
2486
+ `API_KEY:UPDATE_ANY`, then immediately uses that in-memory credential to
2487
+ self-downgrade the same immutable ID to no roles and the exact intended scope.
2488
+ Disk and live-auth proofs must pass before the credential may reach the vault.
2489
+ The provisional credential never reaches argv, logs, evidence, the bundle, or
2490
+ the model. It then
2491
+ revokes both old IDs and proves each old credential receives a 401 or 403.
2492
+ Only after an exact inventory contains the temporary pair alone does it create,
2493
+ store, and probe the canonical `Butler RO` and `Butler RW` pair. It requires an
2494
+ exact four-key temporary-plus-canonical inventory on the uninterrupted path,
2495
+ revokes and rejection-probes
2496
+ both temporary IDs, and finally requires exactly the canonical pair. Every
2497
+ unexpected key, duplicate, malformed permission set, role, name, inventory
2498
+ delta, or probe response fails closed. A resumed path may observe fewer keys
2499
+ only when the transaction checkpoint plus a durable rejection receipt proves
2500
+ that the missing immutable ID was already revoked; it never treats absence
2501
+ alone as success.
2502
+
2503
+ A failed invocation resumes from the durable redacted transaction checkpoint
2504
+ instead of reminting known keys. The checkpoint binds every created immutable
2505
+ ID to its name, scope, vault field, temporary/canonical class, and attestation
2506
+ state. Recovery reconciles exact live inventory and rejection proofs, skips
2507
+ already-attested vault writes after canonical fields advance, and never
2508
+ recreates a temporary key already known revoked. Root-owned recovery ownership
2509
+ survives broker response loss and is cleared only after the harness has
2510
+ checkpointed the immutable ID and the vault has read back the exact binding.
2511
+
2512
+ Record only IDs, names, and permission sets in evidence; never raw key values.
2513
+ Raw credentials must not appear in argv, shell history, stdout, logs, or
2514
+ acceptance evidence. They may cross only the ephemeral private Unit 16 Unix
2515
+ socket in process memory. The narrow revocation-retry recovery records under
2516
+ `/mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof` are the sole file
2517
+ exception: they are root-owned mode 0600 beneath a root-owned mode 0700
2518
+ directory, excluded from backup/evidence, and readable only by the root broker.
2519
+ Recovery state is retained across failed retries.
2520
+ Creation intent is fsynced before the CLI side effect so an exact provisional
2521
+ key can be adopted after process death. Raw recovery proof is durably deleted
2522
+ only after the exact authentication rejection and its redacted replayable
2523
+ receipt are fsynced. Never substitute names for IDs when revoking keys.