openmausbot 0.1.87 → 0.1.88

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 (149) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-BTiuL79F.js → abnfDiagram-O67JEVCF-qd9VD3GV.js} +1 -1
  3. package/dist/assets/{arc-C88Blc2v.js → arc-3RwkUjRe.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-BZsJIZrb.js → architectureDiagram-NJMV4G6O-CFKSJS7a.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CVeMe9Ah.js → blockDiagram-BEXU5L5S-CG1rEfWD.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-Cedk3Bhx.js → c4Diagram-YGBWAQC7-CBW9N664.js} +1 -1
  7. package/dist/assets/channel-CnRnO3tF.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-D-VRqzTB.js → chunk-2Q5K7J3B-2mRfTksB.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BChov01Y.js → chunk-3FUC2YCW-Cv1dpErj.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-C-TfwvHy.js → chunk-5VM5RSS4-B7IACJp-.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Db7CGj5Y.js → chunk-GTNCS2PH-An0hZtYa.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CNvIfqKT.js → chunk-GWA4HPMP-BcJB2nEm.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-C_bEp5tq.js → chunk-JWPE2WC7-CV_W7TN7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-Coi4BOls.js → chunk-NETBCI7D-Bcs_kwnA.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CzylEuL3.js → chunk-WEXAMYUT-lrx_DxFP.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-m7p7ztaD.js → chunk-XXDRQBXY-DabHlLGM.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BMy6o9x2.js → classDiagram-v2-NBCMYWYE-BnHj6P7d.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BDQ__0_J.js → cose-bilkent-JH36ORCC-CfLp6UDi.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-Djv4ghUb.js → cynefin-OW5HDTMX-Dm4dbVA1.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BULrlGYH.js → cynefinDiagram-VND7K2PF-B9mH-PID.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-D-jrT_hU.js → dagre-6A5THRUB-DDc_NVQJ.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-p2UhQmfL.js → diagram-22UHCM2B-lwr8Ip8-.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-DHvGWZ7q.js → diagram-3UASUU5V-BLLe6dmI.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-ChhKDot-.js → diagram-ATOU4E4O-6eUIq4lz.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-B-T7A43R.js → diagram-CDSNMT55-O-pRN7VQ.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CJm8ENNY.js → diagram-MLGK6HIB-BietvGAI.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-510tlza8.js → diagram-MPIPVDR6-Bd2Cl62X.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-PkvRPHel.js → ebnfDiagram-ZINNZB2B-B6iDy69W.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BVBVPoX3.js → elk-276RUBZZ-DQVHfKe0.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BgYMVm7L.js → erDiagram-OPXOYQCR-BWdVp2tQ.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-BGqsnc_3.js → flowDiagram-KWPJA3E3-WLE6rKB4.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-D_-UeReH.js → ganttDiagram-FUAMR5RP-BbjS-4gn.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-D0uooONx.js → gitGraphDiagram-X574FWY7-Dpy2QSGq.js} +1 -1
  34. package/dist/assets/index-BIMdIbaw.css +1 -0
  35. package/dist/assets/index-CHnODCr2.js +583 -0
  36. package/dist/assets/{index-zRsJERGJ.js → index-CIaHn5NS.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B9e1z77f.js → infoDiagram-VRGFBTTK-D_jKWlKe.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BB4vNwvD.js → ishikawaDiagram-OU5B5YK6-DLJohSuK.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DpkosOTB.js → journeyDiagram-ZHPQQLJL-DPOFj0qN.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DbDP4kUu.js → kanban-definition-PNTS6WVX-BI7v-xpK.js} +1 -1
  41. package/dist/assets/{linear-DBJGPmjT.js → linear-C2pWPSxO.js} +1 -1
  42. package/dist/assets/{mermaid.core-DWPRi-ue.js → mermaid.core-CR-j5WQU.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-nszBbJmx.js → mindmap-definition-NLK3R4M7-lz9GvXRh.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CWykJWu_.js → pegDiagram-GJSIUBJH-Dl4_ia61.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-g3Da-Vnv.js → pieDiagram-5QR66LMP-DWuT3n88.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Wzrlpzgh.js → quadrantDiagram-O4NWA36T-DXWe9Jx7.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-moHTFMC0.js → railroadDiagram-XR7U4H2S-COY1IBVY.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CwfXdFH8.js → requirementDiagram-PLB6GJNP-DnyVuhsQ.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-3ZROYvnq.js → sankeyDiagram-IPEJSGJF-BSc4NdGK.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-E1TBebP8.js → sequenceDiagram-PO4LG4MO-kqEU2EGq.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-DHKEU5pu.js → sizeCapture-INFHLROL-BvB4bkWD.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BguEj09D.js → stateDiagram-v2-GCMORJYK-BIrqNv7y.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-C7IDYnhd.js → swimlanes-2SLR337P-CFnalQUE.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C4a8qNGj.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-DCW-F5PW.js → timeline-definition-EJHVYXUP-CsSWJnSQ.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-xBMFtWYV.js → usecaseDiagram-POWQR4AR-DkAmJ1EE.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-B5nIkuH8.js → vennDiagram-UO4OBE2U-C3vd9drL.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DYFAhdbr.js → wardleyDiagram-VNRHLVJA-j26CM-pi.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-DeXwX6Cz.js → xychartDiagram-PMCCYNJV-BYu1Q_at.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/companion/src/routes.js +8 -1
  62. package/dist-server/connector-proxy.js +105 -1
  63. package/dist-server/container-mcp.js +3 -1
  64. package/dist-server/drivers/agents-proxy.js +277 -22
  65. package/dist-server/index.js +23150 -19947
  66. package/dist-server/local-computer-proxy.js +2 -0
  67. package/dist-server/mcp-gate.js +2 -0
  68. package/dist-server/mcp-server.js +5 -3
  69. package/dist-server/message-search.worker.js +52 -0
  70. package/dist-server/openmausbot.js +540 -298
  71. package/dist-server/pair-cli.js +540 -298
  72. package/dist-server/server/admission.js +81 -0
  73. package/dist-server/server/aside-queue.js +294 -0
  74. package/dist-server/server/attachments.js +43 -1
  75. package/dist-server/server/bot-attachment.js +107 -0
  76. package/dist-server/server/bot-overview.js +19 -0
  77. package/dist-server/server/cli-api-setup.js +1 -0
  78. package/dist-server/server/composio.js +297 -1
  79. package/dist-server/server/config.js +25 -3
  80. package/dist-server/server/connector-advertisement.js +165 -0
  81. package/dist-server/server/connector-proxy.js +14 -2
  82. package/dist-server/server/connector-verdict.js +31 -4
  83. package/dist-server/server/container-computer.js +60 -0
  84. package/dist-server/server/contracts.js +22 -0
  85. package/dist-server/server/drivers/acp/core.js +24 -6
  86. package/dist-server/server/drivers/acp/cursor.js +3 -7
  87. package/dist-server/server/drivers/acp/droid.js +4 -7
  88. package/dist-server/server/drivers/acp/gemini.js +4 -4
  89. package/dist-server/server/drivers/acp/grok.js +4 -9
  90. package/dist-server/server/drivers/acp/hermes.js +3 -5
  91. package/dist-server/server/drivers/acp/opencode-go.js +2 -5
  92. package/dist-server/server/drivers/acp/qwen.js +9 -15
  93. package/dist-server/server/drivers/agents-call.js +137 -10
  94. package/dist-server/server/drivers/agents-catalog.js +100 -6
  95. package/dist-server/server/drivers/boxagent.js +2 -16
  96. package/dist-server/server/drivers/claude.js +31 -17
  97. package/dist-server/server/drivers/codex-catalog.js +14 -6
  98. package/dist-server/server/drivers/codex-identity.js +2 -1
  99. package/dist-server/server/drivers/codex-release.js +48 -0
  100. package/dist-server/server/drivers/codex.js +7 -25
  101. package/dist-server/server/drivers/openai-chat.js +26 -2
  102. package/dist-server/server/drivers/pi.js +237 -30
  103. package/dist-server/server/drivers/prompt-split.js +32 -9
  104. package/dist-server/server/drivers/session-idle.js +29 -0
  105. package/dist-server/server/env-path.js +15 -0
  106. package/dist-server/server/guarded-requests.js +2 -2
  107. package/dist-server/server/harness-capabilities.js +46 -0
  108. package/dist-server/server/index.js +1037 -209
  109. package/dist-server/server/message-db.js +22 -42
  110. package/dist-server/server/message-file.js +9 -1
  111. package/dist-server/server/message-search-query.js +43 -0
  112. package/dist-server/server/message-search-worker.js +80 -0
  113. package/dist-server/server/message-search.worker.js +21 -0
  114. package/dist-server/server/model-requests.js +248 -0
  115. package/dist-server/server/options-card.js +21 -0
  116. package/dist-server/server/org-library.js +15 -1
  117. package/dist-server/server/peer-delivery.js +60 -0
  118. package/dist-server/server/peer-roster.js +57 -0
  119. package/dist-server/server/presets.js +32 -32
  120. package/dist-server/server/profile-requests.js +66 -39
  121. package/dist-server/server/profile-revision.js +9 -1
  122. package/dist-server/server/profile-versions.js +6 -3
  123. package/dist-server/server/routine-requests.js +29 -7
  124. package/dist-server/server/skill-fetch.js +72 -14
  125. package/dist-server/server/skills.js +1 -0
  126. package/dist-server/server/store.js +32 -2
  127. package/dist-server/server/system-prompt.js +26 -2
  128. package/dist-server/server/team-setup-requests.js +124 -23
  129. package/dist-server/server/thread-events.js +3 -1
  130. package/dist-server/server/tightening-requests.js +405 -0
  131. package/dist-server/server/tts/chatterbox.js +27 -5
  132. package/dist-server/server/usage-ledger.js +7 -1
  133. package/dist-server/server/workspace-backup.js +40 -3
  134. package/dist-server/server/workspace-backup.worker.js +10 -0
  135. package/dist-server/shared/ask-question.js +19 -0
  136. package/dist-server/shared/credential-request.js +5 -1
  137. package/dist-server/shared/line-diff.js +23 -0
  138. package/dist-server/shared/model-request.js +1 -0
  139. package/dist-server/shared/options-card.js +51 -0
  140. package/dist-server/shared/profile-request.js +5 -4
  141. package/dist-server/shared/tightening-request.js +87 -0
  142. package/dist-server/src/lib/composer-attachments.js +25 -3
  143. package/dist-server/vps-container-mcp.js +3 -1
  144. package/dist-server/workspace-backup.worker.js +19474 -0
  145. package/package.json +1 -1
  146. package/dist/assets/channel-taCNRlUU.js +0 -1
  147. package/dist/assets/index-BwdMAQ6p.css +0 -1
  148. package/dist/assets/index-ByJennr7.js +0 -581
  149. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +0 -8
@@ -5,6 +5,9 @@ import { createHash, randomUUID } from "node:crypto";
5
5
  import { z } from "zod";
6
6
  import { SPAWNED_PROXIES } from "./proxy-paths.js";
7
7
  import { managedConnectorUnavailableReason } from "../shared/connector-availability.js";
8
+ import { CONNECTOR_TOOL_NAME_PATTERN } from "../shared/wire.js";
9
+ import { CONNECTOR_ALLOWED_TOOLS_ENV, CONNECTOR_ALLOWED_TOOLS_MAX_BYTES, CONNECTOR_SERVICE_SLUGS_ENV, CONNECTOR_SERVICE_SLUGS_MAX_BYTES, serializeConnectorAllowedTools, serializeConnectorServiceSlugs, } from "./connector-advertisement.js";
10
+ import { serviceSlugFor, serviceSlugForCandidates } from "./connector-verdict.js";
8
11
  const DEFAULT_BACKEND_ORIGIN = "https://backend.composio.dev";
9
12
  function apiBase() {
10
13
  return (process.env.OMB_COMPOSIO_API ?? `${DEFAULT_BACKEND_ORIGIN}/api/v3.1`).replace(/\/$/, "");
@@ -443,6 +446,30 @@ const NEEDS_AUTH_CONFIG = /does not manage auth|auth[_ ]?config/i;
443
446
  export async function mcpIntegration(cfg, context) {
444
447
  if (!configured(cfg))
445
448
  return null;
449
+ // Connector grants 3/5: a grants record rides to the bridge as a capped
450
+ // JSON env var so tools/list is filtered down to what the bot may call.
451
+ // No record (legacy all-tools bots) or one past the cap mounts without
452
+ // the var — the bridge then relays the unfiltered list and every call is
453
+ // still judged by the slice-2 verdict on the relay endpoint.
454
+ const allowlist = context.connectorTools === undefined
455
+ ? undefined
456
+ : serializeConnectorAllowedTools(context.connectorTools);
457
+ if (allowlist?.oversized) {
458
+ console.warn(`[composio] connector allowlist for bot ${context.botId} exceeds ${CONNECTOR_ALLOWED_TOOLS_MAX_BYTES} bytes;`
459
+ + " tools/list stays unfiltered and grants are enforced per call");
460
+ }
461
+ // The connected-service slugs ride alongside the allowlist so the
462
+ // bridge resolves underscored services (bland_ai) instead of letting a
463
+ // plain-prefix grant (bland) widen them. An unreachable catalog omits
464
+ // the var and the filter keeps its plain-split fallback; legacy
465
+ // all-tools bots mount without it because their list is unfiltered.
466
+ const serviceSlugs = context.connectorTools === undefined
467
+ ? undefined
468
+ : serializeConnectorServiceSlugs(await connectedServiceSlugs(cfg));
469
+ if (serviceSlugs?.oversized) {
470
+ console.warn(`[composio] connector service slugs for bot ${context.botId} exceed ${CONNECTOR_SERVICE_SLUGS_MAX_BYTES} bytes;`
471
+ + " tools/list falls back to plain-prefix resolution");
472
+ }
446
473
  return {
447
474
  command: process.execPath,
448
475
  args: [SPAWNED_PROXIES.connectors],
@@ -460,6 +487,8 @@ export async function mcpIntegration(cfg, context) {
460
487
  OMB_CONNECTOR_TOKEN: context.commsToken,
461
488
  OMB_BOT_ID: context.botId,
462
489
  OMB_THREAD_ID: context.threadId,
490
+ ...(allowlist?.env ? { [CONNECTOR_ALLOWED_TOOLS_ENV]: allowlist.env } : {}),
491
+ ...(serviceSlugs?.env ? { [CONNECTOR_SERVICE_SLUGS_ENV]: serviceSlugs.env } : {}),
463
492
  },
464
493
  };
465
494
  }
@@ -672,6 +701,266 @@ export async function connectedServices(cfg) {
672
701
  ]);
673
702
  return allServiceStates(summarizeAccounts(accounts, []), toolkits);
674
703
  }
