@ouro.bot/cli 0.1.0-alpha.81 → 0.1.0-alpha.810

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