openmausbot 0.1.85 → 0.1.86

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 (193) hide show
  1. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  2. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  3. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  4. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  5. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  10. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  11. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  16. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  17. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  19. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  20. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  22. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  28. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  29. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  31. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  32. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  34. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  35. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  43. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  44. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  46. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  47. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  49. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  50. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  52. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  53. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  54. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  55. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  57. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  58. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  60. package/dist/assets/abnfDiagram-O67JEVCF-DpUZNnFD.js +1 -0
  61. package/dist/assets/arc-DW7mrI4p.js +1 -0
  62. package/dist/assets/architectureDiagram-NJMV4G6O-CyeZ5WLZ.js +36 -0
  63. package/dist/assets/blockDiagram-BEXU5L5S-CBVaNjO7.js +140 -0
  64. package/dist/assets/c4Diagram-YGBWAQC7-sf8d41uT.js +38 -0
  65. package/dist/assets/channel-BXFkgus9.js +1 -0
  66. package/dist/assets/chunk-2Q5K7J3B-BOGvy7Y_.js +1 -0
  67. package/dist/assets/chunk-3FUC2YCW-BiF9zyiz.js +2 -0
  68. package/dist/assets/chunk-5VM5RSS4-EFuZ1SBZ.js +15 -0
  69. package/dist/assets/chunk-GTNCS2PH-pJLNSAT7.js +88 -0
  70. package/dist/assets/chunk-GWA4HPMP-Cb-3_3IT.js +1 -0
  71. package/dist/assets/chunk-JWPE2WC7-DAf5aogn.js +1 -0
  72. package/dist/assets/chunk-NETBCI7D-DR3kiwZz.js +1 -0
  73. package/dist/assets/chunk-WEXAMYUT-BmU8PeyM.js +1 -0
  74. package/dist/assets/chunk-XXDRQBXY-Buh8g8OT.js +1 -0
  75. package/dist/assets/classDiagram-v2-NBCMYWYE-BurfmMWM.js +217 -0
  76. package/dist/assets/cose-bilkent-JH36ORCC-KkGZUEkU.js +1 -0
  77. package/dist/assets/cynefin-OW5HDTMX-C3PpV96d.js +166 -0
  78. package/dist/assets/cynefinDiagram-VND7K2PF-Dt8JwJaI.js +62 -0
  79. package/dist/assets/cytoscape.esm-BB4DxJjf.js +321 -0
  80. package/dist/assets/dagre-6A5THRUB-DkjN8266.js +4 -0
  81. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  82. package/dist/assets/diagram-22UHCM2B-DUAJS0JV.js +200 -0
  83. package/dist/assets/diagram-3UASUU5V-Dw4KhF1a.js +24 -0
  84. package/dist/assets/diagram-ATOU4E4O-Dd1gmQqf.js +3 -0
  85. package/dist/assets/diagram-CDSNMT55-CjwGs0Yw.js +30 -0
  86. package/dist/assets/diagram-MLGK6HIB-BL-69Zrq.js +24 -0
  87. package/dist/assets/diagram-MPIPVDR6-5Zi2N7wo.js +41 -0
  88. package/dist/assets/ebnfDiagram-ZINNZB2B-D9NekWUX.js +1 -0
  89. package/dist/assets/elk-276RUBZZ-CkaSQjxw.js +27 -0
  90. package/dist/assets/erDiagram-OPXOYQCR-CdAVBV8-.js +99 -0
  91. package/dist/assets/flowDiagram-KWPJA3E3-Bm_lWWH7.js +213 -0
  92. package/dist/assets/ganttDiagram-FUAMR5RP-FZ8ejYWJ.js +292 -0
  93. package/dist/assets/gitGraphDiagram-X574FWY7-Dlv9rizZ.js +106 -0
  94. package/dist/assets/graph-D8UmR1vK.js +1 -0
  95. package/dist/assets/index-BGgqlWPJ.css +1 -0
  96. package/dist/assets/index-BtHo8ZKl.js +577 -0
  97. package/dist/assets/{index-CTNNoqSJ.js → index-h9FFNwTu.js} +1 -1
  98. package/dist/assets/infoDiagram-VRGFBTTK-BdQ_gW5B.js +2 -0
  99. package/dist/assets/init-Gi6I4Gst.js +1 -0
  100. package/dist/assets/ishikawaDiagram-OU5B5YK6-EwssEp2X.js +70 -0
  101. package/dist/assets/journeyDiagram-ZHPQQLJL-BX1G1vOd.js +139 -0
  102. package/dist/assets/kanban-definition-PNTS6WVX-BB4kAsyC.js +89 -0
  103. package/dist/assets/linear-3su2TG93.js +1 -0
  104. package/dist/assets/mermaid.core-CDD1E0s9.js +311 -0
  105. package/dist/assets/mindmap-definition-NLK3R4M7-Ba50MsNK.js +96 -0
  106. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  107. package/dist/assets/pegDiagram-GJSIUBJH-DZLkkZlM.js +1 -0
  108. package/dist/assets/pieDiagram-5QR66LMP-DaTz77Uq.js +39 -0
  109. package/dist/assets/quadrantDiagram-O4NWA36T-CpaiidpR.js +7 -0
  110. package/dist/assets/railroadDiagram-XR7U4H2S-mN4JrMeI.js +1 -0
  111. package/dist/assets/requirementDiagram-PLB6GJNP-BSNDRpg7.js +84 -0
  112. package/dist/assets/sankeyDiagram-IPEJSGJF-FngzGNug.js +40 -0
  113. package/dist/assets/sequenceDiagram-PO4LG4MO-CM4pCC3L.js +169 -0
  114. package/dist/assets/sizeCapture-INFHLROL-CRICHXkw.js +1 -0
  115. package/dist/assets/stateDiagram-v2-GCMORJYK-oyTWpxH2.js +291 -0
  116. package/dist/assets/swimlanes-2SLR337P-D9h9mPBq.js +1 -0
  117. package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +8 -0
  118. package/dist/assets/timeline-definition-EJHVYXUP-BJUlN9bW.js +120 -0
  119. package/dist/assets/usecaseDiagram-POWQR4AR-3uBNkbAb.js +370 -0
  120. package/dist/assets/vennDiagram-UO4OBE2U-D2odalzT.js +34 -0
  121. package/dist/assets/wardleyDiagram-VNRHLVJA-DHgFaB9S.js +78 -0
  122. package/dist/assets/xychartDiagram-PMCCYNJV-w5GYM2h0.js +7 -0
  123. package/dist/index.html +2 -2
  124. package/dist-server/container-mcp.js +19 -10
  125. package/dist-server/drivers/agents-proxy.js +16159 -1596
  126. package/dist-server/enterprise/LICENSE +32 -0
  127. package/dist-server/enterprise/server/index.js +5 -5
  128. package/dist-server/index.js +4779 -1667
  129. package/dist-server/mcp-server.js +3 -1
  130. package/dist-server/openmausbot.js +4679 -4266
  131. package/dist-server/pair-cli.js +4679 -4266
  132. package/dist-server/server/admin-activity.js +410 -0
  133. package/dist-server/server/bot-visibility.js +462 -0
  134. package/dist-server/server/box.js +2 -1
  135. package/dist-server/server/browser-runtime.js +1 -1
  136. package/dist-server/server/channel-queue.js +2 -0
  137. package/dist-server/server/cli-prompts.js +24 -2
  138. package/dist-server/server/cli.js +79 -10
  139. package/dist-server/server/composio.js +49 -11
  140. package/dist-server/server/config.js +79 -13
  141. package/dist-server/server/decision-log.js +209 -48
  142. package/dist-server/server/default-model-selection.js +20 -3
  143. package/dist-server/server/delegations.js +11 -9
  144. package/dist-server/server/drivers/acp/core.js +110 -22
  145. package/dist-server/server/drivers/acp/grok.js +4 -3
  146. package/dist-server/server/drivers/agents-call.js +1123 -0
  147. package/dist-server/server/drivers/agents-catalog.js +659 -0
  148. package/dist-server/server/drivers/agents-client.js +28 -0
  149. package/dist-server/server/drivers/agents-proxy.js +18 -1722
  150. package/dist-server/server/drivers/chat-mcp-tools.js +17 -2
  151. package/dist-server/server/drivers/claude.js +21 -6
  152. package/dist-server/server/drivers/codex-mcp-names.js +44 -0
  153. package/dist-server/server/drivers/codex.js +26 -4
  154. package/dist-server/server/drivers/grok.js +2 -1
  155. package/dist-server/server/drivers/openai-compat.js +15 -8
  156. package/dist-server/server/enterprise.js +97 -16
  157. package/dist-server/server/external-runtime.js +83 -0
  158. package/dist-server/server/guarded-requests.js +60 -0
  159. package/dist-server/server/harness/http.js +61 -0
  160. package/dist-server/server/hosted-models.js +151 -0
  161. package/dist-server/server/hosted-slack.js +11 -0
  162. package/dist-server/server/index.js +1718 -348
  163. package/dist-server/server/managed-desktop.js +79 -14
  164. package/dist-server/server/managed-policy.js +153 -0
  165. package/dist-server/server/message-db.js +28 -0
  166. package/dist-server/server/model-context-window.js +7 -0
  167. package/dist-server/server/model-prices.js +121 -0
  168. package/dist-server/server/notify.js +10 -1
  169. package/dist-server/server/peer-roster.js +13 -2
  170. package/dist-server/server/recent-work.js +15 -0
  171. package/dist-server/server/request-auth.js +96 -0
  172. package/dist-server/server/room-handoffs.js +25 -11
  173. package/dist-server/server/routes/hosted-slack.js +20 -0
  174. package/dist-server/server/routes/table.js +14 -0
  175. package/dist-server/server/routines.js +22 -0
  176. package/dist-server/server/spend.js +111 -16
  177. package/dist-server/server/steer-queue.js +6 -1
  178. package/dist-server/server/store.js +292 -21
  179. package/dist-server/server/team-backup.js +10 -1
  180. package/dist-server/server/thread-starters.js +53 -0
  181. package/dist-server/server/usage-ledger.js +31 -8
  182. package/dist-server/server/workspace-backup-policy.js +9 -2
  183. package/dist-server/server/workspace-backup.js +7 -4
  184. package/dist-server/shared/mention-boundary.js +15 -0
  185. package/dist-server/shared/routines.js +7 -1
  186. package/dist-server/shared/team-backup.js +2 -0
  187. package/dist-server/src/lib/composer-attachments.js +16 -0
  188. package/dist-server/vps-container-mcp.js +19 -10
  189. package/enterprise/README.md +33 -12
  190. package/enterprise/server/index.js +5 -5
  191. package/package.json +1 -1
  192. package/dist/assets/index-Ba9G44HI.js +0 -310
  193. package/dist/assets/index-CkRNp7wX.css +0 -1
