openmausbot 0.1.85 → 0.1.87

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 (262) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  3. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  4. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  5. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  11. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  17. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  19. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  20. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  22. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  23. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  25. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  29. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  31. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  32. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  34. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  35. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  44. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  46. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  47. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  49. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  50. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  52. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  53. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  54. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  55. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  57. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  58. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  60. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  61. package/dist/assets/abnfDiagram-O67JEVCF-BTiuL79F.js +1 -0
  62. package/dist/assets/arc-C88Blc2v.js +1 -0
  63. package/dist/assets/architectureDiagram-NJMV4G6O-BZsJIZrb.js +36 -0
  64. package/dist/assets/blockDiagram-BEXU5L5S-CVeMe9Ah.js +140 -0
  65. package/dist/assets/c4Diagram-YGBWAQC7-Cedk3Bhx.js +38 -0
  66. package/dist/assets/channel-taCNRlUU.js +1 -0
  67. package/dist/assets/chunk-2Q5K7J3B-D-VRqzTB.js +1 -0
  68. package/dist/assets/chunk-3FUC2YCW-BChov01Y.js +2 -0
  69. package/dist/assets/chunk-5VM5RSS4-C-TfwvHy.js +15 -0
  70. package/dist/assets/chunk-GTNCS2PH-Db7CGj5Y.js +88 -0
  71. package/dist/assets/chunk-GWA4HPMP-CNvIfqKT.js +1 -0
  72. package/dist/assets/chunk-JWPE2WC7-C_bEp5tq.js +1 -0
  73. package/dist/assets/chunk-NETBCI7D-Coi4BOls.js +1 -0
  74. package/dist/assets/chunk-WEXAMYUT-CzylEuL3.js +1 -0
  75. package/dist/assets/chunk-XXDRQBXY-m7p7ztaD.js +1 -0
  76. package/dist/assets/classDiagram-v2-NBCMYWYE-BMy6o9x2.js +217 -0
  77. package/dist/assets/cose-bilkent-JH36ORCC-BDQ__0_J.js +1 -0
  78. package/dist/assets/cynefin-OW5HDTMX-Djv4ghUb.js +166 -0
  79. package/dist/assets/cynefinDiagram-VND7K2PF-BULrlGYH.js +62 -0
  80. package/dist/assets/cytoscape.esm-BB4DxJjf.js +321 -0
  81. package/dist/assets/dagre-6A5THRUB-D-jrT_hU.js +4 -0
  82. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  83. package/dist/assets/diagram-22UHCM2B-p2UhQmfL.js +200 -0
  84. package/dist/assets/diagram-3UASUU5V-DHvGWZ7q.js +24 -0
  85. package/dist/assets/diagram-ATOU4E4O-ChhKDot-.js +3 -0
  86. package/dist/assets/diagram-CDSNMT55-B-T7A43R.js +30 -0
  87. package/dist/assets/diagram-MLGK6HIB-CJm8ENNY.js +24 -0
  88. package/dist/assets/diagram-MPIPVDR6-510tlza8.js +41 -0
  89. package/dist/assets/ebnfDiagram-ZINNZB2B-PkvRPHel.js +1 -0
  90. package/dist/assets/elk-276RUBZZ-BVBVPoX3.js +27 -0
  91. package/dist/assets/erDiagram-OPXOYQCR-BgYMVm7L.js +99 -0
  92. package/dist/assets/flowDiagram-KWPJA3E3-BGqsnc_3.js +213 -0
  93. package/dist/assets/ganttDiagram-FUAMR5RP-D_-UeReH.js +292 -0
  94. package/dist/assets/gitGraphDiagram-X574FWY7-D0uooONx.js +106 -0
  95. package/dist/assets/graph-D8UmR1vK.js +1 -0
  96. package/dist/assets/index-BwdMAQ6p.css +1 -0
  97. package/dist/assets/index-ByJennr7.js +581 -0
  98. package/dist/assets/{index-CTNNoqSJ.js → index-zRsJERGJ.js} +1 -1
  99. package/dist/assets/infoDiagram-VRGFBTTK-B9e1z77f.js +2 -0
  100. package/dist/assets/init-Gi6I4Gst.js +1 -0
  101. package/dist/assets/ishikawaDiagram-OU5B5YK6-BB4vNwvD.js +70 -0
  102. package/dist/assets/journeyDiagram-ZHPQQLJL-DpkosOTB.js +139 -0
  103. package/dist/assets/kanban-definition-PNTS6WVX-DbDP4kUu.js +89 -0
  104. package/dist/assets/linear-DBJGPmjT.js +1 -0
  105. package/dist/assets/mermaid.core-DWPRi-ue.js +311 -0
  106. package/dist/assets/mindmap-definition-NLK3R4M7-nszBbJmx.js +96 -0
  107. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  108. package/dist/assets/pegDiagram-GJSIUBJH-CWykJWu_.js +1 -0
  109. package/dist/assets/pieDiagram-5QR66LMP-g3Da-Vnv.js +39 -0
  110. package/dist/assets/quadrantDiagram-O4NWA36T-Wzrlpzgh.js +7 -0
  111. package/dist/assets/railroadDiagram-XR7U4H2S-moHTFMC0.js +1 -0
  112. package/dist/assets/requirementDiagram-PLB6GJNP-CwfXdFH8.js +84 -0
  113. package/dist/assets/sankeyDiagram-IPEJSGJF-3ZROYvnq.js +40 -0
  114. package/dist/assets/sequenceDiagram-PO4LG4MO-E1TBebP8.js +169 -0
  115. package/dist/assets/sizeCapture-INFHLROL-DHKEU5pu.js +1 -0
  116. package/dist/assets/stateDiagram-v2-GCMORJYK-BguEj09D.js +291 -0
  117. package/dist/assets/swimlanes-2SLR337P-C7IDYnhd.js +1 -0
  118. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +8 -0
  119. package/dist/assets/timeline-definition-EJHVYXUP-DCW-F5PW.js +120 -0
  120. package/dist/assets/usecaseDiagram-POWQR4AR-xBMFtWYV.js +370 -0
  121. package/dist/assets/vennDiagram-UO4OBE2U-B5nIkuH8.js +34 -0
  122. package/dist/assets/wardleyDiagram-VNRHLVJA-DYFAhdbr.js +78 -0
  123. package/dist/assets/xychartDiagram-PMCCYNJV-DeXwX6Cz.js +7 -0
  124. package/dist/index.html +2 -2
  125. package/dist-server/browser-proxy.js +2 -1
  126. package/dist-server/container-mcp.js +1047 -173
  127. package/dist-server/drivers/agents-proxy.js +16121 -1504
  128. package/dist-server/drivers/phone-proxy.js +33 -2
  129. package/dist-server/enterprise/LICENSE +32 -0
  130. package/dist-server/enterprise/server/index.js +5 -5
  131. package/dist-server/index.js +16134 -7446
  132. package/dist-server/local-computer-proxy.js +3 -3
  133. package/dist-server/local-computer.js +53 -17
  134. package/dist-server/mcp-server.js +3 -1
  135. package/dist-server/openmausbot.js +9353 -6124
  136. package/dist-server/pair-cli.js +9353 -6124
  137. package/dist-server/permission-proxy.js +29 -9
  138. package/dist-server/server/admin-activity.js +410 -0
  139. package/dist-server/server/attachments.js +55 -0
  140. package/dist-server/server/auto-approve.js +4 -3
  141. package/dist-server/server/auto-vm-claims.js +1 -0
  142. package/dist-server/server/bot-package.js +18 -292
  143. package/dist-server/server/bot-profile.js +5 -17
  144. package/dist-server/server/bot-visibility.js +462 -0
  145. package/dist-server/server/box-create-idempotency.js +3 -3
  146. package/dist-server/server/box.js +59 -56
  147. package/dist-server/server/browser-proxy.js +3 -2
  148. package/dist-server/server/browser-runtime.js +38 -2
  149. package/dist-server/server/channel-queue.js +2 -0
  150. package/dist-server/server/cli-prompts.js +24 -2
  151. package/dist-server/server/cli.js +81 -12
  152. package/dist-server/server/command-allowlist.js +154 -0
  153. package/dist-server/server/composio.js +49 -11
  154. package/dist-server/server/computer-wait.js +22 -9
  155. package/dist-server/server/config.js +133 -17
  156. package/dist-server/server/connector-verdict.js +141 -0
  157. package/dist-server/server/container-computer.js +1 -1
  158. package/dist-server/server/decision-log.js +209 -48
  159. package/dist-server/server/default-model-selection.js +29 -3
  160. package/dist-server/server/delegations.js +97 -44
  161. package/dist-server/server/drivers/acp/core.js +163 -33
  162. package/dist-server/server/drivers/acp/grok.js +4 -3
  163. package/dist-server/server/drivers/agents-call.js +1162 -0
  164. package/dist-server/server/drivers/agents-catalog.js +689 -0
  165. package/dist-server/server/drivers/agents-client.js +28 -0
  166. package/dist-server/server/drivers/agents-proxy.js +18 -1722
  167. package/dist-server/server/drivers/boxagent.js +174 -36
  168. package/dist-server/server/drivers/builtIn.js +2 -0
  169. package/dist-server/server/drivers/chat-box-tools.js +90 -0
  170. package/dist-server/server/drivers/chat-images.js +54 -0
  171. package/dist-server/server/drivers/chat-mcp-tools.js +55 -10
  172. package/dist-server/server/drivers/chat-tool-approval.js +70 -18
  173. package/dist-server/server/drivers/claude.js +28 -8
  174. package/dist-server/server/drivers/codex-instructions.js +48 -6
  175. package/dist-server/server/drivers/codex-mcp-names.js +44 -0
  176. package/dist-server/server/drivers/codex-tool-surface.js +20 -0
  177. package/dist-server/server/drivers/codex.js +121 -34
  178. package/dist-server/server/drivers/grok.js +2 -1
  179. package/dist-server/server/drivers/mistral.js +115 -0
  180. package/dist-server/server/drivers/openai-chat.js +155 -32
  181. package/dist-server/server/drivers/openai-compat.js +16 -8
  182. package/dist-server/server/drivers/permission-command.js +35 -0
  183. package/dist-server/server/drivers/phone-proxy.js +47 -2
  184. package/dist-server/server/drivers/pi.js +75 -4
  185. package/dist-server/server/drivers/prompt-split.js +87 -0
  186. package/dist-server/server/enterprise.js +97 -16
  187. package/dist-server/server/external-runtime.js +83 -0
  188. package/dist-server/server/fleet.js +1 -1
  189. package/dist-server/server/group-thread-usage.js +103 -0
  190. package/dist-server/server/guarded-requests.js +60 -0
  191. package/dist-server/server/harness/http.js +61 -0
  192. package/dist-server/server/harness/registry.js +1 -0
  193. package/dist-server/server/hosted-models.js +151 -0
  194. package/dist-server/server/hosted-slack.js +11 -0
  195. package/dist-server/server/index.js +3091 -658
  196. package/dist-server/server/local-computer.js +72 -23
  197. package/dist-server/server/managed-desktop.js +79 -14
  198. package/dist-server/server/managed-policy.js +153 -0
  199. package/dist-server/server/mcp-bridge.js +6 -6
  200. package/dist-server/server/message-db.js +28 -0
  201. package/dist-server/server/model-context-window.js +7 -0
  202. package/dist-server/server/model-prices.js +121 -0
  203. package/dist-server/server/new-bot-defaults.js +115 -0
  204. package/dist-server/server/notify.js +10 -1
  205. package/dist-server/server/org-library.js +1051 -0
  206. package/dist-server/server/package-export.js +627 -39
  207. package/dist-server/server/package-import.js +458 -0
  208. package/dist-server/server/package-parts.js +87 -0
  209. package/dist-server/server/peer-approval.js +16 -7
  210. package/dist-server/server/peer-roster.js +23 -2
  211. package/dist-server/server/presets.js +492 -0
  212. package/dist-server/server/provider-key-check.js +3 -2
  213. package/dist-server/server/published-teams.js +80 -0
  214. package/dist-server/server/recent-work.js +15 -0
  215. package/dist-server/server/request-auth.js +96 -0
  216. package/dist-server/server/room-handoffs.js +44 -14
  217. package/dist-server/server/routes/bot-presets.js +25 -0
  218. package/dist-server/server/routes/hosted-slack.js +20 -0
  219. package/dist-server/server/routes/table.js +14 -0
  220. package/dist-server/server/routine-requests.js +6 -3
  221. package/dist-server/server/routines.js +63 -1
  222. package/dist-server/server/section-context.js +10 -0
  223. package/dist-server/server/skills.js +34 -61
  224. package/dist-server/server/spend.js +111 -16
  225. package/dist-server/server/steer-queue.js +9 -3
  226. package/dist-server/server/store.js +550 -35
  227. package/dist-server/server/surface.js +10 -4
  228. package/dist-server/server/system-prompt.js +12 -4
  229. package/dist-server/server/team-backup.js +16 -3
  230. package/dist-server/server/team-computers.js +10 -0
  231. package/dist-server/server/team-library.js +6 -2
  232. package/dist-server/server/team-manifest.js +4 -3
  233. package/dist-server/server/team-setup-requests.js +16 -5
  234. package/dist-server/server/thread-events.js +17 -1
  235. package/dist-server/server/thread-starters.js +53 -0
  236. package/dist-server/server/turn-context.js +2 -0
  237. package/dist-server/server/turn-watchdog.js +12 -3
  238. package/dist-server/server/usage-ledger.js +43 -9
  239. package/dist-server/server/vps-computer.js +139 -51
  240. package/dist-server/server/vps-container-mcp.js +7 -0
  241. package/dist-server/server/vps-ssh.js +36 -7
  242. package/dist-server/server/workspace-backup-policy.js +21 -2
  243. package/dist-server/server/workspace-backup.js +7 -4
  244. package/dist-server/server/workspace.js +1 -1
  245. package/dist-server/shared/approval-mode.js +1 -1
  246. package/dist-server/shared/ask-question.js +188 -12
  247. package/dist-server/shared/bot-profile.js +15 -0
  248. package/dist-server/shared/command-allowlist.js +1 -0
  249. package/dist-server/shared/credential-request.js +1 -1
  250. package/dist-server/shared/mention-boundary.js +15 -0
  251. package/dist-server/shared/package-format.js +967 -0
  252. package/dist-server/shared/routines.js +7 -1
  253. package/dist-server/shared/skill-md.js +64 -0
  254. package/dist-server/shared/team-backup.js +9 -0
  255. package/dist-server/shared/wire.js +4 -0
  256. package/dist-server/src/lib/composer-attachments.js +16 -0
  257. package/dist-server/vps-container-mcp.js +1147 -179
  258. package/enterprise/README.md +33 -12
  259. package/enterprise/server/index.js +5 -5
  260. package/package.json +1 -1
  261. package/dist/assets/index-Ba9G44HI.js +0 -310
  262. package/dist/assets/index-CkRNp7wX.css +0 -1
