@ouro.bot/cli 0.1.0-alpha.74 → 0.1.0-alpha.740

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 (587) 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 +4724 -16
  15. package/deploy/unraid/Dockerfile +43 -0
  16. package/deploy/unraid/README.txt +1623 -0
  17. package/deploy/unraid/audit-container-spec.sh +4 -0
  18. package/deploy/unraid/container-runtime.json +1 -0
  19. package/deploy/unraid/sanctuary-acceptance-adapter.sh +37 -0
  20. package/deploy/unraid/sanctuary-acceptance-contract.json +200 -0
  21. package/deploy/unraid/sanctuary-acceptance-harness.sh +23 -0
  22. package/deploy/unraid/sanctuary-deployment-target.mjs +709 -0
  23. package/deploy/unraid/sanctuary-safe-rename.py +24 -0
  24. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +1796 -0
  25. package/deploy/unraid/sanctuary-unit16-run.sh +520 -0
  26. package/deploy/unraid/sanctuary-unit18-target-audit.sh +27 -0
  27. package/deploy/unraid/sanctuary.ouro/agent.json +24 -0
  28. package/deploy/unraid/sanctuary.ouro/arc/README.md +3 -0
  29. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +5 -0
  30. package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +8 -0
  31. package/deploy/unraid/sanctuary.ouro/provider-readiness.json +18 -0
  32. package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +3 -0
  33. package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +3 -0
  34. package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +3 -0
  35. package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +5 -0
  36. package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +7 -0
  37. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +21 -0
  38. package/deploy/unraid/sanctuary.xml +25 -0
  39. package/dist/a2a/card.js +57 -0
  40. package/dist/a2a/client.js +180 -0
  41. package/dist/a2a/config.js +50 -0
  42. package/dist/a2a/delegation-stores.js +56 -0
  43. package/dist/a2a/did-resolution.js +93 -0
  44. package/dist/a2a/identity.js +170 -0
  45. package/dist/a2a/inbound-share.js +107 -0
  46. package/dist/a2a/mission-result-wire.js +196 -0
  47. package/dist/a2a/onboarding.js +115 -0
  48. package/dist/a2a/pin-store.js +132 -0
  49. package/dist/a2a/seen-ledger.js +120 -0
  50. package/dist/a2a/server.js +604 -0
  51. package/dist/a2a/task-store.js +69 -0
  52. package/dist/a2a/transport.js +45 -0
  53. package/dist/a2a/types.js +3 -0
  54. package/dist/arc/attention-types.js +8 -0
  55. package/dist/arc/cares.js +144 -0
  56. package/dist/arc/episodes.js +118 -0
  57. package/dist/arc/evolution.js +490 -0
  58. package/dist/arc/flight-recorder.js +1034 -0
  59. package/dist/arc/intentions.js +134 -0
  60. package/dist/arc/json-store.js +117 -0
  61. package/dist/arc/obligations.js +348 -0
  62. package/dist/arc/packets.js +336 -0
  63. package/dist/arc/presence.js +185 -0
  64. package/dist/arc/task-lifecycle.js +57 -0
  65. package/dist/commerce/store.js +755 -0
  66. package/dist/commerce/types.js +3 -0
  67. package/dist/heart/active-work.js +874 -43
  68. package/dist/heart/agent-entry.js +190 -3
  69. package/dist/heart/approval-files.js +113 -0
  70. package/dist/heart/approval-store.js +950 -0
  71. package/dist/heart/attachments/image-normalize.js +194 -0
  72. package/dist/heart/attachments/materialize.js +97 -0
  73. package/dist/heart/attachments/originals.js +88 -0
  74. package/dist/heart/attachments/render.js +29 -0
  75. package/dist/heart/attachments/sources/bluebubbles.js +156 -0
  76. package/dist/heart/attachments/sources/cli-local-file.js +78 -0
  77. package/dist/heart/attachments/sources/index.js +16 -0
  78. package/dist/heart/attachments/store.js +103 -0
  79. package/dist/heart/attachments/types.js +93 -0
  80. package/dist/heart/auth/auth-flow.js +495 -0
  81. package/dist/heart/autonomy-budget.js +477 -0
  82. package/dist/heart/awaiting/await-alert.js +146 -0
  83. package/dist/heart/awaiting/await-expiry.js +108 -0
  84. package/dist/heart/awaiting/await-loader.js +91 -0
  85. package/dist/heart/awaiting/await-parser.js +141 -0
  86. package/dist/heart/awaiting/await-runtime-state.js +100 -0
  87. package/dist/heart/awaiting/await-scheduler.js +382 -0
  88. package/dist/heart/background-operations.js +281 -0
  89. package/dist/heart/bridges/manager.js +137 -17
  90. package/dist/heart/bridges/store.js +14 -2
  91. package/dist/heart/bundle-state.js +168 -0
  92. package/dist/heart/commitments.js +135 -0
  93. package/dist/heart/config-registry.js +331 -0
  94. package/dist/heart/config.js +128 -129
  95. package/dist/heart/context-loss-gauntlet.js +354 -0
  96. package/dist/heart/context-loss-sentinel.js +1155 -0
  97. package/dist/heart/core.js +1759 -331
  98. package/dist/heart/cross-chat-delivery.js +8 -22
  99. package/dist/heart/daemon/agent-config-check.js +451 -0
  100. package/dist/heart/daemon/agent-discovery.js +185 -3
  101. package/dist/heart/daemon/agent-service.js +542 -0
  102. package/dist/heart/daemon/agentic-repair.js +547 -0
  103. package/dist/heart/daemon/await-private-wake.js +59 -0
  104. package/dist/heart/daemon/bluebubbles-cli-integration.js +71 -0
  105. package/dist/heart/daemon/bluebubbles-health-diagnostics.js +122 -0
  106. package/dist/heart/daemon/bluebubbles-host-protocol.js +433 -0
  107. package/dist/heart/daemon/bluebubbles-host.js +273 -0
  108. package/dist/heart/daemon/boot-sync-probe.js +197 -0
  109. package/dist/heart/daemon/cadence.js +180 -0
  110. package/dist/heart/daemon/cli-defaults.js +832 -0
  111. package/dist/heart/daemon/cli-desk.js +322 -0
  112. package/dist/heart/daemon/cli-exec.js +8692 -0
  113. package/dist/heart/daemon/cli-help.js +710 -0
  114. package/dist/heart/daemon/cli-parse.js +2714 -0
  115. package/dist/heart/daemon/cli-render-doctor.js +57 -0
  116. package/dist/heart/daemon/cli-render.js +777 -0
  117. package/dist/heart/daemon/cli-types.js +8 -0
  118. package/dist/heart/daemon/connect-bay.js +323 -0
  119. package/dist/heart/daemon/container-credential-bootstrap.js +273 -0
  120. package/dist/heart/daemon/container-healthcheck.js +94 -0
  121. package/dist/heart/daemon/container-runtime.js +84 -0
  122. package/dist/heart/daemon/container-spec-auditor-main.js +156 -0
  123. package/dist/heart/daemon/container-spec-auditor.js +156 -0
  124. package/dist/heart/daemon/daemon-bootstrap-startup.js +41 -0
  125. package/dist/heart/daemon/daemon-cli.js +29 -2144
  126. package/dist/heart/daemon/daemon-entry.js +930 -10
  127. package/dist/heart/daemon/daemon-health.js +186 -0
  128. package/dist/heart/daemon/daemon-rollup.js +57 -0
  129. package/dist/heart/daemon/daemon-runtime-sync.js +88 -13
  130. package/dist/heart/daemon/daemon-tombstone.js +236 -0
  131. package/dist/heart/daemon/daemon.js +1698 -88
  132. package/dist/heart/daemon/dns-workflow.js +394 -0
  133. package/dist/heart/daemon/doctor-types.js +8 -0
  134. package/dist/heart/daemon/doctor.js +1755 -0
  135. package/dist/heart/daemon/freshness.js +345 -0
  136. package/dist/heart/daemon/habit-private-wake.js +61 -0
  137. package/dist/heart/daemon/health-monitor.js +142 -11
  138. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  139. package/dist/heart/daemon/hooks/bundle-meta.js +135 -1
  140. package/dist/heart/daemon/http-health-probe.js +80 -0
  141. package/dist/heart/daemon/human-command-screens.js +234 -0
  142. package/dist/heart/daemon/human-readiness.js +114 -0
  143. package/dist/heart/daemon/inner-status.js +78 -0
  144. package/dist/heart/daemon/interactive-repair.js +394 -0
  145. package/dist/heart/daemon/launchd.js +37 -8
  146. package/dist/heart/daemon/log-tailer.js +79 -10
  147. package/dist/heart/daemon/logs-prune.js +132 -0
  148. package/dist/heart/daemon/mcp-canary.js +524 -0
  149. package/dist/heart/daemon/message-router.js +45 -5
  150. package/dist/heart/daemon/migrate-to-desk.js +848 -0
  151. package/dist/heart/daemon/os-cron-deps.js +135 -0
  152. package/dist/heart/daemon/os-cron.js +22 -14
  153. package/dist/heart/daemon/ouro-bot-entry.js +4 -2
  154. package/dist/heart/daemon/ouro-entry.js +3 -1
  155. package/dist/heart/daemon/plugin-cli.js +432 -0
  156. package/dist/heart/daemon/process-manager.js +624 -61
  157. package/dist/heart/daemon/provider-discovery.js +137 -0
  158. package/dist/heart/daemon/provider-ping-progress.js +83 -0
  159. package/dist/heart/daemon/prunable-bundle.js +144 -0
  160. package/dist/heart/daemon/pulse.js +511 -0
  161. package/dist/heart/daemon/readiness-repair.js +365 -0
  162. package/dist/heart/daemon/run-hooks.js +2 -0
  163. package/dist/heart/daemon/runtime-logging.js +47 -14
  164. package/dist/heart/daemon/runtime-metadata.js +2 -30
  165. package/dist/heart/daemon/runtime-mode.js +13 -2
  166. package/dist/heart/daemon/safe-mode.js +161 -0
  167. package/dist/heart/daemon/sanctuary-acceptance-adapter.js +2365 -0
  168. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1624 -0
  169. package/dist/heart/daemon/sanctuary-acceptance-marker.js +267 -0
  170. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +985 -0
  171. package/dist/heart/daemon/sanctuary-scheduler-liveness.js +227 -0
  172. package/dist/heart/daemon/sanctuary-scheduler-origin.js +126 -0
  173. package/dist/heart/daemon/sense-manager.js +689 -48
  174. package/dist/heart/daemon/session-id-resolver.js +131 -0
  175. package/dist/heart/daemon/skill-management-installer.js +1 -1
  176. package/dist/heart/daemon/socket-client.js +180 -12
  177. package/dist/heart/daemon/stale-bundle-prune.js +113 -0
  178. package/dist/heart/daemon/startup-tui.js +338 -0
  179. package/dist/heart/daemon/supercronic-supervisor.js +267 -0
  180. package/dist/heart/daemon/task-scheduler.js +117 -39
  181. package/dist/heart/daemon/terminal-ui.js +499 -0
  182. package/dist/heart/daemon/thoughts.js +255 -43
  183. package/dist/heart/daemon/up-progress.js +366 -0
  184. package/dist/heart/daemon/vault-items.js +56 -0
  185. package/dist/heart/delegation.js +1 -4
  186. package/dist/heart/external-events/router.js +121 -0
  187. package/dist/heart/habits/habit-cancel.js +810 -0
  188. package/dist/heart/habits/habit-lifecycle.js +1327 -0
  189. package/dist/heart/habits/habit-migration.js +194 -0
  190. package/dist/heart/habits/habit-parser.js +278 -0
  191. package/dist/heart/habits/habit-runtime-state.js +93 -0
  192. package/dist/heart/habits/habit-scheduler.js +653 -0
  193. package/dist/heart/habits/habit-session-summary.js +318 -0
  194. package/dist/heart/habits/habit-session.js +636 -0
  195. package/dist/heart/{daemon → hatch}/hatch-flow.js +55 -62
  196. package/dist/heart/{daemon → hatch}/hatch-specialist.js +6 -8
  197. package/dist/heart/{daemon → hatch}/specialist-prompt.js +12 -9
  198. package/dist/heart/{daemon → hatch}/specialist-tools.js +53 -18
  199. package/dist/heart/identity.js +184 -59
  200. package/dist/heart/kept-notes.js +289 -0
  201. package/dist/heart/kicks.js +1 -1
  202. package/dist/heart/machine-identity.js +161 -0
  203. package/dist/heart/mail-import-discovery.js +353 -0
  204. package/dist/heart/mailbox/mailbox-http-hooks.js +98 -0
  205. package/dist/heart/mailbox/mailbox-http-response.js +7 -0
  206. package/dist/heart/mailbox/mailbox-http-routes.js +398 -0
  207. package/dist/heart/mailbox/mailbox-http-static.js +103 -0
  208. package/dist/heart/mailbox/mailbox-http-transport.js +116 -0
  209. package/dist/heart/mailbox/mailbox-http.js +99 -0
  210. package/dist/heart/mailbox/mailbox-read.js +39 -0
  211. package/dist/heart/mailbox/mailbox-types.js +27 -0
  212. package/dist/heart/mailbox/mailbox-view.js +197 -0
  213. package/dist/heart/mailbox/readers/agent-machine.js +428 -0
  214. package/dist/heart/mailbox/readers/continuity-readers.js +329 -0
  215. package/dist/heart/mailbox/readers/mail.js +375 -0
  216. package/dist/heart/mailbox/readers/runtime-readers.js +823 -0
  217. package/dist/heart/mailbox/readers/sessions.js +232 -0
  218. package/dist/heart/mailbox/readers/shared.js +111 -0
  219. package/dist/heart/mcp/mcp-server.js +696 -0
  220. package/dist/heart/migrate-config.js +100 -0
  221. package/dist/heart/model-capabilities.js +11 -0
  222. package/dist/heart/orientation-frame.js +345 -0
  223. package/dist/heart/platform.js +81 -0
  224. package/dist/heart/primitives.js +1 -1
  225. package/dist/heart/private-runtime/decision-renderer.js +156 -0
  226. package/dist/heart/private-runtime/index.js +18 -0
  227. package/dist/heart/private-runtime/ledger.js +218 -0
  228. package/dist/heart/private-runtime/policy.js +214 -0
  229. package/dist/heart/private-runtime/types.js +2 -0
  230. package/dist/heart/progress-story.js +1 -1
  231. package/dist/heart/provider-attempt.js +137 -0
  232. package/dist/heart/provider-binding-resolver.js +273 -0
  233. package/dist/heart/provider-credentials.js +449 -0
  234. package/dist/heart/provider-failover.js +311 -0
  235. package/dist/heart/provider-models.js +89 -0
  236. package/dist/heart/provider-ping.js +265 -0
  237. package/dist/heart/provider-readiness-cache.js +119 -0
  238. package/dist/heart/provider-visibility.js +188 -0
  239. package/dist/heart/providers/anthropic-token.js +131 -0
  240. package/dist/heart/providers/anthropic.js +173 -60
  241. package/dist/heart/providers/azure.js +23 -11
  242. package/dist/heart/providers/error-classification.js +127 -0
  243. package/dist/heart/providers/github-copilot.js +34 -38
  244. package/dist/heart/providers/minimax-vlm.js +189 -0
  245. package/dist/heart/providers/minimax.js +26 -8
  246. package/dist/heart/providers/openai-codex-token.js +349 -0
  247. package/dist/heart/providers/openai-codex.js +55 -40
  248. package/dist/heart/providers/openai-compatible.js +177 -0
  249. package/dist/heart/run-ledger.js +247 -0
  250. package/dist/heart/runtime-capability-check.js +170 -0
  251. package/dist/heart/runtime-credentials.js +607 -0
  252. package/dist/heart/runtime-cwd.js +87 -0
  253. package/dist/heart/sense-truth.js +22 -4
  254. package/dist/heart/session-activity.js +90 -24
  255. package/dist/heart/session-events.js +1300 -0
  256. package/dist/heart/session-playback-cli-main.js +5 -0
  257. package/dist/heart/session-playback-cli.js +36 -0
  258. package/dist/heart/session-playback.js +231 -0
  259. package/dist/heart/session-stats-cli-main.js +5 -0
  260. package/dist/heart/session-stats.js +182 -0
  261. package/dist/heart/session-transcript.js +133 -0
  262. package/dist/heart/start-of-turn-packet.js +368 -0
  263. package/dist/heart/streaming.js +604 -207
  264. package/dist/heart/structured-output.js +196 -0
  265. package/dist/heart/sync-classification.js +176 -0
  266. package/dist/heart/sync.js +449 -0
  267. package/dist/heart/target-resolution.js +11 -7
  268. package/dist/heart/tempo.js +93 -0
  269. package/dist/heart/temporal-view.js +41 -0
  270. package/dist/heart/timeouts.js +101 -0
  271. package/dist/heart/tool-activity-callbacks.js +59 -0
  272. package/dist/heart/tool-approval.js +340 -0
  273. package/dist/heart/tool-description.js +155 -0
  274. package/dist/heart/tool-friction.js +55 -0
  275. package/dist/heart/tool-loop.js +200 -0
  276. package/dist/heart/turn-context.js +480 -0
  277. package/dist/heart/{daemon → versioning}/ouro-bot-global-installer.js +6 -5
  278. package/dist/heart/{daemon → versioning}/ouro-bot-wrapper.js +1 -1
  279. package/dist/heart/versioning/ouro-path-installer.js +432 -0
  280. package/dist/heart/versioning/ouro-recovery-launcher.js +76 -0
  281. package/dist/heart/versioning/ouro-version-manager.js +409 -0
  282. package/dist/heart/{daemon → versioning}/staged-restart.js +40 -8
  283. package/dist/heart/{daemon → versioning}/update-checker.js +6 -1
  284. package/dist/heart/versioning/update-hooks.js +154 -0
  285. package/dist/heart/versioning/version-intent.js +114 -0
  286. package/dist/heart/work-card.js +380 -0
  287. package/dist/mailbox-ui/assets/index-Du_9G9WO.css +1 -0
  288. package/dist/mailbox-ui/assets/index-J8zDwHmE.js +1 -0
  289. package/dist/mailbox-ui/assets/vendor-CcN1XpQ9.js +61 -0
  290. package/dist/mailbox-ui/index.html +16 -0
  291. package/dist/mailroom/attention.js +167 -0
  292. package/dist/mailroom/autonomy.js +209 -0
  293. package/dist/mailroom/blob-store.js +889 -0
  294. package/dist/mailroom/body-cache.js +61 -0
  295. package/dist/mailroom/cache-sync-cli.js +58 -0
  296. package/dist/mailroom/core.js +803 -0
  297. package/dist/mailroom/entry.js +160 -0
  298. package/dist/mailroom/file-store.js +568 -0
  299. package/dist/mailroom/hosted-cache-sync.js +384 -0
  300. package/dist/mailroom/mbox-import.js +393 -0
  301. package/dist/mailroom/migration.js +164 -0
  302. package/dist/mailroom/outbound.js +380 -0
  303. package/dist/mailroom/policy.js +263 -0
  304. package/dist/mailroom/reader.js +292 -0
  305. package/dist/mailroom/search-cache.js +513 -0
  306. package/dist/mailroom/search-relevance.js +319 -0
  307. package/dist/mailroom/smtp-ingress.js +176 -0
  308. package/dist/mailroom/source-state.js +176 -0
  309. package/dist/mailroom/thread.js +109 -0
  310. package/dist/mailroom/travel-extract.js +89 -0
  311. package/dist/mind/bundle-manifest.js +24 -2
  312. package/dist/mind/context.js +273 -111
  313. package/dist/mind/desk-section.js +362 -0
  314. package/dist/mind/diary-integrity.js +60 -0
  315. package/dist/mind/{memory.js → diary.js} +68 -77
  316. package/dist/mind/embedding-provider.js +60 -0
  317. package/dist/mind/file-state.js +179 -0
  318. package/dist/mind/{associative-recall.js → note-search.js} +47 -58
  319. package/dist/mind/obligation-steering.js +227 -0
  320. package/dist/mind/pending.js +13 -8
  321. package/dist/mind/prompt-budget.js +479 -0
  322. package/dist/mind/prompt-refresh.js +3 -2
  323. package/dist/mind/prompt.js +1067 -172
  324. package/dist/mind/provenance-trust.js +26 -0
  325. package/dist/mind/record-paths.js +312 -0
  326. package/dist/mind/scrutiny.js +173 -0
  327. package/dist/mind/session-transaction.js +379 -0
  328. package/dist/nerves/cli-logging.js +7 -1
  329. package/dist/nerves/coverage/audit-rules.js +15 -6
  330. package/dist/nerves/coverage/audit.js +28 -2
  331. package/dist/nerves/coverage/cli.js +1 -1
  332. package/dist/nerves/coverage/contract.js +5 -5
  333. package/dist/nerves/coverage/file-completeness.js +139 -5
  334. package/dist/nerves/coverage/run-artifacts.js +55 -35
  335. package/dist/nerves/coverage/source-scanner.js +4 -4
  336. package/dist/nerves/event-buffer.js +111 -0
  337. package/dist/nerves/index.js +293 -9
  338. package/dist/nerves/observation.js +20 -0
  339. package/dist/nerves/redact.js +79 -0
  340. package/dist/nerves/review/cli-main.js +5 -0
  341. package/dist/nerves/review/cli.js +156 -0
  342. package/dist/nerves/review/core.js +159 -0
  343. package/dist/nerves/runtime.js +17 -1
  344. package/dist/repertoire/ado-client.js +15 -56
  345. package/dist/repertoire/ado-semantic.js +16 -10
  346. package/dist/repertoire/api-client.js +97 -0
  347. package/dist/repertoire/bitwarden-store.js +1045 -0
  348. package/dist/repertoire/bundle-templates.js +71 -0
  349. package/dist/repertoire/bw-installer.js +180 -0
  350. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  351. package/dist/repertoire/coding/context-pack.js +331 -0
  352. package/dist/repertoire/coding/feedback.js +218 -30
  353. package/dist/repertoire/coding/index.js +15 -3
  354. package/dist/repertoire/coding/manager.js +166 -10
  355. package/dist/repertoire/coding/spawner.js +58 -11
  356. package/dist/repertoire/coding/tools.js +242 -14
  357. package/dist/repertoire/coding/workbench-client.js +272 -0
  358. package/dist/repertoire/coding/workbench-manager.js +346 -0
  359. package/dist/repertoire/commerce-errors.js +109 -0
  360. package/dist/repertoire/commerce-self-test.js +156 -0
  361. package/dist/repertoire/credential-access.js +178 -0
  362. package/dist/repertoire/desk/classifier.js +362 -0
  363. package/dist/repertoire/duffel-client.js +185 -0
  364. package/dist/repertoire/github-client.js +14 -55
  365. package/dist/repertoire/graph-client.js +11 -52
  366. package/dist/repertoire/guardrails.js +812 -18
  367. package/dist/repertoire/mcp-client.js +44 -3
  368. package/dist/repertoire/mcp-manager.js +259 -20
  369. package/dist/repertoire/mcp-tools.js +83 -0
  370. package/dist/repertoire/plugin-mcp.js +175 -0
  371. package/dist/repertoire/plugins.js +253 -0
  372. package/dist/repertoire/shell-sessions.js +133 -0
  373. package/dist/repertoire/skills.js +48 -4
  374. package/dist/repertoire/stripe-client.js +131 -0
  375. package/dist/repertoire/tool-arguments.js +143 -0
  376. package/dist/repertoire/tool-results.js +29 -0
  377. package/dist/repertoire/tools-a2a.js +699 -0
  378. package/dist/repertoire/tools-attachments.js +317 -0
  379. package/dist/repertoire/tools-awaiting.js +372 -0
  380. package/dist/repertoire/tools-base.js +67 -1095
  381. package/dist/repertoire/tools-bluebubbles.js +2 -0
  382. package/dist/repertoire/tools-bridge.js +144 -0
  383. package/dist/repertoire/tools-bundle.js +993 -0
  384. package/dist/repertoire/tools-commerce.js +253 -0
  385. package/dist/repertoire/tools-config.js +186 -0
  386. package/dist/repertoire/tools-continuity.js +252 -0
  387. package/dist/repertoire/tools-credential.js +383 -0
  388. package/dist/repertoire/tools-evolution.js +527 -0
  389. package/dist/repertoire/tools-files.js +344 -0
  390. package/dist/repertoire/tools-flight.js +290 -0
  391. package/dist/repertoire/tools-flow.js +119 -0
  392. package/dist/repertoire/tools-github.js +3 -8
  393. package/dist/repertoire/tools-habits.js +103 -0
  394. package/dist/repertoire/tools-mail.js +1828 -0
  395. package/dist/repertoire/tools-notes.js +459 -0
  396. package/dist/repertoire/tools-obligations.js +182 -0
  397. package/dist/repertoire/tools-orientation.js +31 -0
  398. package/dist/repertoire/tools-record.js +482 -0
  399. package/dist/repertoire/tools-rsvp.js +139 -0
  400. package/dist/repertoire/tools-runtime.js +150 -0
  401. package/dist/repertoire/tools-session.js +881 -0
  402. package/dist/repertoire/tools-shell.js +133 -0
  403. package/dist/repertoire/tools-stripe.js +224 -0
  404. package/dist/repertoire/tools-surface.js +355 -0
  405. package/dist/repertoire/tools-teams.js +12 -39
  406. package/dist/repertoire/tools-travel.js +125 -0
  407. package/dist/repertoire/tools-trip.js +982 -0
  408. package/dist/repertoire/tools-unraid.js +353 -0
  409. package/dist/repertoire/tools-user-profile.js +146 -0
  410. package/dist/repertoire/tools-vault.js +40 -0
  411. package/dist/repertoire/tools-voice.js +145 -0
  412. package/dist/repertoire/tools.js +276 -79
  413. package/dist/repertoire/travel-api-client.js +360 -0
  414. package/dist/repertoire/unraid-client.js +155 -0
  415. package/dist/repertoire/unraid-restart.js +130 -0
  416. package/dist/repertoire/user-profile.js +131 -0
  417. package/dist/repertoire/vault-setup.js +246 -0
  418. package/dist/repertoire/vault-unlock.js +628 -0
  419. package/dist/rsvp/aisleplanner-client.js +354 -0
  420. package/dist/rsvp/cli.js +926 -0
  421. package/dist/rsvp/config.js +327 -0
  422. package/dist/rsvp/cutover.js +433 -0
  423. package/dist/rsvp/diagnostics.js +232 -0
  424. package/dist/rsvp/diff-renderer.js +176 -0
  425. package/dist/rsvp/habit-policy.js +110 -0
  426. package/dist/rsvp/habit-stage.js +123 -0
  427. package/dist/rsvp/incident-bundle.js +97 -0
  428. package/dist/rsvp/migration.js +204 -0
  429. package/dist/rsvp/native-habit-runner.js +630 -0
  430. package/dist/rsvp/outbound-state.js +628 -0
  431. package/dist/rsvp/query.js +118 -0
  432. package/dist/rsvp/replay.js +233 -0
  433. package/dist/rsvp/snapshot.js +232 -0
  434. package/dist/rsvp/spend-ledger.js +175 -0
  435. package/dist/scripts/claude-code-hook.js +41 -0
  436. package/dist/scripts/claude-code-stop-hook.js +47 -0
  437. package/dist/senses/a2a-entry.js +115 -0
  438. package/dist/senses/attention-queue.js +186 -0
  439. package/dist/senses/await-turn-message.js +58 -0
  440. package/dist/senses/bluebubbles/active-turns.js +216 -0
  441. package/dist/senses/bluebubbles/attachment-cache.js +53 -0
  442. package/dist/senses/bluebubbles/attachment-download.js +137 -0
  443. package/dist/senses/{bluebubbles-client.js → bluebubbles/client.js} +355 -40
  444. package/dist/senses/bluebubbles/context-packet.js +207 -0
  445. package/dist/senses/bluebubbles/context-smoke.js +144 -0
  446. package/dist/senses/bluebubbles/entry.js +90 -0
  447. package/dist/senses/{bluebubbles-inbound-log.js → bluebubbles/inbound-log.js} +21 -3
  448. package/dist/senses/bluebubbles/index.js +3839 -0
  449. package/dist/senses/bluebubbles/latest-turn.js +311 -0
  450. package/dist/senses/{bluebubbles-media.js → bluebubbles/media.js} +121 -71
  451. package/dist/senses/{bluebubbles-model.js → bluebubbles/model.js} +159 -28
  452. package/dist/senses/{bluebubbles-mutation-log.js → bluebubbles/mutation-log.js} +24 -13
  453. package/dist/senses/bluebubbles/outbound-state.js +308 -0
  454. package/dist/senses/bluebubbles/processed-log.js +133 -0
  455. package/dist/senses/bluebubbles/reaction-policy.js +47 -0
  456. package/dist/senses/bluebubbles/replay.js +284 -0
  457. package/dist/senses/{bluebubbles-runtime-state.js → bluebubbles/runtime-state.js} +30 -2
  458. package/dist/senses/bluebubbles/semantic-receipts.js +1986 -0
  459. package/dist/senses/{bluebubbles-session-cleanup.js → bluebubbles/session-cleanup.js} +1 -1
  460. package/dist/senses/bluebubbles/webhook-registration.js +228 -0
  461. package/dist/senses/bluebubbles-meta-guard.js +39 -0
  462. package/dist/senses/cli/bracketed-paste.js +82 -0
  463. package/dist/senses/cli/image-paste.js +287 -0
  464. package/dist/senses/cli/image-ref-navigation.js +75 -0
  465. package/dist/senses/cli/ink-app.js +156 -0
  466. package/dist/senses/cli/inline-diff.js +64 -0
  467. package/dist/senses/cli/input-keys.js +174 -0
  468. package/dist/senses/cli/kill-ring.js +86 -0
  469. package/dist/senses/cli/message-list.js +51 -0
  470. package/dist/senses/cli/ouro-tui.js +607 -0
  471. package/dist/senses/cli/spinner-imperative.js +135 -0
  472. package/dist/senses/cli/spinner.js +101 -0
  473. package/dist/senses/cli/status-line.js +60 -0
  474. package/dist/senses/cli/streaming-markdown.js +526 -0
  475. package/dist/senses/cli/tool-display.js +85 -0
  476. package/dist/senses/cli/tool-render.js +85 -0
  477. package/dist/senses/cli/tui-store.js +241 -0
  478. package/dist/senses/cli/virtual-list.js +35 -0
  479. package/dist/senses/cli-entry.js +60 -8
  480. package/dist/senses/cli-layout.js +100 -0
  481. package/dist/senses/cli.js +621 -321
  482. package/dist/senses/commands.js +66 -3
  483. package/dist/senses/context-packet-ledger.js +371 -0
  484. package/dist/senses/context-packets.js +154 -0
  485. package/dist/senses/habit-lifecycle-guard.js +24 -0
  486. package/dist/senses/habit-turn-message.js +160 -0
  487. package/dist/senses/ingress-evidence.js +2 -0
  488. package/dist/senses/mail-entry.js +66 -0
  489. package/dist/senses/mail.js +524 -0
  490. package/dist/senses/pipeline.js +1075 -176
  491. package/dist/senses/private-runtime-worker.js +741 -0
  492. package/dist/senses/private-runtime.js +1212 -0
  493. package/dist/senses/proactive-content-guard.js +51 -0
  494. package/dist/senses/sanctuary-grounding.js +118 -0
  495. package/dist/senses/sanctuary-health-acceptance-probe-entry.js +12 -0
  496. package/dist/senses/sanctuary-health-acceptance-probe.js +772 -0
  497. package/dist/senses/sanctuary-health-runner.js +146 -0
  498. package/dist/senses/sanctuary-health.js +447 -0
  499. package/dist/senses/sanctuary-interactive-control.js +306 -0
  500. package/dist/senses/sanctuary-runtime.js +206 -0
  501. package/dist/senses/shared-turn.js +432 -0
  502. package/dist/senses/surface-tool.js +109 -0
  503. package/dist/senses/teams-entry.js +60 -8
  504. package/dist/senses/teams.js +515 -217
  505. package/dist/senses/telegram-approval-runtime.js +526 -0
  506. package/dist/senses/telegram-audit-ledger.js +220 -0
  507. package/dist/senses/telegram-client.js +1471 -0
  508. package/dist/senses/telegram-entry.js +80 -0
  509. package/dist/senses/telegram.js +1232 -0
  510. package/dist/senses/trust-gate.js +104 -9
  511. package/dist/senses/voice/audio-playback.js +237 -0
  512. package/dist/senses/voice/audio-routing.js +119 -0
  513. package/dist/senses/voice/elevenlabs.js +202 -0
  514. package/dist/senses/voice/floor-control.js +431 -0
  515. package/dist/senses/voice/floor-controller.js +115 -0
  516. package/dist/senses/voice/golden-path.js +116 -0
  517. package/dist/senses/voice/index.js +29 -0
  518. package/dist/senses/voice/meeting.js +113 -0
  519. package/dist/senses/voice/outbound.js +190 -0
  520. package/dist/senses/voice/phone.js +33 -0
  521. package/dist/senses/voice/playback.js +139 -0
  522. package/dist/senses/voice/realtime-eval.js +496 -0
  523. package/dist/senses/voice/realtime-trace.js +531 -0
  524. package/dist/senses/voice/transcript.js +70 -0
  525. package/dist/senses/voice/turn.js +191 -0
  526. package/dist/senses/voice/twilio-phone-runtime.js +807 -0
  527. package/dist/senses/voice/twilio-phone.js +5081 -0
  528. package/dist/senses/voice/types.js +2 -0
  529. package/dist/senses/voice/whisper.js +161 -0
  530. package/dist/senses/voice-entry.js +81 -0
  531. package/dist/senses/voice-realtime-eval-command.js +99 -0
  532. package/dist/senses/voice-realtime-eval-entry.js +21 -0
  533. package/dist/senses/voice-twilio-entry.js +87 -0
  534. package/dist/trips/core.js +138 -0
  535. package/dist/trips/store.js +265 -0
  536. package/dist/util/frontmatter.js +69 -0
  537. package/npm-shrinkwrap.json +8151 -0
  538. package/package.json +69 -12
  539. package/skills/agent-commerce.md +113 -0
  540. package/skills/browser-navigation.md +117 -0
  541. package/skills/commerce-setup-guide.md +116 -0
  542. package/skills/commerce-setup.md +84 -0
  543. package/skills/configure-dev-tools.md +99 -0
  544. package/skills/travel-planning.md +138 -0
  545. package/dist/heart/daemon/auth-flow.js +0 -430
  546. package/dist/heart/daemon/ouro-path-installer.js +0 -178
  547. package/dist/heart/daemon/update-hooks.js +0 -138
  548. package/dist/heart/safe-workspace.js +0 -228
  549. package/dist/heart/session-recall.js +0 -116
  550. package/dist/mind/friends/channel.js +0 -84
  551. package/dist/mind/friends/group-context.js +0 -144
  552. package/dist/mind/friends/resolver.js +0 -84
  553. package/dist/mind/friends/store-file.js +0 -190
  554. package/dist/mind/friends/store.js +0 -4
  555. package/dist/mind/friends/tokens.js +0 -26
  556. package/dist/mind/friends/trust-explanation.js +0 -74
  557. package/dist/mind/friends/types.js +0 -29
  558. package/dist/repertoire/tasks/board.js +0 -134
  559. package/dist/repertoire/tasks/index.js +0 -224
  560. package/dist/repertoire/tasks/lifecycle.js +0 -80
  561. package/dist/repertoire/tasks/middleware.js +0 -65
  562. package/dist/repertoire/tasks/parser.js +0 -173
  563. package/dist/repertoire/tasks/scanner.js +0 -132
  564. package/dist/repertoire/tasks/transitions.js +0 -144
  565. package/dist/senses/bluebubbles-entry.js +0 -13
  566. package/dist/senses/bluebubbles.js +0 -1180
  567. package/dist/senses/debug-activity.js +0 -148
  568. package/dist/senses/inner-dialog-worker.js +0 -90
  569. package/dist/senses/inner-dialog.js +0 -480
  570. package/subagents/README.md +0 -7
  571. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
  572. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
  573. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
  574. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
  575. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
  576. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +0 -0
  577. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
  578. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
  579. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
  580. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
  581. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
  582. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
  583. /package/dist/{repertoire/tasks/types.js → heart/attachments/sources/adapter.js} +0 -0
  584. /package/dist/heart/{daemon → hatch}/hatch-animation.js +0 -0
  585. /package/dist/heart/{daemon → hatch}/specialist-orchestrator.js +0 -0
  586. /package/dist/heart/{daemon → versioning}/ouro-uti.js +0 -0
  587. /package/dist/heart/{daemon → versioning}/wrapper-publish-guard.js +0 -0
