@wenathlan/devthink 2.0.14 → 2.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (444) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/{dist/bridge.d.ts → bridge.d.ts} +1 -1
  3. package/bridge.d.ts.map +1 -0
  4. package/bridge.js +1877 -0
  5. package/bun.d.ts.map +1 -0
  6. package/bun.js +52329 -0
  7. package/caps/background.json +712 -0
  8. package/caps/cli.json +360 -0
  9. package/caps/library.json +3611 -0
  10. package/caps/mcp.json +126 -0
  11. package/caps/pagebridge.json +377 -0
  12. package/caps/popup.json +51 -0
  13. package/caps/sidepanel.json +522 -0
  14. package/checksums.txt +142 -0
  15. package/cli.d.ts.map +1 -0
  16. package/{dist/cli.js → cli.js} +2 -2
  17. package/companion.d.ts.map +1 -0
  18. package/companion.js +389 -0
  19. package/crossbrowser.d.ts.map +1 -0
  20. package/crossbrowser.js +1191 -0
  21. package/dashdone.d.ts.map +1 -0
  22. package/dashdone.js +368 -0
  23. package/deno.d.ts.map +1 -0
  24. package/deno.js +52325 -0
  25. package/devthink.umd.js +52346 -0
  26. package/fixtures/bun.ts +7 -0
  27. package/fixtures/cjs.js +6 -0
  28. package/fixtures/deno.ts +6 -0
  29. package/fixtures/example-org-pagestate.json +22 -0
  30. package/fixtures/importers/automa-workflow.json +18 -0
  31. package/fixtures/importers/selenium-side.json +20 -0
  32. package/fixtures/importers/tabular-plan.csv +7 -0
  33. package/fixtures/importers/uivision-macro.json +10 -0
  34. package/fixtures/importers/v1-plan.json +13 -0
  35. package/fixtures/mcp-client.mjs +14 -0
  36. package/fixtures/pages/checkout.html +192 -0
  37. package/fixtures/pages/dashboard.html +110 -0
  38. package/fixtures/pages/feed.html +113 -0
  39. package/fixtures/pages/product-grid.html +150 -0
  40. package/fixtures/plans/form-inventory-plan.json +11 -0
  41. package/fixtures/plans/release-notes-plan.json +11 -0
  42. package/fixtures/recipes/agentscompete.json +11 -0
  43. package/fixtures/recipes/agentsforms.json +11 -0
  44. package/fixtures/recipes/agentsmonitor.json +11 -0
  45. package/fixtures/recipes/agentsreview.json +29 -0
  46. package/fixtures/recipes/agentswarm.json +11 -0
  47. package/fixtures/recipes/fillcalendar.json +12 -0
  48. package/fixtures/recipes/fillcheckout.json +13 -0
  49. package/fixtures/recipes/filldependentdropdowns.json +12 -0
  50. package/fixtures/recipes/fillfromcsv.json +13 -0
  51. package/fixtures/recipes/fillloginform.json +12 -0
  52. package/fixtures/recipes/fillmultistepwizard.json +13 -0
  53. package/fixtures/recipes/fillregistration.json +12 -0
  54. package/fixtures/recipes/fillsearchfilters.json +12 -0
  55. package/fixtures/recipes/filltypeahead.json +11 -0
  56. package/fixtures/recipes/monitoralerts.json +12 -0
  57. package/fixtures/recipes/monitoravailability.json +11 -0
  58. package/fixtures/recipes/monitornetworkquiet.json +12 -0
  59. package/fixtures/recipes/monitorpagechanges.json +11 -0
  60. package/fixtures/recipes/monitorpricechange.json +11 -0
  61. package/fixtures/recipes/monitorschedule.json +12 -0
  62. package/fixtures/recipes/scrapedynamicprices.json +13 -0
  63. package/fixtures/recipes/scrapeimagegallery.json +12 -0
  64. package/fixtures/recipes/scrapeinfinitefeed.json +13 -0
  65. package/fixtures/recipes/scrapenestedlists.json +12 -0
  66. package/fixtures/recipes/scrapeproductgrid.json +12 -0
  67. package/fixtures/recipes/scrapereviews.json +12 -0
  68. package/fixtures/recipes/scrapesearchresults.json +12 -0
  69. package/fixtures/recipes/scrapetablepagination.json +12 -0
  70. package/fixtures/recipes/testaccessibility.json +34 -0
  71. package/fixtures/recipes/testconsoleerrors.json +12 -0
  72. package/fixtures/recipes/testdeeplinks.json +12 -0
  73. package/fixtures/recipes/testformvalidation.json +13 -0
  74. package/fixtures/recipes/testlayoutshift.json +12 -0
  75. package/fixtures/recipes/testlinkcheck.json +12 -0
  76. package/fixtures/recipes/testloadtiming.json +12 -0
  77. package/fixtures/recipes/testvisualdiff.json +12 -0
  78. package/fixtures/workflows/changelog-digest-workflow.json +13 -0
  79. package/fixtures/workflows/form-survey-workflow.json +13 -0
  80. package/gallery.json +706 -0
  81. package/hardening.d.ts.map +1 -0
  82. package/hardening.js +188 -0
  83. package/headless.d.ts.map +1 -0
  84. package/headless.js +11522 -0
  85. package/http.d.ts.map +1 -0
  86. package/http.js +2165 -0
  87. package/index.cjs +54682 -0
  88. package/index.d.ts.map +1 -0
  89. package/index.js +52310 -0
  90. package/index.neutral.js +46343 -0
  91. package/{web/manifest.json → manifest.json} +1 -1
  92. package/mcp.d.ts.map +1 -0
  93. package/mcp.js +20515 -0
  94. package/memory.d.ts.map +1 -0
  95. package/{memory.ts → memory.js} +2310 -3834
  96. package/nativehost.template.json +10 -0
  97. package/node.cjs +54686 -0
  98. package/node.d.ts.map +1 -0
  99. package/pack.d.ts.map +1 -0
  100. package/pack.js +805 -0
  101. package/package.json +83 -176
  102. package/policy.d.ts.map +1 -0
  103. package/{policy.ts → policy.js} +4679 -5073
  104. package/progress.d.ts.map +1 -0
  105. package/progress.js +645 -0
  106. package/protocol.d.ts.map +1 -0
  107. package/protocol.js +8334 -0
  108. package/schemas/audit.schema.json +226 -0
  109. package/schemas/capability.schema.json +113 -0
  110. package/schemas/envelope.schema.json +162 -0
  111. package/schemas/memory.schema.json +51 -0
  112. package/schemas/observation.schema.json +113 -0
  113. package/schemas/plan.schema.json +208 -0
  114. package/schemas/progress.schema.json +126 -0
  115. package/schemas/proposal.schema.json +68 -0
  116. package/schemas/session.schema.json +82 -0
  117. package/schemas/tool.schema.json +94 -0
  118. package/server.d.ts.map +1 -0
  119. package/server.js +28877 -0
  120. package/umd-example.html +18 -0
  121. package/umd.d.ts.map +1 -0
  122. package/accounts.ts +0 -2087
  123. package/agent.ts +0 -2331
  124. package/antigravity.ts +0 -2139
  125. package/apifreeze.ts +0 -4146
  126. package/auth.ts +0 -642
  127. package/background.ts +0 -16837
  128. package/bridge.ts +0 -2080
  129. package/build.ts +0 -54
  130. package/bun.ts +0 -26
  131. package/capture.ts +0 -1614
  132. package/checksecrets.ts +0 -72
  133. package/cjs.ts +0 -9
  134. package/cli.ts +0 -3500
  135. package/commands.ts +0 -1101
  136. package/companion.ts +0 -450
  137. package/compatibility.ts +0 -37
  138. package/config.ts +0 -3137
  139. package/constants.ts +0 -943
  140. package/core.ts +0 -1473
  141. package/crossbrowser.ts +0 -1359
  142. package/dashdone.ts +0 -605
  143. package/data.ts +0 -943
  144. package/database.ts +0 -133
  145. package/debug.ts +0 -850
  146. package/deno.ts +0 -27
  147. package/devthink.ts +0 -707
  148. package/dist/accounts.d.ts +0 -409
  149. package/dist/agent.d.ts +0 -837
  150. package/dist/antigravity.d.ts +0 -380
  151. package/dist/apifreeze.d.ts +0 -113
  152. package/dist/auth.d.ts +0 -224
  153. package/dist/capture.d.ts +0 -595
  154. package/dist/cjs.d.ts +0 -6
  155. package/dist/commands.d.ts +0 -310
  156. package/dist/compatibility.d.ts +0 -17
  157. package/dist/config.d.ts +0 -1853
  158. package/dist/constants.d.ts +0 -637
  159. package/dist/core.d.ts +0 -361
  160. package/dist/data.d.ts +0 -330
  161. package/dist/database.d.ts +0 -24
  162. package/dist/debug.d.ts +0 -270
  163. package/dist/engine.d.ts +0 -74
  164. package/dist/environments.d.ts +0 -244
  165. package/dist/evidence.d.ts +0 -277
  166. package/dist/export.d.ts +0 -311
  167. package/dist/fingerprint.d.ts +0 -594
  168. package/dist/flow.d.ts +0 -300
  169. package/dist/gates.d.ts +0 -103
  170. package/dist/identity.d.ts +0 -51
  171. package/dist/ids.d.ts +0 -7
  172. package/dist/llm.d.ts +0 -652
  173. package/dist/models.d.ts +0 -1807
  174. package/dist/net.d.ts +0 -325
  175. package/dist/neutral.d.ts +0 -41
  176. package/dist/oauth.d.ts +0 -703
  177. package/dist/page.d.ts +0 -1166
  178. package/dist/perf.d.ts +0 -481
  179. package/dist/plan.d.ts +0 -134
  180. package/dist/project.d.ts +0 -395
  181. package/dist/providers.d.ts +0 -37
  182. package/dist/quota.d.ts +0 -453
  183. package/dist/recovery.d.ts +0 -621
  184. package/dist/request.d.ts +0 -1492
  185. package/dist/run.d.ts +0 -779
  186. package/dist/runtime.d.ts +0 -212
  187. package/dist/search.d.ts +0 -375
  188. package/dist/security.d.ts +0 -534
  189. package/dist/serve.d.ts +0 -136
  190. package/dist/session.d.ts +0 -296
  191. package/dist/storage.d.ts +0 -53
  192. package/dist/streaming.d.ts +0 -1119
  193. package/dist/swarm.d.ts +0 -328
  194. package/dist/tools.d.ts +0 -170
  195. package/dist/types.d.ts +0 -8357
  196. package/dist/utils.d.ts +0 -82
  197. package/dist/version.d.ts +0 -7
  198. package/dist/versionregistry.d.ts +0 -406
  199. package/dist/views.d.ts +0 -579
  200. package/dist/workbenchsession.d.ts +0 -69
  201. package/dist/workflow.d.ts +0 -649
  202. package/docs/readme.md +0 -92
  203. package/docs/schemas/antigravity.json +0 -192
  204. package/engine.ts +0 -1524
  205. package/environments.ts +0 -1005
  206. package/evidence.ts +0 -643
  207. package/export.ts +0 -889
  208. package/fingerprint.ts +0 -1458
  209. package/flow.ts +0 -1190
  210. package/gates.ts +0 -307
  211. package/gateway.ts +0 -34
  212. package/hardening.ts +0 -191
  213. package/headless.ts +0 -369
  214. package/http.ts +0 -2110
  215. package/identity.ts +0 -246
  216. package/ids.ts +0 -26
  217. package/index.ts +0 -369
  218. package/llm.ts +0 -2682
  219. package/mcp.ts +0 -1835
  220. package/models.ts +0 -3805
  221. package/modes.ts +0 -43
  222. package/net.ts +0 -1214
  223. package/neutral.ts +0 -362
  224. package/node.ts +0 -26
  225. package/oauth.ts +0 -2216
  226. package/offscreen.ts +0 -170
  227. package/pack.ts +0 -956
  228. package/page.ts +0 -7824
  229. package/pagebridge.ts +0 -1116
  230. package/perf.ts +0 -957
  231. package/plan.ts +0 -672
  232. package/plugin.ts +0 -76
  233. package/prisma.config.ts +0 -27
  234. package/progress.ts +0 -1308
  235. package/project.ts +0 -1291
  236. package/protocol.ts +0 -4187
  237. package/providers.ts +0 -246
  238. package/quota.ts +0 -1887
  239. package/recovery.ts +0 -2433
  240. package/request.ts +0 -4482
  241. package/run.ts +0 -2135
  242. package/runtime.ts +0 -526
  243. package/search.ts +0 -1760
  244. package/security.ts +0 -1673
  245. package/serve.ts +0 -419
  246. package/server.ts +0 -5538
  247. package/session.ts +0 -1162
  248. package/storage.ts +0 -182
  249. package/streaming.ts +0 -4101
  250. package/swarm.ts +0 -884
  251. package/sync.ts +0 -56
  252. package/tests/agentcert.test.ts +0 -128
  253. package/tests/agentfleet.test.ts +0 -214
  254. package/tests/agentreview.test.ts +0 -241
  255. package/tests/agentstream.test.ts +0 -335
  256. package/tests/agentwork.test.ts +0 -356
  257. package/tests/apifreeze.test.ts +0 -290
  258. package/tests/apimap.test.ts +0 -112
  259. package/tests/approvalgate.test.ts +0 -175
  260. package/tests/artifactmanifest.test.ts +0 -204
  261. package/tests/attentionfeed.test.ts +0 -163
  262. package/tests/backgroundruns.test.ts +0 -100
  263. package/tests/batchscheduling.test.ts +0 -131
  264. package/tests/blackboard.test.ts +0 -238
  265. package/tests/bridge.test.ts +0 -1088
  266. package/tests/browsercoverage.test.ts +0 -134
  267. package/tests/browserpolyfills.test.ts +0 -124
  268. package/tests/build.mjs +0 -1260
  269. package/tests/clientauth.test.ts +0 -223
  270. package/tests/clitools.test.ts +0 -578
  271. package/tests/confirmgates.test.ts +0 -190
  272. package/tests/containerpack.test.ts +0 -143
  273. package/tests/controlflow.test.ts +0 -2120
  274. package/tests/coordination.test.ts +0 -345
  275. package/tests/copyscreen.test.ts +0 -139
  276. package/tests/core.test.ts +0 -417
  277. package/tests/costcert.test.ts +0 -95
  278. package/tests/crossbrowser.test.ts +0 -115
  279. package/tests/cspaudit.test.ts +0 -79
  280. package/tests/dashdone.test.ts +0 -615
  281. package/tests/datagrid.test.ts +0 -137
  282. package/tests/doccheck.test.ts +0 -107
  283. package/tests/dombatch.test.ts +0 -109
  284. package/tests/emulation.test.ts +0 -282
  285. package/tests/environments.test.ts +0 -259
  286. package/tests/evidenceviews.test.ts +0 -88
  287. package/tests/exporttools.test.ts +0 -185
  288. package/tests/firefoxprep.test.ts +0 -134
  289. package/tests/flowlibrary.test.ts +0 -242
  290. package/tests/flowrun.test.ts +0 -303
  291. package/tests/forensics.test.ts +0 -439
  292. package/tests/gateway.test.ts +0 -939
  293. package/tests/headlesslib.test.ts +0 -171
  294. package/tests/httpstream.test.ts +0 -277
  295. package/tests/immutablelog.test.ts +0 -157
  296. package/tests/inboundguard.test.ts +0 -177
  297. package/tests/lazyload.test.ts +0 -72
  298. package/tests/librarymodes.test.ts +0 -623
  299. package/tests/llm.test.ts +0 -696
  300. package/tests/manifest.test.ts +0 -226
  301. package/tests/maskinputs.test.ts +0 -150
  302. package/tests/matrixverify.test.ts +0 -111
  303. package/tests/mavenpack.test.ts +0 -75
  304. package/tests/mcpserver.test.ts +0 -704
  305. package/tests/memory.test.ts +0 -4155
  306. package/tests/memorycare.test.ts +0 -230
  307. package/tests/migrateplan.test.ts +0 -349
  308. package/tests/minimization.test.ts +0 -414
  309. package/tests/modelroute.test.ts +0 -112
  310. package/tests/multiagent.test.ts +0 -440
  311. package/tests/nativebridge.test.ts +0 -807
  312. package/tests/navigation.test.ts +0 -284
  313. package/tests/nugetpack.test.ts +0 -83
  314. package/tests/orchestration.test.ts +0 -449
  315. package/tests/originpolicy.test.ts +0 -405
  316. package/tests/outputcompare.test.ts +0 -97
  317. package/tests/package.test.ts +0 -91
  318. package/tests/pagebridge.test.ts +0 -5909
  319. package/tests/pentest.test.ts +0 -75
  320. package/tests/perfrecords.test.ts +0 -257
  321. package/tests/permdiff.test.ts +0 -85
  322. package/tests/phishguard.test.ts +0 -128
  323. package/tests/pickerviews.test.ts +0 -155
  324. package/tests/pipelines.test.ts +0 -381
  325. package/tests/planlint.test.ts +0 -240
  326. package/tests/planreview.test.ts +0 -383
  327. package/tests/platformtargets.test.ts +0 -56
  328. package/tests/policy.test.ts +0 -8284
  329. package/tests/portability.test.ts +0 -96
  330. package/tests/progress.test.ts +0 -1036
  331. package/tests/promptlibrary.test.ts +0 -116
  332. package/tests/protocol.test.ts +0 -4793
  333. package/tests/publishingpipeline.test.ts +0 -314
  334. package/tests/quickactions.test.ts +0 -164
  335. package/tests/readiness.test.ts +0 -78
  336. package/tests/recipes.test.ts +0 -163
  337. package/tests/redactshots.test.ts +0 -216
  338. package/tests/registernvidiakeys.ts +0 -51
  339. package/tests/relayserve.test.ts +0 -304
  340. package/tests/releasenotes.test.ts +0 -276
  341. package/tests/resilience.test.ts +0 -467
  342. package/tests/resourceaware.test.ts +0 -187
  343. package/tests/runbudget.test.ts +0 -125
  344. package/tests/runhistory.test.ts +0 -299
  345. package/tests/runreplay.test.ts +0 -113
  346. package/tests/runresume.test.ts +0 -184
  347. package/tests/runstate.test.ts +0 -288
  348. package/tests/runtimeadapters.test.ts +0 -251
  349. package/tests/safariskeleton.test.ts +0 -115
  350. package/tests/sandboxframe.test.ts +0 -196
  351. package/tests/sbom.test.ts +0 -105
  352. package/tests/secretvault.test.ts +0 -188
  353. package/tests/selcache.test.ts +0 -111
  354. package/tests/sessioninterface.test.ts +0 -791
  355. package/tests/sessions.test.ts +0 -591
  356. package/tests/siteprefs.test.ts +0 -228
  357. package/tests/snapshotdelta.test.ts +0 -165
  358. package/tests/statusviews.test.ts +0 -237
  359. package/tests/stepmeter.test.ts +0 -200
  360. package/tests/streamparse.test.ts +0 -163
  361. package/tests/stylepruning.test.ts +0 -90
  362. package/tests/surfaces.test.ts +0 -282
  363. package/tests/sweep.test.ts +0 -78
  364. package/tests/syncbridge.test.ts +0 -164
  365. package/tests/taskqueue.test.ts +0 -206
  366. package/tests/toolcalls.test.ts +0 -336
  367. package/tests/toolcatalog.test.ts +0 -201
  368. package/tests/torture-agent.test.ts +0 -706
  369. package/tests/torture-auth.test.ts +0 -461
  370. package/tests/torture-commands.test.ts +0 -895
  371. package/tests/torture-data.test.ts +0 -923
  372. package/tests/torture-environments.test.ts +0 -285
  373. package/tests/torture-export.test.ts +0 -263
  374. package/tests/torture-http.test.ts +0 -1379
  375. package/tests/torture-llm.test.ts +0 -808
  376. package/tests/torture-memory.test.ts +0 -1084
  377. package/tests/torture-plan.test.ts +0 -829
  378. package/tests/torture-run.test.ts +0 -626
  379. package/tests/torture-session.test.ts +0 -928
  380. package/tests/torture-swarm.test.ts +0 -1245
  381. package/tests/torture-tools.test.ts +0 -964
  382. package/tests/torture-workflow.test.ts +0 -2354
  383. package/tests/tourviews.test.ts +0 -52
  384. package/tests/transparency.test.ts +0 -147
  385. package/tests/trigger.test.ts +0 -941
  386. package/tests/v1sunset.test.ts +0 -428
  387. package/tests/virtlist.test.ts +0 -94
  388. package/tests/vision.test.ts +0 -540
  389. package/tests/vsixpack.test.ts +0 -145
  390. package/tests/webapi.test.ts +0 -523
  391. package/tests/workflow.test.ts +0 -1785
  392. package/tests/workfloweditor.test.ts +0 -759
  393. package/tests/xpipack.test.ts +0 -128
  394. package/tools.ts +0 -734
  395. package/types.ts +0 -9908
  396. package/umd.ts +0 -9
  397. package/utils.ts +0 -249
  398. package/version.ts +0 -8
  399. package/versionregistry.ts +0 -1310
  400. package/views.ts +0 -2153
  401. package/vitest.config.ts +0 -12
  402. package/web/App.tsx +0 -78
  403. package/web/README.md +0 -47
  404. package/web/capacitor.config.ts +0 -46
  405. package/web/const.ts +0 -19
  406. package/web/control.shell.tsx +0 -66
  407. package/web/dashboardpage.ts +0 -554
  408. package/web/db.ts +0 -493
  409. package/web/design.html +0 -541
  410. package/web/gatewayview/Gateway.tsx +0 -345
  411. package/web/gatewayview/config.ts +0 -1042
  412. package/web/gatewayview/definition.ts +0 -174
  413. package/web/icons.ts +0 -15
  414. package/web/optionspage.ts +0 -691
  415. package/web/popup.ts +0 -1651
  416. package/web/schema.prisma +0 -176
  417. package/web/sidepanel.ts +0 -7941
  418. package/web/sitemanifest.json +0 -13
  419. package/web/terminal-ui.tsx +0 -622
  420. package/web/transparencypage.ts +0 -541
  421. package/web/vite.config.ts +0 -39
  422. package/web/workspace.ts +0 -23
  423. package/workbenchmemory.ts +0 -104
  424. package/workbenchsession.ts +0 -304
  425. package/workflow.ts +0 -4495
  426. /package/{dist/bun.d.ts → bun.d.ts} +0 -0
  427. /package/{dist/cli.d.ts → cli.d.ts} +0 -0
  428. /package/{dist/companion.d.ts → companion.d.ts} +0 -0
  429. /package/{dist/crossbrowser.d.ts → crossbrowser.d.ts} +0 -0
  430. /package/{dist/dashdone.d.ts → dashdone.d.ts} +0 -0
  431. /package/{dist/deno.d.ts → deno.d.ts} +0 -0
  432. /package/{dist/hardening.d.ts → hardening.d.ts} +0 -0
  433. /package/{dist/headless.d.ts → headless.d.ts} +0 -0
  434. /package/{dist/http.d.ts → http.d.ts} +0 -0
  435. /package/{dist/index.d.ts → index.d.ts} +0 -0
  436. /package/{dist/mcp.d.ts → mcp.d.ts} +0 -0
  437. /package/{dist/memory.d.ts → memory.d.ts} +0 -0
  438. /package/{dist/node.d.ts → node.d.ts} +0 -0
  439. /package/{dist/pack.d.ts → pack.d.ts} +0 -0
  440. /package/{dist/policy.d.ts → policy.d.ts} +0 -0
  441. /package/{dist/progress.d.ts → progress.d.ts} +0 -0
  442. /package/{dist/protocol.d.ts → protocol.d.ts} +0 -0
  443. /package/{dist/server.d.ts → server.d.ts} +0 -0
  444. /package/{dist/umd.d.ts → umd.d.ts} +0 -0