@@ -2,6 +2,83 @@ import { timingSafeEqual } from "node:crypto";
2
2
  import { isIP } from "node:net";
3
3
  import { denyReason as companionDenial } from "../companion/src/routes.js";
4
4
  const LOOPBACK_SCOPES = ["admin", "client"];
5
+ const SERVICE_SCOPES = ["client"];
6
+ /** What a `service`-trust loopback caller may reach, and nothing else:
7
+ *
8
+ * - liveness and identity: health, who-am-I, edition and brand;
9
+ * - the cloud Slack worker (openmaus-cloud server/slack-worker.ts), which
10
+ * shares the workspace's network namespace and reads the bot list, a
11
+ * thread's messages and a bot's PNG picture, creates a thread, sends
12
+ * through the guarded route, watches and stops its exact request,
13
+ * withdraws a queued line, and declines a card (the handler refuses any
14
+ * other answer from this caller);
15
+ * - the turn-scoped bot capability routes, whose own bearer token is the
16
+ * authorization (checked in the handler), and the test-only capability
17
+ * mint that exists only when its private key is set.
18
+ *
19
+ * Residual risk, not closed here: a bot's shell is a loopback caller too, so
20
+ * it can do everything the worker does. It can post into any bot's thread
21
+ * through the guarded route, including an existing Full-access thread
22
+ * (`expectedApprovalMode: "full"`), and while the operator's shared Full
23
+ * access is on it can open new Full-access threads; either way work runs with
24
+ * Full access and no card, whoever asked the bot. It can also stop a request
25
+ * and decline a card. It can no longer change settings, keys, instances, MCP
26
+ * servers, webhooks, sessions, people, budgets or fleet, loosen a bot's
27
+ * permissions, or approve anything. The planned fix is a relay token that only
28
+ * the Slack worker holds, so these routes stop answering session-less
29
+ * loopback at all. */
30
+ export const SERVICE_ALLOW = [
31
+ { methods: ["GET"], path: /^\/api\/health$/ },
32
+ { methods: ["GET"], path: /^\/api\/auth\/session$/ },
33
+ { methods: ["GET"], path: /^\/api\/edition$/ },
34
+ { methods: ["GET"], path: /^\/api\/brand$/ },
35
+ { methods: ["GET"], path: /^\/api\/bots$/ },
36
+ { methods: ["GET"], path: /^\/api\/threads\/[\w-]+\/messages$/ },
37
+ { methods: ["GET"], path: /^\/api\/attachments\/[\w.-]+$/ },
38
+ { methods: ["POST"], path: /^\/api\/bots\/[\w-]+\/tasks$/ },
39
+ { methods: ["POST"], path: /^\/api\/bots\/[\w-]+\/messages\/guarded$/ },
40
+ { methods: ["GET"], path: /^\/api\/bots\/[\w-]+\/requests\/[\w-]+$/ },
41
+ { methods: ["POST"], path: /^\/api\/bots\/[\w-]+\/requests\/[\w-]+\/interrupt$/ },
42
+ { methods: ["DELETE"], path: /^\/api\/bots\/[\w-]+\/queue\/[\w-]+$/ },
43
+ { methods: ["POST"], path: /^\/api\/threads\/[\w-]+\/respond$/ }, // decline only: see the handler
44
+ { methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], path: /^\/api\/internal\// }, // capability bearer checked in the handler
45
+ { methods: ["POST"], path: /^\/api\/testing\/internal-capability$/ }, // exists only with its private key
46
+ ];
47
+ export function serviceAllowed(method, path) {
48
+ const upper = method.toUpperCase();
49
+ return SERVICE_ALLOW.some((rule) => rule.methods.includes(upper) && rule.path.test(path));
50
+ }
51
+ /** Pick the loopback trust for this process, and say why for the startup log.
52
+ *
53
+ * A packaged desktop keeps `owner`: its mutations already need Electron's
54
+ * per-launch capability, and only its owner uses the machine. Elsewhere the
55
+ * operator may set OMB_LOOPBACK_TRUST=owner|service. Without it a hosted
56
+ * workspace (any OMB_ADMIN_* setting, even an incomplete one) defaults to
57
+ * `service` — shared-workspace Full access is only honoured there, so it needs
58
+ * no rule of its own — and a headless self-hosted server keeps `owner`. A
59
+ * value that is neither fails closed. */
60
+ export function resolveLoopbackTrust(input) {
61
+ const raw = (input.env ?? process.env).OMB_LOOPBACK_TRUST;
62
+ const requested = raw?.trim().toLowerCase();
63
+ if (input.desktopManaged) {
64
+ return { trust: "owner", reason: "desktop app", ...(raw !== undefined ? { warning: "OMB_LOOPBACK_TRUST is ignored in the desktop app" } : {}) };
65
+ }
66
+ if (requested === "owner" || requested === "service") {
67
+ return {
68
+ trust: requested,
69
+ reason: "OMB_LOOPBACK_TRUST",
70
+ ...(requested === "owner" && input.hostedWorkspace
71
+ ? { warning: "OMB_LOOPBACK_TRUST=owner on a shared workspace: every bot's shell can change settings and approve cards as the owner" }
72
+ : {}),
73
+ };
74
+ }
75
+ if (raw !== undefined && requested !== "") {
76
+ return { trust: "service", reason: "OMB_LOOPBACK_TRUST", warning: `OMB_LOOPBACK_TRUST="${raw.replace(/[^\w.-]/g, "").slice(0, 40)}" is not owner or service; using service` };
77
+ }
78
+ if (input.hostedWorkspace)
79
+ return { trust: "service", reason: "hosted workspace" };
80
+ return { trust: "owner", reason: "self-hosted default" };
81
+ }
5
82
  export function isLoopbackHost(host) {
6
83
  if (!host)
7
84
  return false;
@@ -170,6 +247,8 @@ export const CLIENT_ALLOW = [
170
247
  // reads: fleet, transcripts, search (no secrets in any of these)
171
248
  { methods: ["GET"], path: /^\/api\/bots$/ },
172
249
  { methods: ["GET"], path: /^\/api\/team-map$/ },
250
+ // a link into the organisation's Admin: identifiers only, and Admin authorizes its own visitor
251
+ { methods: ["GET"], path: /^\/api\/bots\/[\w-]+\/slack-management$/ },
173
252
  { methods: ["GET"], path: /^\/api\/search$/ },
174
253
  { methods: ["GET"], path: /^\/api\/threads\/[\w-]+\/messages$/ },
175
254
  { methods: ["GET"], path: /^\/api\/threads\/[\w-]+\/messages\/[\w-]+\/image$/ },
@@ -219,6 +298,7 @@ export const CLIENT_ALLOW = [
219
298
  { methods: ["PATCH", "DELETE"], path: /^\/api\/routines\/[\w-]+$/ },
220
299
  { methods: ["POST"], path: /^\/api\/routines\/[\w-]+\/run$/ },
221
300
  { methods: ["POST"], path: /^\/api\/routine-runs\/[\w-]+\/(?:cancel|seen)$/ },
301
+ { methods: ["POST"], path: /^\/api\/routine-runs\/seen-all$/ },
222
302
  // webhook list is secret-free; creating or rotating one is not
223
303
  { methods: ["GET"], path: /^\/api\/webhooks$/ },
224
304
  // configured-or-not booleans; the handler strips the few identifying fields for clients
@@ -255,6 +335,9 @@ export function clientGroupPatchViolation(body) {
255
335
  return key;
256
336
  return null;
257
337
  }
338
+ const CLI_OWNER_HEADER = "x-openmausbot-cli-owner";
339
+ /** The only routes the serving CLI's secret opens: its pairing code. */
340
+ const CLI_OWNER_ROUTE = /^\/api\/auth\/pairing$/;
258
341
  const DESKTOP_OWNER_HEADER = "x-openmausbot-desktop-owner";
259
342
  function mutatingPublicRoute(method, path) {
260
343
  const upper = method.toUpperCase();
@@ -343,6 +426,19 @@ export function resolveRequestAuth(req, options) {
343
426
  !secureTokenMatch(headerValue(req.headers[DESKTOP_OWNER_HEADER]), options.loopbackMutationToken)) {
344
427
  return deny(403, "forbidden: this change must come from the desktop app or a paired device");
345
428
  }
429
+ if (options.loopbackMutationToken === undefined && options.loopbackTrust === "service") {
430
+ // The CLI that started this server may still print a pairing code.
431
+ if (options.cliOwnerToken && CLI_OWNER_ROUTE.test(path) && ["GET", "POST"].includes(method.toUpperCase()) &&
432
+ secureTokenMatch(headerValue(req.headers[CLI_OWNER_HEADER]), options.cliOwnerToken)) {
433
+ return { auth: { kind: "loopback", scopes: LOOPBACK_SCOPES }, status: 401, error: "" };
434
+ }
435
+ // On a shared server "local" includes every bot's shell. Default deny:
436
+ // only the service routes, never an admin change, without a session.
437
+ if (!serviceAllowed(method, path)) {
438
+ return deny(403, "forbidden: on this shared server a local request without a session may only use the service routes; sign in to change settings, people or access");
439
+ }
440
+ return { auth: { kind: "loopback", scopes: SERVICE_SCOPES, trust: "service" }, status: 401, error: "" };
441
+ }
346
442
  return { auth: { kind: "loopback", scopes: LOOPBACK_SCOPES }, status: 401, error: "" };
347
443
  }
348
444
  if (proxied) {
@@ -243,12 +243,13 @@ export class RoomHandoffs {
243
243
  const node = this.nodes.get(generation);
244
244
  if (!node || node.status !== "source")
245
245
  return;
246
+ // An accepted assignment belongs to the queue, not the provider that
247
+ // submitted it. A failed/expired source turn must not erase that work.
248
+ // Explicit Stop, deletion and revoked routes still cancel separately.
246
249
  if (!ok)
247
- this.cancelTree(node, "The originating room turn did not finish", "failed");
248
- else {
249
- node.status = "waiting";
250
- this.publish(node);
251
- }
250
+ node.result = "The originating turn ended before its teammates returned.";
251
+ node.status = "waiting";
252
+ this.publish(node);
252
253
  }
253
254
  cancelTree(node, reason, status = "cancelled") {
254
255
  for (const child of this.children(node.id))
@@ -347,8 +348,10 @@ export class RoomHandoffs {
347
348
  }
348
349
  if (n.status !== "queued" && n.status !== "resume")
349
350
  continue;
350
- // A newly queued child starts only after its author has settled.
351
- if (parent && (parent.status === "source" || parent.status === "running"))
351
+ // Independent conversations can start as soon as work is accepted.
352
+ // Same-room speakers still serialize; never overlap their shared chat.
353
+ if (parent && (parent.status === "source" || parent.status === "running") &&
354
+ (parent.threadId === n.threadId || (n.groupId && n.groupId === parent.groupId)))
352
355
  continue;
353
356
  // A stopped source stops waiting; only work that never started is
354
357
  // dropped with it. A teammate mid-turn keeps its process and reports.
@@ -381,10 +384,10 @@ export class RoomHandoffs {
381
384
  if (terminal(n))
382
385
  return;
383
386
  n.result = result.text.slice(0, 12_000);
384
- if (!result.ok)
385
- this.cancelTree(n, n.result || "Room agent failed", "failed");
386
- else if (this.children(n.id).length > childCount)
387
+ if (this.children(n.id).length > childCount)
387
388
  n.status = "waiting";
389
+ else if (!result.ok)
390
+ this.cancelTree(n, n.result || "Room agent failed", "failed");
388
391
  else
389
392
  n.status = "completed";
390
393
  // Close the paused span with the settlement itself: work enqueued
@@ -392,7 +395,18 @@ export class RoomHandoffs {
392
395
  // budget, not the still-open pause's overstated runway.
393
396
  this.trackExecutionPauses();
394
397
  this.publish(n);
395
- }).catch(e => { this.cancelTree(n, String(e).slice(0, 1000), "failed"); })
398
+ }).catch(e => {
399
+ if (terminal(n))
400
+ return;
401
+ n.result = String(e).slice(0, 1000);
402
+ if (this.children(n.id).length > childCount) {
403
+ n.status = "waiting";
404
+ this.trackExecutionPauses();
405
+ this.publish(n);
406
+ }
407
+ else
408
+ this.cancelTree(n, n.result, "failed");
409
+ })
396
410
  .finally(() => this.controllers.delete(n.id));
397
411
  }
398
412
  }
@@ -0,0 +1,20 @@
1
+ // GET /api/bots/:id/slack-management: where, in the organisation's Admin, this
2
+ // bot's Slack app is managed. A deep link only: this server holds no Slack
3
+ // logic, contacts nobody, and the link carries identifiers, never a credential.
4
+ // Client-scoped in server/request-auth.ts, so a member reading Bot Settings
5
+ // gets the same link; Admin authorizes whoever opens it.
6
+ import { hostedSlackManagement } from "../hosted-slack.js";
7
+ import { PASS } from "./table.js";
8
+ export function createHostedSlackRoutes(deps) {
9
+ return async ({ res, path, method, json }) => {
10
+ const m = path.match(/^\/api\/bots\/([\w-]+)\/slack-management$/);
11
+ if (!m || method !== "GET")
12
+ return PASS;
13
+ const bot = deps.bot(m[1]);
14
+ if (!bot || bot.hidden)
15
+ return json(res, 404, { error: "no such bot" });
16
+ res.setHeader("cache-control", "no-store");
17
+ // The id in the link is the stored bot's, not the text from the request.
18
+ return json(res, 200, hostedSlackManagement(bot.id, deps.hostedReady(), deps.env));
19
+ };
20
+ }
@@ -0,0 +1,14 @@
1
+ /** "Not my route": the next handler, then index.ts's inline routes, get a turn. */
2
+ export const PASS = Symbol("route.pass");
3
+ export const ROUTES = [];
4
+ /** Runs handlers in order until one answers; true means stop routing. A
5
+ * handler that wrote a response but returned PASS by mistake still counts as
6
+ * answered, so the request can never reach a second handler. */
7
+ export async function dispatchRoutes(routes, ctx) {
8
+ for (const route of routes) {
9
+ const out = await route(ctx);
10
+ if (out !== PASS || ctx.res.headersSent || ctx.res.writableEnded)
11
+ return true;
12
+ }
13
+ return false;
14
+ }
@@ -6,6 +6,7 @@ import { DATA_DIR } from "./config.js";
6
6
  import { writeFileAtomic } from "./atomic.js";
7
7
  import { redactSecretsInText } from "./redact.js";
8
8
  import { normalizeCronSchedule, nextCronRuns } from "../shared/routine-schedule.js";
9
+ import { isRoutineProblemRun } from "../shared/routines.js";
9
10
  const persistedSourceThreadId = z.string().trim().min(1).optional().catch(undefined);
10
11
  const webhookRunReceiptSchema = z.object({
11
12
  webhookId: z.string().min(1).max(200),
@@ -1063,6 +1064,27 @@ export class RoutineManager {
1063
1064
  }
1064
1065
  return cloneRun(run);
1065
1066
  }
1067
+ /** Clear every failure indicator at once: stamp seenAt on each unseen
1068
+ * failed/missed run in one committed save, then emit the updated runs so
1069
+ * connected clients drop their dots immediately. A failed save rolls the
1070
+ * stamps back so a retry still finds the unseen runs. */
1071
+ markAllSeen() {
1072
+ if (!this.runs.some((run) => !run.seenAt && isRoutineProblemRun(run)))
1073
+ return [];
1074
+ const stampAt = this.now();
1075
+ const updated = [];
1076
+ this.commitMutation(() => {
1077
+ for (const run of this.runs) {
1078
+ if (run.seenAt || !isRoutineProblemRun(run))
1079
+ continue;
1080
+ run.seenAt = stampAt;
1081
+ updated.push(run);
1082
+ }
1083
+ });
1084
+ for (const run of updated)
1085
+ this.emitRun(run);
1086
+ return updated.map(cloneRun);
1087
+ }
1066
1088
  get isTicking() { return this.ticking; }
1067
1089
  start() {
1068
1090
  if (this.timer)
@@ -1,33 +1,76 @@
1
+ // Spend limits over the usage ledger: what this workspace has spent this
2
+ // month against its cap, the refusal a turn gets once the cap is reached,
3
+ // and the one-per-month notices when the month crosses the warning
4
+ // threshold and the cap. The figure is every cost in the ledger: what
5
+ // engines reported (on a workspace of personal subscriptions, their
6
+ // equivalents too) plus the estimates booked for engines that report tokens
7
+ // but no price (server/model-prices.ts). A turn on an unpriced model counts
8
+ // nothing. Enforced only with the `budgets` entitlement; without it the
9
+ // setting is inert.
10
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
1
12
  import { entitled } from "./enterprise.js";
2
- import { readUsage } from "./usage-ledger.js";
13
+ import { readUsage, usageRowKey } from "./usage-ledger.js";
3
14
  const DEFAULT_WARN_AT_PERCENT = 80;
4
15
  const CACHE_MS = 15_000;
5
16
  // Every turn start asks; reading the month file each time would be silly.
17
+ // A read keeps the keys of the rows it summed, so a booked row can be told
18
+ // apart from the same row read back later.
6
19
  const cache = new Map();
20
+ // Booked turns whose ledger row may not be on disk yet: dataDir → row key →
21
+ // cost. The append is asynchronous, and a turn that ran longer than CACHE_MS
22
+ // would otherwise be re-read out of the month before its own row landed.
23
+ const unwritten = new Map();
7
24
  function monthOf(now) {
8
25
  return now.toISOString().slice(0, 7);
9
26
  }
10
- /** Reported cost this month so far, from the ledger, cached briefly. */
27
+ const costOf = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
28
+ /** Reported and estimated cost this month so far: the ledger file (read at
29
+ * most every CACHE_MS) plus turns booked since whose rows have not landed. */
11
30
  export function monthToDateSpend(dataDir, now = new Date()) {
12
31
  const month = monthOf(now);
13
- const hit = cache.get(dataDir);
14
- if (hit && hit.month === month && now.getTime() - hit.at < CACHE_MS)
15
- return hit.spentUsd;
16
- const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
17
- const spentUsd = readUsage(dataDir, { from, to: now }).reduce((sum, row) => sum + (typeof row.costUsd === "number" && Number.isFinite(row.costUsd) ? row.costUsd : 0), 0);
18
- cache.set(dataDir, { at: now.getTime(), month, spentUsd });
19
- return spentUsd;
20
- }
21
- /** Called right after a turn is booked, so the next check sees it without
22
- * waiting for the ledger's append to land or the cache to expire. */
23
- export function noteSpend(dataDir, costUsd, now = new Date()) {
24
- const hit = cache.get(dataDir);
25
- if (hit && hit.month === monthOf(now) && typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd > 0) {
26
- hit.spentUsd += costUsd;
32
+ let read = cache.get(dataDir);
33
+ if (!read || read.month !== month || now.getTime() - read.at >= CACHE_MS) {
34
+ const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
35
+ const rows = readUsage(dataDir, { from, to: now });
36
+ read = { at: now.getTime(), month, fileUsd: rows.reduce((sum, row) => sum + costOf(row.costUsd), 0), keys: new Set(rows.map(usageRowKey)) };
37
+ cache.set(dataDir, read);
27
38
  }
39
+ let pending = 0;
40
+ for (const [key, entry] of unwritten.get(dataDir) ?? []) {
41
+ if (entry.month === month && !read.keys.has(key))
42
+ pending += entry.costUsd;
43
+ }
44
+ return read.fileUsd + pending;
45
+ }
46
+ /** Called as a turn is booked, with the ledger's write for its row
47
+ * (appendUsage's promise). The cost counts from memory at once; when the
48
+ * row lands it moves into the cached read, and a re-read that already sees
49
+ * the row never counts it twice. A write that failed stops counting, as
50
+ * the ledger has no row for it. */
51
+ export function noteSpend(dataDir, row, written) {
52
+ const costUsd = costOf(row.costUsd);
53
+ if (!costUsd)
54
+ return;
55
+ const key = usageRowKey(row);
56
+ const month = row.at.slice(0, 7);
57
+ const entries = unwritten.get(dataDir) ?? new Map();
58
+ unwritten.set(dataDir, entries);
59
+ entries.set(key, { month, costUsd });
60
+ void written.then((landed) => {
61
+ entries.delete(key);
62
+ if (entries.size === 0 && unwritten.get(dataDir) === entries)
63
+ unwritten.delete(dataDir);
64
+ const read = cache.get(dataDir);
65
+ if (landed && read && read.month === month && !read.keys.has(key)) {
66
+ read.fileUsd += costUsd;
67
+ read.keys.add(key);
68
+ }
69
+ });
28
70
  }
29
71
  export function resetSpendCacheForTests() {
30
72
  cache.clear();
73
+ unwritten.clear();
31
74
  }
32
75
  /** The cap and where the month stands against it; null when there is no
33
76
  * enforceable cap (no entitlement, or none set). */
@@ -59,3 +102,55 @@ export function assertWithinBudget(cfg, dataDir, now = new Date(), isEntitled =
59
102
  return;
60
103
  throw Object.assign(new Error(`this workspace has reached its monthly spend limit of $${usd(state.monthlyUsd)} — an admin can raise it under Settings → Usage`), { status: 409, code: "spend_cap" });
61
104
  }
105
+ const ALERTS_FILE = "alerts.json";
106
+ function readMarks(dataDir) {
107
+ try {
108
+ const value = JSON.parse(readFileSync(join(dataDir, "usage", ALERTS_FILE), "utf8"));
109
+ if (typeof value !== "object" || value === null)
110
+ return null;
111
+ const marks = value;
112
+ return typeof marks.month === "string" && typeof marks.monthlyUsd === "number" ? marks : null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ /** The notice this booking should raise, if any: the cap the first time the
119
+ * month reaches it, the warning the first time it crosses the threshold, and
120
+ * nothing again for either that month. A new month, or a different cap, is a
121
+ * new budget and starts over. Marks live beside the ledger so a restart does
122
+ * not repeat them; if they cannot be written the notice is still sent once
123
+ * for this run. */
124
+ export function takeSpendAlert(dataDir, state) {
125
+ if (!state || (!state.warn && !state.exceeded))
126
+ return null;
127
+ const saved = readMarks(dataDir) ?? memoryMarks.get(dataDir) ?? null;
128
+ const marks = saved && saved.month === state.month && saved.monthlyUsd === state.monthlyUsd
129
+ ? saved
130
+ : { month: state.month, monthlyUsd: state.monthlyUsd };
131
+ const alert = state.exceeded && !marks.cap ? "cap" : state.warn && !marks.warn && !marks.cap ? "warn" : null;
132
+ if (!alert)
133
+ return null;
134
+ // Reaching the cap also answers the warning: one notice, the stronger one.
135
+ const next = { ...marks, warn: true, ...(alert === "cap" ? { cap: true } : {}) };
136
+ memoryMarks.set(dataDir, next);
137
+ try {
138
+ mkdirSync(join(dataDir, "usage"), { recursive: true, mode: 0o700 });
139
+ writeFileSync(join(dataDir, "usage", ALERTS_FILE), JSON.stringify(next), { mode: 0o600 });
140
+ }
141
+ catch {
142
+ /* bookkeeping must never take down the turn */
143
+ }
144
+ return alert;
145
+ }
146
+ const memoryMarks = new Map();
147
+ export function resetSpendAlertsForTests() {
148
+ memoryMarks.clear();
149
+ }
150
+ /** The notice's words. English like the server's other notification titles. */
151
+ export function spendAlertText(alert, state) {
152
+ const spent = `$${state.spentUsd.toFixed(2)} of $${usd(state.monthlyUsd)} spent this month (${state.month})`;
153
+ return alert === "cap"
154
+ ? { title: "Monthly spend limit reached", body: `${spent}. New turns are refused until an admin raises the limit under Settings → Usage.` }
155
+ : { title: `Spend is at ${state.percent}% of the monthly limit`, body: `${spent}. New turns stop when the limit is reached.` };
156
+ }
@@ -72,6 +72,8 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
72
72
  reason: options.reason,
73
73
  unattended: options.unattended,
74
74
  peerAsk: options.peerAsk,
75
+ sender: options.sender,
76
+ trigger: options.trigger,
75
77
  };
76
78
  saveChatFollowup({ id, kind: "bot", ownerId: botId, threadId, payload: item });
77
79
  entry.items.push(item);
@@ -140,6 +142,7 @@ export function drainSteeredMessages(store, run, isBlocked) {
140
142
  sendId: item.sendId,
141
143
  queueId: item.messageId,
142
144
  peerAsk: item.peerAsk,
145
+ sender: item.sender,
143
146
  }));
144
147
  }
145
148
  const last = appended.at(-1);
@@ -153,7 +156,9 @@ export function drainSteeredMessages(store, run, isBlocked) {
153
156
  const running = run(entry.botId, threadId, prompt, last, appended.map((message) => message.id),
154
157
  // one unattended line makes the whole drained turn unattended: a
155
158
  // person's words in the same queue cannot re-attend a bot's own
156
- entry.items.some((item) => item.unattended === true));
159
+ entry.items.some((item) => item.unattended === true),
160
+ // the first waiting line is the one that starts this turn
161
+ { trigger: entry.items[0].trigger, sender: entry.items[0].sender, peerAsk: entry.items[0].peerAsk });
157
162
  void Promise.resolve(running).then(() => settleChatFollowups(ids, null), () => settleChatFollowups(ids, "interrupted")).catch((error) => console.warn("steer-queue: could not settle durable follow-up", error));
158
163
  }
159
164
  }