@ouro.bot/cli 0.1.0-alpha.76 → 0.1.0-alpha.761

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