@@ -1,1379 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- channellive,
4
- closeidlechannels,
5
- createlinepump,
6
- defaultheartbeatms,
7
- defaulthttpstream,
8
- defaultidlewindowms,
9
- enforcemaxclients,
10
- fetchrequestof,
11
- fetchoptionsof,
12
- graphqlopenvelope,
13
- graphqlrequestof,
14
- httpanswer,
15
- httpendpoint,
16
- httpframepipeline,
17
- openstreamchannel,
18
- parsepost,
19
- parsehtmlbody,
20
- payloadvalid,
21
- payloadwithdefaults,
22
- readpath,
23
- readstream,
24
- relayconnectionclose,
25
- relayconnectionopen,
26
- relayframeof,
27
- relayidleconnections,
28
- relaylivetokens,
29
- relaypendingpairings,
30
- relayserverframe,
31
- newrelayserverstate,
32
- routepath,
33
- sendfetch,
34
- sseframe,
35
- splitlines,
36
- starttls,
37
- statusclassof,
38
- streampathof,
39
- streamwindowof,
40
- templateurl,
41
- tlsdecision,
42
- unwrapgraphql,
43
- heartbeat,
44
- listremotestatus,
45
- } from "../http.js";
46
- import { issuetoken, grantallowlistentry } from "../auth.js";
47
- import { defaultmcpconfig, localhostbind } from "../mcp.js";
48
- import type { clientrecord, fetchrequest, mcpserverconfig, streamchannel } from "../types.js";
49
- import type { fetchtransport, transportresponse } from "../http.js";
50
-
51
- const now = 1_800_000_000_000;
52
- const instant = (): Promise<void> => Promise.resolve();
53
-
54
- /** Builds one transport response fixture. */
55
- function response(
56
- status: number,
57
- body = "",
58
- headers: Record<string, string> = {},
59
- location?: string,
60
- ): transportresponse {
61
- return { status, headers, body, ...(location !== undefined ? { location } : {}) };
62
- }
63
-
64
- /** Builds one allowlist fixture entry the pipeline trusts. */
65
- async function pipelinefixture(): Promise<{
66
- tokens: Awaited<ReturnType<typeof issuetoken>>["token"][];
67
- raw: string;
68
- allowlist: ReturnType<typeof grantallowlistentry>;
69
- }> {
70
- const issued = await issuetoken({ clientid: "client1", scopes: ["browser"], now, raw: "raw-token" });
71
- const allowlist = grantallowlistentry({
72
- entries: [],
73
- identity: { fingerprint: "fp-1", displayname: "Laptop agent" },
74
- namespaces: ["browser"],
75
- actor: "user",
76
- now,
77
- });
78
- return { tokens: [issued.token], raw: "raw-token", allowlist };
79
- }
80
-
81
- describe("torture: status class boundaries", () => {
82
- it("grades the exact class edges and unknown out of range statuses", () => {
83
- expect(statusclassof(100)).toBe("informational");
84
- expect(statusclassof(199)).toBe("informational");
85
- expect(statusclassof(200)).toBe("success");
86
- expect(statusclassof(299)).toBe("success");
87
- expect(statusclassof(300)).toBe("redirect");
88
- expect(statusclassof(399)).toBe("redirect");
89
- expect(statusclassof(400)).toBe("clienterror");
90
- expect(statusclassof(499)).toBe("clienterror");
91
- expect(statusclassof(500)).toBe("servererror");
92
- expect(statusclassof(599)).toBe("servererror");
93
- });
94
-
95
- it("refuses the out of range and hostile statuses as unknown", () => {
96
- expect(statusclassof(0)).toBe("unknown");
97
- expect(statusclassof(-1)).toBe("unknown");
98
- expect(statusclassof(600)).toBe("unknown");
99
- expect(statusclassof(99)).toBe("unknown");
100
- expect(statusclassof(Number.NaN)).toBe("unknown");
101
- expect(statusclassof(Number.POSITIVE_INFINITY)).toBe("unknown");
102
- expect(statusclassof(200.5)).toBe("success");
103
- });
104
- });
105
-
106
- describe("torture: url templating and request normalization", () => {
107
- it("substitutes reviewed values while missing keys keep their placeholder", () => {
108
- expect(templateurl("https://api.example/items/{id}", { id: "42" })).toBe("https://api.example/items/42");
109
- expect(templateurl("https://api.example/items/{id}", {})).toBe("https://api.example/items/{id}");
110
- expect(templateurl("https://api.example/{a}/{a}", { a: "x" })).toBe("https://api.example/x/x");
111
- expect(templateurl("https://api.example/{ user }", { user: "x" })).toBe("https://api.example/{ user }");
112
- expect(templateurl("https://api.example/{ID}", { id: "x" })).toBe("https://api.example/{ID}");
113
- expect(templateurl("https://api.example/{}", {})).toBe("https://api.example/{}");
114
- });
115
-
116
- it("refuses malformed fetch request shapes while normalizing the clean ones", () => {
117
- expect(fetchrequestof(undefined)).toBeUndefined();
118
- expect(fetchrequestof(null)).toBeUndefined();
119
- expect(fetchrequestof([])).toBeUndefined();
120
- expect(fetchrequestof("https://x.example")).toBeUndefined();
121
- expect(fetchrequestof({ url: " " })).toBeUndefined();
122
- expect(fetchrequestof({ url: 42 })).toBeUndefined();
123
- expect(fetchrequestof({ url: "https://x.example", method: " " })).toEqual({ url: "https://x.example" });
124
- expect(fetchrequestof({ url: " https://x.example ", method: " delete ", body: "x", mode: "cors" })).toEqual({
125
- url: "https://x.example",
126
- method: "DELETE",
127
- body: "x",
128
- mode: "cors",
129
- });
130
- expect(fetchrequestof({ url: "https://x.example", mode: "websocket" as never })).toEqual({
131
- url: "https://x.example",
132
- });
133
- expect(fetchrequestof({ url: "https://x.example", headers: { keep: "1", drop: 2, alsodrop: null } })).toEqual({
134
- url: "https://x.example",
135
- headers: { keep: "1" },
136
- });
137
- expect(fetchrequestof({ url: "https://x.example", headers: [] })).toEqual({ url: "https://x.example" });
138
- });
139
-
140
- it("normalizes fetch options and stream windows under hostile values", () => {
141
- expect(fetchoptionsof(null)).toEqual({});
142
- expect(fetchoptionsof([])).toEqual({});
143
- expect(fetchoptionsof({ timeout: Number.POSITIVE_INFINITY })).toEqual({});
144
- expect(fetchoptionsof({ timeout: "100" as never })).toEqual({});
145
- expect(fetchoptionsof({ retries: -5, backoff: 0, follow: 0 })).toEqual({ retries: -5, backoff: 0, follow: 0 });
146
- expect(streamwindowof({ budget: 0 })).toEqual({ budget: 0 });
147
- expect(streamwindowof({ budget: Number.NaN })).toEqual({});
148
- expect(streamwindowof({ budget: -1 })).toEqual({ budget: -1 });
149
- expect(streamwindowof([])).toEqual({});
150
- });
151
- });
152
-
153
- describe("torture: sendfetch transport loop", () => {
154
- it("follows redirect chains inside the limit and refuses past it", async () => {
155
- const hops: string[] = [];
156
- const transport: fetchtransport = async (url): Promise<transportresponse> => {
157
- hops.push(url);
158
- if (url === "https://a.example/start") return response(302, "", {}, "/one");
159
- if (url === "https://a.example/one") return response(301, "", {}, "/two");
160
- if (url === "https://a.example/two") return response(308, "", {}, "/final");
161
- return response(200, "landed");
162
- };
163
- const request: fetchrequest = { url: "https://a.example/start" };
164
- const outcome = await sendfetch({ request, transport, sleep: instant });
165
- expect(outcome.status).toBe(200);
166
- expect(outcome.redirects).toBe(3);
167
- expect(outcome.url).toBe("https://a.example/final");
168
- expect(hops).toHaveLength(4);
169
- await expect(sendfetch({ request, options: { follow: 2 }, transport, sleep: instant })).rejects.toThrow(
170
- /exceeded the reviewed follow limit of 2/,
171
- );
172
- await expect(sendfetch({ request, options: { follow: 3 }, transport, sleep: instant })).resolves.toMatchObject({
173
- status: 200,
174
- redirects: 3,
175
- });
176
- });
177
-
178
- it("refuses the chain one hop past the reviewed follow limit", async () => {
179
- const transport: fetchtransport = async (url): Promise<transportresponse> =>
180
- url.endsWith("/a")
181
- ? response(302, "", {}, "/b")
182
- : url.endsWith("/b")
183
- ? response(302, "", {}, "/c")
184
- : response(404, "gone");
185
- await expect(
186
- sendfetch({ request: { url: "https://a.example/a" }, options: { follow: 1 }, transport, sleep: instant }),
187
- ).rejects.toThrow(/exceeded the reviewed follow limit of 1/);
188
- await expect(
189
- sendfetch({ request: { url: "https://a.example/a" }, options: { follow: 0 }, transport, sleep: instant }),
190
- ).rejects.toThrow(/exceeded the reviewed follow limit of 0/);
191
- });
192
-
193
- it("resumes a retry from the redirect position the failed attempt reached", async () => {
194
- const transport: fetchtransport = async (url): Promise<transportresponse> =>
195
- url.endsWith("/a")
196
- ? response(302, "", {}, "/b")
197
- : url.endsWith("/b")
198
- ? response(302, "", {}, "/c")
199
- : response(404, "gone");
200
- const outcome = await sendfetch({
201
- request: { url: "https://a.example/a" },
202
- options: { follow: 1, retries: 1 },
203
- transport,
204
- sleep: instant,
205
- });
206
- expect(outcome.status).toBe(404);
207
- expect(outcome.url).toBe("https://a.example/c");
208
- expect(outcome.retries).toBe(1);
209
- });
210
-
211
- it("downgrades the post body to a bodiless GET on a 301, 302 or 303 redirect", async () => {
212
- const seen: Array<{ url: string; method: string; body?: string }> = [];
213
- const transport: fetchtransport = async (url, init): Promise<transportresponse> => {
214
- seen.push({ url, method: init.method, ...(init.body !== undefined ? { body: init.body } : {}) });
215
- if (url.endsWith("/pay")) return response(302, "", {}, "/landing");
216
- return response(200, "ok");
217
- };
218
- const outcome = await sendfetch({
219
- request: { url: "https://a.example/pay", method: "POST", body: "secret-payload" },
220
- transport,
221
- sleep: instant,
222
- });
223
- expect(outcome.status).toBe(200);
224
- expect(seen).toHaveLength(2);
225
- expect(seen[1]?.method).toBe("GET");
226
- expect(seen[1]?.body).toBeUndefined();
227
- });
228
-
229
- it("keeps the post body through a 307 and 308 redirect", async () => {
230
- const seen: Array<{ url: string; method: string; body?: string }> = [];
231
- const transport: fetchtransport = async (url, init): Promise<transportresponse> => {
232
- seen.push({ url, method: init.method, ...(init.body !== undefined ? { body: init.body } : {}) });
233
- if (url.endsWith("/pay")) return response(307, "", {}, "/pay2");
234
- return response(200, "ok");
235
- };
236
- await sendfetch({
237
- request: { url: "https://a.example/pay", method: "POST", body: "kept" },
238
- transport,
239
- sleep: instant,
240
- });
241
- expect(seen[1]?.method).toBe("POST");
242
- expect(seen[1]?.body).toBe("kept");
243
- });
244
-
245
- it("resolves relative and absolute redirect locations against the current url", async () => {
246
- const transport: fetchtransport = async (url): Promise<transportresponse> => {
247
- if (url === "https://a.example/dir/page") return response(302, "", {}, "other/page");
248
- return response(200, "ok");
249
- };
250
- const outcome = await sendfetch({ request: { url: "https://a.example/dir/page" }, transport, sleep: instant });
251
- expect(outcome.url).toBe("https://a.example/dir/other/page");
252
- const absolute: fetchtransport = async (url): Promise<transportresponse> =>
253
- url === "https://a.example/page" ? response(302, "", {}, "https://b.example/x") : response(200, "ok");
254
- const crossed = await sendfetch({
255
- request: { url: "https://a.example/page" },
256
- transport: absolute,
257
- sleep: instant,
258
- });
259
- expect(crossed.url).toBe("https://b.example/x");
260
- });
261
-
262
- it("stops only at a redirect without a location header", async () => {
263
- const transport: fetchtransport = async (url): Promise<transportresponse> =>
264
- url === "https://a.example/hop" ? response(302, "", {}, "") : response(200, "ok");
265
- const outcome = await sendfetch({ request: { url: "https://a.example/hop" }, transport, sleep: instant });
266
- expect(outcome.status).toBe(302);
267
- expect(outcome.redirects).toBe(0);
268
- });
269
-
270
- it("retries failed attempts with the backoff wait and reports the reason", async () => {
271
- const waits: number[] = [];
272
- let attempts = 0;
273
- const transport: fetchtransport = async (): Promise<transportresponse> => {
274
- attempts += 1;
275
- if (attempts < 3) throw new Error("socket hangup");
276
- return response(200, "third time");
277
- };
278
- const outcome = await sendfetch({
279
- request: { url: "https://a.example/" },
280
- options: { retries: 3, backoff: 25 },
281
- transport,
282
- sleep: async (ms) => {
283
- waits.push(ms);
284
- },
285
- onretry: (attempt, wait, reason) => {
286
- waits.push(-attempt);
287
- void reason;
288
- },
289
- });
290
- expect(outcome.body).toBe("third time");
291
- expect(outcome.retries).toBe(2);
292
- expect(waits).toContain(25);
293
- expect(waits).toContain(50);
294
- expect(waits).toContain(-1);
295
- expect(waits).toContain(-2);
296
- });
297
-
298
- it("throws the attempt accounting when every attempt fails", async () => {
299
- const transport: fetchtransport = async (): Promise<transportresponse> => {
300
- throw new Error("refused");
301
- };
302
- await expect(
303
- sendfetch({ request: { url: "https://a.example/" }, options: { retries: 2 }, transport, sleep: instant }),
304
- ).rejects.toThrow(/failed after 3 attempts with 2 retries: refused/);
305
- await expect(sendfetch({ request: { url: "https://a.example/" }, transport, sleep: instant })).rejects.toThrow(
306
- /failed after 1 attempt with 0 retries/,
307
- );
308
- });
309
-
310
- it("times one attempt out inside the reviewed timeout and answers the attempt accounting", async () => {
311
- let slowcalls = 0;
312
- const transport: fetchtransport = async (url): Promise<transportresponse> => {
313
- if (url.includes("slow")) {
314
- slowcalls += 1;
315
- await new Promise((resolve) => setTimeout(resolve, 30));
316
- return response(200, "late");
317
- }
318
- return response(200, "fast");
319
- };
320
- await expect(
321
- sendfetch({
322
- request: { url: "https://a.example/slow" },
323
- options: { timeout: 5, retries: 0 },
324
- transport,
325
- sleep: instant,
326
- }),
327
- ).rejects.toThrow(/failed after 1 attempt with 0 retries: The request timed out after 5 milliseconds/);
328
- expect(slowcalls).toBe(1);
329
- const hanging: fetchtransport = async (): Promise<transportresponse> => new Promise(() => undefined);
330
- await expect(
331
- sendfetch({
332
- request: { url: "https://a.example/" },
333
- options: { timeout: 5 },
334
- transport: hanging,
335
- sleep: instant,
336
- }),
337
- ).rejects.toThrow(/timed out after 5 milliseconds/);
338
- });
339
-
340
- it("times the attempt out and retries until the next attempt succeeds", async () => {
341
- let calls = 0;
342
- const transport: fetchtransport = async (): Promise<transportresponse> => {
343
- calls += 1;
344
- if (calls === 1) await new Promise((resolve) => setTimeout(resolve, 30));
345
- return response(200, "recovered");
346
- };
347
- const outcome = await sendfetch({
348
- request: { url: "https://a.example/" },
349
- options: { timeout: 5, retries: 1 },
350
- transport,
351
- sleep: instant,
352
- });
353
- expect(outcome.body).toBe("recovered");
354
- expect(calls).toBe(2);
355
- expect(outcome.retries).toBe(1);
356
- });
357
-
358
- it("never strips a body from a POST and never adds one to a GET", async () => {
359
- const seen: Array<{ method: string; body?: string }> = [];
360
- const transport: fetchtransport = async (_url, init): Promise<transportresponse> => {
361
- seen.push({ method: init.method, ...(init.body !== undefined ? { body: init.body } : {}) });
362
- return response(200, "");
363
- };
364
- await sendfetch({
365
- request: { url: "https://a.example/", method: "POST", body: "payload" },
366
- transport,
367
- sleep: instant,
368
- });
369
- await sendfetch({
370
- request: { url: "https://a.example/", method: "GET", body: "dropped" },
371
- transport,
372
- sleep: instant,
373
- });
374
- await sendfetch({ request: { url: "https://a.example/", method: "HEAD" }, transport, sleep: instant });
375
- expect(seen[0]).toEqual({ method: "POST", body: "payload" });
376
- expect(seen[1]).toEqual({ method: "GET" });
377
- expect(seen[2]).toEqual({ method: "HEAD" });
378
- });
379
- });
380
-
381
- describe("torture: stream reading budget", () => {
382
- it("aborts exactly past the byte budget while the exact fit passes", async () => {
383
- const exact = await readstream({ chunks: ["abc", "def"], window: { budget: 6 } });
384
- expect(exact).toEqual({ chunks: 2, bytes: 6, aborted: false });
385
- const over = await readstream({ chunks: ["abc", "def"], window: { budget: 5 } });
386
- expect(over.aborted).toBe(true);
387
- expect(over.bytes).toBe(3);
388
- expect(over.chunks).toBe(1);
389
- expect(over.reason).toMatch(/aborted at 6 bytes past the reviewed byte budget of 5/);
390
- const zero = await readstream({ chunks: ["a"], window: { budget: 0 } });
391
- expect(zero.aborted).toBe(true);
392
- expect(zero.chunks).toBe(0);
393
- const empty = await readstream({ chunks: [], window: { budget: 10 } });
394
- expect(empty).toEqual({ chunks: 0, bytes: 0, aborted: false });
395
- });
396
-
397
- it("stops cleanly on the reviewed abort flag between chunks", async () => {
398
- let stop = false;
399
- const outcome = await readstream({ chunks: ["one", "two", "three"], window: { abort: () => stop, budget: 100 } });
400
- expect(outcome.aborted).toBe(false);
401
- expect(outcome.chunks).toBe(3);
402
- stop = true;
403
- const halted = await readstream({ chunks: ["one", "two"], window: { abort: () => stop } });
404
- expect(halted).toEqual({
405
- chunks: 0,
406
- bytes: 0,
407
- aborted: true,
408
- reason: "The reviewed abort flag stopped the stream.",
409
- });
410
- });
411
-
412
- it("reads a pull function to its exhaustion", async () => {
413
- let pulls = 0;
414
- const outcome = await readstream({
415
- chunks: async () => {
416
- pulls += 1;
417
- return pulls <= 2 ? "x" : undefined;
418
- },
419
- window: {},
420
- });
421
- expect(outcome).toEqual({ chunks: 2, bytes: 2, aborted: false });
422
- expect(pulls).toBe(3);
423
- });
424
- });
425
-
426
- describe("torture: json path reading and graphql envelopes", () => {
427
- it("reads dotted paths with array indexes, defaults and misses", () => {
428
- const parsed = { user: { name: "ada", tags: ["a", "b"] }, count: 3, active: true };
429
- expect(readpath(parsed, [{ name: "n", path: "user.name", kind: "text" }])[0]).toMatchObject({
430
- name: "n",
431
- value: "ada",
432
- });
433
- expect(readpath(parsed, [{ name: "t", path: "user.tags.1", kind: "text" }])[0]?.value).toBe("b");
434
- expect(readpath(parsed, [{ name: "c", path: "count", kind: "number" }])[0]?.value).toBe(3);
435
- expect(readpath(parsed, [{ name: "b", path: "active", kind: "boolean" }])[0]?.value).toBe(true);
436
- expect(readpath(parsed, [{ name: "b", path: "missing.deep", kind: "number", default: -1 }])[0]).toMatchObject({
437
- value: -1,
438
- missing: true,
439
- });
440
- expect(readpath(parsed, [{ name: "x", path: "user.name", kind: "number" }])[0]?.missing).toBe(true);
441
- expect(readpath(null, [{ name: "x", path: "a.b" }])[0]?.missing).toBe(true);
442
- expect(readpath(parsed, [{ name: "x", path: "count", kind: "text" }])[0]?.value).toBe("3");
443
- });
444
-
445
- it("wraps and unwraps graphql envelopes while malformed answers refuse", () => {
446
- expect(
447
- graphqlopenvelope({ query: "query { x }", operationkind: "query", variables: { a: 1 }, operationname: "op" }),
448
- ).toBe(JSON.stringify({ query: "query { x }", variables: { a: 1 }, operationName: "op" }));
449
- expect(unwrapgraphql({ data: { x: 1 }, errors: [{ message: "bad" }, "plain", 5] })).toEqual({
450
- data: { x: 1 },
451
- errors: ["bad", "plain", "5"],
452
- });
453
- expect(unwrapgraphql("nope")).toEqual({ errors: ["The graphql response is not a json object."] });
454
- expect(unwrapgraphql([1])).toEqual({ errors: ["The graphql response is not a json object."] });
455
- expect(unwrapgraphql({ errors: null })).toEqual({ errors: [] });
456
- expect(graphqlrequestof({ query: " ", operationkind: "query" })).toBeUndefined();
457
- expect(graphqlrequestof({ query: "x", operationkind: "mutation" })).toBeDefined();
458
- expect(graphqlrequestof({ query: "x", operationkind: "subscribe" as never })).toBeUndefined();
459
- expect(graphqlrequestof({ query: "x", operationkind: "query", variables: [1] })).toEqual({
460
- query: "x",
461
- operationkind: "query",
462
- });
463
- });
464
-
465
- it("parses html bodies through the injected seam with the multi flag", () => {
466
- const seam = (markup: string) => ({
467
- query: (selector: string) =>
468
- selector === "p" ? markup.split("|").map((text) => ({ text, attributes: {} })) : [],
469
- });
470
- const outcome = parsehtmlbody({
471
- body: "a|b|c",
472
- queries: [{ selector: "p", multi: true }, { selector: "p" }],
473
- parse: seam,
474
- });
475
- expect(outcome[0]?.values).toEqual(["a", "b", "c"]);
476
- expect(outcome[0]?.count).toBe(3);
477
- expect(outcome[1]?.values).toEqual(["a"]);
478
- expect(outcome[1]?.multi).toBe(false);
479
- const attr = parsehtmlbody({ body: "x", queries: [{ selector: "p", attribute: "href" }], parse: seam });
480
- expect(attr[0]?.values).toEqual([""]);
481
- });
482
-
483
- it("validates and defaults payload schemas", () => {
484
- const schema = {
485
- fields: [
486
- { name: "id", kind: "string" as const, required: true },
487
- { name: "n", kind: "number" as const },
488
- { name: "b", kind: "boolean" as const, default: false },
489
- ],
490
- };
491
- expect(payloadvalid({ id: "x", n: 1 }, schema)).toEqual({ ok: true, errors: [] });
492
- expect(payloadvalid({}, undefined)).toEqual({
493
- ok: false,
494
- errors: ["The typed endpoint call needs a reviewed payload schema before it runs."],
495
- });
496
- expect(payloadvalid({ id: null }, schema).errors[0]).toMatch(/required field id of kind string is missing/);
497
- expect(payloadvalid({ id: 1 }, schema).errors[0]).toMatch(/field id must be a string/);
498
- expect(payloadvalid({ id: "x", n: "nan" }, schema).errors[0]).toMatch(/field n must be a finite number/);
499
- expect(payloadvalid({ id: "x", b: "yes" }, schema).errors[0]).toMatch(/field b must be a boolean/);
500
- expect(payloadwithdefaults({ id: "x" }, schema)).toEqual({ id: "x", b: false });
501
- expect(payloadwithdefaults({ id: "x" }, undefined)).toEqual({ id: "x" });
502
- });
503
- });
504
-
505
- describe("torture: http transport endpoints and routing", () => {
506
- it("builds the endpoint records from the config and refuses path confusion", () => {
507
- const config: mcpserverconfig = {
508
- ...defaultmcpconfig(),
509
- port: 9000,
510
- httpstream: { endpoint: "/agent", streampath: "/agent/stream", tls: { mode: "off" } },
511
- };
512
- const endpoint = httpendpoint(config);
513
- expect(endpoint).toMatchObject({
514
- kind: "http",
515
- endpoint: "http://127.0.0.1:9000/agent",
516
- bind: localhostbind,
517
- port: 9000,
518
- localhost: true,
519
- path: "/agent",
520
- });
521
- expect(streampathof(config)).toMatchObject({ path: "/agent/stream", bind: localhostbind, localhost: true });
522
- const defaulted = httpendpoint({
523
- ...defaultmcpconfig(),
524
- port: 7436,
525
- httpstream: { endpoint: " ", streampath: " ", tls: { mode: "off" } },
526
- });
527
- expect(defaulted.path).toBe("/mcp");
528
- expect(streampathof({ ...defaultmcpconfig(), port: 7436 }).path).toBe("/mcp/stream");
529
- expect(
530
- streampathof({
531
- ...defaultmcpconfig(),
532
- port: 7436,
533
- httpstream: { endpoint: "/mcp", streampath: "agent", tls: { mode: "off" } },
534
- }).path,
535
- ).toBe("agent");
536
- });
537
-
538
- it("routes exactly the endpoint and stream paths", () => {
539
- const config: mcpserverconfig = { ...defaultmcpconfig(), httpstream: defaulthttpstream() };
540
- expect(routepath(config, "/mcp")).toBe("endpoint");
541
- expect(routepath(config, "/mcp/stream")).toBe("stream");
542
- expect(routepath(config, "/mcp/")).toBeUndefined();
543
- expect(routepath(config, "/mcp?x=1")).toBeUndefined();
544
- expect(routepath(config, "/mcp/stream/")).toBeUndefined();
545
- expect(routepath(config, "/")).toBeUndefined();
546
- expect(routepath(config, "")).toBeUndefined();
547
- expect(routepath(config, "//mcp")).toBeUndefined();
548
- });
549
-
550
- it("decides tls under every hostile configuration", () => {
551
- expect(tlsdecision({ config: { mode: "off" }, presented: { fingerprint: "sha256:zz" }, now })).toEqual({
552
- tls: false,
553
- verified: false,
554
- });
555
- expect(tlsdecision({ config: { mode: "required" }, now }).reason).toMatch(/presented no certificate/i);
556
- expect(
557
- tlsdecision({
558
- config: { mode: "on", certificatefingerprint: "sha256:aa" },
559
- presented: { fingerprint: "sha256:AA" },
560
- now,
561
- }).reason,
562
- ).toMatch(/does not match the user configured fingerprint/i);
563
- expect(
564
- tlsdecision({
565
- config: { mode: "required", certificatefingerprint: "sha256:aa" },
566
- presented: { fingerprint: "sha256:aa" },
567
- now,
568
- }),
569
- ).toEqual({ tls: true, verified: true });
570
- expect(
571
- tlsdecision({ config: { mode: "required", certificatefingerprint: "" }, presented: { fingerprint: "" }, now }),
572
- ).toEqual({ tls: true, verified: true });
573
- });
574
- });
575
-
576
- describe("torture: posted frame intake and sse framing", () => {
577
- it("parses one frame, one batch and refuses the mixed or garbage bodies", () => {
578
- const frame = { jsonrpc: "2.0", id: 1, method: "ping" };
579
- expect(parsepost(JSON.stringify(frame))).toEqual({ message: frame });
580
- expect(parsepost(JSON.stringify([frame, { jsonrpc: "2.0", id: 2, method: "ping" }]))).toEqual({
581
- message: [frame, { jsonrpc: "2.0", id: 2, method: "ping" }],
582
- });
583
- expect(parsepost(JSON.stringify([frame, "garbage"]))).toEqual({
584
- error: { code: "parse", message: "The posted body does not parse as one json rpc message." },
585
- });
586
- expect(parsepost("[]")).toEqual({
587
- error: { code: "parse", message: "The posted body does not parse as one json rpc message." },
588
- });
589
- expect(parsepost("null")).toEqual({
590
- error: { code: "parse", message: "The posted body does not parse as one json rpc message." },
591
- });
592
- expect(parsepost("42")).toEqual({
593
- error: { code: "parse", message: "The posted body does not parse as one json rpc message." },
594
- });
595
- expect(parsepost("{nope")).toEqual({
596
- error: { code: "parse", message: "The posted body does not parse as one json rpc message." },
597
- });
598
- expect(parsepost("")).toEqual({
599
- error: { code: "parse", message: "The posted body does not parse as one json rpc message." },
600
- });
601
- });
602
-
603
- it("formats the http answer and the server sent event frames exactly", () => {
604
- expect(httpanswer({ jsonrpc: "2.0", id: 7, result: { pong: true } })).toBe(
605
- `${JSON.stringify({ jsonrpc: "2.0", id: 7, result: { pong: true } })}\n`,
606
- );
607
- expect(
608
- httpanswer([
609
- { jsonrpc: "2.0", id: 1, result: 1 },
610
- { jsonrpc: "2.0", id: 2, result: 2 },
611
- ] as never),
612
- ).toBe(
613
- `${JSON.stringify([
614
- { jsonrpc: "2.0", id: 1, result: 1 },
615
- { jsonrpc: "2.0", id: 2, result: 2 },
616
- ])}\n`,
617
- );
618
- const event = sseframe({ id: 12, event: "message", frame: { jsonrpc: "2.0", id: 9, result: null } });
619
- expect(event).toBe(`id: 12\nevent: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: 9, result: null })}\n\n`);
620
- expect(sseframe({ id: 0, event: "", frame: { jsonrpc: "2.0" } }).startsWith("id: 0\n")).toBe(true);
621
- });
622
- });
623
-
624
- describe("torture: stream channels, heartbeats and the client ceiling", () => {
625
- it("opens, beats and sweeps channels with exact idle window edges", () => {
626
- const open = openstreamchannel({ clientid: "c1", now });
627
- expect(open.id).toMatch(/^channel-/);
628
- expect(open.clientid).toBe("c1");
629
- expect(open.openedat).toBe(now);
630
- const beaten = heartbeat({
631
- channels: [open, { ...open, clientid: "c2", closedat: now - 5 }],
632
- clientid: "c1",
633
- now: now + 1000,
634
- });
635
- expect(beaten[0]?.lastbeatat).toBe(now + 1000);
636
- expect(beaten[1]?.lastbeatat).toBe(now);
637
- expect(defaulthttpstream()).toEqual({
638
- endpoint: "/mcp",
639
- streampath: "/mcp/stream",
640
- tls: { mode: "off" },
641
- heartbeatms: defaultheartbeatms,
642
- idlewindowms: defaultidlewindowms,
643
- });
644
- });
645
-
646
- it("treats the exact idle window edge as dead while one millisecond under stays live", () => {
647
- const channel: streamchannel = { id: "ch", clientid: "c1", openedat: now - 100, lastbeatat: now - 10_000 };
648
- expect(channellive(channel, now, 10_000)).toBe(false);
649
- expect(channellive(channel, now - 1, 10_000)).toBe(true);
650
- expect(channellive({ ...channel, closedat: now }, now - 100_000, 1_000_000)).toBe(false);
651
- expect(channellive(channel, now)).toBe(true);
652
- expect(channellive(channel, now, 9_999)).toBe(false);
653
- expect(channellive({ ...channel, lastbeatat: now }, now, 1)).toBe(true);
654
- const swept = closeidlechannels({
655
- channels: [channel, { ...channel, id: "ch2", lastbeatat: now }],
656
- now,
657
- idlewindow: 10_000,
658
- });
659
- expect(swept[0]?.closedat).toBe(now);
660
- expect(swept[1]?.closedat).toBeUndefined();
661
- });
662
-
663
- it("enforces the client ceiling at the exact boundary", () => {
664
- const clients: clientrecord[] = [
665
- { id: "a", transport: "http", paired: true, connectedat: now },
666
- { id: "b", transport: "http", paired: false, connectedat: now },
667
- ];
668
- expect(enforcemaxclients({ clients, maxclients: 2 }).allowed).toBe(false);
669
- expect(enforcemaxclients({ clients, maxclients: 2 }).reason).toMatch(/maximum of 2 remote clients is reached/);
670
- expect(enforcemaxclients({ clients, maxclients: 3 }).allowed).toBe(true);
671
- expect(enforcemaxclients({ clients }).allowed).toBe(true);
672
- expect(
673
- enforcemaxclients({ clients: [{ ...clients[0]!, disconnectedat: now }, clients[1]!], maxclients: 2 }).allowed,
674
- ).toBe(true);
675
- expect(enforcemaxclients({ clients, maxclients: 0 }).allowed).toBe(false);
676
- expect(enforcemaxclients({ clients, maxclients: Number.NaN }).reason).toMatch(/maximum of NaN/);
677
- });
678
-
679
- it("reports the remote status of a configured transport", () => {
680
- const config: mcpserverconfig = {
681
- ...defaultmcpconfig(),
682
- enabled: true,
683
- remoteaccess: { endpoint: "https://agent.example", tls: { mode: "on" }, maxclients: 5 },
684
- };
685
- const status = listremotestatus({
686
- config,
687
- channels: [],
688
- clients: [{ id: "c1", transport: "http", paired: false, connectedat: now }],
689
- tokens: [],
690
- now,
691
- });
692
- expect(status.endpoint).toBe("https://agent.example");
693
- expect(status.tls.mode).toBe("on");
694
- expect(status.tls.certificaterequired).toBe(false);
695
- expect(status.clients).toBe(1);
696
- expect(status.paired).toBe(0);
697
- });
698
- });
699
-
700
- describe("torture: the ordered remote frame pipeline", () => {
701
- it("refuses before the token when tls fails and never leaks pairing state", async () => {
702
- const config: mcpserverconfig = {
703
- ...defaultmcpconfig(),
704
- enabled: true,
705
- remoteaccess: {
706
- endpoint: "https://agent.example",
707
- tls: { mode: "required", certificatefingerprint: "sha256:aa" },
708
- },
709
- };
710
- const fixture = await pipelinefixture();
711
- const refused = await httpframepipeline({
712
- config,
713
- presented: { fingerprint: "sha256:zz" },
714
- tokens: fixture.tokens,
715
- rawtoken: "totally-wrong-token",
716
- allowlist: fixture.allowlist,
717
- fingerprint: "fp-unknown",
718
- now,
719
- });
720
- expect(refused.error?.code).toBe("consentrefused");
721
- expect(refused.error?.message).toMatch(/does not match the user configured fingerprint/);
722
- expect(refused.error?.message).not.toContain("raw-token");
723
- });
724
-
725
- it("answers the fixed auth message for missing, wrong, revoked and expired tokens", async () => {
726
- const config: mcpserverconfig = { ...defaultmcpconfig(), enabled: true };
727
- const fixture = await pipelinefixture();
728
- const missing = await httpframepipeline({
729
- config,
730
- tokens: fixture.tokens,
731
- allowlist: fixture.allowlist,
732
- fingerprint: "fp-1",
733
- now,
734
- });
735
- expect(missing.error?.message).toBe("The remote frame failed its authentication handshake.");
736
- const wrong = await httpframepipeline({
737
- config,
738
- tokens: fixture.tokens,
739
- rawtoken: "",
740
- allowlist: fixture.allowlist,
741
- fingerprint: "fp-1",
742
- now,
743
- });
744
- expect(wrong.error?.message).toBe("The remote frame failed its authentication handshake.");
745
- const emptytokens = await httpframepipeline({
746
- config,
747
- tokens: [],
748
- rawtoken: fixture.raw,
749
- allowlist: fixture.allowlist,
750
- fingerprint: "fp-1",
751
- now,
752
- });
753
- expect(emptytokens.error?.message).toBe("The remote frame failed its authentication handshake.");
754
- const revokedtoken = { ...fixture.tokens[0]!, revokedat: now - 1 };
755
- const revoked = await httpframepipeline({
756
- config,
757
- tokens: [revokedtoken],
758
- rawtoken: fixture.raw,
759
- allowlist: fixture.allowlist,
760
- fingerprint: "fp-1",
761
- now,
762
- });
763
- expect(revoked.error?.message).toBe("The remote frame failed its authentication handshake.");
764
- const expiredtoken = { ...fixture.tokens[0]!, expiresat: now };
765
- const expired = await httpframepipeline({
766
- config,
767
- tokens: [expiredtoken],
768
- rawtoken: fixture.raw,
769
- allowlist: fixture.allowlist,
770
- fingerprint: "fp-1",
771
- now,
772
- });
773
- expect(expired.error?.message).toBe("The remote frame failed its authentication handshake.");
774
- });
775
-
776
- it("refuses unknown fingerprints and ungranted scopes after the token verified", async () => {
777
- const config: mcpserverconfig = { ...defaultmcpconfig(), enabled: true };
778
- const fixture = await pipelinefixture();
779
- const unknownfp = await httpframepipeline({
780
- config,
781
- tokens: fixture.tokens,
782
- rawtoken: fixture.raw,
783
- allowlist: fixture.allowlist,
784
- fingerprint: "fp-attacker",
785
- now,
786
- });
787
- expect(unknownfp.error?.message).toMatch(/fp-attacker is not on the allowlist/);
788
- const namespaceoff = await httpframepipeline({
789
- config,
790
- tokens: fixture.tokens,
791
- rawtoken: fixture.raw,
792
- allowlist: fixture.allowlist,
793
- fingerprint: "fp-1",
794
- toolname: "memory.list",
795
- now,
796
- });
797
- expect(namespaceoff.error?.code).toBe("consentrefused");
798
- expect(namespaceoff.error?.message).toMatch(/grants no memory tools/);
799
- const unknownnamespace = await httpframepipeline({
800
- config,
801
- tokens: fixture.tokens,
802
- rawtoken: fixture.raw,
803
- allowlist: fixture.allowlist,
804
- fingerprint: "fp-1",
805
- toolname: "spy.satellite",
806
- now,
807
- });
808
- expect(unknownnamespace.error?.code).toBe("params");
809
- expect(unknownnamespace.error?.message).toMatch(/no reviewed namespace/);
810
- });
811
-
812
- it("verifies a clean frame and returns its token", async () => {
813
- const config: mcpserverconfig = { ...defaultmcpconfig(), enabled: true, httpstream: defaulthttpstream() };
814
- const fixture = await pipelinefixture();
815
- const clean = await httpframepipeline({
816
- config,
817
- tokens: fixture.tokens,
818
- rawtoken: fixture.raw,
819
- allowlist: fixture.allowlist,
820
- fingerprint: "fp-1",
821
- toolname: "browser.readtext",
822
- now,
823
- });
824
- expect(clean.error).toBeUndefined();
825
- expect(clean.token?.clientid).toBe("client1");
826
- const tokenless = await httpframepipeline({
827
- config,
828
- tokens: fixture.tokens,
829
- rawtoken: fixture.raw,
830
- allowlist: [],
831
- fingerprint: "fp-1",
832
- now,
833
- });
834
- expect(tokenless.error?.message).toMatch(/not on the allowlist/);
835
- });
836
- });
837
-
838
- describe("torture: relay server state machine", () => {
839
- /** Builds one relay session through the deterministic id and hash seams. */
840
- function createsession(
841
- state: ReturnType<typeof newrelayserverstate>,
842
- code: string,
843
- connectionid = "conn-ext",
844
- ): { state: ReturnType<typeof newrelayserverstate>; token: string; sessionid: string } {
845
- const outcome = relayserverframe({
846
- state,
847
- connectionid,
848
- frame: relayframeof({
849
- op: "sessioncreate",
850
- opid: "op-1",
851
- at: now,
852
- body: { role: "extension", pairingcode: code },
853
- }),
854
- now,
855
- idof: (() => {
856
- let n = 0;
857
- return () => `${++n}`;
858
- })(),
859
- hashof: (token) => `h(${token})`,
860
- });
861
- const reply = JSON.parse(outcome.replies[0] ?? "{}") as {
862
- sessionid: string;
863
- token: string;
864
- body: { sessionid: string; token: string };
865
- };
866
- return { state: outcome.state, token: reply.body.token, sessionid: reply.body.sessionid };
867
- }
868
-
869
- it("refuses the unregistered connection and the unparseable frame without state drift", () => {
870
- const state = newrelayserverstate();
871
- const silent = relayserverframe({ state, connectionid: "conn-ghost", frame: "{}", now });
872
- expect(silent).toEqual({ state, replies: [], routed: [] });
873
- const registered = relayconnectionopen(state, "conn-1", now);
874
- const parse = relayserverframe({ state: registered, connectionid: "conn-1", frame: "not json", now });
875
- expect(parse.replies[0]).toContain("does not parse as a servercontract envelope");
876
- expect(parse.routed).toEqual([]);
877
- const emptyid = relayserverframe({
878
- state: registered,
879
- connectionid: "conn-1",
880
- frame: JSON.stringify({ version: 1, op: "eventpost", opid: "", at: now }),
881
- now,
882
- });
883
- expect(emptyid.replies[0]).toContain("does not parse as a servercontract envelope");
884
- });
885
-
886
- it("registers connections once and drops their tokens on close", () => {
887
- let state = relayconnectionopen(newrelayserverstate(), "conn-1", now);
888
- state = relayconnectionopen(state, "conn-1", now + 5);
889
- expect(state.connections).toHaveLength(1);
890
- expect(state.connections[0]?.lastframeat).toBe(now);
891
- expect(() => relayconnectionopen(state, " ", now)).toThrow(/empty connection id never registers/);
892
- const created = createsession(state, "DT-AAA", "conn-1");
893
- expect(
894
- created.state.tokens.some((record) => record.connectionid === "conn-1" && record.token === created.token),
895
- ).toBe(true);
896
- const closed = relayconnectionclose(created.state, "conn-1");
897
- expect(closed.connections).toHaveLength(0);
898
- expect(closed.tokens).toHaveLength(0);
899
- expect(closed.sessions).toHaveLength(1);
900
- });
901
-
902
- it("serves the sessioncreate operation for the extension member only", () => {
903
- let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
904
- state = relayconnectionopen(state, "conn-attacker", now);
905
- const attacker = relayserverframe({
906
- state,
907
- connectionid: "conn-attacker",
908
- frame: relayframeof({ op: "sessioncreate", opid: "op-a", at: now, body: { role: "site" } }),
909
- now,
910
- idof: () => "9",
911
- hashof: (t) => `h(${t})`,
912
- });
913
- expect(attacker.replies[0]).toContain("serves the extension member only");
914
- expect(attacker.state.sessions).toHaveLength(0);
915
- const created = relayserverframe({
916
- state,
917
- connectionid: "conn-ext",
918
- frame: relayframeof({
919
- op: "sessioncreate",
920
- opid: "op-1",
921
- at: now,
922
- body: { role: "extension", memberid: "ext-1" },
923
- }),
924
- now,
925
- idof: () => "1",
926
- hashof: (t) => `h(${t})`,
927
- });
928
- const reply = JSON.parse(created.replies[0] ?? "{}") as {
929
- body: { members: Array<{ role: string; id: string }>; capabilities: unknown };
930
- };
931
- expect(reply.body.members).toEqual([{ role: "extension", id: "ext-1", joinedat: now }]);
932
- expect(created.state.tokens).toHaveLength(1);
933
- expect(created.state.pairings).toHaveLength(0);
934
- });
935
-
936
- it("exchanges each pairing code exactly once and refuses the second spend", () => {
937
- let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
938
- state = relayconnectionopen(state, "conn-site", now);
939
- state = relayconnectionopen(state, "conn-attacker", now);
940
- const created = createsession(state, "DT-PAIR");
941
- expect(created.state.pairings[0]).toMatchObject({ code: "DT-PAIR", used: false });
942
- const firstjoin = relayserverframe({
943
- state: created.state,
944
- connectionid: "conn-site",
945
- frame: relayframeof({
946
- op: "sessionjoin",
947
- opid: "op-2",
948
- at: now + 1,
949
- body: { role: "site", pairingcode: "DT-PAIR" },
950
- }),
951
- now: now + 1,
952
- idof: () => "2",
953
- hashof: (t) => `h(${t})`,
954
- });
955
- const joinreply = JSON.parse(firstjoin.replies[0] ?? "{}") as { body: { token: string; sessionid: string } };
956
- expect(joinreply.body.token).toBe("token-2");
957
- expect(firstjoin.state.pairings[0]?.used).toBe(true);
958
- const secondspend = relayserverframe({
959
- state: firstjoin.state,
960
- connectionid: "conn-attacker",
961
- frame: relayframeof({
962
- op: "sessionjoin",
963
- opid: "op-3",
964
- at: now + 2,
965
- body: { role: "site", pairingcode: "DT-PAIR" },
966
- }),
967
- now: now + 2,
968
- idof: () => "3",
969
- hashof: (t) => `h(${t})`,
970
- });
971
- expect(secondspend.replies[0]).toContain("matches no pending session");
972
- expect(secondspend.state.sessions).toHaveLength(1);
973
- });
974
-
975
- it("refuses expired and wrong pairing codes", () => {
976
- let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
977
- state = relayconnectionopen(state, "conn-site", now);
978
- const created = createsession(state, "DT-EXP");
979
- const expired = relayserverframe({
980
- state: created.state,
981
- connectionid: "conn-site",
982
- frame: relayframeof({
983
- op: "sessionjoin",
984
- opid: "op-2",
985
- at: now + 300_001,
986
- body: { role: "site", pairingcode: "DT-EXP" },
987
- }),
988
- now: now + 300_001,
989
- idof: () => "2",
990
- hashof: (t) => `h(${t})`,
991
- });
992
- expect(expired.replies[0]).toContain("matches no pending session");
993
- const wrong = relayserverframe({
994
- state: created.state,
995
- connectionid: "conn-site",
996
- frame: relayframeof({
997
- op: "sessionjoin",
998
- opid: "op-2",
999
- at: now + 1,
1000
- body: { role: "site", pairingcode: "DT-OTHER" },
1001
- }),
1002
- now: now + 1,
1003
- idof: () => "2",
1004
- hashof: (t) => `h(${t})`,
1005
- });
1006
- expect(wrong.replies[0]).toContain("matches no pending session");
1007
- const whitespace = relayserverframe({
1008
- state: created.state,
1009
- connectionid: "conn-site",
1010
- frame: relayframeof({ op: "sessionjoin", opid: "op-2", at: now + 1, body: { role: "site", pairingcode: " " } }),
1011
- now: now + 1,
1012
- idof: () => "2",
1013
- hashof: (t) => `h(${t})`,
1014
- });
1015
- expect(whitespace.replies[0]).toContain("failed the token authentication");
1016
- });
1017
-
1018
- it("refuses a second site member while the session holds one", () => {
1019
- let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
1020
- state = relayconnectionopen(state, "conn-site", now);
1021
- state = relayconnectionopen(state, "conn-evil", now);
1022
- const created = createsession(state, "DT-SITE");
1023
- const first = relayserverframe({
1024
- state: created.state,
1025
- connectionid: "conn-site",
1026
- frame: relayframeof({
1027
- op: "sessionjoin",
1028
- opid: "op-2",
1029
- at: now + 1,
1030
- body: { role: "site", pairingcode: "DT-SITE" },
1031
- }),
1032
- now: now + 1,
1033
- idof: () => "2",
1034
- hashof: (t) => `h(${t})`,
1035
- });
1036
- expect(JSON.parse(first.replies[0] ?? "{}").body.token).toBe("token-2");
1037
- const second = relayserverframe({
1038
- state: first.state,
1039
- connectionid: "conn-evil",
1040
- frame: relayframeof({
1041
- op: "sessionjoin",
1042
- opid: "op-3",
1043
- at: now + 2,
1044
- body: { role: "site", pairingcode: "DT-SITE" },
1045
- }),
1046
- now: now + 2,
1047
- idof: () => "3",
1048
- hashof: (t) => `h(${t})`,
1049
- });
1050
- expect(second.replies[0]).toContain("matches no pending session");
1051
- });
1052
-
1053
- it("refuses forged tokens on every operation without touching the session state", () => {
1054
- let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
1055
- state = relayconnectionopen(state, "conn-site", now);
1056
- state = relayconnectionopen(state, "conn-evil", now);
1057
- const created = createsession(state, "DT-TOK");
1058
- const forged = relayserverframe({
1059
- state: created.state,
1060
- connectionid: "conn-evil",
1061
- frame: relayframeof({
1062
- op: "eventpost",
1063
- opid: "op-e",
1064
- sessionid: created.sessionid,
1065
- token: "forged-token",
1066
- at: now + 1,
1067
- body: { kind: "chat", stream: "chat" },
1068
- }),
1069
- now: now + 1,
1070
- idof: () => "2",
1071
- hashof: (t) => `h(${t})`,
1072
- });
1073
- expect(forged.replies[0]).toContain("failed the token authentication of its session");
1074
- expect(forged.state.sessions[0]?.events).toHaveLength(0);
1075
- const tokenless = relayserverframe({
1076
- state: created.state,
1077
- connectionid: "conn-ext",
1078
- frame: relayframeof({
1079
- op: "eventpost",
1080
- opid: "op-e",
1081
- sessionid: created.sessionid,
1082
- at: now + 1,
1083
- body: { kind: "chat" },
1084
- }),
1085
- now: now + 1,
1086
- idof: () => "2",
1087
- hashof: (t) => `h(${t})`,
1088
- });
1089
- expect(tokenless.replies[0]).toContain("failed the token authentication");
1090
- const wrongsession = relayserverframe({
1091
- state: created.state,
1092
- connectionid: "conn-ext",
1093
- frame: relayframeof({
1094
- op: "eventpost",
1095
- opid: "op-e",
1096
- sessionid: "session-other",
1097
- token: created.token,
1098
- at: now + 1,
1099
- body: { kind: "chat" },
1100
- }),
1101
- now: now + 1,
1102
- idof: () => "2",
1103
- hashof: (t) => `h(${t})`,
1104
- });
1105
- expect(wrongsession.replies[0]).toContain("failed the token authentication");
1106
- });
1107
-
1108
- it("routes events to the other member with the member token and never echoes secrets", () => {
1109
- let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
1110
- state = relayconnectionopen(state, "conn-site", now);
1111
- const created = createsession(state, "DT-ROUTE");
1112
- const joined = relayserverframe({
1113
- state: created.state,
1114
- connectionid: "conn-site",
1115
- frame: relayframeof({
1116
- op: "sessionjoin",
1117
- opid: "op-2",
1118
- at: now + 1,
1119
- body: { role: "site", pairingcode: "DT-ROUTE" },
1120
- }),
1121
- now: now + 1,
1122
- idof: () => "2",
1123
- hashof: (t) => `h(${t})`,
1124
- });
1125
- const sitetoken = (JSON.parse(joined.replies[0] ?? "{}") as { body: { token: string } }).body.token;
1126
- const posted = relayserverframe({
1127
- state: joined.state,
1128
- connectionid: "conn-ext",
1129
- frame: relayframeof({
1130
- op: "eventpost",
1131
- opid: "op-3",
1132
- sessionid: created.sessionid,
1133
- token: created.token,
1134
- at: now + 2,
1135
- body: { kind: "chat", stream: "chat", payload: { text: "hello" } },
1136
- }),
1137
- now: now + 2,
1138
- idof: () => "3",
1139
- hashof: (t) => `h(${t})`,
1140
- });
1141
- expect(posted.routed).toHaveLength(1);
1142
- expect(posted.routed[0]?.connectionid).toBe("conn-site");
1143
- const routedframe = JSON.parse(posted.routed[0]?.frame ?? "{}") as {
1144
- token?: string;
1145
- body: { payload: { text: string } };
1146
- };
1147
- expect(routedframe.token).toBe(sitetoken);
1148
- expect(routedframe.body.payload.text).toBe("hello");
1149
- const reply = JSON.parse(posted.replies[0] ?? "{}") as { body: { accepted: boolean } };
1150
- expect(reply.body.accepted).toBe(true);
1151
- expect(posted.state.sessions[0]?.events[0]).toMatchObject({ opid: "op-3", kind: "chat", stream: "chat" });
1152
- });
1153
-
1154
- it("acks the eventstream subscription and refuses the operations outside the contract", () => {
1155
- let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
1156
- const created = createsession(state, "DT-STREAM");
1157
- const subscribed = relayserverframe({
1158
- state: created.state,
1159
- connectionid: "conn-ext",
1160
- frame: relayframeof({
1161
- op: "eventstream",
1162
- opid: "op-2",
1163
- sessionid: created.sessionid,
1164
- token: created.token,
1165
- at: now + 1,
1166
- body: { streams: ["chat", "review"], subscribed: true },
1167
- }),
1168
- now: now + 1,
1169
- idof: () => "2",
1170
- hashof: (t) => `h(${t})`,
1171
- });
1172
- const ack = JSON.parse(subscribed.replies[0] ?? "{}") as { body: { streams: string[]; subscribed: boolean } };
1173
- expect(ack.body.streams).toEqual(["chat", "review"]);
1174
- expect(ack.body.subscribed).toBe(true);
1175
- const invalidstreams = relayserverframe({
1176
- state: created.state,
1177
- connectionid: "conn-ext",
1178
- frame: relayframeof({
1179
- op: "eventstream",
1180
- opid: "op-3",
1181
- sessionid: created.sessionid,
1182
- token: created.token,
1183
- at: now + 1,
1184
- body: { streams: "chat" },
1185
- }),
1186
- now: now + 1,
1187
- idof: () => "3",
1188
- hashof: (t) => `h(${t})`,
1189
- });
1190
- expect(JSON.parse(invalidstreams.replies[0] ?? "{}").body.streams).toEqual([]);
1191
- const outside = relayserverframe({
1192
- state: created.state,
1193
- connectionid: "conn-ext",
1194
- frame: JSON.stringify({ version: 1, op: "eventack", opid: "op-4", at: now + 1 }),
1195
- now: now + 1,
1196
- idof: () => "4",
1197
- hashof: (t) => `h(${t})`,
1198
- });
1199
- expect(outside.replies[0]).toContain("does not parse as a servercontract envelope");
1200
- });
1201
-
1202
- it("rotates the token of a returning member and revokes the previous one", () => {
1203
- let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
1204
- const created = createsession(state, "");
1205
- const rotated = relayserverframe({
1206
- state: created.state,
1207
- connectionid: "conn-ext",
1208
- frame: relayframeof({
1209
- op: "sessionjoin",
1210
- opid: "op-2",
1211
- sessionid: created.sessionid,
1212
- token: created.token,
1213
- at: now + 1,
1214
- body: { role: "extension" },
1215
- }),
1216
- now: now + 1,
1217
- idof: () => "9",
1218
- hashof: (t) => `h(${t})`,
1219
- });
1220
- const reply = JSON.parse(rotated.replies[0] ?? "{}") as { body: { token: string; rotated: boolean } };
1221
- expect(reply.body.token).toBe("token-9");
1222
- expect(reply.body.rotated).toBe(true);
1223
- const session = rotated.state.sessions[0]!;
1224
- expect(session.revoked).toContain(`h(${created.token})`);
1225
- expect(relaylivetokens(session)).not.toContain(`h(${created.token})`);
1226
- const replay = relayserverframe({
1227
- state: rotated.state,
1228
- connectionid: "conn-ext",
1229
- frame: relayframeof({
1230
- op: "eventpost",
1231
- opid: "op-3",
1232
- sessionid: created.sessionid,
1233
- token: created.token,
1234
- at: now + 2,
1235
- body: { kind: "chat", stream: "chat" },
1236
- }),
1237
- now: now + 2,
1238
- idof: () => "3",
1239
- hashof: (t) => `h(${t})`,
1240
- });
1241
- expect(replay.replies[0]).toContain("failed the token authentication");
1242
- const fresh = relayserverframe({
1243
- state: rotated.state,
1244
- connectionid: "conn-ext",
1245
- frame: relayframeof({
1246
- op: "eventpost",
1247
- opid: "op-4",
1248
- sessionid: created.sessionid,
1249
- token: "token-9",
1250
- at: now + 3,
1251
- body: { kind: "chat", stream: "chat" },
1252
- }),
1253
- now: now + 3,
1254
- idof: () => "4",
1255
- hashof: (t) => `h(${t})`,
1256
- });
1257
- expect(JSON.parse(fresh.replies[0] ?? "{}").body.accepted).toBe(true);
1258
- });
1259
-
1260
- it("sweeps idle connections and lists pending pairings inside their window", () => {
1261
- let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
1262
- state = relayconnectionopen(state, "conn-site", now - 60_000);
1263
- expect(relayidleconnections(state, now, 60_000)).toEqual(["conn-site"]);
1264
- expect(relayidleconnections(state, now, 60_001)).toEqual([]);
1265
- expect(relayidleconnections(state, now, Number.NaN)).toEqual([]);
1266
- expect(relayidleconnections(state, now, 0)).toEqual([]);
1267
- expect(relayidleconnections(state, now, -1)).toEqual([]);
1268
- const created = createsession(state, "DT-PEND");
1269
- expect(relaypendingpairings(created.state, now)).toEqual(["DT-PEND"]);
1270
- expect(relaypendingpairings(created.state, now + 300_000)).toEqual([]);
1271
- const joined = relayserverframe({
1272
- state: created.state,
1273
- connectionid: "conn-site",
1274
- frame: relayframeof({
1275
- op: "sessionjoin",
1276
- opid: "op-2",
1277
- at: now + 1,
1278
- body: { role: "site", pairingcode: "DT-PEND" },
1279
- }),
1280
- now: now + 1,
1281
- idof: () => "2",
1282
- hashof: (t) => `h(${t})`,
1283
- });
1284
- expect(relaypendingpairings(joined.state, now + 1)).toEqual([]);
1285
- expect(relayframeof({ op: "eventpost", opid: "op-x", at: now, body: {} })).toBe(
1286
- JSON.stringify({ version: 1, op: "eventpost", opid: "op-x", at: now, body: {} }),
1287
- );
1288
- });
1289
- });
1290
-
1291
- describe("torture: stdio framing", () => {
1292
- it("splits lines while partial tails stay buffered", () => {
1293
- expect(splitlines("a\nb\n")).toEqual({ lines: ["a", "b"], rest: "" });
1294
- expect(splitlines("a\nb")).toEqual({ lines: ["a"], rest: "b" });
1295
- expect(splitlines("abc")).toEqual({ lines: [], rest: "abc" });
1296
- expect(splitlines("")).toEqual({ lines: [], rest: "" });
1297
- expect(splitlines("\n\n\n")).toEqual({ lines: [], rest: "" });
1298
- expect(splitlines(" a \n b ")).toEqual({ lines: ["a"], rest: " b " });
1299
- expect(splitlines("a\0b\nc").lines).toEqual(["a\0b"]);
1300
- expect(splitlines("a\r\nb\n").lines).toEqual(["a", "b"]);
1301
- });
1302
-
1303
- it("pumps partial chunks into complete frames with the counters advancing", async () => {
1304
- const written: string[] = [];
1305
- const pump = createlinepump({
1306
- write: (line) => {
1307
- written.push(line);
1308
- },
1309
- handle: async (frame) => ({ jsonrpc: "2.0", id: frame.id ?? null, result: { echo: frame.method } }),
1310
- now,
1311
- });
1312
- await pump.feed('{"jsonrpc":"2.0","id":1,');
1313
- expect(written).toHaveLength(0);
1314
- await pump.feed('"method":"ping"}\n{"jsonrpc":"2.0","id":2,"method":"ping"}\n');
1315
- expect(written).toEqual([
1316
- `${JSON.stringify({ jsonrpc: "2.0", id: 1, result: { echo: "ping" } })}\n`,
1317
- `${JSON.stringify({ jsonrpc: "2.0", id: 2, result: { echo: "ping" } })}\n`,
1318
- ]);
1319
- await pump.feed("\n\n");
1320
- const record = pump.record();
1321
- expect(record.kind).toBe("stdio");
1322
- expect(record.endpoint).toBe("stdio://devthink");
1323
- expect(record.received).toBe(2);
1324
- expect(record.sent).toBe(2);
1325
- expect(record.startedat).toBe(now);
1326
- expect(record.lastframeat).toBe(now);
1327
- const closed = pump.close(now + 10);
1328
- expect(closed.closedat).toBe(now + 10);
1329
- });
1330
-
1331
- it("answers notifications with nothing and decode failures with the parse error", async () => {
1332
- const written: string[] = [];
1333
- let handled = 0;
1334
- const pump = createlinepump({
1335
- write: (line) => {
1336
- written.push(line);
1337
- },
1338
- handle: async (frame) => {
1339
- handled += 1;
1340
- return { jsonrpc: "2.0", id: frame.id ?? null, result: null };
1341
- },
1342
- now,
1343
- });
1344
- await pump.feed('{"jsonrpc":"2.0","method","notifications/only"}\n'.replace("method", ",method"));
1345
- await pump.feed('{"jsonrpc":"2.0","method":"notify/x"}\n');
1346
- expect(handled).toBe(0);
1347
- expect(written).toHaveLength(1);
1348
- expect(JSON.parse(written[0] ?? "{}")).toMatchObject({ jsonrpc: "2.0", id: null, error: { code: "parse" } });
1349
- await pump.feed("not json at all\n");
1350
- expect(written).toHaveLength(2);
1351
- expect(JSON.parse(written[1] ?? "{}")).toMatchObject({ id: null, error: { code: "parse" } });
1352
- });
1353
-
1354
- it("keeps the transport alive when one handler throws", async () => {
1355
- const written: string[] = [];
1356
- let calls = 0;
1357
- const pump = createlinepump({
1358
- write: (line) => {
1359
- written.push(line);
1360
- },
1361
- handle: async (frame) => {
1362
- calls += 1;
1363
- if (calls === 1) throw new Error("handler exploded");
1364
- return { jsonrpc: "2.0", id: frame.id ?? null, result: "ok" };
1365
- },
1366
- now,
1367
- });
1368
- await pump.feed(
1369
- '{"jsonrpc":"2.0","id":"boom","method":"tools/call"}\n{"jsonrpc":"2.0","id":"fine","method":"tools/call"}\n',
1370
- );
1371
- expect(written).toHaveLength(2);
1372
- expect(JSON.parse(written[0] ?? "{}")).toMatchObject({
1373
- id: "boom",
1374
- error: { code: "internal", message: "handler exploded" },
1375
- });
1376
- expect(JSON.parse(written[1] ?? "{}")).toMatchObject({ id: "fine", result: "ok" });
1377
- expect(pump.record().received).toBe(2);
1378
- });
1379
- });