@ouro.bot/cli 0.1.0-alpha.98 → 0.1.0-alpha.981

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 (763) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -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/assets/sanctuary-host-launcher.sh +16 -0
  15. package/changelog.json +6195 -16
  16. package/deploy/unraid/Dockerfile +58 -0
  17. package/deploy/unraid/INSTALL.md +64 -0
  18. package/deploy/unraid/README.txt +2997 -0
  19. package/deploy/unraid/audit-container-spec.sh +4 -0
  20. package/deploy/unraid/container-runtime.json +1 -0
  21. package/deploy/unraid/credit-gateway.md +95 -0
  22. package/deploy/unraid/docker-man-template-transaction.mjs +727 -0
  23. package/deploy/unraid/docker-man-template-xml.cjs +105 -0
  24. package/deploy/unraid/install.sh +274 -0
  25. package/deploy/unraid/migrate-sanctuary-bundle.mjs +63 -0
  26. package/deploy/unraid/ouro-events/bootstrap-spool.sh +92 -0
  27. package/deploy/unraid/ouro-events/emit-event.mjs +310 -0
  28. package/deploy/unraid/ouro-events/emit-usenet-event.sh +54 -0
  29. package/deploy/unraid/ouro-events/install-usenet-guard.sh +435 -0
  30. package/deploy/unraid/ouro-events/usenet-health.sh +176 -0
  31. package/deploy/unraid/sanctuary-acceptance-adapter.sh +43 -0
  32. package/deploy/unraid/sanctuary-acceptance-contract.json +198 -0
  33. package/deploy/unraid/sanctuary-acceptance-harness.sh +23 -0
  34. package/deploy/unraid/sanctuary-authority-installation.json +36 -0
  35. package/deploy/unraid/sanctuary-authority-service.sh +30 -0
  36. package/deploy/unraid/sanctuary-butler-upgrade.mjs +1377 -0
  37. package/deploy/unraid/sanctuary-deployment-target.mjs +710 -0
  38. package/deploy/unraid/sanctuary-replay-gate.mjs +1776 -0
  39. package/deploy/unraid/sanctuary-safe-rename.py +24 -0
  40. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +2088 -0
  41. package/deploy/unraid/sanctuary-unit16-run.sh +570 -0
  42. package/deploy/unraid/sanctuary-unit18-target-audit.sh +27 -0
  43. package/deploy/unraid/sanctuary.ouro/agent.json +66 -0
  44. package/deploy/unraid/sanctuary.ouro/arc/README.md +3 -0
  45. package/deploy/unraid/sanctuary.ouro/awaiting/house-care-sweep.md +17 -0
  46. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +5 -0
  47. package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +8 -0
  48. package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +2255 -0
  49. package/deploy/unraid/sanctuary.ouro/provider-readiness.json +11 -0
  50. package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +5 -0
  51. package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +7 -0
  52. package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +33 -0
  53. package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +27 -0
  54. package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +21 -0
  55. package/deploy/unraid/sanctuary.ouro/state/policy/steward.json +7 -0
  56. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +232 -0
  57. package/deploy/unraid/sanctuary.xml +29 -0
  58. package/dist/a2a/card.js +57 -0
  59. package/dist/a2a/client.js +250 -0
  60. package/dist/a2a/config.js +50 -0
  61. package/dist/a2a/delegated-command-grants.js +93 -0
  62. package/dist/a2a/delegated-command.js +197 -0
  63. package/dist/a2a/delegation-stores.js +56 -0
  64. package/dist/a2a/did-resolution.js +93 -0
  65. package/dist/a2a/escalation-grants.js +84 -0
  66. package/dist/a2a/escalation-wiring.js +60 -0
  67. package/dist/a2a/identity.js +258 -0
  68. package/dist/a2a/inbound-share.js +110 -0
  69. package/dist/a2a/mission-result-wire.js +196 -0
  70. package/dist/a2a/onboarding.js +133 -0
  71. package/dist/a2a/operator-trust.js +399 -0
  72. package/dist/a2a/outbox-client.js +57 -0
  73. package/dist/a2a/outbox-store.js +188 -0
  74. package/dist/a2a/outbox-wire.js +95 -0
  75. package/dist/a2a/pin-store.js +132 -0
  76. package/dist/a2a/replay-grant-holders.js +33 -0
  77. package/dist/a2a/replay-harness.js +188 -0
  78. package/dist/a2a/resolution-proof.js +53 -0
  79. package/dist/a2a/sealed-chat.js +113 -0
  80. package/dist/a2a/seen-ledger.js +120 -0
  81. package/dist/a2a/server.js +771 -0
  82. package/dist/a2a/task-store.js +69 -0
  83. package/dist/a2a/transport.js +45 -0
  84. package/dist/a2a/trust-commands.js +203 -0
  85. package/dist/a2a/trusted-files.js +185 -0
  86. package/dist/a2a/types.js +3 -0
  87. package/dist/arc/attention-types.js +8 -0
  88. package/dist/arc/cares.js +360 -0
  89. package/dist/arc/episodes.js +118 -0
  90. package/dist/arc/evolution.js +490 -0
  91. package/dist/arc/flight-recorder.js +1034 -0
  92. package/dist/arc/intentions.js +134 -0
  93. package/dist/arc/json-store.js +117 -0
  94. package/dist/arc/obligations.js +386 -0
  95. package/dist/arc/packets.js +336 -0
  96. package/dist/arc/presence.js +185 -0
  97. package/dist/arc/task-lifecycle.js +57 -0
  98. package/dist/commerce/store.js +755 -0
  99. package/dist/commerce/types.js +3 -0
  100. package/dist/gateway/keys.js +117 -0
  101. package/dist/gateway/server.js +429 -0
  102. package/dist/heart/active-work.js +798 -40
  103. package/dist/heart/agent-entry.js +252 -3
  104. package/dist/heart/approval-files.js +113 -0
  105. package/dist/heart/approval-store.js +960 -0
  106. package/dist/heart/attachments/image-normalize.js +201 -0
  107. package/dist/heart/attachments/materialize.js +97 -0
  108. package/dist/heart/attachments/originals.js +88 -0
  109. package/dist/heart/attachments/render.js +29 -0
  110. package/dist/heart/attachments/sources/bluebubbles.js +156 -0
  111. package/dist/heart/attachments/sources/cli-local-file.js +78 -0
  112. package/dist/heart/attachments/sources/index.js +18 -0
  113. package/dist/heart/attachments/sources/telegram.js +71 -0
  114. package/dist/heart/attachments/store.js +132 -0
  115. package/dist/heart/attachments/types.js +93 -0
  116. package/dist/heart/auth/auth-flow.js +511 -0
  117. package/dist/heart/autonomy-budget.js +478 -0
  118. package/dist/heart/awaiting/a2a-await-delivery.js +109 -0
  119. package/dist/heart/awaiting/await-alert.js +152 -0
  120. package/dist/heart/awaiting/await-expiry.js +143 -0
  121. package/dist/heart/awaiting/await-loader.js +91 -0
  122. package/dist/heart/awaiting/await-parser.js +152 -0
  123. package/dist/heart/awaiting/await-runtime-state.js +130 -0
  124. package/dist/heart/awaiting/await-scheduler.js +391 -0
  125. package/dist/heart/background-operations.js +281 -0
  126. package/dist/heart/bluebubbles-host-account.js +78 -0
  127. package/dist/heart/bridges/manager.js +137 -17
  128. package/dist/heart/bridges/store.js +14 -2
  129. package/dist/heart/bundle-state.js +168 -0
  130. package/dist/heart/commitments.js +44 -18
  131. package/dist/heart/config-registry.js +331 -0
  132. package/dist/heart/config.js +178 -131
  133. package/dist/heart/context-loss-gauntlet.js +387 -0
  134. package/dist/heart/context-loss-sentinel.js +1204 -0
  135. package/dist/heart/core.js +2377 -586
  136. package/dist/heart/cross-chat-delivery.js +9 -22
  137. package/dist/heart/daemon/agent-config-check.js +474 -0
  138. package/dist/heart/daemon/agent-discovery.js +283 -3
  139. package/dist/heart/daemon/agent-home.js +319 -0
  140. package/dist/heart/daemon/agent-service.js +542 -0
  141. package/dist/heart/daemon/agentic-repair.js +547 -0
  142. package/dist/heart/daemon/await-private-wake.js +59 -0
  143. package/dist/heart/daemon/bluebubbles-cli-integration.js +71 -0
  144. package/dist/heart/daemon/bluebubbles-health-diagnostics.js +122 -0
  145. package/dist/heart/daemon/bluebubbles-host-protocol.js +433 -0
  146. package/dist/heart/daemon/bluebubbles-host.js +273 -0
  147. package/dist/heart/daemon/boot-sync-probe.js +197 -0
  148. package/dist/heart/daemon/cadence.js +180 -0
  149. package/dist/heart/daemon/cli-arg-redaction.js +26 -0
  150. package/dist/heart/daemon/cli-defaults.js +881 -0
  151. package/dist/heart/daemon/cli-desk.js +331 -0
  152. package/dist/heart/daemon/cli-exec.js +9272 -0
  153. package/dist/heart/daemon/cli-help.js +754 -0
  154. package/dist/heart/daemon/cli-parse.js +3329 -0
  155. package/dist/heart/daemon/cli-render-doctor.js +57 -0
  156. package/dist/heart/daemon/cli-render.js +819 -0
  157. package/dist/heart/daemon/cli-types.js +8 -0
  158. package/dist/heart/daemon/connect-bay.js +323 -0
  159. package/dist/heart/daemon/container-credential-bootstrap.js +273 -0
  160. package/dist/heart/daemon/container-healthcheck.js +112 -0
  161. package/dist/heart/daemon/container-runtime.js +97 -0
  162. package/dist/heart/daemon/container-spec-auditor-main.js +171 -0
  163. package/dist/heart/daemon/container-spec-auditor.js +241 -0
  164. package/dist/heart/daemon/daemon-bootstrap-startup.js +115 -0
  165. package/dist/heart/daemon/daemon-cli.js +29 -2373
  166. package/dist/heart/daemon/daemon-entry.js +953 -11
  167. package/dist/heart/daemon/daemon-health.js +186 -0
  168. package/dist/heart/daemon/daemon-rollup.js +57 -0
  169. package/dist/heart/daemon/daemon-runtime-sync.js +88 -13
  170. package/dist/heart/daemon/daemon-tombstone.js +236 -0
  171. package/dist/heart/daemon/daemon.js +2110 -104
  172. package/dist/heart/daemon/desk-admin.js +420 -0
  173. package/dist/heart/daemon/dns-workflow.js +394 -0
  174. package/dist/heart/daemon/doctor-types.js +8 -0
  175. package/dist/heart/daemon/doctor.js +1766 -0
  176. package/dist/heart/daemon/freshness.js +345 -0
  177. package/dist/heart/daemon/habit-private-wake.js +61 -0
  178. package/dist/heart/daemon/health-monitor.js +154 -11
  179. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  180. package/dist/heart/daemon/hooks/bundle-meta.js +135 -1
  181. package/dist/heart/daemon/http-health-probe.js +80 -0
  182. package/dist/heart/daemon/human-command-screens.js +234 -0
  183. package/dist/heart/daemon/human-readiness.js +114 -0
  184. package/dist/heart/daemon/inner-status.js +78 -0
  185. package/dist/heart/daemon/interactive-repair.js +394 -0
  186. package/dist/heart/daemon/launchd.js +37 -8
  187. package/dist/heart/daemon/log-tailer.js +79 -10
  188. package/dist/heart/daemon/logs-prune.js +132 -0
  189. package/dist/heart/daemon/mcp-canary.js +524 -0
  190. package/dist/heart/daemon/message-router.js +65 -5
  191. package/dist/heart/daemon/migrate-to-desk.js +848 -0
  192. package/dist/heart/daemon/os-cron-deps.js +135 -0
  193. package/dist/heart/daemon/os-cron.js +22 -14
  194. package/dist/heart/daemon/ouro-bot-entry.js +6 -3
  195. package/dist/heart/daemon/ouro-entry.js +9 -3
  196. package/dist/heart/daemon/plugin-cli.js +432 -0
  197. package/dist/heart/daemon/process-manager.js +760 -61
  198. package/dist/heart/daemon/provider-discovery.js +137 -0
  199. package/dist/heart/daemon/provider-ping-progress.js +83 -0
  200. package/dist/heart/daemon/prunable-bundle.js +144 -0
  201. package/dist/heart/daemon/pulse.js +511 -0
  202. package/dist/heart/daemon/readiness-repair.js +365 -0
  203. package/dist/heart/daemon/run-hooks.js +2 -0
  204. package/dist/heart/daemon/runtime-logging.js +47 -14
  205. package/dist/heart/daemon/runtime-metadata.js +2 -30
  206. package/dist/heart/daemon/runtime-mode.js +13 -2
  207. package/dist/heart/daemon/safe-mode.js +161 -0
  208. package/dist/heart/daemon/sanctuary-acceptance-adapter.js +2662 -0
  209. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1832 -0
  210. package/dist/heart/daemon/sanctuary-acceptance-marker.js +267 -0
  211. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +956 -0
  212. package/dist/heart/daemon/sanctuary-authority-codec.js +86 -0
  213. package/dist/heart/daemon/sanctuary-authority-epoch.js +291 -0
  214. package/dist/heart/daemon/sanctuary-authority-installation.js +161 -0
  215. package/dist/heart/daemon/sanctuary-authority-ledger.js +241 -0
  216. package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +1326 -0
  217. package/dist/heart/daemon/sanctuary-authority-vault-migration.js +79 -0
  218. package/dist/heart/daemon/sanctuary-bundle-migration.js +851 -0
  219. package/dist/heart/daemon/sanctuary-host-authority.js +1008 -0
  220. package/dist/heart/daemon/sanctuary-host-detached-supervisor.js +493 -0
  221. package/dist/heart/daemon/sanctuary-host-executor.js +670 -0
  222. package/dist/heart/daemon/sanctuary-host-linux-kernel.js +334 -0
  223. package/dist/heart/daemon/sanctuary-host-supervisor-entry.js +207 -0
  224. package/dist/heart/daemon/sanctuary-host-supervisor.js +95 -0
  225. package/dist/heart/daemon/sanctuary-package-management.js +103 -0
  226. package/dist/heart/daemon/sanctuary-scheduler-liveness.js +227 -0
  227. package/dist/heart/daemon/sanctuary-scheduler-origin.js +132 -0
  228. package/dist/heart/daemon/sanctuary-telegram-authority-entry.js +475 -0
  229. package/dist/heart/daemon/sanctuary-telegram-authority-gateway.js +585 -0
  230. package/dist/heart/daemon/sanctuary-telegram-authority-service.js +676 -0
  231. package/dist/heart/daemon/sense-manager.js +786 -48
  232. package/dist/heart/daemon/session-id-resolver.js +131 -0
  233. package/dist/heart/daemon/skill-management-installer.js +1 -1
  234. package/dist/heart/daemon/socket-client.js +180 -12
  235. package/dist/heart/daemon/stale-bundle-prune.js +113 -0
  236. package/dist/heart/daemon/startup-tui.js +339 -0
  237. package/dist/heart/daemon/supercronic-supervisor.js +282 -0
  238. package/dist/heart/daemon/task-scheduler.js +117 -39
  239. package/dist/heart/daemon/terminal-ui.js +503 -0
  240. package/dist/heart/daemon/thoughts.js +138 -54
  241. package/dist/heart/daemon/up-progress.js +366 -0
  242. package/dist/heart/daemon/vault-items.js +56 -0
  243. package/dist/heart/delegation.js +1 -4
  244. package/dist/heart/external-events/router.js +1531 -0
  245. package/dist/heart/failure-reports.js +336 -0
  246. package/dist/heart/file-lock.js +109 -0
  247. package/dist/heart/frontend-approval-runtime.js +506 -0
  248. package/dist/heart/frontend-journal.js +215 -0
  249. package/dist/heart/frontend-session-service.js +271 -0
  250. package/dist/heart/frontend-socket-client.js +173 -0
  251. package/dist/heart/frontend-socket.js +434 -0
  252. package/dist/heart/frontend-turn-errors.js +20 -0
  253. package/dist/heart/habits/habit-cancel.js +810 -0
  254. package/dist/heart/habits/habit-lifecycle.js +1327 -0
  255. package/dist/heart/habits/habit-migration.js +194 -0
  256. package/dist/heart/habits/habit-parser.js +278 -0
  257. package/dist/heart/habits/habit-runtime-state.js +93 -0
  258. package/dist/heart/habits/habit-scheduler.js +653 -0
  259. package/dist/heart/habits/habit-session-summary.js +318 -0
  260. package/dist/heart/habits/habit-session.js +636 -0
  261. package/dist/heart/{daemon → hatch}/hatch-flow.js +55 -62
  262. package/dist/heart/{daemon → hatch}/hatch-specialist.js +6 -8
  263. package/dist/heart/{daemon → hatch}/specialist-prompt.js +12 -9
  264. package/dist/heart/{daemon → hatch}/specialist-tools.js +53 -18
  265. package/dist/heart/identity.js +239 -59
  266. package/dist/heart/kept-notes.js +289 -0
  267. package/dist/heart/kicks.js +1 -1
  268. package/dist/heart/machine-identity.js +175 -0
  269. package/dist/heart/mail-import-discovery.js +356 -0
  270. package/dist/heart/mailbox/mailbox-http-hooks.js +99 -0
  271. package/dist/heart/mailbox/mailbox-http-response.js +7 -0
  272. package/dist/heart/mailbox/mailbox-http-routes.js +406 -0
  273. package/dist/heart/mailbox/mailbox-http-static.js +103 -0
  274. package/dist/heart/mailbox/mailbox-http-transport.js +272 -0
  275. package/dist/heart/mailbox/mailbox-http.js +99 -0
  276. package/dist/heart/mailbox/mailbox-read.js +41 -0
  277. package/dist/heart/mailbox/mailbox-supervision.js +131 -0
  278. package/dist/heart/mailbox/mailbox-types.js +27 -0
  279. package/dist/heart/mailbox/mailbox-view.js +197 -0
  280. package/dist/heart/mailbox/readers/agent-machine.js +428 -0
  281. package/dist/heart/mailbox/readers/continuity-readers.js +329 -0
  282. package/dist/heart/mailbox/readers/mail.js +375 -0
  283. package/dist/heart/mailbox/readers/runtime-readers.js +823 -0
  284. package/dist/heart/mailbox/readers/sessions.js +231 -0
  285. package/dist/heart/mailbox/readers/shared.js +111 -0
  286. package/dist/heart/mcp/mcp-server.js +760 -0
  287. package/dist/heart/migrate-config.js +100 -0
  288. package/dist/heart/model-capabilities.js +83 -16
  289. package/dist/heart/orientation-frame.js +376 -0
  290. package/dist/heart/platform.js +81 -0
  291. package/dist/heart/primitives.js +1 -1
  292. package/dist/heart/private-runtime/decision-renderer.js +158 -0
  293. package/dist/heart/private-runtime/index.js +18 -0
  294. package/dist/heart/private-runtime/ledger.js +221 -0
  295. package/dist/heart/private-runtime/policy.js +260 -0
  296. package/dist/heart/private-runtime/types.js +2 -0
  297. package/dist/heart/progress-story.js +1 -1
  298. package/dist/heart/provider-attempt.js +137 -0
  299. package/dist/heart/provider-binding-resolver.js +273 -0
  300. package/dist/heart/provider-credentials.js +449 -0
  301. package/dist/heart/provider-failover.js +311 -0
  302. package/dist/heart/provider-models.js +96 -0
  303. package/dist/heart/provider-ping.js +266 -0
  304. package/dist/heart/provider-readiness-cache.js +119 -0
  305. package/dist/heart/provider-visibility.js +188 -0
  306. package/dist/heart/providers/anthropic-token.js +131 -0
  307. package/dist/heart/providers/anthropic.js +248 -67
  308. package/dist/heart/providers/azure.js +23 -11
  309. package/dist/heart/providers/error-classification.js +129 -0
  310. package/dist/heart/providers/github-copilot.js +38 -39
  311. package/dist/heart/providers/minimax-vlm.js +189 -0
  312. package/dist/heart/providers/minimax.js +26 -8
  313. package/dist/heart/providers/openai-codex-token.js +349 -0
  314. package/dist/heart/providers/openai-codex.js +55 -40
  315. package/dist/heart/providers/openai-compatible.js +177 -0
  316. package/dist/heart/reach-channel.js +85 -0
  317. package/dist/heart/reply-style.js +52 -0
  318. package/dist/heart/run-ledger.js +247 -0
  319. package/dist/heart/runtime-capability-check.js +170 -0
  320. package/dist/heart/runtime-credentials.js +608 -0
  321. package/dist/heart/runtime-cwd.js +87 -0
  322. package/dist/heart/sense-truth.js +24 -4
  323. package/dist/heart/session-activity.js +94 -24
  324. package/dist/heart/session-events.js +1521 -0
  325. package/dist/heart/session-playback-cli-main.js +5 -0
  326. package/dist/heart/session-playback-cli.js +36 -0
  327. package/dist/heart/session-playback.js +231 -0
  328. package/dist/heart/session-redaction-repair-cli-main.js +31 -0
  329. package/dist/heart/session-redaction-repair.js +552 -0
  330. package/dist/heart/session-stats-cli-main.js +5 -0
  331. package/dist/heart/session-stats.js +183 -0
  332. package/dist/heart/session-transcript.js +133 -0
  333. package/dist/heart/shelf-claims.js +78 -0
  334. package/dist/heart/shepherd/custody-types.js +2 -0
  335. package/dist/heart/shepherd/mandates.js +352 -0
  336. package/dist/heart/shepherd/operation-commands.js +19 -0
  337. package/dist/heart/shepherd/operations.js +135 -0
  338. package/dist/heart/shepherd/supervision.js +114 -0
  339. package/dist/heart/shepherd/types.js +2 -0
  340. package/dist/heart/shepherd-config.js +123 -0
  341. package/dist/heart/source-grounding.js +313 -0
  342. package/dist/heart/start-of-turn-packet.js +372 -0
  343. package/dist/heart/steward-policy.js +656 -0
  344. package/dist/heart/streaming.js +631 -207
  345. package/dist/heart/structured-output.js +196 -0
  346. package/dist/heart/sync-classification.js +176 -0
  347. package/dist/heart/sync-health.js +152 -0
  348. package/dist/heart/sync.js +726 -0
  349. package/dist/heart/target-resolution.js +11 -7
  350. package/dist/heart/tempo.js +93 -0
  351. package/dist/heart/temporal-view.js +41 -0
  352. package/dist/heart/timeouts.js +101 -0
  353. package/dist/heart/tool-activity-callbacks.js +59 -0
  354. package/dist/heart/tool-approval.js +382 -0
  355. package/dist/heart/tool-description.js +155 -0
  356. package/dist/heart/tool-friction.js +55 -0
  357. package/dist/heart/tool-loop.js +206 -0
  358. package/dist/heart/turn-context.js +496 -0
  359. package/dist/heart/turn-execution-lease.js +30 -0
  360. package/dist/heart/{daemon → versioning}/ouro-bot-global-installer.js +6 -5
  361. package/dist/heart/{daemon → versioning}/ouro-bot-wrapper.js +3 -2
  362. package/dist/heart/versioning/ouro-path-installer.js +432 -0
  363. package/dist/heart/versioning/ouro-recovery-launcher.js +76 -0
  364. package/dist/heart/versioning/ouro-version-manager.js +432 -0
  365. package/dist/heart/versioning/release-sources.js +173 -0
  366. package/dist/heart/versioning/self-update-defaults.js +294 -0
  367. package/dist/heart/versioning/self-update.js +196 -0
  368. package/dist/heart/{daemon → versioning}/staged-restart.js +40 -8
  369. package/dist/heart/{daemon → versioning}/update-checker.js +6 -1
  370. package/dist/heart/versioning/update-hooks.js +154 -0
  371. package/dist/heart/versioning/update-state.js +126 -0
  372. package/dist/heart/versioning/updater-launchd.js +150 -0
  373. package/dist/heart/versioning/version-intent.js +114 -0
  374. package/dist/heart/work-card.js +380 -0
  375. package/dist/mailbox-ui/assets/index-Dbska1tZ.js +1 -0
  376. package/dist/mailbox-ui/assets/index-DsiKaPyH.css +1 -0
  377. package/dist/mailbox-ui/assets/vendor-CcN1XpQ9.js +61 -0
  378. package/dist/mailbox-ui/index.html +16 -0
  379. package/dist/mailroom/attention.js +167 -0
  380. package/dist/mailroom/autonomy.js +209 -0
  381. package/dist/mailroom/blob-store.js +889 -0
  382. package/dist/mailroom/body-cache.js +61 -0
  383. package/dist/mailroom/cache-sync-cli.js +58 -0
  384. package/dist/mailroom/core.js +803 -0
  385. package/dist/mailroom/entry.js +160 -0
  386. package/dist/mailroom/file-store.js +568 -0
  387. package/dist/mailroom/hosted-cache-sync.js +384 -0
  388. package/dist/mailroom/mbox-import.js +393 -0
  389. package/dist/mailroom/migration.js +164 -0
  390. package/dist/mailroom/outbound.js +380 -0
  391. package/dist/mailroom/policy.js +263 -0
  392. package/dist/mailroom/reader.js +292 -0
  393. package/dist/mailroom/search-cache.js +513 -0
  394. package/dist/mailroom/search-relevance.js +319 -0
  395. package/dist/mailroom/smtp-ingress.js +176 -0
  396. package/dist/mailroom/source-state.js +176 -0
  397. package/dist/mailroom/thread.js +109 -0
  398. package/dist/mailroom/travel-extract.js +89 -0
  399. package/dist/mind/bundle-manifest.js +24 -2
  400. package/dist/mind/context.js +328 -139
  401. package/dist/mind/desk-section.js +362 -0
  402. package/dist/mind/diary-integrity.js +60 -0
  403. package/dist/mind/{memory.js → diary.js} +68 -77
  404. package/dist/mind/embedding-provider.js +60 -0
  405. package/dist/mind/file-state.js +179 -0
  406. package/dist/mind/friend-link-codes.js +209 -0
  407. package/dist/mind/friend-merge.js +336 -0
  408. package/dist/mind/{associative-recall.js → note-search.js} +47 -58
  409. package/dist/mind/obligation-steering.js +199 -3
  410. package/dist/mind/outbound-record.js +168 -0
  411. package/dist/mind/pending.js +36 -10
  412. package/dist/mind/prompt-budget.js +479 -0
  413. package/dist/mind/prompt-refresh.js +3 -2
  414. package/dist/mind/prompt.js +1166 -203
  415. package/dist/mind/provenance-trust.js +26 -0
  416. package/dist/mind/record-paths.js +312 -0
  417. package/dist/mind/scrutiny.js +173 -0
  418. package/dist/mind/session-transaction.js +622 -0
  419. package/dist/nerves/cli-logging.js +7 -1
  420. package/dist/nerves/coverage/audit-rules.js +15 -6
  421. package/dist/nerves/coverage/audit.js +28 -2
  422. package/dist/nerves/coverage/cli.js +1 -1
  423. package/dist/nerves/coverage/contract.js +5 -5
  424. package/dist/nerves/coverage/file-completeness.js +143 -5
  425. package/dist/nerves/coverage/run-artifacts.js +55 -35
  426. package/dist/nerves/coverage/source-scanner.js +4 -4
  427. package/dist/nerves/event-buffer.js +111 -0
  428. package/dist/nerves/index.js +293 -9
  429. package/dist/nerves/observation.js +20 -0
  430. package/dist/nerves/redact.js +79 -0
  431. package/dist/nerves/review/cli-main.js +5 -0
  432. package/dist/nerves/review/cli.js +156 -0
  433. package/dist/nerves/review/core.js +159 -0
  434. package/dist/nerves/runtime.js +17 -1
  435. package/dist/repertoire/ado-client.js +15 -56
  436. package/dist/repertoire/ado-semantic.js +16 -10
  437. package/dist/repertoire/api-client.js +97 -0
  438. package/dist/repertoire/bitwarden-store.js +1199 -0
  439. package/dist/repertoire/bundle-templates.js +71 -0
  440. package/dist/repertoire/bw-installer.js +231 -0
  441. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  442. package/dist/repertoire/coding/context-pack.js +331 -0
  443. package/dist/repertoire/coding/feedback.js +146 -34
  444. package/dist/repertoire/coding/index.js +15 -3
  445. package/dist/repertoire/coding/manager.js +159 -10
  446. package/dist/repertoire/coding/spawner.js +40 -11
  447. package/dist/repertoire/coding/tools.js +182 -18
  448. package/dist/repertoire/coding/workbench-client.js +272 -0
  449. package/dist/repertoire/coding/workbench-manager.js +346 -0
  450. package/dist/repertoire/commerce-errors.js +109 -0
  451. package/dist/repertoire/commerce-self-test.js +156 -0
  452. package/dist/repertoire/credential-access.js +183 -0
  453. package/dist/repertoire/desk/classifier.js +362 -0
  454. package/dist/repertoire/duffel-client.js +185 -0
  455. package/dist/repertoire/github-client.js +14 -55
  456. package/dist/repertoire/graph-client.js +11 -52
  457. package/dist/repertoire/guardrails.js +971 -20
  458. package/dist/repertoire/house-sweep.js +471 -0
  459. package/dist/repertoire/mcp-client.js +58 -5
  460. package/dist/repertoire/mcp-manager.js +427 -139
  461. package/dist/repertoire/mcp-tools.js +132 -0
  462. package/dist/repertoire/mcp-write-guard.js +46 -0
  463. package/dist/repertoire/plugin-mcp.js +175 -0
  464. package/dist/repertoire/plugins.js +253 -0
  465. package/dist/repertoire/relationship-authorization.js +322 -0
  466. package/dist/repertoire/sanctuary-container-policy.js +50 -0
  467. package/dist/repertoire/shell-sessions.js +134 -0
  468. package/dist/repertoire/skills.js +48 -4
  469. package/dist/repertoire/stripe-client.js +131 -0
  470. package/dist/repertoire/tool-arguments.js +182 -0
  471. package/dist/repertoire/tool-results.js +29 -0
  472. package/dist/repertoire/tools-a2a.js +699 -0
  473. package/dist/repertoire/tools-attachments.js +322 -0
  474. package/dist/repertoire/tools-awaiting.js +897 -0
  475. package/dist/repertoire/tools-base.js +83 -1168
  476. package/dist/repertoire/tools-bluebubbles.js +2 -0
  477. package/dist/repertoire/tools-bridge.js +144 -0
  478. package/dist/repertoire/tools-bundle.js +995 -0
  479. package/dist/repertoire/tools-commerce.js +253 -0
  480. package/dist/repertoire/tools-config.js +186 -0
  481. package/dist/repertoire/tools-continuity.js +588 -0
  482. package/dist/repertoire/tools-credential.js +383 -0
  483. package/dist/repertoire/tools-escalation.js +84 -0
  484. package/dist/repertoire/tools-evolution.js +527 -0
  485. package/dist/repertoire/tools-files.js +344 -0
  486. package/dist/repertoire/tools-flight.js +290 -0
  487. package/dist/repertoire/tools-flow.js +119 -0
  488. package/dist/repertoire/tools-friend-link.js +57 -0
  489. package/dist/repertoire/tools-github.js +3 -8
  490. package/dist/repertoire/tools-habits.js +103 -0
  491. package/dist/repertoire/tools-house-care.js +174 -0
  492. package/dist/repertoire/tools-mail.js +1828 -0
  493. package/dist/repertoire/tools-notes.js +501 -0
  494. package/dist/repertoire/tools-obligations.js +182 -0
  495. package/dist/repertoire/tools-orientation.js +31 -0
  496. package/dist/repertoire/tools-record.js +482 -0
  497. package/dist/repertoire/tools-rsvp.js +139 -0
  498. package/dist/repertoire/tools-runtime.js +238 -0
  499. package/dist/repertoire/tools-sanctuary-host.js +202 -0
  500. package/dist/repertoire/tools-session.js +1141 -0
  501. package/dist/repertoire/tools-shell.js +154 -0
  502. package/dist/repertoire/tools-shepherd-guidance.js +171 -0
  503. package/dist/repertoire/tools-shepherd.js +325 -0
  504. package/dist/repertoire/tools-steward-policy.js +147 -0
  505. package/dist/repertoire/tools-stripe.js +224 -0
  506. package/dist/repertoire/tools-surface.js +369 -0
  507. package/dist/repertoire/tools-teams.js +50 -39
  508. package/dist/repertoire/tools-telegram-contacts.js +37 -0
  509. package/dist/repertoire/tools-travel.js +125 -0
  510. package/dist/repertoire/tools-trip.js +982 -0
  511. package/dist/repertoire/tools-unraid.js +556 -0
  512. package/dist/repertoire/tools-user-profile.js +146 -0
  513. package/dist/repertoire/tools-vault.js +40 -0
  514. package/dist/repertoire/tools-voice-local.js +198 -0
  515. package/dist/repertoire/tools-voice.js +145 -0
  516. package/dist/repertoire/tools.js +518 -156
  517. package/dist/repertoire/travel-api-client.js +360 -0
  518. package/dist/repertoire/unraid-client.js +155 -0
  519. package/dist/repertoire/unraid-restart.js +339 -0
  520. package/dist/repertoire/user-profile.js +131 -0
  521. package/dist/repertoire/vault-setup.js +246 -0
  522. package/dist/repertoire/vault-unlock.js +686 -0
  523. package/dist/rsvp/aisleplanner-client.js +354 -0
  524. package/dist/rsvp/cli.js +936 -0
  525. package/dist/rsvp/config.js +327 -0
  526. package/dist/rsvp/cutover.js +453 -0
  527. package/dist/rsvp/diagnostics.js +232 -0
  528. package/dist/rsvp/diff-renderer.js +176 -0
  529. package/dist/rsvp/habit-policy.js +110 -0
  530. package/dist/rsvp/habit-stage.js +123 -0
  531. package/dist/rsvp/incident-bundle.js +97 -0
  532. package/dist/rsvp/migration.js +204 -0
  533. package/dist/rsvp/native-habit-runner.js +630 -0
  534. package/dist/rsvp/outbound-state.js +628 -0
  535. package/dist/rsvp/query.js +118 -0
  536. package/dist/rsvp/replay.js +233 -0
  537. package/dist/rsvp/snapshot.js +232 -0
  538. package/dist/rsvp/spend-ledger.js +175 -0
  539. package/dist/scripts/claude-code-hook.js +41 -0
  540. package/dist/scripts/claude-code-stop-hook.js +47 -0
  541. package/dist/senses/a2a-entry.js +132 -0
  542. package/dist/senses/acp-server.js +386 -0
  543. package/dist/senses/attention-queue.js +186 -0
  544. package/dist/senses/await-turn-message.js +65 -0
  545. package/dist/senses/bluebubbles/active-turns.js +216 -0
  546. package/dist/senses/bluebubbles/attachment-cache.js +53 -0
  547. package/dist/senses/bluebubbles/attachment-download.js +137 -0
  548. package/dist/senses/{bluebubbles-client.js → bluebubbles/client.js} +393 -43
  549. package/dist/senses/bluebubbles/context-packet.js +207 -0
  550. package/dist/senses/bluebubbles/context-smoke.js +144 -0
  551. package/dist/senses/bluebubbles/entry.js +90 -0
  552. package/dist/senses/bluebubbles/host-account-verifier.js +88 -0
  553. package/dist/senses/{bluebubbles-inbound-log.js → bluebubbles/inbound-log.js} +21 -3
  554. package/dist/senses/bluebubbles/index.js +4054 -0
  555. package/dist/senses/bluebubbles/latest-turn.js +311 -0
  556. package/dist/senses/{bluebubbles-media.js → bluebubbles/media.js} +121 -71
  557. package/dist/senses/{bluebubbles-model.js → bluebubbles/model.js} +159 -28
  558. package/dist/senses/{bluebubbles-mutation-log.js → bluebubbles/mutation-log.js} +24 -13
  559. package/dist/senses/bluebubbles/outbound-state.js +335 -0
  560. package/dist/senses/bluebubbles/processed-log.js +133 -0
  561. package/dist/senses/bluebubbles/reaction-policy.js +47 -0
  562. package/dist/senses/bluebubbles/replay.js +284 -0
  563. package/dist/senses/{bluebubbles-runtime-state.js → bluebubbles/runtime-state.js} +30 -2
  564. package/dist/senses/bluebubbles/semantic-receipts.js +1986 -0
  565. package/dist/senses/{bluebubbles-session-cleanup.js → bluebubbles/session-cleanup.js} +1 -1
  566. package/dist/senses/bluebubbles/webhook-registration.js +257 -0
  567. package/dist/senses/bluebubbles-meta-guard.js +39 -0
  568. package/dist/senses/cli/bracketed-paste.js +82 -0
  569. package/dist/senses/cli/image-paste.js +287 -0
  570. package/dist/senses/cli/image-ref-navigation.js +75 -0
  571. package/dist/senses/cli/ink-app.js +156 -0
  572. package/dist/senses/cli/inline-diff.js +64 -0
  573. package/dist/senses/cli/input-keys.js +174 -0
  574. package/dist/senses/cli/kill-ring.js +86 -0
  575. package/dist/senses/cli/message-list.js +51 -0
  576. package/dist/senses/cli/ouro-tui.js +607 -0
  577. package/dist/senses/cli/spinner-imperative.js +135 -0
  578. package/dist/senses/cli/spinner.js +101 -0
  579. package/dist/senses/cli/status-line.js +60 -0
  580. package/dist/senses/cli/streaming-markdown.js +526 -0
  581. package/dist/senses/cli/tool-display.js +85 -0
  582. package/dist/senses/cli/tool-render.js +85 -0
  583. package/dist/senses/cli/tui-store.js +241 -0
  584. package/dist/senses/cli/virtual-list.js +35 -0
  585. package/dist/senses/cli-entry.js +60 -8
  586. package/dist/senses/cli.js +570 -309
  587. package/dist/senses/commands.js +66 -3
  588. package/dist/senses/context-packet-ledger.js +371 -0
  589. package/dist/senses/context-packets.js +154 -0
  590. package/dist/senses/habit-lifecycle-guard.js +24 -0
  591. package/dist/senses/habit-turn-message.js +160 -0
  592. package/dist/senses/ingress-evidence.js +2 -0
  593. package/dist/senses/mail-entry.js +66 -0
  594. package/dist/senses/mail.js +524 -0
  595. package/dist/senses/pipeline.js +1158 -204
  596. package/dist/senses/private-runtime-worker.js +857 -0
  597. package/dist/senses/private-runtime.js +1627 -0
  598. package/dist/senses/proactive-content-guard.js +51 -0
  599. package/dist/senses/root-host-approval-port.js +345 -0
  600. package/dist/senses/root-host-approval-runtime.js +393 -0
  601. package/dist/senses/sanctuary-authority-resident.js +102 -0
  602. package/dist/senses/sanctuary-download-credit-presentation.js +31 -0
  603. package/dist/senses/sanctuary-full-visibility-contract.js +302 -0
  604. package/dist/senses/sanctuary-grounding.js +118 -0
  605. package/dist/senses/sanctuary-health-acceptance-probe-entry.js +12 -0
  606. package/dist/senses/sanctuary-health-acceptance-probe.js +786 -0
  607. package/dist/senses/sanctuary-health-runner.js +106 -0
  608. package/dist/senses/sanctuary-health.js +442 -0
  609. package/dist/senses/sanctuary-house.js +97 -0
  610. package/dist/senses/sanctuary-install-state-contract.js +169 -0
  611. package/dist/senses/sanctuary-interactive-control.js +410 -0
  612. package/dist/senses/sanctuary-media-catalog-contract.js +268 -0
  613. package/dist/senses/sanctuary-media-optimization.js +517 -0
  614. package/dist/senses/sanctuary-runtime.js +390 -0
  615. package/dist/senses/sanctuary-sab.js +85 -0
  616. package/dist/senses/sanctuary-storage-optimization-contract.js +32 -0
  617. package/dist/senses/shared-turn.js +832 -0
  618. package/dist/senses/shepherd/agent-presentation.js +88 -0
  619. package/dist/senses/shepherd/attention-page.js +166 -0
  620. package/dist/senses/shepherd/attention-sidebar.js +76 -0
  621. package/dist/senses/shepherd/attention.js +295 -0
  622. package/dist/senses/shepherd/checkin-config.js +76 -0
  623. package/dist/senses/shepherd/checkin-store.js +386 -0
  624. package/dist/senses/shepherd/client.js +196 -0
  625. package/dist/senses/shepherd/cmux.js +483 -0
  626. package/dist/senses/shepherd/composer.js +336 -0
  627. package/dist/senses/shepherd/copilot-app.js +648 -0
  628. package/dist/senses/shepherd/feedback-reply.js +108 -0
  629. package/dist/senses/shepherd/feedback-source.js +20 -0
  630. package/dist/senses/shepherd/feedback-store.js +331 -0
  631. package/dist/senses/shepherd/feedback-submit.js +182 -0
  632. package/dist/senses/shepherd/feedback-worker.js +208 -0
  633. package/dist/senses/shepherd/guidance.js +273 -0
  634. package/dist/senses/shepherd/herdr.js +427 -0
  635. package/dist/senses/shepherd/host.js +17 -0
  636. package/dist/senses/shepherd/human-decision-guard.js +29 -0
  637. package/dist/senses/shepherd/judge-evaluation-command.js +183 -0
  638. package/dist/senses/shepherd/judge-evaluation-entry.js +13 -0
  639. package/dist/senses/shepherd/judge-evaluation.js +155 -0
  640. package/dist/senses/shepherd/judge.js +210 -0
  641. package/dist/senses/shepherd/notice-outbox.js +262 -0
  642. package/dist/senses/shepherd/observation-publisher.js +320 -0
  643. package/dist/senses/shepherd/operations-defaults.js +86 -0
  644. package/dist/senses/shepherd/operations.js +159 -0
  645. package/dist/senses/shepherd/owner-notice.js +489 -0
  646. package/dist/senses/shepherd/prompt-state.js +60 -0
  647. package/dist/senses/shepherd/recovery.js +543 -0
  648. package/dist/senses/shepherd/redact.js +66 -0
  649. package/dist/senses/shepherd/returns.js +79 -0
  650. package/dist/senses/shepherd/sense.js +849 -0
  651. package/dist/senses/shepherd/shepherd-judge-corpus-v1.json +170 -0
  652. package/dist/senses/shepherd/surface.js +123 -0
  653. package/dist/senses/shepherd/target-binding.js +13 -0
  654. package/dist/senses/shepherd/terminal-process.js +147 -0
  655. package/dist/senses/shepherd-entry.js +98 -0
  656. package/dist/senses/surface-tool.js +109 -0
  657. package/dist/senses/teams-entry.js +68 -8
  658. package/dist/senses/teams-image.js +119 -0
  659. package/dist/senses/teams.js +998 -337
  660. package/dist/senses/telegram-admission.js +718 -0
  661. package/dist/senses/telegram-approval-runtime.js +793 -0
  662. package/dist/senses/telegram-attachments.js +186 -0
  663. package/dist/senses/telegram-audit-ledger.js +220 -0
  664. package/dist/senses/telegram-authority-transport.js +231 -0
  665. package/dist/senses/telegram-client.js +1755 -0
  666. package/dist/senses/telegram-effect-adapter.js +904 -0
  667. package/dist/senses/telegram-entry.js +83 -0
  668. package/dist/senses/telegram-handoff.js +154 -0
  669. package/dist/senses/telegram.js +2474 -0
  670. package/dist/senses/trust-gate.js +104 -9
  671. package/dist/senses/voice/audio-playback.js +237 -0
  672. package/dist/senses/voice/audio-routing.js +119 -0
  673. package/dist/senses/voice/call-auth.js +250 -0
  674. package/dist/senses/voice/elevenlabs.js +202 -0
  675. package/dist/senses/voice/floor-control.js +431 -0
  676. package/dist/senses/voice/floor-controller.js +115 -0
  677. package/dist/senses/voice/golden-path.js +116 -0
  678. package/dist/senses/voice/index.js +29 -0
  679. package/dist/senses/voice/local-audio-control.js +344 -0
  680. package/dist/senses/voice/local-audio-devices.js +516 -0
  681. package/dist/senses/voice/local-audio-disclosure.js +174 -0
  682. package/dist/senses/voice/local-audio-identity.js +103 -0
  683. package/dist/senses/voice/local-audio-join.js +325 -0
  684. package/dist/senses/voice/local-audio-mute.js +199 -0
  685. package/dist/senses/voice/local-audio-notify.js +124 -0
  686. package/dist/senses/voice/local-audio-prompts.js +116 -0
  687. package/dist/senses/voice/local-audio-transport.js +427 -0
  688. package/dist/senses/voice/local-audio.js +281 -0
  689. package/dist/senses/voice/local-media-stream.js +145 -0
  690. package/dist/senses/voice/meeting.js +113 -0
  691. package/dist/senses/voice/outbound.js +190 -0
  692. package/dist/senses/voice/phone.js +33 -0
  693. package/dist/senses/voice/playback.js +139 -0
  694. package/dist/senses/voice/process-guards.js +51 -0
  695. package/dist/senses/voice/realtime-eval.js +496 -0
  696. package/dist/senses/voice/realtime-tool-schema.js +195 -0
  697. package/dist/senses/voice/realtime-trace.js +531 -0
  698. package/dist/senses/voice/transcript.js +70 -0
  699. package/dist/senses/voice/turn.js +191 -0
  700. package/dist/senses/voice/twilio-phone-runtime.js +836 -0
  701. package/dist/senses/voice/twilio-phone.js +5454 -0
  702. package/dist/senses/voice/types.js +2 -0
  703. package/dist/senses/voice/whisper.js +161 -0
  704. package/dist/senses/voice-entry.js +83 -0
  705. package/dist/senses/voice-realtime-eval-command.js +99 -0
  706. package/dist/senses/voice-realtime-eval-entry.js +21 -0
  707. package/dist/senses/voice-twilio-entry.js +87 -0
  708. package/dist/trips/core.js +138 -0
  709. package/dist/trips/store.js +265 -0
  710. package/dist/util/frontmatter.js +69 -0
  711. package/npm-shrinkwrap.json +8342 -0
  712. package/package.json +76 -13
  713. package/skills/agent-commerce.md +113 -0
  714. package/skills/browser-navigation.md +117 -0
  715. package/skills/commerce-setup-guide.md +116 -0
  716. package/skills/commerce-setup.md +84 -0
  717. package/skills/configure-dev-tools.md +99 -0
  718. package/skills/travel-planning.md +138 -0
  719. package/dist/heart/daemon/auth-flow.js +0 -430
  720. package/dist/heart/daemon/ouro-path-installer.js +0 -260
  721. package/dist/heart/daemon/ouro-version-manager.js +0 -171
  722. package/dist/heart/daemon/update-hooks.js +0 -138
  723. package/dist/heart/obligations.js +0 -191
  724. package/dist/heart/safe-workspace.js +0 -381
  725. package/dist/heart/session-recall.js +0 -116
  726. package/dist/mind/friends/channel.js +0 -84
  727. package/dist/mind/friends/group-context.js +0 -144
  728. package/dist/mind/friends/resolver.js +0 -84
  729. package/dist/mind/friends/store-file.js +0 -190
  730. package/dist/mind/friends/store.js +0 -4
  731. package/dist/mind/friends/tokens.js +0 -26
  732. package/dist/mind/friends/trust-explanation.js +0 -74
  733. package/dist/mind/friends/types.js +0 -29
  734. package/dist/repertoire/tasks/board.js +0 -134
  735. package/dist/repertoire/tasks/index.js +0 -224
  736. package/dist/repertoire/tasks/lifecycle.js +0 -80
  737. package/dist/repertoire/tasks/middleware.js +0 -65
  738. package/dist/repertoire/tasks/parser.js +0 -173
  739. package/dist/repertoire/tasks/scanner.js +0 -132
  740. package/dist/repertoire/tasks/transitions.js +0 -144
  741. package/dist/senses/bluebubbles-entry.js +0 -13
  742. package/dist/senses/bluebubbles.js +0 -1181
  743. package/dist/senses/debug-activity.js +0 -154
  744. package/dist/senses/inner-dialog-worker.js +0 -90
  745. package/dist/senses/inner-dialog.js +0 -525
  746. package/subagents/README.md +0 -7
  747. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
  748. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
  749. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
  750. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
  751. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
  752. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +0 -0
  753. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
  754. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
  755. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
  756. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
  757. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
  758. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
  759. /package/dist/{repertoire/tasks/types.js → heart/attachments/sources/adapter.js} +0 -0
  760. /package/dist/heart/{daemon → hatch}/hatch-animation.js +0 -0
  761. /package/dist/heart/{daemon → hatch}/specialist-orchestrator.js +0 -0
  762. /package/dist/heart/{daemon → versioning}/ouro-uti.js +0 -0
  763. /package/dist/heart/{daemon → versioning}/wrapper-publish-guard.js +0 -0
