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