cartethyia 2.5.3 → 2.5.6

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 (456) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-CADDq4IU.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-DuO1Z7dq.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-BR5HHvbi.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-V5faV9V2.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
  25. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js +1 -0
  26. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-UB690SE2.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-BLujdX7N.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D5C2raAc.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card--AtFedAq.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  60. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  61. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  62. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  63. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  64. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-B9Xvtnfj.js +1 -0
  66. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  68. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  69. package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
  70. package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-sltk1qUi.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-BydyKaDz.js → index-CmxZr14F.js} +2 -2
  74. package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-BhY2sSnO.js} +1 -1
  76. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-BG41et9G.js → input-V1umFiBk.js} +1 -1
  78. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  79. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  80. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-1zzZYM0B.js} +1 -1
  82. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-BivGfuUs.js} +1 -1
  84. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  85. package/dist/dashboard/assets/model-bans-CmffjLuG.js +1 -0
  86. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  87. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-46P0oWMe.js} +1 -1
  88. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  89. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  90. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-BFhGpwR1.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-CAb4F37R.js} +1 -1
  94. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-BO6h8yNv.js → select-BYR_5K2c.js} +2 -2
  96. package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-DNX9sydi.js} +1 -1
  98. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-BHTfXdz_.js} +1 -1
  100. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-mPDRlQkl.js} +1 -1
  102. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  103. package/dist/dashboard/assets/switch-DOmJxg7H.js +1 -0
  104. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  105. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  106. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  107. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  108. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  109. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  110. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  111. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-Dem0lXQj.js} +1 -1
  112. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  113. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  114. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  115. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  116. package/dist/dashboard/index.html +1 -1
  117. package/dist/dashboard/index.html.gz +0 -0
  118. package/dist/dashboard/precompressed-assets.json +57 -57
  119. package/migrations/README.md +52 -52
  120. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  121. package/package.json +96 -95
  122. package/scripts/cli/admin.ts +125 -0
  123. package/scripts/cli/autostart.ts +237 -0
  124. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  125. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -248
  126. package/scripts/cli/doctor.ts +37 -0
  127. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  128. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  129. package/scripts/cli/instance.ts +224 -0
  130. package/scripts/cli/main.ts +88 -18
  131. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  132. package/scripts/cli/port.ts +100 -0
  133. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  134. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  135. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  136. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  137. package/scripts/cli/tray/index.ts +62 -12
  138. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  139. package/scripts/cli/update.ts +40 -0
  140. package/scripts/cli/version.ts +67 -0
  141. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  142. package/src/boot/app.ts +11 -7
  143. package/src/boot/dependencies.ts +50 -19
  144. package/src/boot/timeout.ts +2 -13
  145. package/src/config/cooldowns.ts +186 -138
  146. package/src/config/network.ts +8 -14
  147. package/src/config/providers.ts +7 -10
  148. package/src/config/runtime.ts +76 -76
  149. package/src/config/security.ts +15 -0
  150. package/src/config/spec.ts +52 -7
  151. package/src/config/telemetry.ts +26 -0
  152. package/src/config/timeouts.ts +0 -6
  153. package/src/console/admin/model-abuse/contracts.ts +28 -6
  154. package/src/console/admin/model-abuse/routes.ts +56 -3
  155. package/src/console/api-keys/bucket-spend.ts +15 -4
  156. package/src/console/api-keys/contracts.ts +410 -414
  157. package/src/console/api-keys/routes.ts +881 -880
  158. package/src/console/auth/credentials.ts +3 -3
  159. package/src/console/auth/setup-service.ts +2 -2
  160. package/src/console/backup/contracts.ts +15 -2
  161. package/src/console/backup/export-endpoint.ts +6 -2
  162. package/src/console/backup/nine-router.ts +2 -5
  163. package/src/console/backup/service.ts +4 -0
  164. package/src/console/backup/store.ts +192 -29
  165. package/src/console/backup/validate.ts +3 -45
  166. package/src/console/cli-tools/fs-ops.ts +487 -487
  167. package/src/console/console-router-guards.ts +1 -1
  168. package/src/console/console-router.ts +8 -0
  169. package/src/console/data-management/contracts.ts +100 -0
  170. package/src/console/data-management/operations.ts +234 -0
  171. package/src/console/data-management/routes.ts +103 -0
  172. package/src/console/domain-registration.ts +45 -1
  173. package/src/console/observability/contracts.ts +524 -485
  174. package/src/console/observability/live.ts +22 -0
  175. package/src/console/observability/store.ts +1138 -1018
  176. package/src/console/observability/usage-dimensions.ts +4 -4
  177. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  178. package/src/console/providers/catalog/contracts.ts +6 -0
  179. package/src/console/providers/catalog/model-operations.ts +5 -2
  180. package/src/console/providers/catalog/provider-operations.ts +24 -4
  181. package/src/console/providers/catalog/public-model-store.ts +8 -11
  182. package/src/console/providers/catalog/routes.ts +7 -0
  183. package/src/console/providers/catalog/store.ts +1553 -1532
  184. package/src/console/providers/oauth/routes.ts +8 -12
  185. package/src/console/quota/account-quota-shared.ts +52 -39
  186. package/src/console/quota/account-quota-tenant.ts +6 -6
  187. package/src/console/quota/quota-view.ts +1 -1
  188. package/src/console/quota/refresh.ts +441 -429
  189. package/src/console/remote-support/contracts.ts +61 -0
  190. package/src/console/remote-support/routes.ts +65 -0
  191. package/src/console/remote-support/service.ts +207 -0
  192. package/src/console/response-compression.ts +0 -3
  193. package/src/console/routing/pools/import-parse.ts +206 -0
  194. package/src/console/routing/pools/operations.ts +543 -501
  195. package/src/console/routing/pools/probe-result.ts +3 -2
  196. package/src/console/routing/pools/probe-verdict.ts +132 -132
  197. package/src/console/routing/pools/routes.ts +181 -164
  198. package/src/console/routing/pools/store.ts +8 -8
  199. package/src/console/settings/contracts.ts +95 -2
  200. package/src/console/settings/store.ts +81 -3
  201. package/src/console/shared/cursor.ts +8 -8
  202. package/src/console/shared/errors.ts +1 -3
  203. package/src/console/updates/routes.ts +16 -0
  204. package/src/main.ts +10 -0
  205. package/src/network/http2-bidi.ts +2 -4
  206. package/src/network/pool/agent.ts +478 -478
  207. package/src/network/pool/selector.ts +5 -4
  208. package/src/network/retry.ts +1 -2
  209. package/src/network/ssrf.ts +5 -4
  210. package/src/observability/event-compaction.ts +317 -0
  211. package/src/observability/event-day-index.ts +440 -0
  212. package/src/observability/event-log.ts +155 -6
  213. package/src/observability/logger.ts +6 -6
  214. package/src/observability/payload-capture.ts +158 -33
  215. package/src/observability/payload-store.ts +82 -61
  216. package/src/observability/redaction.ts +5 -6
  217. package/src/observability/request-notify.ts +43 -0
  218. package/src/observability/telemetry-status.ts +8 -9
  219. package/src/observability/token-speed.ts +5 -4
  220. package/src/persistence/db-mode.ts +7 -7
  221. package/src/persistence/health-event-flush.ts +36 -0
  222. package/src/persistence/readiness.ts +1 -1
  223. package/src/persistence/schema.ts +75 -29
  224. package/src/persistence/shared-cache.ts +1 -2
  225. package/src/persistence/stores/api-key-store.ts +18 -6
  226. package/src/persistence/stores/share-store.ts +4 -3
  227. package/src/persistence/stores/telemetry-store.ts +33 -11
  228. package/src/protocol/canonical-model.ts +11 -3
  229. package/src/protocol/codecs.ts +139 -6
  230. package/src/protocol/gateway-error.ts +6 -4
  231. package/src/protocol/image-clamp.ts +19 -1
  232. package/src/protocol/registry.ts +8 -0
  233. package/src/protocol/request/chat.ts +21 -1
  234. package/src/protocol/request/codex.ts +10 -1
  235. package/src/protocol/request/gemini.ts +9 -2
  236. package/src/protocol/request/messages.ts +18 -7
  237. package/src/protocol/request/responses.ts +9 -1
  238. package/src/protocol/response/chat.ts +34 -4
  239. package/src/protocol/response/codex.ts +7 -4
  240. package/src/protocol/response/dsml.ts +140 -28
  241. package/src/protocol/response/responses.ts +11 -5
  242. package/src/protocol/stream-error-frames.ts +4 -5
  243. package/src/providers/adapters/compatible-adapter.ts +3 -0
  244. package/src/providers/authentication/jwt-validator.ts +9 -10
  245. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  246. package/src/providers/bundled-discovery-providers.ts +122 -119
  247. package/src/providers/configured-openai-providers.ts +4 -4
  248. package/src/providers/default-registry.ts +5 -4
  249. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  250. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  251. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  252. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  253. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  254. package/src/providers/discovery/probe-phases.ts +4 -4
  255. package/src/providers/discovery/probe-wire.ts +6 -13
  256. package/src/providers/discovery/probing-service.ts +1117 -1045
  257. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  258. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  259. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  260. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  261. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  262. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  263. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  264. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  265. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  266. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  267. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  268. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  269. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  270. package/src/providers/integrations/claude/claude.ts +608 -608
  271. package/src/providers/integrations/cline/cline-quota.ts +27 -20
  272. package/src/providers/integrations/cline/cline.ts +537 -539
  273. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  274. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  275. package/src/providers/integrations/codex/codex.ts +2 -29
  276. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  277. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  278. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  279. package/src/providers/integrations/cursor/cursor-quota.ts +309 -0
  280. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  281. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  282. package/src/providers/integrations/cursor/cursor.ts +1333 -960
  283. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  284. package/src/providers/integrations/factory/factory-models.ts +3 -5
  285. package/src/providers/integrations/factory/factory.ts +8 -1
  286. package/src/providers/integrations/gemini.ts +6 -1
  287. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  288. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  289. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  290. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  291. package/src/providers/integrations/grok/grok.ts +6 -7
  292. package/src/providers/integrations/hermes.ts +14 -18
  293. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  294. package/src/providers/integrations/kiro/kiro.ts +1 -1
  295. package/src/providers/integrations/mistral.ts +6 -6
  296. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  297. package/src/providers/integrations/opencode/provider.ts +19 -20
  298. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  299. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  300. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  301. package/src/providers/integrations/qoder/qoder.ts +884 -865
  302. package/src/providers/integrations/shared/connect.ts +6 -6
  303. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  304. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  305. package/src/providers/integrations/trae/trae.ts +2 -2
  306. package/src/providers/integrations/zcode/oauth.ts +6 -1
  307. package/src/providers/operations/account-health-service.ts +75 -27
  308. package/src/providers/operations/account-reset-service.ts +1 -1
  309. package/src/providers/operations/client-versions.ts +6 -6
  310. package/src/providers/operations/custom-cli-headers.ts +3 -4
  311. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  312. package/src/providers/operations/provider-catalog-service.ts +3 -4
  313. package/src/providers/provider-contracts.ts +7 -0
  314. package/src/providers/provider-metadata.ts +10 -8
  315. package/src/providers/provider-registry.ts +6 -7
  316. package/src/providers/provider-usage.ts +1 -1
  317. package/src/providers/quota/quota-support.ts +8 -0
  318. package/src/security/abuse.ts +15 -10
  319. package/src/security/access-control.ts +144 -146
  320. package/src/security/admission/contracts.ts +11 -0
  321. package/src/security/admission/in-memory-store.ts +4 -3
  322. package/src/security/admission/lease-sweep.ts +6 -6
  323. package/src/security/admission/reasons.ts +5 -1
  324. package/src/security/admission/redis-store.ts +211 -210
  325. package/src/security/admission/service.ts +4 -0
  326. package/src/security/api-key-auth.ts +27 -17
  327. package/src/security/ip-boundary.ts +155 -15
  328. package/src/security/model-abuse.ts +366 -94
  329. package/src/security/outbound-headers.ts +3 -4
  330. package/src/shared/sleep.ts +4 -0
  331. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  332. package/src/transport/dispatch/attempt-loop.ts +531 -530
  333. package/src/transport/dispatch/generation.ts +6 -0
  334. package/src/transport/dispatch/proxy-request.ts +24 -1
  335. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  336. package/src/transport/dispatch/stream-bridge.ts +1 -3
  337. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  338. package/src/transport/dispatch/streaming/stream.ts +19 -6
  339. package/src/transport/dispatch/upstream.ts +9 -0
  340. package/src/transport/failure-policy.ts +162 -8
  341. package/src/transport/guards/auth-guard.ts +7 -4
  342. package/src/transport/guards/concurrency-guard.ts +0 -5
  343. package/src/transport/guards/error-lifecycle.ts +13 -6
  344. package/src/transport/guards/pipeline.ts +15 -3
  345. package/src/transport/guards/request-context.ts +8 -2
  346. package/src/transport/request/inflight.ts +5 -6
  347. package/src/transport/request/preparer.ts +9 -8
  348. package/src/transport/request/state.ts +21 -8
  349. package/src/transport/request/tenant-preferences.ts +60 -0
  350. package/src/transport/routing/admission.ts +4 -0
  351. package/src/transport/routing/route-catalog.ts +65 -10
  352. package/src/transport/routing/route-model.ts +46 -14
  353. package/src/transport/routing/router.ts +21 -14
  354. package/src/transport/surface/completion.ts +2 -3
  355. package/src/transport/surface/dialects.ts +5 -6
  356. package/src/transport/surface/responses/encode.ts +38 -0
  357. package/src/transport/surface/responses/parse.ts +4 -0
  358. package/src/transport/translation/capabilities.ts +2 -3
  359. package/src/transport/translation/quirks.ts +3 -3
  360. package/src/workers/buddy-growth-tasks.ts +5 -10
  361. package/src/workers/daily-checkin.ts +17 -23
  362. package/src/workers/quota-refresh-sweep.ts +26 -26
  363. package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
  364. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
  365. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
  366. package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
  367. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +0 -1
  368. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
  369. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
  370. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
  371. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
  372. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
  373. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
  374. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +0 -1
  375. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  376. package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
  377. package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
  378. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
  379. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
  380. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
  381. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
  382. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
  383. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
  384. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
  385. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
  386. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
  387. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
  388. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
  389. package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
  390. package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
  391. package/dist/dashboard/assets/UsagePage-B47uOydr.js +0 -56
  392. package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
  393. package/dist/dashboard/assets/api-keys-uNywYlfv.js +0 -1
  394. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  395. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  396. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  397. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  398. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  399. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  400. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  401. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  402. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  403. package/dist/dashboard/assets/dialog-BtpsJU98.js +0 -1
  404. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  405. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  406. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  407. package/dist/dashboard/assets/drawer-WwwtzihF.js +0 -1
  408. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  409. package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
  410. package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
  411. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  412. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  413. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  414. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  415. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  416. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  417. package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
  418. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  419. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  420. package/dist/dashboard/assets/layout-C8mer_Ak.js +0 -1
  421. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  422. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  423. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  424. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  425. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  426. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  427. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  428. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  429. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  430. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  431. package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
  432. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  433. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  434. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  435. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  436. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  437. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  438. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  439. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  440. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  441. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  442. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  443. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  444. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  445. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  446. package/scripts/cli/command/admin.ts +0 -65
  447. package/scripts/cli/command/doctor.ts +0 -257
  448. package/scripts/cli/command/update.ts +0 -23
  449. package/scripts/cli/gateway/drain-token.ts +0 -78
  450. package/scripts/cli/gateway/instance-lock.ts +0 -105
  451. package/scripts/cli/gateway/stop.ts +0 -51
  452. package/scripts/cli/support/bun.ts +0 -27
  453. package/scripts/cli/support/version.ts +0 -37
  454. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-BIt446yE.js} +0 -0
  455. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-BIt446yE.js.gz} +0 -0
  456. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,960 +1,1333 @@
