@opengeni/core 3.0.1 → 3.1.2

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 (257) hide show
  1. package/README.md +4 -4
  2. package/dist/access/index.d.ts +131 -7
  3. package/dist/access/service-initiator.d.ts +3 -0
  4. package/dist/application/archived-session-imports.d.ts +6 -0
  5. package/dist/application/composer-submit.d.ts +1 -1
  6. package/dist/application/connect-operation.d.ts +6 -0
  7. package/dist/application/external-workspace-members.d.ts +13 -1
  8. package/dist/application/modal-native-live-declaration-join.d.ts +37 -0
  9. package/dist/application/modal-native-original-configuration.d.ts +79 -0
  10. package/dist/application/modal-native-worker-host-transport.d.ts +297 -0
  11. package/dist/application/new-session-drafts.d.ts +29 -2
  12. package/dist/application/organization-privacy.d.ts +57 -0
  13. package/dist/application/prepared-mcp-connection.d.ts +31 -0
  14. package/dist/application/sandbox-recovery.d.ts +58 -0
  15. package/dist/application/session-commands.d.ts +3 -0
  16. package/dist/application/session-tenancy.d.ts +13 -4
  17. package/dist/billing/agent-run-admission.d.ts +21 -0
  18. package/dist/billing/limits.d.ts +27 -1
  19. package/dist/billing/model-call-admission.d.ts +34 -0
  20. package/dist/billing/model-usage-settlement.d.ts +112 -0
  21. package/dist/canonical-human-identities.js +1 -1
  22. package/dist/{chunk-VCZFFEPB.js → chunk-7T2SZ7BI.js} +16 -2
  23. package/dist/chunk-7T2SZ7BI.js.map +1 -0
  24. package/dist/{chunk-ZYCNLYGH.js → chunk-OMJFIIWK.js} +178 -12
  25. package/dist/chunk-OMJFIIWK.js.map +1 -0
  26. package/dist/{chunk-H7X52RI2.js → chunk-QI2M2B2K.js} +83 -8
  27. package/dist/chunk-QI2M2B2K.js.map +1 -0
  28. package/dist/{chunk-OF65T3PM.js → chunk-TTWTXQB2.js} +94 -6
  29. package/dist/chunk-TTWTXQB2.js.map +1 -0
  30. package/dist/codex-model-availability.d.ts +55 -0
  31. package/dist/default-session-model.d.ts +192 -0
  32. package/dist/dependencies.d.ts +41 -7
  33. package/dist/developer-setup-credential-ceiling.d.ts +10 -0
  34. package/dist/domain/agent-config-resolution.d.ts +49 -0
  35. package/dist/domain/automations.d.ts +8 -1
  36. package/dist/domain/capabilities.d.ts +112 -8
  37. package/dist/domain/connection-account-selection-error.d.ts +8 -0
  38. package/dist/domain/connector-tool-permissions.d.ts +62 -0
  39. package/dist/domain/direct-model-provider.d.ts +3 -0
  40. package/dist/domain/editable-artifacts/agent-application.d.ts +10 -1
  41. package/dist/domain/editable-artifacts/durable-export.d.ts +22 -1
  42. package/dist/domain/editable-artifacts/ports.d.ts +10 -0
  43. package/dist/domain/editable-artifacts/service.d.ts +3 -1
  44. package/dist/domain/editable-artifacts/types.d.ts +1 -1
  45. package/dist/domain/encryption-key.d.ts +12 -0
  46. package/dist/domain/insights-timing.d.ts +16 -0
  47. package/dist/domain/insights-usage.d.ts +27 -0
  48. package/dist/domain/insights.d.ts +21 -3
  49. package/dist/domain/knowledge-files.d.ts +1 -1
  50. package/dist/domain/knowledge-preparation.d.ts +10 -0
  51. package/dist/domain/knowledge-preparation.test.d.ts +1 -0
  52. package/dist/domain/knowledge-search.d.ts +15 -76
  53. package/dist/domain/mcp-account-bindings.d.ts +17 -0
  54. package/dist/domain/mcp-account-routes.d.ts +21 -0
  55. package/dist/domain/native-mcp-connection-admission.d.ts +3 -0
  56. package/dist/domain/organization-integration-catalog.d.ts +15 -0
  57. package/dist/domain/personal-connection-delegations.d.ts +73 -12
  58. package/dist/domain/pr-review.d.ts +121 -6
  59. package/dist/domain/realtime-voice-billing.d.ts +103 -0
  60. package/dist/domain/resources.d.ts +17 -1
  61. package/dist/domain/scheduled-task-access.d.ts +265 -0
  62. package/dist/domain/scheduled-task-account-audit.d.ts +27 -0
  63. package/dist/domain/scheduled-task-audience.d.ts +70 -0
  64. package/dist/domain/scheduled-tasks.d.ts +61 -18
  65. package/dist/domain/session-connection-accounts.d.ts +53 -0
  66. package/dist/domain/session-file-access.d.ts +4 -0
  67. package/dist/domain/session-native-connection-replacement.d.ts +14 -0
  68. package/dist/domain/session-start-timing.d.ts +7 -0
  69. package/dist/domain/session-tool-policy.d.ts +102 -4
  70. package/dist/domain/sessions.d.ts +179 -15
  71. package/dist/domain/site-audience.d.ts +52 -0
  72. package/dist/domain/skills.d.ts +29 -1
  73. package/dist/domain/slack-bot.d.ts +46 -1
  74. package/dist/domain/slack-publication-secret-safety.d.ts +1 -1
  75. package/dist/domain/vaults/index.d.ts +7 -0
  76. package/dist/domain/vaults/mapping.d.ts +25 -0
  77. package/dist/domain/vaults/provider-registry.d.ts +72 -0
  78. package/dist/domain/vaults/provider-sync.d.ts +176 -0
  79. package/dist/domain/vaults/provider-writes.d.ts +57 -0
  80. package/dist/domain/vaults/secret-resolver.d.ts +34 -0
  81. package/dist/domain/vaults/types.d.ts +24 -0
  82. package/dist/domain/vaults/vault-service.d.ts +35 -0
  83. package/dist/domain/voice-input-billing.d.ts +77 -0
  84. package/dist/domain/web-search-billing.d.ts +51 -0
  85. package/dist/domain/workspace-credits.d.ts +7 -0
  86. package/dist/editable-artifact-live/application.d.ts +2 -1
  87. package/dist/editable-artifact-live/server.d.ts +5 -1
  88. package/dist/editable-artifact-live/ticket.d.ts +2 -1
  89. package/dist/editable-artifact-live/types.d.ts +9 -0
  90. package/dist/editable-artifact-live.js +2 -2
  91. package/dist/editable-artifacts.js +10 -2
  92. package/dist/goal-admission.d.ts +627 -0
  93. package/dist/host-session-usage-limit.d.ts +15 -0
  94. package/dist/index.d.ts +43 -8
  95. package/dist/index.js +22682 -11743
  96. package/dist/index.js.map +1 -1
  97. package/dist/managed-session.d.ts +110 -7
  98. package/dist/model-call.d.ts +58 -0
  99. package/dist/model-catalog.d.ts +29 -3
  100. package/dist/model-provider-credentials.d.ts +43 -0
  101. package/dist/product-usage-metrics.d.ts +30 -0
  102. package/dist/rigs/index.d.ts +1 -13
  103. package/dist/sandbox/fleet.d.ts +58 -4
  104. package/dist/sandbox/routing.d.ts +42 -2
  105. package/dist/sandbox/runtime-settings.d.ts +27 -17
  106. package/dist/session-authorization.d.ts +10 -0
  107. package/dist/session-usage-limit-metrics.d.ts +60 -0
  108. package/dist/session-usage-limits.d.ts +89 -0
  109. package/dist/site-agent-clamp.d.ts +71 -0
  110. package/dist/site-session-origin.d.ts +39 -2
  111. package/dist/transcription.d.ts +120 -3
  112. package/dist/turn-surface.d.ts +20 -0
  113. package/dist/user-presence.d.ts +32 -0
  114. package/dist/workflow-wake-contract.d.ts +13 -1
  115. package/package.json +15 -22
  116. package/src/access/index.ts +961 -38
  117. package/src/access/service-initiator.ts +50 -0
  118. package/src/application/api-integration-servers.ts +9 -0
  119. package/src/application/archived-session-imports.ts +161 -0
  120. package/src/application/composer-submit.ts +3 -2
  121. package/src/application/connect-authority.ts +3 -3
  122. package/src/application/connect-operation.ts +37 -3
  123. package/src/application/external-continuation.ts +20 -3
  124. package/src/application/external-link-work-admission.ts +3 -0
  125. package/src/application/external-workspace-members.ts +51 -3
  126. package/src/application/modal-native-live-declaration-join.ts +196 -0
  127. package/src/application/modal-native-original-configuration.ts +261 -0
  128. package/src/application/modal-native-worker-host-transport.ts +263 -0
  129. package/src/application/new-session-drafts.ts +172 -55
  130. package/src/application/organization-privacy.ts +161 -0
  131. package/src/application/prepared-mcp-connection.ts +133 -0
  132. package/src/application/sandbox-recovery.ts +78 -0
  133. package/src/application/session-commands.ts +73 -1
  134. package/src/application/session-mcp-credential-rotation.ts +61 -18
  135. package/src/application/session-tenancy.ts +262 -44
  136. package/src/application/user-resource-grants.ts +1 -29
  137. package/src/billing/agent-run-admission.ts +117 -0
  138. package/src/billing/limits.ts +138 -14
  139. package/src/billing/model-call-admission.ts +118 -0
  140. package/src/billing/model-usage-settlement.ts +472 -0
  141. package/src/codex-model-availability.ts +264 -0
  142. package/src/default-session-model.ts +692 -0
  143. package/src/dependencies.ts +48 -6
  144. package/src/developer-setup-credential-ceiling.ts +60 -0
  145. package/src/domain/agent-config-resolution.ts +148 -0
  146. package/src/domain/automations.ts +76 -2
  147. package/src/domain/capabilities.ts +830 -181
  148. package/src/domain/connection-account-selection-error.ts +19 -0
  149. package/src/domain/connector-tool-permissions.ts +572 -0
  150. package/src/domain/direct-model-provider.ts +62 -0
  151. package/src/domain/editable-artifacts/agent-application.ts +44 -6
  152. package/src/domain/editable-artifacts/durable-export.ts +100 -5
  153. package/src/domain/editable-artifacts/in-memory.ts +6 -0
  154. package/src/domain/editable-artifacts/ports.ts +11 -0
  155. package/src/domain/editable-artifacts/service.ts +20 -0
  156. package/src/domain/editable-artifacts/types.ts +1 -1
  157. package/src/domain/encryption-key.ts +26 -0
  158. package/src/domain/file-owner.ts +16 -1
  159. package/src/domain/github-action-policies.ts +1 -1
  160. package/src/domain/github-skill-source.ts +3 -1
  161. package/src/domain/insights-timing.ts +95 -0
  162. package/src/domain/insights-usage.ts +67 -0
  163. package/src/domain/insights.ts +167 -43
  164. package/src/domain/knowledge-files.ts +10 -3
  165. package/src/domain/knowledge-messages.ts +1 -0
  166. package/src/domain/knowledge-preparation.test.ts +276 -0
  167. package/src/domain/knowledge-preparation.ts +147 -0
  168. package/src/domain/knowledge-search.test.ts +617 -5
  169. package/src/domain/knowledge-search.ts +240 -23
  170. package/src/domain/mcp-account-bindings.ts +169 -0
  171. package/src/domain/mcp-account-routes.ts +152 -0
  172. package/src/domain/native-mcp-connection-admission.ts +14 -0
  173. package/src/domain/organization-integration-catalog.ts +88 -0
  174. package/src/domain/personal-connection-delegations.ts +399 -257
  175. package/src/domain/pr-review.ts +24 -8
  176. package/src/domain/realtime-voice-billing.ts +425 -0
  177. package/src/domain/resources.ts +50 -4
  178. package/src/domain/scheduled-task-access.ts +1089 -0
  179. package/src/domain/scheduled-task-account-audit.ts +90 -0
  180. package/src/domain/scheduled-task-audience.ts +249 -0
  181. package/src/domain/scheduled-tasks.ts +1313 -374
  182. package/src/domain/session-connection-accounts.ts +239 -0
  183. package/src/domain/session-file-access.ts +47 -0
  184. package/src/domain/session-native-connection-replacement.ts +64 -0
  185. package/src/domain/session-start-timing.ts +60 -0
  186. package/src/domain/session-tool-policy.ts +489 -14
  187. package/src/domain/sessions.ts +2254 -717
  188. package/src/domain/site-audience.ts +130 -0
  189. package/src/domain/skills.ts +9 -0
  190. package/src/domain/slack-bot.ts +141 -4
  191. package/src/domain/slack-publication-secret-safety.ts +1 -1
  192. package/src/domain/vaults/index.ts +19 -0
  193. package/src/domain/vaults/mapping.ts +190 -0
  194. package/src/domain/vaults/provider-registry.ts +201 -0
  195. package/src/domain/vaults/provider-sync.ts +531 -0
  196. package/src/domain/vaults/provider-writes.ts +288 -0
  197. package/src/domain/vaults/secret-resolver.ts +464 -0
  198. package/src/domain/vaults/types.ts +26 -0
  199. package/src/domain/vaults/vault-service.ts +1316 -0
  200. package/src/domain/voice-input-billing.ts +330 -0
  201. package/src/domain/web-search-billing.ts +169 -0
  202. package/src/domain/workspace-credits.ts +13 -0
  203. package/src/editable-artifact-live/application.ts +8 -1
  204. package/src/editable-artifact-live/server.ts +154 -6
  205. package/src/editable-artifact-live/ticket.ts +86 -1
  206. package/src/editable-artifact-live/types.ts +10 -0
  207. package/src/goal-admission.ts +220 -0
  208. package/src/host-session-usage-limit.ts +25 -0
  209. package/src/index.ts +75 -11
  210. package/src/managed-session.ts +137 -6
  211. package/src/model-call.ts +79 -0
  212. package/src/model-catalog.ts +345 -91
  213. package/src/model-provider-credentials.ts +394 -0
  214. package/src/product-usage-metrics.ts +101 -0
  215. package/src/rigs/index.ts +23 -111
  216. package/src/sandbox/fleet.ts +253 -28
  217. package/src/sandbox/routing.ts +204 -43
  218. package/src/sandbox/runtime-settings.ts +159 -85
  219. package/src/session-authorization.ts +172 -23
  220. package/src/session-usage-limit-metrics.ts +95 -0
  221. package/src/session-usage-limits.ts +415 -0
  222. package/src/site-agent-clamp.ts +226 -0
  223. package/src/site-session-origin.ts +84 -5
  224. package/src/transcription.ts +123 -4
  225. package/src/turn-surface.ts +47 -0
  226. package/src/user-presence.ts +134 -0
  227. package/src/workflow-wake-contract.ts +14 -1
  228. package/dist/application/host-mcp-owner.d.ts +0 -6
  229. package/dist/application/host-mcp-resolvers.d.ts +0 -13
  230. package/dist/chunk-5WZYFL45.js +0 -266
  231. package/dist/chunk-5WZYFL45.js.map +0 -1
  232. package/dist/chunk-H7X52RI2.js.map +0 -1
  233. package/dist/chunk-OF65T3PM.js.map +0 -1
  234. package/dist/chunk-VCZFFEPB.js.map +0 -1
  235. package/dist/chunk-ZYCNLYGH.js.map +0 -1
  236. package/dist/domain/connection-authority.d.ts +0 -97
  237. package/dist/domain/connection-authority.js +0 -169
  238. package/dist/domain/connection-authority.js.map +0 -1
  239. package/dist/domain/environments.d.ts +0 -33
  240. package/dist/domain/host-mcp-authority-source-admission.d.ts +0 -10
  241. package/dist/domain/host-mcp-task-admission.d.ts +0 -18
  242. package/dist/domain/packs.d.ts +0 -59
  243. package/dist/domain/product-integration-pack.d.ts +0 -44
  244. package/dist/domain/product-integration-skill.gen.d.ts +0 -5
  245. package/dist/remote-mcp-credentials.d.ts +0 -23
  246. package/dist/remote-mcp-credentials.js +0 -11
  247. package/dist/remote-mcp-credentials.js.map +0 -1
  248. package/src/application/host-mcp-owner.ts +0 -41
  249. package/src/application/host-mcp-resolvers.ts +0 -84
  250. package/src/domain/connection-authority.ts +0 -329
  251. package/src/domain/environments.ts +0 -120
  252. package/src/domain/host-mcp-authority-source-admission.ts +0 -25
  253. package/src/domain/host-mcp-task-admission.ts +0 -121
  254. package/src/domain/packs.ts +0 -699
  255. package/src/domain/product-integration-pack.ts +0 -42
  256. package/src/domain/product-integration-skill.gen.ts +0 -52
  257. package/src/remote-mcp-credentials.ts +0 -361