@@ -0,0 +1,1051 @@
1
+ // The organization library: packages an organization's Admin shares with
2
+ // this desktop, shown on a shelf (Templates → From {Organization}) and added
3
+ // with one click.
4
+ //
5
+ // Electron main owns the Admin connection (electron/managed-desktop.mjs): it
6
+ // fetches the catalog and every release file, checks their SHA-256, stores
7
+ // the files in DATA_DIR/org-library/blobs/, and relays the catalog here over
8
+ // the private utility port. This runtime never talks to Admin. It:
9
+ //
10
+ // - keeps the relayed catalog in memory (a sign-out relays null: the shelf
11
+ // disappears and everything already added stays);
12
+ // - adds a package through the one importer with trust "org": skills
13
+ // switched ON (the organization's Admin published them), routines paused,
14
+ // every record stamped with its install and per-part hashes;
15
+ // - keeps DATA_DIR/org-library/state.json as an index of what was added.
16
+ // The records themselves are the source of truth: the index is rebuilt
17
+ // from them on start, before every Add and whenever records disappear, so
18
+ // a crash between the two writes is adopted, never duplicated. A team Add
19
+ // is marked in the index before its first record, so one the app stopped
20
+ // halfway through is removed again rather than adopted half-built;
21
+ // - switches off the skills and pauses the routines of a release its
22
+ // publisher withdrew;
23
+ // - reports a full snapshot of what this desktop has back to Electron,
24
+ // which posts it to Admin (contract §5.5).
25
+ //
26
+ // With no organization, nothing here runs: the shelf is absent, the routes
27
+ // answer {organization: null}, and no file is written.
28
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, } from "node:fs";
29
+ import { join } from "node:path";
30
+ import { z } from "zod";
31
+ import { writeFileAtomic } from "./atomic.js";
32
+ import { importPackageDocument, orgInstallId, PackageImportError, } from "./package-import.js";
33
+ import { pair, partHash, sha256Hex } from "./package-parts.js";
34
+ import { storedPresetObject } from "./presets.js";
35
+ import { sectionKey } from "./store.js";
36
+ import { PACKAGE_MAX_BYTES, PACKAGE_VERSION, PackageFormatError, parsePackageDocument, } from "../shared/package-format.js";
37
+ export const ORG_LIBRARY_FORMAT = "openmaus.org-library";
38
+ /** The catalog body cap (contract §1.8). */
39
+ export const ORG_LIBRARY_CATALOG_MAX_BYTES = 256 * 1024;
40
+ export const ORG_LIBRARY_MAX_ENTRIES = 100;
41
+ export const ORG_LIBRARY_REPORT_MAX_ENTRIES = 100;
42
+ const UUID = /^[a-f0-9-]{36}$/;
43
+ const HEX64 = /^[a-f0-9]{64}$/;
44
+ const INSTALL_ID = /^[a-f0-9]{32}$/;
45
+ const SEMVER = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/;
46
+ const REF = /^[a-z][a-z0-9-]{1,30}\/[a-z0-9][a-z0-9-]{0,79}$/;
47
+ /** Electron's safeText rule (electron/managed-desktop.mjs): no control characters. */
48
+ // oxlint-disable-next-line no-control-regex
49
+ const CONTROL = /[\x00-\x1f\x7f]/;
50
+ // oxlint-disable-next-line no-control-regex
51
+ const CONTROL_EXCEPT_LINES = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
52
+ const safeText = (max) => z.string().min(1).max(max).refine((value) => !CONTROL.test(value));
53
+ const count = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
54
+ const entrySchema = z.object({
55
+ packageId: z.string().regex(UUID),
56
+ ref: z.string().regex(REF),
57
+ name: safeText(100),
58
+ tagline: safeText(160),
59
+ kind: z.enum(["team", "library"]),
60
+ publisher: z.object({ organizationId: z.string().regex(UUID), name: safeText(100), self: z.boolean() }),
61
+ mode: z.enum(["required", "available", "off"]),
62
+ offAction: z.enum(["keep", "remove"]),
63
+ release: z.object({
64
+ version: z.string().regex(SEMVER),
65
+ sha256: z.string().regex(HEX64),
66
+ sizeBytes: z.number().int().min(1).max(PACKAGE_MAX_BYTES),
67
+ formatVersion: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
68
+ publishedAt: count,
69
+ notes: z.string().max(4_000).refine((value) => !CONTROL_EXCEPT_LINES.test(value)),
70
+ }).nullable(),
71
+ withdrawnReleases: z.array(z.object({ version: z.string().regex(SEMVER), sha256: z.string().regex(HEX64) })).max(50),
72
+ contents: z.object({
73
+ bots: count, skills: count, presets: count, rooms: count, routines: count, connections: count,
74
+ botNames: z.array(safeText(100)).max(12),
75
+ }),
76
+ scanFindings: count,
77
+ });
78
+ const envelopeSchema = z.object({
79
+ format: z.literal(ORG_LIBRARY_FORMAT),
80
+ version: z.literal(1),
81
+ libraryVersion: count,
82
+ organization: z.object({ id: z.string().regex(UUID), name: safeText(100) }),
83
+ truncated: z.literal(true).optional(),
84
+ packages: z.array(z.unknown()).max(ORG_LIBRARY_MAX_ENTRIES),
85
+ });
86
+ /** The catalog as §5.3 defines it. Unknown fields are ignored and a malformed
87
+ * entry is dropped; a malformed envelope returns null (keep the last one). */
88
+ export function parseOrgLibraryCatalog(value) {
89
+ const envelope = envelopeSchema.safeParse(value);
90
+ if (!envelope.success)
91
+ return null;
92
+ const seen = new Set();
93
+ const packages = envelope.data.packages.flatMap((raw) => {
94
+ const entry = entrySchema.safeParse(raw);
95
+ if (!entry.success || seen.has(entry.data.packageId))
96
+ return [];
97
+ seen.add(entry.data.packageId);
98
+ return [entry.data];
99
+ });
100
+ return {
101
+ format: ORG_LIBRARY_FORMAT,
102
+ version: 1,
103
+ libraryVersion: envelope.data.libraryVersion,
104
+ organization: { ...envelope.data.organization },
105
+ ...(envelope.data.truncated ? { truncated: true } : {}),
106
+ packages,
107
+ };
108
+ }
109
+ /** An Admin origin: https, or http on loopback, with nothing after the host. */
110
+ function adminOrigin(value) {
111
+ if (typeof value !== "string" || value.length > 300)
112
+ return null;
113
+ try {
114
+ const url = new URL(value);
115
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
116
+ if (!(url.protocol === "https:" || (url.protocol === "http:" && loopback)))
117
+ return null;
118
+ return url.origin === value ? value : null;
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ const relaySchema = z.object({
125
+ adminOrigin: z.string(),
126
+ organizationId: z.string().regex(UUID),
127
+ organizationName: safeText(100).optional(),
128
+ digest: z.string().regex(HEX64),
129
+ catalog: z.unknown(),
130
+ });
131
+ // ── state.json (contract §3.4) ──────────────────────────────────────────
132
+ const partPair = z.object({ r: z.string().regex(HEX64), w: z.string().regex(HEX64) });
133
+ const installSchema = z.object({
134
+ packageId: z.string().regex(UUID),
135
+ ref: z.string().regex(REF),
136
+ publisher: z.object({ organizationId: z.string().regex(UUID), slug: z.string().min(1).max(40), name: z.string().min(1).max(100) }),
137
+ release: z.string().regex(SEMVER),
138
+ sha256: z.string().regex(HEX64),
139
+ status: z.enum(["installed", "withdrawn", "removed"]),
140
+ // Additive to §3.4: what kind of package, and its display name.
141
+ kind: z.enum(["team", "library"]).default("team"),
142
+ name: z.string().max(100).default(""),
143
+ section: z.string().max(60),
144
+ team: z.object({ parts: z.object({ name: partPair.optional(), brief: partPair.optional(), leader: partPair.optional() }) }),
145
+ bots: z.record(z.string(), z.string()),
146
+ rooms: z.record(z.string(), z.string()),
147
+ routines: z.record(z.string(), z.string()),
148
+ connections: z.record(z.string(), z.object({ name: z.string(), r: z.string().regex(HEX64), w: z.string().regex(HEX64) })),
149
+ presets: z.record(z.string(), z.object({ presetId: z.string(), r: z.string().regex(HEX64) })),
150
+ removedLocally: z.array(z.string()).max(1_000),
151
+ // Additive to §3.4: a removed team's release whose withdrawal has already
152
+ // switched off the copies it left (offered skills on other bots).
153
+ withdrawnHandled: z.string().regex(HEX64).optional(),
154
+ addedAt: count,
155
+ updatedAt: count,
156
+ });
157
+ // Additive to §3.4: a team Add that has started and not finished. Written
158
+ // before the first record, removed with the index write that completes it.
159
+ // The import is synchronous, so one found on disk means the app stopped
160
+ // mid-import (the importer's own rollback runs on errors only).
161
+ const partKeys = z.array(z.string().min(1).max(64)).max(500);
162
+ const addingSchema = z.object({
163
+ packageId: z.string().regex(UUID),
164
+ ref: z.string().regex(REF),
165
+ publisher: installSchema.shape.publisher,
166
+ name: z.string().max(100),
167
+ release: z.string().regex(SEMVER),
168
+ sha256: z.string().regex(HEX64),
169
+ startedAt: count,
170
+ /** What a finished import has: every bot, group chat and routine key, and the leader. */
171
+ expect: z.object({ bots: partKeys, rooms: partKeys, routines: partKeys, leader: z.string().min(1).max(64).nullable() }),
172
+ });
173
+ const stateSchema = z.object({
174
+ version: z.literal(1),
175
+ source: z.object({ adminOrigin: z.string(), organizationId: z.string().regex(UUID) }).nullable(),
176
+ appliedDigest: z.string().regex(HEX64).nullable(),
177
+ installs: z.record(z.string().regex(INSTALL_ID), z.unknown()),
178
+ adding: z.record(z.string().regex(INSTALL_ID), z.unknown()).optional(),
179
+ });
180
+ function emptyState() {
181
+ return { version: 1, source: null, appliedDigest: null, installs: {}, adding: {} };
182
+ }
183
+ /** A team install lives in its own bots, group chats and routines. An
184
+ * offered skill someone put on another bot is a copy, like any skill: it
185
+ * stays, but it does not keep the team "installed". */
186
+ function teamAlive(live) {
187
+ return Boolean(live && (Object.keys(live.bots).length || Object.keys(live.rooms).length || Object.keys(live.routines).length));
188
+ }
189
+ /** Contract §3.4 `removedLocally`: the parts the person deleted, so the
190
+ * automatic update (v1.1) can tell them from parts a new release adds and
191
+ * never brings them back. Before an install's bots, group chats and routines
192
+ * are replaced by what is left, each key that went is noted once, as
193
+ * `agent:<key>`, `room:<key>` or `routine:<key>`. A key that exists again
194
+ * (records restored from a backup) is dropped, so the list never names a
195
+ * part that is there. Returns whether the list changed. */
196
+ function noteRemovedParts(install, next) {
197
+ const removed = new Set(install.removedLocally);
198
+ const present = new Set();
199
+ for (const [kind, was, now] of [
200
+ ["agent", install.bots, next.bots],
201
+ ["room", install.rooms, next.rooms],
202
+ ["routine", install.routines, next.routines],
203
+ ]) {
204
+ for (const key of Object.keys(was))
205
+ if (!Object.hasOwn(now, key))
206
+ removed.add(`${kind}:${key}`);
207
+ for (const key of Object.keys(now))
208
+ present.add(`${kind}:${key}`);
209
+ }
210
+ const after = [...removed].filter((part) => !present.has(part));
211
+ if (after.length === install.removedLocally.length && after.every((part, index) => part === install.removedLocally[index]))
212
+ return false;
213
+ install.removedLocally = after;
214
+ return true;
215
+ }
216
+ const refusal = (status, code, error) => ({ ok: false, status, code, error });
217
+ const NOT_LISTED = "This package is not in your organization's library.";
218
+ const NOT_READY = "This package hasn't finished downloading yet. Try again in a minute.";
219
+ const NEWER_APP = "Update OpenMausBot to add this package.";
220
+ const ADD_FAILED = "This package could not be added, so nothing was changed. Try again, or ask your organization's admin.";
221
+ export class OrgLibrary {
222
+ deps;
223
+ root;
224
+ state;
225
+ library = null;
226
+ /** Adds that failed since the last success, by package (reported as failed). */
227
+ failures = new Map();
228
+ /** stat-keyed "this blob verified" cache for listings; content reads always re-verify. */
229
+ verified = new Map();
230
+ pending = Promise.resolve();
231
+ recheck = null;
232
+ lastPosted = null;
233
+ /** The install id add() is importing right now (never a crashed one). */
234
+ importing = null;
235
+ unsubscribe;
236
+ constructor(deps) {
237
+ this.deps = deps;
238
+ this.root = join(deps.dataDir, "org-library");
239
+ this.state = this.load();
240
+ // Records are the source of truth: reconcile the index with what exists.
241
+ if (this.rebuild())
242
+ this.save();
243
+ this.unsubscribe = deps.store.onChange((change) => this.onStoreChange(change));
244
+ }
245
+ dispose() {
246
+ this.unsubscribe();
247
+ if (this.recheck)
248
+ clearTimeout(this.recheck);
249
+ }
250
+ /** Resolves once relayed work (reconcile, withdrawn handling, report) is done. */
251
+ settled() {
252
+ return this.pending;
253
+ }
254
+ /** The last report snapshot handed to Electron (for tests and fixtures). */
255
+ lastReport() {
256
+ return this.lastPosted ? structuredClone(this.lastPosted) : null;
257
+ }
258
+ get organization() {
259
+ return this.library ? { ...this.library.catalog.organization } : null;
260
+ }
261
+ // ── the relay (Electron main → runtime) ───────────────────────────────
262
+ /** `openmausbot:managed-library`: swap the catalog and return at once; the
263
+ * reconcile and the report run afterwards (the relay times out at 15 s).
264
+ * null (sign-out, expiry, revocation) hides the shelf and changes nothing
265
+ * else: everything already added stays. */
266
+ applyRelay(value) {
267
+ if (value === null) {
268
+ this.library = null;
269
+ return { ok: true };
270
+ }
271
+ const relay = relaySchema.safeParse(value);
272
+ const origin = relay.success ? adminOrigin(relay.data.adminOrigin) : null;
273
+ if (!relay.success || !origin)
274
+ return { ok: false, error: "The organization's library could not be read." };
275
+ let raw = relay.data.catalog;
276
+ if (typeof raw === "string") {
277
+ // The raw body Electron applied: it must be the bytes the digest names.
278
+ if (Buffer.byteLength(raw, "utf8") > ORG_LIBRARY_CATALOG_MAX_BYTES || sha256Hex(raw) !== relay.data.digest) {
279
+ return { ok: false, error: "The organization's library could not be read." };
280
+ }
281
+ try {
282
+ raw = JSON.parse(raw);
283
+ }
284
+ catch {
285
+ return { ok: false, error: "The organization's library could not be read." };
286
+ }
287
+ }
288
+ const catalog = parseOrgLibraryCatalog(raw);
289
+ // A malformed envelope or another organization's catalog keeps the last one.
290
+ if (!catalog || catalog.organization.id !== relay.data.organizationId) {
291
+ return { ok: false, error: "The organization's library could not be read." };
292
+ }
293
+ this.library = { adminOrigin: origin, organizationId: relay.data.organizationId, digest: relay.data.digest, catalog };
294
+ const applied = this.library;
295
+ this.pending = this.pending.then(() => new Promise((resolve) => {
296
+ setImmediate(() => {
297
+ try {
298
+ if (this.library === applied)
299
+ this.reconcile(applied);
300
+ }
301
+ catch (error) {
302
+ console.error(`[org-library] could not apply the library: ${error instanceof Error ? error.message : String(error)}`);
303
+ }
304
+ resolve();
305
+ });
306
+ }));
307
+ return { ok: true };
308
+ }
309
+ reconcile(applied) {
310
+ this.rebuild();
311
+ this.handleWithdrawn();
312
+ this.state.source = { adminOrigin: applied.adminOrigin, organizationId: applied.organizationId };
313
+ this.state.appliedDigest = applied.digest;
314
+ this.save();
315
+ this.report();
316
+ }
317
+ // ── records → index ───────────────────────────────────────────────────
318
+ currentInstallId(packageId) {
319
+ return this.library ? orgInstallId(this.library.adminOrigin, this.library.organizationId, packageId) : null;
320
+ }
321
+ /** Every live record that carries an organization install id. A routine
322
+ * counts only while its bot exists (deleting a bot pauses, not removes,
323
+ * its routines). */
324
+ scanRecords() {
325
+ const { store } = this.deps;
326
+ const found = new Map();
327
+ const entry = (installId) => {
328
+ let value = found.get(installId);
329
+ if (!value) {
330
+ value = { bots: {}, rooms: {}, routines: {}, skills: 0, sections: [] };
331
+ found.set(installId, value);
332
+ }
333
+ return value;
334
+ };
335
+ for (const bot of store.bots) {
336
+ const stamp = bot.installedPackage;
337
+ if (stamp?.source === "org" && stamp.installId && INSTALL_ID.test(stamp.installId) && stamp.agentKey) {
338
+ const value = entry(stamp.installId);
339
+ value.bots[stamp.agentKey] = bot.id;
340
+ value.sections.push(sectionKey(bot.section));
341
+ value.meta ??= stamp;
342
+ }
343
+ for (const skill of this.deps.skills.stamps(bot.id))
344
+ entry(skill.stamp.installId).skills += 1;
345
+ }
346
+ for (const group of store.groups) {
347
+ if (group.installedPackage)
348
+ entry(group.installedPackage.installId).rooms[group.installedPackage.key] = group.id;
349
+ }
350
+ for (const routine of this.deps.routines.packageStamps()) {
351
+ if (store.bot(routine.botId))
352
+ entry(routine.stamp.installId).routines[routine.stamp.key] = routine.routineId;
353
+ }
354
+ return found;
355
+ }
356
+ /** Whether anything here came from an organization. Without an install,
357
+ * a stamped record or a relayed catalog there is nothing to reconcile, so
358
+ * an installation that never had an organization reads no skill state at
359
+ * all. (Skill stamps alone are read only once a catalog arrives: they are
360
+ * how a skills-only package is recognized after a lost state.json.) */
361
+ hasOrgRecords() {
362
+ return Object.keys(this.state.installs).length > 0 ||
363
+ Object.keys(this.state.adding).length > 0 ||
364
+ this.deps.store.bots.some((bot) => bot.installedPackage?.source === "org") ||
365
+ this.deps.store.groups.some((group) => group.installedPackage) ||
366
+ this.deps.routines.packageStamps().length > 0 ||
367
+ this.orgPresetRows().length > 0;
368
+ }
369
+ /** Preset rows (presets.ts) an organization install registered. */
370
+ orgPresetRows() {
371
+ return (this.deps.presets?.list() ?? []).filter((row) => row.source === "org" && INSTALL_ID.test(row.installId));
372
+ }
373
+ /** Reconcile state.json with the records. Returns whether it changed. */
374
+ rebuild() {
375
+ if (!this.library && !this.hasOrgRecords())
376
+ return false;
377
+ const now = this.deps.now?.() ?? Date.now();
378
+ let changed = this.resolveInterruptedAdds(now);
379
+ const records = this.scanRecords();
380
+ for (const [installId, install] of Object.entries(this.state.installs)) {
381
+ const live = records.get(installId);
382
+ if (!teamAlive(live)) {
383
+ // A library install creates no records of its own; it stays.
384
+ if (install.kind === "team" && install.status !== "removed") {
385
+ // Every part is noted too, so the list reads the same whether the
386
+ // person deleted the team at once or one bot at a time.
387
+ noteRemovedParts(install, { bots: {}, rooms: {}, routines: {} });
388
+ install.status = "removed";
389
+ install.bots = {};
390
+ install.rooms = {};
391
+ install.routines = {};
392
+ install.updatedAt = now;
393
+ changed = true;
394
+ }
395
+ continue;
396
+ }
397
+ const next = {
398
+ bots: live.bots,
399
+ rooms: live.rooms,
400
+ routines: live.routines,
401
+ section: mostCommon(live.sections) ?? install.section,
402
+ status: install.status === "removed" ? "installed" : install.status,
403
+ };
404
+ const noted = noteRemovedParts(install, next);
405
+ if (noted || JSON.stringify([install.bots, install.rooms, install.routines, install.section, install.status]) !==
406
+ JSON.stringify([next.bots, next.rooms, next.routines, next.section, next.status])) {
407
+ Object.assign(install, next, { updatedAt: now });
408
+ changed = true;
409
+ }
410
+ }
411
+ // Records with no index entry: a crash after the records but before
412
+ // state.json. Adopt them once the catalog says which package they are.
413
+ for (const [installId, live] of records) {
414
+ if (this.state.installs[installId] || !this.library)
415
+ continue;
416
+ const entry = this.library.catalog.packages.find((candidate) => this.currentInstallId(candidate.packageId) === installId);
417
+ if (!entry)
418
+ continue;
419
+ // A team is adopted from its own records. A skills-only package has
420
+ // none; an offered skill carrying its stamp shows it was added.
421
+ if (entry.kind === "team" ? !teamAlive(live) : !live.skills)
422
+ continue;
423
+ const meta = live.meta;
424
+ const [slug] = entry.ref.split("/");
425
+ this.state.installs[installId] = {
426
+ packageId: entry.packageId,
427
+ ref: meta?.ref ?? entry.ref,
428
+ publisher: meta?.publisher ?? { organizationId: entry.publisher.organizationId, slug: slug, name: entry.publisher.name },
429
+ release: meta?.release ?? entry.release?.version ?? "0.0.0",
430
+ sha256: meta?.sha256 ?? entry.release?.sha256 ?? "0".repeat(64),
431
+ status: "installed",
432
+ kind: entry.kind,
433
+ name: meta?.name ?? entry.name,
434
+ section: mostCommon(live.sections) ?? "",
435
+ // What was written at install is not recoverable here; with no base
436
+ // hashes a later update treats these parts as edited and keeps them.
437
+ // Nor is what the person deleted before state.json was lost, so
438
+ // removedLocally starts empty (docs/org-library.md, known limit).
439
+ team: { parts: {} },
440
+ bots: live.bots,
441
+ rooms: live.rooms,
442
+ routines: live.routines,
443
+ connections: {},
444
+ presets: {},
445
+ removedLocally: [],
446
+ addedAt: now,
447
+ updatedAt: now,
448
+ };
449
+ changed = true;
450
+ }
451
+ if (this.indexPresets(now))
452
+ changed = true;
453
+ return changed;
454
+ }
455
+ /** Preset rows never keep a team "installed" (its bots, group chats and
456
+ * routines do), but they are a skills-and-presets package's only records.
457
+ * Such an install whose index entry was lost (the app stopped between
458
+ * presets.json and state.json, or state.json could not be read) is adopted
459
+ * from them, so Add stays a no-op, its skills stay offered and a
460
+ * withdrawal still reaches its presets. An adopted install of either kind
461
+ * gets its presets back in the index. */
462
+ indexPresets(now) {
463
+ const byInstall = new Map();
464
+ for (const row of this.orgPresetRows())
465
+ byInstall.set(row.installId, [...(byInstall.get(row.installId) ?? []), row]);
466
+ let changed = false;
467
+ for (const [installId, rows] of byInstall) {
468
+ let install = this.state.installs[installId];
469
+ if (!install) {
470
+ const entry = this.library?.catalog.packages.find((candidate) => this.currentInstallId(candidate.packageId) === installId);
471
+ // A team is adopted from its own records (above), never from presets alone.
472
+ if (!entry || entry.kind !== "library")
473
+ continue;
474
+ const row = rows[0];
475
+ const [slug] = entry.ref.split("/");
476
+ const adopted = installSchema.safeParse({
477
+ packageId: entry.packageId,
478
+ ref: row.ref ?? entry.ref,
479
+ publisher: row.publisher ?? { organizationId: entry.publisher.organizationId, slug, name: entry.publisher.name },
480
+ release: row.release,
481
+ sha256: row.sha256 ?? entry.release?.sha256,
482
+ status: "installed", kind: "library", name: entry.name, section: "", team: { parts: {} },
483
+ bots: {}, rooms: {}, routines: {}, connections: {}, presets: {}, removedLocally: [], addedAt: now, updatedAt: now,
484
+ });
485
+ if (!adopted.success)
486
+ continue;
487
+ install = this.state.installs[installId] = adopted.data;
488
+ changed = true;
489
+ }
490
+ if (Object.keys(install.presets).length)
491
+ continue;
492
+ // A row is the preset exactly as the release carried it (§1.6).
493
+ install.presets = Object.fromEntries(rows.map((row) => [row.key, { presetId: row.id, r: partHash(storedPresetObject(row)) }]));
494
+ install.updatedAt = now;
495
+ changed = true;
496
+ }
497
+ return changed;
498
+ }
499
+ /** Team Adds the app never finished (it stopped mid-import). A team whose
500
+ * records are all there is indexed, as any crash after the records is
501
+ * (§3.3). A partial one is removed again, so the person can add it whole
502
+ * from the shelf instead of keeping half a team that Add calls added. */
503
+ resolveInterruptedAdds(now) {
504
+ const pending = Object.entries(this.state.adding).filter(([installId]) => installId !== this.importing);
505
+ if (!pending.length)
506
+ return false;
507
+ const records = this.scanRecords();
508
+ for (const [installId, add] of pending) {
509
+ const live = records.get(installId);
510
+ if (live && this.finished(add, live)) {
511
+ const previous = this.state.installs[installId];
512
+ this.state.installs[installId] = {
513
+ packageId: add.packageId, ref: add.ref, publisher: { ...add.publisher }, release: add.release, sha256: add.sha256,
514
+ status: "installed", kind: "team", name: add.name, section: mostCommon(live.sections) ?? "",
515
+ // As with any adopted install, the team-part and connection hashes
516
+ // were only in the index write that never happened.
517
+ team: { parts: {} }, bots: live.bots, rooms: live.rooms, routines: live.routines, connections: {}, presets: {},
518
+ removedLocally: [], addedAt: previous?.addedAt ?? add.startedAt, updatedAt: now,
519
+ };
520
+ }
521
+ else {
522
+ if (live && teamAlive(live))
523
+ this.discardPartial(installId, live);
524
+ this.failures.set(add.packageId, { packageId: add.packageId, release: add.release, sha256: add.sha256, state: "failed", reason: "import_failed" });
525
+ }
526
+ delete this.state.adding[installId];
527
+ }
528
+ return true;
529
+ }
530
+ /** Every bot (with its skills and starter notes), group chat and routine
531
+ * the Add would have written, and its leader, which is set last. */
532
+ finished(add, live) {
533
+ const { store } = this.deps;
534
+ return add.expect.bots.every((key) => Boolean(live.bots[key] && store.bot(live.bots[key])?.packageBase)) &&
535
+ add.expect.rooms.every((key) => Boolean(live.rooms[key])) &&
536
+ add.expect.routines.every((key) => Boolean(live.routines[key])) &&
537
+ (add.expect.leader === null || store.bot(live.bots[add.expect.leader] ?? "")?.chiefOfStaff === true);
538
+ }
539
+ /** The importer's rollback, for an import the app stopped in the middle
540
+ * of: its routines, group chats and bots go, and its new team with them.
541
+ * Every one of its routines belongs to one of its new bots, and a group
542
+ * chat it had not stamped yet has only those bots in that team. */
543
+ discardPartial(installId, live) {
544
+ const { store, routines } = this.deps;
545
+ const botIds = new Set(Object.values(live.bots));
546
+ for (const routine of routines.listRoutines()) {
547
+ if (botIds.has(routine.botId))
548
+ routines.remove(routine.id);
549
+ }
550
+ const section = mostCommon(live.sections);
551
+ const groups = store.groups.filter((group) => group.installedPackage?.installId === installId ||
552
+ (!group.installedPackage && section !== undefined && sectionKey(group.section) === section &&
553
+ group.memberIds.length > 0 && group.memberIds.every((id) => botIds.has(id))));
554
+ for (const group of groups)
555
+ store.deleteGroup(group.id);
556
+ for (const id of botIds)
557
+ store.deleteBot(id);
558
+ // Only if nothing else is in it (the store refuses otherwise).
559
+ if (section && store.sections.includes(section))
560
+ store.changeEmptySection(section, null);
561
+ console.warn(`[org-library] removed a team that was only partly added when OpenMausBot stopped; it can be added again from the shelf`);
562
+ }
563
+ /** A release its publisher withdrew: its skills off, its routines paused,
564
+ * its status "withdrawn". Only the transition acts, so a person who
565
+ * switches something back on afterwards is not overruled again. A team
566
+ * the person already removed stays "removed", but an offered skill it
567
+ * left on another bot is switched off too, once. */
568
+ handleWithdrawn() {
569
+ if (!this.library)
570
+ return false;
571
+ let changed = false;
572
+ for (const [installId, install] of Object.entries(this.state.installs)) {
573
+ if (this.currentInstallId(install.packageId) !== installId)
574
+ continue;
575
+ if (install.status === "withdrawn" || (install.status === "removed" && install.withdrawnHandled === install.sha256))
576
+ continue;
577
+ const entry = this.library.catalog.packages.find((candidate) => candidate.packageId === install.packageId);
578
+ if (!entry?.withdrawnReleases.some((withdrawn) => withdrawn.sha256 === install.sha256))
579
+ continue;
580
+ this.switchOff(installId);
581
+ if (install.status === "installed")
582
+ install.status = "withdrawn";
583
+ else
584
+ install.withdrawnHandled = install.sha256;
585
+ install.updatedAt = this.deps.now?.() ?? Date.now();
586
+ changed = true;
587
+ }
588
+ return changed;
589
+ }
590
+ switchOff(installId) {
591
+ for (const bot of this.deps.store.bots) {
592
+ for (const skill of this.deps.skills.stamps(bot.id)) {
593
+ if (skill.stamp.installId !== installId || !skill.enabled)
594
+ continue;
595
+ const result = this.deps.skills.setEnabled(bot.id, skill.name, false);
596
+ if ("error" in result)
597
+ console.warn(`[org-library] could not switch off ${skill.name}: ${result.error}`);
598
+ }
599
+ }
600
+ // A bot made from one of the install's presets (presets.ts) got the
601
+ // preset's skills under the release's organization source, unstamped.
602
+ for (const bot of this.deps.store.bots) {
603
+ const made = bot.installedPackage;
604
+ if (made?.source !== "org" || made.installId !== installId || !made.presetKey || !made.ref)
605
+ continue;
606
+ const source = `org:${made.ref}@${made.release}`;
607
+ for (const skill of this.deps.skills.list(bot.id)) {
608
+ if (skill.source !== source || !skill.enabled)
609
+ continue;
610
+ const result = this.deps.skills.setEnabled(bot.id, skill.name, false);
611
+ if ("error" in result)
612
+ console.warn(`[org-library] could not switch off ${skill.name}: ${result.error}`);
613
+ }
614
+ }
615
+ for (const routine of this.deps.routines.packageStamps()) {
616
+ if (routine.stamp.installId !== installId || !routine.enabled)
617
+ continue;
618
+ try {
619
+ this.deps.routines.update(routine.routineId, { enabled: false });
620
+ }
621
+ catch (error) {
622
+ console.warn(`[org-library] could not pause a routine: ${error instanceof Error ? error.message : String(error)}`);
623
+ }
624
+ }
625
+ }
626
+ /** Bots, rooms or routines went away: when that removes an install's
627
+ * last record, the index and the report say so. Debounced. */
628
+ onStoreChange(change) {
629
+ if (change.type !== "bot.deleted" && change.type !== "group.deleted" && change.type !== "sections")
630
+ return;
631
+ if (!Object.keys(this.state.installs).length)
632
+ return;
633
+ if (this.recheck)
634
+ clearTimeout(this.recheck);
635
+ this.recheck = setTimeout(() => {
636
+ this.recheck = null;
637
+ this.pending = this.pending.then(() => {
638
+ try {
639
+ if (this.rebuild()) {
640
+ this.save();
641
+ this.report();
642
+ }
643
+ }
644
+ catch (error) {
645
+ console.error(`[org-library] could not update the library index: ${error instanceof Error ? error.message : String(error)}`);
646
+ }
647
+ });
648
+ }, 250);
649
+ this.recheck.unref?.();
650
+ }
651
+ // ── report ────────────────────────────────────────────────────────────
652
+ /** The full snapshot for the current organization (contract §5.5). */
653
+ reportEntries() {
654
+ const entries = [];
655
+ const reported = new Set();
656
+ for (const [installId, install] of Object.entries(this.state.installs)) {
657
+ if (this.currentInstallId(install.packageId) !== installId)
658
+ continue;
659
+ reported.add(install.packageId);
660
+ entries.push({
661
+ packageId: install.packageId,
662
+ release: install.release,
663
+ sha256: install.sha256,
664
+ state: install.status,
665
+ ...(install.status === "withdrawn" ? { reason: "withdrawn_by_publisher" } : {}),
666
+ ...(install.status === "removed" ? { reason: "removed_locally" } : {}),
667
+ });
668
+ }
669
+ for (const failure of this.failures.values())
670
+ if (!reported.has(failure.packageId))
671
+ entries.push({ ...failure });
672
+ return entries.slice(0, ORG_LIBRARY_REPORT_MAX_ENTRIES);
673
+ }
674
+ report() {
675
+ if (!this.library)
676
+ return;
677
+ const message = {
678
+ type: "openmausbot:managed-library-state",
679
+ digest: this.library.digest,
680
+ packages: this.reportEntries(),
681
+ };
682
+ this.lastPosted = message;
683
+ try {
684
+ this.deps.postState?.(message);
685
+ }
686
+ catch (error) {
687
+ console.warn(`[org-library] could not send the library report: ${error instanceof Error ? error.message : String(error)}`);
688
+ }
689
+ }
690
+ // ── files ─────────────────────────────────────────────────────────────
691
+ statePath() {
692
+ return join(this.root, "state.json");
693
+ }
694
+ load() {
695
+ let text;
696
+ try {
697
+ text = readFileSync(this.statePath(), "utf8");
698
+ }
699
+ catch {
700
+ return emptyState();
701
+ }
702
+ try {
703
+ const parsed = stateSchema.safeParse(JSON.parse(text));
704
+ if (!parsed.success)
705
+ throw new Error("invalid");
706
+ const installs = {};
707
+ for (const [installId, raw] of Object.entries(parsed.data.installs)) {
708
+ const install = installSchema.safeParse(raw);
709
+ if (install.success)
710
+ installs[installId] = install.data;
711
+ }
712
+ const adding = {};
713
+ for (const [installId, raw] of Object.entries(parsed.data.adding ?? {})) {
714
+ const add = addingSchema.safeParse(raw);
715
+ if (add.success)
716
+ adding[installId] = add.data;
717
+ }
718
+ const origin = parsed.data.source ? adminOrigin(parsed.data.source.adminOrigin) : null;
719
+ return {
720
+ version: 1,
721
+ source: parsed.data.source && origin ? { adminOrigin: origin, organizationId: parsed.data.source.organizationId } : null,
722
+ appliedDigest: parsed.data.appliedDigest,
723
+ installs,
724
+ adding,
725
+ };
726
+ }
727
+ catch {
728
+ // The records are the source of truth; an unreadable index is rebuilt.
729
+ console.warn("[org-library] state.json could not be read; rebuilding it from your bots, rooms and routines");
730
+ return emptyState();
731
+ }
732
+ }
733
+ save() {
734
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
735
+ writeFileAtomic(this.statePath(), `${JSON.stringify(this.state, null, 2)}\n`, { mode: 0o600 });
736
+ }
737
+ blobPath(sha256) {
738
+ return join(this.root, "blobs", `${sha256}.json`);
739
+ }
740
+ /** The release bytes, only if they hash to `sha256`. Never follows a link. */
741
+ readBlob(sha256) {
742
+ if (!HEX64.test(sha256))
743
+ return null;
744
+ let descriptor = null;
745
+ try {
746
+ const path = this.blobPath(sha256);
747
+ const before = lstatSync(path);
748
+ if (!before.isFile() || before.size > PACKAGE_MAX_BYTES)
749
+ return null;
750
+ descriptor = openSync(path, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW ?? 0));
751
+ const opened = fstatSync(descriptor);
752
+ if (!opened.isFile() || opened.size > PACKAGE_MAX_BYTES || opened.ino !== before.ino)
753
+ return null;
754
+ const bytes = readFileSync(descriptor);
755
+ return sha256Hex(bytes) === sha256 ? bytes : null;
756
+ }
757
+ catch {
758
+ return null;
759
+ }
760
+ finally {
761
+ if (descriptor !== null) {
762
+ try {
763
+ closeSync(descriptor);
764
+ }
765
+ catch { }
766
+ }
767
+ }
768
+ }
769
+ blobStatus(entry) {
770
+ if (!entry.release)
771
+ return "unavailable";
772
+ if (entry.release.formatVersion > PACKAGE_VERSION)
773
+ return "unsupported";
774
+ const sha = entry.release.sha256;
775
+ let key;
776
+ try {
777
+ const stat = lstatSync(this.blobPath(sha));
778
+ if (!stat.isFile())
779
+ return "unavailable";
780
+ key = `${stat.size}:${stat.mtimeMs}:${stat.ino}`;
781
+ }
782
+ catch {
783
+ return "unavailable";
784
+ }
785
+ if (this.verified.get(sha) === key)
786
+ return "ready";
787
+ if (!this.readBlob(sha))
788
+ return "unavailable";
789
+ this.verified.set(sha, key);
790
+ return "ready";
791
+ }
792
+ /** The verified release, parsed as the organization channel reads it. */
793
+ releaseDocument(entry) {
794
+ if (!entry.release)
795
+ return refusal(404, "no_release", "This package has no release to add right now.");
796
+ if (entry.release.formatVersion > PACKAGE_VERSION)
797
+ return refusal(409, "newer_app_required", NEWER_APP);
798
+ const bytes = this.readBlob(entry.release.sha256);
799
+ if (!bytes)
800
+ return refusal(503, "blob_unavailable", NOT_READY);
801
+ let document;
802
+ try {
803
+ document = parsePackageDocument(JSON.parse(bytes.toString("utf8")), { trust: "org" });
804
+ }
805
+ catch (error) {
806
+ if (error instanceof PackageFormatError && error.code === "newer_version")
807
+ return refusal(409, "newer_app_required", NEWER_APP);
808
+ return refusal(422, "invalid_package", `This package could not be read: ${error instanceof Error ? error.message : "it is not a valid package"}`);
809
+ }
810
+ const [publisherSlug, packageSlug] = entry.ref.split("/");
811
+ if (document.package.id !== packageSlug || document.package.publisher?.organization !== publisherSlug) {
812
+ return refusal(422, "invalid_package", "This package is not the one your organization listed.");
813
+ }
814
+ return { ok: true, status: 200, value: document };
815
+ }
816
+ visibleEntry(packageId) {
817
+ const entry = this.library?.catalog.packages.find((candidate) => candidate.packageId === packageId);
818
+ return entry && entry.mode !== "off" ? entry : null;
819
+ }
820
+ listEntry(entry) {
821
+ const installId = this.currentInstallId(entry.packageId);
822
+ const install = this.state.installs[installId];
823
+ return {
824
+ ...structuredClone(entry),
825
+ blob: this.blobStatus(entry),
826
+ installed: install ? { installId, release: install.release, status: install.status } : null,
827
+ };
828
+ }
829
+ // ── renderer routes ───────────────────────────────────────────────────
830
+ /** GET /api/org-library. With no organization: {organization: null, packages: []}. */
831
+ list() {
832
+ if (!this.library)
833
+ return { organization: null, packages: [] };
834
+ return {
835
+ organization: { ...this.library.catalog.organization },
836
+ packages: this.library.catalog.packages.filter((entry) => entry.mode !== "off").map((entry) => this.listEntry(entry)),
837
+ installs: Object.entries(this.state.installs)
838
+ .filter(([installId, install]) => this.currentInstallId(install.packageId) === installId)
839
+ .map(([installId, install]) => ({
840
+ installId, packageId: install.packageId, name: install.name, release: install.release, status: install.status, publisher: install.publisher.name,
841
+ })),
842
+ };
843
+ }
844
+ /** GET /api/org-library/packages/:packageId — the preview, from the verified file. */
845
+ preview(packageId) {
846
+ const entry = this.visibleEntry(packageId);
847
+ if (!entry)
848
+ return refusal(404, "not_listed", NOT_LISTED);
849
+ const document = this.releaseDocument(entry);
850
+ if (!document.ok)
851
+ return document;
852
+ return { ok: true, status: 200, value: { package: this.listEntry(entry), document: document.value } };
853
+ }
854
+ /** POST /api/org-library/add — one click, no confirmation: the person's
855
+ * click is the decision. Idempotent per install id. */
856
+ add(packageId, importDeps) {
857
+ const library = this.library;
858
+ const entry = this.visibleEntry(packageId);
859
+ if (!library || !entry)
860
+ return refusal(404, "not_listed", NOT_LISTED);
861
+ const installId = this.currentInstallId(packageId);
862
+ if (this.rebuild())
863
+ this.save();
864
+ const existing = this.state.installs[installId];
865
+ if (existing && existing.status !== "removed")
866
+ return { ok: true, status: 200, value: { alreadyAdded: true, installId } };
867
+ const fail = (outcome, reason) => {
868
+ if (entry.release) {
869
+ this.failures.set(packageId, { packageId, release: entry.release.version, sha256: entry.release.sha256, state: "failed", reason });
870
+ this.report();
871
+ }
872
+ return outcome;
873
+ };
874
+ const document = this.releaseDocument(entry);
875
+ if (!document.ok) {
876
+ if (document.code === "no_release")
877
+ return document;
878
+ return fail(document, document.code === "blob_unavailable" ? "blob_unavailable" : document.code === "newer_app_required" ? "newer_app_required" : "invalid_package");
879
+ }
880
+ const release = entry.release;
881
+ const [slug] = entry.ref.split("/");
882
+ const publisher = { organizationId: entry.publisher.organizationId, slug: slug, name: entry.publisher.name };
883
+ const pkg = document.value.package;
884
+ // A team's Add is marked before its first record is written, so if the
885
+ // app stops mid-import the next start finishes the job one way or the
886
+ // other (resolveInterruptedAdds). A skills-only package writes nothing.
887
+ const pending = pkg.agents.length > 0 && Boolean(pkg.team);
888
+ const settle = () => {
889
+ if (!pending)
890
+ return;
891
+ delete this.state.adding[installId];
892
+ this.importing = null;
893
+ };
894
+ if (pending) {
895
+ this.state.adding[installId] = {
896
+ packageId, ref: entry.ref, publisher, name: entry.name, release: release.version, sha256: release.sha256,
897
+ startedAt: this.deps.now?.() ?? Date.now(),
898
+ expect: {
899
+ bots: pkg.agents.map((agent) => agent.key),
900
+ rooms: (pkg.rooms ?? []).map((room) => room.key),
901
+ routines: (pkg.routines ?? []).map((routine) => routine.key),
902
+ leader: pkg.team?.leader ?? null,
903
+ },
904
+ };
905
+ try {
906
+ this.save();
907
+ }
908
+ catch (error) {
909
+ delete this.state.adding[installId];
910
+ console.error(`[org-library] could not start adding ${entry.ref}: ${error instanceof Error ? error.message : String(error)}`);
911
+ return fail(refusal(500, "import_failed", ADD_FAILED), "import_failed");
912
+ }
913
+ this.importing = installId;
914
+ }
915
+ let result;
916
+ try {
917
+ const imported = importPackageDocument(document.value, {
918
+ trust: "org",
919
+ mode: "add",
920
+ org: { installId, ref: entry.ref, packageId, sha256: release.sha256, publisher, adminOrigin: library.adminOrigin, organizationId: library.organizationId },
921
+ }, importDeps);
922
+ if (imported.alreadyAdded) {
923
+ settle();
924
+ this.rebuild();
925
+ this.save();
926
+ return { ok: true, status: 200, value: { alreadyAdded: true, installId } };
927
+ }
928
+ result = imported;
929
+ }
930
+ catch (error) {
931
+ // The importer removed everything it created.
932
+ settle();
933
+ try {
934
+ this.save();
935
+ }
936
+ catch { }
937
+ if (error instanceof PackageImportError)
938
+ return fail(refusal(422, error.code, error.message), "invalid_package");
939
+ console.error(`[org-library] could not add ${entry.ref}: ${error instanceof Error ? error.message : String(error)}`);
940
+ return fail(refusal(500, "import_failed", ADD_FAILED), "import_failed");
941
+ }
942
+ const now = this.deps.now?.() ?? Date.now();
943
+ const index = result.org;
944
+ settle();
945
+ this.state.installs[installId] = {
946
+ packageId,
947
+ ref: entry.ref,
948
+ publisher,
949
+ release: release.version,
950
+ sha256: release.sha256,
951
+ status: "installed",
952
+ kind: index.kind,
953
+ name: entry.name,
954
+ section: index.section,
955
+ team: { parts: index.team.parts },
956
+ bots: index.bots,
957
+ rooms: index.rooms,
958
+ routines: index.routines,
959
+ connections: index.connections,
960
+ // Presets now in New bot (presets.ts), with the release-side hash (§1.6).
961
+ presets: Object.fromEntries((result.presets ?? []).flatMap((preset) => {
962
+ const released = document.value.package.presets?.find((candidate) => candidate.key === preset.key);
963
+ return released ? [[preset.key, { presetId: preset.id, r: partHash(released) }]] : [];
964
+ })),
965
+ removedLocally: [],
966
+ addedAt: now,
967
+ updatedAt: now,
968
+ };
969
+ this.failures.delete(packageId);
970
+ // The records were written first; the index follows, and clears the mark.
971
+ this.save();
972
+ this.report();
973
+ return { ok: true, status: 201, value: { alreadyAdded: false, result } };
974
+ }
975
+ /** The skills installs offer without putting them on a bot (library
976
+ * packages, and a team's unassigned skills): Bot → Skills → From {Org}. */
977
+ offeredSkills(botId) {
978
+ if (!this.library)
979
+ return { organization: null, skills: [] };
980
+ const have = new Set(botId && this.deps.store.bot(botId) ? this.deps.skills.list(botId).map((skill) => skill.name) : []);
981
+ const skills = [];
982
+ for (const [installId, install] of Object.entries(this.state.installs)) {
983
+ if (install.status !== "installed" || this.currentInstallId(install.packageId) !== installId)
984
+ continue;
985
+ const document = this.installDocument(install);
986
+ if (!document)
987
+ continue;
988
+ const referenced = new Set(document.package.agents.flatMap((agent) => agent.skills ?? []));
989
+ for (const skill of document.package.skills?.entries ?? []) {
990
+ if (referenced.has(skill.name))
991
+ continue;
992
+ skills.push({
993
+ installId, packageName: install.name || document.package.name, publisher: install.publisher.name, release: install.release,
994
+ name: skill.name, description: skill.description, added: have.has(skill.name),
995
+ });
996
+ }
997
+ }
998
+ return { organization: { ...this.library.catalog.organization }, skills };
999
+ }
1000
+ installDocument(install) {
1001
+ const bytes = this.readBlob(install.sha256);
1002
+ if (!bytes)
1003
+ return null;
1004
+ try {
1005
+ return parsePackageDocument(JSON.parse(bytes.toString("utf8")), { trust: "org" });
1006
+ }
1007
+ catch {
1008
+ return null;
1009
+ }
1010
+ }
1011
+ /** Put one offered skill on a bot, switched on, stamped with its install. */
1012
+ addOfferedSkill(botId, installId, name) {
1013
+ if (!this.library)
1014
+ return refusal(404, "not_listed", NOT_LISTED);
1015
+ const bot = this.deps.store.bot(botId);
1016
+ if (!bot)
1017
+ return refusal(404, "no_bot", "That bot no longer exists.");
1018
+ const install = this.state.installs[installId];
1019
+ if (!install || this.currentInstallId(install.packageId) !== installId || install.status !== "installed") {
1020
+ return refusal(404, "not_listed", NOT_LISTED);
1021
+ }
1022
+ const document = this.installDocument(install);
1023
+ if (!document)
1024
+ return refusal(503, "blob_unavailable", NOT_READY);
1025
+ const referenced = new Set(document.package.agents.flatMap((agent) => agent.skills ?? []));
1026
+ const skill = document.package.skills?.entries.find((candidate) => candidate.name === name && !referenced.has(candidate.name));
1027
+ if (!skill)
1028
+ return refusal(404, "no_skill", "That skill is not offered by this package.");
1029
+ if (this.deps.skills.list(botId).some((existing) => existing.name === name)) {
1030
+ return refusal(409, "skill_exists", `${bot.name} already has a skill named ${name}.`);
1031
+ }
1032
+ const added = this.deps.skills.installOrg(botId, `org:${install.ref}@${install.release}`, skill.instructions, { installId, key: skill.name, release: install.release, ...pair(skill.instructions, skill.instructions) });
1033
+ if ("error" in added)
1034
+ return refusal(422, "skill_failed", `The skill could not be added: ${added.error}`);
1035
+ return { ok: true, status: 201, value: added };
1036
+ }
1037
+ }
1038
+ function mostCommon(values) {
1039
+ const counts = new Map();
1040
+ for (const value of values)
1041
+ counts.set(value, (counts.get(value) ?? 0) + 1);
1042
+ let best;
1043
+ let bestCount = 0;
1044
+ for (const [value, total] of counts) {
1045
+ if (total > bestCount) {
1046
+ best = value;
1047
+ bestCount = total;
1048
+ }
1049
+ }
1050
+ return best;
1051
+ }