704
+ /** Semantic validation for a connectorTools patch (issue #1737): slugs
705
+ * must name a connected service, and every listed tool name must carry its
706
+ * own service prefix (GMAIL_SEND_EMAIL under "gmail") — a mismatched name
707
+ * would be granted yet permanently refused at call time. The prefix check
708
+ * resolves against the connected-service slugs, so an underscored service
709
+ * (bland_ai) keeps its BLAND_AI_* names and a plain-prefix grant cannot
710
+ * capture them; it always runs, degrading to the plain split when the
711
+ * connection inventory is unreachable. Only the connection-dependent and
712
+ * catalog checks fail open — an unreachable connection inventory, the
713
+ * curated fallback, or a catalog walk that did not reach its end never
714
+ * block the patch, because call-time enforcement (slice 2) stays the
715
+ * authority either way. Returns the rejection message or null. */
716
+ export async function validateConnectorGrants(cfg, grants) {
717
+ const connected = await connectedServices(cfg).catch(() => null);
718
+ const candidates = connected ? Object.keys(connected) : [];
719
+ for (const [slug, grant] of Object.entries(grants)) {
720
+ if (grant.tools === "*")
721
+ continue;
722
+ // The prefix check runs against the connected-service slugs, so an
723
+ // underscored service (bland_ai) accepts its own BLAND_AI_* names —
724
+ // and a plain-prefix grant cannot capture them while the real service
725
+ // is connected.
726
+ const misplaced = grant.tools.filter((tool) => (serviceSlugForCandidates(tool, candidates) ?? serviceSlugFor(tool)) !== slug);
727
+ if (misplaced.length) {
728
+ return `connectorTools.${slug}.tools names tools that belong to another service: ${misplaced.join(", ")}`;
729
+ }
730
+ }
731
+ if (!connected)
732
+ return null;
733
+ const unconnected = Object.keys(grants).filter((slug) => !connected[slug]?.connected);
734
+ if (unconnected.length) {
735
+ return `connectorTools names services that are not connected: ${unconnected.join(", ")}`;
736
+ }
737
+ const catalog = await listToolkits(cfg);
738
+ // source "curated" means the marketplace was unreachable, and a walk that
739
+ // never reached its natural end served only part of it — either way there
740
+ // is nothing trustworthy to cross-check against, and a grant for a service
741
+ // on a page the walk never saw must not be rejected.
742
+ if (catalog.source !== "api" || !catalog.pagination?.complete)
743
+ return null;
744
+ const known = new Set(catalog.cards.map((card) => card.slug.toLowerCase()));
745
+ const unknown = Object.keys(grants).filter((slug) => !known.has(slug));
746
+ if (unknown.length) {
747
+ return `connectorTools names services missing from the connected-apps catalog: ${unknown.join(", ")}`;
748
+ }
749
+ return null;
750
+ }
751
+ /** The grant editor's tool inventory, grouped by service slug. Sourced from
752
+ * the same MCP endpoint the bots' bridge relays to (initialize + tools/list
753
+ * through relayMcp), so what the picker offers is exactly what a granted bot
754
+ * would see — in self-hosted and managed modes alike. Platform meta-tools
755
+ * and per-service connection flows are not per-tool grants (they ride along
756
+ * whenever a service is granted), so they never appear in the picker. */
757
+ export async function listConnectorTools(cfg, options = {}) {
758
+ const identity = connectorToolsIdentity(cfg);
759
+ if (!options.force && connectorToolsCache?.identity === identity
760
+ && Date.now() - connectorToolsCache.at < CONNECTOR_TOOLS_CACHE_MS) {
761
+ return connectorToolsCache.services;
762
+ }
763
+ // Concurrent misses share one MCP walk — two editors opening together
764
+ // must not run the initialize + tools/list sequences twice. force
765
+ // bypasses the settled cache but still joins a walk already running.
766
+ if (connectorToolsRequest?.identity === identity)
767
+ return connectorToolsRequest.services;
768
+ const walk = collectConnectorTools(cfg).then((services) => {
769
+ connectorToolsCache = { at: Date.now(), identity, services };
770
+ return services;
771
+ });
772
+ connectorToolsRequest = { identity, services: walk };
773
+ try {
774
+ return await walk;
775
+ }
776
+ finally {
777
+ if (connectorToolsRequest?.services === walk)
778
+ connectorToolsRequest = null;
779
+ }
780
+ }
781
+ const CONNECTOR_TOOLS_CACHE_MS = 60_000;
782
+ let connectorToolsCache = null;
783
+ /** Which backend an inventory walk belongs to, mirroring the relay's own
784
+ * credential fingerprint: a project key or the managed broker. Cached
785
+ * inventories are keyed by it, so a config switch can never serve the
786
+ * previous backend's tools for another cache window. */
787
+ function connectorToolsIdentity(cfg) {
788
+ const apiKey = projectApiKey(cfg);
789
+ if (apiKey)
790
+ return backendFingerprint("project-tools", apiBase(), apiKey);
791
+ const broker = brokerAccess();
792
+ return broker ? backendFingerprint("managed-tools", broker.url, broker.token) : "none";
793
+ }
794
+ let connectorToolsRequest = null;
795
+ /** The service slugs grant enforcement resolves tool-name prefixes
796
+ * against: every key of the connection inventory, connected or not,
797
+ * because the real backend slugs are what separates an underscored
798
+ * service (bland_ai) from a plain-prefix grant (bland). Cached like the
799
+ * tool inventory — 60s, keyed by backend identity, one shared in-flight
800
+ * walk — and an unreachable inventory reads as an empty list without
801
+ * caching the failure, so callers fall back to the plain split and the
802
+ * next call retries. */
803
+ export async function connectedServiceSlugs(cfg) {
804
+ const identity = connectorToolsIdentity(cfg);
805
+ if (connectorServiceSlugsCache?.identity === identity
806
+ && Date.now() - connectorServiceSlugsCache.at < CONNECTOR_SERVICE_SLUGS_CACHE_MS) {
807
+ return connectorServiceSlugsCache.slugs;
808
+ }
809
+ if (connectorServiceSlugsRequest?.identity === identity)
810
+ return connectorServiceSlugsRequest.slugs;
811
+ const walk = connectedServices(cfg).then((services) => {
812
+ const slugs = Object.keys(services);
813
+ connectorServiceSlugsCache = { at: Date.now(), identity, slugs };
814
+ return slugs;
815
+ }, () => []);
816
+ connectorServiceSlugsRequest = { identity, slugs: walk };
817
+ try {
818
+ return await walk;
819
+ }
820
+ finally {
821
+ if (connectorServiceSlugsRequest?.slugs === walk)
822
+ connectorServiceSlugsRequest = null;
823
+ }
824
+ }
825
+ const CONNECTOR_SERVICE_SLUGS_CACHE_MS = 60_000;
826
+ let connectorServiceSlugsCache = null;
827
+ let connectorServiceSlugsRequest = null;
828
+ /** Tool names longer than any Composio description worth searching. */
829
+ const TOOL_DESCRIPTION_MAX = 240;
830
+ const TOOLS_LIST_PAGES_MAX = 10;
831
+ function connectorToolListing(tool) {
832
+ if (!tool || typeof tool !== "object" || Array.isArray(tool))
833
+ return null;
834
+ const name = tool.name;
835
+ if (typeof name !== "string" || !CONNECTOR_TOOL_NAME_PATTERN.test(name))
836
+ return null;
837
+ if (name.startsWith("COMPOSIO_"))
838
+ return null;
839
+ if (name.endsWith("_MANAGE_CONNECTIONS") || name.endsWith("_WAIT_FOR_CONNECTIONS"))
840
+ return null;
841
+ const description = tool.description;
842
+ const trimmed = typeof description === "string"
843
+ ? description.replace(/\s+/g, " ").trim().slice(0, TOOL_DESCRIPTION_MAX)
844
+ : "";
845
+ return trimmed ? { name, description: trimmed } : { name };
846
+ }
847
+ /** One JSON-RPC response frame from a JSON or SSE body, mirroring the
848
+ * bridge's parseUpstream: SSE data lines are tried in order and the frame
849
+ * carrying the request id wins. */
850
+ function parseMcpResponse(text, id) {
851
+ const trimmed = text.trim();
852
+ if (!trimmed)
853
+ return null;
854
+ const candidate = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
855
+ if (trimmed.startsWith("{")) {
856
+ const parsed = JSON.parse(trimmed);
857
+ return candidate(parsed) ? parsed : null;
858
+ }
859
+ const frames = trimmed
860
+ .split(/\r?\n/)
861
+ .filter((line) => line.startsWith("data:"))
862
+ .map((line) => line.slice(5).trim())
863
+ .filter((line) => line && line !== "[DONE]")
864
+ .flatMap((line) => {
865
+ try {
866
+ return [JSON.parse(line)];
867
+ }
868
+ catch {
869
+ return [];
870
+ }
871
+ })
872
+ .filter(candidate);
873
+ return frames.findLast((frame) => frame.id === id) ?? frames.at(-1) ?? null;
874
+ }
875
+ async function collectConnectorTools(cfg) {
876
+ // Connected services are the real slugs, so they resolve underscored
877
+ // services (bland_ai) the plain split would file under "bland"; an
878
+ // unreachable inventory degrades to that split.
879
+ const connected = await connectedServices(cfg).catch(() => null);
880
+ const candidates = connected ? Object.keys(connected) : [];
881
+ const initialize = await relayMcp(cfg, {
882
+ jsonrpc: "2.0",
883
+ id: "omb-grants-initialize",
884
+ method: "initialize",
885
+ params: {
886
+ protocolVersion: "2025-03-26",
887
+ capabilities: {},
888
+ clientInfo: { name: "openmausbot-grant-editor", version: "1" },
889
+ },
890
+ });
891
+ if (initialize.status !== 200) {
892
+ throw new Error(await responseErrorFromBytes(initialize.status, initialize.bytes));
893
+ }
894
+ const transportSessionId = initialize.transportSessionId;
895
+ // Streamable-HTTP servers expect the handshake notification before the
896
+ // first request; it carries no response, so failures are not fatal.
897
+ await relayMcp(cfg, {
898
+ jsonrpc: "2.0",
899
+ method: "notifications/initialized",
900
+ }, transportSessionId).catch(() => undefined);
901
+ const grouped = new Map();
902
+ let cursor;
903
+ for (let page = 0; page < TOOLS_LIST_PAGES_MAX; page += 1) {
904
+ const list = await relayMcp(cfg, {
905
+ jsonrpc: "2.0",
906
+ id: "omb-grants-tools",
907
+ method: "tools/list",
908
+ params: cursor ? { cursor } : {},
909
+ }, transportSessionId);
910
+ if (list.status !== 200) {
911
+ throw new Error(await responseErrorFromBytes(list.status, list.bytes));
912
+ }
913
+ const frame = parseMcpResponse(new TextDecoder().decode(list.bytes), "omb-grants-tools");
914
+ const error = frame && typeof frame.error === "object" && frame.error !== null
915
+ ? frame.error.message
916
+ : undefined;
917
+ if (typeof error === "string" && error)
918
+ throw new Error(error);
919
+ const result = frame && typeof frame.result === "object" && frame.result !== null
920
+ ? frame.result
921
+ : undefined;
922
+ if (!result || !Array.isArray(result.tools)) {
923
+ throw new Error("Composio returned a tools/list response without a tools array");
924
+ }
925
+ for (const tool of result.tools) {
926
+ const listing = connectorToolListing(tool);
927
+ if (!listing)
928
+ continue;
929
+ const slug = serviceSlugForCandidates(listing.name, candidates) ?? serviceSlugFor(listing.name);
930
+ if (!slug)
931
+ continue;
932
+ const bucket = grouped.get(slug) ?? [];
933
+ if (!bucket.some((existing) => existing.name === listing.name))
934
+ bucket.push(listing);
935
+ grouped.set(slug, bucket);
936
+ }
937
+ cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : undefined;
938
+ if (!cursor)
939
+ break;
940
+ }
941
+ for (const bucket of grouped.values())
942
+ bucket.sort((a, b) => a.name.localeCompare(b.name));
943
+ return Object.fromEntries([...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)));
944
+ }
945
+ async function responseErrorFromBytes(status, bytes) {
946
+ try {
947
+ const body = JSON.parse(new TextDecoder().decode(bytes));
948
+ const message = body && typeof body === "object" && !Array.isArray(body)
949
+ ? body.error
950
+ : undefined;
951
+ if (message && typeof message === "object" && !Array.isArray(message)) {
952
+ const text = message.message;
953
+ if (typeof text === "string" && text)
954
+ return text;
955
+ }
956
+ if (typeof message === "string" && message)
957
+ return message;
958
+ }
959
+ catch {
960
+ // fall through to the generic status line
961
+ }
962
+ return `Composio tools/list: HTTP ${status}`;
963
+ }
675
964
  export async function connectionStatus(cfg, slugs) {
676
965
  const canonicalSlugs = [...new Set(slugs.map(canonicalToolkitSlug))];
677
966
  const apiKey = projectApiKey(cfg);
@@ -989,9 +1278,16 @@ export async function listToolkits(cfg) {
989
1278
  const pagingStoppedEarly = lastReportedPage !== undefined
990
1279
  && reportedTotalPages !== undefined
991
1280
  && lastReportedPage < reportedTotalPages;
992
- const pagination = { items: uniqueCards.length, stalled: shortOfReportedTotal || pagingStoppedEarly };
1281
+ const stalled = shortOfReportedTotal || pagingStoppedEarly;
1282
+ // "end" and "total-reached" are the walk's natural finishes; every
1283
+ // other stop reason left pages unread even when the API reports no
1284
+ // totals for stalled to compare against.
1285
+ const complete = (stop === "end" || stop === "total-reached") && !stalled;
1286
+ const pagination = { items: uniqueCards.length, stalled, complete };
993
1287
  if (reportedTotalItems !== undefined)
994
1288
  pagination.totalItems = reportedTotalItems;
1289
+ if (pagination.stalled)
1290
+ pagination.reason = stop;
995
1291
  if (pagination.stalled) {
996
1292
  console.warn(`[composio] marketplace catalog paging stopped early (${stop}) after ${uniqueCards.length} toolkits`
997
1293
  + (reportedTotalItems !== undefined ? ` of ~${reportedTotalItems}` : "")
@@ -33,7 +33,7 @@ export const MAX_THREAD_EVENT_LOG_BYTES = 4 * 1024 * 1024 * 1024;
33
33
  export const DEFAULT_LOCAL_VM_MODE = "shared";
34
34
  export const DEFAULT_LOCAL_VM_MAX_INSTANCES = 2;
35
35
  export const MIN_LOCAL_VM_MAX_INSTANCES = 1;
36
- export const MAX_LOCAL_VM_MAX_INSTANCES = 4;
36
+ export const MAX_LOCAL_VM_MAX_INSTANCES = 8;
37
37
  export function isValidSshAlias(value) {
38
38
  return typeof value === "string" && SSH_ALIAS.test(value);
39
39
  }
@@ -683,13 +683,35 @@ function migrateLegacyFeatureFlags() {
683
683
  console.error(`[config] could not rename features.skillRecorder: ${error instanceof Error ? error.message : String(error)}`);
684
684
  }
685
685
  }
686
+ /** The last "config.json is being ignored" warning, so a file that stays
687
+ * broken is reported once rather than on every loadConfig() call. */
688
+ let lastIgnoredConfigWarning = "";
686
689
  export function loadConfig() {
687
690
  let cfg = {};
688
691
  try {
689
692
  cfg = parseStoredConfig(parseJson(readFileSync(join(DATA_DIR, "config.json"), "utf8")));
693
+ lastIgnoredConfigWarning = "";
690
694
  }
691
- catch {
692
- /* first run — env fallbacks below */
695
+ catch (error) {
696
+ // No file yet is a normal first run: env fallbacks below. Any other failure
697
+ // (unreadable file, invalid JSON, a schema error in one field) means the
698
+ // whole file is being ignored for this process — every instance, account
699
+ // and setting in it — so say why instead of silently running on defaults.
700
+ // saveConfig() still merges into the raw file, so nothing on disk is lost;
701
+ // the user just needs to know which field to fix.
702
+ if (error?.code !== "ENOENT") {
703
+ // JSON.parse includes a fragment of the input in some error messages.
704
+ // A malformed credential must never be copied into the server log.
705
+ const reason = error instanceof SyntaxError ? "invalid JSON"
706
+ : error instanceof Error ? error.message : "unable to read configuration";
707
+ const warning = `config: ignoring ${join(DATA_DIR, "config.json")} and using defaults: ${reason}`;
708
+ if (warning !== lastIgnoredConfigWarning)
709
+ console.warn(warning);
710
+ lastIgnoredConfigWarning = warning;
711
+ }
712
+ else {
713
+ lastIgnoredConfigWarning = "";
714
+ }
693
715
  }
694
716
  // Env wins over the file for every credential. The desktop shell keeps
695
717
  // these secrets OS-encrypted and hands them to this process as env at
@@ -0,0 +1,165 @@
1
+ // Advertisement filtering for the connector MCP bridge (issue #1737, part
2
+ // of the per-bot Composio tool grants umbrella #1734).
3
+ //
4
+ // Call-time enforcement (connector-verdict.ts, slice 2) stays the
5
+ // authority: it judges the real tool names on the harness relay endpoint,
6
+ // where the loopback token already names the bot. This module is surface
7
+ // only — it trims a tools/list response to the tools the bot was granted,
8
+ // so the model is never offered a call that would be refused a moment
9
+ // later. The allowlist reaches the bridge as JSON in the environment, the
10
+ // same channel every other bridge input uses; whenever filtering cannot
11
+ // run — no env var, malformed env, oversized payload — the bridge relays
12
+ // the list untouched and the grants stay enforced at call time.
13
+ import { CONNECTOR_SLUG_PATTERN, CONNECTOR_TOOL_NAME_PATTERN } from "../shared/wire.js";
14
+ import { serviceSlugFor, serviceSlugForCandidates } from "./connector-verdict.js";
15
+ /** The env var mcpIntegration sets and connector-proxy.ts reads. */
16
+ export const CONNECTOR_ALLOWED_TOOLS_ENV = "OMB_CONNECTOR_ALLOWED_TOOLS";
17
+ /** The env var carrying the connected-service slugs the filter resolves
18
+ * tool-name prefixes against. Without it the filter falls back to the
19
+ * plain first-segment split, exactly as when the catalog is unreachable. */
20
+ export const CONNECTOR_SERVICE_SLUGS_ENV = "OMB_CONNECTOR_SERVICE_SLUGS";
21
+ /** Upper bound for the serialized allowlist. Past it the env var is
22
+ * omitted entirely (mounting must never depend on catalog size) and the
23
+ * bot keeps the unfiltered list with call-time enforcement only. */
24
+ export const CONNECTOR_ALLOWED_TOOLS_MAX_BYTES = 32 * 1024;
25
+ /** Upper bound for the serialized slug list; past it the env var is
26
+ * omitted and the filter falls back to the plain split. */
27
+ export const CONNECTOR_SERVICE_SLUGS_MAX_BYTES = 32 * 1024;
28
+ /** The platform meta-tools. They name no connected-app tool themselves —
29
+ * search and schemas discover, the executor runs what it is told — so
30
+ * they stay advertised whenever any tool is granted; the executor's
31
+ * arguments are still judged call-time by connector-verdict.ts. */
32
+ export const CONNECTOR_META_TOOLS = [
33
+ "COMPOSIO_SEARCH_TOOLS",
34
+ "COMPOSIO_GET_TOOL_SCHEMAS",
35
+ "COMPOSIO_MULTI_EXECUTE_TOOL",
36
+ // Session-level connection meta-tools. COMPOSIO_MANAGE_CONNECTIONS ships
37
+ // in every Composio session; COMPOSIO_WAIT_FOR_CONNECTIONS is opt-in and
38
+ // off by default. They prefix as service "composio", which no grant
39
+ // names, so they ride this list instead of CONNECTION_TOOL_SUFFIXES —
40
+ // connector-proxy intercepts their calls to surface connection cards.
41
+ // The filter only keeps names upstream actually lists, so advertising an
42
+ // opted-out tool changes nothing.
43
+ "COMPOSIO_MANAGE_CONNECTIONS",
44
+ "COMPOSIO_WAIT_FOR_CONNECTIONS",
45
+ ];
46
+ const CONNECTION_TOOL_SUFFIXES = ["_MANAGE_CONNECTIONS", "_WAIT_FOR_CONNECTIONS"];
47
+ /** Harness side: serialize a grants record for the bridge env, or report
48
+ * that it exceeded the cap (the caller warns and omits it). */
49
+ export function serializeConnectorAllowedTools(grants) {
50
+ const env = JSON.stringify(grants);
51
+ if (Buffer.byteLength(env, "utf8") > CONNECTOR_ALLOWED_TOOLS_MAX_BYTES)
52
+ return { oversized: true };
53
+ return { env, oversized: false };
54
+ }
55
+ /** Harness side: serialize the connected-service slugs for the bridge
56
+ * env, or report that they exceeded the cap. Duplicate or non-slug
57
+ * entries are dropped rather than failing the whole mount. */
58
+ export function serializeConnectorServiceSlugs(slugs) {
59
+ const unique = [...new Set(slugs.filter((slug) => CONNECTOR_SLUG_PATTERN.test(slug)))];
60
+ if (!unique.length)
61
+ return { oversized: false };
62
+ const env = JSON.stringify(unique);
63
+ if (Buffer.byteLength(env, "utf8") > CONNECTOR_SERVICE_SLUGS_MAX_BYTES)
64
+ return { oversized: true };
65
+ return { env, oversized: false };
66
+ }
67
+ /** Bridge side: decode and shape-check the allowlist env. Anything off —
68
+ * absent, unparseable, wrong shapes — reads as "no allowlist" so the
69
+ * bridge degrades to relaying the unfiltered list rather than guessing a
70
+ * narrower one. Call-time enforcement holds either way. */
71
+ export function parseConnectorAllowedToolsEnv(raw) {
72
+ if (!raw)
73
+ return null;
74
+ let value;
75
+ try {
76
+ value = JSON.parse(raw);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ if (!value || typeof value !== "object" || Array.isArray(value))
82
+ return null;
83
+ const grants = {};
84
+ for (const [slug, grant] of Object.entries(value)) {
85
+ if (!CONNECTOR_SLUG_PATTERN.test(slug))
86
+ return null;
87
+ if (!grant || typeof grant !== "object" || Array.isArray(grant))
88
+ return null;
89
+ const tools = grant.tools;
90
+ if (tools === "*") {
91
+ grants[slug] = { tools: "*" };
92
+ continue;
93
+ }
94
+ if (!Array.isArray(tools) || tools.length === 0)
95
+ return null;
96
+ if (!tools.every((tool) => typeof tool === "string" && CONNECTOR_TOOL_NAME_PATTERN.test(tool))) {
97
+ return null;
98
+ }
99
+ grants[slug] = { tools };
100
+ }
101
+ return grants;
102
+ }
103
+ /** Bridge side: decode the connected-service slug list. Anything off —
104
+ * absent, unparseable, or one bad entry — reads as an empty list so the
105
+ * filter degrades to the plain split instead of trusting a partial
106
+ * inventory. */
107
+ export function parseConnectorServiceSlugsEnv(raw) {
108
+ if (!raw)
109
+ return [];
110
+ let value;
111
+ try {
112
+ value = JSON.parse(raw);
113
+ }
114
+ catch {
115
+ return [];
116
+ }
117
+ if (!Array.isArray(value))
118
+ return [];
119
+ return value.every((slug) => typeof slug === "string" && CONNECTOR_SLUG_PATTERN.test(slug))
120
+ ? value
121
+ : [];
122
+ }
123
+ /** Whether one advertised tool name survives the filter. A tool is
124
+ * offered when its service is granted with `"*`", when its exact name is
125
+ * granted, or when it is that service's connection flow — a card the
126
+ * person approves, which slice 2 passes through at call time and which a
127
+ * bot granted a service should still be able to offer for another
128
+ * account. */
129
+ export function connectorToolAdvertised(name, grants, candidates = []) {
130
+ if (Object.keys(grants).length > 0 && CONNECTOR_META_TOOLS.includes(name))
131
+ return true;
132
+ // The connected-service slugs are the resolver's candidates, so an
133
+ // underscored service (bland_ai) claims its own tools instead of a
134
+ // plain-prefix grant (bland) widening them; an unreachable catalog
135
+ // leaves the list empty and the plain split stands.
136
+ const service = serviceSlugForCandidates(name, candidates) ?? serviceSlugFor(name);
137
+ const grant = service === null ? undefined : grants[service];
138
+ if (!grant)
139
+ return false;
140
+ if (grant.tools === "*")
141
+ return true;
142
+ if (grant.tools.includes(name))
143
+ return true;
144
+ return CONNECTION_TOOL_SUFFIXES.some((suffix) => name.endsWith(suffix));
145
+ }
146
+ /** Filter one upstream tools/list response — the bridge normalizes SSE and
147
+ * JSON upstreams to a single frame first, so both shapes flow through
148
+ * here. Returns a shallow copy with result.tools trimmed; a frame without
149
+ * a readable tools array passes through unchanged (degrade, never guess).
150
+ * Entries the filter cannot read a name from are kept as-is. */
151
+ export function filterToolsListFrame(frame, grants, candidates = []) {
152
+ const result = frame.result;
153
+ if (!result || typeof result !== "object" || Array.isArray(result))
154
+ return frame;
155
+ const tools = result.tools;
156
+ if (!Array.isArray(tools))
157
+ return frame;
158
+ const kept = tools.filter((tool) => {
159
+ const name = tool && typeof tool === "object" && !Array.isArray(tool)
160
+ ? tool.name
161
+ : undefined;
162
+ return typeof name === "string" ? connectorToolAdvertised(name, grants, candidates) : true;
163
+ });
164
+ return { ...frame, result: { ...result, tools: kept } };
165
+ }
@@ -4,15 +4,24 @@
4
4
  // to the configured Composio Session, but connection requests are converted
5
5
  // into first-class OpenMausBot chat cards. The agent never authors an auth
6
6
  // URL and credentials never pass through its transcript.
7
+ // tools/list responses are trimmed to this bot's granted tools as a
8
+ // prompt-time hint; every call is still judged harness-side by the grants
9
+ // relay.
7
10
  //
8
11
  // stdout is the MCP transport. Never log there.
9
12
  import readline from "node:readline";
10
13
  import { randomUUID } from "node:crypto";
14
+ import { CONNECTOR_ALLOWED_TOOLS_ENV, CONNECTOR_SERVICE_SLUGS_ENV, filterToolsListFrame, parseConnectorAllowedToolsEnv, parseConnectorServiceSlugsEnv, } from "./connector-advertisement.js";
11
15
  const UPSTREAM = process.env.OMB_CONNECTOR_UPSTREAM_URL ?? "";
12
16
  const HARNESS = process.env.OMB_HARNESS_URL ?? "http://127.0.0.1:8799";
13
17
  const BOT_ID = process.env.OMB_BOT_ID ?? "";
14
18
  const THREAD_ID = process.env.OMB_THREAD_ID ?? "";
15
19
  const TOKEN = process.env.OMB_CONNECTOR_TOKEN ?? process.env.OMB_COMMS_TOKEN ?? "";
20
+ // The bot's effective tool grants, or null when the harness sent none —
21
+ // legacy bots, oversized allowlists, anything unreadable. null means the
22
+ // upstream list is relayed verbatim; the harness still judges every call.
23
+ const ALLOWED_TOOLS = parseConnectorAllowedToolsEnv(process.env[CONNECTOR_ALLOWED_TOOLS_ENV]);
24
+ const SERVICE_SLUGS = parseConnectorServiceSlugsEnv(process.env[CONNECTOR_SERVICE_SLUGS_ENV]);
16
25
  const MAX_RESPONSE_BYTES = 20 * 1024 * 1024;
17
26
  const INITIALIZE_RELAY_TIMEOUT_MS = 1_000;
18
27
  const RELAY_TIMEOUT_MS = 10 * 60_000;
@@ -210,8 +219,11 @@ async function handle(message) {
210
219
  }
211
220
  try {
212
221
  const response = await relay(message);
213
- if (response && id !== undefined)
214
- send(response);
222
+ if (response && id !== undefined) {
223
+ send(method === "tools/list" && ALLOWED_TOOLS
224
+ ? filterToolsListFrame(response, ALLOWED_TOOLS, SERVICE_SLUGS)
225
+ : response);
226
+ }
215
227
  }
216
228
  catch (error) {
217
229
  if (id === undefined)
@@ -33,6 +33,22 @@ export function serviceSlugFor(tool) {
33
33
  return null;
34
34
  return tool.slice(0, underscore).toLowerCase();
35
35
  }
36
+ /** The service a Composio tool name belongs to when the caller knows the
37
+ * real service slugs. Slugs may contain underscores (bland_ai), which the
38
+ * plain first-segment split above cannot see — BLAND_AI_MAKE_CALL splits
39
+ * as "bland". When a known candidate matches the name's prefix, the
40
+ * longest candidate wins (bland_ai over bland), so an underscored service
41
+ * keeps its own tools; with no matching candidate the caller falls back
42
+ * to serviceSlugFor. */
43
+ export function serviceSlugForCandidates(tool, candidates) {
44
+ let match = null;
45
+ for (const candidate of candidates) {
46
+ if (tool.startsWith(candidate.toUpperCase() + "_") && (match === null || candidate.length > match.length)) {
47
+ match = candidate;
48
+ }
49
+ }
50
+ return match;
51
+ }
36
52
  /** Classify one relayed JSON-RPC frame. Anything that is not a tools/call,
37
53
  * or is a discovery/connection/platform meta-tool, passes through. */
38
54
  export function connectorCallFromFrame(payload) {
@@ -99,16 +115,27 @@ function multiExecuteCall(invoked, args) {
99
115
  }
100
116
  /** Judge distinct target names against a bot's grants. grants undefined
101
117
  * is the legacy all-tools bot and passes everything; an explicit record —
102
- * including the empty one — allows only what it names. */
103
- export function evaluateConnectorTools(names, grants) {
118
+ * including the empty one — allows only what it names. Service keys are
119
+ * read as own properties only: a name like CONSTRUCTOR_X maps to the
120
+ * "constructor" service, and the inherited property must never pose as
121
+ * a grant.
122
+ * candidates are the caller's connected-service slugs: the real backend
123
+ * slugs, so an underscored service (bland_ai) keeps its own tools instead
124
+ * of a plain-prefix grant (bland) capturing them. An empty list means the
125
+ * connected-service catalog was unreachable, and the plain first-segment
126
+ * split stands as the fallback — call-time enforcement degrades open, the
127
+ * same way advertisement filtering does. */
128
+ export function evaluateConnectorTools(names, grants, candidates = []) {
104
129
  if (grants === undefined) {
105
130
  return { allowed: true, legacy: true, denials: [], rule: "composio" };
106
131
  }
107
132
  const denials = [];
108
133
  let rule = "";
109
134
  for (const tool of new Set(names)) {
110
- const service = serviceSlugFor(tool);
111
- const grant = service === null ? undefined : grants[service];
135
+ const service = serviceSlugForCandidates(tool, candidates) ?? serviceSlugFor(tool);
136
+ const grant = service !== null && Object.hasOwn(grants, service)
137
+ ? grants[service]
138
+ : undefined;
112
139
  if (grant && (grant.tools === "*" || grant.tools.includes(tool))) {
113
140
  if (!rule)
114
141
  rule = "connectorTools." + service;