@@ -0,0 +1,1623 @@
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 only the runtime and sanctuary.ouro bundle paths from
6
+ the Unraid template, and uses restart policy unless-stopped.
7
+
8
+ Start/stop:
9
+ docker start ouro-butler
10
+ docker stop ouro-butler
11
+
12
+ Status and health:
13
+ docker inspect --format '{{.State.Status}} {{.State.Health.Status}}' ouro-butler
14
+ docker logs --tail 200 ouro-butler
15
+
16
+ Effective-spec audit helper:
17
+ Run Update and Restore command sequences from a root shell with `set -eu`.
18
+ Before either sequence, define this helper. It captures one actual container
19
+ inspect and its reviewed image inspect without printing either, invokes the
20
+ packaged auditor from IMAGE_ID, and removes its mode-0600 inputs on success
21
+ or shell exit:
22
+ audit_effective() {
23
+ (
24
+ AUDIT_CONTAINER=$1
25
+ AUDIT_EXPECTED_IMAGE=$2
26
+ INSPECT_DIR=$(mktemp -d /mnt/user/appdata/ouro-butler/staging/inspect.XXXXXX) || return $?
27
+ trap 'rm -f -- "$INSPECT_DIR/container.json" "$INSPECT_DIR/image.json"; rmdir -- "$INSPECT_DIR"' EXIT || return $?
28
+ chmod 0700 "$INSPECT_DIR" || return $?
29
+ umask 077 || return $?
30
+ docker inspect "$AUDIT_CONTAINER" >"$INSPECT_DIR/container.json" || return $?
31
+ docker image inspect "$AUDIT_EXPECTED_IMAGE" >"$INSPECT_DIR/image.json" || return $?
32
+ chmod 0600 "$INSPECT_DIR/container.json" "$INSPECT_DIR/image.json" || return $?
33
+ docker run --rm --pull=never --network=none \
34
+ --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh \
35
+ --mount "type=bind,src=$INSPECT_DIR/container.json,dst=/audit/container.json,readonly" \
36
+ --mount "type=bind,src=$INSPECT_DIR/image.json,dst=/audit/image.json,readonly" \
37
+ "$IMAGE_ID" --inspect /audit/container.json --image-inspect /audit/image.json --expected-image "$AUDIT_EXPECTED_IMAGE" || return $?
38
+ rm -f -- "$INSPECT_DIR/container.json" "$INSPECT_DIR/image.json" || return $?
39
+ rmdir -- "$INSPECT_DIR" || return $?
40
+ trap - EXIT || return $?
41
+ )
42
+ }
43
+ Define these helpers in the same root shell. Each autostart change uses
44
+ Unraid's authenticated loopback WebGUI endpoint with the live root-session
45
+ CSRF token supplied only on request stdin. Requests have hard connection and
46
+ total timeouts, discard response bodies, and read back the durable array
47
+ autostart state without writing its backing file directly:
48
+ set_butler_autostart() {
49
+ (
50
+ AUTOSTART_CONTAINER=$1
51
+ AUTOSTART_ENABLED=$2
52
+ case "$AUTOSTART_CONTAINER" in
53
+ ouro-butler|ouro-butler-staging|ouro-butler-rollback|ouro-butler-legacy-evidence) ;;
54
+ *) return 1 ;;
55
+ esac
56
+ case "$AUTOSTART_ENABLED" in true|false) ;; *) return 1 ;; esac
57
+ AUTOSTART_CSRF_FILE=/var/local/emhttp/var.ini
58
+ test -f "$AUTOSTART_CSRF_FILE" || return $?
59
+ AUTOSTART_CSRF_TOKEN=$(awk -F= '$1 == "csrf_token" {
60
+ value = $2
61
+ sub(/^"/, "", value)
62
+ sub(/"$/, "", value)
63
+ print value
64
+ }' "$AUTOSTART_CSRF_FILE") || return $?
65
+ test -n "$AUTOSTART_CSRF_TOKEN" || return $?
66
+ case "$AUTOSTART_CSRF_TOKEN" in *[!A-Za-z0-9._-]*) return 1 ;; esac
67
+ printf '%s' "action=autostart&container=$AUTOSTART_CONTAINER&auto=$AUTOSTART_ENABLED&wait=0&csrf_token=$AUTOSTART_CSRF_TOKEN" | \
68
+ curl --silent --show-error --fail --request POST \
69
+ --connect-timeout 5 --max-time 15 \
70
+ --header 'Content-Type: application/x-www-form-urlencoded' \
71
+ --data-binary @- --output /dev/null \
72
+ http://127.0.0.1/plugins/dynamix.docker.manager/include/UpdateConfig.php || return $?
73
+ )
74
+ }
75
+ verify_butler_autostart() {
76
+ (
77
+ EXPECTED_AUTOSTART_COUNTS=$1
78
+ AUTOSTART_FILE=/var/lib/docker/unraid-autostart
79
+ test -f "$AUTOSTART_FILE" || return $?
80
+ AUTOSTART_COUNTS=$(awk '
81
+ $1 == "ouro-butler" { production++ }
82
+ $1 == "ouro-butler-staging" { staging++ }
83
+ $1 == "ouro-butler-rollback" { rollback++ }
84
+ $1 == "ouro-butler-legacy-evidence" { legacy++ }
85
+ END { printf "%d %d %d %d", production + 0, staging + 0, rollback + 0, legacy + 0 }
86
+ ' "$AUTOSTART_FILE") || return $?
87
+ test "$AUTOSTART_COUNTS" = "$EXPECTED_AUTOSTART_COUNTS" || return $?
88
+ )
89
+ }
90
+ disable_butler_autostart() {
91
+ set_butler_autostart ouro-butler-staging false || return $?
92
+ set_butler_autostart ouro-butler-rollback false || return $?
93
+ set_butler_autostart ouro-butler-legacy-evidence false || return $?
94
+ set_butler_autostart ouro-butler false || return $?
95
+ verify_butler_autostart "0 0 0 0" || return $?
96
+ }
97
+ enable_butler_autostart() {
98
+ set_butler_autostart ouro-butler-staging false || return $?
99
+ set_butler_autostart ouro-butler-rollback false || return $?
100
+ set_butler_autostart ouro-butler-legacy-evidence false || return $?
101
+ set_butler_autostart ouro-butler true || return $?
102
+ verify_butler_autostart "1 0 0 0" || return $?
103
+ }
104
+ This bounded wait accepts only Docker's healthy state. The image healthcheck
105
+ verifies fresh daemon state plus exactly one managed Telegram process and one
106
+ managed scheduler process. It fails immediately on an exited, dead, or
107
+ unhealthy container and times out after four minutes:
108
+ wait_butler_ready() {
109
+ WAIT_CONTAINER=$1
110
+ WAIT_NOW=$(date +%s) || return $?
111
+ WAIT_DEADLINE=$(( WAIT_NOW + 240 ))
112
+ while :; do
113
+ WAIT_NOW=$(date +%s) || return $?
114
+ test "$WAIT_NOW" -lt "$WAIT_DEADLINE" || break
115
+ WAIT_STATE=$(docker inspect --format '{{.State.Status}} {{.State.Health.Status}}' "$WAIT_CONTAINER") || return $?
116
+ if test "$WAIT_STATE" = "running healthy"; then
117
+ return 0
118
+ fi
119
+ case "$WAIT_STATE" in
120
+ exited\ *|dead\ *|*\ unhealthy) return 1 ;;
121
+ esac
122
+ sleep 5 || return $?
123
+ done
124
+ return 1
125
+ }
126
+ Define these fail-closed image and topology helpers in the same shell. They use
127
+ exact Docker image IDs and exact container names; no mutation is performed:
128
+ validate_exact_image_id() {
129
+ VALIDATE_IMAGE_ID=$1
130
+ case "$VALIDATE_IMAGE_ID" in
131
+ sha256:*) VALIDATE_IMAGE_HEX=${VALIDATE_IMAGE_ID#sha256:} ;;
132
+ *) return 1 ;;
133
+ esac
134
+ test "${#VALIDATE_IMAGE_HEX}" -eq 64 || return $?
135
+ case "$VALIDATE_IMAGE_HEX" in
136
+ *[!0-9a-f]*) return 1 ;;
137
+ esac
138
+ docker image inspect "$VALIDATE_IMAGE_ID" >/dev/null || return $?
139
+ }
140
+ assert_only_running_butler() {
141
+ EXPECTED_RUNNING_BUTLER=$1
142
+ RUNNING_BUTLER_NAMES=$(docker container ls --format '{{.Names}}') || return $?
143
+ RUNNING_BUTLER_COUNTS=$(printf '%s\n' "$RUNNING_BUTLER_NAMES" | awk -v expected="$EXPECTED_RUNNING_BUTLER" '
144
+ {
145
+ if ($0 == "ouro-butler" || $0 == "ouro-butler-staging" || $0 == "ouro-butler-rollback" || $0 == "ouro-butler-legacy-evidence") {
146
+ butlers++
147
+ if ($0 == expected) matching++
148
+ }
149
+ }
150
+ END { printf "%d %d", butlers + 0, matching + 0 }
151
+ ') || return $?
152
+ test "$RUNNING_BUTLER_COUNTS" = "1 1" || return $?
153
+ }
154
+ assert_update_topology() {
155
+ EXPECTED_ROLLBACK_IMAGE_ID=$1
156
+ UPDATE_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
157
+ UPDATE_NAME_COUNTS=$(printf '%s\n' "$UPDATE_CONTAINER_NAMES" | awk '
158
+ $0 == "ouro-butler" { production++ }
159
+ $0 == "ouro-butler-staging" { staging++ }
160
+ $0 == "ouro-butler-rollback" { rollback++ }
161
+ $0 == "ouro-butler-legacy-evidence" { legacy++ }
162
+ END {
163
+ printf "%d %d %d %d", production + 0, staging + 0, rollback + 0, legacy + 0
164
+ }
165
+ ') || return $?
166
+ case "$UPDATE_NAME_COUNTS" in
167
+ "1 0 0 0"|"1 0 1 0"|"1 0 0 1"|"1 0 1 1") ;;
168
+ *) return 1 ;;
169
+ esac
170
+ UPDATE_PRODUCTION_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler) || return $?
171
+ test "$UPDATE_PRODUCTION_RUNNING" = true || return $?
172
+ UPDATE_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
173
+ test "$UPDATE_PRODUCTION_IMAGE_ID" = "$EXPECTED_ROLLBACK_IMAGE_ID" || return $?
174
+ case "$UPDATE_NAME_COUNTS" in
175
+ "1 0 1 0"|"1 0 1 1")
176
+ UPDATE_ROLLBACK_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback) || return $?
177
+ test "$UPDATE_ROLLBACK_RUNNING" = false || return $?
178
+ UPDATE_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
179
+ test "$UPDATE_ROLLBACK_IMAGE_ID" = "$EXPECTED_ROLLBACK_IMAGE_ID" || return $?
180
+ ;;
181
+ esac
182
+ case "$UPDATE_NAME_COUNTS" in
183
+ "1 0 0 1"|"1 0 1 1")
184
+ UPDATE_LEGACY_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence) || return $?
185
+ test "$UPDATE_LEGACY_RUNNING" = false || return $?
186
+ UPDATE_LEGACY_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
187
+ validate_exact_image_id "$UPDATE_LEGACY_IMAGE_ID" || return $?
188
+ ;;
189
+ esac
190
+ assert_only_running_butler ouro-butler || return $?
191
+ }
192
+ validate_sanctuary_roots() {
193
+ (
194
+ VALIDATE_RUNTIME_ROOT=$1
195
+ VALIDATE_AGENT_ROOT=$2
196
+ test -d "$VALIDATE_RUNTIME_ROOT" || return $?
197
+ test -d "$VALIDATE_AGENT_ROOT" || return $?
198
+ VALIDATE_BAD_SHAPE=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev \( -type l -o \( ! -type d -a ! -type f \) \) -print -quit) || return $?
199
+ test -z "$VALIDATE_BAD_SHAPE" || return $?
200
+ for VALIDATE_REQUIRED_FILE in \
201
+ agent.json bundle-meta.json provider-readiness.json tool-profiles.json \
202
+ psyche/SOUL.md habits/sanctuary-health.md; do
203
+ test -s "$VALIDATE_AGENT_ROOT/$VALIDATE_REQUIRED_FILE" || return $?
204
+ done
205
+ test -d "$VALIDATE_RUNTIME_ROOT/vault-unlock" || return $?
206
+ VALIDATE_UNLOCK_FILES=$(find "$VALIDATE_RUNTIME_ROOT/vault-unlock" -xdev -mindepth 1 -maxdepth 1 -type f -name '*.secret' -size +0c -print) || return $?
207
+ test "$(printf '%s\n' "$VALIDATE_UNLOCK_FILES" | awk 'NF { count++ } END { print count + 0 }')" = 1 || return $?
208
+ test ! -e "$VALIDATE_RUNTIME_ROOT/container-credentials.json" || return $?
209
+ test ! -e "$VALIDATE_RUNTIME_ROOT/container-credentials.json.consuming" || return $?
210
+ VALIDATE_WRONG_OWNER=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev \( ! -user 10001 -o ! -group 10001 \) -print -quit) || return $?
211
+ test -z "$VALIDATE_WRONG_OWNER" || return $?
212
+ VALIDATE_WRONG_DIR_MODE=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev -type d ! -perm 0700 -print -quit) || return $?
213
+ test -z "$VALIDATE_WRONG_DIR_MODE" || return $?
214
+ VALIDATE_WRONG_FILE_MODE=$(find "$VALIDATE_RUNTIME_ROOT" "$VALIDATE_AGENT_ROOT" -xdev -type f ! -perm 0600 -print -quit) || return $?
215
+ test -z "$VALIDATE_WRONG_FILE_MODE" || return $?
216
+ )
217
+ }
218
+ assert_restore_preflight() {
219
+ (
220
+ test -n "${BACKUP_ROOT-}" || return 1
221
+ test -n "${IMAGE_ID-}" || return 1
222
+ case "$BACKUP_ROOT" in
223
+ /*) ;;
224
+ *) return 1 ;;
225
+ esac
226
+ test "$BACKUP_ROOT" != / || return $?
227
+ RESTORE_BACKUP_ROOT=$(cd -- "$BACKUP_ROOT" && pwd -P) || return $?
228
+ test "$RESTORE_BACKUP_ROOT" = "$BACKUP_ROOT" || return $?
229
+ test -d "$BACKUP_ROOT/runtime/.ouro-cli" || return $?
230
+ test -d "$BACKUP_ROOT/agent/sanctuary.ouro" || return $?
231
+ validate_sanctuary_roots "$BACKUP_ROOT/runtime/.ouro-cli" "$BACKUP_ROOT/agent/sanctuary.ouro" || return $?
232
+ validate_exact_image_id "$IMAGE_ID" || return $?
233
+ RESTORE_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
234
+ RESTORE_NAME_COUNTS=$(printf '%s\n' "$RESTORE_CONTAINER_NAMES" | awk '
235
+ $0 == "ouro-butler" { production++ }
236
+ $0 == "ouro-butler-staging" { staging++ }
237
+ $0 == "ouro-butler-rollback" { rollback++ }
238
+ $0 == "ouro-butler-legacy-evidence" { legacy++ }
239
+ END {
240
+ printf "%d %d %d %d", production + 0, staging + 0, rollback + 0, legacy + 0
241
+ }
242
+ ') || return $?
243
+ case "$RESTORE_NAME_COUNTS" in
244
+ "1 0 0 0"|"1 0 0 1") ;;
245
+ *) return 1 ;;
246
+ esac
247
+ RESTORE_PRODUCTION_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler) || return $?
248
+ test "$RESTORE_PRODUCTION_RUNNING" = true || return $?
249
+ RESTORE_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
250
+ validate_exact_image_id "$RESTORE_PRODUCTION_IMAGE_ID" || return $?
251
+ if test "$RESTORE_NAME_COUNTS" = "1 0 0 1"; then
252
+ RESTORE_LEGACY_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence) || return $?
253
+ test "$RESTORE_LEGACY_RUNNING" = false || return $?
254
+ RESTORE_LEGACY_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
255
+ validate_exact_image_id "$RESTORE_LEGACY_IMAGE_ID" || return $?
256
+ fi
257
+ assert_only_running_butler ouro-butler || return $?
258
+ )
259
+ }
260
+ ensure_sanctuary_machine_identity() {
261
+ MACHINE_PATH=$1
262
+ test ! -L "$MACHINE_PATH" || return 1
263
+ /usr/local/bin/node -e '
264
+ const fs = require("node:fs");
265
+ const path = process.argv[1];
266
+ const valid = value => value && value.schemaVersion === 1 && value.machineId === "sanctuary"
267
+ && typeof value.createdAt === "string" && typeof value.updatedAt === "string"
268
+ && Array.isArray(value.hostnameAliases);
269
+ if (fs.existsSync(path)) {
270
+ if (!valid(JSON.parse(fs.readFileSync(path, "utf8")))) process.exit(1);
271
+ process.exit(0);
272
+ }
273
+ const now = new Date().toISOString();
274
+ const value = { schemaVersion: 1, machineId: "sanctuary", createdAt: now, updatedAt: now, hostnameAliases: [] };
275
+ const temporary = `${path}.tmp.${process.pid}`;
276
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" });
277
+ fs.renameSync(temporary, path);
278
+ ' "$MACHINE_PATH" || return $?
279
+ chown 10001:10001 "$MACHINE_PATH" || return $?
280
+ chmod 0600 "$MACHINE_PATH" || return $?
281
+ sync -f "$MACHINE_PATH" || return $?
282
+ }
283
+ prepare_canonical_sanctuary_roots() {
284
+ PREPARE_IMAGE_ID=$1
285
+ validate_exact_image_id "$PREPARE_IMAGE_ID" || return $?
286
+ PREPARE_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
287
+ PREPARE_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
288
+ install -d -m 0700 -o 10001 -g 10001 "$PREPARE_RUNTIME_ROOT" || return $?
289
+ install -d -m 0700 -o 10001 -g 10001 "$PREPARE_AGENT_ROOT" || return $?
290
+ ensure_sanctuary_machine_identity "$PREPARE_RUNTIME_ROOT/machine.json" || return $?
291
+ if test ! -f "$PREPARE_AGENT_ROOT/agent.json"; then
292
+ PREPARE_EXISTING_ENTRY=$(find "$PREPARE_AGENT_ROOT" -mindepth 1 -print -quit) || return $?
293
+ test -z "$PREPARE_EXISTING_ENTRY" || return $?
294
+ ! docker container inspect ouro-butler-bundle-bootstrap >/dev/null 2>&1 || return 1
295
+ docker run --rm --pull=never --network=none --name ouro-butler-bundle-bootstrap --user 10001:10001 \
296
+ --mount "type=bind,src=$PREPARE_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
297
+ --mount "type=bind,src=$PREPARE_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
298
+ --entrypoint /bin/sh "$PREPARE_IMAGE_ID" -ceu '
299
+ cp -R /opt/ouro/deploy/unraid/sanctuary.ouro/. /home/ouro/AgentBundles/sanctuary.ouro/
300
+ find /home/ouro/AgentBundles/sanctuary.ouro -type d -exec chmod 0700 {} +
301
+ find /home/ouro/AgentBundles/sanctuary.ouro -type f -exec chmod 0600 {} +
302
+ ' || return $?
303
+ ! docker container inspect ouro-butler-bundle-bootstrap >/dev/null 2>&1 || return 1
304
+ fi
305
+ for PREPARE_REQUIRED_FILE in \
306
+ agent.json bundle-meta.json provider-readiness.json tool-profiles.json \
307
+ psyche/SOUL.md habits/sanctuary-health.md; do
308
+ test -f "$PREPARE_AGENT_ROOT/$PREPARE_REQUIRED_FILE" || return $?
309
+ done
310
+ PREPARE_WRONG_OWNER=$(find "$PREPARE_RUNTIME_ROOT" "$PREPARE_AGENT_ROOT" \( ! -user 10001 -o ! -group 10001 \) -print -quit) || return $?
311
+ test -z "$PREPARE_WRONG_OWNER" || return $?
312
+ PREPARE_WRONG_DIR_MODE=$(find "$PREPARE_RUNTIME_ROOT" "$PREPARE_AGENT_ROOT" -type d ! -perm 0700 -print -quit) || return $?
313
+ test -z "$PREPARE_WRONG_DIR_MODE" || return $?
314
+ PREPARE_WRONG_FILE_MODE=$(find "$PREPARE_RUNTIME_ROOT" "$PREPARE_AGENT_ROOT" -type f ! -perm 0600 -print -quit) || return $?
315
+ test -z "$PREPARE_WRONG_FILE_MODE" || return $?
316
+ }
317
+ validate_sanctuary_legacy_staging() {
318
+ EXPECTED_LEGACY_CONTAINER_ID=${1-}
319
+ EXPECTED_LEGACY_IMAGE_ID=${2-}
320
+ LEGACY_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
321
+ LEGACY_NAME_COUNTS=$(printf '%s\n' "$LEGACY_CONTAINER_NAMES" | awk '
322
+ /butler/ { butlers++ }
323
+ $0 == "ouro-butler-staging" { staging++ }
324
+ END { printf "%d %d", butlers + 0, staging + 0 }
325
+ ') || return $?
326
+ test "$LEGACY_NAME_COUNTS" = "1 1" || return $?
327
+ assert_only_running_butler ouro-butler-staging || return $?
328
+ LEGACY_STAGING_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-staging) || return $?
329
+ test "$LEGACY_STAGING_RUNNING" = true || return $?
330
+ LEGACY_STAGING_CONTAINER_ID=$(docker inspect --format '{{.Id}}' ouro-butler-staging) || return $?
331
+ case "$LEGACY_STAGING_CONTAINER_ID" in *[!0-9a-f]*|'') return 1 ;; esac
332
+ test "${#LEGACY_STAGING_CONTAINER_ID}" -eq 64 || return 1
333
+ LEGACY_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-staging) || return $?
334
+ validate_exact_image_id "$LEGACY_STAGING_IMAGE_ID" || return $?
335
+ if test -n "$EXPECTED_LEGACY_CONTAINER_ID"; then
336
+ test "$LEGACY_STAGING_CONTAINER_ID" = "$EXPECTED_LEGACY_CONTAINER_ID" || return 1
337
+ test "$LEGACY_STAGING_IMAGE_ID" = "$EXPECTED_LEGACY_IMAGE_ID" || return 1
338
+ fi
339
+ }
340
+ prepare_sanctuary_legacy_adoption() {
341
+ IMAGE_ID=$1
342
+ validate_exact_image_id "$IMAGE_ID" || return $?
343
+ validate_sanctuary_legacy_staging || return $?
344
+ PREPARED_LEGACY_CONTAINER_ID=$LEGACY_STAGING_CONTAINER_ID
345
+ PREPARED_LEGACY_IMAGE_ID=$LEGACY_STAGING_IMAGE_ID
346
+ prepare_canonical_sanctuary_roots "$IMAGE_ID" || return $?
347
+ bootstrap_sanctuary_vault "$IMAGE_ID" \
348
+ /mnt/user/appdata/ouro-butler/runtime/container-credentials.json \
349
+ sanctuary-unraid sanctuary || return $?
350
+ validate_sanctuary_legacy_staging "$PREPARED_LEGACY_CONTAINER_ID" "$PREPARED_LEGACY_IMAGE_ID" || return $?
351
+ LEGACY_STAGING_CONTAINER_ID=$PREPARED_LEGACY_CONTAINER_ID
352
+ LEGACY_STAGING_IMAGE_ID=$PREPARED_LEGACY_IMAGE_ID
353
+ }
354
+ capture_sanctuary_legacy_evidence() {
355
+ CAPTURED_LEGACY_CONTAINER_ID=$1
356
+ CAPTURED_LEGACY_IMAGE_ID=$2
357
+ LEGACY_EVIDENCE_ROOT=$3
358
+ case "$CAPTURED_LEGACY_CONTAINER_ID" in *[!0-9a-f]*|'') return 1 ;; esac
359
+ test "${#CAPTURED_LEGACY_CONTAINER_ID}" -eq 64 || return 1
360
+ validate_exact_image_id "$CAPTURED_LEGACY_IMAGE_ID" || return $?
361
+ case "$LEGACY_EVIDENCE_ROOT" in /*) ;; *) return 1 ;; esac
362
+ if test -e "$LEGACY_EVIDENCE_ROOT" || test -L "$LEGACY_EVIDENCE_ROOT"; then
363
+ /usr/local/bin/node -e '
364
+ const fs = require("node:fs");
365
+ const root = fs.lstatSync(process.argv[1]);
366
+ if (!root.isDirectory() || root.isSymbolicLink() || (root.mode & 0o777) !== 0o700) process.exit(1);
367
+ if (root.uid !== process.geteuid() || root.gid !== process.getegid()) process.exit(1);
368
+ ' "$LEGACY_EVIDENCE_ROOT" || return $?
369
+ else
370
+ install -d -m 0700 -o 0 -g 0 "$LEGACY_EVIDENCE_ROOT" || return $?
371
+ fi
372
+ LEGACY_EVIDENCE_DIR="$LEGACY_EVIDENCE_ROOT/${CAPTURED_LEGACY_IMAGE_ID#sha256:}"
373
+ if test -e "$LEGACY_EVIDENCE_DIR" || test -L "$LEGACY_EVIDENCE_DIR"; then
374
+ test -d "$LEGACY_EVIDENCE_DIR" && test ! -L "$LEGACY_EVIDENCE_DIR" || return 1
375
+ else
376
+ mkdir -m 0700 "$LEGACY_EVIDENCE_DIR" || return $?
377
+ fi
378
+ /usr/local/bin/node -e '
379
+ const fs = require("node:fs");
380
+ const [rootPath, directoryPath] = process.argv.slice(1);
381
+ const root = fs.lstatSync(rootPath);
382
+ const directory = fs.lstatSync(directoryPath);
383
+ const privateDirectory = value => value.isDirectory() && !value.isSymbolicLink() && (value.mode & 0o777) === 0o700;
384
+ if (!privateDirectory(root) || !privateDirectory(directory)) process.exit(1);
385
+ if (root.uid !== process.geteuid() || root.gid !== process.getegid()) process.exit(1);
386
+ if (root.uid !== directory.uid || root.gid !== directory.gid) process.exit(1);
387
+ const allowed = new Set(["container.json", "image.json"]);
388
+ if (fs.readdirSync(directoryPath).some(name => !allowed.has(name))) process.exit(1);
389
+ ' "$LEGACY_EVIDENCE_ROOT" "$LEGACY_EVIDENCE_DIR" || return $?
390
+ validate_legacy_evidence_entry() {
391
+ VALIDATED_EVIDENCE_KIND=$1
392
+ VALIDATED_EVIDENCE_PATH=$2
393
+ /usr/local/bin/node -e '
394
+ const fs = require("node:fs");
395
+ const [kind, filePath, rootPath, containerId, imageId] = process.argv.slice(1);
396
+ const root = fs.lstatSync(rootPath);
397
+ const entry = fs.lstatSync(filePath);
398
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== root.uid || entry.gid !== root.gid) process.exit(1);
399
+ const mode = entry.mode & 0o777;
400
+ if (mode !== 0o600 && mode !== 0o644) process.exit(1);
401
+ const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
402
+ if (!Array.isArray(value) || value.length !== 1 || !value[0] || typeof value[0] !== "object") process.exit(1);
403
+ if (kind === "container" && (value[0].Id !== containerId || value[0].Image !== imageId)) process.exit(1);
404
+ if (kind === "image" && value[0].Id !== imageId) process.exit(1);
405
+ ' "$VALIDATED_EVIDENCE_KIND" "$VALIDATED_EVIDENCE_PATH" "$LEGACY_EVIDENCE_ROOT" "$CAPTURED_LEGACY_CONTAINER_ID" "$CAPTURED_LEGACY_IMAGE_ID"
406
+ }
407
+ capture_missing_legacy_evidence_entry() {
408
+ MISSING_EVIDENCE_KIND=$1
409
+ MISSING_EVIDENCE_PATH=$2
410
+ LEGACY_EVIDENCE_TMP=$(mktemp "$LEGACY_EVIDENCE_ROOT/.capture.XXXXXXXX") || return $?
411
+ if test "$MISSING_EVIDENCE_KIND" = container; then
412
+ docker container inspect "$CAPTURED_LEGACY_CONTAINER_ID" >"$LEGACY_EVIDENCE_TMP" || {
413
+ CAPTURE_STATUS=$?
414
+ rm -f "$LEGACY_EVIDENCE_TMP"
415
+ return "$CAPTURE_STATUS"
416
+ }
417
+ else
418
+ docker image inspect "$CAPTURED_LEGACY_IMAGE_ID" >"$LEGACY_EVIDENCE_TMP" || {
419
+ CAPTURE_STATUS=$?
420
+ rm -f "$LEGACY_EVIDENCE_TMP"
421
+ return "$CAPTURE_STATUS"
422
+ }
423
+ fi
424
+ chmod 0600 "$LEGACY_EVIDENCE_TMP" || {
425
+ CAPTURE_STATUS=$?
426
+ rm -f "$LEGACY_EVIDENCE_TMP"
427
+ return "$CAPTURE_STATUS"
428
+ }
429
+ validate_legacy_evidence_entry "$MISSING_EVIDENCE_KIND" "$LEGACY_EVIDENCE_TMP" || {
430
+ CAPTURE_STATUS=$?
431
+ rm -f "$LEGACY_EVIDENCE_TMP"
432
+ return "$CAPTURE_STATUS"
433
+ }
434
+ sync -f "$LEGACY_EVIDENCE_TMP" || {
435
+ CAPTURE_STATUS=$?
436
+ rm -f "$LEGACY_EVIDENCE_TMP"
437
+ return "$CAPTURE_STATUS"
438
+ }
439
+ if ln "$LEGACY_EVIDENCE_TMP" "$MISSING_EVIDENCE_PATH"; then
440
+ rm -f "$LEGACY_EVIDENCE_TMP" || return $?
441
+ else
442
+ rm -f "$LEGACY_EVIDENCE_TMP" || return $?
443
+ validate_legacy_evidence_entry "$MISSING_EVIDENCE_KIND" "$MISSING_EVIDENCE_PATH" || return $?
444
+ fi
445
+ }
446
+ for LEGACY_EVIDENCE_KIND in container image; do
447
+ LEGACY_EVIDENCE_PATH="$LEGACY_EVIDENCE_DIR/$LEGACY_EVIDENCE_KIND.json"
448
+ if test -e "$LEGACY_EVIDENCE_PATH" || test -L "$LEGACY_EVIDENCE_PATH"; then
449
+ validate_legacy_evidence_entry "$LEGACY_EVIDENCE_KIND" "$LEGACY_EVIDENCE_PATH" || return $?
450
+ else
451
+ capture_missing_legacy_evidence_entry "$LEGACY_EVIDENCE_KIND" "$LEGACY_EVIDENCE_PATH" || return $?
452
+ fi
453
+ chmod 0600 "$LEGACY_EVIDENCE_PATH" || return $?
454
+ validate_legacy_evidence_entry "$LEGACY_EVIDENCE_KIND" "$LEGACY_EVIDENCE_PATH" || return $?
455
+ done
456
+ sync -f "$LEGACY_EVIDENCE_DIR" || return $?
457
+ }
458
+ install_from_legacy_staging() {
459
+ prepare_sanctuary_legacy_adoption "$IMAGE_ID" || return $?
460
+ ADOPTION_PREPARED_CONTAINER_ID=$LEGACY_STAGING_CONTAINER_ID
461
+ ADOPTION_PREPARED_IMAGE_ID=$LEGACY_STAGING_IMAGE_ID
462
+ verify_sanctuary_provider_readiness "$IMAGE_ID" || return $?
463
+ ADOPTION_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
464
+ ADOPTION_NAME_COUNTS=$(printf '%s\n' "$ADOPTION_CONTAINER_NAMES" | awk '
465
+ /butler/ { butlers++ }
466
+ $0 == "ouro-butler-staging" { staging++ }
467
+ END { printf "%d %d", butlers + 0, staging + 0 }
468
+ ') || return $?
469
+ test "$ADOPTION_NAME_COUNTS" = "1 1" || return $?
470
+ assert_only_running_butler ouro-butler-staging || return $?
471
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-staging)" = true || return $?
472
+ test "$(docker inspect --format '{{.Id}}' ouro-butler-staging)" = "$ADOPTION_PREPARED_CONTAINER_ID" || return $?
473
+ test "$(docker inspect --format '{{.Image}}' ouro-butler-staging)" = "$ADOPTION_PREPARED_IMAGE_ID" || return $?
474
+ LEGACY_STAGING_CONTAINER_ID=$ADOPTION_PREPARED_CONTAINER_ID
475
+ LEGACY_STAGING_IMAGE_ID=$ADOPTION_PREPARED_IMAGE_ID
476
+ LEGACY_EVIDENCE_ROOT=/mnt/user/appdata/ouro-butler/legacy-evidence
477
+ capture_sanctuary_legacy_evidence "$LEGACY_STAGING_CONTAINER_ID" "$LEGACY_STAGING_IMAGE_ID" "$LEGACY_EVIDENCE_ROOT" || return $?
478
+ validate_sanctuary_legacy_staging "$ADOPTION_PREPARED_CONTAINER_ID" "$ADOPTION_PREPARED_IMAGE_ID" || return $?
479
+ LEGACY_STAGING_CONTAINER_ID=$ADOPTION_PREPARED_CONTAINER_ID
480
+ LEGACY_STAGING_IMAGE_ID=$ADOPTION_PREPARED_IMAGE_ID
481
+ disable_butler_autostart || return $?
482
+ if docker stop "$LEGACY_STAGING_CONTAINER_ID" \
483
+ && CURRENT_LEGACY_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' "$LEGACY_STAGING_CONTAINER_ID") \
484
+ && test "$CURRENT_LEGACY_STAGING_IMAGE_ID" = "$LEGACY_STAGING_IMAGE_ID" \
485
+ && test "$(docker inspect --format '{{.State.Running}}' "$LEGACY_STAGING_CONTAINER_ID")" = false \
486
+ && docker rename "$LEGACY_STAGING_CONTAINER_ID" ouro-butler-legacy-evidence \
487
+ && CURRENT_LEGACY_EVIDENCE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) \
488
+ && test "$CURRENT_LEGACY_EVIDENCE_IMAGE_ID" = "$LEGACY_STAGING_IMAGE_ID" \
489
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence)" = false \
490
+ && docker create --name ouro-butler-staging --network host --restart unless-stopped --user 10001:10001 \
491
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
492
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
493
+ "$IMAGE_ID" \
494
+ && audit_effective ouro-butler-staging "$IMAGE_ID" \
495
+ && docker start ouro-butler-staging \
496
+ && assert_only_running_butler ouro-butler-staging \
497
+ && wait_butler_ready ouro-butler-staging \
498
+ && docker stop ouro-butler-staging \
499
+ && test "$(docker inspect --format '{{.Image}}' ouro-butler-staging)" = "$IMAGE_ID" \
500
+ && docker rm ouro-butler-staging \
501
+ && docker create --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
502
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
503
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
504
+ "$IMAGE_ID" \
505
+ && audit_effective ouro-butler "$IMAGE_ID" \
506
+ && docker start ouro-butler \
507
+ && assert_only_running_butler ouro-butler \
508
+ && wait_butler_ready ouro-butler \
509
+ && enable_butler_autostart; then
510
+ return 0
511
+ else
512
+ ADOPTION_STATUS=$?
513
+ fi
514
+ ADOPTION_RECOVERY_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
515
+ case "
516
+ $ADOPTION_RECOVERY_NAMES
517
+ " in
518
+ *"
519
+ ouro-butler
520
+ "*)
521
+ ADOPTION_PARTIAL_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler) || return $?
522
+ test "$ADOPTION_PARTIAL_IMAGE_ID" = "$IMAGE_ID" || return $?
523
+ docker stop ouro-butler >/dev/null 2>&1 || true
524
+ docker rm --force ouro-butler || return $?
525
+ ;;
526
+ esac
527
+ ADOPTION_RECOVERY_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
528
+ case "
529
+ $ADOPTION_RECOVERY_NAMES
530
+ " in
531
+ *"
532
+ ouro-butler-staging
533
+ "*)
534
+ CURRENT_LEGACY_STAGING_CONTAINER_ID=$(docker inspect --format '{{.Id}}' ouro-butler-staging) || return $?
535
+ case "$CURRENT_LEGACY_STAGING_CONTAINER_ID" in *[!0-9a-f]*|'') return 1 ;; esac
536
+ test "${#CURRENT_LEGACY_STAGING_CONTAINER_ID}" -eq 64 || return 1
537
+ CURRENT_LEGACY_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-staging) || return $?
538
+ case "$CURRENT_LEGACY_STAGING_IMAGE_ID" in
539
+ "$IMAGE_ID")
540
+ docker stop ouro-butler-staging >/dev/null 2>&1 || true
541
+ docker rm --force ouro-butler-staging || return $?
542
+ ;;
543
+ "$LEGACY_STAGING_IMAGE_ID")
544
+ test "$CURRENT_LEGACY_STAGING_CONTAINER_ID" = "$LEGACY_STAGING_CONTAINER_ID" || return 1
545
+ docker stop "$LEGACY_STAGING_CONTAINER_ID" >/dev/null 2>&1 || return $?
546
+ test "$(docker inspect --format '{{.State.Running}}' "$LEGACY_STAGING_CONTAINER_ID")" = false || return $?
547
+ docker rename "$LEGACY_STAGING_CONTAINER_ID" ouro-butler-legacy-evidence || return $?
548
+ ;;
549
+ *) return 1 ;;
550
+ esac
551
+ ;;
552
+ esac
553
+ CURRENT_LEGACY_EVIDENCE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-legacy-evidence) || return $?
554
+ test "$CURRENT_LEGACY_EVIDENCE_IMAGE_ID" = "$LEGACY_STAGING_IMAGE_ID" || return $?
555
+ test "$(docker inspect --format '{{.State.Running}}' ouro-butler-legacy-evidence)" = false || return $?
556
+ return "$ADOPTION_STATUS"
557
+ }
558
+ validate_sanctuary_legacy_import_marker() {
559
+ IMPORT_MARKER=$1
560
+ IMPORT_SOURCE=$2
561
+ test -f "$IMPORT_MARKER" && test ! -L "$IMPORT_MARKER" || return 1
562
+ test "$(stat -c '%u:%g %a' "$IMPORT_MARKER")" = "10001:10001 600" || return 1
563
+ /usr/local/bin/node -e '
564
+ const crypto = require("node:crypto");
565
+ const fs = require("node:fs");
566
+ const marker = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
567
+ const sourceDigest = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(process.argv[2])).digest("hex")}`;
568
+ if (JSON.stringify(Object.keys(marker).sort()) !== JSON.stringify(["importedAt", "machineId", "schemaVersion", "sourceDigest"])) process.exit(1);
569
+ if (marker.schemaVersion !== 1 || marker.machineId !== "sanctuary" || marker.sourceDigest !== sourceDigest) process.exit(1);
570
+ if (!Number.isFinite(Date.parse(marker.importedAt))) process.exit(1);
571
+ ' "$IMPORT_MARKER" "$IMPORT_SOURCE"
572
+ }
573
+ record_sanctuary_legacy_import_marker() {
574
+ IMPORT_MARKER=$1
575
+ IMPORT_SOURCE=$2
576
+ test ! -e "$IMPORT_MARKER" || return 1
577
+ IMPORT_MARKER_TMP=$(mktemp "${IMPORT_MARKER}.tmp.XXXXXX") || return $?
578
+ trap 'rm -f -- "$IMPORT_MARKER_TMP"' EXIT || return $?
579
+ /usr/local/bin/node -e '
580
+ const crypto = require("node:crypto");
581
+ const fs = require("node:fs");
582
+ const sourceDigest = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(process.argv[2])).digest("hex")}`;
583
+ const marker = { schemaVersion: 1, machineId: "sanctuary", sourceDigest, importedAt: new Date().toISOString() };
584
+ fs.writeFileSync(process.argv[1], `${JSON.stringify(marker)}\n`, { mode: 0o600, flag: "w" });
585
+ ' "$IMPORT_MARKER_TMP" "$IMPORT_SOURCE" || return $?
586
+ chown 10001:10001 "$IMPORT_MARKER_TMP" || return $?
587
+ chmod 0600 "$IMPORT_MARKER_TMP" || return $?
588
+ mv -f -- "$IMPORT_MARKER_TMP" "$IMPORT_MARKER" || return $?
589
+ sync -f "$IMPORT_MARKER" || return $?
590
+ sync -f "$(dirname "$IMPORT_MARKER")" || return $?
591
+ trap - EXIT || return $?
592
+ }
593
+ bootstrap_sanctuary_vault() {
594
+ BOOTSTRAP_IMAGE_ID=$1
595
+ BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE=${2-}
596
+ BOOTSTRAP_SOURCE_MACHINE_ID=${3-}
597
+ BOOTSTRAP_TARGET_MACHINE_ID=${4-}
598
+ validate_exact_image_id "$BOOTSTRAP_IMAGE_ID" || return $?
599
+ case "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" in
600
+ "")
601
+ test -z "$BOOTSTRAP_SOURCE_MACHINE_ID" || return 1
602
+ test -z "$BOOTSTRAP_TARGET_MACHINE_ID" || return 1
603
+ ;;
604
+ /mnt/user/appdata/ouro-butler/runtime/container-credentials.json)
605
+ test "$BOOTSTRAP_SOURCE_MACHINE_ID" = sanctuary-unraid || return 1
606
+ test "$BOOTSTRAP_TARGET_MACHINE_ID" = sanctuary || return 1
607
+ ;;
608
+ *) return 1 ;;
609
+ esac
610
+ BOOTSTRAP_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
611
+ BOOTSTRAP_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
612
+ for BOOTSTRAP_CONTAINER in ouro-butler-vault-status ouro-butler-vault-bootstrap ouro-butler-credential-bootstrap ouro-butler-provider-readiness; do
613
+ ! docker container inspect "$BOOTSTRAP_CONTAINER" >/dev/null 2>&1 || return 1
614
+ done
615
+ VAULT_STATUS=$(docker run --rm --pull=never --network host --name ouro-butler-vault-status --user 10001:10001 \
616
+ --mount "type=bind,src=$BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
617
+ --mount "type=bind,src=$BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
618
+ --entrypoint node "$BOOTSTRAP_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
619
+ vault status --agent sanctuary --store plaintext-file) || return $?
620
+ ! docker container inspect ouro-butler-vault-status >/dev/null 2>&1 || return 1
621
+ case "
622
+ $VAULT_STATUS
623
+ " in
624
+ *"
625
+ vault locator: not configured in agent.json
626
+ "*) VAULT_ACTION=create ;;
627
+ *"
628
+ vault locator: agent.json
629
+ "*)
630
+ case "
631
+ $VAULT_STATUS
632
+ " in
633
+ *"
634
+ local unlock: available
635
+ "*) VAULT_ACTION=none ;;
636
+ *"
637
+ local unlock: missing
638
+ "*) VAULT_ACTION=unlock ;;
639
+ *) return 1 ;;
640
+ esac
641
+ ;;
642
+ *) return 1 ;;
643
+ esac
644
+ if test "$VAULT_ACTION" != none; then
645
+ docker run --rm -it --pull=never --network host --name ouro-butler-vault-bootstrap --user 10001:10001 \
646
+ --mount "type=bind,src=$BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
647
+ --mount "type=bind,src=$BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
648
+ --entrypoint node "$BOOTSTRAP_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
649
+ vault "$VAULT_ACTION" --agent sanctuary --store plaintext-file || return $?
650
+ ! docker container inspect ouro-butler-vault-bootstrap >/dev/null 2>&1 || return 1
651
+ fi
652
+ VERIFIED_VAULT_STATUS=$(docker run --rm --pull=never --network host --name ouro-butler-vault-status --user 10001:10001 \
653
+ --mount "type=bind,src=$BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
654
+ --mount "type=bind,src=$BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
655
+ --entrypoint node "$BOOTSTRAP_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
656
+ vault status --agent sanctuary --store plaintext-file) || return $?
657
+ ! docker container inspect ouro-butler-vault-status >/dev/null 2>&1 || return 1
658
+ case "
659
+ $VERIFIED_VAULT_STATUS
660
+ " in
661
+ *"
662
+ vault locator: agent.json
663
+ "*) ;;
664
+ *) return 1 ;;
665
+ esac
666
+ case "
667
+ $VERIFIED_VAULT_STATUS
668
+ " in
669
+ *"
670
+ local unlock: available
671
+ "*) ;;
672
+ *) return 1 ;;
673
+ esac
674
+ if test -n "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE"; then
675
+ BOOTSTRAP_CREDENTIAL_SOURCE="$BOOTSTRAP_RUNTIME_ROOT/container-credentials.json"
676
+ BOOTSTRAP_CREDENTIAL_CLAIM="$BOOTSTRAP_CREDENTIAL_SOURCE.consuming"
677
+ test -f "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
678
+ test ! -L "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
679
+ BOOTSTRAP_LEGACY_METADATA=$(stat -c '%u:%g %a' "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE") || return $?
680
+ test "$BOOTSTRAP_LEGACY_METADATA" = "10001:10001 600" || return $?
681
+ BOOTSTRAP_IMPORT_MARKER="$BOOTSTRAP_RUNTIME_ROOT/legacy-credentials-imported.json"
682
+ if test -e "$BOOTSTRAP_IMPORT_MARKER"; then
683
+ validate_sanctuary_legacy_import_marker "$BOOTSTRAP_IMPORT_MARKER" "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
684
+ test ! -e "$BOOTSTRAP_CREDENTIAL_SOURCE" || return 1
685
+ test ! -e "$BOOTSTRAP_CREDENTIAL_CLAIM" || return 1
686
+ else
687
+ if test -e "$BOOTSTRAP_CREDENTIAL_SOURCE"; then
688
+ test -f "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
689
+ test ! -L "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
690
+ test "$(stat -c '%u:%g %a' "$BOOTSTRAP_CREDENTIAL_SOURCE")" = "10001:10001 600" || return $?
691
+ cmp -s "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
692
+ elif test -e "$BOOTSTRAP_CREDENTIAL_CLAIM"; then
693
+ test -f "$BOOTSTRAP_CREDENTIAL_CLAIM" || return $?
694
+ test ! -L "$BOOTSTRAP_CREDENTIAL_CLAIM" || return $?
695
+ test "$(stat -c '%u:%g %a' "$BOOTSTRAP_CREDENTIAL_CLAIM")" = "10001:10001 600" || return $?
696
+ cmp -s "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" "$BOOTSTRAP_CREDENTIAL_CLAIM" || return $?
697
+ else
698
+ BOOTSTRAP_CREDENTIAL_TMP=$(mktemp "$BOOTSTRAP_RUNTIME_ROOT/container-credentials.json.tmp.XXXXXX") || return $?
699
+ trap 'rm -f -- "$BOOTSTRAP_CREDENTIAL_TMP"' EXIT || return $?
700
+ install -m 0600 -o 10001 -g 10001 "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" "$BOOTSTRAP_CREDENTIAL_TMP" || return $?
701
+ cmp -s "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" "$BOOTSTRAP_CREDENTIAL_TMP" || return $?
702
+ sync -f "$BOOTSTRAP_CREDENTIAL_TMP" || return $?
703
+ mv -f -- "$BOOTSTRAP_CREDENTIAL_TMP" "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
704
+ sync -f "$BOOTSTRAP_RUNTIME_ROOT" || return $?
705
+ trap - EXIT || return $?
706
+ fi
707
+ docker run --rm --pull=never --network host --name ouro-butler-credential-bootstrap --user 10001:10001 \
708
+ --mount "type=bind,src=$BOOTSTRAP_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
709
+ --mount "type=bind,src=$BOOTSTRAP_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
710
+ --entrypoint node "$BOOTSTRAP_IMAGE_ID" -e '
711
+ const { loadContainerCredentialBootstrap } = require("/opt/ouro/dist/heart/daemon/container-credential-bootstrap.js")
712
+ loadContainerCredentialBootstrap(["sanctuary"], {
713
+ machineIdMigration: {
714
+ sourceMachineId: process.argv[1],
715
+ targetMachineId: process.argv[2],
716
+ discardProviderCredentialRecords: { providers: ["minimax"] },
717
+ },
718
+ }).then(
719
+ () => process.exit(0),
720
+ () => { process.stderr.write("credential bootstrap failed\n"); process.exit(1) },
721
+ )
722
+ ' "$BOOTSTRAP_SOURCE_MACHINE_ID" "$BOOTSTRAP_TARGET_MACHINE_ID" || return $?
723
+ # The path-locked Sanctuary migration projects the exact obsolete Minimax
724
+ # provider record out of memory before strict validation and persistence.
725
+ # The original legacy envelope remains byte-for-byte unchanged and is the
726
+ # sole digest authority; current GLM and Gemini credentials are established
727
+ # only by the separate hidden-input authentication commands below.
728
+ ! docker container inspect ouro-butler-credential-bootstrap >/dev/null 2>&1 || return 1
729
+ test ! -e "$BOOTSTRAP_CREDENTIAL_SOURCE" || return $?
730
+ test ! -e "$BOOTSTRAP_CREDENTIAL_CLAIM" || return $?
731
+ record_sanctuary_legacy_import_marker "$BOOTSTRAP_IMPORT_MARKER" "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
732
+ validate_sanctuary_legacy_import_marker "$BOOTSTRAP_IMPORT_MARKER" "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
733
+ fi
734
+ test -f "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
735
+ test ! -L "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE" || return $?
736
+ test "$(stat -c '%u:%g %a' "$BOOTSTRAP_LEGACY_CREDENTIAL_SOURCE")" = "$BOOTSTRAP_LEGACY_METADATA" || return $?
737
+ fi
738
+ validate_sanctuary_roots "$BOOTSTRAP_RUNTIME_ROOT" "$BOOTSTRAP_AGENT_ROOT" || return $?
739
+ }
740
+ authenticate_sanctuary_provider() {
741
+ AUTH_IMAGE_ID=$1
742
+ AUTH_PROVIDER=${2-}
743
+ validate_exact_image_id "$AUTH_IMAGE_ID" || return $?
744
+ case "$AUTH_PROVIDER" in
745
+ openai-compatible) AUTH_CONTAINER=ouro-butler-provider-auth-glm ;;
746
+ openai-compatible-gemini) AUTH_CONTAINER=ouro-butler-provider-auth-gemini ;;
747
+ *) return 1 ;;
748
+ esac
749
+ AUTH_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
750
+ AUTH_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
751
+ ! docker container inspect "$AUTH_CONTAINER" >/dev/null 2>&1 || return 1
752
+ docker run --rm -it --pull=never --network host --name "$AUTH_CONTAINER" --user 10001:10001 \
753
+ --mount "type=bind,src=$AUTH_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
754
+ --mount "type=bind,src=$AUTH_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
755
+ --entrypoint node "$AUTH_IMAGE_ID" /opt/ouro/dist/heart/daemon/ouro-entry.js \
756
+ auth --agent sanctuary --provider "$AUTH_PROVIDER" || return $?
757
+ ! docker container inspect "$AUTH_CONTAINER" >/dev/null 2>&1 || return 1
758
+ }
759
+ verify_sanctuary_provider_readiness() {
760
+ READINESS_IMAGE_ID=$1
761
+ validate_exact_image_id "$READINESS_IMAGE_ID" || return $?
762
+ READINESS_RUNTIME_ROOT=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli
763
+ READINESS_AGENT_ROOT=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
764
+ ! docker container inspect ouro-butler-provider-readiness >/dev/null 2>&1 || return 1
765
+ docker run --rm --pull=never --network host --name ouro-butler-provider-readiness --user 10001:10001 \
766
+ --mount "type=bind,src=$READINESS_RUNTIME_ROOT,dst=/home/ouro/.ouro-cli" \
767
+ --mount "type=bind,src=$READINESS_AGENT_ROOT,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
768
+ --entrypoint /bin/sh "$READINESS_IMAGE_ID" -ceu '
769
+ umask 077
770
+ node /opt/ouro/dist/heart/daemon/ouro-entry.js check --agent sanctuary --lane outward
771
+ node /opt/ouro/dist/heart/daemon/ouro-entry.js check --agent sanctuary --lane inner
772
+ node - <<'"'"'NODE'"'"'
773
+ (async () => {
774
+ try {
775
+ const fs = require("node:fs");
776
+ const root = "/home/ouro/AgentBundles/sanctuary.ouro";
777
+ const agent = JSON.parse(fs.readFileSync(`${root}/agent.json`, "utf8"));
778
+ const policy = JSON.parse(fs.readFileSync(`${root}/provider-readiness.json`, "utf8"));
779
+ const expectedPolicy = { version: 1, selectionPolicy: "explicit-same-lane-only" };
780
+ const expected = new Map([
781
+ ["openai-compatible", { provider: "openai-compatible", model: "glm-5.2", vaultItem: "providers/openai-compatible", baseUrl: "https://api.z.ai/api/paas/v4/" }],
782
+ ["openai-compatible-gemini", { provider: "openai-compatible-gemini", model: "gemini-3.6-flash", vaultItem: "providers/openai-compatible-gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/" }],
783
+ ]);
784
+ const exactLane = value => value && value.provider === "openai-compatible" && value.model === "glm-5.2";
785
+ if (!exactLane(agent.humanFacing) || !exactLane(agent.agentFacing)
786
+ || policy.version !== expectedPolicy.version || policy.selectionPolicy !== expectedPolicy.selectionPolicy
787
+ || !Array.isArray(policy.providers) || policy.providers.length !== expected.size) throw new Error();
788
+ for (const entry of policy.providers) {
789
+ const wanted = expected.get(entry.provider);
790
+ if (!wanted || entry.model !== wanted.model || entry.vaultItem !== wanted.vaultItem || entry.baseUrl !== wanted.baseUrl) throw new Error();
791
+ expected.delete(entry.provider);
792
+ }
793
+ if (expected.size !== 0) throw new Error();
794
+ const { refreshProviderCredentialPool } = require("/opt/ouro/dist/heart/provider-credentials.js");
795
+ const { pingProvider } = require("/opt/ouro/dist/heart/provider-ping.js");
796
+ const refreshed = await refreshProviderCredentialPool("sanctuary", {
797
+ providers: ["openai-compatible", "openai-compatible-gemini"],
798
+ skipCache: true,
799
+ });
800
+ if (!refreshed.ok) throw new Error();
801
+ const glm = refreshed.pool.providers["openai-compatible"];
802
+ const gemini = refreshed.pool.providers["openai-compatible-gemini"];
803
+ const exactRecord = (record, provider, baseUrl) => record && record.provider === provider
804
+ && typeof record.revision === "string" && record.revision.length > 0
805
+ && typeof record.credentials?.apiKey === "string" && record.credentials.apiKey.trim().length > 0
806
+ && record.config?.baseUrl === baseUrl;
807
+ if (!exactRecord(glm, "openai-compatible", "https://api.z.ai/api/paas/v4/")
808
+ || !exactRecord(gemini, "openai-compatible-gemini", "https://generativelanguage.googleapis.com/v1beta/openai/")
809
+ || glm.credentials.apiKey === gemini.credentials.apiKey
810
+ || new Set([glm.revision, gemini.revision]).size !== 2) throw new Error();
811
+ const probes = [
812
+ { provider: "openai-compatible", model: "glm-5.2", credentialRevision: glm.revision, record: glm },
813
+ { provider: "openai-compatible", model: "glm-5.2", credentialRevision: glm.revision, record: glm },
814
+ { provider: "openai-compatible-gemini", model: "gemini-3.6-flash", credentialRevision: gemini.revision, record: gemini },
815
+ ];
816
+ const results = await Promise.all(probes.map(({ provider, model, record }) => pingProvider(
817
+ provider,
818
+ { ...record.credentials, ...record.config },
819
+ { model, timeoutMs: 10000, attemptPolicy: { baseDelayMs: 0 } },
820
+ )));
821
+ for (let index = 0; index < results.length; index += 1) {
822
+ const result = results[index];
823
+ const probe = probes[index];
824
+ if (!result.ok || !Array.isArray(result.attempts) || result.attempts.length < 1 || result.attempts.length > 3
825
+ || !result.attempts.every(attempt => attempt.provider === probe.provider && attempt.model === probe.model
826
+ && attempt.operation === "ping" && typeof attempt.ok === "boolean")
827
+ || result.attempts.at(-1)?.ok !== true) throw new Error();
828
+ }
829
+ process.stdout.write("Sanctuary provider readiness verified.\n");
830
+ } catch {
831
+ process.stderr.write("Sanctuary provider readiness verification failed.\n");
832
+ process.exitCode = 1;
833
+ }
834
+ })();
835
+ NODE
836
+ ' || return $?
837
+ ! docker container inspect ouro-butler-provider-readiness >/dev/null 2>&1 || return 1
838
+ validate_sanctuary_roots "$READINESS_RUNTIME_ROOT" "$READINESS_AGENT_ROOT" || return $?
839
+ }
840
+ run_sanctuary_docker() {
841
+ /usr/bin/timeout -s KILL 20 /usr/bin/docker "$@"
842
+ }
843
+ run_sanctuary_unraid_api() {
844
+ /usr/bin/timeout -s KILL 20 /usr/local/sbin/unraid-api "$@"
845
+ }
846
+ run_sanctuary_node() {
847
+ /usr/local/bin/node "$@"
848
+ }
849
+ resolve_sanctuary_unraid_key() {
850
+ run_sanctuary_node -e '
851
+ const fs = require("node:fs");
852
+ const root = "/boot/config/plugins/dynamix.my.servers/keys";
853
+ const expected = process.argv[1];
854
+ const found = [];
855
+ for (const name of fs.readdirSync(root)) {
856
+ if (!/^[A-Za-z0-9._:-]+\.json$/.test(name)) process.exit(1);
857
+ const path = `${root}/${name}`;
858
+ const stat = fs.lstatSync(path);
859
+ if (!stat.isFile() || stat.isSymbolicLink()) process.exit(1);
860
+ const value = JSON.parse(fs.readFileSync(path, "utf8"));
861
+ if (value.id === expected) found.push({ path, name: value.name });
862
+ }
863
+ if (found.length !== 1 || typeof found[0].name !== "string" || !/^[A-Za-z0-9 ._:-]+$/.test(found[0].name)) process.exit(1);
864
+ process.stdout.write(`${found[0].path}\t${found[0].name}`);
865
+ ' "$1"
866
+ }
867
+ sanctuary_revoked_key_recovery_path() {
868
+ case "$1" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
869
+ printf '%s/%s.json' /mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof "$1"
870
+ }
871
+ validate_sanctuary_revoked_key_recovery() {
872
+ test -f "$1" && test ! -L "$1" || return 1
873
+ test "$(stat -c '%u:%g %a' "$1")" = "0:0 600" || return 1
874
+ }
875
+ preserve_sanctuary_revoked_key() {
876
+ KEY_ID=$1
877
+ KEY_SOURCE=$2
878
+ RECOVERY_ROOT=/mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof
879
+ test -f "$KEY_SOURCE" && test ! -L "$KEY_SOURCE" || return 1
880
+ if test ! -e "$RECOVERY_ROOT"; then
881
+ install -d -m 0700 -o root -g root "$RECOVERY_ROOT" || return $?
882
+ fi
883
+ test -d "$RECOVERY_ROOT" && test ! -L "$RECOVERY_ROOT" || return 1
884
+ test "$(stat -c '%u:%g %a' "$RECOVERY_ROOT")" = "0:0 700" || return 1
885
+ RECOVERY_PATH=$(sanctuary_revoked_key_recovery_path "$KEY_ID") || return $?
886
+ test ! -e "$RECOVERY_PATH" || return 1
887
+ RECOVERY_TMP=$(mktemp "$RECOVERY_ROOT/$KEY_ID.json.tmp.XXXXXX") || return $?
888
+ trap 'rm -f -- "$RECOVERY_TMP"' EXIT || return $?
889
+ install -m 0600 -o root -g root "$KEY_SOURCE" "$RECOVERY_TMP" || return $?
890
+ cmp -s "$KEY_SOURCE" "$RECOVERY_TMP" || return 1
891
+ mv -f -- "$RECOVERY_TMP" "$RECOVERY_PATH" || return $?
892
+ sync -f "$RECOVERY_ROOT" || return $?
893
+ trap - EXIT || return $?
894
+ }
895
+ clear_sanctuary_revoked_key() {
896
+ RECOVERY_PATH=$(sanctuary_revoked_key_recovery_path "$1") || return $?
897
+ test -f "$RECOVERY_PATH" && test ! -L "$RECOVERY_PATH" || return 1
898
+ test "$(stat -c '%u:%g %a' "$RECOVERY_PATH")" = "0:0 600" || return 1
899
+ rm -f -- "$RECOVERY_PATH" || return $?
900
+ sync -f "$(dirname "$RECOVERY_PATH")" || return $?
901
+ }
902
+ verify_vault_backed_unraid_key() {
903
+ KEY_ID=$1
904
+ CAPABILITY=$2
905
+ case "$KEY_ID" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
906
+ case "$CAPABILITY" in read-only|bounded-write) ;; *) return 1 ;; esac
907
+ IMAGE_DIGEST=${IMAGE_ID#sha256:}
908
+ test "$IMAGE_DIGEST" != "$IMAGE_ID" || return 1
909
+ test "${#IMAGE_DIGEST}" -eq 64 || return 1
910
+ case "$IMAGE_DIGEST" in *[!0-9a-f]*) return 1 ;; esac
911
+ test "$(run_sanctuary_docker image inspect --format '{{.Id}}' "$IMAGE_ID")" = "$IMAGE_ID" || return $?
912
+ KEY_TARGET=$(resolve_sanctuary_unraid_key "$KEY_ID") || return $?
913
+ KEY_PATH=${KEY_TARGET%% *}
914
+ test "$KEY_PATH" != "$KEY_TARGET" || return 1
915
+ test -f "$KEY_PATH" && test ! -L "$KEY_PATH" || return 1
916
+ VERIFY_RESULT=$(run_sanctuary_docker run --rm --pull=never --network host \
917
+ --user 0:0 \
918
+ --mount type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli,readonly \
919
+ --mount type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro,readonly \
920
+ --mount type=bind,src="$KEY_PATH",dst=/run/ouro-acceptance/unraid-key.json,readonly \
921
+ --entrypoint /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh \
922
+ "$IMAGE_ID" vault-probe "$KEY_ID" "$CAPABILITY") || return $?
923
+ printf '%s' "$VERIFY_RESULT" | run_sanctuary_node -e '
924
+ let input = "";
925
+ process.stdin.setEncoding("utf8");
926
+ process.stdin.on("data", chunk => { input += chunk; });
927
+ process.stdin.on("end", () => {
928
+ const value = JSON.parse(input);
929
+ const expectedId = process.argv[1];
930
+ const expectedCapability = process.argv[2];
931
+ const expectedProof = expectedCapability === "read-only"
932
+ ? "read-authorized-write-denied"
933
+ : "read-authorized-write-reached-not-found";
934
+ if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(["capability", "keyId", "proof", "valid"])) process.exit(1);
935
+ if (value.valid !== true || value.keyId !== expectedId || value.capability !== expectedCapability) process.exit(1);
936
+ if (value.proof !== expectedProof) process.exit(1);
937
+ });
938
+ ' "$KEY_ID" "$CAPABILITY" || return $?
939
+ }
940
+ inventory_unraid_key_ids() {
941
+ IMAGE_DIGEST=${IMAGE_ID#sha256:}
942
+ test "$IMAGE_DIGEST" != "$IMAGE_ID" || return 1
943
+ test "${#IMAGE_DIGEST}" -eq 64 || return 1
944
+ case "$IMAGE_DIGEST" in *[!0-9a-f]*) return 1 ;; esac
945
+ test "$(run_sanctuary_docker image inspect --format '{{.Id}}' "$IMAGE_ID")" = "$IMAGE_ID" || return $?
946
+ INVENTORY_RESULT=$(printf '%s' '{"operation":"closed-inventory"}' | \
947
+ run_sanctuary_docker run --rm -i --pull=never --network none \
948
+ --user 0:0 --read-only \
949
+ --mount type=bind,src=/boot/config/plugins/dynamix.my.servers/keys,dst=/boot/config/plugins/dynamix.my.servers/keys,readonly \
950
+ --entrypoint /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh "$IMAGE_ID") || return $?
951
+ printf '%s' "$INVENTORY_RESULT" | run_sanctuary_node -e '
952
+ let input = "";
953
+ process.stdin.setEncoding("utf8");
954
+ process.stdin.on("data", chunk => { input += chunk; });
955
+ process.stdin.on("end", () => {
956
+ const value = JSON.parse(input);
957
+ if (!value || JSON.stringify(Object.keys(value)) !== JSON.stringify(["keys"]) || !Array.isArray(value.keys)) process.exit(1);
958
+ for (const key of value.keys) {
959
+ if (!key || JSON.stringify(Object.keys(key).sort()) !== JSON.stringify(["id", "roles", "scope"])) process.exit(1);
960
+ if (!/^[A-Za-z0-9._:-]+$/.test(key.id)) process.exit(1);
961
+ if (!["read-only", "bounded-write", "legacy-write"].includes(key.scope) || key.roles !== "none") process.exit(1);
962
+ process.stdout.write(`${key.id}\t${key.scope}\t${key.roles}\n`);
963
+ }
964
+ });
965
+ ' || return $?
966
+ }
967
+ revoke_unraid_key_exact() {
968
+ KEY_ID=$1
969
+ case "$KEY_ID" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
970
+ test "${REVOKED_KEY_FD_OPEN:-no}" = no || return 1
971
+ KEY_TARGET=$(resolve_sanctuary_unraid_key "$KEY_ID") || return $?
972
+ KEY_PATH=${KEY_TARGET%% *}
973
+ KEY_NAME=${KEY_TARGET#* }
974
+ test "$KEY_PATH" != "$KEY_TARGET" || return 1
975
+ test -f "$KEY_PATH" && test ! -L "$KEY_PATH" || return $?
976
+ preserve_sanctuary_revoked_key "$KEY_ID" "$KEY_PATH" || return $?
977
+ if ! REVOKE_RESULT=$(run_sanctuary_unraid_api apikey --name "$KEY_NAME" --delete --json); then
978
+ return 1
979
+ fi
980
+ if ! printf '%s' "$REVOKE_RESULT" | run_sanctuary_node -e '
981
+ let input = "";
982
+ process.stdin.setEncoding("utf8");
983
+ process.stdin.on("data", chunk => { input += chunk; });
984
+ process.stdin.on("end", () => {
985
+ const value = JSON.parse(input);
986
+ const expectedId = process.argv[1];
987
+ const expectedName = process.argv[2];
988
+ if (value.deleted !== 1 || !Array.isArray(value.keys) || value.keys.length !== 1) process.exit(1);
989
+ if (value.keys[0].id !== expectedId || value.keys[0].name !== expectedName) process.exit(1);
990
+ });
991
+ ' "$KEY_ID" "$KEY_NAME"; then
992
+ return 1
993
+ fi
994
+ }
995
+ verify_revoked_unraid_key_rejected() {
996
+ KEY_ID=$1
997
+ case "$KEY_ID" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; esac
998
+ REVOKED_KEY_RECOVERY_PATH=$(sanctuary_revoked_key_recovery_path "$KEY_ID") || return $?
999
+ validate_sanctuary_revoked_key_recovery "$REVOKED_KEY_RECOVERY_PATH" || return $?
1000
+ IMAGE_DIGEST=${IMAGE_ID#sha256:}
1001
+ test "$IMAGE_DIGEST" != "$IMAGE_ID" || return 1
1002
+ test "${#IMAGE_DIGEST}" -eq 64 || return 1
1003
+ case "$IMAGE_DIGEST" in *[!0-9a-f]*) return 1 ;; esac
1004
+ test "$(run_sanctuary_docker image inspect --format '{{.Id}}' "$IMAGE_ID")" = "$IMAGE_ID" || return 1
1005
+ REJECT_ATTEMPT=0
1006
+ while test "$REJECT_ATTEMPT" -lt 3; do
1007
+ REJECT_ATTEMPT=$((REJECT_ATTEMPT + 1))
1008
+ if REJECT_RESULT=$(run_sanctuary_docker run --rm -i --pull=never --network host \
1009
+ --user 10001:10001 --read-only \
1010
+ --entrypoint /bin/sh "$IMAGE_ID" -ceu \
1011
+ 'exec 3<&0; exec /opt/ouro/deploy/unraid/sanctuary-acceptance-adapter.sh revoked-probe "$1" "$2" 3<&3' \
1012
+ sanctuary-revoked-probe "$KEY_ID" http://127.0.0.1/graphql <"$REVOKED_KEY_RECOVERY_PATH") \
1013
+ && printf '%s' "$REJECT_RESULT" | run_sanctuary_node -e '
1014
+ let input = "";
1015
+ process.stdin.setEncoding("utf8");
1016
+ process.stdin.on("data", chunk => { input += chunk; });
1017
+ process.stdin.on("end", () => {
1018
+ const value = JSON.parse(input);
1019
+ if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(["id", "rejected", "status"])) process.exit(1);
1020
+ if (value.rejected !== true || value.id !== process.argv[1] || ![401, 403].includes(value.status)) process.exit(1);
1021
+ });
1022
+ ' "$KEY_ID"; then
1023
+ clear_sanctuary_revoked_key "$KEY_ID" || return $?
1024
+ return 0
1025
+ fi
1026
+ test "$REJECT_ATTEMPT" -lt 3 || return 1
1027
+ sleep 2
1028
+ done
1029
+ return 1
1030
+ }
1031
+ Update:
1032
+ Build and verify a new ouro-butler:<version> image first. The tag is only a
1033
+ lookup handle and never authorizes container creation. Resolve and validate
1034
+ the exact local Docker image ID before staging:
1035
+ IMAGE_TAG=ouro-butler:<version>
1036
+ IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$IMAGE_TAG")
1037
+ printf '%s\n' "$IMAGE_ID" | grep -Eq '^sha256:[0-9a-f]{64}$'
1038
+ docker image inspect "$IMAGE_ID" >/dev/null
1039
+ Initial install/adoption is a separate terminal path for the verified live
1040
+ legacy state: no production or rollback, exactly one running (possibly
1041
+ unhealthy) ouro-butler-staging, and no legacy-evidence container. After the
1042
+ helper definitions above are loaded and IMAGE_ID is resolved, run this exact
1043
+ sequence. The two authentication commands prompt through the runtime's hidden
1044
+ terminal input; do not place credentials in arguments, shell variables, or
1045
+ history.
1046
+ Sanctuary legacy adoption commands:
1047
+ prepare_sanctuary_legacy_adoption "$IMAGE_ID"
1048
+ authenticate_sanctuary_provider "$IMAGE_ID" openai-compatible
1049
+ authenticate_sanctuary_provider "$IMAGE_ID" openai-compatible-gemini
1050
+ verify_sanctuary_provider_readiness "$IMAGE_ID"
1051
+ install_from_legacy_staging
1052
+ These commands are one terminal path; after install succeeds, stop and do not
1053
+ continue into the normal update below. The final install is noninteractive:
1054
+ it reruns resumable preparation plus fresh readiness, but never authentication.
1055
+ That function never applies the canonical auditor to the known-noncanonical
1056
+ legacy container or restarts it. It verifies and pins the legacy image,
1057
+ provisions the absent canonical roots from the exact target image's packaged
1058
+ Sanctuary skeleton, proves required files plus exact ownership/modes, and runs
1059
+ same-image vault bootstrap and provider readiness before durably snapshotting
1060
+ both exact container and image inspect records. Any preparation, vault, or
1061
+ readiness failure returns while legacy remains running with autostart
1062
+ untouched. It then stops and rechecks the legacy container and renames it to
1063
+ stopped ouro-butler-legacy-evidence. A fresh canonical staging container is
1064
+ created from exact IMAGE_ID and canonical binds, audited, started, proven to
1065
+ be the only running Butler, readiness-checked, then stopped and removed only
1066
+ after its image is rechecked. Only then is fresh canonical production created,
1067
+ audited, started, readiness-checked, and placed in bare production autostart.
1068
+ Failure removes only partial target-image staging/production containers,
1069
+ quarantines the exact legacy container without deletion or restart, leaves
1070
+ autostart disabled, and propagates the original status.
1071
+ For a normal update, preflight the canonical topology before any autostart or
1072
+ container mutation. Production must be the only running Butler poller;
1073
+ staging must be absent; rollback may be absent or one stopped container with
1074
+ the exact production image. A stopped legacy-evidence container is preserved.
1075
+ Disable every Butler name in Unraid's array-autostart file and verify that
1076
+ result before stopping production. First resolve and validate the exact image
1077
+ ID of the known-good production container while it is still running, so a
1078
+ lookup failure cannot strand a renamed container:
1079
+ ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
1080
+ validate_exact_image_id "$ROLLBACK_IMAGE_ID"
1081
+ if assert_update_topology "$ROLLBACK_IMAGE_ID"; then
1082
+ :
1083
+ else
1084
+ UPDATE_PREFLIGHT_STATUS=$?
1085
+ (exit "$UPDATE_PREFLIGHT_STATUS")
1086
+ fi
1087
+ audit_effective ouro-butler "$ROLLBACK_IMAGE_ID"
1088
+ Only after that topology gate, stage copies of the packaged template and
1089
+ runtime policy at these paths:
1090
+ STAGED_TEMPLATE=/mnt/user/appdata/ouro-butler/staging/sanctuary.xml
1091
+ STAGED_RUNTIME_POLICY=/mnt/user/appdata/ouro-butler/staging/container-runtime.json
1092
+ Replace sha256:REPLACE_WITH_EXACT_LOCAL_IMAGE_ID in the staged template at
1093
+ $STAGED_TEMPLATE with exactly $IMAGE_ID. Before docker create, run the
1094
+ packaged auditor from that exact image ID with both staged inputs mounted
1095
+ read-only:
1096
+ docker run --rm --pull=never --network=none \
1097
+ --entrypoint /opt/ouro/deploy/unraid/audit-container-spec.sh \
1098
+ --mount "type=bind,src=$STAGED_TEMPLATE,dst=/audit/sanctuary.xml,readonly" \
1099
+ --mount "type=bind,src=$STAGED_RUNTIME_POLICY,dst=/audit/container-runtime.json,readonly" \
1100
+ "$IMAGE_ID" --template /audit/sanctuary.xml --runtime-policy /audit/container-runtime.json --expected-image "$IMAGE_ID"
1101
+ Guard the atomic autostart disable separately. If it fails, production has not
1102
+ been touched and the captured status is propagated:
1103
+ if disable_butler_autostart; then
1104
+ :
1105
+ else
1106
+ AUTOSTART_DISABLE_STATUS=$?
1107
+ (exit "$AUTOSTART_DISABLE_STATUS")
1108
+ fi
1109
+ Define the stale-rollback cleanup used by the preparation guard:
1110
+ remove_stopped_rollback_if_present() {
1111
+ EXPECTED_STALE_ROLLBACK_IMAGE_ID=$1
1112
+ ROLLBACK_CONTAINER_NAMES=$(docker container ls -a --format '{{.Names}}') || return $?
1113
+ case "
1114
+ $ROLLBACK_CONTAINER_NAMES
1115
+ " in
1116
+ *"
1117
+ ouro-butler-rollback
1118
+ "*)
1119
+ ROLLBACK_RUNNING=$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback) || return $?
1120
+ test "$ROLLBACK_RUNNING" = false || return $?
1121
+ CURRENT_STALE_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback) || return $?
1122
+ test "$CURRENT_STALE_ROLLBACK_IMAGE_ID" = "$EXPECTED_STALE_ROLLBACK_IMAGE_ID" || return $?
1123
+ docker rm ouro-butler-rollback || return $?
1124
+ ;;
1125
+ esac
1126
+ }
1127
+ Stop production, remove only a stopped stale rollback, rename the known-good
1128
+ container, and verify it remains stopped in one explicit preparation guard:
1129
+ if docker stop ouro-butler \
1130
+ && remove_stopped_rollback_if_present "$ROLLBACK_IMAGE_ID" \
1131
+ && docker rename ouro-butler ouro-butler-rollback \
1132
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false; then
1133
+ :
1134
+ else
1135
+ PRODUCTION_PREPARATION_STATUS=$?
1136
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
1137
+ if docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
1138
+ docker stop ouro-butler-rollback >/dev/null 2>&1 || true
1139
+ CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback)
1140
+ test "$CURRENT_ROLLBACK_IMAGE_ID" = "$ROLLBACK_IMAGE_ID"
1141
+ docker rm --force ouro-butler-rollback >/dev/null 2>&1 || true
1142
+ fi
1143
+ ! docker container inspect ouro-butler-rollback >/dev/null 2>&1
1144
+ audit_effective ouro-butler "$ROLLBACK_IMAGE_ID"
1145
+ docker start ouro-butler
1146
+ assert_only_running_butler ouro-butler
1147
+ wait_butler_ready ouro-butler
1148
+ enable_butler_autostart
1149
+ elif docker container inspect ouro-butler-rollback >/dev/null 2>&1; then
1150
+ docker stop ouro-butler-rollback >/dev/null 2>&1 || true
1151
+ test "$(docker inspect --format '{{.Image}}' ouro-butler-rollback)" = "$ROLLBACK_IMAGE_ID"
1152
+ docker rename ouro-butler-rollback ouro-butler
1153
+ audit_effective ouro-butler "$ROLLBACK_IMAGE_ID"
1154
+ docker start ouro-butler
1155
+ assert_only_running_butler ouro-butler
1156
+ wait_butler_ready ouro-butler
1157
+ enable_butler_autostart
1158
+ fi
1159
+ (exit "$PRODUCTION_PREPARATION_STATUS")
1160
+ fi
1161
+ Preparation failure therefore either restores the still-named exact production
1162
+ after removing any stale rollback, or renames the exact stopped rollback back;
1163
+ both recoveries re-audit, start, bounded-wait, atomically restore production-only
1164
+ autostart, and propagate the original failure. If neither exact container can
1165
+ be found, the failure propagates with Butler autostart disabled.
1166
+ Put the entire post-rename staging phase in one explicit conditional so
1167
+ `set -eu` cannot bypass rollback at the create, effective-audit, start, or
1168
+ bounded-readiness boundary. Staging uses the exact image ID with no command,
1169
+ environment, port, device, capability, privilege, or extra mount override.
1170
+ A passing staging container is stopped and removed inside the same condition,
1171
+ completing the poller handoff before production creation:
1172
+ if docker create --name ouro-butler-staging --network host --restart unless-stopped --user 10001:10001 \
1173
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
1174
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1175
+ "$IMAGE_ID" \
1176
+ && audit_effective ouro-butler-staging "$IMAGE_ID" \
1177
+ && docker start ouro-butler-staging \
1178
+ && assert_only_running_butler ouro-butler-staging \
1179
+ && wait_butler_ready ouro-butler-staging \
1180
+ && docker stop ouro-butler-staging \
1181
+ && docker rm ouro-butler-staging; then
1182
+ :
1183
+ else
1184
+ STAGING_ACTIVATION_STATUS=$?
1185
+ if docker container inspect ouro-butler-staging >/dev/null 2>&1; then
1186
+ docker stop ouro-butler-staging >/dev/null 2>&1 || true
1187
+ PARTIAL_STAGING_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-staging)
1188
+ test "$PARTIAL_STAGING_IMAGE_ID" = "$IMAGE_ID"
1189
+ docker rm --force ouro-butler-staging >/dev/null 2>&1 || true
1190
+ fi
1191
+ ! docker container inspect ouro-butler-staging >/dev/null 2>&1
1192
+ CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback)
1193
+ test "$CURRENT_ROLLBACK_IMAGE_ID" = "$ROLLBACK_IMAGE_ID"
1194
+ docker rename ouro-butler-rollback ouro-butler
1195
+ audit_effective ouro-butler "$ROLLBACK_IMAGE_ID"
1196
+ docker start ouro-butler
1197
+ assert_only_running_butler ouro-butler
1198
+ wait_butler_ready ouro-butler
1199
+ enable_butler_autostart
1200
+ (exit "$STAGING_ACTIVATION_STATUS")
1201
+ fi
1202
+ The failure arm safely handles staging that was never created, remains
1203
+ stopped, is running, or exited: it force-removes any partial staging state,
1204
+ verifies the name is absent, restores and re-audits the old production against
1205
+ its exact pre-recorded image ID, starts and bounded-waits it, atomically
1206
+ restores production-only autostart, and propagates the original failure.
1207
+ At no point may production and staging run together against the same Telegram token.
1208
+ Create and activate production from the same exact image ID and exact authority
1209
+ in one explicit conditional so `set -eu` cannot exit before rollback. Only a
1210
+ successful create, effective audit, start, stopped rollback assertion, and
1211
+ bounded readiness wait may enable production autostart. Any failure captures
1212
+ the activation status, safely removes a partially created new production if it
1213
+ exists, restores the stopped rollback container, audits it against its exact
1214
+ old image ID, proves it ready, atomically enables only production autostart,
1215
+ and then propagates the original activation failure:
1216
+ if docker create --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
1217
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
1218
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1219
+ "$IMAGE_ID" \
1220
+ && audit_effective ouro-butler "$IMAGE_ID" \
1221
+ && docker start ouro-butler \
1222
+ && assert_only_running_butler ouro-butler \
1223
+ && test "$(docker inspect --format '{{.State.Running}}' ouro-butler-rollback)" = false \
1224
+ && wait_butler_ready ouro-butler \
1225
+ && enable_butler_autostart; then
1226
+ :
1227
+ else
1228
+ PRODUCTION_ACTIVATION_STATUS=$?
1229
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
1230
+ docker stop ouro-butler >/dev/null 2>&1 || true
1231
+ PARTIAL_PRODUCTION_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
1232
+ test "$PARTIAL_PRODUCTION_IMAGE_ID" = "$IMAGE_ID"
1233
+ docker rm --force ouro-butler >/dev/null 2>&1 || true
1234
+ fi
1235
+ ! docker container inspect ouro-butler >/dev/null 2>&1
1236
+ CURRENT_ROLLBACK_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler-rollback)
1237
+ test "$CURRENT_ROLLBACK_IMAGE_ID" = "$ROLLBACK_IMAGE_ID"
1238
+ docker rename ouro-butler-rollback ouro-butler
1239
+ audit_effective ouro-butler "$ROLLBACK_IMAGE_ID"
1240
+ docker start ouro-butler
1241
+ assert_only_running_butler ouro-butler
1242
+ wait_butler_ready ouro-butler
1243
+ enable_butler_autostart
1244
+ (exit "$PRODUCTION_ACTIVATION_STATUS")
1245
+ fi
1246
+ Keep ouro-butler-rollback stopped until the new production container is proven
1247
+ or the explicit rollback arm restores it.
1248
+ Never create a container from the mutable lookup tag.
1249
+
1250
+ Backup:
1251
+ Stop ouro-butler, then snapshot both of these directories together:
1252
+ /mnt/user/appdata/ouro-butler/runtime/.ouro-cli
1253
+ /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro
1254
+ A routine backup must not contain container-credentials.json or
1255
+ container-credentials.json.consuming. If either exists, credential migration
1256
+ is pending or failed and must be reconciled before taking the backup.
1257
+
1258
+ Restore:
1259
+ Set BACKUP_ROOT to the exact verified snapshot containing `runtime/.ouro-cli`
1260
+ and `agent/sanctuary.ouro`, and set IMAGE_ID to its recorded local image ID.
1261
+ Before any autostart, root, or container mutation, run the nounset-safe input,
1262
+ backup-root, image, and topology preflight. It requires a nonempty canonical
1263
+ absolute BACKUP_ROOT (not /), both exact required directories, an exact local
1264
+ sha256 image ID, canonical production as the only running Butler poller, no
1265
+ staging or rollback, and at most one exact stopped legacy-evidence container:
1266
+ if assert_restore_preflight; then
1267
+ :
1268
+ else
1269
+ RESTORE_PREFLIGHT_STATUS=$?
1270
+ (exit "$RESTORE_PREFLIGHT_STATUS")
1271
+ fi
1272
+ Guard the atomic autostart disable before stopping or removing any Butler
1273
+ container. Failure propagates while the existing production remains untouched:
1274
+ if disable_butler_autostart; then
1275
+ :
1276
+ else
1277
+ RESTORE_AUTOSTART_DISABLE_STATUS=$?
1278
+ (exit "$RESTORE_AUTOSTART_DISABLE_STATUS")
1279
+ fi
1280
+ Stop and remove only ouro-butler, restore both roots without stale files,
1281
+ restore exact ownership, validate the image, and create/audit/start/bounded-wait
1282
+ the restored production inside one explicit condition. The initial tolerant
1283
+ stop plus inspected force-removal safely handles an already absent or stopped
1284
+ container:
1285
+ if { docker stop ouro-butler >/dev/null 2>&1 || true; } \
1286
+ && {
1287
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
1288
+ docker rm --force ouro-butler
1289
+ else
1290
+ :
1291
+ fi
1292
+ } \
1293
+ && ! docker container inspect ouro-butler >/dev/null 2>&1 \
1294
+ && rsync -a --delete "$BACKUP_ROOT/runtime/.ouro-cli/" /mnt/user/appdata/ouro-butler/runtime/.ouro-cli/ \
1295
+ && rsync -a --delete "$BACKUP_ROOT/agent/sanctuary.ouro/" /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/ \
1296
+ && chown -R 10001:10001 /mnt/user/appdata/ouro-butler/runtime/.ouro-cli /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro \
1297
+ && validate_sanctuary_roots /mnt/user/appdata/ouro-butler/runtime/.ouro-cli /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro \
1298
+ && docker image inspect "$IMAGE_ID" >/dev/null \
1299
+ && docker create --name ouro-butler --network host --restart unless-stopped --user 10001:10001 \
1300
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/runtime/.ouro-cli,dst=/home/ouro/.ouro-cli" \
1301
+ --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro,dst=/home/ouro/AgentBundles/sanctuary.ouro" \
1302
+ "$IMAGE_ID" \
1303
+ && audit_effective ouro-butler "$IMAGE_ID" \
1304
+ && docker start ouro-butler \
1305
+ && assert_only_running_butler ouro-butler \
1306
+ && wait_butler_ready ouro-butler \
1307
+ && enable_butler_autostart; then
1308
+ :
1309
+ else
1310
+ RESTORE_ACTIVATION_STATUS=$?
1311
+ if docker container inspect ouro-butler >/dev/null 2>&1; then
1312
+ docker stop ouro-butler >/dev/null 2>&1 || true
1313
+ PARTIAL_RESTORE_IMAGE_ID=$(docker inspect --format '{{.Image}}' ouro-butler)
1314
+ test "$PARTIAL_RESTORE_IMAGE_ID" = "$IMAGE_ID"
1315
+ docker rm --force ouro-butler >/dev/null 2>&1 || true
1316
+ fi
1317
+ ! docker container inspect ouro-butler >/dev/null 2>&1
1318
+ (exit "$RESTORE_ACTIVATION_STATUS")
1319
+ fi
1320
+ A restore has no retained old container or pre-restore data root to roll back
1321
+ to. Any stop/remove, data replacement, ownership, image validation, create,
1322
+ audit, start, or readiness failure therefore reaches the same cleanup arm,
1323
+ leaves all Butler autostart entries disabled, and propagates the captured
1324
+ failure for operator repair or a verified restore retry.
1325
+
1326
+ Credential recovery:
1327
+ The one canonical Telegram identity is display name `Mendelow Cloud Butler`,
1328
+ username `MendelowCloudButlerBot`, numeric bot ID `8541786263`. Treat any
1329
+ different display name, username, numeric ID, or fallback handle as a hard
1330
+ preflight failure; do not start polling or send a test message.
1331
+ Run `bootstrap_sanctuary_vault "$IMAGE_ID"` interactively before installing
1332
+ credentials. It uses only fresh `--rm` containers from that exact image as
1333
+ UID/GID 10001 with the canonical two binds; create/unlock is the sole `-it`
1334
+ action so the master secret remains on the hidden terminal prompt.
1335
+ Its first read-only `vault status` is authoritative: it runs `vault create`
1336
+ only when the locator is absent, runs `vault unlock` only when the locator
1337
+ exists but local unlock is missing, and does not prompt when local unlock is
1338
+ already available. It then requires the configured locator, available local
1339
+ unlock, and successful auth verification. Never guess from a failed status
1340
+ command.
1341
+ Initial legacy adoption passes the exact legacy envelope path as the helper's
1342
+ second argument. After vault availability, the helper validates and atomically
1343
+ copies that envelope into the canonical runtime root, imports it with a fresh
1344
+ same-image one-shot container, and requires both canonical source and claim to
1345
+ be absent before provider readiness. The exact legacy source remains untouched
1346
+ as rollback evidence until canonical acceptance completes.
1347
+ Restore or unlock the Sanctuary agent vault, then run provider refresh.
1348
+ /mnt/user/appdata/ouro-butler/runtime/.ouro-cli/container-credentials.json
1349
+ exists only for a one-time migration: if
1350
+ used, install it atomically as root with ownership 10001:10001 and mode 0600.
1351
+ Startup atomically claims it as container-credentials.json.consuming and
1352
+ writes every credential class into the unlocked Sanctuary vault before deleting the claimed envelope.
1353
+ Only then does it refresh the daemon's in-memory credentials.
1354
+ If validation or any vault write fails, startup fails closed and preserves
1355
+ container-credentials.json.consuming for the next startup to reconcile.
1356
+ Restore or create/unlock the configured agent vault, then restart the same
1357
+ reviewed image and let it retry the preserved claim. If both the source and
1358
+ claimed envelope exist and they are safe regular 0600 files owned by the
1359
+ runtime user, startup resumes automatically only when they are byte-for-byte identical:
1360
+ it durably removes the redundant unclaimed source and continues the existing claim.
1361
+ If they differ, the repair is human-required: stop the Butler, securely compare and quarantine
1362
+ the two envelopes, then restore exactly one reviewed envelope at the source path.
1363
+ Never print either envelope's contents or place them in logs or command arguments.
1364
+ After successful import, both files
1365
+ are absent and the vault is the only credential source of truth.
1366
+ Never print or place credential values in logs, templates, command arguments,
1367
+ backups, or this runbook.
1368
+
1369
+ Packaged deployment-target containment gates:
1370
+ Unit 16 is fixed to the `staging` profile and therefore targets only
1371
+ `ouro-butler-staging`; it does not require `ouro-butler` to exist. Before the
1372
+ broker starts, the packaged deployment auditor captures all present canonical
1373
+ production/staging/rollback identities in one Docker inspect, verifies the
1374
+ target image, `unless-stopped`, host networking, matching GraphQL and durable
1375
+ file autostart identity/state for every present canonical container,
1376
+ and exactly one running Butler, then repeats the canonical snapshot to reject
1377
+ races. It binds the target PID and exact Docker cgroup-v2 path to the inspected
1378
+ container ID; Unit 16 carries that immutable ID into every launcher and broker
1379
+ Docker operation and binds it to the GraphQL PrefixedID suffix. It scans every
1380
+ cgroup process and requires stable before/after process, owned-socket, and
1381
+ listener inventories. Between the two canonical topology captures, it verifies
1382
+ the exact immutable-ID target is running and unpaused, pauses that ID without
1383
+ changing restart or autostart state, and performs every cgroup, all-thread
1384
+ descriptor, TCP, UDP, and Unix observation inside that one quiesced window,
1385
+ including two matching complete terminal snapshots; nonconvergence fails
1386
+ closed after eight attempts. It always makes bounded exact-ID unpause attempts,
1387
+ verifies the same ID and PID resumed their original running, unpaused state,
1388
+ and only then performs the final full-profile topology capture. It
1389
+ rejects every owned UDP listener and every externally reachable owned TCP listener. Only loopback
1390
+ Mailbox port 6876 plus stream-listening endpoints at the fixed daemon and
1391
+ acceptance Unix control paths are permitted; all other named Unix endpoint
1392
+ types (including datagrams) fail closed. Inherited descriptors for the same
1393
+ socket inode are deduplicated, while unknown loopback ports, wildcard/host-address
1394
+ listeners, process/socket/listener drift, or ambiguous ownership fail closed.
1395
+ Unit 18 uses the separately packaged fixed final command after activation.
1396
+ It accepts either the legacy-adoption topology with no canonical rollback or
1397
+ exactly one stopped, non-autostarted rollback; running/autostarted rollback,
1398
+ staging presence, and ambiguous canonical identities always fail closed:
1399
+ UNIT18_TARGET_TMP=$(mktemp /run/sanctuary-unit18-target-audit.sh.XXXXXX)
1400
+ /usr/bin/timeout -s KILL 20 /usr/bin/docker run --rm --pull=never --network none \
1401
+ --entrypoint /bin/cat "$IMAGE_ID" /opt/ouro/deploy/unraid/sanctuary-unit18-target-audit.sh >"$UNIT18_TARGET_TMP"
1402
+ chmod 0500 "$UNIT18_TARGET_TMP"
1403
+ "$UNIT18_TARGET_TMP" "$IMAGE_ID"
1404
+ rm -f -- "$UNIT18_TARGET_TMP"
1405
+ Neither packaged command accepts a container name or an open-ended profile.
1406
+
1407
+ Packaged Unit 16 acceptance execution:
1408
+ Use only the launcher packaged in the exact reviewed image. Install it without
1409
+ network access, then create the two private roots with the runtime UID/GID:
1410
+ UNIT16_ROOT=/mnt/user/appdata/ouro-butler/acceptance
1411
+ install -d -m 0700 -o 10001 -g 10001 "$UNIT16_ROOT/configs" "$UNIT16_ROOT/evidence"
1412
+ UNIT16_LAUNCHER_TMP=$(mktemp "$UNIT16_ROOT/sanctuary-unit16-run.sh.tmp.XXXXXX")
1413
+ /usr/bin/timeout -s KILL 20 /usr/bin/docker run --rm --pull=never --network none \
1414
+ --entrypoint /bin/cat "$IMAGE_ID" /opt/ouro/deploy/unraid/sanctuary-unit16-run.sh >"$UNIT16_LAUNCHER_TMP"
1415
+ install -m 0755 -o root -g root "$UNIT16_LAUNCHER_TMP" "$UNIT16_ROOT/sanctuary-unit16-run.sh"
1416
+ rm -f -- "$UNIT16_LAUNCHER_TMP"
1417
+ After final activation, create the disjoint final-profile roots and invoke the
1418
+ same immutable launcher with its only non-staging selector. The selector is a
1419
+ closed `staging|final` enum; it never accepts a container name, ID, or path:
1420
+ UNIT18_ROOT=/mnt/user/appdata/ouro-butler/acceptance/final
1421
+ install -d -m 0700 -o 10001 -g 10001 "$UNIT18_ROOT/configs" "$UNIT18_ROOT/evidence"
1422
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final materialize evidence-snapshot
1423
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" --profile final evidence-snapshot evidence-snapshot.json
1424
+ Use `--profile final` on every final pre-reboot and post-reboot materialize/run
1425
+ command. Omitting it remains the byte-for-byte Unit 16 staging path. Never mix
1426
+ staging and final config/evidence roots or reuse a config across profiles.
1427
+ Never hand-author a Unit 16 config. Materialize it from the exact image, then
1428
+ execute it in the order below. Before every execution the launcher regenerates
1429
+ the config from the packaged fixed contract and requires byte-for-byte equality.
1430
+ The cursor snapshot is deliberately materialized and executed twice around the
1431
+ live scenario. Telegram bootstrap reads the new bot token from host file
1432
+ descriptor 3; callback injection reads the reviewed saved callback-update JSON
1433
+ from the same descriptor. The launcher explicitly maps host fd 3 to Docker
1434
+ stdin and then to in-container fd 3 only for those two commands. Neither value
1435
+ belongs in argv, shell history, a config file, or an unrelated command's stdin.
1436
+ Define this Bash helper in the root shell. It disables terminal echo while
1437
+ reading the token, opens an anonymous descriptor for the launcher, and unsets
1438
+ the short-lived shell value on either launcher success or failure:
1439
+ run_unit16_telegram_bootstrap() {
1440
+ local UNIT16_BOT_TOKEN UNIT16_BOT_STATUS
1441
+ printf 'Telegram bot token: ' >&2
1442
+ IFS= read -r -s UNIT16_BOT_TOKEN || return $?
1443
+ printf '\n' >&2
1444
+ if "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" telegram-bootstrap telegram-bootstrap.json \
1445
+ 3< <(printf '%s\n' "$UNIT16_BOT_TOKEN"); then
1446
+ UNIT16_BOT_STATUS=0
1447
+ else
1448
+ UNIT16_BOT_STATUS=$?
1449
+ fi
1450
+ unset UNIT16_BOT_TOKEN
1451
+ return "$UNIT16_BOT_STATUS"
1452
+ }
1453
+ Stage the reviewed callback JSON at the fixed path below in the root-owned
1454
+ tmpfs inbox, then use this single fail-closed helper. It opens the input once,
1455
+ validates the opened descriptor and its original path refer to the same
1456
+ root-owned mode-0600 regular file, and invokes the launcher only afterward.
1457
+ Success overwrites, fsyncs, and unlinks the raw input. Any failure allocates a
1458
+ collision-free quarantine file atomically, copies and fsyncs the input into it,
1459
+ locks it to mode 000, then overwrites and removes the tmpfs source;
1460
+ no raw callback update remains loose in `/root` or the inbox.
1461
+ UNIT16_CALLBACK_INBOX=/run/ouro-unit16-callback-input
1462
+ UNIT16_CALLBACK_FILE=$UNIT16_CALLBACK_INBOX/callback-update.json
1463
+ UNIT16_CALLBACK_QUARANTINE_ROOT=$UNIT16_ROOT/callback-quarantine
1464
+ install -d -m 0700 -o root -g root "$UNIT16_CALLBACK_INBOX" "$UNIT16_CALLBACK_QUARANTINE_ROOT"
1465
+ run_unit16_callback_inject() {
1466
+ (
1467
+ UNIT16_CALLBACK_VALIDATED=no
1468
+ UNIT16_CALLBACK_STATUS=1
1469
+ unit16_callback_cleanup() {
1470
+ UNIT16_CALLBACK_STATUS=$?
1471
+ trap - EXIT HUP INT TERM
1472
+ if test -e "$UNIT16_CALLBACK_FILE" || test -L "$UNIT16_CALLBACK_FILE"; then
1473
+ if test "$UNIT16_CALLBACK_STATUS" -eq 0 && test "$UNIT16_CALLBACK_VALIDATED" = yes; then
1474
+ if ! truncate -s 0 /proc/self/fd/3 || ! sync -f /proc/self/fd/3 \
1475
+ || ! rm -f -- "$UNIT16_CALLBACK_FILE" || ! sync -f "$UNIT16_CALLBACK_INBOX"; then
1476
+ UNIT16_CALLBACK_STATUS=1
1477
+ fi
1478
+ elif test -f "$UNIT16_CALLBACK_FILE" && test ! -L "$UNIT16_CALLBACK_FILE"; then
1479
+ UNIT16_CALLBACK_QUARANTINE_FILE=$(mktemp "$UNIT16_CALLBACK_QUARANTINE_ROOT/failed.XXXXXX") || UNIT16_CALLBACK_QUARANTINE_FILE=
1480
+ if test -n "$UNIT16_CALLBACK_QUARANTINE_FILE" \
1481
+ && chmod 0600 "$UNIT16_CALLBACK_QUARANTINE_FILE" \
1482
+ && cp -- "$UNIT16_CALLBACK_FILE" "$UNIT16_CALLBACK_QUARANTINE_FILE" \
1483
+ && sync -f "$UNIT16_CALLBACK_QUARANTINE_FILE" \
1484
+ && chmod 000 "$UNIT16_CALLBACK_QUARANTINE_FILE" \
1485
+ && sync -f "$UNIT16_CALLBACK_QUARANTINE_ROOT"; then
1486
+ :
1487
+ else
1488
+ test -z "${UNIT16_CALLBACK_QUARANTINE_FILE-}" || rm -f -- "$UNIT16_CALLBACK_QUARANTINE_FILE"
1489
+ UNIT16_CALLBACK_STATUS=1
1490
+ fi
1491
+ if ! truncate -s 0 "$UNIT16_CALLBACK_FILE" || ! sync -f "$UNIT16_CALLBACK_FILE" \
1492
+ || ! rm -f -- "$UNIT16_CALLBACK_FILE" || ! sync -f "$UNIT16_CALLBACK_INBOX"; then
1493
+ UNIT16_CALLBACK_STATUS=1
1494
+ fi
1495
+ else
1496
+ rm -f -- "$UNIT16_CALLBACK_FILE" || UNIT16_CALLBACK_STATUS=1
1497
+ sync -f "$UNIT16_CALLBACK_INBOX" || UNIT16_CALLBACK_STATUS=1
1498
+ fi
1499
+ fi
1500
+ exec 3<&-
1501
+ exit "$UNIT16_CALLBACK_STATUS"
1502
+ }
1503
+ trap unit16_callback_cleanup EXIT
1504
+ trap 'exit 129' HUP
1505
+ trap 'exit 130' INT
1506
+ trap 'exit 143' TERM
1507
+ test -d "$UNIT16_CALLBACK_INBOX" && test ! -L "$UNIT16_CALLBACK_INBOX" || exit 1
1508
+ test "$(stat -c '%F %u:%g %a' "$UNIT16_CALLBACK_INBOX")" = "directory 0:0 700" || exit 1
1509
+ test -d "$UNIT16_CALLBACK_QUARANTINE_ROOT" && test ! -L "$UNIT16_CALLBACK_QUARANTINE_ROOT" || exit 1
1510
+ test "$(stat -c '%F %u:%g %a' "$UNIT16_CALLBACK_QUARANTINE_ROOT")" = "directory 0:0 700" || exit 1
1511
+ test -f "$UNIT16_CALLBACK_FILE" && test ! -L "$UNIT16_CALLBACK_FILE" || exit 1
1512
+ exec 3<"$UNIT16_CALLBACK_FILE"
1513
+ test "$(stat -Lc '%F' /proc/self/fd/3)" = "regular file" || exit 1
1514
+ test "$(stat -c '%d:%i' "$UNIT16_CALLBACK_FILE")" = "$(stat -Lc '%d:%i' /proc/self/fd/3)" || exit 1
1515
+ UNIT16_CALLBACK_VALIDATED=yes
1516
+ test "$(stat -Lc '%F %u:%g %a' /proc/self/fd/3)" = "regular file 0:0 600" || exit 1
1517
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" callback-inject callback-inject.json 3<&3
1518
+ )
1519
+ }
1520
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" materialize telegram-bootstrap
1521
+ run_unit16_telegram_bootstrap
1522
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" materialize cursor-snapshot before
1523
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" cursor-snapshot cursor-snapshot.json
1524
+ # Perform the live scenario whose cursor movement is being measured.
1525
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" materialize cursor-snapshot after
1526
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" cursor-snapshot cursor-snapshot.json
1527
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" materialize cursor-delta
1528
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" cursor-delta cursor-delta.json
1529
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" materialize callback-inject
1530
+ run_unit16_callback_inject
1531
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" materialize unraid-key-rotate
1532
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" unraid-key-rotate unraid-key-rotate.json
1533
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" materialize reboot-request
1534
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" reboot-request reboot-request.json
1535
+ # The preceding command captures and seals preflight evidence before it asks
1536
+ # the broker to stage the reboot, then seals the requested-phase evidence and
1537
+ # fsyncs the requested checkpoint before invoking the host reboot. Reconnect
1538
+ # only after Unraid and Docker are ready.
1539
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" materialize reboot-resume
1540
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" reboot-resume reboot-resume.json
1541
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" materialize evidence-snapshot
1542
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" evidence-snapshot evidence-snapshot.json
1543
+ # reboot-resume seals the changed boot identity and recovery milestones.
1544
+ # evidence-snapshot then runs the remaining scenarios sequentially with a
1545
+ # 72-minute aggregate bound; each completed scenario records provenance at
1546
+ # capture time and always clears its public gate, private marker, and receipt.
1547
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" materialize evidence-bundle-index
1548
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" evidence-bundle-index evidence-bundle-index.json
1549
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" materialize evidence-bundle-verify
1550
+ "$UNIT16_ROOT/sanctuary-unit16-run.sh" "$IMAGE_ID" evidence-bundle-verify evidence-bundle-verify.json
1551
+ Each execution revalidates the immutable image and the production container's
1552
+ exact image ID. The main one-shot gets a read-only root filesystem, one writable
1553
+ evidence mount, narrowly selected runtime/bundle modes, and individual read-only
1554
+ image/container/health/boot fact files. Host-only operations cross an ephemeral
1555
+ root-owned private Unix socket (root:10001 mode 0660) to a root broker extracted
1556
+ from the same exact image. The broker accepts only fixed Unraid inventory/create/
1557
+ exact-revoke/rejection operations, exact fixed-target snapshots, and a
1558
+ two-phase reboot reservation/final commit. Reservation drains queued owner
1559
+ mutations and rejects new ones; after the requested checkpoint is fsynced, the
1560
+ broker repeats the array/parity/mover/durable-health preflight and invokes the
1561
+ fixed host reboot itself exactly once. The Unit 16 broker inspects only the
1562
+ deployment profile's attested immutable container ID (staging is
1563
+ `ouro-butler-staging`), requires its immutable image ID, writes that exact typed/redacted
1564
+ snapshot before accepting requests, and exposes a nonnegative Docker restart
1565
+ count. Both initial and refreshed snapshots share the same exact schema. The
1566
+ broker derives `autostartExact` from both the Unraid GraphQL `autoStart` value
1567
+ and exactly one staging entry in `/var/lib/docker/unraid-autostart`, derives
1568
+ `updaterDisabled` from `/opt/ouro/container-runtime.json` inside the exact image,
1569
+ and derives `vaultUnlocked`/`manualAuthRequired` from a bounded live Sanctuary
1570
+ vault-status command only when its non-secret summary proves the required
1571
+ Telegram runtime fields and both configured provider records were freshly read.
1572
+ Scenario handles remain private to the scenario adapter. The main one-shot
1573
+ never receives the Docker socket, Unraid key directory, or a host-root mount.
1574
+ Telegram bootstrap additionally brackets its one-shot with a host-controlled
1575
+ poller quiescence guard: it verifies the exact healthy staging container,
1576
+ assumes recovery responsibility before attempting the stop, stops it with a
1577
+ 30-second grace bound, proves it is stopped, and mounts a
1578
+ root-owned typed zero-poller fact. Its exit/signal trap restarts that same exact
1579
+ container and waits up to 120 seconds for healthy recovery on both success and
1580
+ failure. It never reads or changes Unraid autostart configuration. Every command
1581
+ also receives a freshly generated, redacted typed container-inspect snapshot;
1582
+ raw container environment or credential values are never captured.
1583
+ Evidence-snapshot keeps the bundle mount read-only and overlays only the exact
1584
+ canonical `state/acceptance` directory as writable. It first stops the exact
1585
+ healthy staging container under recovery responsibility, then opens the
1586
+ directory with `O_DIRECTORY|O_NOFOLLOW` and bind-mounts from the inherited fd
1587
+ while no Sanctuary UID process is running. The launcher bind-pins that inode
1588
+ behind a root-only alias and back onto the canonical path against rename swaps,
1589
+ restarts the same exact image healthy, and compares the alias, canonical path,
1590
+ and staging container's inode before and after capture. Cleanup unmounts the
1591
+ canonical pin and then the alias. The staging daemon therefore sees the same
1592
+ inode used by the one-shot for marker correlation.
1593
+
1594
+ Audit and safety verification:
1595
+ Inspect AgentBundles/sanctuary.ouro/state/approvals for durable approval and
1596
+ restart-attempt receipts. Confirm no Docker socket/device/host-root mounts and
1597
+ no published ports with docker inspect. Confirm Config.Image equals the exact
1598
+ reviewed local image ID, not the build tag. The read key must reject Docker
1599
+ stop and restart mutations; only the separate write key may perform the one
1600
+ typed approved restart action.
1601
+ The packaged `unraid-key-rotate` command is the only canonical key-rotation
1602
+ authority. It inventories the two occupied canonical names by exact immutable
1603
+ ID, creates collision-safe `Butler RO Rotation <suffix>` and
1604
+ `Butler RW Rotation <suffix>` keys, stores and capability-probes both through
1605
+ the Sanctuary machine vault, and requires an exact four-key inventory. It then
1606
+ revokes both old IDs and proves each old credential receives a 401 or 403.
1607
+ Only after an exact inventory contains the temporary pair alone does it create,
1608
+ store, and probe the canonical `Butler RO` and `Butler RW` pair. It requires an
1609
+ exact four-key temporary-plus-canonical inventory, revokes and rejection-probes
1610
+ both temporary IDs, and finally requires exactly the canonical pair. Every
1611
+ unexpected key, duplicate, malformed permission set, role, name, inventory
1612
+ delta, or probe response fails closed.
1613
+
1614
+ Record only IDs, names, and permission sets in evidence; never raw key values.
1615
+ Raw credentials must not appear in argv, shell history, stdout, logs, or
1616
+ acceptance evidence. They may cross only the ephemeral private Unit 16 Unix
1617
+ socket in process memory. The narrow revocation-retry recovery records under
1618
+ `/mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof` are the sole file
1619
+ exception: they are root-owned mode 0600 beneath a root-owned mode 0700
1620
+ directory, excluded from backup/evidence, streamed only on standard input to
1621
+ the bounded rejection probe, retained across failed retries, and durably
1622
+ deleted only after the exact authentication rejection is validated. Never
1623
+ substitute names for IDs when revoking keys.