@ouro.bot/cli 0.1.0-alpha.77 → 0.1.0-alpha.770

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 (608) 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 +4930 -16
  15. package/deploy/unraid/Dockerfile +48 -0
  16. package/deploy/unraid/README.txt +2483 -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 +1804 -0
  31. package/deploy/unraid/sanctuary-unit16-run.sh +544 -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 +27 -0
  46. package/dist/a2a/card.js +57 -0
  47. package/dist/a2a/client.js +180 -0
  48. package/dist/a2a/config.js +50 -0
  49. package/dist/a2a/delegation-stores.js +56 -0
  50. package/dist/a2a/did-resolution.js +93 -0
  51. package/dist/a2a/identity.js +170 -0
  52. package/dist/a2a/inbound-share.js +107 -0
  53. package/dist/a2a/mission-result-wire.js +196 -0
  54. package/dist/a2a/onboarding.js +115 -0
  55. package/dist/a2a/pin-store.js +132 -0
  56. package/dist/a2a/seen-ledger.js +120 -0
  57. package/dist/a2a/server.js +604 -0
  58. package/dist/a2a/task-store.js +69 -0
  59. package/dist/a2a/transport.js +45 -0
  60. package/dist/a2a/types.js +3 -0
  61. package/dist/arc/attention-types.js +8 -0
  62. package/dist/arc/cares.js +360 -0
  63. package/dist/arc/episodes.js +118 -0
  64. package/dist/arc/evolution.js +490 -0
  65. package/dist/arc/flight-recorder.js +1034 -0
  66. package/dist/arc/intentions.js +134 -0
  67. package/dist/arc/json-store.js +117 -0
  68. package/dist/arc/obligations.js +386 -0
  69. package/dist/arc/packets.js +336 -0
  70. package/dist/arc/presence.js +185 -0
  71. package/dist/arc/task-lifecycle.js +57 -0
  72. package/dist/commerce/store.js +755 -0
  73. package/dist/commerce/types.js +3 -0
  74. package/dist/heart/active-work.js +820 -29
  75. package/dist/heart/agent-entry.js +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 +64 -18
  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 +2014 -469
  106. package/dist/heart/cross-chat-delivery.js +9 -22
  107. package/dist/heart/daemon/agent-config-check.js +451 -0
  108. package/dist/heart/daemon/agent-discovery.js +185 -3
  109. package/dist/heart/daemon/agent-service.js +542 -0
  110. package/dist/heart/daemon/agentic-repair.js +547 -0
  111. package/dist/heart/daemon/await-private-wake.js +59 -0
  112. package/dist/heart/daemon/bluebubbles-cli-integration.js +71 -0
  113. package/dist/heart/daemon/bluebubbles-health-diagnostics.js +122 -0
  114. package/dist/heart/daemon/bluebubbles-host-protocol.js +433 -0
  115. package/dist/heart/daemon/bluebubbles-host.js +273 -0
  116. package/dist/heart/daemon/boot-sync-probe.js +197 -0
  117. package/dist/heart/daemon/cadence.js +180 -0
  118. package/dist/heart/daemon/cli-defaults.js +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 +176 -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 +1877 -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 +2468 -0
  176. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1624 -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 +1187 -0
  196. package/dist/heart/habits/habit-cancel.js +810 -0
  197. package/dist/heart/habits/habit-lifecycle.js +1327 -0
  198. package/dist/heart/habits/habit-migration.js +194 -0
  199. package/dist/heart/habits/habit-parser.js +278 -0
  200. package/dist/heart/habits/habit-runtime-state.js +93 -0
  201. package/dist/heart/habits/habit-scheduler.js +653 -0
  202. package/dist/heart/habits/habit-session-summary.js +318 -0
  203. package/dist/heart/habits/habit-session.js +636 -0
  204. package/dist/heart/{daemon → hatch}/hatch-flow.js +55 -62
  205. package/dist/heart/{daemon → hatch}/hatch-specialist.js +6 -8
  206. package/dist/heart/{daemon → hatch}/specialist-prompt.js +12 -9
  207. package/dist/heart/{daemon → hatch}/specialist-tools.js +53 -18
  208. package/dist/heart/identity.js +184 -59
  209. package/dist/heart/kept-notes.js +289 -0
  210. package/dist/heart/kicks.js +1 -1
  211. package/dist/heart/machine-identity.js +161 -0
  212. package/dist/heart/mail-import-discovery.js +353 -0
  213. package/dist/heart/mailbox/mailbox-http-hooks.js +98 -0
  214. package/dist/heart/mailbox/mailbox-http-response.js +7 -0
  215. package/dist/heart/mailbox/mailbox-http-routes.js +398 -0
  216. package/dist/heart/mailbox/mailbox-http-static.js +103 -0
  217. package/dist/heart/mailbox/mailbox-http-transport.js +116 -0
  218. package/dist/heart/mailbox/mailbox-http.js +99 -0
  219. package/dist/heart/mailbox/mailbox-read.js +39 -0
  220. package/dist/heart/mailbox/mailbox-types.js +27 -0
  221. package/dist/heart/mailbox/mailbox-view.js +197 -0
  222. package/dist/heart/mailbox/readers/agent-machine.js +428 -0
  223. package/dist/heart/mailbox/readers/continuity-readers.js +329 -0
  224. package/dist/heart/mailbox/readers/mail.js +375 -0
  225. package/dist/heart/mailbox/readers/runtime-readers.js +823 -0
  226. package/dist/heart/mailbox/readers/sessions.js +232 -0
  227. package/dist/heart/mailbox/readers/shared.js +111 -0
  228. package/dist/heart/mcp/mcp-server.js +696 -0
  229. package/dist/heart/migrate-config.js +100 -0
  230. package/dist/heart/model-capabilities.js +11 -0
  231. package/dist/heart/orientation-frame.js +345 -0
  232. package/dist/heart/platform.js +81 -0
  233. package/dist/heart/primitives.js +1 -1
  234. package/dist/heart/private-runtime/decision-renderer.js +158 -0
  235. package/dist/heart/private-runtime/index.js +18 -0
  236. package/dist/heart/private-runtime/ledger.js +221 -0
  237. package/dist/heart/private-runtime/policy.js +228 -0
  238. package/dist/heart/private-runtime/types.js +2 -0
  239. package/dist/heart/progress-story.js +1 -1
  240. package/dist/heart/provider-attempt.js +137 -0
  241. package/dist/heart/provider-binding-resolver.js +273 -0
  242. package/dist/heart/provider-credentials.js +449 -0
  243. package/dist/heart/provider-failover.js +311 -0
  244. package/dist/heart/provider-models.js +96 -0
  245. package/dist/heart/provider-ping.js +265 -0
  246. package/dist/heart/provider-readiness-cache.js +119 -0
  247. package/dist/heart/provider-visibility.js +188 -0
  248. package/dist/heart/providers/anthropic-token.js +131 -0
  249. package/dist/heart/providers/anthropic.js +173 -60
  250. package/dist/heart/providers/azure.js +23 -11
  251. package/dist/heart/providers/error-classification.js +127 -0
  252. package/dist/heart/providers/github-copilot.js +34 -38
  253. package/dist/heart/providers/minimax-vlm.js +189 -0
  254. package/dist/heart/providers/minimax.js +26 -8
  255. package/dist/heart/providers/openai-codex-token.js +349 -0
  256. package/dist/heart/providers/openai-codex.js +55 -40
  257. package/dist/heart/providers/openai-compatible.js +177 -0
  258. package/dist/heart/run-ledger.js +247 -0
  259. package/dist/heart/runtime-capability-check.js +170 -0
  260. package/dist/heart/runtime-credentials.js +607 -0
  261. package/dist/heart/runtime-cwd.js +87 -0
  262. package/dist/heart/sense-truth.js +22 -4
  263. package/dist/heart/session-activity.js +90 -24
  264. package/dist/heart/session-events.js +1318 -0
  265. package/dist/heart/session-playback-cli-main.js +5 -0
  266. package/dist/heart/session-playback-cli.js +36 -0
  267. package/dist/heart/session-playback.js +231 -0
  268. package/dist/heart/session-stats-cli-main.js +5 -0
  269. package/dist/heart/session-stats.js +182 -0
  270. package/dist/heart/session-transcript.js +133 -0
  271. package/dist/heart/start-of-turn-packet.js +372 -0
  272. package/dist/heart/steward-policy.js +302 -0
  273. package/dist/heart/streaming.js +604 -207
  274. package/dist/heart/structured-output.js +196 -0
  275. package/dist/heart/sync-classification.js +176 -0
  276. package/dist/heart/sync.js +449 -0
  277. package/dist/heart/target-resolution.js +11 -7
  278. package/dist/heart/tempo.js +93 -0
  279. package/dist/heart/temporal-view.js +41 -0
  280. package/dist/heart/timeouts.js +101 -0
  281. package/dist/heart/tool-activity-callbacks.js +59 -0
  282. package/dist/heart/tool-approval.js +342 -0
  283. package/dist/heart/tool-description.js +155 -0
  284. package/dist/heart/tool-friction.js +55 -0
  285. package/dist/heart/tool-loop.js +200 -0
  286. package/dist/heart/turn-context.js +481 -0
  287. package/dist/heart/{daemon → versioning}/ouro-bot-global-installer.js +6 -5
  288. package/dist/heart/{daemon → versioning}/ouro-bot-wrapper.js +1 -1
  289. package/dist/heart/versioning/ouro-path-installer.js +432 -0
  290. package/dist/heart/versioning/ouro-recovery-launcher.js +76 -0
  291. package/dist/heart/versioning/ouro-version-manager.js +409 -0
  292. package/dist/heart/{daemon → versioning}/staged-restart.js +40 -8
  293. package/dist/heart/{daemon → versioning}/update-checker.js +6 -1
  294. package/dist/heart/versioning/update-hooks.js +154 -0
  295. package/dist/heart/versioning/version-intent.js +114 -0
  296. package/dist/heart/work-card.js +380 -0
  297. package/dist/mailbox-ui/assets/index-Du_9G9WO.css +1 -0
  298. package/dist/mailbox-ui/assets/index-J8zDwHmE.js +1 -0
  299. package/dist/mailbox-ui/assets/vendor-CcN1XpQ9.js +61 -0
  300. package/dist/mailbox-ui/index.html +16 -0
  301. package/dist/mailroom/attention.js +167 -0
  302. package/dist/mailroom/autonomy.js +209 -0
  303. package/dist/mailroom/blob-store.js +889 -0
  304. package/dist/mailroom/body-cache.js +61 -0
  305. package/dist/mailroom/cache-sync-cli.js +58 -0
  306. package/dist/mailroom/core.js +803 -0
  307. package/dist/mailroom/entry.js +160 -0
  308. package/dist/mailroom/file-store.js +568 -0
  309. package/dist/mailroom/hosted-cache-sync.js +384 -0
  310. package/dist/mailroom/mbox-import.js +393 -0
  311. package/dist/mailroom/migration.js +164 -0
  312. package/dist/mailroom/outbound.js +380 -0
  313. package/dist/mailroom/policy.js +263 -0
  314. package/dist/mailroom/reader.js +292 -0
  315. package/dist/mailroom/search-cache.js +513 -0
  316. package/dist/mailroom/search-relevance.js +319 -0
  317. package/dist/mailroom/smtp-ingress.js +176 -0
  318. package/dist/mailroom/source-state.js +176 -0
  319. package/dist/mailroom/thread.js +109 -0
  320. package/dist/mailroom/travel-extract.js +89 -0
  321. package/dist/mind/bundle-manifest.js +24 -2
  322. package/dist/mind/context.js +278 -111
  323. package/dist/mind/desk-section.js +362 -0
  324. package/dist/mind/diary-integrity.js +60 -0
  325. package/dist/mind/{memory.js → diary.js} +68 -77
  326. package/dist/mind/embedding-provider.js +60 -0
  327. package/dist/mind/file-state.js +179 -0
  328. package/dist/mind/{associative-recall.js → note-search.js} +47 -58
  329. package/dist/mind/obligation-steering.js +227 -0
  330. package/dist/mind/pending.js +34 -8
  331. package/dist/mind/prompt-budget.js +479 -0
  332. package/dist/mind/prompt-refresh.js +3 -2
  333. package/dist/mind/prompt.js +1058 -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 -1139
  392. package/dist/repertoire/tools-bluebubbles.js +2 -0
  393. package/dist/repertoire/tools-bridge.js +144 -0
  394. package/dist/repertoire/tools-bundle.js +993 -0
  395. package/dist/repertoire/tools-commerce.js +253 -0
  396. package/dist/repertoire/tools-config.js +186 -0
  397. package/dist/repertoire/tools-continuity.js +521 -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 +1081 -204
  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 +302 -0
  508. package/dist/senses/sanctuary-grounding.js +118 -0
  509. package/dist/senses/sanctuary-health-acceptance-probe-entry.js +12 -0
  510. package/dist/senses/sanctuary-health-acceptance-probe.js +774 -0
  511. package/dist/senses/sanctuary-health-runner.js +101 -0
  512. package/dist/senses/sanctuary-health.js +408 -0
  513. package/dist/senses/sanctuary-interactive-control.js +306 -0
  514. package/dist/senses/sanctuary-media-optimization.js +436 -0
  515. package/dist/senses/sanctuary-runtime.js +287 -0
  516. package/dist/senses/sanctuary-sab.js +85 -0
  517. package/dist/senses/sanctuary-storage-optimization-contract.js +32 -0
  518. package/dist/senses/shared-turn.js +488 -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 +796 -0
  528. package/dist/senses/telegram-entry.js +83 -0
  529. package/dist/senses/telegram.js +2018 -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/obligations.js +0 -141
  569. package/dist/heart/safe-workspace.js +0 -228
  570. package/dist/heart/session-recall.js +0 -116
  571. package/dist/mind/friends/channel.js +0 -84
  572. package/dist/mind/friends/group-context.js +0 -144
  573. package/dist/mind/friends/resolver.js +0 -84
  574. package/dist/mind/friends/store-file.js +0 -190
  575. package/dist/mind/friends/store.js +0 -4
  576. package/dist/mind/friends/tokens.js +0 -26
  577. package/dist/mind/friends/trust-explanation.js +0 -74
  578. package/dist/mind/friends/types.js +0 -29
  579. package/dist/repertoire/tasks/board.js +0 -134
  580. package/dist/repertoire/tasks/index.js +0 -224
  581. package/dist/repertoire/tasks/lifecycle.js +0 -80
  582. package/dist/repertoire/tasks/middleware.js +0 -65
  583. package/dist/repertoire/tasks/parser.js +0 -173
  584. package/dist/repertoire/tasks/scanner.js +0 -132
  585. package/dist/repertoire/tasks/transitions.js +0 -144
  586. package/dist/senses/bluebubbles-entry.js +0 -13
  587. package/dist/senses/bluebubbles.js +0 -1180
  588. package/dist/senses/debug-activity.js +0 -148
  589. package/dist/senses/inner-dialog-worker.js +0 -90
  590. package/dist/senses/inner-dialog.js +0 -525
  591. package/subagents/README.md +0 -7
  592. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
  593. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
  594. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
  595. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
  596. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
  597. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +0 -0
  598. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
  599. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
  600. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
  601. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
  602. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
  603. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
  604. /package/dist/{repertoire/tasks/types.js → heart/attachments/sources/adapter.js} +0 -0
  605. /package/dist/heart/{daemon → hatch}/hatch-animation.js +0 -0
  606. /package/dist/heart/{daemon → hatch}/specialist-orchestrator.js +0 -0
  607. /package/dist/heart/{daemon → versioning}/ouro-uti.js +0 -0
  608. /package/dist/heart/{daemon → versioning}/wrapper-publish-guard.js +0 -0