1
- // The canonical Cursor adapter.
2
- //
3
- // Cursor speaks Connect-RPC over protobuf on a bidirectional HTTP/2 stream, so
4
- // this adapter frames its own protocol instead of going through a wire codec. It
5
- // owns the whole tool loop in both directions:
6
- //
7
- // - a Cursor executable tool request becomes a client `tool_call_delta`;
8
- // - the client's next request carries that tool's result and the *same*
9
- // upstream Run answers it, so the model continues where it left off.
10
- //
11
- // Two rules govern what is executed. Only Cursor's authoritative
12
- // `exec_server_message.mcp_args` is a tool call — `interaction_update`'s
13
- // `tool_call_started` / `tool_call_completed` are display notifications, and
14
- // Cursor's native shell/filesystem/workspace tools are never run here. And an
15
- // `mcp_args` request is executable only when its namespace and function name
16
- // match a tool the current request declared; anything else is answered
17
- // `rejected` so the model can continue without the gateway ever touching a
18
- // filesystem or a shell on the caller's behalf.
19
- import { GatewayError } from "../../../protocol/gateway-error";
20
- import type {
21
- CanonicalEvent,
22
- CanonicalRequest,
23
- CanonicalStopReason,
24
- ContentPart,
25
- UsageRecord,
26
- } from "../../../protocol/canonical-model";
27
- import { resolveImageSource, splitDataUrl } from "../../../protocol/codecs";
28
- import { IMAGE_CLAMP_MAX_BYTES } from "../../../protocol/image-clamp";
29
- import type { ProviderAdapter, ProviderDispatchContext, ProviderDispatchTarget } from "../../provider-contracts";
30
- import { readCredentialSecret } from "../../provider-contracts";
31
- import type { Http2BidiEndReason, ValidatedHttp2BidiStream } from "../../../network/http2-bidi";
32
- import type { ProviderContinuationScope } from "../../provider-contracts";
33
- import { CURSOR_PROVIDER_ID, RUN_IDLE_TTL_MS, isAllowedAgentHost } from "./cursor-shared";
34
- import {
35
- CURSOR_MCP_NAMESPACE as MCP_NAMESPACE,
36
- buildCancelAction,
37
- buildKvGetBlobResult,
38
- buildKvSetBlobResult,
39
- buildMcpRejected,
40
- buildMcpResult,
41
- buildMcpStateResult,
42
- buildHeartbeat,
43
- buildRequestContextResult,
44
- buildRunRequest,
45
- historyFromRequest,
46
- liveUserText,
47
- systemPromptFrom,
48
- toolDescriptorsFor,
49
- type CursorImageInput,
50
- type CursorToolDescriptor,
51
- } from "./cursor-request";
52
- import { cursorRunTerminationError, decodeServerMessages, openCursorRun } from "./cursor-http2";
53
- import { cursorRunSessions } from "./cursor-session-manager";
54
- import type { CursorContinuationScope, CursorRunSession } from "./cursor-session-manager";
55
- import { fetchCursorRunOrigin } from "./cursor-catalog";
56
- import { defaultParameterValues, fetchCursorModels } from "./cursor-catalog";
57
- import type { CursorModelInfo } from "./cursor-catalog";
58
- import { decodeValueToJson, decodeStructEntriesToJson } from "./protocol/struct";
59
-
60
- /**
61
- * Client-heartbeat cadence for every Cursor Run.
62
- *
63
- * The edge answers a turn only after it has seen at least one client
64
- * heartbeat, and the donor pins this to 5s (`heartbeatMs` in its continuation
65
- * policy). A longer cadence is a Run that streams heartbeats and never
66
- * answers; a shorter one is traffic the upstream does not ask for.
67
- */
68
- const CURSOR_HEARTBEAT_INTERVAL_MS = 5_000;
69
-
70
- /**
71
- * How long one account's model roster is reused for `parameters` lookups.
72
- *
73
- * A Run needs the model's declared default parameter tuple, but the roster is
74
- * per-account and moves rarely, so it is fetched once and cached rather than on
75
- * every dispatch.
76
- */
77
- const CURSOR_ROSTER_TTL_MS = 300_000;
78
- /** credential → { roster, fetchedAt } for the parameter lookup. */
79
- const rosterCache = new Map<string, { roster: readonly CursorModelInfo[]; fetchedAt: number }>();
80
- /** Client-heartbeat cadence the Cursor edge expects (donor `heartbeatMs`). */
81
- const CURSOR_HEARTBEAT_INTERVAL_OVERRIDE = (() => {
82
- const raw = process.env.CARTETHYIA_CURSOR_HEARTBEAT_MS?.trim();
83
- if (raw === undefined || raw === "") return undefined;
84
- const parsed = Number(raw);
85
- return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined;
86
- })();
87
-
88
- /** Image MIME types Cursor accepts in `selected_images`. */
89
- const ALLOWED_IMAGE_MIME_TYPES: Record<string, true> = {
90
- "image/png": true,
91
- "image/jpeg": true,
92
- "image/webp": true,
93
- "image/gif": true,
94
- };
95
-
96
- /** Largest image payload accepted, matching the shared pre-send clamp. */
97
- const MAX_IMAGE_BYTES = IMAGE_CLAMP_MAX_BYTES;
98
-
99
- /** Identifies an allowed image payload from its magic bytes. */
100
- function sniffImageMime(bytes: Uint8Array): string | undefined {
101
- if (bytes.byteLength >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
102
- return "image/png";
103
- }
104
- if (bytes.byteLength >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
105
- return "image/jpeg";
106
- }
107
- if (
108
- bytes.byteLength >= 6 &&
109
- bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38
110
- ) {
111
- return "image/gif";
112
- }
113
- if (
114
- bytes.byteLength >= 12 &&
115
- bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
116
- bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
117
- ) {
118
- return "image/webp";
119
- }
120
- return undefined;
121
- }
122
-
123
- function base64ToBytes(data: string): Uint8Array | undefined {
124
- try {
125
- const binary = atob(data);
126
- const out = new Uint8Array(binary.length);
127
- for (let index = 0; index < binary.length; index += 1) out[index] = binary.charCodeAt(index);
128
- return out;
129
- } catch {
130
- return undefined;
131
- }
132
- }
133
-
134
- /**
135
- * Validates one decoded image payload.
136
- *
137
- * The MIME type is verified by sniffing and only then compared against the
138
- * declared type: a caller-supplied `content-type` or data-URL media type is a
139
- * claim, and sending bytes the upstream will reject — or bytes that are not an
140
- * image at all — fails the whole request rather than being dropped silently.
141
- */
142
- function finishImage(bytes: Uint8Array): CursorImageInput {
143
- const sniffed = sniffImageMime(bytes);
144
- const mimeType = sniffed;
145
- if (mimeType === undefined || ALLOWED_IMAGE_MIME_TYPES[mimeType] !== true) {
146
- throw new GatewayError(
147
- "invalid_request",
148
- 400,
149
- "Cursor accepts PNG, JPEG, WebP and GIF image input only",
150
- );
151
- }
152
- if (bytes.byteLength > MAX_IMAGE_BYTES) {
153
- throw new GatewayError(
154
- "invalid_request",
155
- 400,
156
- `Cursor image input exceeds the ${MAX_IMAGE_BYTES}-byte limit`,
157
- );
158
- }
159
- return { data: bytes, mimeType };
160
- }
161
-
162
- /** Reads one canonical image part into Cursor `selected_images` bytes. */
163
- async function resolveImageInput(
164
- part: Extract<ContentPart, { kind: "image" }>,
165
- context: ProviderDispatchContext,
166
- ): Promise<CursorImageInput> {
167
- const source = resolveImageSource(part.payload);
168
- const url = source?.url;
169
- if (url === undefined) {
170
- throw new GatewayError(
171
- "invalid_request",
172
- 400,
173
- "Cursor image input requires inline image data or an HTTPS image URL",
174
- );
175
- }
176
- const dataUrl = splitDataUrl(url);
177
- if (dataUrl !== undefined) {
178
- const bytes = base64ToBytes(dataUrl.data);
179
- if (bytes === undefined) {
180
- throw new GatewayError("invalid_request", 400, "Cursor image attachment is not valid base64");
181
- }
182
- return finishImage(bytes);
183
- }
184
- const parsed = new URL(url);
185
- if (parsed.protocol !== "https:") {
186
- throw new GatewayError(
187
- "invalid_request",
188
- 400,
189
- "Cursor image URLs must be HTTPS; private and non-TLS sources are not fetched",
190
- );
191
- }
192
- const fetcher = context.outbound_attachment_fetch;
193
- if (fetcher === undefined) {
194
- throw new GatewayError(
195
- "invalid_request",
196
- 400,
197
- "Cursor has no validated egress to fetch this image URL",
198
- );
199
- }
200
- // Redirects are not followed: reject the response rather than letting a
201
- // public URL redirect the attachment fetch to a private address.
202
- const response = await fetcher(url, {
203
- method: "GET",
204
- redirect: "manual",
205
- signal: context.abort_signal,
206
- });
207
- if (!response.ok) {
208
- throw new GatewayError(
209
- "invalid_request",
210
- 400,
211
- `Cursor could not fetch this image URL (${response.status})`,
212
- );
213
- }
214
- const body = new Uint8Array(await response.arrayBuffer());
215
- if (body.byteLength > MAX_IMAGE_BYTES) {
216
- throw new GatewayError(
217
- "invalid_request",
218
- 400,
219
- `Cursor image input exceeds the ${MAX_IMAGE_BYTES}-byte limit`,
220
- );
221
- }
222
- return finishImage(body);
223
- }
224
-
225
- /** Collects every canonical image part of a request into Cursor images. */
226
- async function collectImages(
227
- request: CanonicalRequest,
228
- context: ProviderDispatchContext,
229
- ): Promise<CursorImageInput[]> {
230
- const images: CursorImageInput[] = [];
231
- for (const message of request.messages) {
232
- for (const part of message.content) {
233
- if (part.kind !== "image") continue;
234
- images.push(await resolveImageInput(part, context));
235
- }
236
- }
237
- return images;
238
- }
239
-
240
- function usageFromTurnEnded(turnEnded: Record<string, unknown>): UsageRecord {
241
- const count = (value: unknown): number =>
242
- typeof value === "number" && Number.isFinite(value) ? value : 0;
243
- const input = count(turnEnded["input_tokens"]);
244
- const output = count(turnEnded["output_tokens"]);
245
- const cacheRead = count(turnEnded["cache_read"]);
246
- return {
247
- input_tokens: input,
248
- cached_input_tokens: cacheRead,
249
- cache_write_tokens: count(turnEnded["cache_write"]),
250
- uncached_input_tokens: Math.max(0, input - cacheRead),
251
- output_tokens: output,
252
- reasoning_tokens: count(turnEnded["reasoning_tokens"]),
253
- estimated_cost: null,
254
- total_tokens: input + output,
255
- };
256
- }
257
-
258
- function recordOf(value: unknown): Record<string, unknown> | undefined {
259
- return value !== null && typeof value === "object" && !Array.isArray(value)
260
- ? (value as Record<string, unknown>)
261
- : undefined;
262
- }
263
-
264
- function nonEmptyStringOf(value: unknown): string | undefined {
265
- return typeof value === "string" && value.length > 0 ? value : undefined;
266
- }
267
-
268
- /**
269
- * Reads one inbound `mcp_args` request.
270
- *
271
- * Returns `undefined` for anything that is not an executable call. Cursor's own
272
- * host tools arrive under their own server identifiers (or none at all) and are
273
- * never executed here — refusing them is what keeps a model's request from
274
- * turning into a shell command on the gateway's host.
275
- */
276
- function readExecutableMcpCall(
277
- mcpArgs: Record<string, unknown>,
278
- descriptors: readonly CursorToolDescriptor[],
279
- ): { toolName: string; arguments: string } | undefined {
280
- if (nonEmptyStringOf(mcpArgs["provider_identifier"]) !== MCP_NAMESPACE) return undefined;
281
- const toolName = nonEmptyStringOf(mcpArgs["tool_name"]);
282
- if (toolName === undefined) return undefined;
283
- if (!descriptors.some((descriptor) => descriptor.toolName === toolName)) return undefined;
284
- return { toolName, arguments: decodeMcpArguments(mcpArgs) };
285
- }
286
-
287
- /**
288
- * Decodes Cursor's `McpArgs.args` — a `map<string, google.protobuf.Value>`
289
- * carried as repeated map-entry messages — into a canonical arguments string.
290
- */
291
- function decodeMcpArguments(mcpArgs: Record<string, unknown>): string {
292
- const entries = mcpArgs["args"];
293
- const raw = Array.isArray(entries)
294
- ? entries.filter((entry): entry is Uint8Array => entry instanceof Uint8Array)
295
- : [];
296
- if (raw.length === 0) return "{}";
297
- try {
298
- return JSON.stringify(decodeStructEntriesToJson(raw));
299
- } catch {
300
- return "{}";
301
- }
302
- }
303
-
304
- /** One image-bearing block of a tool result, ready for `McpToolResultContentItem`. */
305
- interface ResultImage {
306
- readonly data: Uint8Array;
307
- readonly mimeType: string;
308
- }
309
-
310
- /** Reads the images out of one canonical tool result. */
311
- function resultImages(part: Extract<ContentPart, { kind: "toolResult" }>): ResultImage[] {
312
- if (typeof part.content === "string") return [];
313
- const images: ResultImage[] = [];
314
- for (const child of part.content) {
315
- if (child.kind !== "image") continue;
316
- const source = resolveImageSource(child.payload);
317
- const url = source?.url;
318
- if (url === undefined) continue;
319
- const dataUrl = splitDataUrl(url);
320
- if (dataUrl === undefined) continue;
321
- const bytes = base64ToBytes(dataUrl.data);
322
- if (bytes === undefined) continue;
323
- const mimeType = sniffImageMime(bytes) ?? dataUrl.mediaType;
324
- if (ALLOWED_IMAGE_MIME_TYPES[mimeType] !== true) continue;
325
- if (bytes.byteLength > MAX_IMAGE_BYTES) continue;
326
- images.push({ data: bytes, mimeType });
327
- }
328
- return images;
329
- }
330
-
331
- /** One Run's live state while this adapter is pumping it. */
332
- interface RunHandle {
333
- readonly stream: ValidatedHttp2BidiStream;
334
- readonly write: (message: Uint8Array) => Promise<void>;
335
- close(reason?: string): void;
336
- }
337
-
338
- class CursorAdapter implements ProviderAdapter {
339
- readonly provider_id = CURSOR_PROVIDER_ID;
340
-
341
- /**
342
- * Dispatches one canonical request.
343
- *
344
- * Two entry shapes, decided before anything is dialed:
345
- *
346
- * - **Continuation** — the request carries a tool result for a call this
347
- * process is holding. The already-open Run is resumed: no new Run, no new
348
- * account lease, no new pool slot.
349
- * - **Fresh turn** — a new Run. If the request carries complete assistant +
350
- * tool history but no live session (a restart, or an expired idle Run), the
351
- * whole history is rebased into the new Run so the model still sees the
352
- * conversation.
353
- */
354
- async *dispatch(
355
- request: CanonicalRequest,
356
- candidate: ProviderDispatchTarget,
357
- context: ProviderDispatchContext,
358
- ): AsyncIterable<CanonicalEvent> {
359
- const pending = readPendingToolResult(request, context);
360
- if (pending !== undefined && pending.length > 0) {
361
- yield* this.#continueRun(request, candidate, context, pending);
362
- return;
363
- }
364
- yield* this.#startRun(request, candidate, context);
365
- }
366
-
367
- /**
368
- * Resumes the held Run for one tool result.
369
- *
370
- * Every pending call must resolve to the *same* Run and the same account. A
371
- * request whose calls resolve to different Runs — or to no live Run at all —
372
- * is refused rather than partially applied: writing one result to another
373
- * account's Run would corrupt both conversations, and silently starting a new
374
- * Run would answer a tool call the model is still waiting on.
375
- */
376
- async *#continueRun(
377
- request: CanonicalRequest,
378
- candidate: ProviderDispatchTarget,
379
- context: ProviderDispatchContext,
380
- pending: readonly PendingToolResult[],
381
- ): AsyncIterable<CanonicalEvent> {
382
- const scope = context.continuation_scope;
383
- if (scope === undefined) {
384
- throw new GatewayError(
385
- "invalid_request",
386
- 400,
387
- "This tool result cannot be matched to a live Cursor Run; resend the complete conversation.",
388
- );
389
- }
390
- const resolved = pending.map((item) => cursorRunSessions.resolveCall(item.callId, scope));
391
- if (resolved.some((entry) => entry === undefined)) {
392
- throw new GatewayError(
393
- "invalid_request",
394
- 400,
395
- "This tool result refers to a Cursor tool call that is no longer pending. Resend the complete conversation.",
396
- { callIds: pending.map((item) => item.callId) },
397
- );
398
- }
399
- const runId = resolved[0]?.session.runId;
400
- if (runId === undefined) {
401
- throw new GatewayError("invalid_request", 400, "Cursor Run is no longer live");
402
- }
403
- if (resolved.some((entry) => entry?.session.runId !== runId)) {
404
- throw new GatewayError(
405
- "invalid_request",
406
- 400,
407
- "These tool results belong to different Cursor Runs; resend them one Run at a time.",
408
- );
409
- }
410
- const session = cursorRunSessions.get(runId);
411
- if (session === undefined) {
412
- throw new GatewayError("invalid_request", 400, "Cursor Run is no longer live");
413
- }
414
- const handle = this.#handles.get(runId);
415
- if (handle === undefined) {
416
- // The Run's socket is gone even though the record survived (a transport
417
- // failure the pump did not observe). Rebase rather than answer into a
418
- // dead stream.
419
- cursorRunSessions.terminate(runId, "Cursor Run transport is gone");
420
- yield* this.#startRun(request, candidate, context);
421
- return;
422
- }
423
- let sequence = 0;
424
- const next = (): number => (sequence += 1);
425
- try {
426
- for (const item of pending) {
427
- const entry = cursorRunSessions.resolveCall(item.callId, scope);
428
- if (entry === undefined) continue;
429
- await handle.write(
430
- buildMcpResult({
431
- execId: entry.call.execId,
432
- text: item.text,
433
- isError: item.isError,
434
- ...(item.images.length > 0 ? { images: item.images } : {}),
435
- }),
436
- );
437
- cursorRunSessions.clearPendingCall(runId, item.callId);
438
- }
439
- yield* this.#pump({
440
- request,
441
- candidate,
442
- context,
443
- handle,
444
- runId,
445
- session,
446
- next,
447
- continuation: true,
448
- });
449
- } finally {
450
- // The Run stays open only while it still owes an answer. A terminal close
451
- // is the session manager's single release path.
452
- if (cursorRunSessions.get(runId) === undefined) this.#handles.delete(runId);
453
- else if (!this.#awaitsToolResult && session.pending.size === 0) {
454
- cursorRunSessions.terminate(runId);
455
- this.#handles.delete(runId);
456
- }
457
- }
458
- }
459
-
460
- async *#startRun(
461
- request: CanonicalRequest,
462
- candidate: ProviderDispatchTarget,
463
- context: ProviderDispatchContext,
464
- ): AsyncIterable<CanonicalEvent> {
465
- const outboundHttp2 = context.outbound_http2;
466
- if (outboundHttp2 === undefined) {
467
- throw new GatewayError(
468
- "admission_unavailable",
469
- 503,
470
- "Cursor requires validated HTTP/2 egress; none is configured for this route",
471
- { providerId: CURSOR_PROVIDER_ID },
472
- );
473
- }
474
- const credential = readCredentialSecret(
475
- context.credential,
476
- "Cursor account credential is missing or empty; reconnect the account",
477
- );
478
- const origin = await this.#resolveOrigin(credential, context);
479
- const rawModelId = candidate.model_id.length > 0 ? candidate.model_id : request.model;
480
- const modelId = resolveCursorWireModelId(rawModelId);
481
- // Cursor declares per-model parameter tuples (effort / context / fast). A
482
- // fresh Run must carry the model's declared default tuple: with an empty
483
- // `parameters` the Run streams only heartbeats and never turns.
484
- const parameterValues = defaultParameterValues(
485
- await this.#rosterFor(credential, context).then((roster) =>
486
- roster.find((model) => model.id === modelId),
487
- ),
488
- );
489
- const images = await collectImages(request, context);
490
- const descriptors = toolDescriptorsFor(request.tools);
491
- const systemPrompt = systemPromptFrom(request);
492
- const conversationId = crypto.randomUUID();
493
- const runId = crypto.randomUUID();
494
- const opened = await openCursorRun({
495
- origin,
496
- credential,
497
- outboundHttp2,
498
- signal: context.abort_signal,
499
- });
500
- let closed = false;
501
- const handle: RunHandle = {
502
- stream: opened.stream,
503
- write: opened.write,
504
- close: (reason) => {
505
- if (closed) return;
506
- closed = true;
507
- if (reason !== undefined && reason.length > 0) {
508
- void opened.write(buildCancelAction(reason)).catch(() => undefined);
509
- }
510
- opened.stream.abort();
511
- },
512
- };
513
- this.#handles.set(runId, handle);
514
- const session: CursorRunSession = {
515
- runId,
516
- conversationId,
517
- scope: sessionScope(context),
518
- descriptors,
519
- pending: new Map(),
520
- calls: new Map(),
521
- usage: undefined,
522
- failure: undefined,
523
- };
524
- let sequence = 0;
525
- const next = (): number => (sequence += 1);
526
- try {
527
- await opened.write(
528
- buildRunRequest({
529
- text: liveUserText(request),
530
- modelId,
531
- conversationId,
532
- runId,
533
- ...(images.length > 0 ? { images } : {}),
534
- ...(descriptors.length > 0 ? { descriptors } : {}),
535
- ...(systemPrompt === undefined ? {} : { systemPrompt }),
536
- ...(parameterValues.length > 0 ? { parameterValues } : {}),
537
- // A fresh Run always rebases the client's history into its user text:
538
- // Cursor's own checkpoint cannot be reconstructed after a restart, and
539
- // `root_prompt_messages_json` would suppress the RequestContext rules.
540
- history: historyFromRequest(request),
541
- }),
542
- );
543
- // Registered before the first frame is pumped: a tool call can arrive on
544
- // the very first frame, and the Run must already be claimable.
545
- cursorRunSessions.register({
546
- session,
547
- releaseLease: context.lease_ownership?.transferToProviderSession(),
548
- close: (reason) => handle.close(reason),
549
- idleTtlMs: RUN_IDLE_TTL_MS,
550
- });
551
- yield* this.#pump({
552
- request,
553
- candidate,
554
- context,
555
- handle,
556
- runId,
557
- session,
558
- next,
559
- continuation: false,
560
- });
561
- } catch (error) {
562
- cursorRunSessions.terminate(runId, error instanceof Error ? error.message : undefined);
563
- this.#handles.delete(runId);
564
- throw error;
565
- } finally {
566
- if (cursorRunSessions.get(runId) === undefined) this.#handles.delete(runId);
567
- // A Run whose tool call is registered and unanswered is *held*, not
568
- // finished. The consumer may stop reading before the pump reaches its
569
- // terminal (a stream the client already has enough of), and tearing the
570
- // Run down there loses the call it just handed out — the next request's
571
- // tool result then resolves nothing.
572
- else if (!this.#awaitsToolResult && session.pending.size === 0) {
573
- cursorRunSessions.terminate(runId);
574
- this.#handles.delete(runId);
575
- }
576
- }
577
- }
578
-
579
- /** Live Run handles, keyed by run id. Process-local, like the sessions. */
580
- readonly #handles = new Map<string, RunHandle>();
581
- /** Set by the pump when the last terminal it emitted still owes a tool result. */
582
- #awaitsToolResult = false;
583
-
584
- /**
585
- * Resolves the Run origin for one account.
586
- *
587
- * Cursor routes an account to a region-specific host, and the wrong host
588
- * silently refuses the Run; the API host serves auth and catalog calls but is
589
- * not the origin `GetServerConfig` names for Run. A resolution failure is a
590
- * transport fault, never a fallback to the API host.
591
- */
592
- async #resolveOrigin(credential: string, context: ProviderDispatchContext): Promise<string> {
593
- try {
594
- const origin = await fetchCursorRunOrigin({
595
- credential,
596
- ...(context.outbound_fetch === undefined
597
- ? {}
598
- : { fetcher: context.outbound_fetch as unknown as typeof fetch }),
599
- signal: context.abort_signal,
600
- });
601
- if (!isAllowedAgentHost(new URL(origin).hostname)) {
602
- throw new Error("resolved origin is not a Cursor host");
603
- }
604
- return origin;
605
- } catch (error) {
606
- throw new GatewayError(
607
- "platform_unavailable",
608
- 502,
609
- `Cursor Run origin could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
610
- { providerId: CURSOR_PROVIDER_ID },
611
- "upstream",
612
- );
613
- }
614
- }
615
-
616
- /**
617
- * The account's model roster, for the default parameter tuple.
618
- *
619
- * Cached per credential and never fatal: a Run whose model is not in the
620
- * roster (or whose roster fetch failed) still runs with no parameters rather
621
- * than failing the dispatch. The tuple is an optimization, not a requirement.
622
- */
623
- async #rosterFor(
624
- credential: string,
625
- context: ProviderDispatchContext,
626
- ): Promise<readonly CursorModelInfo[]> {
627
- const cached = rosterCache.get(credential);
628
- if (cached !== undefined && Date.now() - cached.fetchedAt < CURSOR_ROSTER_TTL_MS) {
629
- return cached.roster;
630
- }
631
- try {
632
- const roster = await fetchCursorModels({
633
- credential,
634
- ...(context.outbound_fetch === undefined
635
- ? {}
636
- : { fetcher: context.outbound_fetch as unknown as typeof fetch }),
637
- signal: context.abort_signal,
638
- });
639
- rosterCache.set(credential, { roster, fetchedAt: Date.now() });
640
- return roster;
641
- } catch {
642
- return cached?.roster ?? [];
643
- }
644
- }
645
-
646
- async *#pump(input: {
647
- readonly request: CanonicalRequest;
648
- readonly candidate: ProviderDispatchTarget;
649
- readonly context: ProviderDispatchContext;
650
- readonly handle: RunHandle;
651
- readonly runId: string;
652
- readonly session: CursorRunSession;
653
- readonly next: () => number;
654
- readonly continuation: boolean;
655
- }): AsyncIterable<CanonicalEvent> {
656
- const { context, handle, runId, session } = input;
657
- let sawToolCall = false;
658
- let terminalUsage: UsageRecord | undefined;
659
- let stopReason: CanonicalStopReason = "stop";
660
- let failure: GatewayError | undefined;
661
- const unsubscribe = handle.stream.onTerminal((reason: Http2BidiEndReason) => {
662
- if (reason.kind === "local-close") return;
663
- const mapped = cursorRunTerminationError({ stream: handle.stream, reason });
664
- if (mapped !== undefined) failure = mapped;
665
- });
666
- // The Cursor edge answers a Run only after it sees a client heartbeat (the
667
- // donor's continuation policy pins `heartbeatMs` to 5s). One heartbeat is
668
- // sent eagerly on open here and then on the interval; clearing it when the
669
- // body reader ends is safe because the reader's end already means the Run
670
- // is over.
671
- const beat = (): void =>
672
- void handle.write(buildHeartbeat()).catch(() => undefined);
673
- const onAbort = (): void => clearInterval(heartbeatTimer);
674
- const heartbeatTimer = setInterval(
675
- beat,
676
- CURSOR_HEARTBEAT_INTERVAL_OVERRIDE ?? CURSOR_HEARTBEAT_INTERVAL_MS,
677
- );
678
- void beat();
679
- context.abort_signal.addEventListener("abort", onAbort, { once: true });
680
- if (context.abort_signal.aborted) onAbort();
681
- heartbeatTimer.unref();
682
- try {
683
- for await (const message of decodeServerMessages(handle.stream.body)) {
684
- if (context.abort_signal.aborted) {
685
- failure = new GatewayError("transport_closed", 499, "request was cancelled");
686
- break;
687
- }
688
- const execServer = recordOf(message["exec_server_message"]);
689
- if (execServer !== undefined) {
690
- const handled = await this.#handleExecServerMessage(execServer, session, handle);
691
- if (handled.kind !== "tool_call") continue;
692
- const callId = `call_cursor_${runId}_${handled.execId}`;
693
- const scope = context.continuation_scope;
694
- if (scope === undefined) {
695
- // Without a scope, no later request could be authorized to answer
696
- // this call, so the Run must not be held open for one.
697
- await handle.write(buildMcpRejected(handled.execId, "tool call is not answerable"));
698
- continue;
699
- }
700
- if (!cursorRunSessions.registerPendingCall(runId, { callId, execId: handled.execId, toolName: handled.toolName })) {
701
- await handle.write(buildMcpRejected(handled.execId, "tool call is not answerable"));
702
- continue;
703
- }
704
- sawToolCall = true;
705
- yield {
706
- type: "tool_call_delta",
707
- sequence_number: input.next(),
708
- call_id: callId,
709
- name: handled.toolName,
710
- arguments_delta: handled.arguments,
711
- };
712
- continue;
713
- }
714
-
715
- const kv = recordOf(message["kv_server_message"]);
716
- if (kv !== undefined && (await this.#handleKvMessage(kv, handle))) continue;
717
-
718
- const interaction = recordOf(message["interaction_update"]);
719
- if (interaction === undefined) continue;
720
-
721
- const textDelta = recordOf(interaction["text_delta"]);
722
- if (textDelta !== undefined) {
723
- const text = typeof textDelta["text"] === "string" ? textDelta["text"] : "";
724
- if (text.length > 0) {
725
- yield {
726
- type: "content_delta",
727
- sequence_number: input.next(),
728
- content: { kind: "text", text },
729
- };
730
- }
731
- continue;
732
- }
733
- const thinking = recordOf(interaction["thinking_delta"]);
734
- if (thinking !== undefined) {
735
- const text = typeof thinking["text"] === "string" ? thinking["text"] : "";
736
- if (text.length > 0) {
737
- yield {
738
- type: "content_delta",
739
- sequence_number: input.next(),
740
- content: { kind: "reasoning", payload: null, summary: text },
741
- };
742
- }
743
- continue;
744
- }
745
- // `tool_call_started` / `tool_call_completed` are display notifications:
746
- // they report what Cursor's host did, not a call this gateway executes.
747
- // Only `turn_ended` is authoritative for the turn's end and its usage.
748
- const turnEnded = recordOf(interaction["turn_ended"]);
749
- if (turnEnded !== undefined) {
750
- terminalUsage = usageFromTurnEnded(turnEnded);
751
- break;
752
- }
753
- }
754
- } catch (error) {
755
- failure =
756
- error instanceof GatewayError
757
- ? error
758
- : new GatewayError(
759
- "platform_unavailable",
760
- 502,
761
- `Cursor Run failed: ${error instanceof Error ? error.message : String(error)}`,
762
- { providerId: CURSOR_PROVIDER_ID },
763
- "upstream",
764
- );
765
- } finally {
766
- unsubscribe();
767
- clearInterval(heartbeatTimer);
768
- context.abort_signal.removeEventListener("abort", onAbort);
769
- }
770
-
771
- if (failure !== undefined) {
772
- this.#awaitsToolResult = false;
773
- cursorRunSessions.terminate(runId, failure.message);
774
- yield {
775
- type: "terminal",
776
- sequence_number: input.next(),
777
- state: "failed",
778
- stop_reason: "error",
779
- // The refusal text, so the caller sees "you hit your usage limit"
780
- // instead of a terminal with no reason attached.
781
- stop_details: { code: failure.code, status: failure.status, message: failure.message },
782
- ...(terminalUsage === undefined ? {} : { usage: terminalUsage }),
783
- };
784
- return;
785
- }
786
- if (sawToolCall) {
787
- // The interim terminal: the client must answer the tool call, and this Run
788
- // stays open for it. `usage_deferred` tells the settle path not to charge
789
- // estimated tokens for a turn whose real usage arrives with the Run's
790
- // `turn_ended` on the continuation request.
791
- this.#awaitsToolResult = true;
792
- cursorRunSessions.touch(runId);
793
- yield {
794
- type: "terminal",
795
- sequence_number: input.next(),
796
- state: "complete",
797
- stop_reason: "tool_use",
798
- usage_deferred: true,
799
- };
800
- return;
801
- }
802
- this.#awaitsToolResult = false;
803
- cursorRunSessions.terminate(runId);
804
- yield {
805
- type: "terminal",
806
- sequence_number: input.next(),
807
- state: "complete",
808
- stop_reason: stopReason,
809
- ...(terminalUsage === undefined ? {} : { usage: terminalUsage }),
810
- };
811
- }
812
-
813
- /**
814
- * Handles one Cursor exec request.
815
- *
816
- * The control paths are mandatory, not optional: `request_context_args`,
817
- * `mcp_state_exec_args`, and the KV channel all stall the Run forever if the
818
- * client never answers. Only `mcp_args` — and only for a declared tool —
819
- * becomes a tool call; every other variant is refused rather than executed.
820
- */
821
- async #handleExecServerMessage(
822
- execServer: Record<string, unknown>,
823
- session: CursorRunSession,
824
- handle: RunHandle,
825
- ): Promise<{ kind: "handled" } | { kind: "tool_call"; execId: number; toolName: string; arguments: string }> {
826
- const execId = typeof execServer["id"] === "number" ? execServer["id"] : 0;
827
- if (recordOf(execServer["request_context_args"]) !== undefined) {
828
- await handle.write(buildRequestContextResult(execId, probeContextFor(session.descriptors)));
829
- return { kind: "handled" };
830
- }
831
- if (recordOf(execServer["mcp_state_exec_args"]) !== undefined) {
832
- await handle.write(buildMcpStateResult(execId, session.descriptors));
833
- return { kind: "handled" };
834
- }
835
- const mcpArgs = recordOf(execServer["mcp_args"]);
836
- if (mcpArgs !== undefined) {
837
- const call = readExecutableMcpCall(mcpArgs, session.descriptors);
838
- if (call === undefined) {
839
- await handle.write(
840
- buildMcpRejected(execId, "this tool is not one the request declared"),
841
- );
842
- return { kind: "handled" };
843
- }
844
- return { kind: "tool_call", execId, toolName: call.toolName, arguments: call.arguments };
845
- }
846
- // Every other exec variant is a Cursor host tool (shell, read, edit, glob,
847
- // subagent, …). Refusing it is the security boundary: the gateway does not
848
- // run filesystem or shell operations the client never asked for.
849
- await handle.write(buildMcpRejected(execId, "Cursor host tools are not executed by this gateway"));
850
- return { kind: "handled" };
851
- }
852
-
853
- /** Answers one KV blob request; `false` when the message carried none. */
854
- async #handleKvMessage(kv: Record<string, unknown>, handle: RunHandle): Promise<boolean> {
855
- const id = typeof kv["id"] === "number" ? kv["id"] : 0;
856
- if (recordOf(kv["set_blob_args"]) !== undefined) {
857
- await handle.write(buildKvSetBlobResult(id));
858
- return true;
859
- }
860
- const getArgs = recordOf(kv["get_blob_args"]);
861
- if (getArgs !== undefined) {
862
- const blobId = getArgs["blob_id"];
863
- const data = blobId instanceof Uint8Array ? blobId : new Uint8Array(0);
864
- await handle.write(buildKvGetBlobResult(id, data));
865
- return true;
866
- }
867
- return false;
868
- }
869
- }
870
-
871
- /** RequestContext Cursor receives in reply to a context probe. */
872
- function probeContextFor(descriptors: readonly CursorToolDescriptor[]): Record<string, unknown> {
873
- return {
874
- tools: descriptors.map((descriptor) => ({
875
- name: descriptor.name,
876
- description: descriptor.description,
877
- input_schema: descriptor.inputSchema,
878
- provider_identifier: descriptor.providerIdentifier,
879
- tool_name: descriptor.toolName,
880
- })),
881
- mcp_info_complete: true,
882
- rules_info_complete: true,
883
- env_info_complete: true,
884
- };
885
- }
886
-
887
- /**
888
- * The continuation scope of one dispatch.
889
- *
890
- * Derived from the route's own account identity plus the caller's tenant and API
891
- * key. A route with no account cannot scope a continuation, and a Run that
892
- * cannot be scoped must not be held open across requests at all.
893
- */
894
- function sessionScope(context: ProviderDispatchContext): CursorContinuationScope {
895
- const scope: ProviderContinuationScope | undefined = context.continuation_scope;
896
- return {
897
- tenantId: scope?.tenantId ?? "",
898
- apiKeyId: scope?.apiKeyId ?? "",
899
- providerId: CURSOR_PROVIDER_ID,
900
- providerAccountId: scope?.providerAccountId ?? context.credential.account_id ?? "",
901
- };
902
- }
903
-
904
- /** One tool result the client sent back for a call this process is holding. */
905
- interface PendingToolResult {
906
- readonly callId: string;
907
- readonly text: string;
908
- readonly isError: boolean;
909
- readonly images: readonly ResultImage[];
910
- }
911
-
912
- /**
913
- * Reads the tool results this request wants delivered to a held Run.
914
- *
915
- * Only ids in the `call_cursor_<run-id>_<exec-id>` shape are considered: any
916
- * other id is the client's own, belongs to a different provider's conversation,
917
- * and must not be sent to Cursor.
918
- */
919
- function readPendingToolResult(
920
- request: CanonicalRequest,
921
- _context: ProviderDispatchContext,
922
- ): PendingToolResult[] | undefined {
923
- const results: PendingToolResult[] = [];
924
- for (const message of request.messages) {
925
- for (const part of message.content) {
926
- if (part.kind !== "toolResult") continue;
927
- if (!part.call_id.startsWith("call_cursor_")) continue;
928
- const text =
929
- typeof part.content === "string"
930
- ? part.content
931
- : part.content.map((child) => (child.kind === "text" ? child.text : "")).join("\n");
932
- results.push({
933
- callId: part.call_id,
934
- text,
935
- isError: part.is_error === true,
936
- images: resultImages(part),
937
- });
938
- }
939
- }
940
- return results.length > 0 ? results : undefined;
941
- }
942
-
943
- /** Reconstructs one tool result's text and images for the adapter's tests. */
944
- export { decodeStructEntriesToJson, decodeValueToJson };
945
-
946
- export function createCursorAdapter(): ProviderAdapter {
947
- return new CursorAdapter();
948
- }
949
-
950
- export const cursorAdapter: ProviderAdapter = createCursorAdapter();
951
- /** Maps virtual/canonical client model IDs to upstream Cursor wire IDs. */
952
- function resolveCursorWireModelId(rawModelId: string): string {
953
- const id = rawModelId.trim().toLowerCase();
954
- // Only `auto` is an alias: Cursor's own roster names its Auto model
955
- // `default`. `grok-4.6` is already the upstream id, and rewriting it to
956
- // `cursor-grok-4.6` made every Grok Run answer "AI Model Not Found".
957
- if (id === "auto") return "default";
958
- return rawModelId;
959
- }
960
-
1
+ // The canonical Cursor adapter.
2
+ //
3
+ // Cursor speaks Connect-RPC over protobuf on a bidirectional HTTP/2 stream, so
4
+ // this adapter frames its own protocol instead of going through a wire codec. It
5
+ // owns the whole tool loop in both directions:
6
+ //
7
+ // - a Cursor executable tool request becomes a client `tool_call_delta`;
8
+ // - the client's next request carries that tool's result and the *same*
9
+ // upstream Run answers it, so the model continues where it left off.
10
+ //
11
+ // Two rules govern what is executed. Only Cursor's authoritative
12
+ // `exec_server_message.mcp_args` is a tool call — `interaction_update`'s
13
+ // `tool_call_started` / `tool_call_completed` are display notifications, and
14
+ // Cursor's native shell/filesystem/workspace tools are never run here. And an
15
+ // `mcp_args` request is executable only when its namespace and function name
16
+ // match a tool the current request declared; anything else is answered
17
+ // `rejected` so the model can continue without the gateway ever touching a
18
+ // filesystem or a shell on the caller's behalf.
19
+ import { GatewayError } from "../../../protocol/gateway-error";
20
+ import type {
21
+ CanonicalEvent,
22
+ CanonicalRequest,
23
+ CanonicalStopReason,
24
+ ContentPart,
25
+ UsageRecord,
26
+ } from "../../../protocol/canonical-model";
27
+ import { resolveImageSource, splitDataUrl } from "../../../protocol/codecs";
28
+ import { IMAGE_CLAMP_MAX_BYTES } from "../../../protocol/image-clamp";
29
+ import type { ProviderAdapter, ProviderDispatchContext, ProviderDispatchTarget } from "../../provider-contracts";
30
+ import { readCredentialSecret } from "../../provider-contracts";
31
+ import type { Http2BidiEndReason, ValidatedHttp2BidiStream } from "../../../network/http2-bidi";
32
+ import type { ProviderContinuationScope } from "../../provider-contracts";
33
+ import { CURSOR_PROVIDER_ID, RUN_IDLE_TTL_MS, isAllowedAgentHost } from "./cursor-shared";
34
+ import {
35
+ CURSOR_MCP_NAMESPACE as MCP_NAMESPACE,
36
+ buildAllowlistPrecheckResult,
37
+ buildCancelAction,
38
+ buildExecStreamClose,
39
+ buildExecThrow,
40
+ buildInteractionResponse,
41
+ buildKvGetBlobResult,
42
+ buildKvSetBlobResult,
43
+ buildMcpRejected,
44
+ buildMcpResult,
45
+ buildMcpStateResult,
46
+ buildHeartbeat,
47
+ buildRequestContextResult,
48
+ buildRunRequest,
49
+ historyFromRequest,
50
+ liveUserText,
51
+ systemPromptFrom,
52
+ toolDescriptorsFor,
53
+ type CursorImageInput,
54
+ type CursorToolDescriptor,
55
+ } from "./cursor-request";
56
+ import { cursorRunTerminationError, decodeServerMessages, openCursorRun } from "./cursor-http2";
57
+ import { cursorRunSessions } from "./cursor-session-manager";
58
+ import type { CursorContinuationScope, CursorRunSession } from "./cursor-session-manager";
59
+ import { fetchCursorRunOrigin } from "./cursor-catalog";
60
+ import { defaultParameterValues, fetchCursorModels } from "./cursor-catalog";
61
+ import type { CursorModelInfo } from "./cursor-catalog";
62
+ import { decodeValueToJson, decodeStructEntriesToJson } from "./protocol/struct";
63
+
64
+ /**
65
+ * Client-heartbeat cadence for every Cursor Run.
66
+ *
67
+ * The edge answers a turn only after it has seen at least one client
68
+ * heartbeat, and the donor pins this to 5s (`heartbeatMs` in its continuation
69
+ * policy). A longer cadence is a Run that streams heartbeats and never
70
+ * answers; a shorter one is traffic the upstream does not ask for.
71
+ */
72
+ const CURSOR_HEARTBEAT_INTERVAL_MS = 5_000;
73
+
74
+ /**
75
+ * How long one account's model roster is reused for `parameters` lookups.
76
+ *
77
+ * A Run needs the model's declared default parameter tuple, but the roster is
78
+ * per-account and moves rarely, so it is fetched once and cached rather than on
79
+ * every dispatch.
80
+ */
81
+ const CURSOR_ROSTER_TTL_MS = 300_000;
82
+ /** credential → { roster, fetchedAt } for the parameter lookup. */
83
+ const rosterCache = new Map<string, { roster: readonly CursorModelInfo[]; fetchedAt: number }>();
84
+ /** Client-heartbeat cadence the Cursor edge expects (donor `heartbeatMs`). */
85
+ const CURSOR_HEARTBEAT_INTERVAL_OVERRIDE = (() => {
86
+ const raw = process.env.CARTETHYIA_CURSOR_HEARTBEAT_MS?.trim();
87
+ if (raw === undefined || raw === "") return undefined;
88
+ const parsed = Number(raw);
89
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined;
90
+ })();
91
+
92
+ /** Image MIME types Cursor accepts in `selected_images`. */
93
+ const ALLOWED_IMAGE_MIME_TYPES: Record<string, true> = {
94
+ "image/png": true,
95
+ "image/jpeg": true,
96
+ "image/webp": true,
97
+ "image/gif": true,
98
+ };
99
+
100
+ /** Largest image payload accepted, matching the shared pre-send clamp. */
101
+ const MAX_IMAGE_BYTES = IMAGE_CLAMP_MAX_BYTES;
102
+
103
+ /** Identifies an allowed image payload from its magic bytes. */
104
+ function sniffImageMime(bytes: Uint8Array): string | undefined {
105
+ if (bytes.byteLength >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
106
+ return "image/png";
107
+ }
108
+ if (bytes.byteLength >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
109
+ return "image/jpeg";
110
+ }
111
+ if (
112
+ bytes.byteLength >= 6 &&
113
+ bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38
114
+ ) {
115
+ return "image/gif";
116
+ }
117
+ if (
118
+ bytes.byteLength >= 12 &&
119
+ bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
120
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
121
+ ) {
122
+ return "image/webp";
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ function base64ToBytes(data: string): Uint8Array | undefined {
128
+ try {
129
+ const binary = atob(data);
130
+ const out = new Uint8Array(binary.length);
131
+ for (let index = 0; index < binary.length; index += 1) out[index] = binary.charCodeAt(index);
132
+ return out;
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Validates one decoded image payload.
140
+ *
141
+ * The MIME type is verified by sniffing and only then compared against the
142
+ * declared type: a caller-supplied `content-type` or data-URL media type is a
143
+ * claim, and sending bytes the upstream will reject — or bytes that are not an
144
+ * image at all — fails the whole request rather than being dropped silently.
145
+ */
146
+ function finishImage(bytes: Uint8Array): CursorImageInput {
147
+ const sniffed = sniffImageMime(bytes);
148
+ const mimeType = sniffed;
149
+ if (mimeType === undefined || ALLOWED_IMAGE_MIME_TYPES[mimeType] !== true) {
150
+ throw new GatewayError(
151
+ "invalid_request",
152
+ 400,
153
+ "Cursor accepts PNG, JPEG, WebP and GIF image input only",
154
+ );
155
+ }
156
+ if (bytes.byteLength > MAX_IMAGE_BYTES) {
157
+ throw new GatewayError(
158
+ "invalid_request",
159
+ 400,
160
+ `Cursor image input exceeds the ${MAX_IMAGE_BYTES}-byte limit`,
161
+ );
162
+ }
163
+ return { data: bytes, mimeType };
164
+ }
165
+
166
+ /** Reads one canonical image part into Cursor `selected_images` bytes. */
167
+ async function resolveImageInput(
168
+ part: Extract<ContentPart, { kind: "image" }>,
169
+ context: ProviderDispatchContext,
170
+ ): Promise<CursorImageInput> {
171
+ const source = resolveImageSource(part.payload);
172
+ const url = source?.url;
173
+ if (url === undefined) {
174
+ throw new GatewayError(
175
+ "invalid_request",
176
+ 400,
177
+ "Cursor image input requires inline image data or an HTTPS image URL",
178
+ );
179
+ }
180
+ const dataUrl = splitDataUrl(url);
181
+ if (dataUrl !== undefined) {
182
+ const bytes = base64ToBytes(dataUrl.data);
183
+ if (bytes === undefined) {
184
+ throw new GatewayError("invalid_request", 400, "Cursor image attachment is not valid base64");
185
+ }
186
+ return finishImage(bytes);
187
+ }
188
+ const parsed = new URL(url);
189
+ if (parsed.protocol !== "https:") {
190
+ throw new GatewayError(
191
+ "invalid_request",
192
+ 400,
193
+ "Cursor image URLs must be HTTPS; private and non-TLS sources are not fetched",
194
+ );
195
+ }
196
+ const fetcher = context.outbound_attachment_fetch;
197
+ if (fetcher === undefined) {
198
+ throw new GatewayError(
199
+ "invalid_request",
200
+ 400,
201
+ "Cursor has no validated egress to fetch this image URL",
202
+ );
203
+ }
204
+ // Redirects are not followed: reject the response rather than letting a
205
+ // public URL redirect the attachment fetch to a private address.
206
+ const response = await fetcher(url, {
207
+ method: "GET",
208
+ redirect: "manual",
209
+ signal: context.abort_signal,
210
+ });
211
+ if (!response.ok) {
212
+ throw new GatewayError(
213
+ "invalid_request",
214
+ 400,
215
+ `Cursor could not fetch this image URL (${response.status})`,
216
+ );
217
+ }
218
+ const body = new Uint8Array(await response.arrayBuffer());
219
+ if (body.byteLength > MAX_IMAGE_BYTES) {
220
+ throw new GatewayError(
221
+ "invalid_request",
222
+ 400,
223
+ `Cursor image input exceeds the ${MAX_IMAGE_BYTES}-byte limit`,
224
+ );
225
+ }
226
+ return finishImage(body);
227
+ }
228
+
229
+ /** Collects every canonical image part of a request into Cursor images. */
230
+ async function collectImages(
231
+ request: CanonicalRequest,
232
+ context: ProviderDispatchContext,
233
+ ): Promise<CursorImageInput[]> {
234
+ const images: CursorImageInput[] = [];
235
+ for (const message of request.messages) {
236
+ for (const part of message.content) {
237
+ if (part.kind !== "image") continue;
238
+ images.push(await resolveImageInput(part, context));
239
+ }
240
+ }
241
+ return images;
242
+ }
243
+
244
+ function usageFromTurnEnded(turnEnded: Record<string, unknown>): UsageRecord {
245
+ const count = (value: unknown): number =>
246
+ typeof value === "number" && Number.isFinite(value) ? value : 0;
247
+ const input = count(turnEnded["input_tokens"]);
248
+ const output = count(turnEnded["output_tokens"]);
249
+ const cacheRead = count(turnEnded["cache_read"]);
250
+ return {
251
+ input_tokens: input,
252
+ cached_input_tokens: cacheRead,
253
+ cache_write_tokens: count(turnEnded["cache_write"]),
254
+ uncached_input_tokens: Math.max(0, input - cacheRead),
255
+ output_tokens: output,
256
+ reasoning_tokens: count(turnEnded["reasoning_tokens"]),
257
+ estimated_cost: null,
258
+ total_tokens: input + output,
259
+ };
260
+ }
261
+
262
+ function recordOf(value: unknown): Record<string, unknown> | undefined {
263
+ return value !== null && typeof value === "object" && !Array.isArray(value)
264
+ ? (value as Record<string, unknown>)
265
+ : undefined;
266
+ }
267
+
268
+ function nonEmptyStringOf(value: unknown): string | undefined {
269
+ return typeof value === "string" && value.length > 0 ? value : undefined;
270
+ }
271
+
272
+ /**
273
+ * Reads one inbound `mcp_args` request.
274
+ *
275
+ * Returns `undefined` for anything that is not an executable call. Cursor's own
276
+ * host tools arrive under their own server identifiers (or none at all) and are
277
+ * never executed here — refusing them is what keeps a model's request from
278
+ * turning into a shell command on the gateway's host.
279
+ */
280
+ function readExecutableMcpCall(
281
+ mcpArgs: Record<string, unknown>,
282
+ descriptors: readonly CursorToolDescriptor[],
283
+ ): { toolName: string; arguments: string } | undefined {
284
+ if (nonEmptyStringOf(mcpArgs["provider_identifier"]) !== MCP_NAMESPACE) return undefined;
285
+ const toolName = nonEmptyStringOf(mcpArgs["tool_name"]);
286
+ if (toolName === undefined) return undefined;
287
+ if (!descriptors.some((descriptor) => descriptor.toolName === toolName)) return undefined;
288
+ return { toolName, arguments: decodeMcpArguments(mcpArgs) };
289
+ }
290
+
291
+ /**
292
+ * Decodes Cursor's `McpArgs.args` — a `map<string, google.protobuf.Value>`
293
+ * carried as repeated map-entry messages — into a canonical arguments string.
294
+ */
295
+ function decodeMcpArguments(mcpArgs: Record<string, unknown>): string {
296
+ const entries = mcpArgs["args"];
297
+ const raw = Array.isArray(entries)
298
+ ? entries.filter((entry): entry is Uint8Array => entry instanceof Uint8Array)
299
+ : [];
300
+ if (raw.length === 0) return "{}";
301
+ try {
302
+ return JSON.stringify(decodeStructEntriesToJson(raw));
303
+ } catch {
304
+ return "{}";
305
+ }
306
+ }
307
+
308
+ /** One image-bearing block of a tool result, ready for `McpToolResultContentItem`. */
309
+ interface ResultImage {
310
+ readonly data: Uint8Array;
311
+ readonly mimeType: string;
312
+ }
313
+
314
+ /** Reads the images out of one canonical tool result. */
315
+ function resultImages(part: Extract<ContentPart, { kind: "toolResult" }>): ResultImage[] {
316
+ if (typeof part.content === "string") return [];
317
+ const images: ResultImage[] = [];
318
+ for (const child of part.content) {
319
+ if (child.kind !== "image") continue;
320
+ const source = resolveImageSource(child.payload);
321
+ const url = source?.url;
322
+ if (url === undefined) continue;
323
+ const dataUrl = splitDataUrl(url);
324
+ if (dataUrl === undefined) continue;
325
+ const bytes = base64ToBytes(dataUrl.data);
326
+ if (bytes === undefined) continue;
327
+ const mimeType = sniffImageMime(bytes) ?? dataUrl.mediaType;
328
+ if (ALLOWED_IMAGE_MIME_TYPES[mimeType] !== true) continue;
329
+ if (bytes.byteLength > MAX_IMAGE_BYTES) continue;
330
+ images.push({ data: bytes, mimeType });
331
+ }
332
+ return images;
333
+ }
334
+
335
+ /** One Run's live state while this adapter is pumping it. */
336
+ interface RunHandle {
337
+ readonly stream: ValidatedHttp2BidiStream;
338
+ readonly write: (message: Uint8Array) => Promise<void>;
339
+ close(reason?: string): void;
340
+ }
341
+
342
+ /** One decoded server frame, ready for pump consumption. */
343
+ type RunFrame = Record<string, unknown>;
344
+
345
+ /**
346
+ * The reader's end-of-stream marker: every enumeration over the frame queue
347
+ * resolves `done` here, so a pump nested across requests finalizes on the
348
+ * same signal the raw reader produced.
349
+ */
350
+ interface RunStreamEnd {
351
+ readonly __streamEnd: Http2BidiEndReason;
352
+ readonly failure: GatewayError | undefined;
353
+ }
354
+
355
+ function isRunStreamEnd(item: unknown): item is RunStreamEnd {
356
+ return typeof item === "object" && item !== null && "__streamEnd" in item;
357
+ }
358
+
359
+ type RunFrameItem = RunFrame | RunStreamEnd;
360
+
361
+ function isHeartbeatFrame(item: unknown): boolean {
362
+ if (item === null || typeof item !== "object" || isRunStreamEnd(item)) return false;
363
+ const update = (item as RunFrame)["interaction_update"];
364
+ if (update === null || typeof update !== "object") return false;
365
+ return "heartbeat" in (update as Record<string, unknown>);
366
+ }
367
+
368
+ /**
369
+ * One Run's shared frame queue.
370
+ *
371
+ * A single read loop decodes `AgentServerMessage` frames for the Run's whole
372
+ * lifetime, independent of any single request. A pump subscribes for the leg
373
+ * it serves; when that request ends, frames keep queueing (the reader and the
374
+ * client-heartbeat cadence both stay alive), and the Run's next leg drains
375
+ * them. Without this, a request that ends between the tool call and its
376
+ * answer disposed the body reader and the heartbeat timer, leaving the Cursor
377
+ * edge fed by nothing and the turn waiting on a result nobody could deliver.
378
+ */
379
+ class RunFrameStream {
380
+ private items: RunFrameItem[] = [];
381
+ private waiters: Array<{
382
+ resolve: (result: IteratorResult<RunFrameItem>) => void;
383
+ reject: (error: unknown) => void;
384
+ }> = [];
385
+ private closed = false;
386
+ private subscribers = 0;
387
+
388
+ /** Bounds the gap between legs: heartbeats are droppable, semantics are not. */
389
+ private static readonly MAX_IDLE_ITEMS = 2_048;
390
+
391
+ push(message: RunFrame): void {
392
+ if (this.closed) return;
393
+ if (this.subscribers === 0 && this.items.length > 0) {
394
+ // An idle Run only accumulates keepalives; drop them first so a bounded
395
+ // queue survives a long tool-result gap without losing real frames.
396
+ while (this.items.length >= RunFrameStream.MAX_IDLE_ITEMS && isHeartbeatFrame(this.items[0])) {
397
+ this.items.shift();
398
+ }
399
+ }
400
+ this.items.push(message);
401
+ this.#drain();
402
+ }
403
+
404
+ end(reason: Http2BidiEndReason, failure: GatewayError | undefined): void {
405
+ if (this.closed) return;
406
+ this.closed = true;
407
+ this.items.push({ __streamEnd: reason, failure });
408
+ this.#drain();
409
+ }
410
+
411
+ subscribe(): AsyncIterableIterator<RunFrameItem> {
412
+ this.subscribers += 1;
413
+ const self = this;
414
+ const next = async (): Promise<IteratorResult<RunFrameItem>> => {
415
+ const front = self.items.shift();
416
+ if (front !== undefined) return { done: false, value: front };
417
+ if (self.closed) {
418
+ self.#release();
419
+ return { done: true, value: undefined };
420
+ }
421
+ return await new Promise<IteratorResult<RunFrameItem>>((resolve, reject) => {
422
+ self.waiters.push({ resolve, reject });
423
+ });
424
+ };
425
+ const releaseOnReturn = (): Promise<IteratorResult<RunFrameItem>> => {
426
+ self.#release();
427
+ self.#flushWaiters({ done: true, value: undefined });
428
+ return Promise.resolve({ done: true, value: undefined });
429
+ };
430
+ const iterator: AsyncIterableIterator<RunFrameItem> = {
431
+ next,
432
+ return: releaseOnReturn,
433
+ [Symbol.asyncIterator](): AsyncIterableIterator<RunFrameItem> {
434
+ return iterator;
435
+ },
436
+ };
437
+ return iterator;
438
+ }
439
+
440
+ #release(): void {
441
+ this.subscribers = Math.max(0, this.subscribers - 1);
442
+ }
443
+
444
+ /** Resolves parked consumers with queued items, in order. */
445
+ #drain(): void {
446
+ while (this.waiters.length > 0 && this.items.length > 0) {
447
+ const waiter = this.waiters.shift();
448
+ waiter?.resolve({ done: false, value: this.items.shift() as RunFrameItem });
449
+ }
450
+ if (this.closed && this.items.length === 0) {
451
+ this.#flushWaiters({ done: true, value: undefined });
452
+ }
453
+ }
454
+
455
+ #flushWaiters(result: IteratorResult<RunFrameItem>): void {
456
+ for (const waiter of this.waiters) waiter.resolve(result);
457
+ this.waiters = [];
458
+ }
459
+ }
460
+
461
+ class CursorAdapter implements ProviderAdapter {
462
+ readonly provider_id = CURSOR_PROVIDER_ID;
463
+
464
+ /**
465
+ * Dispatches one canonical request.
466
+ *
467
+ * Two entry shapes, decided before anything is dialed:
468
+ *
469
+ * - **Continuation** — the request carries a tool result for a call this
470
+ * process is holding. The already-open Run is resumed: no new Run, no new
471
+ * account lease, no new pool slot.
472
+ * - **Fresh turn** — a new Run. If the request carries complete assistant +
473
+ * tool history but no live session (a restart, or an expired idle Run), the
474
+ * whole history is rebased into the new Run so the model still sees the
475
+ * conversation.
476
+ */
477
+ async *dispatch(
478
+ request: CanonicalRequest,
479
+ candidate: ProviderDispatchTarget,
480
+ context: ProviderDispatchContext,
481
+ ): AsyncIterable<CanonicalEvent> {
482
+ const pending = readPendingToolResult(request, context);
483
+ if (pending !== undefined && pending.length > 0) {
484
+ yield* this.#continueRun(request, candidate, context, pending);
485
+ return;
486
+ }
487
+ yield* this.#startRun(request, candidate, context);
488
+ }
489
+
490
+ /**
491
+ * Resumes the held Run for one tool result.
492
+ *
493
+ * Every pending call must resolve to the *same* Run and the same account. A
494
+ * request whose calls resolve to different Runs — or to no live Run at all —
495
+ * is refused rather than partially applied: writing one result to another
496
+ * account's Run would corrupt both conversations, and silently starting a new
497
+ * Run would answer a tool call the model is still waiting on.
498
+ */
499
+ async *#continueRun(
500
+ request: CanonicalRequest,
501
+ candidate: ProviderDispatchTarget,
502
+ context: ProviderDispatchContext,
503
+ pending: readonly PendingToolResult[],
504
+ ): AsyncIterable<CanonicalEvent> {
505
+ const scope = context.continuation_scope;
506
+ if (scope === undefined) {
507
+ throw new GatewayError(
508
+ "invalid_request",
509
+ 400,
510
+ "This tool result cannot be matched to a live Cursor Run; resend the complete conversation.",
511
+ );
512
+ }
513
+ const resolvedAll = pending.map((item) => cursorRunSessions.resolveCall(item.callId, scope));
514
+ // Results previous legs already answered resolve nothing: the replayed
515
+ // conversation carries them as history, not as an unanswered call. When
516
+ // *no* call resolves AND the call id live nowhere in this process — a
517
+ // gateway restart between legs, an evicted idle Run, then — the request is
518
+ // by definition a complete replay (every answered call is present in the
519
+ // conversation), and the fresh-turn path rebases it into a new Run.
520
+ // A call id that is live under another scope keeps the strict refusal:
521
+ // adopting that conversation would surface another tenant's Run under
522
+ // this caller.
523
+ if (
524
+ resolvedAll.every((entry) => entry === undefined) &&
525
+ isCompleteToolHistoryReplay(request, pending) &&
526
+ pending.every((item) => !cursorRunSessions.hasAnyCall(item.callId))
527
+ ) {
528
+ yield* this.#startRun(request, candidate, context);
529
+ return;
530
+ }
531
+ const resolved = resolvedAll.filter((entry) => entry !== undefined);
532
+ const runId = resolved[0]?.session.runId;
533
+ if (runId === undefined) {
534
+ throw new GatewayError(
535
+ "invalid_request",
536
+ 400,
537
+ "This tool result refers to a Cursor tool call that is no longer pending. Resend the complete conversation.",
538
+ { callIds: pending.map((item) => item.callId) },
539
+ );
540
+ }
541
+ if (resolved.some((entry) => entry?.session.runId !== runId)) {
542
+ throw new GatewayError(
543
+ "invalid_request",
544
+ 400,
545
+ "These tool results belong to different Cursor Runs; resend them one Run at a time.",
546
+ );
547
+ }
548
+ const session = cursorRunSessions.get(runId);
549
+ if (session === undefined) {
550
+ throw new GatewayError("invalid_request", 400, "Cursor Run is no longer live");
551
+ }
552
+ const handle = this.#handles.get(runId);
553
+ if (handle === undefined) {
554
+ // The Run's socket is gone even though the record survived (a transport
555
+ // failure the pump did not observe). Rebase rather than answer into a
556
+ // dead stream.
557
+ cursorRunSessions.terminate(runId, "Cursor Run transport is gone");
558
+ yield* this.#startRun(request, candidate, context);
559
+ return;
560
+ }
561
+ let sequence = 0;
562
+ const next = (): number => (sequence += 1);
563
+ try {
564
+ for (const item of pending) {
565
+ const entry = cursorRunSessions.resolveCall(item.callId, scope);
566
+ if (entry === undefined) continue;
567
+ await handle.write(
568
+ buildMcpResult({
569
+ execId: entry.call.execId,
570
+ ...(entry.call.execIdString === undefined ? {} : { execIdString: entry.call.execIdString }),
571
+ text: item.text,
572
+ isError: item.isError,
573
+ ...(item.images.length > 0 ? { images: item.images } : {}),
574
+ }),
575
+ );
576
+ cursorRunSessions.clearPendingCall(runId, item.callId);
577
+ }
578
+ yield* this.#pump({
579
+ request,
580
+ candidate,
581
+ context,
582
+ handle,
583
+ runId,
584
+ session,
585
+ next,
586
+ continuation: true,
587
+ });
588
+ } finally {
589
+ // The Run stays open only while it still owes an answer. A terminal close
590
+ // is the session manager's single release path.
591
+ if (cursorRunSessions.get(runId) === undefined) {
592
+ this.#handles.delete(runId);
593
+ this.cleanupReader(runId);
594
+ } else if (session.pending.size === 0) {
595
+ cursorRunSessions.terminate(runId);
596
+ this.#handles.delete(runId);
597
+ this.cleanupReader(runId);
598
+ }
599
+ }
600
+ }
601
+
602
+ async *#startRun(
603
+ request: CanonicalRequest,
604
+ candidate: ProviderDispatchTarget,
605
+ context: ProviderDispatchContext,
606
+ ): AsyncIterable<CanonicalEvent> {
607
+ const outboundHttp2 = context.outbound_http2;
608
+ if (outboundHttp2 === undefined) {
609
+ throw new GatewayError(
610
+ "admission_unavailable",
611
+ 503,
612
+ "Cursor requires validated HTTP/2 egress; none is configured for this route",
613
+ { providerId: CURSOR_PROVIDER_ID },
614
+ );
615
+ }
616
+ const credential = readCredentialSecret(
617
+ context.credential,
618
+ "Cursor account credential is missing or empty; reconnect the account",
619
+ );
620
+ const origin = await this.#resolveOrigin(credential, context);
621
+ const rawModelId = candidate.model_id.length > 0 ? candidate.model_id : request.model;
622
+ const modelId = resolveCursorWireModelId(rawModelId);
623
+ // Cursor declares per-model parameter tuples (effort / context / fast). A
624
+ // fresh Run must carry the model's declared default tuple: with an empty
625
+ // `parameters` the Run streams only heartbeats and never turns.
626
+ const roster = await this.#rosterFor(credential, context);
627
+ const rosterEntry =
628
+ roster.find((model) => model.id === modelId) ??
629
+ roster.find((model) => model.id === rawModelId) ??
630
+ roster.find((model) => model.id.toLowerCase() === "default") ??
631
+ roster.find((model) => model.id.toLowerCase() === "auto");
632
+ const parameterValues = defaultParameterValues(rosterEntry);
633
+ const images = await collectImages(request, context);
634
+ const descriptors = toolDescriptorsFor(request.tools);
635
+ const systemPrompt = systemPromptFrom(request);
636
+ const conversationId = crypto.randomUUID();
637
+ const runId = crypto.randomUUID();
638
+ const opened = await openCursorRun({
639
+ origin,
640
+ credential,
641
+ outboundHttp2,
642
+ signal: context.abort_signal,
643
+ });
644
+ let closed = false;
645
+ const handle: RunHandle = {
646
+ stream: opened.stream,
647
+ write: opened.write,
648
+ close: (reason) => {
649
+ if (closed) return;
650
+ closed = true;
651
+ if (reason !== undefined && reason.length > 0) {
652
+ void opened.write(buildCancelAction(reason)).catch(() => undefined);
653
+ }
654
+ opened.stream.abort();
655
+ },
656
+ };
657
+ this.#handles.set(runId, handle);
658
+ // Reader before the run request: frames can only arrive after it, but the
659
+ // reader must own them from the first byte, across every serving request.
660
+ this.#startReader(runId, handle);
661
+ const session: CursorRunSession = {
662
+ runId,
663
+ conversationId,
664
+ scope: sessionScope(context),
665
+ descriptors,
666
+ pending: new Map(),
667
+ calls: new Map(),
668
+ usage: undefined,
669
+ failure: undefined,
670
+ };
671
+ let sequence = 0;
672
+ const next = (): number => (sequence += 1);
673
+ try {
674
+ await opened.write(
675
+ buildRunRequest({
676
+ text: liveUserText(request),
677
+ modelId,
678
+ conversationId,
679
+ runId,
680
+ ...(images.length > 0 ? { images } : {}),
681
+ ...(descriptors.length > 0 ? { descriptors } : {}),
682
+ ...(systemPrompt === undefined ? {} : { systemPrompt }),
683
+ ...(parameterValues.length > 0 ? { parameterValues } : {}),
684
+ // A fresh Run always rebases the client's history into its user text:
685
+ // Cursor's own checkpoint cannot be reconstructed after a restart, and
686
+ // `root_prompt_messages_json` would suppress the RequestContext rules.
687
+ history: historyFromRequest(request),
688
+ }),
689
+ );
690
+ // Registered before the first frame is pumped: a tool call can arrive on
691
+ // the very first frame, and the Run must already be claimable.
692
+ cursorRunSessions.register({
693
+ session,
694
+ releaseLease: context.lease_ownership?.transferToProviderSession(),
695
+ close: (reason) => handle.close(reason),
696
+ idleTtlMs: RUN_IDLE_TTL_MS,
697
+ });
698
+ yield* this.#pump({
699
+ request,
700
+ candidate,
701
+ context,
702
+ handle,
703
+ runId,
704
+ session,
705
+ next,
706
+ continuation: false,
707
+ });
708
+ } catch (error) {
709
+ cursorRunSessions.terminate(runId, error instanceof Error ? error.message : undefined);
710
+ this.#handles.delete(runId);
711
+ throw error;
712
+ } finally {
713
+ if (cursorRunSessions.get(runId) === undefined) {
714
+ this.#handles.delete(runId);
715
+ this.cleanupReader(runId);
716
+ }
717
+ // A Run whose tool call is registered and unanswered is *held*, not
718
+ // finished. Its pump ended at the interim `tool_use` terminal, but the
719
+ // reader and the heartbeat cadence stay alive so the result leg can
720
+ // resolve the call; tearing the Run down at this boundary loses the call
721
+ // it just handed out, and the next request's tool result resolves nothing.
722
+ else if (session.pending.size === 0) {
723
+ cursorRunSessions.terminate(runId);
724
+ this.#handles.delete(runId);
725
+ this.cleanupReader(runId);
726
+ }
727
+ }
728
+ }
729
+
730
+ /** Live Run handles, keyed by run id. Process-local, like the sessions. */
731
+ readonly #handles = new Map<string, RunHandle>();
732
+ /** One shared frame reader per live Run, surviving its requests. */
733
+ readonly #readers = new Map<string, RunFrameStream>();
734
+
735
+ /**
736
+ * Opens the Run's single frame reader.
737
+ *
738
+ * Ownership lives here, not inside a request: every request that serves this
739
+ * Run subscribes to the same queue, so a request that ends between a tool
740
+ * call and its answer cannot cancel the reading of the next one's turn.
741
+ *
742
+ * A reader whose body iterator ended between legs (the edge closes, or a
743
+ * test harness drains the queue) can be restarted on the still-live handle;
744
+ * the Run's continuation then re-opens the read side instead of failing.
745
+ */
746
+ #startReader(runId: string, handle: RunHandle): RunFrameStream {
747
+ const frames = new RunFrameStream();
748
+ this.#readers.set(runId, frames);
749
+ // The client-heartbeat cadence belongs to the reader: the Cursor edge
750
+ // expects a keepalive while the Run stays open, including the window
751
+ // between a tool call and the request that carries its result.
752
+ let state = "open";
753
+ const beat = (): void => {
754
+ if (state !== "open") return;
755
+ void handle.write(buildHeartbeat()).catch(() => undefined);
756
+ };
757
+ const heartbeatTimer = setInterval(
758
+ beat,
759
+ CURSOR_HEARTBEAT_INTERVAL_OVERRIDE ?? CURSOR_HEARTBEAT_INTERVAL_MS,
760
+ );
761
+ heartbeatTimer.unref();
762
+ const finish = (): void => {
763
+ if (state === "open") state = "closed";
764
+ clearInterval(heartbeatTimer);
765
+ };
766
+ // Stream failures do not always surface as a throw from the body iterator;
767
+ // the terminal event is the authoritative signal (auth revoked, quota,
768
+ // RST_STREAM). `local-close` is our own abort: the session manager already
769
+ // decided the outcome, so the pump just sees a clean end.
770
+ const unsubscribeTerminal = handle.stream.onTerminal((reason) => {
771
+ if (reason.kind === "local-close") return;
772
+ const mapped = cursorRunTerminationError({ stream: handle.stream, reason });
773
+ frames.end(reason, mapped === undefined ? undefined : mapped);
774
+ unsubscribeTerminal();
775
+ });
776
+ void (async () => {
777
+ try {
778
+ for await (const message of decodeServerMessages(handle.stream.body)) {
779
+ frames.push(message);
780
+ }
781
+ // The body ended without a recorded terminal: treat it as a clean
782
+ // server close so every pump sees an end marker either way.
783
+ frames.end({ kind: "remote-clean-close" }, undefined);
784
+ } catch (error) {
785
+ const failure =
786
+ error instanceof GatewayError
787
+ ? error
788
+ : new GatewayError(
789
+ "platform_unavailable",
790
+ 502,
791
+ `Cursor Run failed: ${error instanceof Error ? error.message : String(error)}`,
792
+ { providerId: CURSOR_PROVIDER_ID },
793
+ "upstream",
794
+ );
795
+ frames.end({ kind: "remote-error", error: error instanceof Error ? error : new Error(String(error)) }, failure);
796
+ } finally {
797
+ unsubscribeTerminal();
798
+ finish();
799
+ this.#readers.delete(runId);
800
+ }
801
+ })();
802
+ return frames;
803
+ }
804
+
805
+ private cleanupReader(runId: string): void {
806
+ this.#readers.get(runId)?.end({ kind: "local-close" }, undefined);
807
+ this.#readers.delete(runId);
808
+ }
809
+
810
+ /**
811
+ * Resolves the Run origin for one account.
812
+ *
813
+ * Cursor routes an account to a region-specific host, and the wrong host
814
+ * silently refuses the Run; the API host serves auth and catalog calls but is
815
+ * not the origin `GetServerConfig` names for Run. A resolution failure is a
816
+ * transport fault, never a fallback to the API host.
817
+ */
818
+ async #resolveOrigin(credential: string, context: ProviderDispatchContext): Promise<string> {
819
+ try {
820
+ const origin = await fetchCursorRunOrigin({
821
+ credential,
822
+ ...(context.outbound_fetch === undefined
823
+ ? {}
824
+ : { fetcher: context.outbound_fetch as unknown as typeof fetch }),
825
+ signal: context.abort_signal,
826
+ });
827
+ if (!isAllowedAgentHost(new URL(origin).hostname)) {
828
+ throw new Error("resolved origin is not a Cursor host");
829
+ }
830
+ return origin;
831
+ } catch (error) {
832
+ throw new GatewayError(
833
+ "platform_unavailable",
834
+ 502,
835
+ `Cursor Run origin could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
836
+ { providerId: CURSOR_PROVIDER_ID },
837
+ "upstream",
838
+ );
839
+ }
840
+ }
841
+
842
+ /**
843
+ * The account's model roster, for the default parameter tuple.
844
+ *
845
+ * Cached per credential and never fatal: a Run whose model is not in the
846
+ * roster (or whose roster fetch failed) still runs with no parameters rather
847
+ * than failing the dispatch. The tuple is an optimization, not a requirement.
848
+ */
849
+ async #rosterFor(
850
+ credential: string,
851
+ context: ProviderDispatchContext,
852
+ ): Promise<readonly CursorModelInfo[]> {
853
+ const cached = rosterCache.get(credential);
854
+ if (cached !== undefined && Date.now() - cached.fetchedAt < CURSOR_ROSTER_TTL_MS) {
855
+ return cached.roster;
856
+ }
857
+ try {
858
+ const roster = await fetchCursorModels({
859
+ credential,
860
+ ...(context.outbound_fetch === undefined
861
+ ? {}
862
+ : { fetcher: context.outbound_fetch as unknown as typeof fetch }),
863
+ signal: context.abort_signal,
864
+ });
865
+ rosterCache.set(credential, { roster, fetchedAt: Date.now() });
866
+ return roster;
867
+ } catch {
868
+ return cached?.roster ?? [];
869
+ }
870
+ }
871
+
872
+ async *#pump(input: {
873
+ readonly request: CanonicalRequest;
874
+ readonly candidate: ProviderDispatchTarget;
875
+ readonly context: ProviderDispatchContext;
876
+ readonly handle: RunHandle;
877
+ readonly runId: string;
878
+ readonly session: CursorRunSession;
879
+ readonly next: () => number;
880
+ readonly continuation: boolean;
881
+ }): AsyncIterable<CanonicalEvent> {
882
+ const { context, handle, runId, session } = input;
883
+ let sawToolCall = false;
884
+ let terminalUsage: UsageRecord | undefined;
885
+ let stopReason: CanonicalStopReason = "stop";
886
+ let failure: GatewayError | undefined;
887
+ const frames = this.#readers.get(runId) ?? this.#startReader(runId, handle);
888
+ try {
889
+ const messages = frames.subscribe();
890
+ for await (const item of messages) {
891
+ if (isRunStreamEnd(item)) {
892
+ failure = item.failure;
893
+ break;
894
+ }
895
+ const message = item;
896
+ if (context.abort_signal.aborted) {
897
+ failure = new GatewayError("transport_closed", 499, "request was cancelled");
898
+ break;
899
+ }
900
+ const execServer = recordOf(message["exec_server_message"]);
901
+ if (execServer !== undefined) {
902
+ const handled = await this.#handleExecServerMessage(execServer, session, handle);
903
+ if (handled.kind !== "tool_call") continue;
904
+ const callId = `call_cursor_${runId}_${handled.execId}`;
905
+ const scope = context.continuation_scope;
906
+ if (scope === undefined) {
907
+ // Without a scope, no later request could be authorized to answer
908
+ // this call, so the Run must not be held open for one.
909
+ await handle.write(buildMcpRejected(handled.execId, "tool call is not answerable", handled.execIdString));
910
+ continue;
911
+ }
912
+ if (!cursorRunSessions.registerPendingCall(runId, {
913
+ callId,
914
+ execId: handled.execId,
915
+ ...(handled.execIdString === undefined ? {} : { execIdString: handled.execIdString }),
916
+ toolName: handled.toolName,
917
+ })) {
918
+ await handle.write(buildMcpRejected(handled.execId, "tool call is not answerable", handled.execIdString));
919
+ continue;
920
+ }
921
+ sawToolCall = true;
922
+ yield {
923
+ type: "tool_call_delta",
924
+ sequence_number: input.next(),
925
+ call_id: callId,
926
+ name: handled.toolName,
927
+ arguments_delta: handled.arguments,
928
+ };
929
+ // Interim terminal the moment the call is registered and answered.
930
+ // The Run stays open for the result leg; this request must not wait
931
+ // for `turn_ended`, which only arrives after the client answers —
932
+ // holding it open here is exactly the deadlock that broke tool calling.
933
+ cursorRunSessions.touch(runId);
934
+ yield {
935
+ type: "terminal",
936
+ sequence_number: input.next(),
937
+ state: "complete",
938
+ stop_reason: "tool_use" as const,
939
+ usage_deferred: true,
940
+ };
941
+ return;
942
+ }
943
+
944
+ const kv = recordOf(message["kv_server_message"]);
945
+ if (kv !== undefined && (await this.#handleKvMessage(kv, handle))) continue;
946
+
947
+ // A permission gate the edge raises outside the exec channel (hosted
948
+ // web search, Exa, first-call MCP auth, ...). Dropping the frame left
949
+ // the Run alive on heartbeats alone: the tool turn never started and
950
+ // every tools-declaring request stalled to the client until its own
951
+ // timeout. Answering a conservative typed response keeps the turn live.
952
+ const query = recordOf(message["interaction_query"]);
953
+ if (query !== undefined) {
954
+ await this.#handleInteractionQuery(query, handle);
955
+ continue;
956
+ }
957
+
958
+ const interaction = recordOf(message["interaction_update"]);
959
+ if (interaction === undefined) continue;
960
+
961
+ const textDelta = recordOf(interaction["text_delta"]);
962
+ if (textDelta !== undefined) {
963
+ const text = typeof textDelta["text"] === "string" ? textDelta["text"] : "";
964
+ if (text.length > 0) {
965
+ yield {
966
+ type: "content_delta",
967
+ sequence_number: input.next(),
968
+ content: { kind: "text", text },
969
+ };
970
+ }
971
+ continue;
972
+ }
973
+ const thinking = recordOf(interaction["thinking_delta"]);
974
+ if (thinking !== undefined) {
975
+ const text = typeof thinking["text"] === "string" ? thinking["text"] : "";
976
+ if (text.length > 0) {
977
+ yield {
978
+ type: "content_delta",
979
+ sequence_number: input.next(),
980
+ content: { kind: "reasoning", payload: null, summary: text },
981
+ };
982
+ }
983
+ continue;
984
+ }
985
+ // `tool_call_started` / `tool_call_completed` are display notifications:
986
+ // they report what Cursor's host did, not a call this gateway executes.
987
+ // Only `turn_ended` is authoritative for the turn's end and its usage.
988
+ const turnEnded = recordOf(interaction["turn_ended"]);
989
+ if (turnEnded !== undefined) {
990
+ terminalUsage = usageFromTurnEnded(turnEnded);
991
+ break;
992
+ }
993
+ }
994
+ } catch (error) {
995
+ failure =
996
+ error instanceof GatewayError
997
+ ? error
998
+ : new GatewayError(
999
+ "platform_unavailable",
1000
+ 502,
1001
+ `Cursor Run failed: ${error instanceof Error ? error.message : String(error)}`,
1002
+ { providerId: CURSOR_PROVIDER_ID },
1003
+ "upstream",
1004
+ );
1005
+ }
1006
+
1007
+ if (failure !== undefined) {
1008
+ cursorRunSessions.terminate(runId, failure.message);
1009
+ yield {
1010
+ type: "terminal",
1011
+ sequence_number: input.next(),
1012
+ state: "failed",
1013
+ stop_reason: "error",
1014
+ // The refusal text, so the caller sees "you hit your usage limit"
1015
+ // instead of a terminal with no reason attached.
1016
+ stop_details: { code: failure.code, status: failure.status, message: failure.message },
1017
+ ...(terminalUsage === undefined ? {} : { usage: terminalUsage }),
1018
+ };
1019
+ return;
1020
+ }
1021
+ if (sawToolCall) {
1022
+ // Defensive: the interim terminal was already emitted with the call; the
1023
+ // ends-fast path below must not terminate the held Run.
1024
+ cursorRunSessions.touch(runId);
1025
+ yield {
1026
+ type: "terminal",
1027
+ sequence_number: input.next(),
1028
+ state: "complete",
1029
+ stop_reason: "tool_use" as const,
1030
+ usage_deferred: true,
1031
+ };
1032
+ return;
1033
+ }
1034
+ cursorRunSessions.terminate(runId);
1035
+ yield {
1036
+ type: "terminal",
1037
+ sequence_number: input.next(),
1038
+ state: "complete",
1039
+ stop_reason: stopReason,
1040
+ ...(terminalUsage === undefined ? {} : { usage: terminalUsage }),
1041
+ };
1042
+ }
1043
+
1044
+ /**
1045
+ * Handles one Cursor exec request.
1046
+ *
1047
+ * The control paths are mandatory, not optional: `request_context_args`,
1048
+ * `mcp_state_exec_args`, and the KV channel all stall the Run forever if the
1049
+ * client never answers. `mcp_args` — only for a declared tool — and the host
1050
+ * exec variants the client itself declared (`shell_args` → its shell tool,
1051
+ * `write_args`, `read_args`) become tool calls: the client executes them
1052
+ * locally. Every other variant is refused rather than executed here.
1053
+ */
1054
+ async #handleExecServerMessage(
1055
+ execServer: Record<string, unknown>,
1056
+ session: CursorRunSession,
1057
+ handle: RunHandle,
1058
+ ): Promise<
1059
+ | { kind: "handled" }
1060
+ | {
1061
+ kind: "tool_call";
1062
+ execId: number;
1063
+ execIdString: string | undefined;
1064
+ toolName: string;
1065
+ arguments: string;
1066
+ }
1067
+ > {
1068
+ const execId = typeof execServer["id"] === "number" ? execServer["id"] : 0;
1069
+ // Newer builds pair exec replies by this frame's UUID as well as the
1070
+ // numeric id: a reply carrying only the numeric id is never matched and
1071
+ // the Run strands on heartbeats waiting for a result that already left.
1072
+ const execIdString = nonEmptyStringOf(execServer["exec_id"]);
1073
+ if (recordOf(execServer["request_context_args"]) !== undefined) {
1074
+ await handle.write(buildRequestContextResult(execId, probeContextFor(session.descriptors), execIdString));
1075
+ return { kind: "handled" };
1076
+ }
1077
+ if (recordOf(execServer["mcp_state_exec_args"]) !== undefined) {
1078
+ await handle.write(buildMcpStateResult(execId, session.descriptors, execIdString));
1079
+ return { kind: "handled" };
1080
+ }
1081
+ // Allowlist prechecks precede the real call. They are approval probes, not
1082
+ // invocations — this gateway advertises every executable surface through
1083
+ // the MCP channel, so its own host surface is never allowlisted and the
1084
+ // typed result frame (not an mcp_result) is what the server waits on.
1085
+ const precheckKind = recordOf(execServer["shell_allowlist_precheck_args"]) !== undefined
1086
+ ? "shell"
1087
+ : recordOf(execServer["mcp_allowlist_precheck_args"]) !== undefined
1088
+ ? "mcp"
1089
+ : recordOf(execServer["web_fetch_allowlist_precheck_args"]) !== undefined
1090
+ ? "web_fetch"
1091
+ : undefined;
1092
+ if (precheckKind !== undefined) {
1093
+ await handle.write(buildAllowlistPrecheckResult(execId, precheckKind, execIdString));
1094
+ return { kind: "handled" };
1095
+ }
1096
+ const mcpArgs = recordOf(execServer["mcp_args"]);
1097
+ if (mcpArgs !== undefined) {
1098
+ const call = readExecutableMcpCall(mcpArgs, session.descriptors);
1099
+ if (call === undefined) {
1100
+ await handle.write(
1101
+ buildMcpRejected(execId, "this tool is not one the request declared", execIdString),
1102
+ );
1103
+ return { kind: "handled" };
1104
+ }
1105
+ return {
1106
+ kind: "tool_call",
1107
+ execId,
1108
+ execIdString,
1109
+ toolName: call.toolName,
1110
+ arguments: call.arguments,
1111
+ };
1112
+ }
1113
+ // Host exec frames (shell_args, write_args, read_args, and every other
1114
+ // Cursor-native variant) are not executed here. Cursor's own executor on
1115
+ // the local CLI handles them; the MCP channel is the correct path for tool
1116
+ // calls routed through this gateway.
1117
+ //
1118
+ // A direct-forward path (`readHostExecCall`) was implemented experimentally
1119
+ // but produced protocol errors in practice: the typed result frames it sent
1120
+ // back (shell_result, write_result, read_result) were not matched correctly
1121
+ // by the server under all code paths, causing the Run to stall or the model
1122
+ // to receive malformed tool output. The path has been removed; use MCP.
1123
+ const refusal = "Cursor host tools are not executed by this gateway";
1124
+ await handle.write(buildExecThrow(execId, refusal, execIdString));
1125
+ await handle.write(buildExecStreamClose(execId, execIdString));
1126
+ return { kind: "handled" };
1127
+ }
1128
+
1129
+ /**
1130
+ * Answers one interaction query so the Run RPC can continue.
1131
+ *
1132
+ * Hosted web search, Exa, web fetch, and the first-call MCP auth gate block
1133
+ * the turn until the client writes an `interaction_response`; dropping the
1134
+ * frame left the stream alive on heartbeats that are not semantic progress.
1135
+ * Network permission gates are approved (the gateway already serves the
1136
+ * model through its own validated egress), interactive prompts are rejected
1137
+ * in band, and VM setup is left unanswered rather than reporting a fake
1138
+ * success.
1139
+ */
1140
+ async #handleInteractionQuery(query: Record<string, unknown>, handle: RunHandle): Promise<void> {
1141
+ const queryId = typeof query["id"] === "number" ? query["id"] : 0;
1142
+ const body = recordOf(query["query"]);
1143
+ const queryVariant = body === undefined ? undefined : Object.keys(body)[0];
1144
+ const rejectReason = "this gateway runs headless and cannot answer interactive prompts";
1145
+ switch (queryVariant) {
1146
+ case "web_search_request_query":
1147
+ await handle.write(buildInteractionResponse(queryId, "web_search_request_response", {
1148
+ approved: {},
1149
+ }));
1150
+ return;
1151
+ case "web_fetch_request_query":
1152
+ await handle.write(buildInteractionResponse(queryId, "web_fetch_request_response", {
1153
+ approved: {},
1154
+ }));
1155
+ return;
1156
+ case "ask_question_interaction_query":
1157
+ await handle.write(buildInteractionResponse(queryId, "ask_question_interaction_response", {
1158
+ result: { rejected: { reason: rejectReason } },
1159
+ }));
1160
+ return;
1161
+ case "switch_mode_request_query":
1162
+ await handle.write(buildInteractionResponse(queryId, "switch_mode_request_response", {
1163
+ rejected: { reason: rejectReason },
1164
+ }));
1165
+ return;
1166
+ case "create_plan_request_query":
1167
+ await handle.write(buildInteractionResponse(queryId, "create_plan_request_response", {
1168
+ result: { error: { error: rejectReason } },
1169
+ }));
1170
+ return;
1171
+ case "pr_management_request_query":
1172
+ await handle.write(buildInteractionResponse(queryId, "pr_management_result", {
1173
+ rejected: { reason: rejectReason },
1174
+ }));
1175
+ return;
1176
+ case "mcp_auth_request_query":
1177
+ await handle.write(buildInteractionResponse(queryId, "mcp_auth_request_response", {
1178
+ rejected: { reason: rejectReason },
1179
+ }));
1180
+ return;
1181
+ case "generate_image_request_query":
1182
+ await handle.write(buildInteractionResponse(queryId, "generate_image_request_response", {
1183
+ rejected: { reason: rejectReason },
1184
+ }));
1185
+ return;
1186
+ case "replace_env_args":
1187
+ await handle.write(buildInteractionResponse(queryId, "replace_env_result", {
1188
+ failure: { error_message: rejectReason },
1189
+ }));
1190
+ return;
1191
+ case "connect_scm_request_query":
1192
+ await handle.write(buildInteractionResponse(queryId, "connect_scm_request_response", {
1193
+ rejected: { reason: rejectReason },
1194
+ }));
1195
+ return;
1196
+ case "setup_vm_environment_args":
1197
+ // Result oneof is success-only: do not invent a VM; silence is better
1198
+ // than a false SetupVmEnvironmentSuccess.
1199
+ return;
1200
+ default:
1201
+ return;
1202
+ }
1203
+ }
1204
+
1205
+ /** Answers one KV blob request; `false` when the message carried none. */
1206
+ async #handleKvMessage(kv: Record<string, unknown>, handle: RunHandle): Promise<boolean> {
1207
+ const id = typeof kv["id"] === "number" ? kv["id"] : 0;
1208
+ if (recordOf(kv["set_blob_args"]) !== undefined) {
1209
+ await handle.write(buildKvSetBlobResult(id));
1210
+ return true;
1211
+ }
1212
+ const getArgs = recordOf(kv["get_blob_args"]);
1213
+ if (getArgs !== undefined) {
1214
+ const blobId = getArgs["blob_id"];
1215
+ const data = blobId instanceof Uint8Array ? blobId : new Uint8Array(0);
1216
+ await handle.write(buildKvGetBlobResult(id, data));
1217
+ return true;
1218
+ }
1219
+ return false;
1220
+ }
1221
+ }
1222
+
1223
+ /** RequestContext Cursor receives in reply to a context probe. */
1224
+ function probeContextFor(descriptors: readonly CursorToolDescriptor[]): Record<string, unknown> {
1225
+ return {
1226
+ tools: descriptors.map((descriptor) => ({
1227
+ name: descriptor.name,
1228
+ description: descriptor.description,
1229
+ input_schema: descriptor.inputSchema,
1230
+ provider_identifier: descriptor.providerIdentifier,
1231
+ tool_name: descriptor.toolName,
1232
+ })),
1233
+ mcp_info_complete: true,
1234
+ rules_info_complete: true,
1235
+ env_info_complete: true,
1236
+ };
1237
+ }
1238
+
1239
+ /**
1240
+ * The continuation scope of one dispatch.
1241
+ *
1242
+ * Derived from the route's own account identity plus the caller's tenant and API
1243
+ * key. A route with no account cannot scope a continuation, and a Run that
1244
+ * cannot be scoped must not be held open across requests at all.
1245
+ */
1246
+ function sessionScope(context: ProviderDispatchContext): CursorContinuationScope {
1247
+ const scope: ProviderContinuationScope | undefined = context.continuation_scope;
1248
+ return {
1249
+ tenantId: scope?.tenantId ?? "",
1250
+ apiKeyId: scope?.apiKeyId ?? "",
1251
+ providerId: CURSOR_PROVIDER_ID,
1252
+ providerAccountId: scope?.providerAccountId ?? context.credential.account_id ?? "",
1253
+ };
1254
+ }
1255
+
1256
+ /** One tool result the client sent back for a call this process is holding. */
1257
+ interface PendingToolResult {
1258
+ readonly callId: string;
1259
+ readonly text: string;
1260
+ readonly isError: boolean;
1261
+ readonly images: readonly ResultImage[];
1262
+ }
1263
+
1264
+ /**
1265
+ * Reads the tool results this request wants delivered to a held Run.
1266
+ *
1267
+ * Only ids in the `call_cursor_<run-id>_<exec-id>` shape are considered: any
1268
+ * other id is the client's own, belongs to a different provider's conversation,
1269
+ * and must not be sent to Cursor.
1270
+ */
1271
+ function readPendingToolResult(
1272
+ request: CanonicalRequest,
1273
+ _context: ProviderDispatchContext,
1274
+ ): PendingToolResult[] | undefined {
1275
+ const results: PendingToolResult[] = [];
1276
+ for (const message of request.messages) {
1277
+ for (const part of message.content) {
1278
+ if (part.kind !== "toolResult") continue;
1279
+ if (!part.call_id.startsWith("call_cursor_")) continue;
1280
+ const text =
1281
+ typeof part.content === "string"
1282
+ ? part.content
1283
+ : part.content.map((child) => (child.kind === "text" ? child.text : "")).join("\n");
1284
+ results.push({
1285
+ callId: part.call_id,
1286
+ text,
1287
+ isError: part.is_error === true,
1288
+ images: resultImages(part),
1289
+ });
1290
+ }
1291
+ }
1292
+ return results.length > 0 ? results : undefined;
1293
+ }
1294
+
1295
+ /**
1296
+ * Marks a request as a complete conversation replay rather than a tail
1297
+ * continuation: every `call_cursor_*` tool result it carries is answered by a
1298
+ * `toolCall` block in the same conversation. That is the shape a client gets
1299
+ * when it resends its recorded transcript — a gateway restart between legs or
1300
+ * an evicted Run — and the only shape safe to rebase into a fresh Run, where
1301
+ * the recorded exchange travels as history.
1302
+ */
1303
+ function isCompleteToolHistoryReplay(
1304
+ request: CanonicalRequest,
1305
+ pending: readonly PendingToolResult[],
1306
+ ): boolean {
1307
+ const calledIds = new Set<string>();
1308
+ for (const message of request.messages) {
1309
+ for (const part of message.content) {
1310
+ if (part.kind === "toolCall") calledIds.add(part.call_id);
1311
+ }
1312
+ }
1313
+ return pending.every((item) => calledIds.has(item.callId));
1314
+ }
1315
+
1316
+ /** Reconstructs one tool result's text and images for the adapter's tests. */
1317
+ export { decodeStructEntriesToJson, decodeValueToJson };
1318
+
1319
+ export function createCursorAdapter(): ProviderAdapter {
1320
+ return new CursorAdapter();
1321
+ }
1322
+
1323
+ export const cursorAdapter: ProviderAdapter = createCursorAdapter();
1324
+ /** Maps virtual/canonical client model IDs to upstream Cursor wire IDs. */
1325
+ function resolveCursorWireModelId(rawModelId: string): string {
1326
+ const id = rawModelId.trim().toLowerCase();
1327
+ // Only `auto` is an alias: Cursor's own roster names its Auto model
1328
+ // `default`. `grok-4.6` is already the upstream id, and rewriting it to
1329
+ // `cursor-grok-4.6` made every Grok Run answer "AI Model Not Found".
1330
+ if (id === "auto") return "default";
1331
+ return rawModelId;
1332
+ }
1333
+