@ouro.bot/cli 0.1.0-alpha.75 → 0.1.0-alpha.751

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