@@ -0,0 +1,2483 @@
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
+ provision_sanctuary_sab_credential "$IMAGE_ID" || return $?
797
+ verify_sanctuary_sab_readiness "$IMAGE_ID" || return $?
798
+ validate_sanctuary_legacy_staging "$PREPARED_LEGACY_CONTAINER_ID" "$PREPARED_LEGACY_IMAGE_ID" || return $?
799
+ LEGACY_STAGING_CONTAINER_ID=$PREPARED_LEGACY_CONTAINER_ID
800
+ LEGACY_STAGING_IMAGE_ID=$PREPARED_LEGACY_IMAGE_ID
801
+ }
802
+ capture_sanctuary_legacy_evidence() {
803
+ CAPTURED_LEGACY_CONTAINER_ID=$1
804
+ CAPTURED_LEGACY_IMAGE_ID=$2
805
+ LEGACY_EVIDENCE_ROOT=$3
806
+ case "$CAPTURED_LEGACY_CONTAINER_ID" in *[!0-9a-f]*|'') return 1 ;; esac
807
+ test "${#CAPTURED_LEGACY_CONTAINER_ID}" -eq 64 || return 1
808
+ validate_exact_image_id "$CAPTURED_LEGACY_IMAGE_ID" || return $?
809
+ case "$LEGACY_EVIDENCE_ROOT" in /*) ;; *) return 1 ;; esac
810
+ if test -e "$LEGACY_EVIDENCE_ROOT" || test -L "$LEGACY_EVIDENCE_ROOT"; then
811
+ /usr/local/bin/node -e '
812
+ const fs = require("node:fs");
813
+ const root = fs.lstatSync(process.argv[1]);
814
+ if (!root.isDirectory() || root.isSymbolicLink() || (root.mode & 0o777) !== 0o700) process.exit(1);
815
+ if (root.uid !== process.geteuid() || root.gid !== process.getegid()) process.exit(1);
816
+ ' "$LEGACY_EVIDENCE_ROOT" || return $?
817
+ else
818
+ install -d -m 0700 -o 0 -g 0 "$LEGACY_EVIDENCE_ROOT" || return $?
819
+ fi
820
+ LEGACY_EVIDENCE_DIR="$LEGACY_EVIDENCE_ROOT/${CAPTURED_LEGACY_IMAGE_ID#sha256:}"
821
+ if test -e "$LEGACY_EVIDENCE_DIR" || test -L "$LEGACY_EVIDENCE_DIR"; then
822
+ test -d "$LEGACY_EVIDENCE_DIR" && test ! -L "$LEGACY_EVIDENCE_DIR" || return 1
823
+ else
824
+ mkdir -m 0700 "$LEGACY_EVIDENCE_DIR" || return $?
825
+ fi
826
+ /usr/local/bin/node -e '
827
+ const fs = require("node:fs");
828
+ const [rootPath, directoryPath] = process.argv.slice(1);
829
+ const root = fs.lstatSync(rootPath);
830
+ const directory = fs.lstatSync(directoryPath);
831
+ const privateDirectory = value => value.isDirectory() && !value.isSymbolicLink() && (value.mode & 0o777) === 0o700;
832
+ if (!privateDirectory(root) || !privateDirectory(directory)) process.exit(1);
833
+ if (root.uid !== process.geteuid() || root.gid !== process.getegid()) process.exit(1);
834
+ if (root.uid !== directory.uid || root.gid !== directory.gid) process.exit(1);
835
+ const allowed = new Set(["container.json", "image.json"]);
836
+ if (fs.readdirSync(directoryPath).some(name => !allowed.has(name))) process.exit(1);
837
+ ' "$LEGACY_EVIDENCE_ROOT" "$LEGACY_EVIDENCE_DIR" || return $?
838
+ validate_legacy_evidence_entry() {
839
+ VALIDATED_EVIDENCE_KIND=$1
840
+ VALIDATED_EVIDENCE_PATH=$2
841
+ /usr/local/bin/node -e '
842
+ const fs = require("node:fs");
843
+ const [kind, filePath, rootPath, containerId, imageId] = process.argv.slice(1);
844
+ const root = fs.lstatSync(rootPath);
845
+ const entry = fs.lstatSync(filePath);
846
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== root.uid || entry.gid !== root.gid) process.exit(1);
847
+ const mode = entry.mode & 0o777;
848
+ if (mode !== 0o600 && mode !== 0o644) process.exit(1);
849
+ const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
850
+ if (!Array.isArray(value) || value.length !== 1 || !value[0] || typeof value[0] !== "object") process.exit(1);
851
+ if (kind === "container" && (value[0].Id !== containerId || value[0].Image !== imageId)) process.exit(1);
852
+ if (kind === "image" && value[0].Id !== imageId) process.exit(1);
853
+ ' "$VALIDATED_EVIDENCE_KIND" "$VALIDATED_EVIDENCE_PATH" "$LEGACY_EVIDENCE_ROOT" "$CAPTURED_LEGACY_CONTAINER_ID" "$CAPTURED_LEGACY_IMAGE_ID"
854
+ }
855
+ capture_missing_legacy_evidence_entry() {
856
+ MISSING_EVIDENCE_KIND=$1
857
+ MISSING_EVIDENCE_PATH=$2
858
+ LEGACY_EVIDENCE_TMP=$(mktemp "$LEGACY_EVIDENCE_ROOT/.capture.XXXXXXXX") || return $?
859
+ if test "$MISSING_EVIDENCE_KIND" = container; then
860
+ docker container inspect "$CAPTURED_LEGACY_CONTAINER_ID" >"$LEGACY_EVIDENCE_TMP" || {
861
+ CAPTURE_STATUS=$?
862
+ rm -f "$LEGACY_EVIDENCE_TMP"
863
+ return "$CAPTURE_STATUS"
864
+ }
865
+ else
866
+ docker image inspect "$CAPTURED_LEGACY_IMAGE_ID" >"$LEGACY_EVIDENCE_TMP" || {
867
+ CAPTURE_STATUS=$?
868
+ rm -f "$LEGACY_EVIDENCE_TMP"
869
+ return "$CAPTURE_STATUS"
870
+ }
871
+ fi
872
+ chmod 0600 "$LEGACY_EVIDENCE_TMP" || {
873
+ CAPTURE_STATUS=$?
874
+ rm -f "$LEGACY_EVIDENCE_TMP"
875
+ return "$CAPTURE_STATUS"
876
+ }
877
+ validate_legacy_evidence_entry "$MISSING_EVIDENCE_KIND" "$LEGACY_EVIDENCE_TMP" || {
878
+ CAPTURE_STATUS=$?
879
+ rm -f "$LEGACY_EVIDENCE_TMP"
880
+ return "$CAPTURE_STATUS"
881
+ }
882
+ sync -f "$LEGACY_EVIDENCE_TMP" || {
883
+ CAPTURE_STATUS=$?
884
+ rm -f "$LEGACY_EVIDENCE_TMP"
885
+ return "$CAPTURE_STATUS"
886
+ }
887
+ if ln "$LEGACY_EVIDENCE_TMP" "$MISSING_EVIDENCE_PATH"; then
888
+ rm -f "$LEGACY_EVIDENCE_TMP" || return $?
889
+ else
890
+ rm -f "$LEGACY_EVIDENCE_TMP" || return $?
891
+ validate_legacy_evidence_entry "$MISSING_EVIDENCE_KIND" "$MISSING_EVIDENCE_PATH" || return $?
892
+ fi
893
+ }
894
+ for LEGACY_EVIDENCE_KIND in container image; do
895
+ LEGACY_EVIDENCE_PATH="$LEGACY_EVIDENCE_DIR/$LEGACY_EVIDENCE_KIND.json"
896
+ if test -e "$LEGACY_EVIDENCE_PATH" || test -L "$LEGACY_EVIDENCE_PATH"; then
897
+ validate_legacy_evidence_entry "$LEGACY_EVIDENCE_KIND" "$LEGACY_EVIDENCE_PATH" || return $?
898
+ else
899
+ capture_missing_legacy_evidence_entry "$LEGACY_EVIDENCE_KIND" "$LEGACY_EVIDENCE_PATH" || return $?
900
+ fi
901
+ chmod 0600 "$LEGACY_EVIDENCE_PATH" || return $?
902
+ validate_legacy_evidence_entry "$LEGACY_EVIDENCE_KIND" "$LEGACY_EVIDENCE_PATH" || return $?
903
+ done
904
+ sync -f "$LEGACY_EVIDENCE_DIR" || return $?
905
+ }
906
+ install_from_legacy_staging() {
907
+ prepare_sanctuary_legacy_adoption "$IMAGE_ID" || return $?
908
+ ADOPTION_PREPARED_CONTAINER_ID=$LEGACY_STAGING_CONTAINER_ID
909
+ ADOPTION_PREPARED_IMAGE_ID=$LEGACY_STAGING_IMAGE_ID
910
+ verify_sanctuary_provider_readiness "$IMAGE_ID" || return $?
911
+ ADOPTION_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
912
+ ADOPTION_NAME_COUNTS=$(printf '%s\n' "$ADOPTION_CONTAINER_NAMES" | awk '
913
+ /butler/ { butlers++ }
914
+ $0 == "ouro-butler-staging" { staging++ }
915
+ END { printf "%d %d", butlers + 0, staging + 0 }
916
+ ') || return $?
917
+ test "$ADOPTION_NAME_COUNTS" = "1 1" || return $?
918
+ assert_only_running_butler ouro-butler-staging || return $?
919
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-staging)" = true || return $?
920
+ test "$(docker inspect --format '{{.Id}}' ouro-butler-staging)" = "$ADOPTION_PREPARED_CONTAINER_ID" || return $?
921
+ test "$(docker inspect --format '{{.Image}}' ouro-butler-staging)" = "$ADOPTION_PREPARED_IMAGE_ID" || return $?
922
+ LEGACY_STAGING_CONTAINER_ID=$ADOPTION_PREPARED_CONTAINER_ID
923
+ LEGACY_STAGING_IMAGE_ID=$ADOPTION_PREPARED_IMAGE_ID
924
+ LEGACY_EVIDENCE_ROOT=/mnt/user/appdata/ouro-butler/legacy-evidence
925
+ capture_sanctuary_legacy_evidence "$LEGACY_STAGING_CONTAINER_ID" "$LEGACY_STAGING_IMAGE_ID" "$LEGACY_EVIDENCE_ROOT" || return $?
926
+ validate_sanctuary_legacy_staging "$ADOPTION_PREPARED_CONTAINER_ID" "$ADOPTION_PREPARED_IMAGE_ID" || return $?
927
+ LEGACY_STAGING_CONTAINER_ID=$ADOPTION_PREPARED_CONTAINER_ID
928
+ LEGACY_STAGING_IMAGE_ID=$ADOPTION_PREPARED_IMAGE_ID
929
+ disable_butler_autostart || return $?
930
+ if docker stop "$LEGACY_STAGING_CONTAINER_ID" \
931
+ && CURRENT_LEGACY_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' "$LEGACY_STAGING_CONTAINER_ID") \
932
+ && test "$CURRENT_LEGACY_STAGING_IMAGE_ID" = "$LEGACY_STAGING_IMAGE_ID" \
933
+ && test "$(docker inspect --format '{{.State.Running}}' "$LEGACY_STAGING_CONTAINER_ID")" = false \
934
+ && docker rename "$LEGACY_STAGING_CONTAINER_ID" ouro-butler-legacy-evidence \
935
+ && CURRENT_LEGACY_EVIDENCE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) \
936
+ && test "$CURRENT_LEGACY_EVIDENCE_IMAGE_ID" = "$LEGACY_STAGING_IMAGE_ID" \
937
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence)" = false \
938
+ && assert_only_running_butler - \
939
+ && docker create --name ouro-butler-staging --network host --restart unless-stopped --user 10001:10001 \
940
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
941
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
942
+ --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
943
+ "$IMAGE_ID" \
944
+ && audit_effective ouro-butler-staging "$IMAGE_ID" "$IMAGE_ID" \
945
+ && docker start ouro-butler-staging \
946
+ && assert_only_running_butler ouro-butler-staging \
947
+ && wait_butler_ready ouro-butler-staging \
948
+ && docker stop ouro-butler-staging \
949
+ && test "$(docker inspect --format '{{.Image}}' ouro-butler-staging)" = "$IMAGE_ID" \
950
+ && docker rm ouro-butler-staging \
951
+ && assert_only_running_butler - \
952
+ && docker create --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
953
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
954
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
955
+ --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,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
+ provision_sanctuary_sab_credential() {
1191
+ SAB_BOOTSTRAP_IMAGE_ID=$1
1192
+ validate_exact_image_id "$SAB_BOOTSTRAP_IMAGE_ID" || return $?
1193
+ SAB_BOOTSTRAP_SOURCE=/mnt/user/appdata/sabnzbd/sabnzbd.ini
1194
+ SAB_BOOTSTRAP_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1195
+ SAB_BOOTSTRAP_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1196
+ SAB_BOOTSTRAP_ENVELOPE="$SAB_BOOTSTRAP_RUNTIME_ROOT/container-credentials.json"
1197
+ SAB_BOOTSTRAP_CLAIM="$SAB_BOOTSTRAP_ENVELOPE.consuming"
1198
+ test -f "$SAB_BOOTSTRAP_SOURCE" || return $?
1199
+ test ! -L "$SAB_BOOTSTRAP_SOURCE" || return 1
1200
+ test ! -e "$SAB_BOOTSTRAP_ENVELOPE" || return 1
1201
+ test ! -e "$SAB_BOOTSTRAP_CLAIM" || return 1
1202
+ ! docker container inspect ouro-butler-sab-credential-bootstrap >/dev/null 2>&1 || return 1
1203
+ SAB_BOOTSTRAP_TMP=$(mktemp "$SAB_BOOTSTRAP_RUNTIME_ROOT/container-credentials.json.tmp.XXXXXX") || return $?
1204
+ trap 'rm -f -- "$SAB_BOOTSTRAP_TMP"' EXIT || return $?
1205
+ /usr/local/bin/node -e '
1206
+ const fs = require("node:fs");
1207
+ const [source, destination] = process.argv.slice(1);
1208
+ const match = fs.readFileSync(source, "utf8").match(/^\s*api_key\s*=\s*(\S+)\s*$/mu);
1209
+ if (!match || !match[1]) process.exit(1);
1210
+ const credential = { type: "ouro.runtimeCredentialBootstrap", agentName: "sanctuary", machineId: "sanctuary", machineRuntimeConfig: { sabnzbdApiKey: match[1] } };
1211
+ const envelope = { schemaVersion: 1, credentials: [credential] };
1212
+ fs.writeFileSync(destination, `${JSON.stringify(envelope)}\n`, { mode: 0o600, flag: "w" });
1213
+ ' "$SAB_BOOTSTRAP_SOURCE" "$SAB_BOOTSTRAP_TMP" || return $?
1214
+ chown 10001:10001 "$SAB_BOOTSTRAP_TMP" || return $?
1215
+ chmod 0600 "$SAB_BOOTSTRAP_TMP" || return $?
1216
+ sync -f "$SAB_BOOTSTRAP_TMP" || return $?
1217
+ mv -f -- "$SAB_BOOTSTRAP_TMP" "$SAB_BOOTSTRAP_ENVELOPE" || return $?
1218
+ sync -f "$SAB_BOOTSTRAP_RUNTIME_ROOT" || return $?
1219
+ trap - EXIT || return $?
1220
+ docker run --rm --pull=never --network host --name ouro-butler-sab-credential-bootstrap --user 10001:10001 \
1221
+ --mount "type=bind,src=$SAB_BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1222
+ --mount "type=bind,src=$SAB_BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1223
+ --entrypoint node "$SAB_BOOTSTRAP_IMAGE_ID" -e '
1224
+ const { loadContainerCredentialBootstrap } = require("/opt/ouro/dist/heart/daemon/container-credential-bootstrap.js");
1225
+ loadContainerCredentialBootstrap(["sanctuary"]).then(
1226
+ () => process.exit(0),
1227
+ () => { process.stderr.write("SAB credential bootstrap failed\n"); process.exit(1); },
1228
+ );
1229
+ ' || return $?
1230
+ ! docker container inspect ouro-butler-sab-credential-bootstrap >/dev/null 2>&1 || return 1
1231
+ test ! -e "$SAB_BOOTSTRAP_ENVELOPE" || return 1
1232
+ test ! -e "$SAB_BOOTSTRAP_CLAIM" || return 1
1233
+ }
1234
+ verify_sanctuary_sab_readiness() {
1235
+ SAB_READINESS_IMAGE_ID=$1
1236
+ validate_exact_image_id "$SAB_READINESS_IMAGE_ID" || return $?
1237
+ ! docker container inspect ouro-butler-sab-readiness >/dev/null 2>&1 || return 1
1238
+ docker run --rm --pull=never --network host --name ouro-butler-sab-readiness --user 10001:10001 \
1239
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
1240
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1241
+ --entrypoint node "$SAB_READINESS_IMAGE_ID" -e '
1242
+ (async () => {
1243
+ try {
1244
+ const { refreshMachineRuntimeCredentialConfig } = require("/opt/ouro/dist/heart/runtime-credentials.js");
1245
+ const { createSanctuarySabClient } = require("/opt/ouro/dist/senses/sanctuary-sab.js");
1246
+ const current = await refreshMachineRuntimeCredentialConfig("sanctuary", "sanctuary");
1247
+ if (!current.ok || typeof current.config.sabnzbdApiKey !== "string" || !current.config.sabnzbdApiKey.trim()) throw new Error();
1248
+ const snapshot = await createSanctuarySabClient({ loadApiKey: async () => current.config.sabnzbdApiKey }).readQueue();
1249
+ if (typeof snapshot.paused !== "boolean" || !Number.isSafeInteger(snapshot.queuedJobs) || !snapshot.observedAt) throw new Error();
1250
+ process.stdout.write("Sanctuary SAB queue readiness verified.\n");
1251
+ } catch {
1252
+ process.stderr.write("Sanctuary SAB queue readiness verification failed.\n");
1253
+ process.exitCode = 1;
1254
+ }
1255
+ })();
1256
+ ' || return $?
1257
+ ! docker container inspect ouro-butler-sab-readiness >/dev/null 2>&1 || return 1
1258
+ }
1259
+ authenticate_sanctuary_provider() {
1260
+ AUTH_IMAGE_ID=$1
1261
+ AUTH_PROVIDER=${2-}
1262
+ validate_exact_image_id "$AUTH_IMAGE_ID" || return $?
1263
+ case "$AUTH_PROVIDER" in
1264
+ openai-compatible) AUTH_CONTAINER=ouro-butler-provider-auth-glm ;;
1265
+ openai-compatible-gemini) AUTH_CONTAINER=ouro-butler-provider-auth-gemini ;;
1266
+ *) return 1 ;;
1267
+ esac
1268
+ AUTH_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1269
+ AUTH_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1270
+ ! docker container inspect "$AUTH_CONTAINER" >/dev/null 2>&1 || return 1
1271
+ docker run --rm -it --pull=never --network host --name "$AUTH_CONTAINER" --user 10001:10001 \
1272
+ --mount "type=bind,src=$AUTH_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1273
+ --mount "type=bind,src=$AUTH_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1274
+ --entrypoint node "$AUTH_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
1275
+ auth --agent sanctuary --provider "$AUTH_PROVIDER" || return $?
1276
+ ! docker container inspect "$AUTH_CONTAINER" >/dev/null 2>&1 || return 1
1277
+ }
1278
+ verify_sanctuary_provider_readiness() {
1279
+ READINESS_IMAGE_ID=$1
1280
+ validate_exact_image_id "$READINESS_IMAGE_ID" || return $?
1281
+ READINESS_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1282
+ READINESS_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1283
+ ! docker container inspect ouro-butler-provider-readiness >/dev/null 2>&1 || return 1
1284
+ docker run --rm --pull=never --network host --name ouro-butler-provider-readiness --user 10001:10001 \
1285
+ --mount "type=bind,src=$READINESS_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
1286
+ --mount "type=bind,src=$READINESS_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1287
+ --entrypoint /bin/sh "$READINESS_IMAGE_ID" -ceu '
1288
+ umask 077
1289
+ node /opt/ouro/dist/heart/daemon/ouro-entry.js check --agent sanctuary --lane outward
1290
+ node /opt/ouro/dist/heart/daemon/ouro-entry.js check --agent sanctuary --lane inner
1291
+ node - <<'"'"'NODE'"'"'
1292
+ (async () => {
1293
+ try {
1294
+ const fs = require("node:fs");
1295
+ const root = "/home/ouro/AgentBundles/sanctuary.ouro";
1296
+ const agent = JSON.parse(fs.readFileSync(`${root}/agent.json`, "utf8"));
1297
+ const policy = JSON.parse(fs.readFileSync(`${root}/provider-readiness.json`, "utf8"));
1298
+ const expectedPolicy = { version: 1, selectionPolicy: "explicit-same-lane-only" };
1299
+ const expected = new Map([
1300
+ ["minimax", { provider: "minimax", model: "MiniMax-M3", vaultItem: "providers/minimax" }],
1301
+ ]);
1302
+ const exactLane = value => value && value.provider === "minimax" && value.model === "MiniMax-M3";
1303
+ if (!exactLane(agent.humanFacing) || !exactLane(agent.agentFacing)
1304
+ || policy.version !== expectedPolicy.version || policy.selectionPolicy !== expectedPolicy.selectionPolicy
1305
+ || !Array.isArray(policy.providers) || policy.providers.length !== expected.size) throw new Error();
1306
+ for (const entry of policy.providers) {
1307
+ const wanted = expected.get(entry.provider);
1308
+ if (!wanted || entry.model !== wanted.model || entry.vaultItem !== wanted.vaultItem) throw new Error();
1309
+ expected.delete(entry.provider);
1310
+ }
1311
+ if (expected.size !== 0) throw new Error();
1312
+ const { refreshProviderCredentialPool } = require("/opt/ouro/dist/heart/provider-credentials.js");
1313
+ const refreshed = await refreshProviderCredentialPool("sanctuary", {
1314
+ providers: ["minimax"],
1315
+ skipCache: true,
1316
+ });
1317
+ if (!refreshed.ok) throw new Error();
1318
+ const minimax = refreshed.pool.providers["minimax"];
1319
+ const exactRecord = (record, provider) => record && record.provider === provider
1320
+ && typeof record.revision === "string" && record.revision.length > 0
1321
+ && typeof record.credentials?.apiKey === "string" && record.credentials.apiKey.trim().length > 0;
1322
+ if (!exactRecord(minimax, "minimax")) throw new Error();
1323
+ process.stdout.write("Sanctuary provider readiness verified.\n");
1324
+ } catch {
1325
+ process.stderr.write("Sanctuary provider readiness verification failed.\n");
1326
+ process.exitCode = 1;
1327
+ }
1328
+ })();
1329
+ NODE
1330
+ ' || return $?
1331
+ ! docker container inspect ouro-butler-provider-readiness >/dev/null 2>&1 || return 1
1332
+ validate_sanctuary_roots "$READINESS_RUNTIME_ROOT" "$READINESS_AGENT_ROOT" || return $?
1333
+ }
1334
+ run_sanctuary_docker() {
1335
+ /usr/bin/timeout -s KILL 20 /usr/bin/docker "$@"
1336
+ }
1337
+ run_sanctuary_unraid_api() {
1338
+ /usr/bin/timeout -s KILL 20 /usr/local/sbin/unraid-api "$@"
1339
+ }
1340
+ run_sanctuary_node() {
1341
+ /usr/local/bin/node "$@"
1342
+ }
1343
+ resolve_sanctuary_unraid_key() {
1344
+ run_sanctuary_node -e '
1345
+ const fs = require("node:fs");
1346
+ const root = "/boot/config/plugins/dynamix.my.servers/keys";
1347
+ const expected = process.argv[1];
1348
+ const found = [];
1349
+ for (const name of fs.readdirSync(root)) {
1350
+ if (!/^[A-Za-z0-9._:-]+\.json$/.test(name)) process.exit(1);
1351
+ const path = `${root}/${name}`;
1352
+ const stat = fs.lstatSync(path);
1353
+ if (!stat.isFile() || stat.isSymbolicLink()) process.exit(1);
1354
+ const value = JSON.parse(fs.readFileSync(path, "utf8"));
1355
+ if (value.id === expected) found.push({ path, name: value.name });
1356
+ }
1357
+ if (found.length !== 1 || typeof found[0].name !== "string" || !/^[A-Za-z0-9 ._:-]+$/.test(found[0].name)) process.exit(1);
1358
+ process.stdout.write(`${found[0].path}\t${found[0].name}`);
1359
+ ' "$1"
1360
+ }
1361
+ sanctuary_revoked_key_recovery_path() {
1362
+ case "$1" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
1363
+ printf '%s/%s.json' /mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof "$1"
1364
+ }
1365
+ validate_sanctuary_revoked_key_recovery() {
1366
+ test -f "$1" && test ! -L "$1" || return 1
1367
+ test "$(stat -c '%u:%g %a' "$1")" = "0:0 600" || return 1
1368
+ }
1369
+ preserve_sanctuary_revoked_key() {
1370
+ KEY_ID=$1
1371
+ KEY_SOURCE=$2
1372
+ RECOVERY_ROOT=/mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof
1373
+ test -f "$KEY_SOURCE" && test ! -L "$KEY_SOURCE" || return 1
1374
+ if test ! -e "$RECOVERY_ROOT"; then
1375
+ install -d -m 0700 -o root -g root "$RECOVERY_ROOT" || return $?
1376
+ fi
1377
+ test -d "$RECOVERY_ROOT" && test ! -L "$RECOVERY_ROOT" || return 1
1378
+ test "$(stat -c '%u:%g %a' "$RECOVERY_ROOT")" = "0:0 700" || return 1
1379
+ RECOVERY_PATH=$(sanctuary_revoked_key_recovery_path "$KEY_ID") || return $?
1380
+ test ! -e "$RECOVERY_PATH" || return 1
1381
+ RECOVERY_TMP=$(mktemp "$RECOVERY_ROOT/$KEY_ID.json.tmp.XXXXXX") || return $?
1382
+ trap 'rm -f -- "$RECOVERY_TMP"' EXIT || return $?
1383
+ install -m 0600 -o root -g root "$KEY_SOURCE" "$RECOVERY_TMP" || return $?
1384
+ cmp -s "$KEY_SOURCE" "$RECOVERY_TMP" || return 1
1385
+ mv -f -- "$RECOVERY_TMP" "$RECOVERY_PATH" || return $?
1386
+ sync -f "$RECOVERY_ROOT" || return $?
1387
+ trap - EXIT || return $?
1388
+ }
1389
+ clear_sanctuary_revoked_key() {
1390
+ RECOVERY_PATH=$(sanctuary_revoked_key_recovery_path "$1") || return $?
1391
+ test -f "$RECOVERY_PATH" && test ! -L "$RECOVERY_PATH" || return 1
1392
+ test "$(stat -c '%u:%g %a' "$RECOVERY_PATH")" = "0:0 600" || return 1
1393
+ rm -f -- "$RECOVERY_PATH" || return $?
1394
+ sync -f "$(dirname "$RECOVERY_PATH")" || return $?
1395
+ }
1396
+ verify_vault_backed_unraid_key() {
1397
+ KEY_ID=$1
1398
+ CAPABILITY=$2
1399
+ case "$KEY_ID" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
1400
+ case "$CAPABILITY" in read-only|bounded-write) ;; *) return 1 ;; esac
1401
+ IMAGE_DIGEST=${IMAGE_ID#sha256:}
1402
+ test "$IMAGE_DIGEST" != "$IMAGE_ID" || return 1
1403
+ test "${#IMAGE_DIGEST}" -eq 64 || return 1
1404
+ case "$IMAGE_DIGEST" in *[!0-9a-f]*) return 1 ;; esac
1405
+ test "$(run_sanctuary_docker image inspect --format '{{.Id}}' "$IMAGE_ID")" = "$IMAGE_ID" || return $?
1406
+ KEY_TARGET=$(resolve_sanctuary_unraid_key "$KEY_ID") || return $?
1407
+ KEY_PATH=${KEY_TARGET%% *}
1408
+ test "$KEY_PATH" != "$KEY_TARGET" || return 1
1409
+ test -f "$KEY_PATH" && test ! -L "$KEY_PATH" || return 1
1410
+ VERIFY_RESULT=$(run_sanctuary_docker run --rm --pull=never --network host \
1411
+ --user 0:0 \
1412
+ --mount type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli,readonly \
1413
+ --mount type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro,readonly \
1414
+ --mount type=bind,src="$KEY_PATH",dst=/run/ouro-acceptance/unraid-key.json,readonly \
1415
+ --entrypoint /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh \
1416
+ "$IMAGE_ID" vault-probe "$KEY_ID" "$CAPABILITY") || return $?
1417
+ printf '%s' "$VERIFY_RESULT" | run_sanctuary_node -e '
1418
+ let input = "";
1419
+ process.stdin.setEncoding("utf8");
1420
+ process.stdin.on("data", chunk => { input += chunk; });
1421
+ process.stdin.on("end", () => {
1422
+ const value = JSON.parse(input);
1423
+ const expectedId = process.argv[1];
1424
+ const expectedCapability = process.argv[2];
1425
+ const expectedProof = expectedCapability === "read-only"
1426
+ ? "read-authorized-write-denied"
1427
+ : "read-authorized-write-reached-not-found";
1428
+ if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(["capability", "keyId", "proof", "valid"])) process.exit(1);
1429
+ if (value.valid !== true || value.keyId !== expectedId || value.capability !== expectedCapability) process.exit(1);
1430
+ if (value.proof !== expectedProof) process.exit(1);
1431
+ });
1432
+ ' "$KEY_ID" "$CAPABILITY" || return $?
1433
+ }
1434
+ inventory_unraid_key_ids() {
1435
+ IMAGE_DIGEST=${IMAGE_ID#sha256:}
1436
+ test "$IMAGE_DIGEST" != "$IMAGE_ID" || return 1
1437
+ test "${#IMAGE_DIGEST}" -eq 64 || return 1
1438
+ case "$IMAGE_DIGEST" in *[!0-9a-f]*) return 1 ;; esac
1439
+ test "$(run_sanctuary_docker image inspect --format '{{.Id}}' "$IMAGE_ID")" = "$IMAGE_ID" || return $?
1440
+ INVENTORY_RESULT=$(printf '%s' '{"operation":"closed-inventory"}' | \
1441
+ run_sanctuary_docker run --rm -i --pull=never --network none \
1442
+ --user 0:0 --read-only \
1443
+ --mount type=bind,src=/boot/config/plugins/dynamix.my.servers/keys,dst=/boot/config/plugins/dynamix.my.servers/keys,readonly \
1444
+ --entrypoint /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh "$IMAGE_ID") || return $?
1445
+ printf '%s' "$INVENTORY_RESULT" | run_sanctuary_node -e '
1446
+ let input = "";
1447
+ process.stdin.setEncoding("utf8");
1448
+ process.stdin.on("data", chunk => { input += chunk; });
1449
+ process.stdin.on("end", () => {
1450
+ const value = JSON.parse(input);
1451
+ if (!value || JSON.stringify(Object.keys(value)) !== JSON.stringify(["keys"]) || !Array.isArray(value.keys)) process.exit(1);
1452
+ for (const key of value.keys) {
1453
+ if (!key || JSON.stringify(Object.keys(key).sort()) !== JSON.stringify(["id", "roles", "scope"])) process.exit(1);
1454
+ if (!/^[A-Za-z0-9._:-]+$/.test(key.id)) process.exit(1);
1455
+ if (!["read-only", "bounded-write", "legacy-write"].includes(key.scope) || key.roles !== "none") process.exit(1);
1456
+ process.stdout.write(`${key.id}\t${key.scope}\t${key.roles}\n`);
1457
+ }
1458
+ });
1459
+ ' || return $?
1460
+ }
1461
+ revoke_unraid_key_exact() {
1462
+ KEY_ID=$1
1463
+ case "$KEY_ID" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
1464
+ test "${REVOKED_KEY_FD_OPEN:-no}" = no || return 1
1465
+ KEY_TARGET=$(resolve_sanctuary_unraid_key "$KEY_ID") || return $?
1466
+ KEY_PATH=${KEY_TARGET%% *}
1467
+ KEY_NAME=${KEY_TARGET#* }
1468
+ test "$KEY_PATH" != "$KEY_TARGET" || return 1
1469
+ test -f "$KEY_PATH" && test ! -L "$KEY_PATH" || return $?
1470
+ preserve_sanctuary_revoked_key "$KEY_ID" "$KEY_PATH" || return $?
1471
+ if ! REVOKE_RESULT=$(run_sanctuary_unraid_api apikey --name "$KEY_NAME" --delete --json); then
1472
+ return 1
1473
+ fi
1474
+ if ! printf '%s' "$REVOKE_RESULT" | run_sanctuary_node -e '
1475
+ let input = "";
1476
+ process.stdin.setEncoding("utf8");
1477
+ process.stdin.on("data", chunk => { input += chunk; });
1478
+ process.stdin.on("end", () => {
1479
+ const value = JSON.parse(input);
1480
+ const expectedId = process.argv[1];
1481
+ const expectedName = process.argv[2];
1482
+ if (value.deleted !== 1 || !Array.isArray(value.keys) || value.keys.length !== 1) process.exit(1);
1483
+ if (value.keys[0].id !== expectedId || value.keys[0].name !== expectedName) process.exit(1);
1484
+ });
1485
+ ' "$KEY_ID" "$KEY_NAME"; then
1486
+ return 1
1487
+ fi
1488
+ }
1489
+ verify_revoked_unraid_key_rejected() {
1490
+ KEY_ID=$1
1491
+ case "$KEY_ID" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
1492
+ REVOKED_KEY_RECOVERY_PATH=$(sanctuary_revoked_key_recovery_path "$KEY_ID") || return $?
1493
+ validate_sanctuary_revoked_key_recovery "$REVOKED_KEY_RECOVERY_PATH" || return $?
1494
+ IMAGE_DIGEST=${IMAGE_ID#sha256:}
1495
+ test "$IMAGE_DIGEST" != "$IMAGE_ID" || return 1
1496
+ test "${#IMAGE_DIGEST}" -eq 64 || return 1
1497
+ case "$IMAGE_DIGEST" in *[!0-9a-f]*) return 1 ;; esac
1498
+ test "$(run_sanctuary_docker image inspect --format '{{.Id}}' "$IMAGE_ID")" = "$IMAGE_ID" || return 1
1499
+ REJECT_ATTEMPT=0
1500
+ while test "$REJECT_ATTEMPT" -lt 3; do
1501
+ REJECT_ATTEMPT=$((REJECT_ATTEMPT + 1))
1502
+ if REJECT_RESULT=$(run_sanctuary_docker run --rm -i --pull=never --network host \
1503
+ --user 10001:10001 --read-only \
1504
+ --entrypoint /bin/sh "$IMAGE_ID" -ceu \
1505
+ 'exec 3<&0; exec /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh revoked-probe "$1" "$2" 3<&3' \
1506
+ sanctuary-revoked-probe "$KEY_ID" http://127.0.0.1/graphql <"$REVOKED_KEY_RECOVERY_PATH") \
1507
+ && printf '%s' "$REJECT_RESULT" | run_sanctuary_node -e '
1508
+ let input = "";
1509
+ process.stdin.setEncoding("utf8");
1510
+ process.stdin.on("data", chunk => { input += chunk; });
1511
+ process.stdin.on("end", () => {
1512
+ const value = JSON.parse(input);
1513
+ if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(["id", "rejected", "status"])) process.exit(1);
1514
+ if (value.rejected !== true || value.id !== process.argv[1] || ![401, 403].includes(value.status)) process.exit(1);
1515
+ });
1516
+ ' "$KEY_ID"; then
1517
+ clear_sanctuary_revoked_key "$KEY_ID" || return $?
1518
+ return 0
1519
+ fi
1520
+ test "$REJECT_ATTEMPT" -lt 3 || return 1
1521
+ sleep 2
1522
+ done
1523
+ return 1
1524
+ }
1525
+ Update:
1526
+ Build and verify a new ouro-butler:<version> image first. The tag is only a
1527
+ lookup handle and never authorizes container creation. Resolve and validate
1528
+ the exact local Docker image ID before staging:
1529
+ IMAGE_TAG=ouro-butler:<version>
1530
+ IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$IMAGE_TAG")
1531
+ printf '%s\n' "$IMAGE_ID" | grep -Eq '^sha256:[0-9a-f]{64}$'
1532
+ docker image inspect "$IMAGE_ID" >/dev/null
1533
+ AUDIT_RUNNER_IMAGE_ID=$IMAGE_ID
1534
+ validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID"
1535
+ 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:
1536
+ EVENT_ASSET_STAGE=$(mktemp -d /mnt/user/appdata/ouro-butler/staging/ouro-events.XXXXXX)
1537
+ chmod 0700 "$EVENT_ASSET_STAGE"
1538
+ EVENT_SCRIPT_STAGE="$EVENT_ASSET_STAGE/ouro-events"
1539
+ mkdir "$EVENT_SCRIPT_STAGE"
1540
+ STAGED_TEMPLATE="$EVENT_ASSET_STAGE/sanctuary.xml"
1541
+ STAGED_RUNTIME_POLICY="$EVENT_ASSET_STAGE/container-runtime.json"
1542
+ EVENT_ASSET_CONTAINER=
1543
+ cleanup_event_asset_stage() {
1544
+ if test -n "$EVENT_ASSET_CONTAINER"; then
1545
+ docker rm --force "$EVENT_ASSET_CONTAINER" >/dev/null 2>&1 || true
1546
+ fi
1547
+ rm -rf -- "$EVENT_ASSET_STAGE"
1548
+ }
1549
+ trap cleanup_event_asset_stage EXIT
1550
+ EVENT_ASSET_CONTAINER=$(docker create --pull=never --network none --read-only --entrypoint /bin/false "$IMAGE_ID")
1551
+ test "$(docker inspect --format '{{.Image}}' "$EVENT_ASSET_CONTAINER")" = "$IMAGE_ID"
1552
+ docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/ouro-events/." "$EVENT_SCRIPT_STAGE/"
1553
+ docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/sanctuary.xml" "$STAGED_TEMPLATE"
1554
+ docker cp "$EVENT_ASSET_CONTAINER:/opt/ouro/deploy/unraid/container-runtime.json" "$STAGED_RUNTIME_POLICY"
1555
+ docker rm "$EVENT_ASSET_CONTAINER"
1556
+ EVENT_ASSET_CONTAINER=
1557
+ EXPECTED_RELEASE_ASSETS=$(printf '%s\n' container-runtime.json ouro-events sanctuary.xml)
1558
+ ACTUAL_RELEASE_ASSETS=$(find "$EVENT_ASSET_STAGE" -mindepth 1 -maxdepth 1 -exec basename {} \; | LC_ALL=C sort)
1559
+ test "$ACTUAL_RELEASE_ASSETS" = "$EXPECTED_RELEASE_ASSETS"
1560
+ test -d "$EVENT_SCRIPT_STAGE" && test ! -L "$EVENT_SCRIPT_STAGE"
1561
+ test -f "$STAGED_TEMPLATE" && test ! -L "$STAGED_TEMPLATE"
1562
+ test -f "$STAGED_RUNTIME_POLICY" && test ! -L "$STAGED_RUNTIME_POLICY"
1563
+ EXPECTED_EVENT_ASSETS=$(printf '%s\n' bootstrap-spool.sh emit-event.mjs emit-usenet-event.sh install-usenet-guard.sh usenet-health.sh)
1564
+ ACTUAL_EVENT_ASSETS=$(find "$EVENT_SCRIPT_STAGE" -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | LC_ALL=C sort)
1565
+ test "$ACTUAL_EVENT_ASSETS" = "$EXPECTED_EVENT_ASSETS"
1566
+ test -z "$(find "$EVENT_SCRIPT_STAGE" -mindepth 1 -maxdepth 1 \( -type l -o ! -type f \) -print -quit)"
1567
+ /bin/bash "$EVENT_SCRIPT_STAGE/install-usenet-guard.sh" --source-root "$EVENT_SCRIPT_STAGE"
1568
+ /bin/bash /boot/config/custom/ouro-events/install-usenet-guard.sh --boot --install-root /boot/config/custom
1569
+ verify_installed_usenet_guard() {
1570
+ test "$(findmnt -n -o FSTYPE --target /boot/config/custom)" = vfat
1571
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/usenet_health.sh)" = 0:0:600
1572
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/bootstrap-spool.sh)" = 0:0:600
1573
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/emit-event.mjs)" = 0:0:600
1574
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/emit-usenet-event.sh)" = 0:0:600
1575
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/install-usenet-guard.sh)" = 0:0:600
1576
+ cmp -s "$EVENT_SCRIPT_STAGE/usenet-health.sh" /boot/config/custom/usenet_health.sh
1577
+ cmp -s "$EVENT_SCRIPT_STAGE/bootstrap-spool.sh" /boot/config/custom/ouro-events/bootstrap-spool.sh
1578
+ cmp -s "$EVENT_SCRIPT_STAGE/emit-event.mjs" /boot/config/custom/ouro-events/emit-event.mjs
1579
+ cmp -s "$EVENT_SCRIPT_STAGE/emit-usenet-event.sh" /boot/config/custom/ouro-events/emit-usenet-event.sh
1580
+ cmp -s "$EVENT_SCRIPT_STAGE/install-usenet-guard.sh" /boot/config/custom/ouro-events/install-usenet-guard.sh
1581
+ test "$(grep -Fxc '/bin/bash /boot/config/custom/ouro-events/install-usenet-guard.sh --boot --install-root /boot/config/custom' /boot/config/go)" = 1
1582
+ test "$(grep -Fxc '/boot/config/custom/ouro-events/bootstrap-spool.sh --mount' /boot/config/go || true)" = 0
1583
+ 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
1584
+ INSTALLED_GUARD_CRON=$(crontab -l)
1585
+ test "$(printf '%s\n' "$INSTALLED_GUARD_CRON" | grep -Fxc '*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh # ouro:usenet-health')" = 1
1586
+ INSTALLED_LEGACY_GUARD_COUNT=$(printf '%s\n' "$INSTALLED_GUARD_CRON" | grep -Fxc '*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh' || true)
1587
+ test "$INSTALLED_LEGACY_GUARD_COUNT" = 0
1588
+ test "$(printf '%s\n' "$INSTALLED_GUARD_CRON" | grep -Fc '/bin/bash /boot/config/custom/usenet_health.sh')" = 1
1589
+ test "$(findmnt -n -o FSTYPE --target /boot/config/custom/ouro-events/spool)" = tmpfs
1590
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/spool)" = 0:0:755
1591
+ INSTALLED_SPOOL_OPTIONS=$(findmnt -n -o OPTIONS --target /boot/config/custom/ouro-events/spool)
1592
+ for INSTALLED_SPOOL_OPTION in nodev nosuid noexec; do
1593
+ case ",$INSTALLED_SPOOL_OPTIONS," in *",$INSTALLED_SPOOL_OPTION,"*) ;; *) return 1 ;; esac
1594
+ done
1595
+ INSTALLED_SPOOL_SIZE=$(findmnt -bn -o SIZE --target /boot/config/custom/ouro-events/spool)
1596
+ test "$INSTALLED_SPOOL_SIZE" -le 4194304
1597
+ }
1598
+ verify_installed_usenet_guard
1599
+ /usr/local/bin/node -e '
1600
+ const fs = require("node:fs");
1601
+ const [templatePath, imageId] = process.argv.slice(1);
1602
+ if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error("invalid exact image ID");
1603
+ const source = fs.readFileSync(templatePath, "utf8");
1604
+ const repositories = [...source.matchAll(/<Repository>[^<]*<\/Repository>/g)];
1605
+ if (repositories.length !== 1) throw new Error("template must contain exactly one Repository element");
1606
+ const staged = source.replace(repositories[0][0], `<Repository>${imageId}</Repository>`);
1607
+ if ((staged.match(/<Repository>[^<]*<\/Repository>/g) ?? []).length !== 1
1608
+ || !staged.includes(`<Repository>${imageId}</Repository>`)) throw new Error("exact Repository staging failed");
1609
+ fs.writeFileSync(templatePath, staged);
1610
+ ' "$STAGED_TEMPLATE" "$IMAGE_ID"
1611
+ 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.
1612
+ Initial install/adoption is a separate terminal path for the verified live
1613
+ legacy state: no production or rollback, exactly one running (possibly
1614
+ unhealthy) ouro-butler-staging, and no legacy-evidence container. After the
1615
+ helper definitions above are loaded and IMAGE_ID is resolved, run this exact
1616
+ sequence. MiniMax credentials are imported from the byte-verified legacy
1617
+ bootstrap envelope; do not place credentials in arguments, shell variables, or
1618
+ history.
1619
+ These provider-readiness commands are adoption-only; do not use them as a
1620
+ normal-update precheck against an active canonical production daemon.
1621
+ Sanctuary legacy adoption commands:
1622
+ prepare_sanctuary_legacy_adoption "$IMAGE_ID"
1623
+ verify_sanctuary_provider_readiness "$IMAGE_ID"
1624
+ install_from_legacy_staging
1625
+ These commands are one terminal path; after install succeeds, stop and do not
1626
+ continue into the normal update below. The final install is noninteractive:
1627
+ it reruns resumable preparation plus fresh readiness, but never authentication.
1628
+ That function never applies the canonical auditor to the known-noncanonical
1629
+ legacy container or restarts it. It verifies and pins the legacy image,
1630
+ provisions the absent canonical roots from the exact target image's packaged
1631
+ Sanctuary skeleton, proves required files plus exact ownership/modes, and runs
1632
+ same-image vault bootstrap and provider readiness before durably snapshotting
1633
+ both exact container and image inspect records. Any preparation, vault, or
1634
+ readiness failure returns while legacy remains running with autostart
1635
+ untouched. It then stops and rechecks the legacy container and renames it to
1636
+ stopped ouro-butler-legacy-evidence. A fresh canonical staging container is
1637
+ created from exact IMAGE_ID and canonical binds, audited, started, proven to
1638
+ be the only running Butler, readiness-checked, then stopped and removed only
1639
+ after its image is rechecked. Only then is fresh canonical production created,
1640
+ audited, started, readiness-checked, and placed in bare production autostart.
1641
+ Failure removes only partial target-image staging/production containers,
1642
+ quarantines the exact legacy container without deletion or restart, leaves
1643
+ autostart disabled, and propagates the original status.
1644
+ For this cutover, preflight either the exact known alpha.742 two-mount source
1645
+ or an already-canonical source before any autostart or container mutation.
1646
+ Production must be the only running Butler poller;
1647
+ staging must be absent; rollback may be absent or one stopped container with
1648
+ the exact production image. A stopped legacy-evidence container is preserved.
1649
+ Disable every Butler name in Unraid's array-autostart file and verify that
1650
+ result before stopping production. First resolve and validate the exact image
1651
+ ID of the known-good production container while it is still running, so a
1652
+ lookup failure cannot strand a renamed container:
1653
+ recover_pending_sanctuary_bundle_migration "$IMAGE_ID"
1654
+ /bin/bash /boot/config/custom/ouro-events/bootstrap-spool.sh --mount
1655
+ test "$(findmnt -n -o FSTYPE --target /boot/config/custom/ouro-events/spool)" = tmpfs
1656
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/spool)" = 0:0:755
1657
+ ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
1658
+ validate_exact_image_id "$ROLLBACK_IMAGE_ID"
1659
+ if assert_update_topology "$ROLLBACK_IMAGE_ID"; then
1660
+ :
1661
+ else
1662
+ UPDATE_PREFLIGHT_STATUS=$?
1663
+ (exit "$UPDATE_PREFLIGHT_STATUS")
1664
+ fi
1665
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1666
+ The earlier exact-image asset stage has already replaced the template's sole
1667
+ Repository element with $IMAGE_ID. After the topology and source gates pass,
1668
+ audit the staged template and runtime policy with the packaged auditor, then
1669
+ remove the private stage before any Butler mutation:
1670
+ docker run --rm --pull=never --network=none \
1671
+ --user 0:0 --read-only --cap-drop=ALL --security-opt=no-new-privileges \
1672
+ --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh \
1673
+ --mount "type=bind,src=$STAGED_TEMPLATE,dst=/audit/sanctuary.xml,readonly" \
1674
+ --mount "type=bind,src=$STAGED_RUNTIME_POLICY,dst=/audit/container-runtime.json,readonly" \
1675
+ "$IMAGE_ID" --template /audit/sanctuary.xml --runtime-policy /audit/container-runtime.json --expected-image "$IMAGE_ID"
1676
+ cleanup_event_asset_stage
1677
+ trap - EXIT
1678
+ if provision_sanctuary_sab_credential "$IMAGE_ID" \
1679
+ && verify_sanctuary_sab_readiness "$IMAGE_ID"; then
1680
+ :
1681
+ else
1682
+ SAB_READINESS_STATUS=$?
1683
+ (exit "$SAB_READINESS_STATUS")
1684
+ fi
1685
+ Guard the atomic autostart disable separately. If it fails, production has not
1686
+ been touched and the captured status is propagated:
1687
+ if disable_butler_autostart; then
1688
+ :
1689
+ else
1690
+ AUTOSTART_DISABLE_STATUS=$?
1691
+ (exit "$AUTOSTART_DISABLE_STATUS")
1692
+ fi
1693
+ Define the stale-rollback cleanup used by the preparation guard:
1694
+ remove_stopped_rollback_if_present() {
1695
+ EXPECTED_STALE_ROLLBACK_IMAGE_ID=$1
1696
+ ROLLBACK_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
1697
+ case "
1698
+ $ROLLBACK_CONTAINER_NAMES
1699
+ " in
1700
+ *"
1701
+ ouro-butler-rollback
1702
+ "*)
1703
+ ROLLBACK_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback) || return $?
1704
+ test "$ROLLBACK_RUNNING" = false || return $?
1705
+ CURRENT_STALE_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
1706
+ test "$CURRENT_STALE_ROLLBACK_IMAGE_ID" = "$EXPECTED_STALE_ROLLBACK_IMAGE_ID" || return $?
1707
+ docker rm ouro-butler-rollback || return $?
1708
+ ;;
1709
+ esac
1710
+ }
1711
+ Stop production, remove only a stopped stale rollback, rename the known-good
1712
+ container, verify it remains stopped, and apply the exact target image's
1713
+ package-managed bundle migration in one explicit preparation guard.
1714
+ Package-managed files are exactly `provider-readiness.json`,
1715
+ `tool-profiles.json`, `habits/sanctuary-health.md`, and the five canonical
1716
+ files under `psyche/`. The migration also merges the three bundle-meta version
1717
+ fields. It never installs or mutates steward policy authority. It preserves
1718
+ agent.json, all steward policy and audit bytes, relationships, sessions, and
1719
+ every other state path. Repeating it is a no-op.
1720
+ Before its first managed write, the migrator atomically fsyncs one mode-0600
1721
+ rollback record at `.sanctuary-package-managed-rollback.json`. That transient
1722
+ record contains a verified SHA-256 digest over the exact prior bytes, modes,
1723
+ and parent existence for those
1724
+ files plus bundle-meta, bound to distinct exact rollback and target image IDs.
1725
+ It is the migration transaction record, not a second bundle authority. Normal
1726
+ managed-file failure restores it immediately; every
1727
+ later container rollback restores it before the old container restarts, while
1728
+ successful production readiness and autostart commit it. A killed updater is
1729
+ reconciled from the same record before update topology preflight: partial new
1730
+ containers are removed, interrupted atomic-write stages are discarded, the
1731
+ exact prior bundle is restored idempotently, and only the image ID recorded in
1732
+ the transaction may resume as production. A malformed record, wrong image,
1733
+ or restore failure leaves autostart disabled and fails closed. Once target
1734
+ readiness and autostart have passed, commit first durably renames the record;
1735
+ a kill then keeps target production in place and the next run validates its
1736
+ exact topology/readiness before finishing commit. Migration failure then enters the same exact container
1737
+ rollback arm before staging starts:
1738
+ if docker stop ouro-butler \
1739
+ && remove_stopped_rollback_if_present "$ROLLBACK_IMAGE_ID" \
1740
+ && docker rename ouro-butler ouro-butler-rollback \
1741
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false \
1742
+ && assert_only_running_butler - \
1743
+ && migrate_sanctuary_package_managed_bundle "$IMAGE_ID" migrate "$ROLLBACK_IMAGE_ID"; then
1744
+ :
1745
+ else
1746
+ PRODUCTION_PREPARATION_STATUS=$?
1747
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
1748
+ if docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
1749
+ docker stop ouro-butler-rollback >/dev/null 2>&1 || true
1750
+ CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback)
1751
+ test "$CURRENT_ROLLBACK_IMAGE_ID" = "$ROLLBACK_IMAGE_ID"
1752
+ docker rm --force ouro-butler-rollback >/dev/null 2>&1 || true
1753
+ fi
1754
+ ! docker container inspect ouro-butler-rollback >/dev/null 2>&1
1755
+ rollback_sanctuary_bundle_if_pending "$IMAGE_ID"
1756
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1757
+ docker start ouro-butler
1758
+ assert_only_running_butler ouro-butler
1759
+ wait_butler_ready ouro-butler
1760
+ enable_butler_autostart
1761
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
1762
+ elif docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
1763
+ docker stop ouro-butler-rollback >/dev/null 2>&1 || true
1764
+ test "$(docker inspect --format '{{.Image}}' ouro-butler-rollback)" = "$ROLLBACK_IMAGE_ID"
1765
+ rollback_sanctuary_bundle_if_pending "$IMAGE_ID"
1766
+ docker rename ouro-butler-rollback ouro-butler
1767
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1768
+ docker start ouro-butler
1769
+ assert_only_running_butler ouro-butler
1770
+ wait_butler_ready ouro-butler
1771
+ enable_butler_autostart
1772
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
1773
+ fi
1774
+ (exit "$PRODUCTION_PREPARATION_STATUS")
1775
+ fi
1776
+ Preparation failure therefore either restores the still-named exact production
1777
+ after removing any stale rollback, or renames the exact stopped rollback back;
1778
+ both recoveries revalidate, start, bounded-wait, atomically restore production-only
1779
+ autostart, and propagate the original failure. If neither exact container can
1780
+ be found, the failure propagates with Butler autostart disabled.
1781
+ Put the entire post-rename staging phase in one explicit conditional so
1782
+ `set -eu` cannot bypass rollback at the create, effective-audit, start, or
1783
+ bounded-readiness boundary. Staging uses the exact image ID with no command,
1784
+ environment, port, device, capability, privilege, or extra mount override.
1785
+ A passing staging container is stopped and removed inside the same condition,
1786
+ completing the poller handoff before production creation:
1787
+ if docker create --name ouro-butler-staging --network host --restart unless-stopped --user 10001:10001 \
1788
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
1789
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1790
+ --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
1791
+ "$IMAGE_ID" \
1792
+ && audit_effective ouro-butler-staging "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" \
1793
+ && docker start ouro-butler-staging \
1794
+ && assert_only_running_butler ouro-butler-staging \
1795
+ && wait_butler_ready ouro-butler-staging \
1796
+ && docker stop ouro-butler-staging \
1797
+ && docker rm ouro-butler-staging \
1798
+ && assert_only_running_butler -; then
1799
+ :
1800
+ else
1801
+ STAGING_ACTIVATION_STATUS=$?
1802
+ if docker container inspect ouro-butler-staging >/dev/null 2>&1; then
1803
+ docker stop ouro-butler-staging >/dev/null 2>&1 || true
1804
+ PARTIAL_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-staging)
1805
+ test "$PARTIAL_STAGING_IMAGE_ID" = "$IMAGE_ID"
1806
+ docker rm --force ouro-butler-staging >/dev/null 2>&1 || true
1807
+ fi
1808
+ ! docker container inspect ouro-butler-staging >/dev/null 2>&1
1809
+ CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback)
1810
+ test "$CURRENT_ROLLBACK_IMAGE_ID" = "$ROLLBACK_IMAGE_ID"
1811
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" rollback
1812
+ docker rename ouro-butler-rollback ouro-butler
1813
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1814
+ docker start ouro-butler
1815
+ assert_only_running_butler ouro-butler
1816
+ wait_butler_ready ouro-butler
1817
+ enable_butler_autostart
1818
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
1819
+ (exit "$STAGING_ACTIVATION_STATUS")
1820
+ fi
1821
+ The failure arm safely handles staging that was never created, remains
1822
+ stopped, is running, or exited: it force-removes any partial staging state,
1823
+ verifies the name is absent, restores and revalidates the old production against
1824
+ its exact pre-recorded image ID, starts and bounded-waits it, atomically
1825
+ restores production-only autostart, and propagates the original failure.
1826
+ At no point may production and staging run together against the same Telegram token.
1827
+ Create and activate production from the same exact image ID and exact authority
1828
+ in one explicit conditional so `set -eu` cannot exit before rollback. Only a
1829
+ successful create, effective audit, start, stopped rollback assertion, and
1830
+ bounded readiness wait may enable production autostart. Any failure captures
1831
+ the activation status, safely removes a partially created new production if it
1832
+ exists, restores the stopped rollback container, audits it against its exact
1833
+ old image ID, proves it ready, atomically enables only production autostart,
1834
+ and then propagates the original activation failure:
1835
+ if docker create --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
1836
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
1837
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1838
+ --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
1839
+ "$IMAGE_ID" \
1840
+ && audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" \
1841
+ && docker start ouro-butler \
1842
+ && assert_only_running_butler ouro-butler \
1843
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false \
1844
+ && wait_butler_ready ouro-butler \
1845
+ && enable_butler_autostart; then
1846
+ :
1847
+ else
1848
+ PRODUCTION_ACTIVATION_STATUS=$?
1849
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
1850
+ docker stop ouro-butler >/dev/null 2>&1 || true
1851
+ PARTIAL_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
1852
+ test "$PARTIAL_PRODUCTION_IMAGE_ID" = "$IMAGE_ID"
1853
+ docker rm --force ouro-butler >/dev/null 2>&1 || true
1854
+ fi
1855
+ ! docker container inspect ouro-butler >/dev/null 2>&1
1856
+ CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback)
1857
+ test "$CURRENT_ROLLBACK_IMAGE_ID" = "$ROLLBACK_IMAGE_ID"
1858
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" rollback
1859
+ docker rename ouro-butler-rollback ouro-butler
1860
+ assert_update_source "$ROLLBACK_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1861
+ docker start ouro-butler
1862
+ assert_only_running_butler ouro-butler
1863
+ wait_butler_ready ouro-butler
1864
+ enable_butler_autostart
1865
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
1866
+ (exit "$PRODUCTION_ACTIVATION_STATUS")
1867
+ fi
1868
+ migrate_sanctuary_package_managed_bundle "$IMAGE_ID" commit
1869
+ Keep ouro-butler-rollback stopped until the new production container is proven
1870
+ or the explicit rollback arm restores it.
1871
+ Never create a container from the mutable lookup tag.
1872
+
1873
+ Backup:
1874
+ Set BACKUP_ROOT to a new absolute snapshot path on the destination filesystem.
1875
+ Set AUDIT_RUNNER_IMAGE_TAG to the reviewed new image containing the
1876
+ legacy-aware auditor; the alpha.742 source image cannot be the runner.
1877
+ Capture and validate the exact source image before stopping ouro-butler, then
1878
+ build a temporary snapshot with root-only provenance and atomically rename it into place:
1879
+ test -n "${BACKUP_ROOT-}"
1880
+ case "$BACKUP_ROOT" in /*) ;; *) exit 1 ;; esac
1881
+ test "$BACKUP_ROOT" != /
1882
+ test ! -e "$BACKUP_ROOT"
1883
+ BACKUP_PARENT=$(dirname -- "$BACKUP_ROOT")
1884
+ test "$(cd -- "$BACKUP_PARENT" && pwd -P)" = "$BACKUP_PARENT"
1885
+ BACKUP_TMP=$BACKUP_ROOT.tmp.$$
1886
+ test ! -e "$BACKUP_TMP"
1887
+ AUDIT_RUNNER_IMAGE_TAG=ouro-butler:<new-version>
1888
+ AUDIT_RUNNER_IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$AUDIT_RUNNER_IMAGE_TAG")
1889
+ validate_exact_image_id "$AUDIT_RUNNER_IMAGE_ID"
1890
+ test "$AUDIT_RUNNER_IMAGE_ID" != sha256:681449ad47a2621705cd339b481e6339236b31dc65e195b1cf5025d0f2191d7d
1891
+ BACKUP_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
1892
+ validate_exact_image_id "$BACKUP_IMAGE_ID"
1893
+ assert_update_source "$BACKUP_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID"
1894
+ assert_only_running_butler ouro-butler
1895
+ wait_butler_ready ouro-butler
1896
+ BACKUP_AUTOSTART_COUNTS=$(butler_autostart_counts)
1897
+ case "$BACKUP_AUTOSTART_COUNTS" in "0 0 0 0"|"1 0 0 0") ;; *) exit 1 ;; esac
1898
+ backup_host_file() {
1899
+ BACKUP_HOST_SOURCE=$1
1900
+ BACKUP_HOST_RELATIVE=$2
1901
+ case "$BACKUP_HOST_RELATIVE" in
1902
+ 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) ;;
1903
+ *) return 1 ;;
1904
+ esac
1905
+ if test -e "$BACKUP_HOST_SOURCE" || test -L "$BACKUP_HOST_SOURCE"; then
1906
+ test -f "$BACKUP_HOST_SOURCE" && test ! -L "$BACKUP_HOST_SOURCE" || return 1
1907
+ ! host_file_contains_inline_credential "$BACKUP_HOST_SOURCE" || return 1
1908
+ install -D -m 0600 -o 0 -g 0 "$BACKUP_HOST_SOURCE" "$BACKUP_TMP/host/$BACKUP_HOST_RELATIVE" || return $?
1909
+ printf 'present\t%s\n' "$BACKUP_HOST_RELATIVE" >>"$BACKUP_TMP/host/inventory" || return $?
1910
+ else
1911
+ printf 'absent\t%s\n' "$BACKUP_HOST_RELATIVE" >>"$BACKUP_TMP/host/inventory" || return $?
1912
+ fi
1913
+ }
1914
+ host_file_contains_inline_credential() {
1915
+ LC_ALL=C awk '
1916
+ BEGIN { IGNORECASE = 1; found = 0 }
1917
+ /[0-9]{6,}:[A-Za-z0-9_-]{20,}/ { found = 1 }
1918
+ /Authorization:[[:space:]]*(Bearer|Basic)[[:space:]]+["\047]?[A-Za-z0-9][A-Za-z0-9._~+\/=:-]{11,}/ { found = 1 }
1919
+ /--(token|api-key|api_key|secret|password)(=|[[:space:]]+)["\047]?[A-Za-z0-9][A-Za-z0-9._~+\/=:-]{11,}/ { found = 1 }
1920
+ /[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\/@[:space:]]+:[^\/@[:space:]]+@/ { found = 1 }
1921
+ /-----BEGIN ([A-Z0-9]+ )*PRIVATE KEY-----/ { found = 1 }
1922
+ /(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 }
1923
+ END { exit found ? 0 : 1 }
1924
+ ' "$1"
1925
+ }
1926
+ snapshot_butler_go_fragments() {
1927
+ BACKUP_GO_SOURCE=$1
1928
+ BACKUP_GO_TARGET=$2
1929
+ : >"$BACKUP_GO_TARGET" || return $?
1930
+ while IFS= read -r BACKUP_GO_LINE || test -n "$BACKUP_GO_LINE"; do
1931
+ case "$BACKUP_GO_LINE" in
1932
+ "/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")
1933
+ printf '%s\n' "$BACKUP_GO_LINE" >>"$BACKUP_GO_TARGET" || return $?
1934
+ ;;
1935
+ esac
1936
+ done <"$BACKUP_GO_SOURCE"
1937
+ }
1938
+ snapshot_butler_cron_fragments() {
1939
+ BACKUP_CRON_INPUT=$1
1940
+ BACKUP_CRON_TARGET=$2
1941
+ : >"$BACKUP_CRON_TARGET" || return $?
1942
+ while IFS= read -r BACKUP_CRON_LINE || test -n "$BACKUP_CRON_LINE"; do
1943
+ case "$BACKUP_CRON_LINE" in
1944
+ "*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh"|"*/15 * * * * /bin/bash /boot/config/custom/usenet_health.sh # ouro:usenet-health")
1945
+ printf '%s\n' "$BACKUP_CRON_LINE" >>"$BACKUP_CRON_TARGET" || return $?
1946
+ ;;
1947
+ esac
1948
+ done <"$BACKUP_CRON_INPUT"
1949
+ }
1950
+ create_private_cron_capture() {
1951
+ BACKUP_CRON_ROOT=$(mktemp -d /tmp/ouro-backup-cron.XXXXXX) || return $?
1952
+ BACKUP_CRON_SOURCE=$BACKUP_CRON_ROOT/stdout
1953
+ BACKUP_CRON_ERROR=$BACKUP_CRON_ROOT/stderr
1954
+ test -d "$BACKUP_CRON_ROOT" && test ! -L "$BACKUP_CRON_ROOT" || return 1
1955
+ test "$(stat -c '%u:%a' "$BACKUP_CRON_ROOT" 2>/dev/null || stat -f '%u:%Lp' "$BACKUP_CRON_ROOT")" = "$(id -u):700" || return 1
1956
+ install -m 0600 /dev/null "$BACKUP_CRON_SOURCE" || return $?
1957
+ install -m 0600 /dev/null "$BACKUP_CRON_ERROR" || return $?
1958
+ test -f "$BACKUP_CRON_SOURCE" && test ! -L "$BACKUP_CRON_SOURCE" || return 1
1959
+ test -f "$BACKUP_CRON_ERROR" && test ! -L "$BACKUP_CRON_ERROR" || return 1
1960
+ test "$(stat -c '%u:%a' "$BACKUP_CRON_SOURCE" 2>/dev/null || stat -f '%u:%Lp' "$BACKUP_CRON_SOURCE")" = "$(id -u):600" || return 1
1961
+ test "$(stat -c '%u:%a' "$BACKUP_CRON_ERROR" 2>/dev/null || stat -f '%u:%Lp' "$BACKUP_CRON_ERROR")" = "$(id -u):600" || return 1
1962
+ }
1963
+ cleanup_private_cron_capture() {
1964
+ rm -f -- "$BACKUP_CRON_SOURCE" "$BACKUP_CRON_ERROR" || return $?
1965
+ rmdir -- "$BACKUP_CRON_ROOT"
1966
+ }
1967
+ snapshot_butler_host_fragments() {
1968
+ create_private_cron_capture || return $?
1969
+ trap 'cleanup_private_cron_capture' RETURN
1970
+ if test -f /boot/config/go && test ! -L /boot/config/go; then
1971
+ BACKUP_GO_STATE=present
1972
+ BACKUP_GO_DIGEST=$(sha256sum /boot/config/go | awk '{print $1}') || return $?
1973
+ snapshot_butler_go_fragments /boot/config/go "$BACKUP_TMP/host/go.butler-lines" || return $?
1974
+ elif test -e /boot/config/go || test -L /boot/config/go; then
1975
+ return 1
1976
+ else
1977
+ BACKUP_GO_STATE=absent
1978
+ BACKUP_GO_DIGEST=-
1979
+ : >"$BACKUP_TMP/host/go.butler-lines" || return $?
1980
+ fi
1981
+ BACKUP_CRON_USER=$(id -un) || return $?
1982
+ if crontab -l >"$BACKUP_CRON_SOURCE" 2>"$BACKUP_CRON_ERROR"; then
1983
+ BACKUP_CRON_STATE=present
1984
+ BACKUP_CRON_DIGEST=$(sha256sum "$BACKUP_CRON_SOURCE" | awk '{print $1}') || return $?
1985
+ else
1986
+ BACKUP_CRON_STATUS=$?
1987
+ if test "$BACKUP_CRON_STATUS" -eq 1 && grep -Fxq "no crontab for $BACKUP_CRON_USER" "$BACKUP_CRON_ERROR"; then
1988
+ BACKUP_CRON_STATE=absent
1989
+ BACKUP_CRON_DIGEST=-
1990
+ : >"$BACKUP_CRON_SOURCE" || return $?
1991
+ else
1992
+ return "$BACKUP_CRON_STATUS"
1993
+ fi
1994
+ fi
1995
+ rm -f -- "$BACKUP_CRON_ERROR" || return $?
1996
+ snapshot_butler_cron_fragments "$BACKUP_CRON_SOURCE" "$BACKUP_TMP/host/crontab.butler-lines" || return $?
1997
+ BACKUP_GO_COUNT=$(awk 'END { print NR + 0 }' "$BACKUP_TMP/host/go.butler-lines") || return $?
1998
+ BACKUP_CRON_COUNT=$(awk 'END { print NR + 0 }' "$BACKUP_TMP/host/crontab.butler-lines") || return $?
1999
+ 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 $?
2000
+ chown 0:0 "$BACKUP_TMP/host/go.butler-lines" "$BACKUP_TMP/host/crontab.butler-lines" "$BACKUP_TMP/host/global-state" || return $?
2001
+ chmod 0600 "$BACKUP_TMP/host/go.butler-lines" "$BACKUP_TMP/host/crontab.butler-lines" "$BACKUP_TMP/host/global-state" || return $?
2002
+ printf 'present\t%s\n' go.butler-lines crontab.butler-lines global-state >>"$BACKUP_TMP/host/inventory" || return $?
2003
+ cleanup_private_cron_capture || return $?
2004
+ trap - RETURN
2005
+ }
2006
+ Snapshot both of these directories together:
2007
+ /mnt/user/appdata/ouro-butler/runtime/.ouro-cli
2008
+ /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
2009
+ A routine backup must not contain container-credentials.json or
2010
+ container-credentials.json.consuming. If either exists, credential migration
2011
+ is pending or failed and must be reconciled before taking the backup.
2012
+ The Telegram control socket is live process state, not durable data. Exclude
2013
+ exactly that socket while preserving every regular file byte-for-byte, then
2014
+ require it to be absent from the stopped backup:
2015
+ BACKUP_OPERATION_STATUS=0
2016
+ if docker stop ouro-butler \
2017
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler)" = false \
2018
+ && install -d -m 0700 -o 0 -g 0 "$BACKUP_TMP" "$BACKUP_TMP/runtime" "$BACKUP_TMP/agent" "$BACKUP_TMP/host" "$BACKUP_TMP/provenance" \
2019
+ && install -d -m 0700 -o 0 -g 0 "$BACKUP_TMP/host/custom" "$BACKUP_TMP/host/custom/ouro-events" \
2020
+ && install -m 0600 -o 0 -g 0 /dev/null "$BACKUP_TMP/host/inventory" \
2021
+ && backup_host_file /boot/config/custom/usenet_health.sh custom/usenet_health.sh \
2022
+ && backup_host_file /boot/config/custom/ouro-events/bootstrap-spool.sh custom/ouro-events/bootstrap-spool.sh \
2023
+ && backup_host_file /boot/config/custom/ouro-events/emit-event.mjs custom/ouro-events/emit-event.mjs \
2024
+ && backup_host_file /boot/config/custom/ouro-events/emit-usenet-event.sh custom/ouro-events/emit-usenet-event.sh \
2025
+ && backup_host_file /boot/config/custom/ouro-events/install-usenet-guard.sh custom/ouro-events/install-usenet-guard.sh \
2026
+ && snapshot_butler_host_fragments \
2027
+ && test ! -e "$BACKUP_TMP/host/notify.conf" \
2028
+ && test ! -e "$BACKUP_TMP/host/sabnzbd.ini" \
2029
+ && rsync -a --exclude='/state/acceptance/telegram-control.sock' /mnt/user/appdata/ouro-butler/runtime/.ouro-cli "$BACKUP_TMP/runtime/" \
2030
+ && rsync -a --exclude='/state/acceptance/telegram-control.sock' /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro "$BACKUP_TMP/agent/" \
2031
+ && test ! -S "$BACKUP_TMP/agent/sanctuary.ouro/state/acceptance/telegram-control.sock" \
2032
+ && validate_sanctuary_roots "$BACKUP_TMP/runtime/.ouro-cli" "$BACKUP_TMP/agent/sanctuary.ouro" \
2033
+ && docker container inspect ouro-butler >"$BACKUP_TMP/provenance/container-inspect.json" \
2034
+ && docker image inspect "$BACKUP_IMAGE_ID" >"$BACKUP_TMP/provenance/image-inspect.json" \
2035
+ && 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" \
2036
+ && printf '%s\n' "$BACKUP_IMAGE_ID" >"$BACKUP_TMP/provenance/image-id" \
2037
+ && 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" \
2038
+ && chmod 0600 "$BACKUP_TMP/provenance/container-inspect.json" "$BACKUP_TMP/provenance/image-inspect.json" "$BACKUP_TMP/provenance/package-version" "$BACKUP_TMP/provenance/image-id" \
2039
+ && (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) \
2040
+ && chown 0:0 "$BACKUP_TMP/provenance/manifest.sha256" \
2041
+ && chmod 0600 "$BACKUP_TMP/provenance/manifest.sha256" \
2042
+ && verify_sanctuary_snapshot_provenance "$BACKUP_TMP" "$BACKUP_IMAGE_ID" \
2043
+ && sync -f "$BACKUP_TMP/provenance/manifest.sha256" \
2044
+ && mv -- "$BACKUP_TMP" "$BACKUP_ROOT" \
2045
+ && sync -f "$BACKUP_PARENT"; then
2046
+ :
2047
+ else
2048
+ BACKUP_OPERATION_STATUS=$?
2049
+ fi
2050
+ if assert_update_source "$BACKUP_IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" \
2051
+ && docker start ouro-butler \
2052
+ && assert_only_running_butler ouro-butler \
2053
+ && wait_butler_ready ouro-butler \
2054
+ && verify_butler_autostart "$BACKUP_AUTOSTART_COUNTS"; then
2055
+ :
2056
+ else
2057
+ BACKUP_RECOVERY_STATUS=$?
2058
+ if test -d "$BACKUP_ROOT"; then
2059
+ printf '%s\n' "CRITICAL: production recovery failed after backup; completed snapshot remains intact at $BACKUP_ROOT" >&2
2060
+ else
2061
+ printf '%s\n' "CRITICAL: production recovery failed and no completed snapshot was published; inspect $BACKUP_TMP" >&2
2062
+ fi
2063
+ (exit "$BACKUP_RECOVERY_STATUS")
2064
+ fi
2065
+ if test "$BACKUP_OPERATION_STATUS" -ne 0; then
2066
+ if test -d "$BACKUP_ROOT"; then
2067
+ printf '%s\n' "Backup publication was not durably confirmed; production was recovered; completed snapshot remains intact at $BACKUP_ROOT" >&2
2068
+ else
2069
+ printf '%s\n' "Backup failed before atomic publication; production was recovered; inspect $BACKUP_TMP" >&2
2070
+ fi
2071
+ (exit "$BACKUP_OPERATION_STATUS")
2072
+ fi
2073
+
2074
+ Restore:
2075
+ Set BACKUP_ROOT to the exact verified snapshot containing `runtime/.ouro-cli`
2076
+ and `agent/sanctuary.ouro`, set IMAGE_ID to its recorded local image ID, and
2077
+ set AUDIT_RUNNER_IMAGE_ID to the exact reviewed new image containing the
2078
+ legacy-aware auditor. The runner is independent from the restored image.
2079
+ AUDIT_RUNNER_IMAGE_TAG=ouro-butler:<new-version>
2080
+ AUDIT_RUNNER_IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$AUDIT_RUNNER_IMAGE_TAG")
2081
+ Before any autostart, root, or container mutation, run the nounset-safe input,
2082
+ backup-root, image, and topology preflight. It requires a nonempty canonical
2083
+ absolute BACKUP_ROOT (not /), both exact required directories, an exact local
2084
+ sha256 image ID, canonical production as the only running Butler poller, no
2085
+ staging or rollback, and at most one exact stopped legacy-evidence container.
2086
+ It also executes the reviewed runner against the live source container before
2087
+ any autostart, root, or container mutation:
2088
+ if assert_restore_preflight; then
2089
+ :
2090
+ else
2091
+ RESTORE_PREFLIGHT_STATUS=$?
2092
+ (exit "$RESTORE_PREFLIGHT_STATUS")
2093
+ fi
2094
+ The verified host snapshot is restored only by this explicit Restore path.
2095
+ Ordinary update failure continues to use the installer's short-lived
2096
+ transaction rollback. Extract the installer from the reviewed runner image,
2097
+ then let that fixed allowlist transaction restore exact presence/absence for
2098
+ the five guard assets and reapply only the captured Butler-owned go/crontab
2099
+ fragments. Unrelated current go/crontab lines are never stored in the durable
2100
+ snapshot and remain untouched by restore. The transaction deactivates the
2101
+ current owned cron before swapping files and restores the entire pre-restore
2102
+ host state if any swap or final crontab activation fails:
2103
+ HOST_RESTORE_INSTALLER=$(mktemp /tmp/ouro-usenet-host-restore.XXXXXX)
2104
+ 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"
2105
+ chmod 0500 "$HOST_RESTORE_INSTALLER"
2106
+ Guard the atomic autostart disable before stopping or removing any Butler
2107
+ container. Failure propagates while the existing production remains untouched:
2108
+ /bin/bash /boot/config/custom/ouro-events/bootstrap-spool.sh --mount
2109
+ test "$(findmnt -n -o FSTYPE --target /boot/config/custom/ouro-events/spool)" = tmpfs
2110
+ test "$(stat -c '%u:%g:%a' /boot/config/custom/ouro-events/spool)" = 0:0:755
2111
+ if disable_butler_autostart; then
2112
+ :
2113
+ else
2114
+ RESTORE_AUTOSTART_DISABLE_STATUS=$?
2115
+ (exit "$RESTORE_AUTOSTART_DISABLE_STATUS")
2116
+ fi
2117
+ Stop and remove only ouro-butler, restore both roots without stale files,
2118
+ restore exact ownership, validate the image, and create/audit/start/bounded-wait
2119
+ the restored production inside one explicit condition. The initial tolerant
2120
+ stop plus inspected force-removal safely handles an already absent or stopped
2121
+ container:
2122
+ if { docker stop ouro-butler >/dev/null 2>&1 || true; } \
2123
+ && {
2124
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
2125
+ docker rm --force ouro-butler
2126
+ else
2127
+ :
2128
+ fi
2129
+ } \
2130
+ && ! docker container inspect ouro-butler >/dev/null 2>&1 \
2131
+ && rsync -a --delete "$BACKUP_ROOT/runtime/.ouro-cli/" /mnt/user/appdata/ouro-butler/runtime/.ouro-cli/ \
2132
+ && rsync -a --delete "$BACKUP_ROOT/agent/sanctuary.ouro/" /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/ \
2133
+ && chown -R 10001:10001 /mnt/user/appdata/ouro-butler/runtime/.ouro-cli /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro \
2134
+ && validate_sanctuary_roots /mnt/user/appdata/ouro-butler/runtime/.ouro-cli /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro \
2135
+ && /bin/bash "$HOST_RESTORE_INSTALLER" --restore-root "$BACKUP_ROOT/host" \
2136
+ && rm -f "$HOST_RESTORE_INSTALLER" \
2137
+ && docker image inspect "$IMAGE_ID" >/dev/null \
2138
+ && docker create --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
2139
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
2140
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
2141
+ --mount "type=bind,src=/boot/config/custom/ouro-events/spool,dst=/run/ouro-events,readonly" \
2142
+ "$IMAGE_ID" \
2143
+ && audit_effective ouro-butler "$IMAGE_ID" "$AUDIT_RUNNER_IMAGE_ID" \
2144
+ && docker start ouro-butler \
2145
+ && assert_only_running_butler ouro-butler \
2146
+ && wait_butler_ready ouro-butler \
2147
+ && enable_butler_autostart; then
2148
+ :
2149
+ else
2150
+ RESTORE_ACTIVATION_STATUS=$?
2151
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
2152
+ docker stop ouro-butler >/dev/null 2>&1 || true
2153
+ PARTIAL_RESTORE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
2154
+ test "$PARTIAL_RESTORE_IMAGE_ID" = "$IMAGE_ID"
2155
+ docker rm --force ouro-butler >/dev/null 2>&1 || true
2156
+ fi
2157
+ ! docker container inspect ouro-butler >/dev/null 2>&1
2158
+ rm -f "$HOST_RESTORE_INSTALLER"
2159
+ (exit "$RESTORE_ACTIVATION_STATUS")
2160
+ fi
2161
+ A restore has no retained old container or pre-restore data root to roll back
2162
+ to. Any stop/remove, data replacement, ownership, image validation, create,
2163
+ audit, start, or readiness failure therefore reaches the same cleanup arm,
2164
+ leaves all Butler autostart entries disabled, and propagates the captured
2165
+ failure for operator repair or a verified restore retry.
2166
+
2167
+ Credential recovery:
2168
+ The one canonical Telegram identity is display name `Mendelow Cloud Butler`,
2169
+ username `MendelowCloudButlerBot`, numeric bot ID `8541786263`. Treat any
2170
+ different display name, username, numeric ID, or fallback handle as a hard
2171
+ preflight failure; do not start polling or send a test message.
2172
+ Run `bootstrap_sanctuary_vault "$IMAGE_ID"` interactively before installing
2173
+ credentials. It uses only fresh `--rm` containers from that exact image as
2174
+ UID/GID 10001 with the canonical two binds; create/unlock is the sole `-it`
2175
+ action so the master secret remains on the hidden terminal prompt.
2176
+ Its first read-only `vault status` is authoritative: it runs `vault create`
2177
+ only when the locator is absent, runs `vault unlock` only when the locator
2178
+ exists but local unlock is missing, and does not prompt when local unlock is
2179
+ already available. It then requires the configured locator, available local
2180
+ unlock, and successful auth verification. Never guess from a failed status
2181
+ command.
2182
+ Initial legacy adoption passes the exact legacy envelope path as the helper's
2183
+ second argument. After vault availability, the helper validates and atomically
2184
+ copies that envelope into the canonical runtime root, imports it with a fresh
2185
+ same-image one-shot container, and requires both canonical source and claim to
2186
+ be absent before provider readiness. The exact legacy source remains untouched
2187
+ as rollback evidence until canonical acceptance completes.
2188
+ Restore or unlock the Sanctuary agent vault, then run provider refresh.
2189
+ /mnt/user/appdata/ouro-butler/runtime/.ouro-cli/container-credentials.json
2190
+ exists only for a one-time migration: if
2191
+ used, install it atomically as root with ownership 10001:10001 and mode 0600.
2192
+ Startup atomically claims it as container-credentials.json.consuming and
2193
+ writes every credential class into the unlocked Sanctuary vault before deleting the claimed envelope.
2194
+ Only then does it refresh the daemon's in-memory credentials.
2195
+ If validation or any vault write fails, startup fails closed and preserves
2196
+ container-credentials.json.consuming for the next startup to reconcile.
2197
+ Restore or create/unlock the configured agent vault, then restart the same
2198
+ reviewed image and let it retry the preserved claim. If both the source and
2199
+ claimed envelope exist and they are safe regular 0600 files owned by the
2200
+ runtime user, startup resumes automatically only when they are byte-for-byte identical:
2201
+ it durably removes the redundant unclaimed source and continues the existing claim.
2202
+ If they differ, the repair is human-required: stop the Butler, securely compare and quarantine
2203
+ the two envelopes, then restore exactly one reviewed envelope at the source path.
2204
+ Never print either envelope's contents or place them in logs or command arguments.
2205
+ After successful import, both files
2206
+ are absent and the vault is the only credential source of truth.
2207
+ `provision_sanctuary_sab_credential "$IMAGE_ID"` is the one-shot migration for
2208
+ the existing SAB key. Root reads the fixed host INI, writes only a mode-0600
2209
+ `ouro.runtimeCredentialBootstrap` envelope owned by 10001:10001, and a
2210
+ same-image one-shot container merges `sabnzbdApiKey` into
2211
+ `runtime/machines/sanctuary/config`; the key never appears in argv, the
2212
+ environment, logs, the Unraid template, or the Butler mounts. The bootstrap
2213
+ reconciler is idempotent for the same value and fails closed on a conflict.
2214
+ `verify_sanctuary_sab_readiness "$IMAGE_ID"` then reloads that machine item
2215
+ from the vault and performs a real bounded queue read before any poller
2216
+ cutover. The runtime and agent roots are persistent binds and package-managed
2217
+ updates do not replace the machine vault item, so the credential survives
2218
+ template and image updates.
2219
+ Bootstrap is not the rotation path. After SAB rotates its key, use the
2220
+ existing hidden-prompt merge command
2221
+ `ouro vault config set --agent sanctuary --key sabnzbdApiKey --scope machine`,
2222
+ restart the reviewed Butler container so its machine-config cache refreshes,
2223
+ and rerun `verify_sanctuary_sab_readiness "$IMAGE_ID"`; a conflicting
2224
+ bootstrap is expected to stop rather than overwrite canonical vault state.
2225
+ Never print or place credential values in logs, templates, command arguments,
2226
+ backups, or this runbook.
2227
+
2228
+ Packaged deployment-target containment gates:
2229
+ Unit 16 is fixed to the `staging` profile and therefore targets only
2230
+ `ouro-butler-staging`; it does not require `ouro-butler` to exist. Before the
2231
+ broker starts, the packaged deployment auditor captures all present canonical
2232
+ production/staging/rollback identities in one Docker inspect, verifies the
2233
+ target image, `unless-stopped`, host networking, matching GraphQL and durable
2234
+ file autostart identity/state for every present canonical container,
2235
+ and exactly one running Butler, then repeats the canonical snapshot to reject
2236
+ races. It binds the target PID and exact Docker cgroup-v2 path to the inspected
2237
+ container ID; Unit 16 carries that immutable ID into every launcher and broker
2238
+ Docker operation and binds it to the GraphQL PrefixedID suffix. It scans every
2239
+ cgroup process and requires stable before/after process, owned-socket, and
2240
+ listener inventories. Between the two canonical topology captures, it verifies
2241
+ the exact immutable-ID target is running and unpaused, pauses that ID without
2242
+ changing restart or autostart state, and performs every cgroup, all-thread
2243
+ descriptor, TCP, UDP, and Unix observation inside that one quiesced window,
2244
+ including two matching complete terminal snapshots; nonconvergence fails
2245
+ closed after eight attempts. It always makes bounded exact-ID unpause attempts,
2246
+ verifies the same ID and PID resumed their original running, unpaused state,
2247
+ and only then performs the final full-profile topology capture. It
2248
+ rejects every owned UDP listener and every externally reachable owned TCP listener. Only loopback
2249
+ Mailbox port 6876 plus stream-listening endpoints at the fixed daemon and
2250
+ acceptance Unix control paths are permitted; all other named Unix endpoint
2251
+ types (including datagrams) fail closed. Inherited descriptors for the same
2252
+ socket inode are deduplicated, while unknown loopback ports, wildcard/host-address
2253
+ listeners, process/socket/listener drift, or ambiguous ownership fail closed.
2254
+ Unit 18 uses the separately packaged fixed final command after activation.
2255
+ It accepts either the legacy-adoption topology with no canonical rollback or
2256
+ exactly one stopped, non-autostarted rollback; running/autostarted rollback,
2257
+ staging presence, and ambiguous canonical identities always fail closed:
2258
+ UNIT18_TARGET_TMP=$(mktemp /run/sanctuary-unit18-target-audit.sh.XXXXXX)
2259
+ /usr/bin/timeout -s KILL 20 /usr/bin/docker run --rm --pull=never --network none \
2260
+ --entrypoint /bin/cat "$IMAGE_ID" /opt/ouro/deploy/unraid/sanctuary-unit18-target-audit.sh >"$UNIT18_TARGET_TMP"
2261
+ chmod 0500 "$UNIT18_TARGET_TMP"
2262
+ "$UNIT18_TARGET_TMP" "$IMAGE_ID"
2263
+ rm -f -- "$UNIT18_TARGET_TMP"
2264
+ Neither packaged command accepts a container name or an open-ended profile.
2265
+
2266
+ Packaged Unit 16 acceptance execution:
2267
+ Use only the launcher packaged in the exact reviewed image. Install it without
2268
+ network access, then create the two private roots with the runtime UID/GID:
2269
+ UNIT16_ROOT=/mnt/user/appdata/ouro-butler/acceptance
2270
+ install -d -m 0700 -o 10001 -g 10001 "$UNIT16_ROOT/configs" "$UNIT16_ROOT/evidence"
2271
+ UNIT16_LAUNCHER_TMP=$(mktemp "$UNIT16_ROOT/sanctuary-unit16-run.sh.tmp.XXXXXX")
2272
+ /usr/bin/timeout -s KILL 20 /usr/bin/docker run --rm --pull=never --network none \
2273
+ --entrypoint /bin/cat "$IMAGE_ID" /opt/ouro/deploy/unraid/sanctuary-unit16-run.sh >"$UNIT16_LAUNCHER_TMP"
2274
+ install -m 0755 -o root -g root "$UNIT16_LAUNCHER_TMP" "$UNIT16_ROOT/sanctuary-unit16-run.sh"
2275
+ rm -f -- "$UNIT16_LAUNCHER_TMP"
2276
+ After final activation, create the disjoint final-profile roots and invoke the
2277
+ same immutable launcher with its only non-staging selector. The selector is a
2278
+ closed `staging|final` enum; it never accepts a container name, ID, or path:
2279
+ UNIT18_ROOT=/mnt/user/appdata/ouro-butler/acceptance/final
2280
+ install -d -m 0700 -o 10001 -g 10001 "$UNIT18_ROOT/configs" "$UNIT18_ROOT/evidence"
2281
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-snapshot
2282
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-snapshot evidence-snapshot.json
2283
+ Use `--profile final` on every final pre-reboot and post-reboot materialize/run
2284
+ command. Omitting it remains the byte-for-byte Unit 16 staging path. Never mix
2285
+ staging and final config/evidence roots or reuse a config across profiles.
2286
+ Never hand-author a Unit 16 config. Materialize it from the exact image, then
2287
+ execute it in the order below. Before every execution the launcher regenerates
2288
+ the config from the packaged fixed contract and requires byte-for-byte equality.
2289
+ 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.
2290
+ The cursor snapshot is deliberately materialized and executed twice around the
2291
+ live scenario. Telegram bootstrap reads the new bot token from host file
2292
+ descriptor 3; callback injection reads the reviewed saved callback-update JSON
2293
+ from the same descriptor. The launcher explicitly maps host fd 3 to Docker
2294
+ stdin and then to in-container fd 3 only for those two commands. Neither value
2295
+ belongs in argv, shell history, a config file, or an unrelated command's stdin.
2296
+ Define this Bash helper in the root shell. It disables terminal echo while
2297
+ reading the token, opens an anonymous descriptor for the launcher, and unsets
2298
+ the short-lived shell value on either launcher success or failure:
2299
+ run_unit16_telegram_bootstrap() {
2300
+ local UNIT16_BOT_TOKEN UNIT16_BOT_STATUS
2301
+ printf 'Telegram bot token: ' >&2
2302
+ IFS= read -r -s UNIT16_BOT_TOKEN || return $?
2303
+ printf '\n' >&2
2304
+ if "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final telegram-bootstrap telegram-bootstrap.json \
2305
+ 3< <(printf '%s\n' "$UNIT16_BOT_TOKEN"); then
2306
+ UNIT16_BOT_STATUS=0
2307
+ else
2308
+ UNIT16_BOT_STATUS=$?
2309
+ fi
2310
+ unset UNIT16_BOT_TOKEN
2311
+ return "$UNIT16_BOT_STATUS"
2312
+ }
2313
+ Stage the reviewed callback JSON at the fixed path below in the root-owned
2314
+ tmpfs inbox, then use this single fail-closed helper. It opens the input once,
2315
+ validates the opened descriptor and its original path refer to the same
2316
+ root-owned mode-0600 regular file, and invokes the launcher only afterward.
2317
+ Success overwrites, fsyncs, and unlinks the raw input. Any failure allocates a
2318
+ collision-free quarantine file atomically, copies and fsyncs the input into it,
2319
+ locks it to mode 000, then overwrites and removes the tmpfs source;
2320
+ no raw callback update remains loose in `/root` or the inbox.
2321
+ UNIT16_CALLBACK_INBOX=/run/ouro-unit16-callback-input
2322
+ UNIT16_CALLBACK_FILE=$UNIT16_CALLBACK_INBOX/callback-update.json
2323
+ UNIT16_CALLBACK_QUARANTINE_ROOT=$UNIT16_ROOT/callback-quarantine
2324
+ install -d -m 0700 -o root -g root "$UNIT16_CALLBACK_INBOX" "$UNIT16_CALLBACK_QUARANTINE_ROOT"
2325
+ run_unit16_callback_inject() {
2326
+ (
2327
+ UNIT16_CALLBACK_VALIDATED=no
2328
+ UNIT16_CALLBACK_STATUS=1
2329
+ unit16_callback_cleanup() {
2330
+ UNIT16_CALLBACK_STATUS=$?
2331
+ trap - EXIT HUP INT TERM
2332
+ if test -e "$UNIT16_CALLBACK_FILE" || test -L "$UNIT16_CALLBACK_FILE"; then
2333
+ if test "$UNIT16_CALLBACK_STATUS" -eq 0 && test "$UNIT16_CALLBACK_VALIDATED" = yes; then
2334
+ if ! truncate -s 0 /proc/self/fd/3 || ! sync -f /proc/self/fd/3 \
2335
+ || ! rm -f -- "$UNIT16_CALLBACK_FILE" || ! sync -f "$UNIT16_CALLBACK_INBOX"; then
2336
+ UNIT16_CALLBACK_STATUS=1
2337
+ fi
2338
+ elif test -f "$UNIT16_CALLBACK_FILE" && test ! -L "$UNIT16_CALLBACK_FILE"; then
2339
+ UNIT16_CALLBACK_QUARANTINE_FILE=$(mktemp "$UNIT16_CALLBACK_QUARANTINE_ROOT/failed.XXXXXX") || UNIT16_CALLBACK_QUARANTINE_FILE=
2340
+ if test -n "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2341
+ && chmod 0600 "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2342
+ && cp -- "$UNIT16_CALLBACK_FILE" "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2343
+ && sync -f "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2344
+ && chmod 000 "$UNIT16_CALLBACK_QUARANTINE_FILE" \
2345
+ && sync -f "$UNIT16_CALLBACK_QUARANTINE_ROOT"; then
2346
+ :
2347
+ else
2348
+ test -z "${UNIT16_CALLBACK_QUARANTINE_FILE-}" || rm -f -- "$UNIT16_CALLBACK_QUARANTINE_FILE"
2349
+ UNIT16_CALLBACK_STATUS=1
2350
+ fi
2351
+ if ! truncate -s 0 "$UNIT16_CALLBACK_FILE" || ! sync -f "$UNIT16_CALLBACK_FILE" \
2352
+ || ! rm -f -- "$UNIT16_CALLBACK_FILE" || ! sync -f "$UNIT16_CALLBACK_INBOX"; then
2353
+ UNIT16_CALLBACK_STATUS=1
2354
+ fi
2355
+ else
2356
+ rm -f -- "$UNIT16_CALLBACK_FILE" || UNIT16_CALLBACK_STATUS=1
2357
+ sync -f "$UNIT16_CALLBACK_INBOX" || UNIT16_CALLBACK_STATUS=1
2358
+ fi
2359
+ fi
2360
+ exec 3<&-
2361
+ exit "$UNIT16_CALLBACK_STATUS"
2362
+ }
2363
+ trap unit16_callback_cleanup EXIT
2364
+ trap 'exit 129' HUP
2365
+ trap 'exit 130' INT
2366
+ trap 'exit 143' TERM
2367
+ test -d "$UNIT16_CALLBACK_INBOX" && test ! -L "$UNIT16_CALLBACK_INBOX" || exit 1
2368
+ test "$(stat -c '%F %u:%g %a' "$UNIT16_CALLBACK_INBOX")" = "directory 0:0 700" || exit 1
2369
+ test -d "$UNIT16_CALLBACK_QUARANTINE_ROOT" && test ! -L "$UNIT16_CALLBACK_QUARANTINE_ROOT" || exit 1
2370
+ test "$(stat -c '%F %u:%g %a' "$UNIT16_CALLBACK_QUARANTINE_ROOT")" = "directory 0:0 700" || exit 1
2371
+ test -f "$UNIT16_CALLBACK_FILE" && test ! -L "$UNIT16_CALLBACK_FILE" || exit 1
2372
+ exec 3<"$UNIT16_CALLBACK_FILE"
2373
+ test "$(stat -Lc '%F' /proc/self/fd/3)" = "regular file" || exit 1
2374
+ test "$(stat -c '%d:%i' "$UNIT16_CALLBACK_FILE")" = "$(stat -Lc '%d:%i' /proc/self/fd/3)" || exit 1
2375
+ UNIT16_CALLBACK_VALIDATED=yes
2376
+ test "$(stat -Lc '%F %u:%g %a' /proc/self/fd/3)" = "regular file 0:0 600" || exit 1
2377
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final callback-inject callback-inject.json 3<&3
2378
+ )
2379
+ }
2380
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize telegram-bootstrap
2381
+ run_unit16_telegram_bootstrap
2382
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize cursor-snapshot before
2383
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final cursor-snapshot cursor-snapshot.json
2384
+ # Perform the live scenario whose cursor movement is being measured.
2385
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize cursor-snapshot after
2386
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final cursor-snapshot cursor-snapshot.json
2387
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize cursor-delta
2388
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final cursor-delta cursor-delta.json
2389
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize callback-inject
2390
+ run_unit16_callback_inject
2391
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize unraid-key-rotate
2392
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final unraid-key-rotate unraid-key-rotate.json
2393
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize reboot-request
2394
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final reboot-request reboot-request.json
2395
+ # The preceding command captures and seals preflight evidence before it asks
2396
+ # the broker to stage the reboot, then seals the requested-phase evidence and
2397
+ # fsyncs the requested checkpoint before invoking the host reboot. Reconnect
2398
+ # only after Unraid and Docker are ready.
2399
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize reboot-resume
2400
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final reboot-resume reboot-resume.json
2401
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-snapshot
2402
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-snapshot evidence-snapshot.json
2403
+ # reboot-resume seals the changed boot identity and recovery milestones.
2404
+ # evidence-snapshot then runs the remaining scenarios sequentially with a
2405
+ # 72-minute aggregate bound; each completed scenario records provenance at
2406
+ # capture time and always clears its public gate, private marker, and receipt.
2407
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-bundle-index
2408
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-bundle-index evidence-bundle-index.json
2409
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-bundle-verify
2410
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-bundle-verify evidence-bundle-verify.json
2411
+ Each execution revalidates the immutable image and the production container's
2412
+ exact image ID. The main one-shot gets a read-only root filesystem, one writable
2413
+ evidence mount, narrowly selected runtime/bundle modes, and individual read-only
2414
+ image/container/health/boot fact files. Host-only operations cross an ephemeral
2415
+ root-owned private Unix socket (root:10001 mode 0660) to a root broker extracted
2416
+ from the same exact image. The broker accepts only fixed Unraid inventory/create/
2417
+ exact-revoke/rejection operations, exact fixed-target snapshots, and a
2418
+ two-phase reboot reservation/final commit. Reservation drains queued owner
2419
+ mutations and rejects new ones; after the requested checkpoint is fsynced, the
2420
+ broker repeats the array/parity/mover/durable-health preflight and invokes the
2421
+ fixed host reboot itself exactly once. The Unit 16 broker inspects only the
2422
+ deployment profile's attested immutable container ID (staging is
2423
+ `ouro-butler-staging`), requires its immutable image ID, writes that exact typed/redacted
2424
+ snapshot before accepting requests, and exposes a nonnegative Docker restart
2425
+ count. Both initial and refreshed snapshots share the same exact schema. The
2426
+ broker derives `autostartExact` from both the Unraid GraphQL `autoStart` value
2427
+ and exactly one staging entry in `/var/lib/docker/unraid-autostart`, derives
2428
+ `updaterDisabled` from `/opt/ouro/container-runtime.json` inside the exact image,
2429
+ and derives `vaultUnlocked`/`manualAuthRequired` from a bounded live Sanctuary
2430
+ vault-status command only when its non-secret summary proves the required
2431
+ Telegram runtime fields and both configured provider records were freshly read.
2432
+ Scenario handles remain private to the scenario adapter. The main one-shot
2433
+ never receives the Docker socket, Unraid key directory, or a host-root mount.
2434
+ Telegram bootstrap additionally brackets its one-shot with a host-controlled
2435
+ poller quiescence guard: it verifies the exact healthy staging container,
2436
+ assumes recovery responsibility before attempting the stop, stops it with a
2437
+ 30-second grace bound, proves it is stopped, and mounts a
2438
+ root-owned typed zero-poller fact. Its exit/signal trap restarts that same exact
2439
+ container and waits up to 120 seconds for healthy recovery on both success and
2440
+ failure. It never reads or changes Unraid autostart configuration. Every command
2441
+ also receives a freshly generated, redacted typed container-inspect snapshot;
2442
+ raw container environment or credential values are never captured.
2443
+ Evidence-snapshot keeps the bundle mount read-only and overlays only the exact
2444
+ canonical `state/acceptance` directory as writable. It first stops the exact
2445
+ healthy staging container under recovery responsibility, then opens the
2446
+ directory with `O_DIRECTORY|O_NOFOLLOW` and bind-mounts from the inherited fd
2447
+ while no Sanctuary UID process is running. The launcher bind-pins that inode
2448
+ behind a root-only alias and back onto the canonical path against rename swaps,
2449
+ restarts the same exact image healthy, and compares the alias, canonical path,
2450
+ and staging container's inode before and after capture. Cleanup unmounts the
2451
+ canonical pin and then the alias. The staging daemon therefore sees the same
2452
+ inode used by the one-shot for marker correlation.
2453
+
2454
+ Audit and safety verification:
2455
+ Inspect AgentBundles/sanctuary.ouro/state/approvals for durable approval and
2456
+ restart-attempt receipts. Confirm no Docker socket/device/host-root mounts and
2457
+ no published ports with docker inspect. Confirm Config.Image equals the exact
2458
+ reviewed local image ID, not the build tag. The read key must reject Docker
2459
+ stop and restart mutations; only the separate write key may perform the one
2460
+ typed approved restart action.
2461
+ The packaged `unraid-key-rotate` command is the only canonical key-rotation
2462
+ authority. It inventories the two occupied canonical names by exact immutable
2463
+ ID, creates collision-safe `Butler RO Rotation <suffix>` and
2464
+ `Butler RW Rotation <suffix>` keys, stores and capability-probes both through
2465
+ the Sanctuary machine vault, and requires an exact four-key inventory. It then
2466
+ revokes both old IDs and proves each old credential receives a 401 or 403.
2467
+ Only after an exact inventory contains the temporary pair alone does it create,
2468
+ store, and probe the canonical `Butler RO` and `Butler RW` pair. It requires an
2469
+ exact four-key temporary-plus-canonical inventory, revokes and rejection-probes
2470
+ both temporary IDs, and finally requires exactly the canonical pair. Every
2471
+ unexpected key, duplicate, malformed permission set, role, name, inventory
2472
+ delta, or probe response fails closed.
2473
+
2474
+ Record only IDs, names, and permission sets in evidence; never raw key values.
2475
+ Raw credentials must not appear in argv, shell history, stdout, logs, or
2476
+ acceptance evidence. They may cross only the ephemeral private Unit 16 Unix
2477
+ socket in process memory. The narrow revocation-retry recovery records under
2478
+ `/mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof` are the sole file
2479
+ exception: they are root-owned mode 0600 beneath a root-owned mode 0700
2480
+ directory, excluded from backup/evidence, streamed only on standard input to
2481
+ the bounded rejection probe, retained across failed retries, and durably
2482
+ deleted only after the exact authentication rejection is validated. Never
2483
+ substitute names for IDs when revoking keys.