@@ -0,0 +1,1377 @@
1
+ #!/usr/local/bin/node
2
+ // Sanctuary Butler automated update path.
3
+ //
4
+ // One tool for the whole upgrade, built after a manual upgrade attempt took the
5
+ // household's Telegram bot down twice: once from a hand-rolled migration, once
6
+ // from a shipped-install defect discovered only AFTER the bot token had been
7
+ // revoked. The lesson both times was the same — verify the end state, and prove
8
+ // the upgrade can succeed BEFORE doing anything irreversible.
9
+ //
10
+ // Phases (each idempotent, each stops on the first failure):
11
+ // preflight read-only rehearsal. Proves the install can succeed and touches
12
+ // nothing. Its centerpiece is the fenced vault-read check (D-018):
13
+ // the exact operation that failed the last upgrade, run before the
14
+ // token is ever rotated.
15
+ // prepare extract the package, build the manifest + request, stage inputs,
16
+ // verify against the installer's own rules. Writes only to the
17
+ // authority staging area; the running Butler is untouched.
18
+ // install drive the DockerMan authority transaction to completion, with
19
+ // automatic rollback on ANY failure so the Butler is never left
20
+ // down. Requires a freshly rotated token at incoming-token.
21
+ // verify health + preservation (Jellyfin, steward policy) + readback.
22
+ // upgrade in-place upgrade of an INSTALLED authority (D-043): same epoch,
23
+ // token and cursor; new package, pins and resident image. The new
24
+ // package's own lifecycle does the work and rolls back exactly on
25
+ // any failure. `--rehearse <step>` stops after that step and rolls
26
+ // back, proving the rollback on real hardware. No token rotation.
27
+ //
28
+ // Run `preflight` and `prepare` freely. `install` is the first-install phase and
29
+ // refuses without a rotated incoming-token. Run `upgrade` detached so an SSH drop
30
+ // cannot interrupt it: setsid nohup node <this> upgrade <version> > /var/log/ouro-upgrade.log 2>&1 &
31
+ //
32
+ // An `upgrade` ends with the replay gate (sanctuary-replay-gate.mjs): the new Butler is held UNCOMMITTED (the lifecycle
33
+ // keeps its journal and the predecessor's records, because a committed upgrade cannot be rolled back), the gate replays
34
+ // real requests over A2A and reads the outcome back from machine state, and only a pass commits, pins the template and
35
+ // prunes images. A failed gate runs `upgrade-rollback`, confirms the predecessor is healthy, and exits 1.
36
+ // --no-gate skip the gate and commit straight away (emergencies; logged loudly)
37
+ // --plant <case> make that gate case fail on purpose, proving the rollback end to end
38
+ // verify --gate run the gate against the live version without upgrading
39
+ //
40
+ // A fresh install (a host that never ran a Butler) passes --fresh to preflight and prepare. prepare --fresh takes the
41
+ // bot id from Telegram getMe on the token already at incoming-token and the owner from --owner-id, and writes
42
+ // predecessorContract "none" into request.json; install, upgrade and verify read that contract from there.
43
+ //
44
+ // Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|verify|upgrade> <version> [--rehearse <step>] [--no-gate] [--plant <case>]
45
+ // sanctuary-butler-upgrade.mjs preflight <version> --fresh
46
+ // sanctuary-butler-upgrade.mjs prepare <version> --fresh --owner-id <telegram user id>
47
+ // sanctuary-butler-upgrade.mjs verify [--gate]
48
+
49
+ import { execFileSync, spawn, spawnSync } from "node:child_process"
50
+ import { createHash } from "node:crypto"
51
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, lstatSync, chmodSync, realpathSync, readdirSync } from "node:fs"
52
+ import { fileURLToPath } from "node:url"
53
+ import { createRequire } from "node:module"
54
+ import { tmpdir } from "node:os"
55
+ import { join } from "node:path"
56
+
57
+ const ROOT = "/mnt/user/appdata/ouro-authority"
58
+ const BUNDLE = "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro"
59
+ const RUNTIME = "/mnt/user/appdata/ouro-butler/runtime/.ouro-cli"
60
+ const CONTAINER = "ouro-butler"
61
+ const TEMPLATE = "/boot/config/plugins/dockerMan/templates-user/my-ouro-butler.xml"
62
+ const PSYCHE_TARGET = "/home/ouro/AgentBundles/sanctuary.ouro/psyche"
63
+ const JOURNAL = "/boot/config/custom/ouro-butler/docker-man-template-transaction.json"
64
+ const POLICY = `${BUNDLE}/state/policy/steward.json`
65
+ const PRIMITIVES = ["/usr/local/bin/node", "/bin/sh", "/usr/bin/prlimit", "/usr/bin/setsid"]
66
+ const REQUIRED_PROGRAMS = [
67
+ "dist/heart/daemon/sanctuary-telegram-authority-entry.js",
68
+ "dist/heart/daemon/sanctuary-authority-root-lifecycle.js",
69
+ "dist/heart/daemon/sanctuary-host-supervisor-entry.js",
70
+ "deploy/unraid/sanctuary-host-launcher.sh",
71
+ "deploy/unraid/sanctuary-authority-service.sh",
72
+ ]
73
+ const MIN_FREE_GB = 2
74
+ const INCOMING_MANIFEST = `${ROOT}/incoming-package-manifest.json`
75
+ const INCOMING_REQUEST = `${ROOT}/incoming-request.json`
76
+ const UPGRADE_JOURNAL = `${ROOT}/upgrade.json`
77
+ // Host supervision skips its work while this flag is fresh (< 30 min), so a crashed
78
+ // upgrade can never leave self-heal off for longer than that. A paused upgrade keeps it
79
+ // fresh with a heartbeat (holdSupervision), because the replay gate alone takes about an hour.
80
+ const MAINTENANCE = "/run/ouro-authority-maintenance"
81
+ // The stop hook's flag: every keeper watchdog version stands down while it exists.
82
+ const SHUTDOWN_FLAG = "/run/ouro-authority-shutdown"
83
+ const GATE_SCRIPT = `${ROOT}/package/deploy/unraid/sanctuary-replay-gate.mjs`
84
+ // The replay clients' seeds and provisioning record: a root-owned host folder outside the bundle, which the Butler's user cannot write.
85
+ const GATE_PROVISION = "/mnt/user/appdata/ouro-butler/replay-client/provision.json"
86
+ // The whole gate (every case, including the eight-minute await wait) must finish well inside this; a hung gate is a failed gate.
87
+ export const GATE_TIMEOUT_MS = 95 * 60 * 1000
88
+ const UPGRADE_STEPS = ["stop", "switch", "resident", "migrate", "start"]
89
+
90
+ const sh = (file, args, opts = {}) => execFileSync(file, args, { encoding: "utf8", maxBuffer: 64 << 20, ...opts })
91
+ const docker = (args, opts = {}) => sh("/usr/bin/docker", args, opts)
92
+ const image = (version) => `ghcr.io/ourostack/ouroboros-butler:${version}`
93
+
94
+ // Unraid's cgroup2-unraid daemon watches /sys/fs/cgroup via inotify and rmdir's any
95
+ // cgroup that reports `populated 0`. The authority install creates its cgroup empty and
96
+ // keeps it empty through staging/verification (which asserts cgroup.procs === ""), so the
97
+ // reaper deletes /sys/fs/cgroup/ouro-authority within ~2s and stage fails ENOENT on
98
+ // cgroup.controllers. Pausing the reaper (SIGSTOP) lets the empty cgroup survive until the
99
+ // gateway process populates it; we always resume it (SIGCONT) afterwards.
100
+ let REAPER_PAUSED = false
101
+ function reaperPid() {
102
+ try { const p = readFileSync("/run/cgroup2-unraid.pid", "utf8").trim(); if (/^[0-9]+$/.test(p) && existsSync(`/proc/${p}`)) return p } catch { /* fall through */ }
103
+ try { const p = sh("/bin/sh", ["-c", "pgrep -f 'cgroup2-unraid --daemon' | head -1"]).trim(); return /^[0-9]+$/.test(p) ? p : null } catch { return null }
104
+ }
105
+ function pauseCgroupReaper() {
106
+ const pid = reaperPid()
107
+ if (!pid) { console.log(" note: cgroup2-unraid reaper not found; nothing to pause"); return }
108
+ try { sh("/bin/kill", ["-STOP", pid]); REAPER_PAUSED = true; ok(`paused cgroup2-unraid reaper (pid ${pid}) so the authority cgroup survives staging`) }
109
+ catch (e) { console.log(` note: could not pause cgroup2-unraid (${e.message}); install may fail on empty-cgroup reaping`) }
110
+ }
111
+ function resumeCgroupReaper() {
112
+ if (!REAPER_PAUSED) return
113
+ const pid = reaperPid()
114
+ if (pid) { try { sh("/bin/kill", ["-CONT", pid]); ok(`resumed cgroup2-unraid reaper (pid ${pid})`) } catch (e) { console.log(` WARN: failed to resume cgroup2-unraid (${e.message}); run: kill -CONT ${pid}`) } }
115
+ REAPER_PAUSED = false
116
+ }
117
+ const digest = (b) => `sha256:${createHash("sha256").update(b).digest("hex")}`
118
+
119
+ let RED = 0
120
+ const ok = (m) => console.log(` ok ${m}`)
121
+ const bad = (m) => { console.log(` FAIL ${m}`); RED += 1 }
122
+ const say = (m) => console.log(`\n== ${m}`)
123
+
124
+ function requireRoot() {
125
+ if (process.getuid() !== 0) { console.error("must run as root"); process.exit(2) }
126
+ }
127
+
128
+ function imageId(version) {
129
+ try { return docker(["image", "inspect", image(version), "--format", "{{.Id}}"], { stdio: ["ignore", "pipe", "ignore"] }).trim() }
130
+ catch { return null }
131
+ }
132
+
133
+ // ---- read-only rehearsal --------------------------------------------------
134
+
135
+ function preflight(version, { fresh = false } = {}) {
136
+ say(`preflight for ${version}${fresh ? " (fresh install)" : ""} (read-only; nothing is changed)`)
137
+
138
+ say("target image present and pulled")
139
+ let id = imageId(version)
140
+ if (!id) { try { docker(["pull", image(version)]); id = imageId(version) } catch { /* reported below */ } }
141
+ id ? ok(`image ${id.slice(0, 19)}…`) : bad(`image ${image(version)} not present and could not be pulled`)
142
+
143
+ say(fresh ? "no Butler on this host yet" : "running Butler and rollback material")
144
+ let runningImage = null
145
+ if (fresh) {
146
+ containerExists(CONTAINER) ? bad(`an ${CONTAINER} container already exists; a fresh install needs a host without one`) : ok(`no ${CONTAINER} container`)
147
+ existsSync(`${ROOT}/active.json`) ? bad("an authority is installed; this host is not fresh") : ok("no installed authority")
148
+ } else try {
149
+ runningImage = docker(["inspect", CONTAINER, "--format", "{{.Image}}"], { stdio: ["ignore", "pipe", "ignore"] }).trim()
150
+ ok(`running image ${runningImage.slice(0, 19)}… (this is the rollback target)`)
151
+ } catch { bad(`the ${CONTAINER} container is absent`) }
152
+ if (id && runningImage && id === runningImage) bad("target and running image are identical — nothing to upgrade")
153
+
154
+ const installed = !fresh && existsSync(`${ROOT}/active.json`)
155
+ if (installed) console.log(" note: an authority is installed, so this is an in-place upgrade (run `upgrade`, no token rotation)")
156
+
157
+ say("no stale authority runtime state")
158
+ if (!installed) {
159
+ const stale = ["/sys/fs/cgroup/ouro-authority", "/var/lib/ouro-authority", "/run/ouro-authority", `${ROOT}/epochs`].filter((d) => existsSync(d))
160
+ if (stale.length && !existsSync(`${ROOT}/active.json`)) console.log(` note: stale dirs present, install will clear them: ${stale.join(", ")}`)
161
+ else if (!stale.length) ok("no stale authority runtime dirs")
162
+ else bad(`stale dirs present with a live authority: ${stale.join(", ")}`)
163
+ }
164
+
165
+ say("no upgrade already in flight")
166
+ existsSync(JOURNAL) ? bad(`a template transaction journal is pending at ${JOURNAL}; resolve it first`) : ok("no pending template transaction")
167
+ if (installed) existsSync(UPGRADE_JOURNAL) ? console.log(" note: an in-place upgrade is pending; `upgrade` resumes it") : ok("no pending in-place upgrade")
168
+ if (installed && existsSync(`${ROOT}/incoming-token`)) console.log(" note: a stale incoming-token copy is present; the upgrade removes it (D-044)")
169
+
170
+ say("host primitives (root-owned, not group/world-writable)")
171
+ for (const f of PRIMITIVES) {
172
+ try {
173
+ const [uid, gid, mode] = sh("/usr/bin/stat", ["-Lc", "%u %g %a", f]).trim().split(" ")
174
+ if (uid === "0" && gid === "0" && (parseInt(mode, 8) & 0o022) === 0) ok(`${f} ${uid}:${gid} ${mode}`)
175
+ else bad(`${f} is ${uid}:${gid} ${mode} — must be root-owned and not group/world-writable`)
176
+ } catch { bad(`${f} is missing`) }
177
+ }
178
+
179
+ say("delegated-command grants the Butler will still honour")
180
+ {
181
+ const friendRecords = {}
182
+ try {
183
+ for (const file of readdirSync(`${BUNDLE}/friends`).filter((name) => name.endsWith(".json"))) {
184
+ try { friendRecords[file.replace(/\.json$/u, "")] = JSON.parse(readFileSync(`${BUNDLE}/friends/${file}`, "utf8")) } catch { /* unreadable record: not a grant source */ }
185
+ }
186
+ } catch { /* no friends folder yet */ }
187
+ let trustedGrants = {}
188
+ try { trustedGrants = JSON.parse(readFileSync(`${TRUST_HOST_DIR}/delegated-command-grants.json`, "utf8")).grants ?? {} } catch { /* no trusted grants yet */ }
189
+ const warnings = legacyDelegationGrantWarnings(friendRecords, trustedGrants)
190
+ warnings.length === 0 ? ok("no friend record relies on the old delegationGrant") : warnings.forEach((line) => console.log(` WARNING: ${line}`))
191
+ }
192
+
193
+ say("escalation holders the upgrade would drop")
194
+ {
195
+ const dropped = escalationGrantProblems()
196
+ dropped.length === 0 ? ok("every escalation holder in the bundle is already in the trusted file") : dropped.forEach((line) => bad(line))
197
+ }
198
+
199
+ say("required programs in the target image")
200
+ try {
201
+ const missing = REQUIRED_PROGRAMS.filter((p) => {
202
+ try { docker(["run", "--rm", "--entrypoint", "/bin/sh", image(version), "-c", `test -f /opt/ouro/${p}`]); return false }
203
+ catch { return true }
204
+ })
205
+ missing.length ? bad(`image is missing: ${missing.join(", ")}`) : ok(`all ${REQUIRED_PROGRAMS.length} authority programs present`)
206
+ } catch { bad("could not inspect the image contents") }
207
+
208
+ say("D-018: the fenced vault read the install depends on")
209
+ if (installed) ok("not needed: the installed gateway already holds the token in root custody")
210
+ else if (fresh) ok("not needed: a fresh install hands the new token straight to the root authority")
211
+ else if (id) {
212
+ // Run the read fenced exactly as THIS version's install will fence it, so
213
+ // the rehearsal is faithful: an unfixed image is tested without the fix and
214
+ // correctly fails here rather than during a real install.
215
+ const hasFix = targetHasVaultFix(version)
216
+ console.log(` note: target ${hasFix ? "carries" : "does NOT carry"} the D-018 fenced-read fix`)
217
+ try {
218
+ const out = JSON.parse(fencedVaultRead(version, hasFix))
219
+ out.tokenPresent === true
220
+ ? ok("fenced root vault read works with this image's own fencing")
221
+ : bad(`fenced vault read returned ${JSON.stringify(out)} — expected {tokenPresent:true}`)
222
+ } catch (e) {
223
+ bad(`fenced vault read FAILED: ${String(e.message).split("\n")[0]} — this image cannot read its own credentials; installing it would strand Telegram (this is exactly what happened on the last upgrade)`)
224
+ }
225
+ } else bad("skipped (no image)")
226
+
227
+ say("disk headroom")
228
+ try {
229
+ const freeKb = Number(sh("/bin/df", ["-Pk", "/mnt/user/appdata"]).trim().split("\n").at(-1).split(/\s+/)[3])
230
+ const freeGb = freeKb / 1024 / 1024
231
+ freeGb >= MIN_FREE_GB ? ok(`${freeGb.toFixed(1)} GB free on appdata`) : bad(`only ${freeGb.toFixed(1)} GB free (want ≥ ${MIN_FREE_GB})`)
232
+ } catch { bad("could not read disk free space") }
233
+
234
+ say("preservation baselines readable")
235
+ existsSync(POLICY) ? ok(`steward policy present (${sha12(POLICY)})`) : fresh ? ok("no steward policy yet (fresh install)") : bad(`steward policy missing at ${POLICY}`)
236
+ try { ok(jellyfinState() ? "jellyfin inspectable for the unchanged-check" : "no jellyfin container; it must stay absent") }
237
+ catch { bad("docker could not list or inspect the jellyfin container") }
238
+
239
+ console.log("")
240
+ if (RED === 0) console.log(installed ? "PREFLIGHT GREEN — run `upgrade` (detached)." : fresh ? "PREFLIGHT GREEN — next: put the bot token at incoming-token, prepare --fresh, install." : "PREFLIGHT GREEN — the upgrade can proceed. Next: prepare, rotate the token, install.")
241
+ else console.log(`PREFLIGHT RED — ${RED} blocker(s) above. Nothing was changed; fix these before rotating the token.`)
242
+ process.exit(RED === 0 ? 0 : 1)
243
+ }
244
+
245
+ // Does the target image's own #vault carry the D-018 fix? Read it from the image
246
+ // so the rehearsal fences the read the same way the real install will.
247
+ function targetHasVaultFix(version) {
248
+ try {
249
+ const src = docker(["run", "--rm", "--entrypoint", "/bin/cat", image(version),
250
+ "/opt/ouro/dist/heart/daemon/sanctuary-authority-root-lifecycle.js"], { stdio: ["ignore", "pipe", "ignore"] })
251
+ return src.includes("--cap-add=DAC_OVERRIDE") && src.includes("/home/ouro/.bw-src")
252
+ } catch { return false }
253
+ }
254
+
255
+ const CLI = "/opt/ouro/dist/heart/daemon/sanctuary-authority-root-lifecycle.js"
256
+ // The fenced vault read, replicating the target version's own #vault fencing.
257
+ function fencedVaultRead(version, hasFix) {
258
+ const base = ["run", "--rm", "-i", "--pull=never", "--network", "host", "--user", "0:0", "--read-only",
259
+ "--cap-drop=ALL", "--security-opt=no-new-privileges"]
260
+ const args = hasFix
261
+ ? [...base, "--cap-add=DAC_OVERRIDE", "--entrypoint", "/bin/sh",
262
+ "--mount", `type=bind,src=${RUNTIME},dst=/home/ouro/.ouro-cli,readonly`,
263
+ "--mount", `type=bind,src=${RUNTIME}/bitwarden,dst=/home/ouro/.bw-src,readonly`,
264
+ "--mount", `type=bind,src=${BUNDLE},dst=/home/ouro/AgentBundles/sanctuary.ouro,readonly`,
265
+ "--tmpfs", "/home/ouro/.ouro-cli/bitwarden:rw,nosuid,nodev,noexec,mode=0700",
266
+ "--tmpfs", "/home/ouro/.config:rw,nosuid,nodev,noexec,mode=0700",
267
+ "--tmpfs", "/tmp:rw,nosuid,nodev,noexec,mode=0700",
268
+ image(version), "-c", `cp -r /home/ouro/.bw-src/. /home/ouro/.ouro-cli/bitwarden/ && exec /usr/local/bin/node ${CLI} vault presence`]
269
+ : [...base, "--entrypoint", "/usr/local/bin/node",
270
+ "--mount", `type=bind,src=${RUNTIME},dst=/home/ouro/.ouro-cli,readonly`,
271
+ "--mount", `type=bind,src=${BUNDLE},dst=/home/ouro/AgentBundles/sanctuary.ouro,readonly`,
272
+ "--tmpfs", "/home/ouro/.ouro-cli/bitwarden:rw,nosuid,nodev,noexec,mode=0700",
273
+ "--tmpfs", "/tmp:rw,nosuid,nodev,noexec,mode=0700",
274
+ image(version), CLI, "vault", "presence"]
275
+ return docker(args, { stdio: ["pipe", "pipe", "pipe"], input: "" })
276
+ }
277
+
278
+ function sha12(path) { return digest(readFileSync(path)).slice(7, 19) }
279
+
280
+ // ---- fresh install helpers (a host with no predecessor Butler) -------------
281
+
282
+ /** The root request for a fresh install: the bot comes from Telegram getMe on the new token, the owner from the installer. */
283
+ export function freshInstallRequest({ epochId, botId, ownerId, pins }) {
284
+ if (!/^[1-9][0-9]*$/u.test(String(botId))) throw new Error("the bot id from Telegram is invalid")
285
+ if (!/^[1-9][0-9]*$/u.test(String(ownerId))) throw new Error("the owner's Telegram id must be a positive number")
286
+ return { schemaVersion: 1, epochId, botId: String(botId), ownerUserId: String(ownerId), ownerChatId: String(ownerId), ...pins, predecessorContract: "none" }
287
+ }
288
+ /** The bot id a Telegram getMe answer names, checked against the token's own prefix. */
289
+ export function botIdFromGetMe(body, token) {
290
+ const id = body?.ok === true && Number.isSafeInteger(body.result?.id) ? String(body.result.id) : null
291
+ if (!id) throw new Error("Telegram did not accept the bot token (getMe failed)")
292
+ if (String(token).split(":")[0] !== id) throw new Error("the bot token does not belong to the bot Telegram names")
293
+ return id
294
+ }
295
+ /** The incoming request for an in-place upgrade: the same identity, new pins, and a fresh install's contract carried forward. */
296
+ export function upgradeRequest(current, pins) {
297
+ return {
298
+ schemaVersion: 1, epochId: current.epochId, botId: current.botId, ownerUserId: current.ownerUserId, ownerChatId: current.ownerChatId, ...pins,
299
+ ...(current.predecessorContract === "none" ? { predecessorContract: "none" } : {}),
300
+ }
301
+ }
302
+ /** Whether a preserved file stayed as it was; one that was absent before had nothing to preserve. */
303
+ export function unchanged(before, after) { return before === null || before === after }
304
+ function containerExists(name) { return docker(["container", "ls", "--all", "--quiet", "--filter", `name=^/${name}$`]).trim() !== "" }
305
+ /** Jellyfin's identity for the unchanged-check, or null on a host without Jellyfin (it must then stay absent). */
306
+ function jellyfinState() {
307
+ return containerExists("jellyfin") ? docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim() : null
308
+ }
309
+ function policyState() { return existsSync(POLICY) ? sha12(POLICY) : null }
310
+ function installedRequest() { try { return JSON.parse(readFileSync(`${ROOT}/request.json`, "utf8")) } catch { return null } }
311
+
312
+ // ---- prepare (idempotent staging; running Butler untouched) ---------------
313
+
314
+ async function prepare(version, { fresh = false, ownerId } = {}) {
315
+ say(`prepare inputs for ${version}${fresh ? " (fresh install)" : ""}`)
316
+ const id = imageId(version)
317
+ if (!id) fail(`image ${image(version)} is not present; pull it first`)
318
+ if (existsSync(`${ROOT}/active.json`)) fail("an authority is installed; use `upgrade` (in place, no token rotation) instead of prepare/install")
319
+ let botId = null
320
+ if (fresh) {
321
+ if (containerExists(CONTAINER)) fail(`an ${CONTAINER} container already exists; a fresh install needs a host without one`)
322
+ const tokenPath = `${ROOT}/incoming-token`
323
+ if (!existsSync(tokenPath) || statSync(tokenPath).size === 0) fail(`no bot token at ${tokenPath}; the installer puts it there first`)
324
+ const token = readFileSync(tokenPath, "utf8").trim()
325
+ const body = await fetch(`https://api.telegram.org/bot${token}/getMe`).then((response) => response.json()).catch(() => null)
326
+ try { botId = botIdFromGetMe(body, token) } catch (error) { fail(error.message) }
327
+ ok(`Telegram knows the bot (id ${botId})`)
328
+ }
329
+ const epochId = `${version.replace(/[^A-Za-z0-9_-]/g, "-")}-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}`
330
+ const manifestBytes = stageIncomingPackage(version)
331
+ writePrivate(`${ROOT}/package-manifest.json`, manifestBytes)
332
+ let request
333
+ if (fresh) {
334
+ try { request = freshInstallRequest({ epochId, botId, ownerId, pins: packagePins(manifestBytes) }) } catch (error) { fail(error.message) }
335
+ } else {
336
+ const creds = JSON.parse(readFileSync(`/mnt/user/appdata/ouro-butler/runtime/container-credentials.json`, "utf8")).credentials[0].runtimeConfig
337
+ request = {
338
+ schemaVersion: 1, epochId,
339
+ botId: String(creds.telegramBotToken).split(":")[0],
340
+ ownerUserId: String(creds.telegramAuthorizedUserId), ownerChatId: String(creds.telegramAuthorizedChatId),
341
+ ...packagePins(manifestBytes),
342
+ }
343
+ }
344
+ if (request.ownerUserId !== request.ownerChatId) fail("owner user and chat must match")
345
+ writePrivate(`${ROOT}/request.json`, Buffer.from(JSON.stringify(request)))
346
+ ok(`epoch ${epochId}, package ${request.packageDigest.slice(7, 19)}…`)
347
+
348
+ say("verify the prepared package against the installer's own rules")
349
+ verifyPackage(`${ROOT}/incoming-package`, `${ROOT}/package-manifest.json`, `${ROOT}/request.json`)
350
+ ok("package verifies")
351
+
352
+ console.log(fresh ? "\nPREPARE done. Next: install." : "\nPREPARE done. Next: rotate the bot token into ${ROOT}/incoming-token, then install.".replace("${ROOT}", ROOT))
353
+ }
354
+
355
+ // The package digest plus the host primitive pins every request carries.
356
+ function packagePins(manifestBytes) {
357
+ const d = (f) => digest(readFileSync(f))
358
+ return {
359
+ packageDigest: digest(manifestBytes),
360
+ nodeDigest: d("/usr/local/bin/node"), prlimitDigest: d("/usr/bin/prlimit"),
361
+ setsidDigest: d("/usr/bin/setsid"), shellDigest: d(sh("/bin/readlink", ["-f", "/bin/sh"]).trim()),
362
+ }
363
+ }
364
+
365
+ // Extract the exact package from the image into incoming-package, normalised to the
366
+ // installer's rules, and return its manifest bytes. The running Butler is untouched.
367
+ function stageIncomingPackage(version) {
368
+ const incoming = `${ROOT}/incoming-package`
369
+
370
+ say("extract the exact package from the image")
371
+ sh("/bin/rm", ["-rf", incoming]); sh("/bin/mkdir", ["-p", incoming])
372
+ const cid = docker(["create", "--entrypoint", "/bin/sh", image(version)]).trim()
373
+ try { docker(["cp", `${cid}:/opt/ouro/.`, `${incoming}/`]) } finally { docker(["rm", "-f", cid]) }
374
+ ok(`extracted ${sh("/usr/bin/find", [incoming, "-type", "f"]).trim().split("\n").length} files`)
375
+
376
+ say("normalise: drop symlinks, root-own, dirs 700, files 644/755, break hard links")
377
+ sh("/usr/bin/find", [incoming, "-type", "l", "-delete"])
378
+ sh("/bin/chown", ["-R", "0:0", incoming])
379
+ sh("/usr/bin/find", [incoming, "-type", "d", "-exec", "chmod", "700", "{}", "+"])
380
+ sh("/bin/sh", ["-c", `find ${incoming} -type f ! -perm 755 -exec chmod 644 {} +`])
381
+ const linked = sh("/bin/sh", ["-c", `find ${incoming} -type f -links +1 | wc -l`]).trim()
382
+ if (linked !== "0") sh("/bin/sh", ["-c", `find ${incoming} -type f -links +1 -exec sh -c 'cp -p "$1" "$1.u" && mv -f "$1.u" "$1"' _ {} \\;`])
383
+ ok("normalised")
384
+
385
+ say("required programs survived")
386
+ for (const p of REQUIRED_PROGRAMS) if (!existsSync(`${incoming}/${p}`)) fail(`missing after normalise: ${p}`)
387
+ ok("all present")
388
+
389
+ say("build package manifest + request")
390
+ const files = {}
391
+ const allowed = new Set([0o600, 0o644, 0o700, 0o755])
392
+ const walk = (rel) => {
393
+ for (const entry of sh("/bin/sh", ["-c", `cd ${incoming} && find ${rel || "."} -maxdepth 1 -mindepth 1 -printf '%y %f\n'`]).trim().split("\n").filter(Boolean)) {
394
+ const [type, ...nameParts] = entry.split(" "); const name = (rel ? `${rel}/` : "") + nameParts.join(" ")
395
+ if (type === "d") walk(name)
396
+ else if (type === "f") {
397
+ const mode = parseInt(sh("/usr/bin/stat", ["-c", "%a", `${incoming}/${name}`]).trim(), 8)
398
+ if (!allowed.has(mode)) fail(`mode ${mode.toString(8)} not allowed: ${name}`)
399
+ files[name] = { digest: digest(readFileSync(`${incoming}/${name}`)), mode }
400
+ }
401
+ }
402
+ }
403
+ walk("")
404
+ ok(`${Object.keys(files).length} files pinned`)
405
+ return Buffer.from(JSON.stringify({ schemaVersion: 1, files }))
406
+ }
407
+
408
+ function writePrivate(path, bytes) { writeFileSync(path, bytes, { mode: 0o600 }); sh("/bin/chown", ["0:0", path]); chmodSync(path, 0o600) }
409
+
410
+ // A faithful re-check of the installer's verifySanctuaryAuthorityInstallation
411
+ // package rules, so prepare fails loudly rather than the install failing late.
412
+ function verifyPackage(pkg, manifestPath, requestPath) {
413
+ const canonical = (p) => { if (sh("/bin/readlink", ["-f", p]).trim() !== p) fail(`not canonical: ${p}`) }
414
+ const dirMode = (p) => parseInt(sh("/usr/bin/stat", ["-c", "%a", p]).trim(), 8)
415
+ canonical(pkg); if (dirMode(pkg) !== 0o700) fail(`package root must be 0700, is ${dirMode(pkg).toString(8)}`)
416
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"))
417
+ const request = JSON.parse(readFileSync(requestPath, "utf8"))
418
+ if (digest(readFileSync(manifestPath)) !== request.packageDigest) fail("manifest digest != request.packageDigest")
419
+ const allowed = new Set([0o600, 0o644, 0o700, 0o755])
420
+ for (const [rel, pin] of Object.entries(manifest.files)) {
421
+ if (!/^[A-Za-z0-9_@.-]+(?:\/[A-Za-z0-9_@.-]+)*$/.test(rel)) fail(`bad path: ${rel}`)
422
+ if (!allowed.has(pin.mode)) fail(`bad mode pin: ${rel}`)
423
+ if (digest(readFileSync(`${pkg}/${rel}`)) !== pin.digest) fail(`digest changed: ${rel}`)
424
+ }
425
+ for (const p of REQUIRED_PROGRAMS) if (!manifest.files[p]) fail(`manifest missing program: ${p}`)
426
+ const prim = (f, want) => { const st = sh("/usr/bin/stat", ["-Lc", "%u %g %a", f]).trim().split(" "); if (!(st[0] === "0" && st[1] === "0" && (parseInt(st[2], 8) & 0o022) === 0 && digest(readFileSync(f)) === want)) fail(`host primitive pin would fail: ${f}`) }
427
+ prim("/usr/local/bin/node", request.nodeDigest); prim(sh("/bin/readlink", ["-f", "/bin/sh"]).trim(), request.shellDigest)
428
+ prim("/usr/bin/prlimit", request.prlimitDigest); prim("/usr/bin/setsid", request.setsidDigest)
429
+ }
430
+
431
+ // ---- install (the one destructive phase; auto-rolls-back on any failure) --
432
+
433
+ function tx(op, extra = []) {
434
+ const driver = `${ROOT}/incoming-package/deploy/unraid/docker-man-template-transaction.mjs`
435
+ try {
436
+ return sh("/usr/local/bin/node", [driver, op, ...extra], { stdio: ["pipe", "pipe", "pipe"] })
437
+ } catch (e) {
438
+ // Surface the transaction's real stderr/stdout, not just "Command failed".
439
+ const detail = [e.stderr, e.stdout].map((x) => (x ? String(x).trim() : "")).filter(Boolean).join("\n")
440
+ throw new Error(`${op} failed:\n${detail || e.message}`)
441
+ }
442
+ }
443
+
444
+ const AUTOSTART_FILE = "/var/lib/docker/unraid-autostart"
445
+ let RESIDENT_AUTOSTART_LINE = "ouro-butler 0"
446
+ const AUTOSTART_RE = /^ouro-butler(?:-rollback|-staging|-legacy-evidence)?(?:\s|$)/u
447
+ // configure-resident/#configured require the resident to be ABSENT from Unraid's direct
448
+ // autostart during the handoff (the authority controls its start, not Unraid boot). The
449
+ // final commit proof then requires it PRESENT. So we disable it before authority-activate
450
+ // and re-enable it before commit. #directAutostartDisabled also requires the file be
451
+ // root:root 0644, which we preserve.
452
+ function disableResidentAutostart() {
453
+ if (!existsSync(AUTOSTART_FILE)) return
454
+ const lines = readFileSync(AUTOSTART_FILE, "utf8").split("\n")
455
+ const found = lines.find((l) => AUTOSTART_RE.test(l))
456
+ if (found) RESIDENT_AUTOSTART_LINE = found
457
+ writeFileSync(AUTOSTART_FILE, lines.filter((l) => !AUTOSTART_RE.test(l)).join("\n"))
458
+ sh("/bin/chown", ["0:0", AUTOSTART_FILE]); chmodSync(AUTOSTART_FILE, 0o644)
459
+ ok("disabled ouro-butler direct autostart for the authority handoff")
460
+ }
461
+ function enableResidentAutostart() {
462
+ const lines = existsSync(AUTOSTART_FILE) ? readFileSync(AUTOSTART_FILE, "utf8").split("\n").filter((l) => l.length) : []
463
+ if (!lines.some((l) => AUTOSTART_RE.test(l))) lines.push(RESIDENT_AUTOSTART_LINE)
464
+ writeFileSync(AUTOSTART_FILE, lines.join("\n") + "\n")
465
+ sh("/bin/chown", ["0:0", AUTOSTART_FILE]); chmodSync(AUTOSTART_FILE, 0o644)
466
+ ok("re-enabled ouro-butler direct autostart")
467
+ }
468
+
469
+ // Recreate the resident container into the tokenless, gateway-mounted target form that
470
+ // #configure/#target require. Nothing in the authority transaction does this — on Unraid it
471
+ // is a DockerMan "Apply" of the swapped template. We perform the equivalent here, matching
472
+ // what DockerMan produces (managed label + icon + the template's mounts) so start-resident,
473
+ // verify-install and the final commit proof are all satisfied. The container is created
474
+ // stopped; start-resident starts it. #target asserts: target image, user 10001:10001, no
475
+ // token env, exactly one read-only /run/ouro-authority mount, no docker.sock/${ROOT} mounts.
476
+ // Operator-set grants (delegated commands, escalation) live outside the bundle, in a root-owned host directory mounted
477
+ // read-only at /etc/ouro/trust/sanctuary. The bundle's recursive chown cannot reach it, and the resident cannot write it.
478
+ export const TRUST_HOST_DIR = "/mnt/user/appdata/ouro-butler/trust/sanctuary"
479
+ export const TRUST_MOUNT = `${TRUST_HOST_DIR}:/etc/ouro/trust/sanctuary:ro`
480
+
481
+ /** What is wrong with a host trust directory's stat, or null when it is a real root-owned directory closed to group and other writes. */
482
+ export function trustDirIssue(stat) {
483
+ if (stat.isSymbolicLink()) return `${TRUST_HOST_DIR} is a symlink`
484
+ if (!stat.isDirectory()) return `${TRUST_HOST_DIR} is not a directory`
485
+ if (stat.uid !== 0) return `${TRUST_HOST_DIR} must be owned by root`
486
+ if ((stat.mode & 0o022) !== 0) return `${TRUST_HOST_DIR} is writable by group or other`
487
+ return null
488
+ }
489
+
490
+ // Creates the host trust directory root-owned 0755 when it is missing; refuses one the resident could have made.
491
+ function ensureTrustHostDir() {
492
+ if (!existsSync(TRUST_HOST_DIR)) {
493
+ sh("/bin/mkdir", ["-p", TRUST_HOST_DIR])
494
+ sh("/bin/chown", ["-h", "0:0", TRUST_HOST_DIR])
495
+ chmodSync(TRUST_HOST_DIR, 0o755)
496
+ }
497
+ const issue = trustDirIssue(lstatSync(TRUST_HOST_DIR))
498
+ if (issue) fail(`${issue}; fix it as root before the upgrade`)
499
+ }
500
+
501
+ /** What is wrong with the resident's trust mount, from `docker inspect` Mounts, or null when it is the read-only host directory. */
502
+ export function trustMountIssue(mounts) {
503
+ const mount = (Array.isArray(mounts) ? mounts : []).find((entry) => entry?.Destination === "/etc/ouro/trust/sanctuary")
504
+ if (!mount) return "the operator trust directory is not mounted in the resident, so no grant would be honoured"
505
+ if (mount.Source !== TRUST_HOST_DIR) return `the operator trust directory is mounted from ${mount.Source}, not the root-owned host directory`
506
+ return mount.RW === false ? null : "the operator trust directory is mounted read-write in the resident"
507
+ }
508
+
509
+ /** Friends whose records still carry the old delegationGrant but have no trusted grant: the record is now only a hint, so they are refused. */
510
+ export function legacyDelegationGrantWarnings(friendRecords, trustedGrants) {
511
+ return Object.entries(friendRecords)
512
+ .filter(([id, record]) => record?.delegationGrant && !(id in trustedGrants))
513
+ .map(([id, record]) => `${record.name ?? id} (${id}) has a delegationGrant on its friend record that is no longer honoured; grant it with \`ouro a2a delegated-commands grant --friend ${id} --did <did:key>\` as root`)
514
+ }
515
+
516
+ /**
517
+ * Escalation holders the old harness honoured (the bundle's state/a2a/escalation-grants.json) that the trusted file does not list. The
518
+ * upgrade never copies a grant out of the bundle, so each of these loses the escalation grant until the operator re-grants it.
519
+ */
520
+ export function legacyEscalationGrantProblems(legacyGrants, trustedGrants) {
521
+ return Object.entries(legacyGrants ?? {})
522
+ .filter(([id]) => !(id in (trustedGrants ?? {})))
523
+ .map(([id, grant]) => `escalation holder ${id} is in the bundle's state/a2a/escalation-grants.json but not in ${TRUST_HOST_DIR}/escalation-grants.json, so the upgrade would drop it; grant it as root first with \`ouro a2a escalation grant --agent sanctuary --friend ${id} --did ${typeof grant?.did === "string" ? grant.did : "<did:key>"}\` (the one-off container form is in deploy/unraid/README.txt)`)
524
+ }
525
+
526
+ /** Reads the legacy bundle file and the trusted file from the host and returns the holders the upgrade would drop. */
527
+ function escalationGrantProblems() {
528
+ const read = (file) => { try { return JSON.parse(readFileSync(file, "utf8")).grants ?? {} } catch { return {} } }
529
+ return legacyEscalationGrantProblems(read(`${BUNDLE}/state/a2a/escalation-grants.json`), read(`${TRUST_HOST_DIR}/escalation-grants.json`))
530
+ }
531
+
532
+ function recreateResident(version) {
533
+ say("recreate resident container (tokenless, gateway socket mounted)")
534
+ disableResidentAutostart()
535
+ ensureTrustHostDir()
536
+ const icon = "https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png"
537
+ docker(["rm", "-f", CONTAINER], { stdio: "ignore" })
538
+ docker(["create", "--name", CONTAINER, "--network", "host", "--restart", "unless-stopped", "--user", "10001:10001",
539
+ "-l", "net.unraid.docker.managed=dockerman",
540
+ "-l", `net.unraid.docker.icon=${icon}`,
541
+ "-l", "org.opencontainers.image.source=https://github.com/ourostack/ouroboros",
542
+ "-v", "/mnt/user/appdata/ouro-butler/runtime/.ouro-cli:/home/ouro/.ouro-cli:rw",
543
+ "-v", "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro:/home/ouro/AgentBundles/sanctuary.ouro:rw",
544
+ "-v", "/boot/config/custom/ouro-events/spool:/run/ouro-events:ro",
545
+ "-v", "/run/ouro-authority:/run/ouro-authority:ro",
546
+ "-v", TRUST_MOUNT,
547
+ image(version)])
548
+ const ref = docker(["inspect", CONTAINER, "--format", "{{.Config.Image}}"]).trim()
549
+ if (ref !== image(version)) fail(`recreated resident is ${ref}, expected ${image(version)}`)
550
+ ok(`recreated ${CONTAINER} on ${version} (created, stopped, gateway-mounted)`)
551
+ }
552
+
553
+ // Migrate the agent bundle to the target version against the packaged bundle template
554
+ // (deploy/unraid/sanctuary.ouro inside the extracted package — it holds provider-readiness.json
555
+ // and the rest of the package-managed files). Without this, the target runtime reads an
556
+ // unmigrated predecessor bundle and its context-loss sentinel goes critical, crash-looping the
557
+ // resident telegram sense. Run while the resident is stopped (between recreate and activate).
558
+ // The recursive chown above also hands state/replay to the resident, but the replay gate's trust rests on that
559
+ // directory being root-owned and read-only to it. Put it back (the resident is stopped) and drop any window file.
560
+ // The recursive restore runs while the resident owns the bundle, so state/<name>, or state itself, may be a link it planted.
561
+ // A WARN line in the upgrade log says plainly that a restore step refused; nothing is chowned or chmodded through a link.
562
+ const warn = (m) => console.log(`\nWARN ${m}`)
563
+ export function trustedStateChild(bundle, name, { lstat = lstatSync, realpath = realpathSync, log = warn } = {}) {
564
+ const dir = `${bundle}/state/${name}`
565
+ const refuse = (reason) => { log(`upgrade restore REFUSED ${dir}: ${reason}; no chown or chmod was applied through it`); return null }
566
+ let stat
567
+ try { stat = lstat(dir) } catch { return null }
568
+ if (!stat.isDirectory()) return refuse(stat.isSymbolicLink() ? "it is a symlink" : "it is not a directory")
569
+ let resolved = ""
570
+ let expected = ""
571
+ try { resolved = realpath(dir); expected = `${realpath(bundle)}/state/${name}` } catch { /* unresolvable: refused below */ }
572
+ if (!resolved || resolved !== expected) return refuse("it resolves outside the bundle's own state directory")
573
+ return dir
574
+ }
575
+ export function restoreReplayRoot(bundle = BUNDLE, { lstat = lstatSync, realpath = realpathSync, chmod = chmodSync, rm = rmSync, run = sh, log = warn } = {}) {
576
+ const dir = trustedStateChild(bundle, "replay", { lstat, realpath, log })
577
+ if (!dir) return
578
+ run("/bin/chown", ["-h", "0:0", dir])
579
+ chmod(dir, 0o755)
580
+ rm(`${dir}/window.json`, { force: true, recursive: true })
581
+ }
582
+ // The psyche files stay owned by the resident (uid 10001) at exactly mode 0600: alpha.978's boot check
583
+ // (prepareSanctuaryPackageManagedBundle) requires that, and a rollback may start that release again. The recursive chown above
584
+ // already handed them to 10001; this puts the modes back (directories 0755, files 0600) and is the state psycheProblems checks.
585
+ export function restorePsycheModes(dir, { lstat = lstatSync, readdir = readdirSync, chmod = chmodSync } = {}) {
586
+ const walk = (current) => {
587
+ const stat = lstat(current)
588
+ if (stat.isSymbolicLink()) return
589
+ if (stat.isDirectory()) {
590
+ chmod(current, 0o755)
591
+ for (const name of readdir(current)) walk(`${current}/${name}`)
592
+ } else chmod(current, 0o600)
593
+ }
594
+ walk(dir)
595
+ }
596
+ function restorePsycheRoot() {
597
+ const dir = `${BUNDLE}/psyche`
598
+ if (existsSync(dir)) restorePsycheModes(dir)
599
+ }
600
+
601
+ /** What is wrong with the psyche folder as the resident's boot check needs it: files owned by the resident at exactly 0600, directories not writable by group or others, no symlinks. Empty when it is sound. */
602
+ export function psycheProblems(dir, { lstat = lstatSync, readdir = readdirSync } = {}) {
603
+ const problems = []
604
+ const walk = (current) => {
605
+ const stat = lstat(current)
606
+ if (stat.isSymbolicLink()) { problems.push(`${current} is a symlink`); return }
607
+ if (stat.uid !== 10001) problems.push(`${current} is owned by uid ${stat.uid}, not the resident (10001)`)
608
+ if (stat.isDirectory()) {
609
+ if ((stat.mode & 0o022) !== 0) problems.push(`${current} is writable by group or others (mode ${(stat.mode & 0o777).toString(8)})`)
610
+ for (const name of readdir(current)) walk(`${current}/${name}`)
611
+ } else if ((stat.mode & 0o777) !== 0o600) problems.push(`${current} is mode ${(stat.mode & 0o777).toString(8)}, not 600`)
612
+ }
613
+ walk(dir)
614
+ return problems
615
+ }
616
+ /**
617
+ * What is wrong with the resident's psyche mount, from `docker inspect` Mounts, or null for a supported shape: no separate psyche
618
+ * mount (current releases), or a read-only one (alpha.978 and earlier, which a rollback recreates).
619
+ */
620
+ export function psycheMountIssue(mounts) {
621
+ const mount = (Array.isArray(mounts) ? mounts : []).find((entry) => entry?.Destination === PSYCHE_TARGET)
622
+ return !mount || mount.RW === false ? null : "psyche is mounted read-write as a separate mount in the resident"
623
+ }
624
+
625
+ function migrateBundle(version, rollbackImage) {
626
+ say(`migrate agent bundle to ${version}`)
627
+ const pkgBundle = `${ROOT}/incoming-package/deploy/unraid/sanctuary.ouro`
628
+ const migrate = `${ROOT}/incoming-package/deploy/unraid/migrate-sanctuary-bundle.mjs`
629
+ if (!existsSync(`${pkgBundle}/provider-readiness.json`)) fail(`packaged bundle template missing provider-readiness.json at ${pkgBundle}`)
630
+ if (rollbackImage === null) {
631
+ // A fresh install has no earlier image to roll back to: write the packaged files with no rollback record.
632
+ sh("/usr/local/bin/node", [migrate, "--package-root", pkgBundle, "--agent-root", BUNDLE, "--operation", "seed"])
633
+ ok("bundle seeded from the package (fresh install)")
634
+ } else {
635
+ sh("/usr/local/bin/node", [migrate, "--package-root", pkgBundle, "--agent-root", BUNDLE, "--operation", "migrate",
636
+ "--rollback-image-id", rollbackImage, "--target-image-id", imageId(version)])
637
+ ok("bundle migrated (rollback retained)")
638
+ sh("/usr/local/bin/node", [migrate, "--package-root", pkgBundle, "--agent-root", BUNDLE, "--operation", "commit"])
639
+ }
640
+ // migrate/commit ran as root; the resident owns its bundle as uid 10001, so restore ownership
641
+ sh("/bin/chown", ["-R", "10001:10001", BUNDLE])
642
+ restoreReplayRoot()
643
+ restorePsycheRoot()
644
+ ok("bundle committed (ownership restored to resident)")
645
+ }
646
+
647
+ // Host supervision for the root-authority gateway on Unraid. Installs three scripts, all
648
+ // verified on the live box (see desk butler-agent D-026/D-029/D-032/D-033/D-035):
649
+ // - gateway-supervisor.sh: keeps cgroup2-unraid paused (D-026), creates/repairs the
650
+ // socket dir Docker would otherwise create wrong (D-033), and (re)starts the gateway
651
+ // through the authority's own boot path, start.sh --boot (D-032) — never raw node.
652
+ // - gateway-keeper-watchdog.sh: cron */2 resurrects the supervisor (D-029), except
653
+ // during shutdown.
654
+ // - /boot/config/stop: Unraid runs it before stopping the array; it stops the supervisor
655
+ // and gateway gracefully, resumes the reaper, and traces to /boot/logs (D-035).
656
+ const AUTHORITY_CUSTOM = "/boot/config/custom/ouro-authority"
657
+ const GATEWAY_SUPERVISOR = `${AUTHORITY_CUSTOM}/gateway-supervisor.sh`
658
+ const GATEWAY_WATCHDOG = `${AUTHORITY_CUSTOM}/gateway-keeper-watchdog.sh`
659
+ const UNRAID_STOP_HOOK = "/boot/config/stop"
660
+ const GATEWAY_SUPERVISOR_SH = String.raw`#!/bin/sh
661
+ # Ouro authority gateway keeper for Unraid.
662
+ # The root-authority gateway needs /sys/fs/cgroup/ouro-authority to persist, and Unraid's
663
+ # cgroup2-unraid removes every top-level cgroup that reports populated 0. The kernel will not
664
+ # remove a cgroup that has a child cgroup, so the authority keeps an empty ouro-keep child
665
+ # (D-026). This keeper:
666
+ # - creates that keep child and lets the reaper run; it pauses the reaper only while the
667
+ # keep child is missing (a fresh boot, or an authority older than the keep child),
668
+ # - keeps the gateway process alive,
669
+ # - reconnects the resident (docker restart) if it is unhealthy while the gateway is ready
670
+ # (handles reboot ordering, where Docker autostarts the resident before the gateway).
671
+ set -u
672
+ CG=/sys/fs/cgroup/ouro-authority
673
+ R=/mnt/user/appdata/ouro-authority
674
+ GW=$R/package/dist/heart/daemon/sanctuary-telegram-authority-entry.js
675
+ CFG=$R/active.json
676
+ LOG=/var/log/ouro-gateway.log
677
+ # Only an authority from 0.1.0-alpha.837 on understands the keep child; an older gateway
678
+ # refuses to start beside an unknown cgroup (a rollback to 830 hit exactly that). So the
679
+ # keep child exists only while the installed package supports it; otherwise it is removed
680
+ # and the reaper stays paused, the pre-837 behaviour.
681
+ keep_supported() { grep -q ouro-keep "$R/package/dist/heart/daemon/sanctuary-authority-installation.js" 2>/dev/null; }
682
+ reaper_policy() {
683
+ P=$(cat /run/cgroup2-unraid.pid 2>/dev/null) || return 0
684
+ [ -n "$P" ] || return 0
685
+ if keep_supported && [ -d "$CG/ouro-keep" ]; then kill -CONT "$P" 2>/dev/null; return 0; fi
686
+ S=$(ps -o stat= -p "$P" 2>/dev/null | tr -d ' ')
687
+ case "$S" in T*) : ;; *) kill -STOP "$P" 2>/dev/null ;; esac
688
+ }
689
+ ensure_cg() {
690
+ [ -d "$CG" ] || { mkdir -m 700 "$CG" 2>/dev/null; chown 0:0 "$CG" 2>/dev/null
691
+ for c in cpu memory pids; do echo "+$c" > "$CG/cgroup.subtree_control" 2>/dev/null; done; }
692
+ if keep_supported; then [ -d "$CG/ouro-keep" ] || mkdir -m 700 "$CG/ouro-keep" 2>/dev/null
693
+ else [ -d "$CG/ouro-keep" ] && rmdir "$CG/ouro-keep" 2>/dev/null; fi
694
+ }
695
+ ensure_sock() {
696
+ # Docker autostarts the resident before us and auto-creates its missing bind
697
+ # source /run/ouro-authority as 755 root:root; the authority boot rejects that as
698
+ # unsafe. Create it, or repair it in place (same inode, so the resident's bind
699
+ # mount stays valid), as 750 root:10001.
700
+ [ -d /run/ouro-authority ] || mkdir -p /run/ouro-authority
701
+ chown 0:10001 /run/ouro-authority 2>/dev/null
702
+ chmod 0750 /run/ouro-authority 2>/dev/null
703
+ }
704
+ gw_pid() { ps -eo pid,args | grep '[s]anctuary-telegram-authority-entry.js' | awk '{print $1}' | head -1; }
705
+ start_gw() {
706
+ # Launch through the authority lifecycle boot (start.sh --boot), never raw node:
707
+ # it waits for the array + Docker, rebuilds the tmpfs runtime state a reboot
708
+ # wipes (/var/lib/ouro-authority), clears a stale readiness.json, launches the
709
+ # gateway detached, and returns once it is ready.
710
+ rm -f "$R"/epochs/*/authority.lock 2>/dev/null
711
+ echo "$(date) keeper: authority boot (runtime dirs + gateway)" >> "$LOG"
712
+ /bin/sh /boot/config/custom/ouro-authority/start.sh --boot >> "$LOG" 2>&1 \
713
+ || echo "$(date) keeper: authority boot failed; will retry" >> "$LOG"
714
+ }
715
+ # An in-place upgrade pauses us with a flag; a stale flag (> 30 min) is ignored.
716
+ maintenance() { [ -n "$(find /run/ouro-authority-maintenance -mmin -30 2>/dev/null)" ]; }
717
+ reaper_policy; ensure_cg; ensure_sock
718
+ last_res=0
719
+ while true; do
720
+ if maintenance; then sleep 15; continue; fi
721
+ reaper_policy
722
+ ensure_cg
723
+ reaper_policy
724
+ ensure_sock
725
+ [ -n "$(gw_pid)" ] || { start_gw; sleep 8; }
726
+ rd=$(jq -rc .status "$R"/epochs/*/readiness.json 2>/dev/null)
727
+ st=$(docker inspect ouro-butler --format '{{.State.Health.Status}}' 2>/dev/null)
728
+ ss=$(docker inspect ouro-butler --format '{{.State.Status}}' 2>/dev/null)
729
+ now=$(date +%s)
730
+ # Unhealthy, or left stopped (an interrupted upgrade or restart): the gateway is ready, so bring it back.
731
+ if [ "$rd" = ready ] && { [ "$st" = unhealthy ] || [ "$ss" = exited ] || [ "$ss" = created ]; } && [ $((now - last_res)) -gt 90 ]; then
732
+ echo "$(date) keeper: reconnecting resident ($ss/$st)" >> "$LOG"
733
+ docker restart ouro-butler >/dev/null 2>&1
734
+ last_res=$now
735
+ fi
736
+ sleep 15
737
+ done
738
+ `
739
+ const GATEWAY_WATCHDOG_SH = String.raw`#!/bin/sh
740
+ # Ouro authority gateway-supervisor watchdog (D-028 hardening).
741
+ # gateway-supervisor.sh keeps the .830 root-authority gateway alive and the
742
+ # cgroup2-unraid reaper paused. The supervisor was a single point of failure:
743
+ # started once from /boot/config/go, nothing restarted it if it died -- and a dead
744
+ # supervisor means a dead gateway would never be restarted (bot fully offline, as the
745
+ # gateway holds the Telegram token). This watchdog (cron */2) resurrects it if absent.
746
+ set -u
747
+ # The stop hook sets this during shutdown; never resurrect the supervisor then.
748
+ [ -e /run/ouro-authority-shutdown ] && exit 0
749
+ # An in-place upgrade pauses supervision; a stale flag (> 30 min) is ignored.
750
+ [ -n "$(find /run/ouro-authority-maintenance -mmin -30 2>/dev/null)" ] && exit 0
751
+ SUP=/boot/config/custom/ouro-authority/gateway-supervisor.sh
752
+ LOG=/var/log/ouro-gateway.log
753
+ if [ ! -f "$SUP" ]; then
754
+ echo "$(date) watchdog: supervisor script MISSING at $SUP" >> "$LOG"
755
+ exit 0
756
+ fi
757
+ if ps -eo args 2>/dev/null | grep -q '[g]ateway-supervisor.sh'; then
758
+ exit 0
759
+ fi
760
+ echo "$(date) watchdog: supervisor not running -- restarting" >> "$LOG"
761
+ setsid /bin/sh "$SUP" >/dev/null 2>&1 &
762
+ `
763
+ const UNRAID_STOP_HOOK_SH = String.raw`#!/bin/bash
764
+ # Ouro authority stop hook (run by Unraid's rc.local_shutdown before the array stops).
765
+ # Stops our host processes cleanly so nothing of ours is respawning or frozen during
766
+ # shutdown, and leaves a trace on the flash drive for diagnosis. Every step is bounded.
767
+ LOG=/boot/logs/ouro-shutdown.log
768
+ mkdir -p /boot/logs
769
+ {
770
+ echo "$(date) stop hook: begin"
771
+ touch /run/ouro-authority-shutdown
772
+ for p in $(pgrep -f '^/bin/sh /boot/config/custom/ouro-authority/[g]ateway-supervisor'); do kill "$p"; done
773
+ for p in $(pgrep -f '[s]anctuary-telegram-authority-entry.js'); do kill "$p"; done
774
+ for i in 1 2 3 4 5 6 7 8 9 10; do pgrep -f '[s]anctuary-telegram-authority-entry.js' >/dev/null || break; sleep 1; done
775
+ pkill -9 -f '[s]anctuary-telegram-authority-entry.js' 2>/dev/null
776
+ P=$(cat /run/cgroup2-unraid.pid 2>/dev/null); [ -n "$P" ] && kill -CONT "$P"
777
+ echo "$(date) stop hook: supervisor+gateway stopped, reaper resumed ($(pgrep -fc '[s]anctuary-telegram-authority-entry.js') gateways left)"
778
+ } >> "$LOG" 2>&1
779
+ `
780
+ const WATCHDOG_CRON = `*/2 * * * * /bin/sh ${GATEWAY_WATCHDOG} # ouro-authority-gateway-watchdog`
781
+ function installGatewaySupervisor() {
782
+ for (const [path, body, mode] of [[GATEWAY_SUPERVISOR, GATEWAY_SUPERVISOR_SH, 0o700], [GATEWAY_WATCHDOG, GATEWAY_WATCHDOG_SH, 0o700], [UNRAID_STOP_HOOK, UNRAID_STOP_HOOK_SH, 0o755]]) {
783
+ writeFileSync(path, body)
784
+ sh("/bin/chown", ["0:0", path]); chmodSync(path, mode)
785
+ }
786
+ ok("gateway supervisor, watchdog, and Unraid stop hook installed")
787
+ const go = "/boot/config/go"
788
+ try {
789
+ let g = readFileSync(go, "utf8")
790
+ g = g.split("\n").filter((l) => !/ouro-authority\/start\.sh --boot/.test(l) && !/ouro-authority-gateway/.test(l) && !/gateway-keeper-watchdog/.test(l)).join("\n")
791
+ if (!g.endsWith("\n")) g += "\n"
792
+ g += `setsid /bin/sh ${GATEWAY_SUPERVISOR} >/dev/null 2>&1 & # ouro-authority-gateway\n`
793
+ g += `(crontab -l 2>/dev/null | grep -v gateway-keeper-watchdog; echo "${WATCHDOG_CRON}") | crontab - # ouro-authority-gateway-watchdog\n`
794
+ writeFileSync(go, g)
795
+ ok("supervisor + watchdog cron wired into /boot/config/go for reboot durability")
796
+ } catch (e) { console.log(` WARN: could not wire go hook (${e.message}); start manually on boot`) }
797
+ sh("/bin/sh", ["-c", `(crontab -l 2>/dev/null | grep -v gateway-keeper-watchdog; echo "${WATCHDOG_CRON}") | crontab -`])
798
+ // Replace any running supervisor so exactly one runs the new code; clear a stale shutdown flag.
799
+ sh("/bin/sh", ["-c", "rm -f /run/ouro-authority-shutdown; for p in $(pgrep -f '^/bin/sh /boot/config/custom/ouro-authority/[g]ateway-supervisor' || true); do kill $p; done"])
800
+ sh("/bin/sh", ["-c", `setsid /bin/sh ${GATEWAY_SUPERVISOR} >/dev/null 2>&1 &`])
801
+ ok("gateway supervisor started")
802
+ }
803
+
804
+ // On a successful authority-activate, finalize a durable, stable target: archive the DockerMan
805
+ // transaction journal (committed-equivalent for our purposes), and hand cgroup-reaper pausing +
806
+ // gateway/resident supervision to the keeper. The reaper is intentionally left paused (the
807
+ // keeper keeps it paused); do not resume it here.
808
+ function finalizeStable() {
809
+ if (existsSync(JOURNAL)) { sh("/bin/mv", ["-f", JOURNAL, `${JOURNAL}.committed.${Date.now()}`]); ok("transaction journal archived (committed)") }
810
+ installGatewaySupervisor()
811
+ REAPER_PAUSED = false
812
+ }
813
+
814
+ function install(version) {
815
+ say(`install ${version}`)
816
+ const incoming = `${ROOT}/incoming-package`
817
+ if (!existsSync(`${ROOT}/request.json`) || !existsSync(incoming)) fail("not prepared; run prepare first")
818
+ const pkgVersion = JSON.parse(readFileSync(`${incoming}/deploy/unraid/sanctuary.ouro/bundle-meta.json`, "utf8")).runtimeVersion
819
+ if (pkgVersion !== version) fail(`prepared package is ${pkgVersion}, not ${version}; re-run prepare`)
820
+ if (!existsSync(`${ROOT}/incoming-token`) || statSync(`${ROOT}/incoming-token`).size === 0) fail(`no rotated token at ${ROOT}/incoming-token — rotate it in BotFather first`)
821
+ if (existsSync(JOURNAL)) fail("a template transaction is already pending; resolve it first")
822
+ if (targetHasVaultFix(version) === false) fail(`${version} lacks the D-018 fenced-read fix; the install would strand Telegram (run preflight)`)
823
+ const fresh = installedRequest()?.predecessorContract === "none"
824
+
825
+ // Clear stale authority runtime dirs from a prior failed attempt so #runtimeDirectories
826
+ // starts clean (it skips a dir that already exists). Safe only when no authority is live
827
+ // (no active.json) and no transaction is pending. Note: the empty-cgroup ENOENT at stage
828
+ // is caused by Unraid's cgroup2-unraid reaper, handled by pauseCgroupReaper() below.
829
+ if (!existsSync(`${ROOT}/active.json`) && !existsSync(JOURNAL)) {
830
+ for (const d of ["/sys/fs/cgroup/ouro-authority", "/var/lib/ouro-authority", "/run/ouro-authority", `${ROOT}/epochs`, `${ROOT}/package`]) {
831
+ try { if (existsSync(d)) { sh("/bin/sh", ["-c", `find ${d} -depth -type d -exec rmdir {} + 2>/dev/null; rm -rf ${d} 2>/dev/null`], { stdio: "ignore" }) } } catch { /* best effort */ }
832
+ }
833
+ killLeakedGateway()
834
+ ok("cleared stale authority runtime dirs (cgroup/staging/socket/epochs) and any leaked gateway")
835
+ }
836
+ const targetImage = imageId(version)
837
+ if (fresh && containerExists(CONTAINER)) fail(`an ${CONTAINER} container already exists; a fresh install needs a host without one`)
838
+ const rollbackImage = fresh ? null : docker(["inspect", CONTAINER, "--format", "{{.Image}}"]).trim()
839
+ if (!targetImage || targetImage === rollbackImage) fail("target/rollback image identity invalid")
840
+ const jellyfinBefore = jellyfinState()
841
+ const policyBefore = fresh ? policyState() : policyState() ?? fail(`steward policy missing`)
842
+ ok(`target ${targetImage.slice(0,19)}… rollback ${rollbackImage ? `${rollbackImage.slice(0,19)}…` : "none (fresh install)"} policy ${policyBefore ?? "none yet"}`)
843
+
844
+ const srcTemplate = `${ROOT}/source-template.xml`
845
+ sh("/usr/bin/install", ["-m", "600", "-o", "0", "-g", "0", `${incoming}/deploy/unraid/sanctuary.xml`, srcTemplate])
846
+ const manifestDigest = JSON.parse(readFileSync(`${ROOT}/request.json`, "utf8")).packageDigest
847
+
848
+ pauseCgroupReaper()
849
+ try {
850
+ say("transaction: prepare -> authority-install -> authority-activate -> commit")
851
+ tx("prepare", ["--source-template", srcTemplate, "--version-tag", image(version),
852
+ "--manifest-digest", manifestDigest, "--rollback-image-id", rollbackImage ?? "none", "--target-image-id", targetImage])
853
+ ok("prepared")
854
+ tx("authority-install"); ok("authority installed (gateway up)")
855
+ recreateResident(version)
856
+ migrateBundle(version, rollbackImage)
857
+ tx("authority-activate"); ok("resident activated")
858
+ waitHealthy(300)
859
+ enableResidentAutostart()
860
+ finalizeStable()
861
+ ok("stabilized: bundle migrated, gateway supervised, journal committed")
862
+ } catch (e) {
863
+ resumeCgroupReaper()
864
+ console.log(`\n!! install failed:\n${e.message}\n!! auto-rolling back so the Butler is not left down`)
865
+ if (fresh) {
866
+ freshRollback()
867
+ fail("fresh install rolled back; nothing is left running. Fix the error above, then run prepare --fresh and install again.")
868
+ }
869
+ autoRollback(rollbackImage, version)
870
+ fail("install rolled back; the Butler is on its prior version. See output above.")
871
+ }
872
+
873
+ say("preservation")
874
+ jellyfinState() === jellyfinBefore ? ok(jellyfinBefore ? "jellyfin unchanged" : "no jellyfin, as before") : fail("JELLYFIN CHANGED")
875
+ unchanged(policyBefore, policyState()) ? ok(policyBefore ? "steward policy unchanged" : "no steward policy before install") : fail("STEWARD POLICY CHANGED")
876
+ console.log(`\nINSTALL done — ${version} live. Run verify.`)
877
+ }
878
+
879
+ // Kill any leaked authority host process (the gateway / host-supervisor). A failed
880
+ // authority-retire/stop-gateway during rollback can leave sanctuary-telegram-authority-entry
881
+ // running against a since-removed active.json — it competes for the Telegram token and holds
882
+ // FUSE files open on ${ROOT}/package. Best-effort; the resident (ouro-butler) is untouched.
883
+ function killLeakedGateway() {
884
+ try {
885
+ const pids = sh("/bin/sh", ["-c", "pgrep -f 'dist/heart/daemon/sanctuary-(telegram-authority|host-supervisor)-entry' || true"]).trim().split(/\s+/u).filter(Boolean)
886
+ for (const pid of pids) { try { sh("/bin/kill", ["-TERM", pid]) } catch { /* ignore */ } }
887
+ if (pids.length) { sh("/bin/sleep", ["3"]); for (const pid of pids) { try { sh("/bin/kill", ["-KILL", pid]) } catch { /* ignore */ } } ok(`stopped ${pids.length} leaked authority process(es)`) }
888
+ } catch { /* best effort */ }
889
+ }
890
+
891
+ // A fresh install rolls back to nothing: the authority retires and removes the resident it created, the template
892
+ // transaction removes the template it wrote, and nothing is restarted because nothing ran before.
893
+ function freshRollback() {
894
+ for (const op of ["authority-retire", "authority-restore", "rollback"]) {
895
+ try { tx(op) } catch (e) { console.log(` ${op}: ${String(e.message).split("\n")[0]}`) }
896
+ }
897
+ killLeakedGateway()
898
+ try { if (containerExists(CONTAINER)) docker(["rm", "-f", CONTAINER]) } catch (e) { console.log(` remove ${CONTAINER}: ${String(e.message).split("\n")[0]}`) }
899
+ console.log(containerExists(CONTAINER) ? ` ${CONTAINER} is still present — remove it by hand before retrying` : " rollback complete — no Butler container is left behind")
900
+ }
901
+
902
+ function autoRollback(rollbackImage, version) {
903
+ try { tx("authority-retire") } catch (e) { console.log(` authority-retire: ${String(e.message).split("\n")[0]}`) }
904
+ try { tx("authority-restore") } catch (e) { console.log(` authority-restore: ${String(e.message).split("\n")[0]}`) }
905
+ try { tx("rollback") } catch (e) { console.log(` template rollback: ${String(e.message).split("\n")[0]}`) }
906
+ killLeakedGateway()
907
+ try { if (!docker(["inspect", CONTAINER, "--format", "{{.State.Running}}"], { stdio: ["ignore","pipe","ignore"] }).trim().startsWith("true")) docker(["start", CONTAINER]) } catch { /* below */ }
908
+ // The predecessor reads its token from the materialised credential cache, not
909
+ // the vault directly. authority-restore returns the token to the vault, but a
910
+ // stale cache leaves the resident crash-looping on 401 (this is exactly what
911
+ // stranded the bot on 2026-09-22). Re-materialise the vault token into the
912
+ // cache, keeping the value host-side only, and confirm recovery.
913
+ try {
914
+ rematerialiseToken(version)
915
+ docker(["restart", CONTAINER])
916
+ console.log(" re-materialised token from the vault and restarted the predecessor")
917
+ } catch (e) { console.log(` re-materialise: ${String(e.message).split("\n")[0]} — MANUAL recovery may be needed`) }
918
+ for (let i = 0; i < 24; i++) {
919
+ const st = (() => { try { return docker(["inspect", CONTAINER, "--format", "{{.State.Status}}/{{.State.Health.Status}}"], { stdio: ["ignore","pipe","ignore"] }).trim() } catch { return "?" } })()
920
+ if (st === "running/healthy") { console.log(" rollback complete — predecessor healthy again"); return }
921
+ sh("/bin/sleep", ["10"])
922
+ }
923
+ console.log(" rollback container did NOT return healthy — inspect `docker logs ouro-butler` and re-materialise the token manually")
924
+ }
925
+
926
+ // Restores the predecessor's telegram token after a failed install. The subtle
927
+ // truth learned by dogfooding: once the upgrade has rotated the token, the OLD
928
+ // token is REVOKED, so the predecessor cannot be restored to it — it must be
929
+ // brought up on the NEW token (incoming-token). So this prefers incoming-token
930
+ // when present (the post-rotation case), putting it back into the vault AND the
931
+ // materialised cache; only if no rotation happened does it fall back to the
932
+ // vault's own token. The value stays in host-side pipes, never in this process.
933
+ // Runs through a quiet sole-user container of the target image (the reliable way
934
+ // to reach the bitwarden store off the running resident).
935
+ // Force an explicit `bw sync` inside the vault-fix container before a write. The
936
+ // resident and the install's fenced ops share one local bitwarden cache; after an
937
+ // interrupted install the local copy can be older than the server, so a bare
938
+ // `bw edit` fails ("The client copy of this cipher is out of date"). The store's
939
+ // own sync-on-login skips when its freshness marker is <60s old, so we sync
940
+ // directly (unlock -> sync) and let the write proceed against a reconciled cache.
941
+ function forceBwSync(name) {
942
+ const script = [
943
+ 'const cp=require("node:child_process"),fs=require("node:fs");',
944
+ 'const {readVaultUnlockSecret}=require("/opt/ouro/dist/repertoire/vault-unlock.js");',
945
+ 'const cfg=JSON.parse(fs.readFileSync("/home/ouro/AgentBundles/sanctuary.ouro/agent.json","utf8")).vault||{};',
946
+ 'const email=cfg.email, url=cfg.serverUrl;',
947
+ 'const base="/home/ouro/.ouro-cli/bitwarden";',
948
+ 'const APP=base+"/"+fs.readdirSync(base).find(d=>/^[0-9a-f]{16,}$/.test(d));',
949
+ 'const u=readVaultUnlockSecret({agentName:"sanctuary",email,serverUrl:url});',
950
+ 'const env={...process.env,BITWARDENCLI_APPDATA_DIR:APP,OURO_BW_MASTER_PASSWORD:u.secret};',
951
+ 'const sess=cp.execFileSync("bw",["unlock","--passwordenv","OURO_BW_MASTER_PASSWORD","--raw"],{env,encoding:"utf8"}).trim();',
952
+ 'cp.execFileSync("bw",["sync"],{env:{...env,BW_SESSION:sess},encoding:"utf8"});',
953
+ 'process.stdout.write("bw-synced");',
954
+ ].join("")
955
+ try { docker(["exec", "-u", "0:0", name, "node", "-e", script], { stdio: ["ignore", "pipe", "pipe"] }); ok("forced bw sync before token restore") }
956
+ catch (e) { console.log(` forceBwSync: ${String(e.message).split("\n")[0]} (continuing; restore may still succeed)`) }
957
+ }
958
+
959
+ function rematerialiseToken(version) {
960
+ const cc = "/mnt/user/appdata/ouro-butler/runtime/container-credentials.json"
961
+ const incoming = `${ROOT}/incoming-token`
962
+ const fromIncoming = existsSync(incoming) && statSync(incoming).size > 0
963
+ const name = `ouro-vault-fix-${process.pid}`
964
+ docker(["rm", "-f", name], { stdio: "ignore" })
965
+ docker(["run", "-d", "--name", name, "--network", "host", "--user", "0:0",
966
+ "-v", `${RUNTIME}:/home/ouro/.ouro-cli`, "-v", `${BUNDLE}:/home/ouro/AgentBundles/sanctuary.ouro`,
967
+ "--entrypoint", "sleep", image(version), "infinity"])
968
+ try {
969
+ sh("/bin/sleep", ["3"])
970
+ if (fromIncoming) {
971
+ // Put the new (valid) token back into the vault, then materialise it. Build the
972
+ // restore JSON in JS and pipe it via stdin: jq's rtrimstr with a JS "\n" template
973
+ // produced a raw newline inside jq's string literal and jq rejected it, which is what
974
+ // stranded the bot on 2026-09-22. Owner ids come from the resident's own cache.
975
+ const tok = readFileSync(incoming, "utf8").replace(/[\r\n]+$/u, "")
976
+ const resident = JSON.parse(readFileSync(cc, "utf8"))
977
+ const rc = resident.credentials[0].runtimeConfig
978
+ const restore = JSON.stringify({ token: tok, botId: tok.split(":")[0],
979
+ ownerUserId: String(rc.telegramAuthorizedUserId), ownerChatId: String(rc.telegramAuthorizedChatId) })
980
+ forceBwSync(name)
981
+ docker(["exec", "-i", "-u", "0:0", name, "node", CLI, "vault", "restore"], { input: restore, stdio: ["pipe", "pipe", "pipe"] })
982
+ // Fail loudly if the restored token is not actually live at Telegram, rather
983
+ // than leaving a "healthy" container polling a revoked token (a deaf bot).
984
+ try {
985
+ const me = sh("/bin/sh", ["-c", `curl -s "https://api.telegram.org/bot${tok}/getMe"`]).trim()
986
+ if (!/"ok":true/.test(me)) console.log(` WARN: restored token getMe not ok — the bot may be deaf: ${me.slice(0, 120)}`)
987
+ else ok("restored token verified live at Telegram (getMe ok)")
988
+ } catch { /* network check is best-effort */ }
989
+ rc.telegramBotToken = tok
990
+ writeFileSync(`${cc}.tmp`, JSON.stringify(resident))
991
+ sh("/bin/sh", ["-c", `chown --reference=${cc} ${cc}.tmp && chmod --reference=${cc} ${cc}.tmp && mv ${cc}.tmp ${cc}`])
992
+ } else {
993
+ sh("/bin/sh", ["-c",
994
+ `set -o pipefail; docker exec -u 0:0 ${name} node ${CLI} vault snapshot | jq -r .token > ${cc}.tok.$$ && ` +
995
+ `jq --rawfile t ${cc}.tok.$$ '.credentials[0].runtimeConfig.telegramBotToken=($t|rtrimstr("\n"))' ${cc} > ${cc}.tmp.$$ && ` +
996
+ `chown --reference=${cc} ${cc}.tmp.$$ && chmod --reference=${cc} ${cc}.tmp.$$ && mv ${cc}.tmp.$$ ${cc}; rm -f ${cc}.tok.$$`])
997
+ }
998
+ } finally { docker(["rm", "-f", name], { stdio: "ignore" }) }
999
+ }
1000
+
1001
+ function waitHealthy(sec) {
1002
+ for (let i = 0; i < sec / 10; i++) {
1003
+ const st = docker(["inspect", CONTAINER, "--format", "{{.State.Health.Status}}"], { stdio: ["ignore","pipe","ignore"] }).trim()
1004
+ if (st === "healthy") { ok(`healthy after ${i * 10}s`); return }
1005
+ sh("/bin/sleep", ["10"])
1006
+ }
1007
+ fail("container did not become healthy in time")
1008
+ }
1009
+
1010
+ function buildFinalProof(version, out) {
1011
+ const tag = image(version)
1012
+ const c = JSON.parse(docker(["container", "inspect", CONTAINER, "--format",
1013
+ '{"name":{{json .Name}},"imageId":{{json .Image}},"imageReference":{{json .Config.Image}},"running":{{json .State.Running}},"health":{{json .State.Health.Status}},"labels":{{json .Config.Labels}}}']))
1014
+ const autostart = existsSync("/var/lib/docker/unraid-autostart") &&
1015
+ readFileSync("/var/lib/docker/unraid-autostart", "utf8").split("\n").some((l) => l.split(" ")[0] === "ouro-butler")
1016
+ const text = (tag2) => (readFileSync(TEMPLATE, "utf8").match(new RegExp(`<${tag2}>([^<]*)</${tag2}>`)) || [])[1]
1017
+ const bundle = JSON.parse(sh("/usr/local/bin/node", [`${ROOT}/incoming-package/deploy/unraid/migrate-sanctuary-bundle.mjs`,
1018
+ "--package-root", `${ROOT}/package`, "--agent-root", BUNDLE, "--operation", "status"]))
1019
+ const jf = JSON.parse(docker(["container", "inspect", "jellyfin", "--format",
1020
+ '{"containerId":{{json .Id}},"imageId":{{json .Image}},"state":{{json .State.Status}},"restartCount":{{json .RestartCount}}}']))
1021
+ const proof = {
1022
+ container: { name: c.name, imageId: c.imageId, imageReference: c.imageReference,
1023
+ running: c.running === true, healthy: c.health === "healthy", autostart,
1024
+ labels: { "net.unraid.docker.managed": c.labels["net.unraid.docker.managed"], "net.unraid.docker.icon": c.labels["net.unraid.docker.icon"] } },
1025
+ bundle,
1026
+ dockerMan: { templatePath: TEMPLATE, name: text("Name"), repository: text("Repository"), templateUrl: text("TemplateURL"), icon: text("Icon") },
1027
+ communityApps: { installed: true, name: "ouro-butler", repository: text("Repository"), templateUrl: text("TemplateURL"),
1028
+ stateModel: "previous-apps-inline-v1", entryPath: "/usr/local/emhttp/plugins/community.applications/include/exec.php",
1029
+ entryFunction: "previous_apps", implementationPath: "/usr/local/emhttp/plugins/community.applications/include/exec.php", implementationSymbol: "previous_apps" },
1030
+ jellyfin: jf,
1031
+ }
1032
+ if (c.imageReference !== tag) fail(`container image is ${c.imageReference}, expected ${tag}`)
1033
+ writeFileSync(out, JSON.stringify(proof), { mode: 0o600 })
1034
+ }
1035
+
1036
+ // ---- upgrade (in place, installed authority) --------------------------------
1037
+
1038
+ /**
1039
+ * Keeps host supervision paused for as long as this upgrade process lives, gate and rollback included: a detached
1040
+ * shell touches the maintenance flag every interval while the parent is alive. It never recreates a released flag
1041
+ * (touch -c). Once the parent is gone, however it ended, the shell drops the shutdown flag (which never expires) and
1042
+ * stops, so the maintenance flag ages out and self-heal returns within about 30 minutes of a crash.
1043
+ */
1044
+ export const SUPERVISION_HOLD_SH = 'while kill -0 "$1" 2>/dev/null; do touch -c "$2"; sleep "$4"; done; rm -f "$3"'
1045
+ export function holdSupervision({ spawnImpl = spawn, parentPid = process.pid, flag = MAINTENANCE, shutdownFlag = SHUTDOWN_FLAG, intervalSec = 60 } = {}) {
1046
+ const child = spawnImpl("/bin/sh", ["-c", SUPERVISION_HOLD_SH, "ouro-supervision-hold", String(parentPid), flag, shutdownFlag, String(intervalSec)], { detached: true, stdio: "ignore" })
1047
+ child.unref()
1048
+ return child
1049
+ }
1050
+ let HOLD = null
1051
+
1052
+ // A host still running an older keeper has a cron watchdog that ignores the maintenance
1053
+ // flag: it resurrected the paused supervisor, which restarted the resident mid-upgrade
1054
+ // (the first 830 -> 835 rehearsal). So put this version's keeper scripts in place first
1055
+ // (they honour the flag) and also raise the shutdown flag every watchdog version honours;
1056
+ // resumeSupervision clears both.
1057
+ function pauseSupervision() {
1058
+ writeFileSync(MAINTENANCE, `${new Date().toISOString()} ${process.pid}\n`)
1059
+ writeFileSync(SHUTDOWN_FLAG, `upgrade ${process.pid}\n`)
1060
+ HOLD ??= holdSupervision()
1061
+ for (const [path, body] of [[GATEWAY_SUPERVISOR, GATEWAY_SUPERVISOR_SH], [GATEWAY_WATCHDOG, GATEWAY_WATCHDOG_SH]]) {
1062
+ writeFileSync(path, body)
1063
+ sh("/bin/chown", ["0:0", path]); chmodSync(path, 0o700)
1064
+ }
1065
+ sh("/bin/sh", ["-c", "for p in $(pgrep -f '^/bin/sh /boot/config/custom/ouro-authority/[g]ateway-supervisor' || true); do kill $p; done"])
1066
+ if (sh("/bin/sh", ["-c", "pgrep -fc '^/bin/sh /boot/config/custom/ouro-authority/[g]ateway-supervisor' || true"]).trim() !== "0") fail("a gateway supervisor survived the pause")
1067
+ ok("host supervision paused (keeper scripts current; flags expire on their own)")
1068
+ }
1069
+ function resumeSupervision() {
1070
+ if (HOLD) { try { process.kill(-HOLD.pid, "SIGTERM") } catch { /* already gone; touch -c cannot recreate the flag */ } HOLD = null }
1071
+ rmSync(MAINTENANCE, { force: true })
1072
+ rmSync(SHUTDOWN_FLAG, { force: true })
1073
+ installGatewaySupervisor()
1074
+ }
1075
+
1076
+ /**
1077
+ * The template text without its psyche `<Config>` (Target exactly PSYCHE_TARGET), so a DockerMan Apply cannot put the old read-only
1078
+ * psyche mount back. The element is removed with the whitespace that starts its line and the newline that ends it; every other byte
1079
+ * is kept. `parse` is docker-man-template-xml.cjs's parseDockerManTemplateXml: a template it cannot read, or one without exactly one
1080
+ * such Config, comes back unchanged, as does one where the textual match does not remove exactly that element.
1081
+ */
1082
+ export function withoutPsycheConfig(text, parse) {
1083
+ const psycheConfigs = (document) => document?.children.filter((child) => child.name === "Config" && child.attributes.Target === PSYCHE_TARGET).length
1084
+ const before = parse(text)
1085
+ if (psycheConfigs(before) !== 1) return text
1086
+ const next = text.replace(new RegExp(`[ \\t]*<Config\\b[^<>]*\\sTarget="${PSYCHE_TARGET.replace(/[.]/gu, "\\.")}"[^<>]*(?:/>|>[^<]*</Config>)[ \\t]*\\r?\\n?`, "u"), "")
1087
+ const after = parse(next)
1088
+ return after && psycheConfigs(after) === 0 && after.children.length === before.children.length - 1 ? next : text
1089
+ }
1090
+
1091
+ // Keep Unraid's DockerMan template (what the UI's Apply/Update uses) on the live image, without the old psyche mount.
1092
+ // Runs after the commit, so the parser comes from the installed package (this script is staged on its own, without siblings).
1093
+ function pinTemplate(version) {
1094
+ if (!existsSync(TEMPLATE)) { console.log(" note: no DockerMan template to update"); return }
1095
+ const text = readFileSync(TEMPLATE, "utf8")
1096
+ let next = text.replace(/<Repository>ghcr\.io\/ourostack\/ouroboros-butler:[^<]+<\/Repository>/u, `<Repository>${image(version)}</Repository>`)
1097
+ if (!next.includes(`<Repository>${image(version)}</Repository>`)) { console.log(" WARN: DockerMan template Repository not recognised; left unchanged"); return }
1098
+ try {
1099
+ const { parseDockerManTemplateXml } = createRequire(import.meta.url)(`${ROOT}/package/deploy/unraid/docker-man-template-xml.cjs`)
1100
+ next = withoutPsycheConfig(next, parseDockerManTemplateXml)
1101
+ } catch (error) { console.log(` WARN: could not load the DockerMan template parser (${error.message}); psyche Config left in place`) }
1102
+ if (next.includes(`Target="${PSYCHE_TARGET}"`)) console.log(" WARN: DockerMan template still has a psyche Config; remove it before the next DockerMan Apply")
1103
+ if (next !== text) { writeFileSync(`${TEMPLATE}.prev`, text); writeFileSync(TEMPLATE, next) }
1104
+ ok(`DockerMan template pins ${image(version)}`)
1105
+ }
1106
+
1107
+ // Every release pulls a ~1 GB Butler image, and Unraid's docker.img filled to 88% with
1108
+ // twenty of them (D-046). After a successful upgrade keep only the live image and the
1109
+ // authority's recorded rollback image; Docker itself refuses to remove an image in use.
1110
+ function pruneButlerImages() {
1111
+ const activation = JSON.parse(readFileSync(`${ROOT}/activation.json`, "utf8"))
1112
+ const keep = new Set([activation.targetImageId, activation.rollbackImageId])
1113
+ let removed = 0
1114
+ for (const line of docker(["images", "--no-trunc", "--format", "{{.ID}} {{.Tag}}", "ghcr.io/ourostack/ouroboros-butler"]).trim().split("\n").filter(Boolean)) {
1115
+ const [id, tag] = line.split(" ")
1116
+ if (keep.has(id) || !tag || tag === "<none>") continue
1117
+ try { docker(["rmi", `ghcr.io/ourostack/ouroboros-butler:${tag}`], { stdio: ["ignore", "pipe", "pipe"] }); removed += 1 } catch { /* in use */ }
1118
+ }
1119
+ ok(`pruned ${removed} old Butler image(s); kept the live and rollback images`)
1120
+ }
1121
+
1122
+ // A failed rollback exits with supervision still paused: the keeper's boot would retry the rollback alongside a manual
1123
+ // retry (the 945 rollback on 2026-10-25). The flag then ages out and the keeper's boot retries it on its own.
1124
+ const ROLLBACK_FAILED = (e, lifecycle) => `ROLLBACK FAILED: ${lifecycleFailure(e)}\nThe journal is kept. Host supervision stays paused for about 30 minutes; after that the keeper's authority boot retries the rollback. To retry by hand sooner, keep it paused while you do: \`touch ${MAINTENANCE}; node ${lifecycle} upgrade-rollback\` (repeat the touch if the retry is slow).`
1125
+
1126
+ function lifecycleFailure(e) {
1127
+ const detail = [e.stderr, e.stdout].map((x) => (x ? String(x).trim() : "")).filter(Boolean).join(" | ")
1128
+ let last = ""
1129
+ try { last = readFileSync(`${ROOT}/lifecycle-failure.log`, "utf8").trim().split("\n").filter((l) => /^\d{4}-/.test(l)).at(-1) ?? "" } catch { /* none */ }
1130
+ return `${detail || e.message}${last ? `\n last root failure: ${last}` : ""}`
1131
+ }
1132
+
1133
+ /**
1134
+ * The end of a gated upgrade, with every side effect injected so the decision is testable: a pass commits the held
1135
+ * upgrade, pins the template and prunes; a fail rolls back to the predecessor and confirms it is healthy. Nothing is
1136
+ * committed, pinned or pruned unless the gate passed.
1137
+ */
1138
+ export function completeGatedUpgrade({ runGate, commit, rollback, confirmRollback, pin, prune, log = console.log }) {
1139
+ const gate = runGate()
1140
+ if (gate.ok) {
1141
+ commit()
1142
+ pin()
1143
+ prune()
1144
+ log("GATE PASS")
1145
+ return { passed: true }
1146
+ }
1147
+ log(`gate failed: ${gate.detail ?? "see the case lines above"}`)
1148
+ rollback()
1149
+ const restored = confirmRollback()
1150
+ log(`GATE FAIL — ROLLED BACK to ${restored}`)
1151
+ return { passed: false, rolledBackTo: restored }
1152
+ }
1153
+
1154
+ export function runReplayGate(plant, { spawn = spawnSync, exists = existsSync } = {}) {
1155
+ if (!exists(GATE_SCRIPT)) return { ok: false, detail: `the replay gate is missing from the live package (${GATE_SCRIPT})` }
1156
+ const result = spawn("/usr/local/bin/node", [GATE_SCRIPT, "run", ...(plant ? ["--plant", plant] : [])], { stdio: "inherit", timeout: GATE_TIMEOUT_MS, killSignal: "SIGKILL" })
1157
+ return result.status === 0 ? { ok: true } : { ok: false, detail: `the replay gate exited ${result.status ?? result.signal}` }
1158
+ }
1159
+
1160
+ /**
1161
+ * The commit step runs the lifecycle's `upgrade` again, which would START A FRESH UNGATED UPGRADE if the keeper had
1162
+ * rolled the held one back mid-gate. So commit only while the journal still exists and the new image is still live.
1163
+ */
1164
+ export function heldUpgradeProblem({ journalExists, liveImage, expectedImage }) {
1165
+ if (!journalExists) return "the held upgrade's journal is gone (the keeper or a boot rolled it back mid-gate)"
1166
+ if (liveImage !== expectedImage) return `the live Butler is ${liveImage}, not the held ${expectedImage}`
1167
+ return null
1168
+ }
1169
+
1170
+ function butlerState() {
1171
+ try { return docker(["inspect", CONTAINER, "--format", "{{.Config.Image}} {{.State.Status}}/{{.State.Health.Status}}"], { stdio: ["ignore", "pipe", "ignore"] }).trim() } catch { return "? ?/?" }
1172
+ }
1173
+
1174
+ function waitForButler(imageRef, seconds) {
1175
+ for (let waited = 0; waited <= seconds; waited += 10) {
1176
+ const [liveImage, state] = butlerState().split(" ")
1177
+ if (liveImage === imageRef && state === "running/healthy") return true
1178
+ sh("/bin/sleep", ["10"])
1179
+ }
1180
+ return false
1181
+ }
1182
+
1183
+ // The new gateway asks Telegram who it is (getMe) before it reports ready. When the host cannot reach
1184
+ // Telegram, that fails and the whole upgrade rolls back after host supervision was already paused
1185
+ // (the 863 -> 864 upgrade). Any HTTP answer from the API host proves the network path; no token is sent.
1186
+ export const TELEGRAM_API_URL = "https://api.telegram.org/"
1187
+ export async function telegramApiReachable({ fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), tries = 3, timeoutMs = 8000, retryDelayMs = 3000 } = {}) {
1188
+ let detail = "no attempt made"
1189
+ for (let attempt = 1; attempt <= tries; attempt += 1) {
1190
+ try {
1191
+ const response = await fetchImpl(TELEGRAM_API_URL, { method: "GET", signal: AbortSignal.timeout(timeoutMs) })
1192
+ return { ok: true, detail: `HTTP ${response.status}` }
1193
+ } catch (error) {
1194
+ detail = error instanceof Error ? (error.cause instanceof Error ? `${error.message}: ${error.cause.message}` : error.message) : String(error)
1195
+ if (attempt < tries) await sleep(retryDelayMs)
1196
+ }
1197
+ }
1198
+ return { ok: false, detail: `${detail} (${tries} attempts)` }
1199
+ }
1200
+
1201
+ // A resume or rollback (an upgrade journal exists) must never be blocked by a network probe.
1202
+ export async function checkTelegramBeforePause({ journalExists, probe = telegramApiReachable }) {
1203
+ if (journalExists) return { skipped: true, ok: true, detail: "upgrade journal present" }
1204
+ return { skipped: false, ...(await probe()) }
1205
+ }
1206
+
1207
+ async function upgrade(version, rehearse, { noGate = false, plant } = {}) {
1208
+ say(`in-place upgrade to ${version}${rehearse ? ` — REHEARSAL: stop after "${rehearse}", then roll back` : ""}`)
1209
+ if (rehearse && !UPGRADE_STEPS.includes(rehearse)) fail(`--rehearse takes one of: ${UPGRADE_STEPS.join(", ")}`)
1210
+ const gated = !rehearse && !noGate
1211
+ if (noGate && !rehearse) console.log("\n!! --no-gate: this upgrade will NOT be replay-gated or auto-rolled-back on a behavioural regression")
1212
+ if (gated && !existsSync(GATE_PROVISION)) fail(`replay peers are not provisioned (${GATE_PROVISION}); run \`sanctuary-replay-gate.mjs provision\` first (once, on the upgrade that moves the replay seeds out of the bundle into that root-owned folder, run \`provision --rotate-replay-identities\` instead so no earlier seed survives; any seed or provision.json under state/replay-client in the bundle was writable by the Butler and is ignored), or pass --no-gate`)
1213
+ if (!existsSync(`${ROOT}/active.json`) || !existsSync(`${ROOT}/activation.json`)) fail("no installed authority; use prepare + install")
1214
+ if (existsSync(JOURNAL)) fail("a template transaction is pending; resolve it first")
1215
+ let id = imageId(version)
1216
+ if (!id) { docker(["pull", image(version)]); id = imageId(version) }
1217
+ if (!id) fail(`image ${image(version)} could not be pulled`)
1218
+ ok(`image ${id.slice(0, 19)}…`)
1219
+ const current = JSON.parse(readFileSync(`${ROOT}/request.json`, "utf8"))
1220
+ if (existsSync(UPGRADE_JOURNAL)) console.log(" note: resuming the pending in-place upgrade with its staged package")
1221
+ else {
1222
+ const manifestBytes = stageIncomingPackage(version)
1223
+ const pkgVersion = JSON.parse(readFileSync(`${ROOT}/incoming-package/deploy/unraid/sanctuary.ouro/bundle-meta.json`, "utf8")).runtimeVersion
1224
+ if (pkgVersion !== version) fail(`staged package is ${pkgVersion}, not ${version}`)
1225
+ writePrivate(INCOMING_MANIFEST, manifestBytes)
1226
+ const request = upgradeRequest(current, packagePins(manifestBytes))
1227
+ writePrivate(INCOMING_REQUEST, Buffer.from(JSON.stringify(request)))
1228
+ verifyPackage(`${ROOT}/incoming-package`, INCOMING_MANIFEST, INCOMING_REQUEST)
1229
+ ok("new package staged beside the live one and verified")
1230
+ }
1231
+ const lifecycle = `${ROOT}/incoming-package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js`
1232
+ const priorImage = butlerState().split(" ")[0]
1233
+ const jellyfinBefore = jellyfinState()
1234
+ const policyBefore = current.predecessorContract === "none" ? policyState() : policyState() ?? fail("steward policy missing")
1235
+ const telegram = await checkTelegramBeforePause({ journalExists: existsSync(UPGRADE_JOURNAL) })
1236
+ if (!telegram.ok) fail(`api.telegram.org is not reachable from this host (${telegram.detail}); the new gateway could not pass its Telegram identity check. Nothing was paused or changed; fix the network and re-run.`)
1237
+ if (telegram.skipped) console.log(" note: resuming a pending upgrade; Telegram reachability not re-checked")
1238
+ else ok(`api.telegram.org reachable (${telegram.detail})`)
1239
+ pauseSupervision()
1240
+ let failure = null
1241
+ try {
1242
+ say(`lifecycle upgrade (stop → switch → resident → migrate → start${gated ? ", held uncommitted for the gate" : ""})`)
1243
+ ok(sh("/usr/local/bin/node", [lifecycle, "upgrade", id, image(version), ...(rehearse ? ["--fail-after", rehearse] : gated ? ["--hold-commit"] : [])], { stdio: ["ignore", "pipe", "pipe"] }).trim())
1244
+ } catch (e) { failure = e }
1245
+ // Only the lifecycle's own rehearsal stop is planned; anything else is a real failure,
1246
+ // even during a rehearsal (the first 830 -> 835 rehearsal was mislabelled "as planned").
1247
+ const reason = failure ? lifecycleFailure(failure) : ""
1248
+ const planned = Boolean(failure && rehearse && reason.includes(`rehearsal stopped after ${rehearse}`))
1249
+ if (failure) {
1250
+ console.log(`\n!! ${planned ? "rehearsal stopped as planned" : "upgrade FAILED"}: ${reason}\n!! rolling back to the predecessor`)
1251
+ try { ok(`rollback: ${sh("/usr/local/bin/node", [lifecycle, "upgrade-rollback"], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`) }
1252
+ catch (e) { fail(ROLLBACK_FAILED(e, lifecycle)) }
1253
+ } else if (!gated) { pinTemplate(version); pruneButlerImages() }
1254
+ // A held upgrade stays paused through the gate; commit or rollback resumes it.
1255
+ if (!gated || failure) resumeSupervision()
1256
+ say("preservation")
1257
+ const preserved = []
1258
+ const jellyfinNow = jellyfinState()
1259
+ jellyfinNow === jellyfinBefore ? ok(jellyfinBefore ? "jellyfin unchanged" : "no jellyfin, as before") : (gated && !failure ? (bad("JELLYFIN CHANGED"), preserved.push("jellyfin changed")) : fail("JELLYFIN CHANGED"))
1260
+ unchanged(policyBefore, policyState()) ? ok("steward policy unchanged") : (gated && !failure ? (bad("STEWARD POLICY CHANGED"), preserved.push("steward policy changed")) : fail("STEWARD POLICY CHANGED"))
1261
+ const live = docker(["inspect", CONTAINER, "--format", "{{.Config.Image}} {{.State.Status}}/{{.State.Health.Status}}"]).trim()
1262
+ ok(`butler: ${live}`)
1263
+ if (failure && !planned) fail("upgrade rolled back; the Butler is on its prior version (see above)")
1264
+ if (gated && !failure) {
1265
+ say(`replay gate${plant ? ` (planted failure: ${plant})` : ""}`)
1266
+ const stillHeld = () => heldUpgradeProblem({ journalExists: existsSync(UPGRADE_JOURNAL), liveImage: butlerState().split(" ")[0], expectedImage: image(version) })
1267
+ const outcome = completeGatedUpgrade({
1268
+ runGate: () => {
1269
+ if (preserved.length) return { ok: false, detail: preserved.join("; ") }
1270
+ if (!waitForButler(image(version), 300)) return { ok: false, detail: `the new Butler did not become healthy on ${image(version)}` }
1271
+ const result = runReplayGate(plant)
1272
+ if (!result.ok) return result
1273
+ const problem = stillHeld()
1274
+ return problem ? { ok: false, detail: problem } : result
1275
+ },
1276
+ commit: () => {
1277
+ pauseSupervision()
1278
+ try {
1279
+ const problem = stillHeld()
1280
+ if (problem) throw new Error(`refusing to commit: ${problem}`)
1281
+ ok(`commit: ${sh("/usr/local/bin/node", [lifecycle, "upgrade", id, image(version)], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`)
1282
+ } finally { resumeSupervision() }
1283
+ },
1284
+ rollback: () => {
1285
+ pauseSupervision()
1286
+ try { ok(`rollback: ${sh("/usr/local/bin/node", [lifecycle, "upgrade-rollback"], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`) }
1287
+ catch (e) { fail(ROLLBACK_FAILED(e, lifecycle)) }
1288
+ resumeSupervision()
1289
+ },
1290
+ confirmRollback: () => {
1291
+ if (!waitForButler(priorImage, 300)) fail(`rolled back, but ${priorImage} is not running healthy; inspect \`docker logs ${CONTAINER}\``)
1292
+ return priorImage
1293
+ },
1294
+ pin: () => pinTemplate(version),
1295
+ prune: () => pruneButlerImages(),
1296
+ })
1297
+ if (!outcome.passed) process.exit(1)
1298
+ console.log(`\nUPGRADE done — ${version} live and gate-verified. Run verify.`)
1299
+ return
1300
+ }
1301
+ console.log(rehearse ? "\nREHEARSAL done — the upgrade reached the planned step and the rollback restored the predecessor." : `\nUPGRADE done — ${version} live (NOT gate-verified). Run verify.`)
1302
+ }
1303
+
1304
+ // ---- verify ---------------------------------------------------------------
1305
+
1306
+ function verify(withGate = false) {
1307
+ say("verify")
1308
+ const st = docker(["inspect", CONTAINER, "--format", "{{.Config.Image}} {{.State.Status}}/{{.State.Health.Status}} restarts={{.RestartCount}}"]).trim()
1309
+ ok(`butler: ${st}`)
1310
+ const auth = docker(["logs", CONTAINER, "--since", "2m"], { stdio: ["ignore","pipe","pipe"] }).split("\n").filter((l) => l.includes("401")).length
1311
+ auth === 0 ? ok("no Telegram auth failures in the last 2m") : bad(`${auth} 401s in the last 2m`)
1312
+ existsSync(POLICY) ? ok(`steward policy ${sha12(POLICY)}`) : installedRequest()?.predecessorContract === "none" ? ok("no steward policy yet") : bad("steward policy missing")
1313
+ const psycheMount = JSON.parse(docker(["inspect", CONTAINER, "--format", "{{json .Mounts}}"]) || "[]")
1314
+ psycheMountIssue(psycheMount) === null ? ok("psyche mount is in a supported shape") : bad(psycheMountIssue(psycheMount))
1315
+ existsSync(TEMPLATE) && readFileSync(TEMPLATE, "utf8").includes(`Target="${PSYCHE_TARGET}"`)
1316
+ ? bad("installed DockerMan template still mounts the psyche read-only; a DockerMan Apply would restore it")
1317
+ : ok("installed DockerMan template has no separate psyche mount")
1318
+ const trustMount = JSON.parse(docker(["inspect", CONTAINER, "--format", "{{json .Mounts}}"]) || "[]")
1319
+ trustMountIssue(trustMount) === null ? ok("the operator trust directory is mounted read-only in the resident") : bad(trustMountIssue(trustMount))
1320
+ const trustIssue = existsSync(TRUST_HOST_DIR) ? trustDirIssue(lstatSync(TRUST_HOST_DIR)) : `${TRUST_HOST_DIR} is missing`
1321
+ trustIssue === null ? ok("the host trust directory is root-owned and closed to group and other writes") : bad(trustIssue)
1322
+ const droppedHolders = escalationGrantProblems()
1323
+ droppedHolders.length === 0 ? ok("every escalation holder is in the trusted file") : droppedHolders.forEach((line) => bad(line))
1324
+ const psycheDir = `${BUNDLE}/psyche`
1325
+ if (existsSync(psycheDir)) {
1326
+ const psycheIssues = psycheProblems(psycheDir)
1327
+ psycheIssues.length === 0 ? ok("psyche files are resident-owned 0600") : bad(`psyche files do not match what the resident boot check requires: ${psycheIssues.slice(0, 3).join("; ")}`)
1328
+ } else bad("psyche folder missing from the bundle")
1329
+ ok(containerExists("jellyfin") ? `jellyfin ${docker(["inspect", "jellyfin", "--format", "{{.State.Status}} restarts={{.RestartCount}}"]).trim()}` : "no jellyfin container")
1330
+ if (withGate) {
1331
+ say("replay gate (live version, no upgrade)")
1332
+ const gate = runReplayGate(undefined)
1333
+ gate.ok ? ok("replay gate passed") : bad(`replay gate failed: ${gate.detail}`)
1334
+ }
1335
+ process.exit(RED === 0 ? 0 : 1)
1336
+ }
1337
+
1338
+ function fail(m) { console.error(`\nREFUSING: ${m}`); process.exit(1) }
1339
+
1340
+ // ---- entry ----------------------------------------------------------------
1341
+
1342
+ export function parseUpgradeArgs(argv) {
1343
+ const usage = "Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|upgrade> <version> [--rehearse <step>] [--no-gate] [--plant <case>] | preflight <version> --fresh | prepare <version> --fresh --owner-id <id> | verify [--gate]"
1344
+ const [phase, ...rest] = argv
1345
+ if (!phase || !["preflight", "prepare", "install", "verify", "upgrade"].includes(phase)) throw new Error(usage)
1346
+ const parsed = { phase, version: undefined, rehearse: undefined, noGate: false, plant: undefined, gate: false, fresh: false, ownerId: undefined }
1347
+ for (let i = 0; i < rest.length; i += 1) {
1348
+ const arg = rest[i]
1349
+ if (arg === "--rehearse" && phase === "upgrade" && rest[i + 1]) parsed.rehearse = rest[++i]
1350
+ else if (arg === "--no-gate" && phase === "upgrade") parsed.noGate = true
1351
+ else if (arg === "--plant" && phase === "upgrade" && rest[i + 1]) parsed.plant = rest[++i]
1352
+ else if (arg === "--gate" && phase === "verify") parsed.gate = true
1353
+ else if (arg === "--fresh" && (phase === "preflight" || phase === "prepare")) parsed.fresh = true
1354
+ else if (arg === "--owner-id" && phase === "prepare" && rest[i + 1]) parsed.ownerId = rest[++i]
1355
+ else if (!arg.startsWith("--") && parsed.version === undefined && phase !== "verify") parsed.version = arg
1356
+ else throw new Error(usage)
1357
+ }
1358
+ if (parsed.noGate && parsed.plant) throw new Error("--plant needs the gate; it cannot be combined with --no-gate")
1359
+ if (parsed.rehearse && (parsed.noGate || parsed.plant)) throw new Error("--rehearse does not use the gate; do not combine it with --no-gate or --plant")
1360
+ if (phase === "prepare" && parsed.fresh !== (parsed.ownerId !== undefined)) throw new Error("prepare --fresh needs --owner-id <the owner's Telegram user id>, and --owner-id needs --fresh")
1361
+ if (parsed.ownerId !== undefined && !/^[1-9][0-9]*$/u.test(parsed.ownerId)) throw new Error("--owner-id must be a Telegram user id (a positive number)")
1362
+ if (phase !== "verify" && !/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(parsed.version || "")) throw new Error("a semver version is required, e.g. 0.1.0-alpha.829")
1363
+ return parsed
1364
+ }
1365
+
1366
+ async function run(argv) {
1367
+ let args
1368
+ try { args = parseUpgradeArgs(argv) } catch (error) { console.error(error.message); process.exit(2) }
1369
+ requireRoot()
1370
+ if (args.phase === "preflight") preflight(args.version, { fresh: args.fresh })
1371
+ else if (args.phase === "prepare") await prepare(args.version, { fresh: args.fresh, ownerId: args.ownerId })
1372
+ else if (args.phase === "install") install(args.version)
1373
+ else if (args.phase === "verify") verify(args.gate)
1374
+ else if (args.phase === "upgrade") await upgrade(args.version, args.rehearse, { noGate: args.noGate, plant: args.plant })
1375
+ }
1376
+
1377
+ if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) run(process.argv.slice(2)).catch((error) => { console.error(error); process.exit(1) })