@@ -1,12 +1,21 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
3
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
- import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { withLockedCapabilityInstallation } from "@opengeni/db/capability-reconciliation";
6
+ import {
7
+ environmentsEncryptionKeyBytes,
8
+ findIntegrationsOauthClient,
9
+ parseIntegrationsOauthClientsJson,
10
+ type Settings,
11
+ } from "@opengeni/config";
12
+ import { pinnedFetch } from "@opengeni/network";
5
13
  import {
6
14
  CapabilityCatalogItem,
7
15
  capabilityCatalogItemIsTrustedForExposure,
8
16
  FIKEN_PROVIDER_DOMAIN,
9
17
  FIRST_PARTY_MCP_TOOL_NAMES,
18
+ OPENGENI_BROWSER_EXTENSION_URL,
10
19
  type AccessGrant,
11
20
  type CapabilityAction,
12
21
  type CapabilityCatalogResponse,
@@ -16,7 +25,7 @@ import {
16
25
  type EnableCapabilityRequest,
17
26
  type McpServerConnectionRef,
18
27
  type McpPersonalConnectionDelegation,
19
- OPENGENI_PERSONAL_SLACK_MCP_URL,
28
+ assertOrganizationIntegrationAllowed,
20
29
  type SocialConnection,
21
30
  } from "@opengeni/contracts";
22
31
  import {
@@ -34,8 +43,12 @@ import {
34
43
  getCapabilityCatalogItem,
35
44
  getCapabilityInstallation,
36
45
  getConnectionMetadata,
37
- getCodexAppsCredentialAuthorizationForRun,
38
- getWorkspaceGrant,
46
+ readCodexCutoverDisposition,
47
+ resolveSubscriptionCoreCodexAppsDesignation,
48
+ rlsContextForWorkspace,
49
+ subscriptionCoreCodexAppsRequestAuth,
50
+ type SubscriptionCoreCodexAppsRequestAuth,
51
+ type CodexCutoverDisposition,
39
52
  getStoredCapabilityHeaderCiphertext,
40
53
  listCapabilityCatalogItems,
41
54
  listCapabilityInstallations,
@@ -43,7 +56,6 @@ import {
43
56
  listEnabledMcpCapabilityServers,
44
57
  listInstalledApiIntegrations,
45
58
  listInstalledSkills,
46
- listPackInstallations,
47
59
  listSocialConnections,
48
60
  mcpServerIdForCapability,
49
61
  upsertCapabilityCatalogItem,
@@ -53,11 +65,15 @@ import {
53
65
  type InstalledSkillSummary,
54
66
  } from "@opengeni/db";
55
67
  import { HTTPException } from "hono/http-exception";
56
- import { hasPermission } from "../access";
68
+ import {
69
+ withOrganizationIntegrationAcquisition,
70
+ withOrganizationIntegrationPolicyFence,
71
+ } from "@opengeni/db/organization-integration-policy";
72
+
57
73
  import { isFikenConnection, preferredFikenConnection } from "./fiken";
74
+ import { isOnePasswordConnection, onePasswordConnectionMetadata } from "./vaults/provider-registry";
58
75
  import { listSkillLibraryEntries, type SkillLibraryEntry } from "@opengeni/runtime/skill-library";
59
- import { listCapabilityPacks, listWorkspaceCapabilityPacks } from "./packs";
60
- import { assertHostMcpAuthoritySourceAdmissionEnabled } from "./host-mcp-authority-source-admission";
76
+ import { assertNativeMcpConnectionRef } from "./native-mcp-connection-admission";
61
77
 
62
78
  const officialMcpRegistryUrl = "https://registry.modelcontextprotocol.io";
63
79
  const firstPartyMcpServerIds = new Set(["opengeni", "files", "docs"]);
@@ -66,7 +82,6 @@ const mcpRegistryMaxPages = 3;
66
82
  const mcpCapabilityProbeTimeoutMs = 15000;
67
83
  const maxMcpCredentialHeaders = 16;
68
84
  const maxMcpCredentialHeaderValueLength = 4096;
69
- const officialGmailMcpUrl = "https://gmailmcp.googleapis.com/mcp/v1";
70
85
  // RFC 9110 field-name token characters.
71
86
  const mcpCredentialHeaderName = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
72
87
 
@@ -75,30 +90,36 @@ export async function buildCapabilityCatalog(input: {
75
90
  workspaceId: string;
76
91
  settings: Settings;
77
92
  subjectId?: string | null;
93
+ /** The workspace's organization, when the caller knows it (skips a lookup). */
94
+ accountId?: string;
78
95
  }): Promise<CapabilityCatalogResponse> {
79
96
  const [
80
97
  persistedItems,
81
98
  capabilityInstallations,
82
- packInstallations,
83
- workspacePacks,
84
99
  socialConnections,
85
100
  workspaceConnections,
86
101
  curatedLibrarySkills,
87
102
  installedSkills,
88
- codexAppsCredentialId,
103
+ codexAppsDesignation,
104
+ runnableMcpServers,
89
105
  ] = await Promise.all([
90
106
  listCapabilityCatalogItems(input.db, input.workspaceId),
91
107
  listCapabilityInstallations(input.db, input.workspaceId),
92
- listPackInstallations(input.db, input.workspaceId),
93
- listWorkspaceCapabilityPacks(input.db, input.workspaceId),
94
108
  listSocialConnections(input.db, input.workspaceId, 500, input.subjectId),
95
109
  listConnectionsMetadata(input.db, input.workspaceId, null),
96
110
  discoverCuratedSkillLibraryItems(),
97
111
  listInstalledSkills(input.db, input.workspaceId),
98
112
  input.settings.codexConnectedAppsEnabled
99
- ? resolveCodexAppsCredentialIdForRun(input.db, input.workspaceId)
113
+ ? resolveCodexAppsDesignationForRun(
114
+ input.db,
115
+ input.workspaceId,
116
+ input.accountId ? { accountId: input.accountId } : {},
117
+ )
100
118
  : Promise.resolve(null),
119
+ listEnabledMcpCapabilityServers(input.db, input.workspaceId),
101
120
  ]);
121
+ const runnableCapabilityIds = new Set(runnableMcpServers.map((server) => server.capabilityId));
122
+ const operatorOAuthClientConfigured = operatorOAuthClientResolver(input.settings);
102
123
  const catalogInstallations = capabilityInstallations.filter(
103
124
  (installation) => installation.kind === "mcp",
104
125
  );
@@ -110,19 +131,11 @@ export async function buildCapabilityCatalog(input: {
110
131
  .filter((skill) => skill.owners.some((owner) => owner.kind === "direct"))
111
132
  .map((skill) => [skill.capabilityId, skill]),
112
133
  );
113
- const activePackIds = new Set(
114
- packInstallations
115
- .filter((installation) => installation.status === "active")
116
- .map((installation) => installation.packId),
117
- );
118
- const builtInPackIds = new Set(listCapabilityPacks().map((pack) => pack.id));
119
134
  const builtIns = [
120
- ...workspacePacks.map((pack) =>
121
- packCatalogItem(pack, builtInPackIds.has(pack.id) ? "built_in" : "manual"),
122
- ),
123
135
  ...configuredMcpCatalogItems(input.settings),
124
136
  ...providerIntegrationCatalogItems(socialConnections),
125
137
  fikenCatalogItem(workspaceConnections.filter(isFikenConnection)),
138
+ onePasswordCatalogItem(workspaceConnections.filter(isOnePasswordConnection)),
126
139
  ...curatedLibrarySkills.filter((item) => installedSkillById.has(item.id)),
127
140
  ...installedSkills
128
141
  .filter(
@@ -132,7 +145,7 @@ export async function buildCapabilityCatalog(input: {
132
145
  .map(installedSkillCatalogItem),
133
146
  ];
134
147
  const codexApps = input.settings.codexConnectedAppsEnabled
135
- ? codexAppsCatalogItem(codexAppsCredentialId !== null)
148
+ ? codexAppsCatalogItem(codexAppsDesignation !== null)
136
149
  : null;
137
150
  const items = dedupeCatalogItems([
138
151
  ...builtIns,
@@ -151,8 +164,28 @@ export async function buildCapabilityCatalog(input: {
151
164
  const projected =
152
165
  item.kind === "skill"
153
166
  ? applyInstalledSkillEnablement(item, installedSkillById.get(item.id))
154
- : applyCapabilityEnablement(item, capabilityInstallationById.get(item.id), activePackIds);
155
- return applyCapabilityLifecycle(projected);
167
+ : applyCapabilityEnablement(item, capabilityInstallationById.get(item.id));
168
+ // An installed connector can become unrunnable after a lifecycle or
169
+ // ownership change. Use the execution registry rather than presenting
170
+ // its stale catalog definition as a selectable tool.
171
+ const runtimeProjected =
172
+ projected.kind === "mcp" &&
173
+ projected.enabled &&
174
+ projected.source !== "configured" &&
175
+ projected.source !== "built_in" &&
176
+ !runnableCapabilityIds.has(projected.id)
177
+ ? {
178
+ ...projected,
179
+ runtime: {
180
+ ...projected.runtime,
181
+ available: false,
182
+ notes: "This installed connection is unavailable. Review its connection settings.",
183
+ },
184
+ }
185
+ : projected;
186
+ return applyCapabilityLifecycle(
187
+ applyOperatorOAuthClientRequirement(runtimeProjected, operatorOAuthClientConfigured),
188
+ );
156
189
  })
157
190
  .sort(compareCatalogItems);
158
191
  return {
@@ -161,6 +194,40 @@ export async function buildCapabilityCatalog(input: {
161
194
  };
162
195
  }
163
196
 
197
+ /**
198
+ * `metadata.oauthClientRequirement` is stamped by the catalog import from
199
+ * `data/catalog/oauth-client-requirements.json` for providers that refuse
200
+ * OAuth self-registration. Whether the row can connect here is a deployment
201
+ * fact, so the catalog projects it rather than every surface guessing. The
202
+ * matcher is the one the OAuth start uses to find an operator client.
203
+ */
204
+ function operatorOAuthClientResolver(settings: Settings): (issuer: string) => boolean {
205
+ let configured: ReturnType<typeof parseIntegrationsOauthClientsJson> = {};
206
+ try {
207
+ configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
208
+ } catch {
209
+ // Startup validates this setting; a malformed value can configure nothing.
210
+ }
211
+ return (issuer) => findIntegrationsOauthClient(configured, [issuer]) !== null;
212
+ }
213
+
214
+ export function applyOperatorOAuthClientRequirement(
215
+ item: CapabilityCatalogItem,
216
+ configured: (issuer: string) => boolean,
217
+ ): CapabilityCatalogItem {
218
+ if (item.kind !== "mcp") return item;
219
+ const requirement = item.metadata.oauthClientRequirement;
220
+ const issuer =
221
+ requirement && typeof requirement === "object" && !Array.isArray(requirement)
222
+ ? (requirement as Record<string, unknown>).issuer
223
+ : undefined;
224
+ if (typeof issuer !== "string" || !issuer) return item;
225
+ return {
226
+ ...item,
227
+ runtime: { ...item.runtime, operatorOAuthClient: { configured: configured(issuer) } },
228
+ };
229
+ }
230
+
164
231
  export async function createCatalogItem(input: {
165
232
  db: Database;
166
233
  accountId: string;
@@ -168,11 +235,7 @@ export async function createCatalogItem(input: {
168
235
  payload: CreateCapabilityCatalogItemRequest;
169
236
  }): Promise<CapabilityCatalogItem> {
170
237
  const id = input.payload.id?.trim() || generatedCapabilityId(input.payload);
171
- if (id.startsWith("pack:")) {
172
- throw new HTTPException(422, {
173
- message: "packs are managed by OpenGeni and cannot be manually created",
174
- });
175
- }
238
+
176
239
  if (id.startsWith("skill:")) {
177
240
  throw new HTTPException(422, {
178
241
  message: "Skills are installed through the Skill library or source import flow",
@@ -240,6 +303,14 @@ type EnableCapabilityInput = {
240
303
  capabilityId: string;
241
304
  payload: EnableCapabilityRequest;
242
305
  probeMcpServer?: McpCapabilityProbe;
306
+ /** Server-owned receipt from a just-verified native Connect credential.
307
+ * Never projected into EnableCapabilityRequest or accepted from a client. */
308
+ verifiedConnection?: {
309
+ id: string;
310
+ version: number;
311
+ endpointUrl: string;
312
+ connectivity: Record<string, unknown>;
313
+ };
243
314
  };
244
315
 
245
316
  export async function enableCapability(
@@ -252,6 +323,7 @@ export async function enableCapability(
252
323
  /** Probe outside a durable Connect commit; persist the exact prepared settings
253
324
  * inside the caller's authorized receipt transaction. Native enable uses this too. */
254
325
  export async function prepareCapabilityEnable(input: EnableCapabilityInput) {
326
+ input = { ...input, payload: structuredClone(input.payload) };
255
327
  const item = await requireCatalogItem(
256
328
  input.db,
257
329
  input.workspaceId,
@@ -277,11 +349,7 @@ export async function prepareCapabilityEnable(input: EnableCapabilityInput) {
277
349
  message: "Install Plugins through the Plugin Package flow",
278
350
  });
279
351
  }
280
- if (item.kind === "pack") {
281
- throw new HTTPException(409, {
282
- message: "Install Packs through the Pack installation preview flow",
283
- });
284
- }
352
+
285
353
  if (item.kind === "mcp" && !item.runtime.available) {
286
354
  throw new HTTPException(422, {
287
355
  message: "MCP capabilities need a remote streamable HTTP endpoint before they can be enabled",
@@ -297,16 +365,69 @@ export async function prepareCapabilityEnable(input: EnableCapabilityInput) {
297
365
  delete installationConfig.headerNames;
298
366
  delete installationConfig.connectionRef;
299
367
  if (item.kind === "mcp") {
300
- const headers = await resolveMcpCredentialHeaders(input, item);
368
+ if (
369
+ item.endpointUrl?.replace(/\/+$/u, "") === "https://gmailmcp.googleapis.com/mcp/v1" &&
370
+ (input.payload.connectionRef?.subjectScope !== "subject" ||
371
+ Object.keys(input.payload.headers ?? {}).length > 0)
372
+ ) {
373
+ throw new HTTPException(422, {
374
+ message: "Gmail requires a personal-owned connection reference",
375
+ });
376
+ }
377
+ // This generic, potentially workspace-authored catalog is not proof of a
378
+ // curated provider identity. Dedicated Connect adapters classify separately.
379
+ const unchanged = await withOrganizationIntegrationPolicyFence(
380
+ input.db,
381
+ input,
382
+ async (tx, policy) => {
383
+ const existing = await unchangedMcpInstallation(
384
+ { ...input, db: tx },
385
+ item,
386
+ installationConfig,
387
+ );
388
+ if (existing) return existing;
389
+ assertOrganizationIntegrationAllowed(policy, "custom:mcp");
390
+ return null;
391
+ },
392
+ );
393
+ if (unchanged) {
394
+ return {
395
+ commit: (db: Database) =>
396
+ withOrganizationIntegrationPolicyFence(db, input, async (tx) => {
397
+ const current = await unchangedMcpInstallation(
398
+ { ...input, db: tx },
399
+ item,
400
+ installationConfig,
401
+ );
402
+ if (!current || !isDeepStrictEqual(current, unchanged)) {
403
+ throw new HTTPException(409, {
404
+ message: "Capability installation changed; reload before reconciling",
405
+ });
406
+ }
407
+ return current;
408
+ }),
409
+ };
410
+ }
411
+ const headers = input.verifiedConnection
412
+ ? null
413
+ : await resolveMcpCredentialHeaders(input, item);
301
414
  const connectionRef = input.payload.connectionRef
302
415
  ? await validateMcpCapabilityConnectionRef(input, item, input.payload.connectionRef)
303
416
  : null;
304
417
  assertRequiredMcpCredentialHeaders(item, headers, connectionRef);
418
+ if (input.verifiedConnection) await validateVerifiedMcpConnection(input, item);
305
419
  installationMetadata = {
306
420
  ...installationMetadata,
307
- ...(connectionRef && !headers
308
- ? authDeferredMcpConnectivity()
309
- : await validateMcpCapabilityConnection(item, input.probeMcpServer, headers ?? undefined)),
421
+ ...(input.verifiedConnection
422
+ ? input.verifiedConnection.connectivity
423
+ : connectionRef && !headers
424
+ ? authDeferredMcpConnectivity()
425
+ : await validateMcpCapabilityConnection(
426
+ item,
427
+ input.probeMcpServer,
428
+ headers ?? undefined,
429
+ input.settings,
430
+ )),
310
431
  };
311
432
  if (connectionRef) {
312
433
  installationConfig.connectionRef = connectionRef;
@@ -325,8 +446,92 @@ export async function prepareCapabilityEnable(input: EnableCapabilityInput) {
325
446
  kind: item.kind,
326
447
  config: installationConfig,
327
448
  metadata: installationMetadata,
449
+ ...(input.payload.onlyIfUninstalled ? { onlyIfUninstalled: true } : {}),
450
+ };
451
+ return {
452
+ commit: (db: Database) =>
453
+ item.kind === "mcp"
454
+ ? withOrganizationIntegrationAcquisition(db, installation, ["custom:mcp"], async (tx) => {
455
+ if (input.verifiedConnection)
456
+ await validateVerifiedMcpConnection({ ...input, db: tx }, item);
457
+ return enableCapabilityInstallation(tx, installation);
458
+ })
459
+ : enableCapabilityInstallation(db, installation),
328
460
  };
329
- return { commit: (db: Database) => enableCapabilityInstallation(db, installation) };
461
+ }
462
+
463
+ async function validateVerifiedMcpConnection(
464
+ input: EnableCapabilityInput,
465
+ item: CapabilityCatalogItem,
466
+ ) {
467
+ const proof = input.verifiedConnection;
468
+ const ref = input.payload.connectionRef;
469
+ if (
470
+ !proof ||
471
+ !ref ||
472
+ item.endpointUrl !== proof.endpointUrl ||
473
+ ref.resource !== proof.endpointUrl
474
+ )
475
+ throw new HTTPException(409, { message: "MCP verification no longer matches the connection" });
476
+ const connection = await getConnectionMetadata(
477
+ input.db,
478
+ input.workspaceId,
479
+ proof.id,
480
+ input.grant.subjectId,
481
+ );
482
+ if (
483
+ !connection ||
484
+ connection.version !== proof.version ||
485
+ connection.status !== "active" ||
486
+ connection.providerDomain !== ref.providerDomain ||
487
+ connection.kind !== ref.kind ||
488
+ connection.subjectId !== (ref.subjectScope === "subject" ? input.grant.subjectId : null) ||
489
+ connection.metadata.mcpUrl !== proof.endpointUrl
490
+ )
491
+ throw new HTTPException(409, { message: "MCP connection changed after verification" });
492
+ }
493
+
494
+ /** A no-effect reconciliation is not a new acquisition. Validate ordinary
495
+ * connection visibility before locking the installation, and compare actual
496
+ * stored credentials rather than trusting caller-supplied identity hints. */
497
+ async function unchangedMcpInstallation(
498
+ input: EnableCapabilityInput,
499
+ item: CapabilityCatalogItem,
500
+ requestedConfig: Record<string, unknown>,
501
+ ): Promise<CapabilityInstallation | null> {
502
+ const connectionRef = input.payload.connectionRef
503
+ ? await validateMcpCapabilityConnectionRef(input, item, input.payload.connectionRef)
504
+ : null;
505
+ const existing = await withLockedCapabilityInstallation(
506
+ input.db,
507
+ input.workspaceId,
508
+ item.id,
509
+ (tx) => getCapabilityInstallation(tx, input.workspaceId, item.id),
510
+ );
511
+ if (!existing || existing.kind !== "mcp" || existing.status !== "active") return null;
512
+ const {
513
+ headerNames: _names,
514
+ headersEncrypted: _encrypted,
515
+ headers: _headers,
516
+ ...storedConfig
517
+ } = existing.config;
518
+ const config = { ...requestedConfig, ...(connectionRef ? { connectionRef } : {}) };
519
+ const { mcpConnectivity: _storedConnectivity, ...storedMetadata } = existing.metadata;
520
+ const { mcpConnectivity: _requestedConnectivity, ...requestedMetadata } = input.payload.metadata;
521
+ if (
522
+ !isDeepStrictEqual(config, storedConfig) ||
523
+ !isDeepStrictEqual(requestedMetadata, storedMetadata)
524
+ )
525
+ return null;
526
+ const provided = normalizedMcpCredentialHeaders(input.payload.headers);
527
+ if (provided) {
528
+ const stored = await resolveMcpCredentialHeaders(
529
+ { ...input, payload: { ...input.payload, headers: {} } },
530
+ item,
531
+ );
532
+ if (!isDeepStrictEqual(provided, stored)) return null;
533
+ }
534
+ return existing;
330
535
  }
331
536
 
332
537
  /**
@@ -425,24 +630,11 @@ async function validateMcpCapabilityConnectionRef(
425
630
  ref: McpServerConnectionRef,
426
631
  ): Promise<McpServerConnectionRef> {
427
632
  const subjectScope = ref.subjectScope ?? "workspace";
428
- const endpointUrl = item.endpointUrl?.replace(/\/+$/, "");
429
- // Gmail is a consumer mailbox; Slack's hosted MCP issues user tokens only and
430
- // shared Slack authority belongs to the OpenGeni workspace bot. Neither may
431
- // become a workspace-owned connection reference.
432
- const personalOnly =
433
- item.metadata.connectionOwnership === "personal_only" ||
434
- endpointUrl === officialGmailMcpUrl ||
435
- endpointUrl === OPENGENI_PERSONAL_SLACK_MCP_URL;
436
- if (personalOnly && subjectScope !== "subject") {
437
- throw new HTTPException(422, {
438
- message:
439
- "this capability requires a personal connection; each workspace member must connect their own account",
440
- });
441
- }
442
633
  const normalized: McpServerConnectionRef = {
443
634
  providerDomain: ref.providerDomain.trim(),
444
635
  subjectScope,
445
636
  ...(ref.connectionId ? { connectionId: ref.connectionId } : {}),
637
+ ...(ref.accountSelection ? { accountSelection: ref.accountSelection } : {}),
446
638
  ...(ref.authoritySource === "host" ? { authoritySource: "host" as const } : {}),
447
639
  ...(ref.provider ? { provider: ref.provider.trim() } : {}),
448
640
  ...(ref.kind ? { kind: ref.kind } : {}),
@@ -468,7 +660,7 @@ async function validateMcpCapabilityConnectionRef(
468
660
  });
469
661
  }
470
662
  if (normalized.authoritySource === "host") {
471
- assertHostMcpAuthoritySourceAdmissionEnabled(input.settings, normalized);
663
+ assertNativeMcpConnectionRef(normalized);
472
664
  return normalized;
473
665
  }
474
666
 
@@ -480,7 +672,11 @@ async function validateMcpCapabilityConnectionRef(
480
672
  input.grant.subjectId,
481
673
  )
482
674
  : null;
483
- if (!connection && subjectScope === "subject" && !normalized.connectionId) {
675
+ if (
676
+ !connection &&
677
+ !normalized.connectionId &&
678
+ (subjectScope === "subject" || normalized.accountSelection === "all_eligible")
679
+ ) {
484
680
  const visible = await listConnectionsMetadata(
485
681
  input.db,
486
682
  input.workspaceId,
@@ -489,7 +685,7 @@ async function validateMcpCapabilityConnectionRef(
489
685
  connection =
490
686
  visible.find(
491
687
  (candidate) =>
492
- candidate.subjectId === input.grant.subjectId &&
688
+ candidate.subjectId === (subjectScope === "subject" ? input.grant.subjectId : null) &&
493
689
  candidate.providerDomain === normalized.providerDomain &&
494
690
  (!normalized.kind || candidate.kind === normalized.kind) &&
495
691
  candidate.status === "active",
@@ -589,6 +785,7 @@ export type McpCapabilityProbeInput = {
589
785
  url: string;
590
786
  timeoutMs: number;
591
787
  headers?: Record<string, string>;
788
+ settings?: Settings;
592
789
  };
593
790
 
594
791
  export type McpCapabilityProbeResult = {
@@ -603,6 +800,7 @@ export async function validateMcpCapabilityConnection(
603
800
  item: CapabilityCatalogItem,
604
801
  probe: McpCapabilityProbe = probeStreamableHttpMcpServer,
605
802
  headers?: Record<string, string>,
803
+ settings?: Settings,
606
804
  ): Promise<Record<string, unknown>> {
607
805
  if (item.kind !== "mcp") {
608
806
  return {};
@@ -619,6 +817,7 @@ export async function validateMcpCapabilityConnection(
619
817
  url: item.endpointUrl,
620
818
  timeoutMs: mcpCapabilityProbeTimeoutMs,
621
819
  ...(headers ? { headers } : {}),
820
+ ...(settings ? { settings } : {}),
622
821
  });
623
822
  return {
624
823
  mcpConnectivity: {
@@ -637,6 +836,8 @@ export async function validateMcpCapabilityConnection(
637
836
  async function probeStreamableHttpMcpServer(
638
837
  input: McpCapabilityProbeInput,
639
838
  ): Promise<McpCapabilityProbeResult> {
839
+ const settings = input.settings;
840
+ if (!settings) throw new Error("Network policy is unavailable for MCP validation");
640
841
  const controller = new AbortController();
641
842
  const timeout = setTimeout(() => controller.abort(), input.timeoutMs);
642
843
  const client = new Client(
@@ -649,6 +850,11 @@ async function probeStreamableHttpMcpServer(
649
850
  signal: controller.signal,
650
851
  ...(input.headers ? { headers: input.headers } : {}),
651
852
  },
853
+ fetch: (url, init) =>
854
+ pinnedFetch(url, init, settings, {
855
+ label: "MCP capability probe",
856
+ requireHttpsOutsideLocalTest: true,
857
+ }),
652
858
  });
653
859
  await client.connect(transport as unknown as Transport, {
654
860
  timeout: input.timeoutMs,
@@ -674,9 +880,9 @@ function mcpProbeErrorMessage(error: unknown, endpointUrl: string): string {
674
880
  normalized,
675
881
  )
676
882
  ) {
677
- return `OpenGeni could not reach a valid Streamable HTTP MCP server at ${endpoint}. Check the endpoint URL or choose a different catalog entry.`;
883
+ return `Opengeni could not reach a valid Streamable HTTP MCP server at ${endpoint}. Check the endpoint URL or choose a different catalog entry.`;
678
884
  }
679
- return `OpenGeni could not initialize ${endpoint}. Check the endpoint configuration or try again.`;
885
+ return `Opengeni could not initialize ${endpoint}. Check the endpoint configuration or try again.`;
680
886
  }
681
887
 
682
888
  function safeEndpointLabel(endpointUrl: string): string {
@@ -715,11 +921,7 @@ export async function disableCapability(input: {
715
921
  message: "Remove Plugins through the Plugin Package flow",
716
922
  });
717
923
  }
718
- if (item.kind === "pack") {
719
- throw new HTTPException(409, {
720
- message: "Uninstall Packs through the Pack uninstall preview flow",
721
- });
722
- }
924
+
723
925
  if (item.source === "built_in" || item.source === "configured") {
724
926
  throw new HTTPException(409, {
725
927
  message:
@@ -742,27 +944,111 @@ export async function settingsWithEnabledCapabilityMcpServers(
742
944
  subjectId?: string;
743
945
  personalConnectionDelegations?: readonly McpPersonalConnectionDelegation[];
744
946
  onResolvedApiIntegrations?: (integrations: readonly ApiIntegrationRuntime[]) => void;
947
+ /** Every enabled capability server read, before static-id shadowing. */
948
+ onResolvedCapabilityServers?: (servers: readonly EnabledMcpCapabilityServer[]) => void;
949
+ /**
950
+ * The Codex Apps designation the caller already resolves (a claim reads
951
+ * its cutover once), or what it knows for resolving it here.
952
+ */
953
+ codexApps?: Promise<CodexAppsDesignationForRun | null> | CodexAppsRunContext;
954
+ /**
955
+ * `db` is one bounded transaction: run the reads one after another, since
956
+ * its RLS scopes must not overlap.
957
+ */
958
+ oneReadAtATime?: boolean;
745
959
  },
746
960
  ): Promise<Settings> {
747
- const apiIntegrationsPromise = options?.subjectId
748
- ? listInstalledApiIntegrations(db, workspaceId, options.subjectId)
749
- : listInstalledApiIntegrationsForDelegations(
750
- db,
751
- workspaceId,
752
- options?.personalConnectionDelegations ?? [],
753
- );
754
- const [enabled, apiIntegrations, codexAppsCredentialId] = await Promise.all([
755
- listEnabledMcpCapabilityServers(db, workspaceId),
756
- apiIntegrationsPromise,
757
- resolveCodexAppsCredentialIdForRun(db, workspaceId),
758
- ]);
961
+ const readApiIntegrations = () =>
962
+ options?.subjectId
963
+ ? listInstalledApiIntegrations(db, workspaceId, options.subjectId)
964
+ : listInstalledApiIntegrationsForDelegations(
965
+ db,
966
+ workspaceId,
967
+ options?.personalConnectionDelegations ?? [],
968
+ );
969
+ // Registration is dropped below when Apps are off for the deployment, so
970
+ // resolving the designation then would be pure cost.
971
+ const readCodexAppsDesignation = () =>
972
+ !settings.codexConnectedAppsEnabled
973
+ ? Promise.resolve(null)
974
+ : options?.codexApps instanceof Promise
975
+ ? options.codexApps
976
+ : resolveCodexAppsDesignationForRun(db, workspaceId, options?.codexApps ?? {});
977
+ let enabled: Awaited<ReturnType<typeof listEnabledMcpCapabilityServers>>;
978
+ let apiIntegrations: Awaited<ReturnType<typeof readApiIntegrations>>;
979
+ let codexAppsDesignation: Awaited<ReturnType<typeof readCodexAppsDesignation>>;
980
+ if (options?.oneReadAtATime) {
981
+ enabled = await listEnabledMcpCapabilityServers(db, workspaceId);
982
+ apiIntegrations = await readApiIntegrations();
983
+ codexAppsDesignation = await readCodexAppsDesignation();
984
+ } else {
985
+ // The integration read starts first: callers that pass a transaction rely
986
+ // on its subject scope being established before the other reads begin.
987
+ const apiIntegrationsPromise = readApiIntegrations();
988
+ [enabled, apiIntegrations, codexAppsDesignation] = await Promise.all([
989
+ listEnabledMcpCapabilityServers(db, workspaceId),
990
+ apiIntegrationsPromise,
991
+ readCodexAppsDesignation(),
992
+ ]);
993
+ }
759
994
  options?.onResolvedApiIntegrations?.(apiIntegrations);
995
+ options?.onResolvedCapabilityServers?.(enabled);
996
+ return settingsWithCodexAppsMcpServer(
997
+ settingsWithApiIntegrationServers(
998
+ settingsWithMcpCapabilityServers(settings, enabled),
999
+ apiIntegrations,
1000
+ ),
1001
+ codexAppsDesignation !== null,
1002
+ );
1003
+ }
1004
+
1005
+ /**
1006
+ * `settingsWithEnabledCapabilityMcpServers` for a live target fence: only the
1007
+ * rows that can produce `serverIds`. That is the capability installations that
1008
+ * produced them at preparation plus any catalog item explicitly claiming one of
1009
+ * those ids (shadowing), integrations with exactly those runtime keys (read
1010
+ * with the same subject visibility), and the Codex designation only when Codex
1011
+ * Apps itself is a target. Static servers come from `settings`, unchanged.
1012
+ * Restricted to `serverIds`, the result equals the complete projection.
1013
+ */
1014
+ export async function settingsWithEnabledCapabilityMcpServersForTargets(
1015
+ db: Database,
1016
+ workspaceId: string,
1017
+ settings: Settings,
1018
+ options: {
1019
+ subjectId: string;
1020
+ accountId: string;
1021
+ serverIds: readonly string[];
1022
+ capabilityIds: readonly string[];
1023
+ onResolvedApiIntegrations?: (integrations: readonly ApiIntegrationRuntime[]) => void;
1024
+ /** Checked between reads: a stopped caller starts no further read. */
1025
+ signal?: AbortSignal;
1026
+ },
1027
+ ): Promise<Settings> {
1028
+ const serverIds = new Set(options.serverIds);
1029
+ // One RLS scope at a time: `db` may be a single bounded transaction.
1030
+ const enabled = (
1031
+ await listEnabledMcpCapabilityServers(db, workspaceId, {
1032
+ capabilityIds: options.capabilityIds,
1033
+ serverIds: [...serverIds],
1034
+ })
1035
+ ).filter((server) => serverIds.has(server.id));
1036
+ options.signal?.throwIfAborted();
1037
+ const apiIntegrations = await listInstalledApiIntegrations(db, workspaceId, options.subjectId, [
1038
+ ...serverIds,
1039
+ ]);
1040
+ options.signal?.throwIfAborted();
1041
+ const codexAppsDesignation =
1042
+ settings.codexConnectedAppsEnabled && serverIds.has(CODEX_APPS_MCP_SERVER_ID)
1043
+ ? await resolveCodexAppsDesignationForRun(db, workspaceId, { accountId: options.accountId })
1044
+ : null;
1045
+ options.onResolvedApiIntegrations?.(apiIntegrations);
760
1046
  return settingsWithCodexAppsMcpServer(
761
1047
  settingsWithApiIntegrationServers(
762
1048
  settingsWithMcpCapabilityServers(settings, enabled),
763
1049
  apiIntegrations,
764
1050
  ),
765
- codexAppsCredentialId !== null,
1051
+ codexAppsDesignation !== null,
766
1052
  );
767
1053
  }
768
1054
 
@@ -773,7 +1059,7 @@ export function apiIntegrationsMatchingDelegations(
773
1059
  const exact = new Set(
774
1060
  delegations.map((delegation) =>
775
1061
  [
776
- delegation.serverId,
1062
+ delegation.canonicalServerId ?? delegation.serverId,
777
1063
  delegation.connectionId,
778
1064
  delegation.providerDomain.toLowerCase(),
779
1065
  delegation.kind ?? "",
@@ -842,19 +1128,77 @@ export function settingsWithApiIntegrationServers(
842
1128
  }
843
1129
 
844
1130
  /**
845
- * Resolve executable Apps authority. The connector must remain active and its
846
- * exact owner must still hold workspace connection-management permission.
1131
+ * The workspace's executable Codex Apps designation. Without a Codex cutover
1132
+ * row this is the legacy designation (`resolveCodexAppsCredentialIdForRun`).
1133
+ * With an enabled cutover it is the core designation, which the database
1134
+ * rechecks (designated shared Codex connection, still in the workspace's
1135
+ * scope, active). A disabled cutover row designates nothing and reads no
1136
+ * legacy Codex table.
1137
+ */
1138
+ export type CodexAppsDesignationForRun = {
1139
+ source: "core";
1140
+ accountId: string;
1141
+ connectionId: string;
1142
+ };
1143
+
1144
+ /**
1145
+ * What the caller already knows, so the resolver does not read it again: the
1146
+ * workspace's organization (skips the workspace lookup) and the organization's
1147
+ * Codex cutover disposition (skips the cutover read; a claim has read it).
1148
+ */
1149
+ export type CodexAppsRunContext = {
1150
+ accountId?: string;
1151
+ disposition?: CodexCutoverDisposition | Promise<CodexCutoverDisposition>;
1152
+ };
1153
+
1154
+ export async function resolveCodexAppsDesignationForRun(
1155
+ db: Database,
1156
+ workspaceId: string,
1157
+ known: CodexAppsRunContext = {},
1158
+ ): Promise<CodexAppsDesignationForRun | null> {
1159
+ let owner = known.accountId;
1160
+ const accountId = async () =>
1161
+ (owner ??= (await rlsContextForWorkspace(db, workspaceId)).accountId);
1162
+ const disposition =
1163
+ known.disposition !== undefined
1164
+ ? await known.disposition
1165
+ : await readCodexCutoverDisposition(db, await accountId(), workspaceId);
1166
+ if (disposition === "maintenance") return null;
1167
+ const coreAccountId = await accountId();
1168
+ const designation = await resolveSubscriptionCoreCodexAppsDesignation(db, {
1169
+ accountId: coreAccountId,
1170
+ workspaceId,
1171
+ });
1172
+ return designation?.status === "active"
1173
+ ? { source: "core", accountId: coreAccountId, connectionId: designation.connectionId }
1174
+ : null;
1175
+ }
1176
+
1177
+ /** Runtime Apps authentication for whichever designation `resolveCodexAppsDesignationForRun` returned. */
1178
+ export function codexAppsRequestAuthForDesignation(
1179
+ db: Database,
1180
+ settings: Settings,
1181
+ workspaceId: string,
1182
+ designation: CodexAppsDesignationForRun,
1183
+ ): SubscriptionCoreCodexAppsRequestAuth {
1184
+ return subscriptionCoreCodexAppsRequestAuth(db, settings, {
1185
+ accountId: designation.accountId,
1186
+ workspaceId,
1187
+ connectionId: designation.connectionId,
1188
+ });
1189
+ }
1190
+
1191
+ /**
1192
+ * Resolve the legacy executable Apps authority. Organizations with a Codex
1193
+ * cutover row (enabled or disabled) have none here: their designation lives
1194
+ * on the core (`resolveCodexAppsDesignationForRun`).
847
1195
  */
848
1196
  export async function resolveCodexAppsCredentialIdForRun(
849
1197
  db: Database,
850
1198
  workspaceId: string,
851
1199
  ): Promise<string | null> {
852
- const authorization = await getCodexAppsCredentialAuthorizationForRun(db, workspaceId);
853
- if (!authorization) return null;
854
- const grant = await getWorkspaceGrant(db, authorization.ownerSubjectId, workspaceId);
855
- return grant && hasPermission(grant.permissions, "connections:write")
856
- ? authorization.credentialId
857
- : null;
1200
+ const designation = await resolveCodexAppsDesignationForRun(db, workspaceId);
1201
+ return designation?.connectionId ?? null;
858
1202
  }
859
1203
 
860
1204
  /**
@@ -1055,56 +1399,10 @@ async function requireCatalogItem(
1055
1399
  return item;
1056
1400
  }
1057
1401
 
1058
- function packCatalogItem(
1059
- pack: ReturnType<typeof listCapabilityPacks>[number],
1060
- source: "built_in" | "manual",
1061
- ): CapabilityCatalogItem {
1062
- const customMetadata = { ...pack.metadata };
1063
- for (const key of [
1064
- "packId",
1065
- "version",
1066
- "connectors",
1067
- "knowledge",
1068
- "scheduledTaskTemplates",
1069
- "sandboxImage",
1070
- "sandboxProviderImages",
1071
- "skills",
1072
- ]) {
1073
- delete customMetadata[key];
1074
- }
1075
- return CapabilityCatalogItem.parse({
1076
- id: `pack:${pack.id}`,
1077
- kind: "pack",
1078
- source,
1079
- name: pack.name,
1080
- description: pack.description,
1081
- category: pack.category,
1082
- tags: [pack.role, pack.category, "pack"],
1083
- tools: pack.tools,
1084
- runtime: {
1085
- available: true,
1086
- notes: "Enables role-scoped tools, connectors, knowledge, and scheduled-task templates.",
1087
- },
1088
- metadata: {
1089
- ...customMetadata,
1090
- packId: pack.id,
1091
- version: pack.version,
1092
- connectors: pack.connectors,
1093
- knowledge: pack.knowledge,
1094
- scheduledTaskTemplates: pack.scheduledTaskTemplates,
1095
- ...(pack.variableSet ? { variableSet: pack.variableSet } : {}),
1096
- // Runtime composition surface only: skill names, never file content.
1097
- ...(pack.sandboxImage ? { sandboxImage: pack.sandboxImage } : {}),
1098
- ...(pack.sandboxProviderImages ? { sandboxProviderImages: pack.sandboxProviderImages } : {}),
1099
- ...(pack.skills.length > 0 ? { skills: pack.skills.map((skill) => skill.name) } : {}),
1100
- },
1101
- });
1102
- }
1103
-
1104
1402
  function configuredMcpCatalogItems(settings: Settings): CapabilityCatalogItem[] {
1105
1403
  return (
1106
1404
  settings.mcpServers
1107
- // OpenGeni, Files, and Document Search are native runtime surfaces. They
1405
+ // Opengeni, Files, and Document Search are native runtime surfaces. They
1108
1406
  // remain available to sessions through configuration, but are not things a
1109
1407
  // user installs, connects, or enables in the Capabilities control center.
1110
1408
  .filter(
@@ -1121,12 +1419,23 @@ function configuredMcpCatalogItems(settings: Settings): CapabilityCatalogItem[]
1121
1419
  category: "configured",
1122
1420
  tags: ["mcp", ...(server.allowedTools?.length ? ["limited-tools"] : [])],
1123
1421
  endpointUrl: server.url,
1422
+ authKind:
1423
+ server.connectionRef?.authoritySource !== "host" &&
1424
+ server.connectionRef?.kind === "oauth2"
1425
+ ? "oauth2"
1426
+ : null,
1427
+ // Deployment-managed personal selectors need the same account picker
1428
+ // as installed connectors. Fixed bindings remain server-resolved.
1429
+ connectionRef:
1430
+ server.connectionRef?.subjectScope === "subject" && !server.connectionRef.connectionId
1431
+ ? installationConnectionRef({ connectionRef: server.connectionRef })
1432
+ : null,
1124
1433
  tools: [{ kind: "mcp", id: server.id }],
1125
1434
  runtime: {
1126
1435
  available: true,
1127
1436
  mcpServerId: server.id,
1128
1437
  transport: "streamable-http",
1129
- notes: "Managed by this OpenGeni deployment through OPENGENI_MCP_SERVERS.",
1438
+ notes: "Managed by this Opengeni deployment through OPENGENI_MCP_SERVERS.",
1130
1439
  },
1131
1440
  metadata: {
1132
1441
  mcpServerId: server.id,
@@ -1262,7 +1571,7 @@ function fikenCatalogItem(fikenConnections: ConnectionMetadata[]): CapabilityCat
1262
1571
  runtime: {
1263
1572
  available: true,
1264
1573
  mcpServerId: "opengeni",
1265
- notes: "Fiken access is provided through OpenGeni's first-party fiken tools.",
1574
+ notes: "Fiken access is provided through Opengeni's first-party fiken tools.",
1266
1575
  },
1267
1576
  enabled: fikenEnabled,
1268
1577
  enabledReason: fikenEnabled
@@ -1273,6 +1582,7 @@ function fikenCatalogItem(fikenConnections: ConnectionMetadata[]): CapabilityCat
1273
1582
  metadata: {
1274
1583
  connectorMode: "first_party_fiken",
1275
1584
  ownership: "workspace",
1585
+ connectionStatus: fikenConnection?.status ?? null,
1276
1586
  // Derived from the contracts catalog so a new fiken_* tool cannot be
1277
1587
  // registered without also appearing on the capability tile.
1278
1588
  firstPartyMcpTools: FIRST_PARTY_MCP_TOOL_NAMES.filter((name) => name.startsWith("fiken_")),
@@ -1280,6 +1590,52 @@ function fikenCatalogItem(fikenConnections: ConnectionMetadata[]): CapabilityCat
1280
1590
  });
1281
1591
  }
1282
1592
 
1593
+ /**
1594
+ * The 1Password tile (design §10.9). Connecting a service account happens in
1595
+ * Settings > Secrets (`POST /connections/1password/install`); its vaults are
1596
+ * then reached through secret references and the secrets tools, not MCP.
1597
+ * Enabled while a workspace-shared 1Password connection is active.
1598
+ */
1599
+ function onePasswordCatalogItem(connections: ConnectionMetadata[]): CapabilityCatalogItem {
1600
+ const active = connections.filter((connection) => connection.status === "active");
1601
+ const enabled = active.length > 0;
1602
+ return CapabilityCatalogItem.parse({
1603
+ id: "api:1password",
1604
+ kind: "api",
1605
+ source: "built_in",
1606
+ name: "1Password",
1607
+ description:
1608
+ "Connect a 1Password service account so its vaults appear as read-only or read-write secret vaults.",
1609
+ category: "security",
1610
+ tags: ["api", "1password", "password-manager", "secrets", "vaults"],
1611
+ homepageUrl: "https://1password.com",
1612
+ authModel: "service_account_token",
1613
+ providerDomain: "1password.com",
1614
+ surfaceType: "first_party_onepassword",
1615
+ authKind: "api_key",
1616
+ tools: [],
1617
+ runtime: {
1618
+ available: true,
1619
+ notes: "1Password vaults are used through secret references and the secrets tools.",
1620
+ },
1621
+ enabled,
1622
+ enabledReason: enabled
1623
+ ? `${active.length === 1 ? "1Password connection" : `${active.length} 1Password connections`} active`
1624
+ : null,
1625
+ metadata: {
1626
+ connectorMode: "first_party_onepassword",
1627
+ connectionCount: connections.length,
1628
+ planTiers: [
1629
+ ...new Set(
1630
+ connections.map(
1631
+ (connection) => onePasswordConnectionMetadata(connection.metadata)?.planTier,
1632
+ ),
1633
+ ),
1634
+ ].filter(Boolean),
1635
+ },
1636
+ });
1637
+ }
1638
+
1283
1639
  function providerIntegrationCatalogItems(
1284
1640
  socialConnections: SocialConnection[],
1285
1641
  ): CapabilityCatalogItem[] {
@@ -1304,11 +1660,11 @@ function providerIntegrationCatalogItems(
1304
1660
  available: true,
1305
1661
  mcpServerId: "opengeni",
1306
1662
  notes:
1307
- "OpenGeni's social provider adapter routes every call through an exact visible account Connection.",
1663
+ "Opengeni's social provider adapter routes every call through an exact visible account Connection.",
1308
1664
  },
1309
1665
  enabled,
1310
1666
  enabledReason: socialConnectionSummary(counts),
1311
- provenance: "OpenGeni provider adapter",
1667
+ provenance: "Opengeni provider adapter",
1312
1668
  metadata: {
1313
1669
  providerAdapter: "social",
1314
1670
  provider: definition.provider,
@@ -1326,14 +1682,21 @@ function providerIntegrationCatalogItems(
1326
1682
  * recommend the owning product flow without manufacturing an enabled catalog
1327
1683
  * row for GitHub resources, Documents, schedules, or other platform features.
1328
1684
  */
1329
- export function nativeConnectionCapabilityRecommendations(): CapabilityCatalogItem[] {
1330
- return [
1685
+ /** The in-chat card id for connecting a person's own computer. */
1686
+ export const CONNECTED_MACHINE_CAPABILITY_ID = "api:connected-machine";
1687
+
1688
+ export { OPENGENI_BROWSER_EXTENSION_URL };
1689
+
1690
+ export function nativeConnectionCapabilityRecommendations(
1691
+ options: { connectedMachines?: boolean } = {},
1692
+ ): CapabilityCatalogItem[] {
1693
+ const items = [
1331
1694
  CapabilityCatalogItem.parse({
1332
1695
  id: "api:github-app",
1333
1696
  kind: "api",
1334
1697
  source: "built_in",
1335
1698
  name: "GitHub App",
1336
- description: "Connect repositories through OpenGeni's GitHub resource picker.",
1699
+ description: "Connect repositories through Opengeni's GitHub resource picker.",
1337
1700
  category: "source-control",
1338
1701
  tags: ["github", "repositories", "source-control"],
1339
1702
  homepageUrl: "https://github.com",
@@ -1361,6 +1724,66 @@ export function nativeConnectionCapabilityRecommendations(): CapabilityCatalogIt
1361
1724
  },
1362
1725
  }),
1363
1726
  ];
1727
+ if (options.connectedMachines) {
1728
+ items.push(
1729
+ CapabilityCatalogItem.parse({
1730
+ id: CONNECTED_MACHINE_CAPABILITY_ID,
1731
+ kind: "api",
1732
+ source: "built_in",
1733
+ name: "Connected Machine",
1734
+ description:
1735
+ "Run this chat on the person's own computer or server (Mac, Linux or Windows), and optionally let agents use their Chrome through the Opengeni Browser extension.",
1736
+ category: "compute",
1737
+ tags: [
1738
+ "machine",
1739
+ "computer",
1740
+ "laptop",
1741
+ "desktop",
1742
+ "mac",
1743
+ "macbook",
1744
+ "linux",
1745
+ "windows",
1746
+ "server",
1747
+ "gpu",
1748
+ "local",
1749
+ "self-hosted",
1750
+ "chrome",
1751
+ "browser",
1752
+ "extension",
1753
+ "terminal",
1754
+ ],
1755
+ homepageUrl: null,
1756
+ providerDomain: "opengeni.ai",
1757
+ authModel: "connected_machine_enrollment",
1758
+ authKind: "none",
1759
+ surfaceType: "first_party_connected_machine",
1760
+ tools: [{ kind: "mcp", id: "opengeni" }],
1761
+ runtime: {
1762
+ available: true,
1763
+ notes: `The person runs a one-line connect command on their machine from the chat card. The Opengeni Browser Chrome extension (${OPENGENI_BROWSER_EXTENSION_URL}) works only after the machine is connected.`,
1764
+ },
1765
+ lifecycle: {
1766
+ status: "available",
1767
+ readiness: "setup_required",
1768
+ detail: "Connect a machine from the chat card.",
1769
+ managedBy: "platform",
1770
+ },
1771
+ actions: ["connect", "inspect"],
1772
+ metadata: {
1773
+ firstPartyMcpTools: [
1774
+ "sandboxes_list",
1775
+ "sandbox_swap",
1776
+ "run_on",
1777
+ "connected_machine_enroll_token",
1778
+ "connected_machine_enable_screen_control",
1779
+ ],
1780
+ browserExtensionUrl: OPENGENI_BROWSER_EXTENSION_URL,
1781
+ recommendationOnly: true,
1782
+ },
1783
+ }),
1784
+ );
1785
+ }
1786
+ return items;
1364
1787
  }
1365
1788
 
1366
1789
  function socialConnectionCounts(
@@ -1521,31 +1944,21 @@ function socialProviderConnectionCounts(item: CapabilityCatalogItem): {
1521
1944
  export function applyCapabilityEnablement(
1522
1945
  item: CapabilityCatalogItem,
1523
1946
  installation: CapabilityInstallation | undefined,
1524
- activePackIds: Set<string>,
1525
1947
  ): CapabilityCatalogItem {
1948
+ if (item.surfaceType === "first_party_fiken" || item.surfaceType === "first_party_onepassword") {
1949
+ // Fiken and 1Password are API items with connection-derived state, not
1950
+ // generic API installations. Preserve that state before the non-MCP fallback below.
1951
+ return { ...item, connectionRef: null };
1952
+ }
1526
1953
  if (item.kind === "skill" || item.kind === "api" || item.kind === "plugin") {
1527
1954
  return { ...item, enabled: false, enabledReason: null, connectionRef: null };
1528
1955
  }
1529
- if (item.kind === "pack") {
1530
- const enabled = activePackIds.has(packIdFromCapabilityId(item.id));
1531
- return {
1532
- ...item,
1533
- enabled,
1534
- enabledReason: enabled ? "enabled" : null,
1535
- };
1536
- }
1537
1956
  if (isSocialProviderIntegration(item)) {
1538
1957
  // Provider-integration state is derived from every authoritative visible
1539
1958
  // social Connection while the catalog is built. The catalog summary never
1540
1959
  // publishes a personal connection UUID or collapses many accounts to one.
1541
1960
  return { ...item, connectionRef: null };
1542
1961
  }
1543
- if (item.surfaceType === "first_party_fiken") {
1544
- // Fiken connector state is derived from the authoritative workspace
1545
- // connection row while the catalog is built; browseable never means an
1546
- // account is already connected.
1547
- return { ...item, connectionRef: null };
1548
- }
1549
1962
  if (item.surfaceType === "codex_apps") {
1550
1963
  // Unlike ordinary built-ins, Apps availability is derived from the exact
1551
1964
  // active workspace designation above. Do not overwrite it with the
@@ -1557,7 +1970,7 @@ export function applyCapabilityEnablement(
1557
1970
  ...item,
1558
1971
  enabled: true,
1559
1972
  enabledReason: "managed by deployment",
1560
- connectionRef: null,
1973
+ connectionRef: item.connectionRef,
1561
1974
  };
1562
1975
  }
1563
1976
  if (item.source === "built_in") {
@@ -1568,11 +1981,17 @@ export function applyCapabilityEnablement(
1568
1981
  }
1569
1982
  const activeInstallation = installation?.status === "active";
1570
1983
  const enabled = !!activeInstallation && capabilityInstallationRuntimeReady(item, installation);
1984
+ const connectionRef =
1985
+ enabled && installation ? installationConnectionRef(installation.config) : null;
1571
1986
  return {
1572
1987
  ...item,
1988
+ // Older custom entries recorded the native credential kind on the
1989
+ // installation only. Project it without guessing from a URL or overwriting
1990
+ // an explicit catalog authentication contract.
1991
+ authKind: item.authKind ?? (connectionRef?.kind === "oauth2" ? "oauth2" : null),
1573
1992
  enabled,
1574
1993
  enabledReason: enabled ? "enabled" : null,
1575
- connectionRef: enabled && installation ? installationConnectionRef(installation.config) : null,
1994
+ connectionRef,
1576
1995
  };
1577
1996
  }
1578
1997
 
@@ -1599,7 +2018,10 @@ function applyCapabilityLifecycle(item: CapabilityCatalogItem): CapabilityCatalo
1599
2018
  detail: item.runtime.notes,
1600
2019
  managedBy: item.source === "built_in" ? "platform" : null,
1601
2020
  },
1602
- actions: ["inspect"],
2021
+ actions:
2022
+ item.enabled && item.kind === "mcp" && item.source !== "built_in"
2023
+ ? ["disconnect", "inspect"]
2024
+ : ["inspect"],
1603
2025
  };
1604
2026
  }
1605
2027
 
@@ -1638,7 +2060,7 @@ function applyCapabilityLifecycle(item: CapabilityCatalogItem): CapabilityCatalo
1638
2060
 
1639
2061
  const installed = item.enabled;
1640
2062
  const actions: CapabilityAction[] = installed
1641
- ? item.kind === "pack" || item.kind === "skill" || item.kind === "plugin"
2063
+ ? item.kind === "skill" || item.kind === "plugin"
1642
2064
  ? ["configure", "update", "uninstall", "inspect"]
1643
2065
  : ["configure", "disconnect", "inspect"]
1644
2066
  : item.kind === "mcp" || item.kind === "api"
@@ -1701,24 +2123,34 @@ function installationConnectionRef(
1701
2123
  if (!ref || typeof ref !== "object") {
1702
2124
  return null;
1703
2125
  }
1704
- const { authoritySource, connectionId, providerDomain, kind, subjectScope } = ref as Record<
1705
- string,
1706
- unknown
1707
- >;
2126
+ const { authoritySource, connectionId, providerDomain, kind, subjectScope, accountSelection } =
2127
+ ref as Record<string, unknown>;
1708
2128
  if (typeof providerDomain !== "string" || typeof kind !== "string") {
1709
2129
  return null;
1710
2130
  }
1711
2131
  if (authoritySource === "host") {
1712
2132
  // The internal installation/runtime ref retains the exact host binding.
1713
2133
  // Public capability catalogs use the existing null representation for an
1714
- // enabled capability without a native OpenGeni connection, so indefinitely
2134
+ // enabled capability without a native Opengeni connection, so indefinitely
1715
2135
  // open old browser bundles cannot treat a host UUID as native OAuth state.
1716
2136
  return null;
1717
2137
  }
2138
+ if (accountSelection === "all_eligible" && connectionId === undefined) {
2139
+ return {
2140
+ providerDomain,
2141
+ kind,
2142
+ accountSelection,
2143
+ ...(subjectScope === "subject" ? { subjectScope } : {}),
2144
+ };
2145
+ }
1718
2146
  if (subjectScope === "subject") {
1719
2147
  // Never project a native personal connection UUID through workspace-visible
1720
2148
  // capability configuration, including legacy rows that still contain one.
1721
- return { providerDomain, kind, subjectScope: "subject" };
2149
+ return {
2150
+ providerDomain,
2151
+ kind,
2152
+ subjectScope: "subject",
2153
+ };
1722
2154
  }
1723
2155
  if (typeof connectionId !== "string") {
1724
2156
  return null;
@@ -1753,13 +2185,42 @@ export type CapabilityCatalogSearchMatch = {
1753
2185
  item: CapabilityCatalogItem;
1754
2186
  score: number;
1755
2187
  matchedOn: Array<"name" | "provider" | "tag" | "category" | "description" | "id">;
2188
+ /**
2189
+ * True when the item matched only through the typo-tolerant tier (an edit
2190
+ * distance hit), never through an exact, prefix, or substring hit.
2191
+ */
2192
+ approximate: boolean;
2193
+ };
2194
+
2195
+ export type CapabilityCatalogSuggestion = {
2196
+ item: CapabilityCatalogItem;
2197
+ /** Name similarity to the query in [0, 1]; 1 means identical once normalized. */
2198
+ similarity: number;
1756
2199
  };
1757
2200
 
2201
+ // Typo tolerance stays deterministic and bounded: tokens shorter than four
2202
+ // characters never fuzz, compared strings are capped, and only identity fields
2203
+ // (name, provider domain, tags) participate in the fuzzy tier.
2204
+ const FUZZY_MIN_TOKEN_LENGTH = 4;
2205
+ const FUZZY_PREFIX_MIN_TOKEN_LENGTH = 5;
2206
+ const FUZZY_MAX_COMPARED_LENGTH = 48;
2207
+ const FUZZY_MAX_TOKENS = 8;
2208
+ const FUZZY_FIELDS = new Set<CapabilityCatalogSearchMatch["matchedOn"][number]>([
2209
+ "name",
2210
+ "provider",
2211
+ "tag",
2212
+ ]);
2213
+
1758
2214
  /**
1759
2215
  * Deterministically rank the already-merged workspace catalog for an agent.
1760
2216
  * The caller still owns live authorization checks (GitHub binding, OAuth row,
1761
2217
  * and so on); this function searches metadata only and never probes a provider
1762
2218
  * or exposes credential/setup prose to the model.
2219
+ *
2220
+ * Exact word, prefix, and substring hits rank first. A typo-tolerant tier
2221
+ * (bounded Damerau-Levenshtein on name, provider-domain, and tag words, plus
2222
+ * the space-stripped name against the space-stripped query) contributes a
2223
+ * smaller score so a one-letter spelling difference still finds the entry.
1763
2224
  */
1764
2225
  export function searchCapabilityCatalogItems(
1765
2226
  items: readonly CapabilityCatalogItem[],
@@ -1769,11 +2230,13 @@ export function searchCapabilityCatalogItems(
1769
2230
  const phrase = normalizeCapabilitySearchText(query);
1770
2231
  const tokens = [...new Set(phrase.split(" ").filter(Boolean))].slice(0, 24);
1771
2232
  if (tokens.length === 0) return [];
2233
+ const compactPhrase = phrase.replaceAll(" ", "");
2234
+ const fuzzyTokens = tokens.slice(0, FUZZY_MAX_TOKENS);
1772
2235
  const boundedLimit = Math.min(20, Math.max(1, Math.trunc(limit)));
1773
2236
  const weightedFields = (item: CapabilityCatalogItem) =>
1774
2237
  [
1775
2238
  ["name", item.name, 100],
1776
- ["provider", item.providerDomain ?? "", 80],
2239
+ ["provider", capabilityCatalogSearchProviderDomain(item) ?? "", 80],
1777
2240
  ["tag", item.tags.join(" "), 50],
1778
2241
  ["category", item.category, 30],
1779
2242
  ["id", item.id, 25],
@@ -1784,25 +2247,54 @@ export function searchCapabilityCatalogItems(
1784
2247
  .filter((item) => capabilityCatalogItemIsTrustedForExposure(item))
1785
2248
  .flatMap((item): CapabilityCatalogSearchMatch[] => {
1786
2249
  let score = 0;
2250
+ let approximate = true;
1787
2251
  const matchedOn = new Set<CapabilityCatalogSearchMatch["matchedOn"][number]>();
2252
+ const hit = (
2253
+ field: CapabilityCatalogSearchMatch["matchedOn"][number],
2254
+ points: number,
2255
+ fuzzy = false,
2256
+ ) => {
2257
+ score += points;
2258
+ matchedOn.add(field);
2259
+ if (!fuzzy) approximate = false;
2260
+ };
1788
2261
  for (const [field, raw, weight] of weightedFields(item)) {
1789
2262
  const value = normalizeCapabilitySearchText(raw);
1790
2263
  if (!value) continue;
1791
2264
  const words = value.split(" ");
2265
+ const fuzzyField = FUZZY_FIELDS.has(field);
1792
2266
  if (value.includes(phrase)) {
1793
- score += Math.round(weight * 1.5);
1794
- matchedOn.add(field);
2267
+ hit(field, Math.round(weight * 1.5));
2268
+ } else if (fuzzyField && compactPhrase.length >= FUZZY_MIN_TOKEN_LENGTH) {
2269
+ // Compare with separators removed so "wispr flow" meets "WisprFlow"
2270
+ // and "wisprflow" meets "Wispr Flow"; a near miss is typo-tolerant.
2271
+ const compactValue = value.replaceAll(" ", "");
2272
+ const budget = fuzzyEditBudget(compactPhrase.length);
2273
+ if (compactValue.includes(compactPhrase)) {
2274
+ hit(field, weight);
2275
+ } else {
2276
+ const distance = boundedEditDistance(compactPhrase, compactValue, budget);
2277
+ if (distance.whole <= budget) {
2278
+ hit(field, Math.round(weight * 0.5), true);
2279
+ } else if (
2280
+ compactPhrase.length >= FUZZY_PREFIX_MIN_TOKEN_LENGTH &&
2281
+ distance.prefix <= budget
2282
+ ) {
2283
+ hit(field, Math.round(weight * 0.3), true);
2284
+ }
2285
+ }
1795
2286
  }
1796
2287
  for (const token of tokens) {
1797
2288
  if (words.includes(token)) {
1798
- score += weight;
1799
- matchedOn.add(field);
2289
+ hit(field, weight);
1800
2290
  } else if (words.some((word) => word.startsWith(token) || token.startsWith(word))) {
1801
- score += Math.round(weight * 0.7);
1802
- matchedOn.add(field);
2291
+ hit(field, Math.round(weight * 0.7));
1803
2292
  } else if (value.includes(token)) {
1804
- score += Math.round(weight * 0.4);
1805
- matchedOn.add(field);
2293
+ hit(field, Math.round(weight * 0.4));
2294
+ } else if (fuzzyField && fuzzyTokens.includes(token)) {
2295
+ const fuzzy = fuzzyWordMatch(token, words);
2296
+ if (fuzzy === "word") hit(field, Math.round(weight * 0.3), true);
2297
+ else if (fuzzy === "prefix") hit(field, Math.round(weight * 0.2), true);
1806
2298
  }
1807
2299
  }
1808
2300
  }
@@ -1810,7 +2302,7 @@ export function searchCapabilityCatalogItems(
1810
2302
  if (item.enabled) score += 12;
1811
2303
  if (item.tier === "verified") score += 8;
1812
2304
  if (item.source === "built_in") score += 6;
1813
- return [{ item, score, matchedOn: [...matchedOn] }];
2305
+ return [{ item, score, matchedOn: [...matchedOn], approximate }];
1814
2306
  })
1815
2307
  .sort(
1816
2308
  (left, right) =>
@@ -1822,6 +2314,167 @@ export function searchCapabilityCatalogItems(
1822
2314
  .slice(0, boundedLimit);
1823
2315
  }
1824
2316
 
2317
+ /**
2318
+ * Closest catalog names for a query that matched nothing. A miss must never
2319
+ * read as "this integration does not exist" while the workspace has entries,
2320
+ * so callers surface these as explicitly labelled suggestions. An empty
2321
+ * (all-punctuation) query returns the first entries by name for browsing.
2322
+ */
2323
+ export function suggestCapabilityCatalogItems(
2324
+ items: readonly CapabilityCatalogItem[],
2325
+ query: string,
2326
+ limit = 5,
2327
+ ): CapabilityCatalogSuggestion[] {
2328
+ const boundedLimit = Math.min(20, Math.max(1, Math.trunc(limit)));
2329
+ const compactPhrase = normalizeCapabilitySearchText(query)
2330
+ .replaceAll(" ", "")
2331
+ .slice(0, FUZZY_MAX_COMPARED_LENGTH);
2332
+ return items
2333
+ .filter((item) => capabilityCatalogItemIsTrustedForExposure(item))
2334
+ .map((item): CapabilityCatalogSuggestion => {
2335
+ if (!compactPhrase) return { item, similarity: 0 };
2336
+ let best = 0;
2337
+ for (const raw of [item.name, capabilityCatalogSearchProviderDomain(item) ?? ""]) {
2338
+ const candidate = normalizeCapabilitySearchText(raw)
2339
+ .replaceAll(" ", "")
2340
+ .slice(0, FUZZY_MAX_COMPARED_LENGTH);
2341
+ if (!candidate) continue;
2342
+ const distance = boundedEditDistance(compactPhrase, candidate, FUZZY_MAX_COMPARED_LENGTH);
2343
+ best = Math.max(
2344
+ best,
2345
+ 1 - distance.whole / Math.max(compactPhrase.length, candidate.length),
2346
+ // A query that resembles the start of a longer name ("wspr" for
2347
+ // "Whisprflow") is as informative as a whole-name resemblance.
2348
+ 1 - distance.prefix / compactPhrase.length,
2349
+ );
2350
+ }
2351
+ return { item, similarity: Math.round(Math.max(0, best) * 100) / 100 };
2352
+ })
2353
+ .sort(
2354
+ (left, right) =>
2355
+ right.similarity - left.similarity ||
2356
+ Number(right.item.enabled) - Number(left.item.enabled) ||
2357
+ left.item.name.localeCompare(right.item.name) ||
2358
+ left.item.id.localeCompare(right.item.id),
2359
+ )
2360
+ .slice(0, boundedLimit);
2361
+ }
2362
+
2363
+ /**
2364
+ * The vendor domain search may match. A stored providerDomain wins. Workspace
2365
+ * and operator entries (manual, configured, built-in) without one fall back to
2366
+ * the registrable domain of their endpoint (api.wisprflow.ai -> wisprflow.ai).
2367
+ * Registry rows do not: their endpoints may sit on a third-party host. This is
2368
+ * a search-only projection and never changes the stored item or any
2369
+ * connection/authority matching.
2370
+ */
2371
+ export function capabilityCatalogSearchProviderDomain(item: CapabilityCatalogItem): string | null {
2372
+ const stored = item.providerDomain?.trim();
2373
+ if (stored) return stored;
2374
+ if (item.source === "registry" || item.source === "public_registry") return null;
2375
+ for (const candidate of [item.endpointUrl, item.mcpUrl, item.homepageUrl]) {
2376
+ if (!candidate) continue;
2377
+ const domain = registrableDomain(candidate);
2378
+ if (domain) return domain;
2379
+ }
2380
+ return null;
2381
+ }
2382
+
2383
+ // Common two-label public suffixes. Without a full public-suffix list this
2384
+ // keeps hosts such as api.example.co.uk on example.co.uk rather than co.uk.
2385
+ const TWO_LABEL_PUBLIC_SUFFIXES = new Set([
2386
+ "ac.uk",
2387
+ "co.uk",
2388
+ "gov.uk",
2389
+ "org.uk",
2390
+ "com.au",
2391
+ "net.au",
2392
+ "org.au",
2393
+ "co.nz",
2394
+ "co.jp",
2395
+ "co.kr",
2396
+ "co.in",
2397
+ "co.za",
2398
+ "com.br",
2399
+ "com.cn",
2400
+ "com.mx",
2401
+ "com.sg",
2402
+ "com.tr",
2403
+ ]);
2404
+
2405
+ function registrableDomain(url: string): string | null {
2406
+ let hostname: string;
2407
+ try {
2408
+ hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, "");
2409
+ } catch {
2410
+ return null;
2411
+ }
2412
+ if (!hostname || hostname.includes(":") || /^\d+(\.\d+){3}$/.test(hostname)) return null;
2413
+ const labels = hostname.split(".").filter(Boolean);
2414
+ if (labels.length <= 2) return labels.join(".") || null;
2415
+ const lastTwo = labels.slice(-2).join(".");
2416
+ return TWO_LABEL_PUBLIC_SUFFIXES.has(lastTwo) ? labels.slice(-3).join(".") : lastTwo;
2417
+ }
2418
+
2419
+ function fuzzyEditBudget(length: number): number {
2420
+ if (length < FUZZY_MIN_TOKEN_LENGTH) return 0;
2421
+ return length >= 8 ? 2 : 1;
2422
+ }
2423
+
2424
+ function fuzzyWordMatch(token: string, words: readonly string[]): "word" | "prefix" | null {
2425
+ const budget = fuzzyEditBudget(token.length);
2426
+ if (budget === 0 || token.length > FUZZY_MAX_COMPARED_LENGTH) return null;
2427
+ let prefixHit = false;
2428
+ for (const word of words) {
2429
+ const lengthGap = Math.abs(word.length - token.length);
2430
+ const prefixCandidate =
2431
+ token.length >= FUZZY_PREFIX_MIN_TOKEN_LENGTH && word.length > token.length;
2432
+ if (lengthGap > budget && !prefixCandidate) continue;
2433
+ const distance = boundedEditDistance(token, word, budget);
2434
+ if (lengthGap <= budget && distance.whole <= budget) return "word";
2435
+ if (prefixCandidate && distance.prefix <= budget) prefixHit = true;
2436
+ }
2437
+ return prefixHit ? "prefix" : null;
2438
+ }
2439
+
2440
+ /**
2441
+ * Optimal-string-alignment (restricted Damerau-Levenshtein) distance from
2442
+ * `source` to all of `target` (`whole`) and to its closest prefix (`prefix`).
2443
+ * Both inputs are capped at FUZZY_MAX_COMPARED_LENGTH, and the computation
2444
+ * stops early once every cell of a row exceeds `budget`, returning budget + 1.
2445
+ */
2446
+ function boundedEditDistance(
2447
+ source: string,
2448
+ target: string,
2449
+ budget: number,
2450
+ ): { whole: number; prefix: number } {
2451
+ const a = source.slice(0, FUZZY_MAX_COMPARED_LENGTH);
2452
+ const b = target.slice(0, FUZZY_MAX_COMPARED_LENGTH);
2453
+ const over = budget + 1;
2454
+ const width = b.length + 1;
2455
+ let previousPrevious = new Array<number>(width).fill(0);
2456
+ let previous = Array.from({ length: width }, (_, index) => index);
2457
+ let current = new Array<number>(width).fill(0);
2458
+ for (let i = 1; i <= a.length; i += 1) {
2459
+ current[0] = i;
2460
+ let rowMinimum = current[0];
2461
+ for (let j = 1; j <= b.length; j += 1) {
2462
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
2463
+ let value = Math.min(previous[j]! + 1, current[j - 1]! + 1, previous[j - 1]! + cost);
2464
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
2465
+ value = Math.min(value, previousPrevious[j - 2]! + 1);
2466
+ }
2467
+ current[j] = value;
2468
+ if (value < rowMinimum) rowMinimum = value;
2469
+ }
2470
+ if (rowMinimum > budget) return { whole: over, prefix: over };
2471
+ [previousPrevious, previous, current] = [previous, current, previousPrevious];
2472
+ }
2473
+ let prefix = previous[0]!;
2474
+ for (let j = 1; j <= b.length; j += 1) prefix = Math.min(prefix, previous[j]!);
2475
+ return { whole: Math.min(previous[b.length]!, over), prefix: Math.min(prefix, over) };
2476
+ }
2477
+
1825
2478
  function normalizeCapabilitySearchText(value: string): string {
1826
2479
  return value
1827
2480
  .normalize("NFKD")
@@ -1844,10 +2497,6 @@ function publicRegistryCapabilityId(name: string, version: string, endpointUrl:
1844
2497
  return `mcp-registry:${slugify(name)}-${shortHash(`${name}:${version}:${endpointUrl}`)}`;
1845
2498
  }
1846
2499
 
1847
- function packIdFromCapabilityId(capabilityId: string): string {
1848
- return capabilityId.replace(/^pack:/, "");
1849
- }
1850
-
1851
2500
  function uniqueTags(tags: string[]): string[] {
1852
2501
  return [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
1853
2502
  }