@bevel-software/platform-core-backend 0.33.0 → 0.34.0

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 (497) hide show
  1. package/agent-guide/access-control.md +19 -8
  2. package/agent-guide/html-views.md +231 -5
  3. package/agent-guide/tool-manuals.md +1 -1
  4. package/dist/core/catalog-revision.d.ts +12 -1
  5. package/dist/core/catalog-revision.d.ts.map +1 -1
  6. package/dist/core/catalog-revision.js +23 -3
  7. package/dist/core/catalog-revision.js.map +1 -1
  8. package/dist/core/core-ports.d.ts +41 -0
  9. package/dist/core/core-ports.d.ts.map +1 -1
  10. package/dist/core/core-ports.js.map +1 -1
  11. package/dist/core/create-core-server.d.ts.map +1 -1
  12. package/dist/core/create-core-server.js +14 -7
  13. package/dist/core/create-core-server.js.map +1 -1
  14. package/dist/core/create-core-services.d.ts +16 -5
  15. package/dist/core/create-core-services.d.ts.map +1 -1
  16. package/dist/core/create-core-services.js +37 -12
  17. package/dist/core/create-core-services.js.map +1 -1
  18. package/dist/core/lifecycle.d.ts +16 -0
  19. package/dist/core/lifecycle.d.ts.map +1 -1
  20. package/dist/core/lifecycle.js +12 -2
  21. package/dist/core/lifecycle.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/modules/access/access-control.interface.d.ts +17 -9
  26. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  27. package/dist/modules/access/access-control.service.d.ts +12 -2
  28. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  29. package/dist/modules/access/access-control.service.js +164 -75
  30. package/dist/modules/access/access-control.service.js.map +1 -1
  31. package/dist/modules/access/access-mutation.service.d.ts +22 -24
  32. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  33. package/dist/modules/access/access-mutation.service.js +48 -51
  34. package/dist/modules/access/access-mutation.service.js.map +1 -1
  35. package/dist/modules/access/access-view.d.ts.map +1 -1
  36. package/dist/modules/access/access-view.js +12 -11
  37. package/dist/modules/access/access-view.js.map +1 -1
  38. package/dist/modules/access/access.routes.d.ts +3 -0
  39. package/dist/modules/access/access.routes.d.ts.map +1 -1
  40. package/dist/modules/access/access.routes.js +35 -29
  41. package/dist/modules/access/access.routes.js.map +1 -1
  42. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -1
  43. package/dist/modules/access/groups-admin.routes.js +4 -2
  44. package/dist/modules/access/groups-admin.routes.js.map +1 -1
  45. package/dist/modules/access/groups-admin.service.d.ts +44 -13
  46. package/dist/modules/access/groups-admin.service.d.ts.map +1 -1
  47. package/dist/modules/access/groups-admin.service.js +80 -38
  48. package/dist/modules/access/groups-admin.service.js.map +1 -1
  49. package/dist/modules/access/groups-edit.d.ts +1 -1
  50. package/dist/modules/access/groups-edit.d.ts.map +1 -1
  51. package/dist/modules/access/groups-edit.js +1 -1
  52. package/dist/modules/access/groups-edit.js.map +1 -1
  53. package/dist/modules/access/reference-scan.d.ts +116 -24
  54. package/dist/modules/access/reference-scan.d.ts.map +1 -1
  55. package/dist/modules/access/reference-scan.js +305 -44
  56. package/dist/modules/access/reference-scan.js.map +1 -1
  57. package/dist/modules/access/roles-admin.service.d.ts +17 -8
  58. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  59. package/dist/modules/access/roles-admin.service.js +32 -23
  60. package/dist/modules/access/roles-admin.service.js.map +1 -1
  61. package/dist/modules/access-model/access-grammar.d.ts +35 -10
  62. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  63. package/dist/modules/access-model/access-grammar.js +60 -20
  64. package/dist/modules/access-model/access-grammar.js.map +1 -1
  65. package/dist/modules/access-model/access-splice.d.ts +22 -18
  66. package/dist/modules/access-model/access-splice.d.ts.map +1 -1
  67. package/dist/modules/access-model/access-splice.js +20 -14
  68. package/dist/modules/access-model/access-splice.js.map +1 -1
  69. package/dist/modules/access-model/group-files.d.ts +1 -0
  70. package/dist/modules/access-model/group-files.d.ts.map +1 -1
  71. package/dist/modules/access-model/group-files.js +5 -1
  72. package/dist/modules/access-model/group-files.js.map +1 -1
  73. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -1
  74. package/dist/modules/agent-guide/agent-guide.tools.js +13 -3
  75. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -1
  76. package/dist/modules/agent-instructions/compose.d.ts +3 -1
  77. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  78. package/dist/modules/agent-instructions/compose.js +3 -1
  79. package/dist/modules/agent-instructions/compose.js.map +1 -1
  80. package/dist/modules/agent-instructions/shared-file-rules.d.ts +5 -1
  81. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  82. package/dist/modules/agent-instructions/shared-file-rules.js +16 -1
  83. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  84. package/dist/modules/agent-scope/agent-scope.contract.d.ts +67 -0
  85. package/dist/modules/agent-scope/agent-scope.contract.d.ts.map +1 -0
  86. package/dist/modules/agent-scope/agent-scope.contract.js +27 -0
  87. package/dist/modules/agent-scope/agent-scope.contract.js.map +1 -0
  88. package/dist/modules/agent-scope/connection-scope.d.ts +111 -0
  89. package/dist/modules/agent-scope/connection-scope.d.ts.map +1 -0
  90. package/dist/modules/agent-scope/connection-scope.js +165 -0
  91. package/dist/modules/agent-scope/connection-scope.js.map +1 -0
  92. package/dist/modules/agent-scope/index.d.ts +3 -0
  93. package/dist/modules/agent-scope/index.d.ts.map +1 -0
  94. package/dist/modules/agent-scope/index.js +3 -0
  95. package/dist/modules/agent-scope/index.js.map +1 -0
  96. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  97. package/dist/modules/audit/agent-audit.service.js +2 -0
  98. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  99. package/dist/modules/audit/audit.contract.d.ts +6 -0
  100. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  101. package/dist/modules/audit/audit.contract.js.map +1 -1
  102. package/dist/modules/auth/account-admission.d.ts +12 -0
  103. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  104. package/dist/modules/auth/account-admission.js +15 -0
  105. package/dist/modules/auth/account-admission.js.map +1 -1
  106. package/dist/modules/auth/auth.middleware.d.ts +27 -1
  107. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  108. package/dist/modules/auth/auth.middleware.js +42 -4
  109. package/dist/modules/auth/auth.middleware.js.map +1 -1
  110. package/dist/modules/auth/auth.routes.d.ts +13 -9
  111. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  112. package/dist/modules/auth/auth.routes.js +35 -29
  113. package/dist/modules/auth/auth.routes.js.map +1 -1
  114. package/dist/modules/auth/auth.service.d.ts +55 -6
  115. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  116. package/dist/modules/auth/auth.service.js +128 -34
  117. package/dist/modules/auth/auth.service.js.map +1 -1
  118. package/dist/modules/auth/index.d.ts +1 -1
  119. package/dist/modules/auth/index.d.ts.map +1 -1
  120. package/dist/modules/auth/index.js +1 -1
  121. package/dist/modules/auth/index.js.map +1 -1
  122. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  123. package/dist/modules/auth/oidc-auth-provider.js +2 -9
  124. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  125. package/dist/modules/database/core-schema.d.ts +68 -0
  126. package/dist/modules/database/core-schema.d.ts.map +1 -1
  127. package/dist/modules/database/core-schema.js +33 -0
  128. package/dist/modules/database/core-schema.js.map +1 -1
  129. package/dist/modules/declared-variables/declared-variables.routes.d.ts +8 -1
  130. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -1
  131. package/dist/modules/declared-variables/declared-variables.routes.js +13 -2
  132. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -1
  133. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  134. package/dist/modules/embed/embed.tools.js +5 -0
  135. package/dist/modules/embed/embed.tools.js.map +1 -1
  136. package/dist/modules/kb-fs/index.d.ts +2 -0
  137. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  138. package/dist/modules/kb-fs/index.js +1 -0
  139. package/dist/modules/kb-fs/index.js.map +1 -1
  140. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  141. package/dist/modules/kb-fs/locking-filesystem.js +7 -1
  142. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  143. package/dist/modules/kb-fs/repo-path.d.ts +26 -6
  144. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  145. package/dist/modules/kb-fs/repo-path.js +22 -7
  146. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  147. package/dist/modules/kb-fs/tmp-ref.d.ts +36 -0
  148. package/dist/modules/kb-fs/tmp-ref.d.ts.map +1 -0
  149. package/dist/modules/kb-fs/tmp-ref.js +76 -0
  150. package/dist/modules/kb-fs/tmp-ref.js.map +1 -0
  151. package/dist/modules/mcp/mcp-auth.middleware.d.ts +7 -0
  152. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  153. package/dist/modules/mcp/mcp-auth.middleware.js +14 -1
  154. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  155. package/dist/modules/mcp/mcp.routes.d.ts +7 -1
  156. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  157. package/dist/modules/mcp/mcp.routes.js +39 -4
  158. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  159. package/dist/modules/mcp/mcp.service.d.ts +11 -0
  160. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  161. package/dist/modules/mcp/mcp.service.js +68 -13
  162. package/dist/modules/mcp/mcp.service.js.map +1 -1
  163. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +24 -1
  164. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  165. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +45 -10
  166. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  167. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +9 -0
  168. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -1
  169. package/dist/modules/mcp/oauth/oauth-consent.routes.js +34 -1
  170. package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -1
  171. package/dist/modules/plugins/index.d.ts +1 -1
  172. package/dist/modules/plugins/index.d.ts.map +1 -1
  173. package/dist/modules/plugins/index.js.map +1 -1
  174. package/dist/modules/plugins/plugin-links.d.ts +18 -2
  175. package/dist/modules/plugins/plugin-links.d.ts.map +1 -1
  176. package/dist/modules/plugins/plugin-links.js.map +1 -1
  177. package/dist/modules/plugins/plugins.routes.d.ts +5 -1
  178. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  179. package/dist/modules/plugins/plugins.routes.js +32 -5
  180. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  181. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +9 -0
  182. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  183. package/dist/modules/secrets-vault/secrets-vault.routes.js +15 -3
  184. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  185. package/dist/modules/skills/index.d.ts +3 -3
  186. package/dist/modules/skills/index.d.ts.map +1 -1
  187. package/dist/modules/skills/index.js +0 -2
  188. package/dist/modules/skills/index.js.map +1 -1
  189. package/dist/modules/skills/skills.contract.d.ts +16 -0
  190. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  191. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  192. package/dist/modules/skills/skills.service.js +6 -1
  193. package/dist/modules/skills/skills.service.js.map +1 -1
  194. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  195. package/dist/modules/skills/skills.tools.js +24 -12
  196. package/dist/modules/skills/skills.tools.js.map +1 -1
  197. package/dist/modules/tool-auth/external-api-key.interface.d.ts +11 -0
  198. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  199. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
  200. package/dist/modules/tool-auth/external-api-key.service.d.ts +1 -0
  201. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  202. package/dist/modules/tool-auth/external-api-key.service.js +4 -1
  203. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  204. package/dist/modules/tool-auth/index.d.ts +1 -1
  205. package/dist/modules/tool-auth/index.d.ts.map +1 -1
  206. package/dist/modules/tool-auth/internal-token.service.d.ts +9 -0
  207. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
  208. package/dist/modules/tool-auth/internal-token.service.js +1 -0
  209. package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
  210. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +27 -3
  211. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  212. package/dist/modules/tool-auth/tool-auth.middleware.js +32 -6
  213. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  214. package/dist/modules/tool-helpers/tool-context.d.ts +7 -0
  215. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  216. package/dist/modules/tool-helpers/tool-context.js +3 -0
  217. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  218. package/dist/modules/tool-helpers/tool-handler.d.ts +15 -0
  219. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  220. package/dist/modules/tool-helpers/tool-handler.js +19 -0
  221. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  222. package/dist/modules/tool-helpers/tool.contract.d.ts +10 -0
  223. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  224. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  225. package/dist/modules/tool-helpers/validate-token.d.ts +3 -1
  226. package/dist/modules/tool-helpers/validate-token.d.ts.map +1 -1
  227. package/dist/modules/tool-helpers/validate-token.js +1 -1
  228. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  229. package/dist/modules/tool-manuals/index.d.ts +3 -3
  230. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  231. package/dist/modules/tool-manuals/index.js +0 -2
  232. package/dist/modules/tool-manuals/index.js.map +1 -1
  233. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +19 -6
  234. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  235. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +7 -1
  236. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  237. package/dist/modules/tool-manuals/tool-manuals.routes.js +41 -4
  238. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  239. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +13 -6
  240. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  241. package/dist/modules/tool-manuals/tool-manuals.service.js +23 -15
  242. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  243. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  244. package/dist/modules/tool-manuals/tool-manuals.tools.js +25 -10
  245. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  246. package/dist/modules/tool-registry/manual.routes.d.ts +8 -1
  247. package/dist/modules/tool-registry/manual.routes.d.ts.map +1 -1
  248. package/dist/modules/tool-registry/manual.routes.js +27 -7
  249. package/dist/modules/tool-registry/manual.routes.js.map +1 -1
  250. package/dist/modules/tool-registry/tool.contract.d.ts +7 -0
  251. package/dist/modules/tool-registry/tool.contract.d.ts.map +1 -1
  252. package/dist/modules/tool-registry/tool.contract.js.map +1 -1
  253. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  254. package/dist/modules/workflow/agent-tools/workflow.tools.js +14 -9
  255. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  256. package/dist/modules/workflow/git/git.service.d.ts +2 -2
  257. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  258. package/dist/modules/workflow/git/git.service.js +20 -35
  259. package/dist/modules/workflow/git/git.service.js.map +1 -1
  260. package/dist/modules/workflow/workflow.service.d.ts +2 -1
  261. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  262. package/dist/modules/workflow/workflow.service.js +3 -0
  263. package/dist/modules/workflow/workflow.service.js.map +1 -1
  264. package/dist/modules/workspace/file-readers/doc-extract.service.d.ts +28 -4
  265. package/dist/modules/workspace/file-readers/doc-extract.service.d.ts.map +1 -1
  266. package/dist/modules/workspace/file-readers/doc-extract.service.js +61 -40
  267. package/dist/modules/workspace/file-readers/doc-extract.service.js.map +1 -1
  268. package/dist/modules/workspace/index.d.ts +8 -0
  269. package/dist/modules/workspace/index.d.ts.map +1 -1
  270. package/dist/modules/workspace/index.js +8 -0
  271. package/dist/modules/workspace/index.js.map +1 -1
  272. package/dist/modules/workspace/nul-scan.d.ts +19 -0
  273. package/dist/modules/workspace/nul-scan.d.ts.map +1 -0
  274. package/dist/modules/workspace/nul-scan.js +55 -0
  275. package/dist/modules/workspace/nul-scan.js.map +1 -0
  276. package/dist/modules/workspace/pattern-matcher.contract.d.ts +110 -27
  277. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -1
  278. package/dist/modules/workspace/pattern-matcher.contract.js +30 -13
  279. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -1
  280. package/dist/modules/workspace/ripgrep-search.d.ts +54 -0
  281. package/dist/modules/workspace/ripgrep-search.d.ts.map +1 -0
  282. package/dist/modules/workspace/ripgrep-search.js +657 -0
  283. package/dist/modules/workspace/ripgrep-search.js.map +1 -0
  284. package/dist/modules/workspace/ripgrep-version.d.ts +15 -0
  285. package/dist/modules/workspace/ripgrep-version.d.ts.map +1 -0
  286. package/dist/modules/workspace/ripgrep-version.js +31 -0
  287. package/dist/modules/workspace/ripgrep-version.js.map +1 -0
  288. package/dist/modules/workspace/startup/beside-checkout.d.ts +8 -5
  289. package/dist/modules/workspace/startup/beside-checkout.d.ts.map +1 -1
  290. package/dist/modules/workspace/startup/beside-checkout.js +14 -5
  291. package/dist/modules/workspace/startup/beside-checkout.js.map +1 -1
  292. package/dist/modules/workspace/worker-pool.d.ts +3 -4
  293. package/dist/modules/workspace/worker-pool.d.ts.map +1 -1
  294. package/dist/modules/workspace/worker-pool.js.map +1 -1
  295. package/dist/modules/workspace/workspace-tmp.d.ts +146 -0
  296. package/dist/modules/workspace/workspace-tmp.d.ts.map +1 -0
  297. package/dist/modules/workspace/workspace-tmp.js +435 -0
  298. package/dist/modules/workspace/workspace-tmp.js.map +1 -0
  299. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  300. package/dist/modules/workspace/workspace.routes.js +5 -26
  301. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  302. package/dist/modules/workspace/workspace.service.d.ts +47 -6
  303. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  304. package/dist/modules/workspace/workspace.service.js +86 -6
  305. package/dist/modules/workspace/workspace.service.js.map +1 -1
  306. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  307. package/dist/modules/workspace/workspace.tools.js +460 -116
  308. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  309. package/dist/shared/path-order.d.ts +28 -8
  310. package/dist/shared/path-order.d.ts.map +1 -1
  311. package/dist/shared/path-order.js +37 -8
  312. package/dist/shared/path-order.js.map +1 -1
  313. package/migrations/0022_session_version.sql +6 -0
  314. package/migrations/0023_agent_path.sql +3 -0
  315. package/migrations/meta/0022_snapshot.json +2360 -0
  316. package/migrations/meta/0023_snapshot.json +2378 -0
  317. package/migrations/meta/_journal.json +14 -0
  318. package/package.json +3 -3
  319. package/src/__tests__/pg-scratch.test.ts +69 -0
  320. package/src/__tests__/pg-scratch.ts +31 -0
  321. package/src/__tests__/slow-ripgrep.ts +65 -0
  322. package/src/core/__tests__/boot-without-ripgrep.test.ts +34 -0
  323. package/src/core/__tests__/catalog-revision.test.ts +89 -0
  324. package/src/core/__tests__/damaged-document.tools.test.ts +2 -2
  325. package/src/core/__tests__/gated-boot.test.ts +8 -1
  326. package/src/core/__tests__/lifecycle.test.ts +46 -13
  327. package/src/core/catalog-revision.ts +24 -2
  328. package/src/core/core-ports.ts +42 -0
  329. package/src/core/create-core-server.ts +13 -0
  330. package/src/core/create-core-services.ts +49 -12
  331. package/src/core/lifecycle.ts +26 -2
  332. package/src/index.ts +1 -1
  333. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +12 -0
  334. package/src/modules/access/__tests__/access-control.colliding-name-cases.test.ts +113 -0
  335. package/src/modules/access/__tests__/access-control.frontmatter-head.test.ts +117 -0
  336. package/src/modules/access/__tests__/access-control.group-named-admin.test.ts +122 -0
  337. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +50 -0
  338. package/src/modules/access/__tests__/access-declarations.test.ts +19 -0
  339. package/src/modules/access/__tests__/access-groups.test.ts +55 -22
  340. package/src/modules/access/__tests__/access-mutation.service.test.ts +85 -59
  341. package/src/modules/access/__tests__/access-restrict-here.test.ts +7 -5
  342. package/src/modules/access/__tests__/access.routes.admins.test.ts +1 -0
  343. package/src/modules/access/__tests__/access.routes.batch.test.ts +2 -0
  344. package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +2 -0
  345. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +50 -14
  346. package/src/modules/access/__tests__/access.routes.overrides.test.ts +2 -1
  347. package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +1 -0
  348. package/src/modules/access/__tests__/access.routes.prospective.test.ts +2 -0
  349. package/src/modules/access/__tests__/access.routes.revoke.test.ts +18 -13
  350. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  351. package/src/modules/access/__tests__/grant-sources.test.ts +20 -15
  352. package/src/modules/access/__tests__/groups-admin.routes.test.ts +61 -0
  353. package/src/modules/access/__tests__/groups-admin.service.test.ts +211 -18
  354. package/src/modules/access/__tests__/reference-scan.test.ts +366 -14
  355. package/src/modules/access/__tests__/roles-admin.security.test.ts +1 -1
  356. package/src/modules/access/__tests__/roles-admin.service.test.ts +43 -22
  357. package/src/modules/access/__tests__/roles-capabilities.test.ts +6 -2
  358. package/src/modules/access/__tests__/roles.routes.test.ts +1 -1
  359. package/src/modules/access/access-control.interface.ts +17 -10
  360. package/src/modules/access/access-control.service.ts +158 -72
  361. package/src/modules/access/access-mutation.service.ts +48 -53
  362. package/src/modules/access/access-view.ts +12 -10
  363. package/src/modules/access/access.routes.ts +43 -33
  364. package/src/modules/access/groups-admin.routes.ts +4 -2
  365. package/src/modules/access/groups-admin.service.ts +114 -44
  366. package/src/modules/access/groups-edit.ts +1 -1
  367. package/src/modules/access/reference-scan.ts +319 -46
  368. package/src/modules/access/roles-admin.service.ts +43 -26
  369. package/src/modules/access/synced-groups-writer.ts +1 -1
  370. package/src/modules/access-model/__tests__/access-grammar.test.ts +38 -1
  371. package/src/modules/access-model/__tests__/access-splice.test.ts +50 -8
  372. package/src/modules/access-model/access-grammar.ts +65 -20
  373. package/src/modules/access-model/access-splice.ts +31 -23
  374. package/src/modules/access-model/group-files.ts +5 -0
  375. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +31 -0
  376. package/src/modules/agent-guide/agent-guide.tools.ts +13 -2
  377. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +1 -0
  378. package/src/modules/agent-instructions/compose.ts +3 -1
  379. package/src/modules/agent-instructions/shared-file-rules.ts +17 -1
  380. package/src/modules/agent-scope/__tests__/agent-surfaces.connect-as.test.ts +421 -0
  381. package/src/modules/agent-scope/__tests__/connection-scope.test.ts +46 -0
  382. package/src/modules/agent-scope/__tests__/in-memory-kb.ts +148 -0
  383. package/src/modules/agent-scope/agent-scope.contract.ts +83 -0
  384. package/src/modules/agent-scope/connection-scope.ts +199 -0
  385. package/src/modules/agent-scope/index.ts +15 -0
  386. package/src/modules/audit/agent-audit.service.ts +2 -0
  387. package/src/modules/audit/audit.contract.ts +6 -0
  388. package/src/modules/auth/__tests__/auth.middleware.test.ts +30 -2
  389. package/src/modules/auth/__tests__/auth.routes.test.ts +71 -5
  390. package/src/modules/auth/__tests__/auth.service.test.ts +90 -9
  391. package/src/modules/auth/__tests__/password-change-sessions.pg.test.ts +269 -0
  392. package/src/modules/auth/account-admission.ts +19 -0
  393. package/src/modules/auth/auth.middleware.ts +45 -4
  394. package/src/modules/auth/auth.routes.ts +46 -29
  395. package/src/modules/auth/auth.service.ts +135 -34
  396. package/src/modules/auth/index.ts +2 -0
  397. package/src/modules/auth/oidc-auth-provider.ts +2 -9
  398. package/src/modules/database/__tests__/session-version-migration.pg.test.ts +133 -0
  399. package/src/modules/database/core-schema.ts +33 -0
  400. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +44 -1
  401. package/src/modules/declared-variables/declared-variables.routes.ts +12 -1
  402. package/src/modules/embed/__tests__/embed.tools.test.ts +12 -0
  403. package/src/modules/embed/embed.tools.ts +5 -0
  404. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +25 -0
  405. package/src/modules/kb-fs/__tests__/repo-path.test.ts +53 -8
  406. package/src/modules/kb-fs/index.ts +2 -0
  407. package/src/modules/kb-fs/locking-filesystem.ts +7 -1
  408. package/src/modules/kb-fs/repo-path.ts +33 -7
  409. package/src/modules/kb-fs/tmp-ref.ts +81 -0
  410. package/src/modules/mcp/__tests__/connect-as.pg.test.ts +137 -0
  411. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +29 -5
  412. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +8 -0
  413. package/src/modules/mcp/__tests__/mcp.connect-as.test.ts +195 -0
  414. package/src/modules/mcp/__tests__/mcp.routes.connect-as.test.ts +192 -0
  415. package/src/modules/mcp/__tests__/oauth-consent.connect-as.test.ts +146 -0
  416. package/src/modules/mcp/mcp-auth.middleware.ts +19 -2
  417. package/src/modules/mcp/mcp.routes.ts +39 -3
  418. package/src/modules/mcp/mcp.service.ts +107 -17
  419. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +46 -8
  420. package/src/modules/mcp/oauth/oauth-consent.routes.ts +41 -1
  421. package/src/modules/plugins/__tests__/plugins.tools.test.ts +38 -2
  422. package/src/modules/plugins/index.ts +1 -1
  423. package/src/modules/plugins/plugin-links.ts +19 -2
  424. package/src/modules/plugins/plugins.routes.ts +35 -4
  425. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +113 -0
  426. package/src/modules/secrets-vault/secrets-vault.routes.ts +23 -3
  427. package/src/modules/skills/__tests__/skills.service.test.ts +34 -0
  428. package/src/modules/skills/index.ts +7 -2
  429. package/src/modules/skills/skills.contract.ts +17 -0
  430. package/src/modules/skills/skills.service.ts +5 -1
  431. package/src/modules/skills/skills.tools.ts +44 -13
  432. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +4 -0
  433. package/src/modules/tool-auth/external-api-key.interface.ts +11 -1
  434. package/src/modules/tool-auth/external-api-key.service.ts +5 -2
  435. package/src/modules/tool-auth/index.ts +1 -1
  436. package/src/modules/tool-auth/internal-token.service.ts +10 -0
  437. package/src/modules/tool-auth/tool-auth.middleware.ts +45 -3
  438. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +3 -1
  439. package/src/modules/tool-helpers/tool-context.ts +10 -0
  440. package/src/modules/tool-helpers/tool-handler.ts +34 -0
  441. package/src/modules/tool-helpers/tool.contract.ts +10 -0
  442. package/src/modules/tool-helpers/validate-token.ts +4 -2
  443. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +80 -4
  444. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +42 -0
  445. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  446. package/src/modules/tool-manuals/index.ts +6 -2
  447. package/src/modules/tool-manuals/tool-manuals.contract.ts +18 -6
  448. package/src/modules/tool-manuals/tool-manuals.routes.ts +40 -3
  449. package/src/modules/tool-manuals/tool-manuals.service.ts +31 -15
  450. package/src/modules/tool-manuals/tool-manuals.tools.ts +28 -11
  451. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +2 -2
  452. package/src/modules/tool-registry/manual.routes.ts +26 -5
  453. package/src/modules/tool-registry/tool.contract.ts +7 -0
  454. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  455. package/src/modules/workflow/__tests__/workflow.service.sync.test.ts +1 -1
  456. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +54 -3
  457. package/src/modules/workflow/agent-tools/workflow.tools.ts +14 -9
  458. package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +130 -3
  459. package/src/modules/workflow/git/git.service.ts +29 -48
  460. package/src/modules/workflow/workflow.service.ts +5 -0
  461. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -2
  462. package/src/modules/workspace/__tests__/holds-nul.test.ts +66 -0
  463. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +3 -3
  464. package/src/modules/workspace/__tests__/ripgrep-search.test.ts +570 -0
  465. package/src/modules/workspace/__tests__/ripgrep-version.test.ts +29 -0
  466. package/src/modules/workspace/__tests__/workspace-tmp.tools.test.ts +928 -0
  467. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +17 -37
  468. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +20 -8
  469. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -2
  470. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +22 -10
  471. package/src/modules/workspace/__tests__/workspace.tools.test.ts +393 -113
  472. package/src/modules/workspace/file-readers/__tests__/doc-extract.service.test.ts +10 -0
  473. package/src/modules/workspace/file-readers/doc-extract.service.ts +71 -38
  474. package/src/modules/workspace/index.ts +20 -0
  475. package/src/modules/workspace/nul-scan.ts +50 -0
  476. package/src/modules/workspace/pattern-matcher.contract.ts +123 -32
  477. package/src/modules/workspace/ripgrep-search.ts +715 -0
  478. package/src/modules/workspace/ripgrep-version.ts +35 -0
  479. package/src/modules/workspace/startup/__tests__/beside-checkout.test.ts +11 -0
  480. package/src/modules/workspace/startup/beside-checkout.ts +13 -5
  481. package/src/modules/workspace/worker-pool.ts +3 -4
  482. package/src/modules/workspace/workspace-tmp.ts +457 -0
  483. package/src/modules/workspace/workspace.routes.ts +6 -27
  484. package/src/modules/workspace/workspace.service.ts +92 -15
  485. package/src/modules/workspace/workspace.tools.ts +500 -155
  486. package/src/shared/path-order.ts +37 -8
  487. package/starter-packs/engineering/Plugins/engineering-starter/access.md +1 -1
  488. package/starter-packs/general/Plugins/general-starter/access.md +1 -1
  489. package/starter-packs/operations/Plugins/operations-starter/access.md +1 -1
  490. package/starter-packs/sales/Plugins/sales-starter/access.md +1 -1
  491. package/starter-packs/support/Plugins/support-starter/access.md +1 -1
  492. package/dist/modules/workspace/bounded-matcher.d.ts +0 -73
  493. package/dist/modules/workspace/bounded-matcher.d.ts.map +0 -1
  494. package/dist/modules/workspace/bounded-matcher.js +0 -200
  495. package/dist/modules/workspace/bounded-matcher.js.map +0 -1
  496. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +0 -213
  497. package/src/modules/workspace/bounded-matcher.ts +0 -271
@@ -8,13 +8,14 @@ import AdmZip from 'adm-zip';
8
8
  import express from 'express';
9
9
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
10
  import { testKbContext } from '../../../__tests__/kb-context.js';
11
+ import { slowRipgrep } from '../../../__tests__/slow-ripgrep.js';
11
12
  import { LocalFilesystem } from '@mastra/core/workspace';
12
13
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
13
14
  import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
14
15
  import { ToolError, type ToolContext } from '../../tool-helpers/tool.contract.js';
15
16
  import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
16
17
  import { registerWorkspaceTools } from '../workspace.tools.js';
17
- import { WorkerPatternMatcher, type WorkerPatternMatcherOptions } from '../bounded-matcher.js';
18
+ import { RipgrepPatternMatcher, type RipgrepPatternMatcherOptions } from '../index.js';
18
19
  import { sharedFileRules, sharedFileRulesSection } from '../../agent-instructions/shared-file-rules.js';
19
20
  import { composeAgentGuide } from '../../agent-guide/agent-guide.js';
20
21
  import { GUIDE_FIRST_SENTENCE } from '../../tool-registry/guide-first.js';
@@ -144,13 +145,13 @@ let raceHook: (() => Promise<void>) | null = null;
144
145
  * The grep matcher `start()` wires, built from `matcherOptions` (the
145
146
  * defaults, unless a test narrows the pool) and closed after each test.
146
147
  */
147
- let patternMatcher: WorkerPatternMatcher | undefined;
148
+ let patternMatcher: RipgrepPatternMatcher | undefined;
148
149
  /**
149
150
  * The document extractor `start()` wires, closed after each test: its workers
150
151
  * are made lazily, and `unref()` lets the process exit but keeps them alive.
151
152
  */
152
153
  let docExtractor: WorkerDocumentExtractor | undefined;
153
- let matcherOptions: WorkerPatternMatcherOptions = {};
154
+ let matcherOptions: RipgrepPatternMatcherOptions = {};
154
155
 
155
156
  async function start(
156
157
  scope: 'read' | 'write' = 'write',
@@ -160,7 +161,7 @@ async function start(
160
161
  ): Promise<string> {
161
162
  raceHook = null;
162
163
  await patternMatcher?.close();
163
- patternMatcher = new WorkerPatternMatcher(matcherOptions);
164
+ patternMatcher = new RipgrepPatternMatcher(matcherOptions);
164
165
  await docExtractor?.close();
165
166
  const extractor = new WorkerDocumentExtractor();
166
167
  docExtractor = extractor;
@@ -1015,6 +1016,10 @@ describe("the agent guide at the guide's name", () => {
1015
1016
  expect((await grep(base, 'needle', 'AGENTS.md')).matches).toEqual([{ path: GUIDE, line: 3, text: 'Name every needle you plant.' }]);
1016
1017
  // A search under a folder does not reach a file that is not under it.
1017
1018
  expect((await grep(base, 'needle', `${KB_DIR}/Handbook`)).matches.map((m) => m.path)).toEqual([`${KB_DIR}/Handbook/a.md`]);
1019
+ // A folder named with a trailing slash answers the same paths, spelled
1020
+ // once, and the guide once.
1021
+ expect((await grep(base, 'needle', `${KB_DIR}/Handbook/`)).matches.map((m) => m.path)).toEqual([`${KB_DIR}/Handbook/a.md`]);
1022
+ expect((await grep(base, 'needle', `${KB_DIR}/`)).matches).toEqual(fromRoot.matches);
1018
1023
 
1019
1024
  // With the knowledge base's own AGENTS.md, the search covers the composed
1020
1025
  // text — the own file first, then the guide — and once: the walk's own
@@ -1029,17 +1034,18 @@ describe("the agent guide at the guide's name", () => {
1029
1034
  ]);
1030
1035
  expect((await grep(base, 'needle', GUIDE)).matches).toEqual(composed);
1031
1036
 
1032
- // The own file does not use up `max_results` twice: the walk leaves it to
1033
- // the composed search, so a file later in the tree is still reached when
1034
- // the own file alone has more matches than the cap. Without that, the
1035
- // walk filled the cap from AGENTS.md, those matches were dropped as
1036
- // duplicates, and Handbook/ was never searched.
1037
+ // The own file does not use up `max_results` twice: the tree search
1038
+ // leaves it to the composed search, so its lines are counted once and the
1039
+ // file after it in path order is still reached. Counted twice, its five
1040
+ // lines and their duplicates would fill a cap of seven, and Handbook/
1041
+ // would be cut.
1037
1042
  await fs.writeFile(`${KB_DIR}/AGENTS.md`, `# Acme\n\n${'needle\n'.repeat(5)}`);
1038
- const capped = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', max_results: 3 })).json()) as {
1039
- matches: { path: string }[];
1043
+ const capped = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', max_results: 7 })).json()) as {
1044
+ matches: { path: string; line: number }[];
1040
1045
  };
1041
- expect(capped.matches.map((m) => m.path)).toContain(`${KB_DIR}/Handbook/a.md`);
1042
- expect(capped.matches).toHaveLength(3);
1046
+ // Five lines of the own file and the guide's one, then the file after it.
1047
+ expect(capped.matches.map((m) => m.path)).toEqual([...Array<string>(6).fill(GUIDE), `${KB_DIR}/Handbook/a.md`]);
1048
+ expect(new Set(capped.matches.map((m) => `${m.path}:${m.line}`)).size).toBe(7);
1043
1049
  await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n\nOur needle rule.\n');
1044
1050
 
1045
1051
  // A caller who may not read the own file searches the guide alone, from
@@ -1192,6 +1198,22 @@ describe('grep with a path that names a file', () => {
1192
1198
  }
1193
1199
  });
1194
1200
 
1201
+ it('a named text file answers the lines a folder search answers from it, valid UTF-8 lines of a file holding others included', async () => {
1202
+ const base = await start();
1203
+ await fs.writeFile(`${KB_DIR}/mixed/latin.txt`, Buffer.concat([Buffer.from('needle \xff\n', 'latin1'), Buffer.from('needle ok\n')]));
1204
+ const named = await grep(base, { pattern: 'needle', path: `${KB_DIR}/mixed/latin.txt` });
1205
+ const folder = await grep(base, { pattern: 'needle', path: `${KB_DIR}/mixed` });
1206
+ expect(named.matches).toEqual([{ path: `${KB_DIR}/mixed/latin.txt`, line: 2, text: 'needle ok' }]);
1207
+ expect(named.matches).toEqual(folder.matches);
1208
+ expect(named.note).toBeUndefined();
1209
+ // A line that is not UTF-8 counts toward ripgrep's cap, yet the line after it is still answered.
1210
+ for (const path of [`${KB_DIR}/mixed/latin.txt`, `${KB_DIR}/mixed`]) {
1211
+ const one = await grep(base, { pattern: 'needle', path, max_results: 1 });
1212
+ expect(one.matches).toEqual([{ path: `${KB_DIR}/mixed/latin.txt`, line: 2, text: 'needle ok' }]);
1213
+ expect(one.truncated).toBe(true);
1214
+ }
1215
+ });
1216
+
1195
1217
  it('a path with nothing at it fails with an error naming the path — never an empty success', async () => {
1196
1218
  const base = await start();
1197
1219
  const res = await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: `${KB_DIR}/notes/ghost.md` });
@@ -1278,119 +1300,356 @@ describe('grep with a path that names a file', () => {
1278
1300
  });
1279
1301
 
1280
1302
  /**
1281
- * A caller's pattern runs off the event loop under a budget (WorkerPatternMatcher):
1282
- * one that backtracks for ever is stopped with a worded 400, and every other
1283
- * request keeps being served meanwhile — here, a `read_file` on the same
1284
- * server, which shares the one loop the pattern used to hold.
1303
+ * grep runs ripgrep (RipgrepPatternMatcher), as a child process: the pattern
1304
+ * is ripgrep's default engine's to judge, a search is stopped at its budget
1305
+ * while every other request keeps being served, and the walk covers every
1306
+ * text file the caller may read — hidden, git-ignored and untracked alike —
1307
+ * without following a link or entering `.git`.
1285
1308
  */
1286
- describe('a pattern too expensive to match', () => {
1287
- const LONG_LINE = `${'a'.repeat(39)}!`;
1309
+ describe('grep runs ripgrep', () => {
1310
+ type GrepBody = { matches: { path: string; line: number; text: string }[]; truncated: boolean; note?: string; error?: string; code?: string };
1311
+ const grep = async (base: string, body: Record<string, unknown>): Promise<{ status: number; body: GrepBody }> => {
1312
+ const res = await post(`${base}/api/agent/tools/grep`, body);
1313
+ return { status: res.status, body: (await res.json()) as GrepBody };
1314
+ };
1315
+ const git = (...args: string[]) => promisify(execFile)('git', args, { cwd: join(tempDir, KB_DIR) });
1316
+
1317
+ it('stops a search at its 5 s budget with the 400 "too expensive", leaves no ripgrep running, and serves other calls meanwhile', async () => {
1318
+ const slow = await slowRipgrep();
1319
+ try {
1320
+ matcherOptions = {};
1321
+ const base = await start();
1322
+ await fs.writeFile(`${KB_DIR}/hit.md`, 'needle\n');
1323
+ const started = Date.now();
1324
+ const searching = grep(base, { pattern: 'needle' }).then((r) => ({ ...r, took: Date.now() - started }));
1325
+ // Probe while the search runs: each read must answer well within a second.
1326
+ const probes: number[] = [];
1327
+ let done = false;
1328
+ void searching.finally(() => (done = true));
1329
+ while (!done) {
1330
+ const t = Date.now();
1331
+ const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` });
1332
+ expect(res.status).toBe(200);
1333
+ probes.push(Date.now() - t);
1334
+ await new Promise((r) => setTimeout(r, 100));
1335
+ }
1336
+ const { status, body, took } = await searching;
1337
+ expect(status).toBe(400);
1338
+ expect(body.code).toBe('pattern_too_expensive');
1339
+ expect(body.error).toMatch(/^Pattern too expensive: /);
1340
+ expect(body.error).toContain("this search's 5 s budget");
1341
+ expect(took).toBeGreaterThanOrEqual(5_000);
1342
+ expect(took).toBeLessThan(5_000 + 2_000);
1343
+ expect(probes.length).toBeGreaterThan(10);
1344
+ expect(Math.max(...probes)).toBeLessThan(1_000);
1345
+ expect(await slow.alive()).toEqual([]);
1346
+ } finally {
1347
+ await slow.remove();
1348
+ }
1349
+ }, 15_000);
1350
+
1351
+ it('answers 503 when every search slot is taken and the queue is full, rather than an incomplete result', async () => {
1352
+ const slow = await slowRipgrep();
1353
+ try {
1354
+ matcherOptions = { budgetMs: 1_000, maxRunning: 1, maxWaiting: 1 };
1355
+ const base = await start();
1356
+ await fs.writeFile(`${KB_DIR}/hit.md`, 'needle\n');
1357
+ const holding = grep(base, { pattern: 'needle' }).then((r) => r.status);
1358
+ while ((await slow.alive()).length === 0) await new Promise((r) => setTimeout(r, 20));
1359
+ const queued = grep(base, { pattern: 'needle' }).then((r) => r.status);
1360
+ await new Promise((r) => setTimeout(r, 100));
1361
+ const refused = await grep(base, { pattern: 'needle' });
1362
+ expect(refused.status).toBe(503);
1363
+ expect(refused.body.code).toBe('search_busy');
1364
+ expect(await Promise.all([holding, queued])).toEqual([400, 400]);
1365
+ expect(await slow.alive()).toEqual([]);
1366
+ // A slot is free again: with the real ripgrep back, a search answers.
1367
+ await slow.remove();
1368
+ const after = await grep(base, { pattern: 'needle', path: `${KB_DIR}/hit.md` });
1369
+ expect(after.status).toBe(200);
1370
+ expect(after.body.matches).toEqual([{ path: `${KB_DIR}/hit.md`, line: 1, text: 'needle' }]);
1371
+ } finally {
1372
+ await slow.remove();
1373
+ }
1374
+ });
1375
+
1376
+ it.each([
1377
+ ['look-ahead', 'foo(?=bar)', /look-around/],
1378
+ ['look-behind', '(?<=foo)bar', /look-around/],
1379
+ ['a backreference', '(a)\\1', /backreferences are not supported/],
1380
+ ['invalid syntax', 'a(', /unclosed group/],
1381
+ ])('refuses %s with a 400 quoting ripgrep and naming the dialect, whatever path it names', async (_what, pattern, reason) => {
1382
+ const base = await start();
1383
+ await fs.writeFile(`${KB_DIR}/img.png`, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0]));
1384
+ for (const path of [undefined, `${KB_DIR}/a.md`, `${KB_DIR}/img.png`]) {
1385
+ const res = await grep(base, path === undefined ? { pattern } : { pattern, path });
1386
+ expect(res.status).toBe(400);
1387
+ expect(res.body.code).toBe('invalid_pattern');
1388
+ expect(res.body.error).toMatch(/^Invalid regex: /);
1389
+ expect(res.body.error).toMatch(reason);
1390
+ expect(res.body.error).toContain('RE2-style patterns: no look-around and no backreferences');
1391
+ // ripgrep's advice to pass a flag the caller cannot pass is not repeated.
1392
+ expect(res.body.error).not.toContain('--pcre2');
1393
+ }
1394
+ });
1395
+
1396
+ it('the description and the pattern input say which patterns the engine takes', async () => {
1397
+ await start();
1398
+ const def = (await toolRegistry.listInternal()).find((t) => t.name === 'grep');
1399
+ expect(def?.description).toContain("grep runs ripgrep's default regex engine, which takes RE2-style patterns: no look-around and no backreferences.");
1400
+ expect(JSON.stringify(def?.inputSchema ?? def)).toContain('RE2-style (ripgrep): no look-around, no backreferences.');
1401
+ });
1288
1402
 
1289
- it('answers 400 "too expensive" fast over a 40-character line, while other calls stay under a second', async () => {
1403
+ it('a pattern that backtracks for ever in JavaScript is answered at once: the engine does not backtrack', async () => {
1290
1404
  const base = await start();
1291
- await fs.writeFile(`${KB_DIR}/redos.md`, `intro\n${LONG_LINE}\n`);
1405
+ const longLine = `${'a'.repeat(5_000)}!`;
1406
+ await fs.writeFile(`${KB_DIR}/redos.md`, `intro\n${longLine}\n`);
1292
1407
  const started = Date.now();
1293
- const searching = post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$', path: `${KB_DIR}/redos.md` }).then(
1294
- async (res) => ({ status: res.status, body: (await res.json()) as { error: string; code?: string }, took: Date.now() - started }),
1295
- );
1296
- // Probe while the search runs: each read must answer within a second.
1297
- const probes: number[] = [];
1298
- let done = false;
1299
- void searching.finally(() => (done = true));
1300
- while (!done) {
1301
- const t = Date.now();
1302
- const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` });
1408
+ const none = await grep(base, { pattern: '^(a+)+$' });
1409
+ expect(none.status).toBe(200);
1410
+ expect(none.body.matches).toEqual([]);
1411
+ const some = await grep(base, { pattern: '^a+!$', path: `${KB_DIR}/redos.md` });
1412
+ expect(some.body.matches).toEqual([{ path: `${KB_DIR}/redos.md`, line: 2, text: longLine.slice(0, 300) }]);
1413
+ expect(Date.now() - started).toBeLessThan(2_000);
1414
+ });
1415
+
1416
+ it('searches hidden, git-ignored and untracked files, never .git, and never through a link', async () => {
1417
+ const base = await start();
1418
+ const outside = await mkdtemp(join(tmpdir(), 'grep-outside-'));
1419
+ try {
1420
+ await writeFile(join(outside, 'secret.md'), 'needle from outside\n');
1421
+ await git('init', '-q');
1422
+ await fs.writeFile(`${KB_DIR}/.gitignore`, 'build/\nignored.md\n');
1423
+ await fs.writeFile(`${KB_DIR}/tracked.md`, 'needle tracked\n');
1424
+ await git('add', '.gitignore', 'tracked.md');
1425
+ await git('-c', 'user.email=t@x', '-c', 'user.name=t', 'commit', '-qm', 'init');
1426
+ await fs.writeFile(`${KB_DIR}/.notes.md`, 'needle hidden\n');
1427
+ await fs.writeFile(`${KB_DIR}/.hidden/deep.md`, 'needle in a hidden folder\n');
1428
+ await fs.writeFile(`${KB_DIR}/build/out.txt`, 'needle ignored folder\n');
1429
+ await fs.writeFile(`${KB_DIR}/ignored.md`, 'needle ignored file\n');
1430
+ await fs.writeFile(`${KB_DIR}/untracked.md`, 'needle untracked\n');
1431
+ await writeFile(join(tempDir, KB_DIR, '.git', 'needle-in-git'), 'needle git internals\n');
1432
+ await mkdir(join(tempDir, KB_DIR, 'sub', '.GIT'), { recursive: true });
1433
+ await writeFile(join(tempDir, KB_DIR, 'sub', '.GIT', 'x'), 'needle git internals\n');
1434
+ await symlink(join(outside, 'secret.md'), join(tempDir, KB_DIR, 'link.md'));
1435
+ await symlink(outside, join(tempDir, KB_DIR, 'linked-dir'));
1436
+ await fs.writeFile(`${KB_DIR}/binary.dat`, Buffer.concat([Buffer.from('needle in binary\n'), Buffer.from([0, 1, 2])]));
1437
+ const res = await grep(base, { pattern: 'needle' });
1303
1438
  expect(res.status).toBe(200);
1304
- probes.push(Date.now() - t);
1305
- await new Promise((r) => setTimeout(r, 50));
1439
+ expect(res.body.matches.map((m) => m.path)).toEqual([
1440
+ `${KB_DIR}/.hidden/deep.md`,
1441
+ `${KB_DIR}/.notes.md`,
1442
+ `${KB_DIR}/build/out.txt`,
1443
+ `${KB_DIR}/ignored.md`,
1444
+ `${KB_DIR}/tracked.md`,
1445
+ `${KB_DIR}/untracked.md`,
1446
+ ]);
1447
+ expect(JSON.stringify(res.body)).not.toContain('outside');
1448
+ expect(JSON.stringify(res.body)).not.toContain('git internals');
1449
+ } finally {
1450
+ await rm(outside, { recursive: true, force: true });
1306
1451
  }
1307
- const { status, body, took } = await searching;
1308
- expect(status).toBe(400);
1309
- expect(body.error).toMatch(/^Pattern too expensive: /);
1310
- expect(body.code).toBe('pattern_too_expensive');
1311
- expect(body.error).toContain("this search's 5 s budget");
1312
- expect(took).toBeLessThan(5_000 + 2_000);
1313
- expect(probes.length).toBeGreaterThan(0);
1314
- expect(Math.max(...probes)).toBeLessThan(1_000);
1315
1452
  });
1316
1453
 
1317
- it('stops it in a directory walk too, rather than skipping the file as unreadable', async () => {
1318
- const base = await start();
1319
- await fs.writeFile(`${KB_DIR}/redos.md`, `${LONG_LINE}\n`);
1320
- const res = await post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$' });
1321
- expect(res.status).toBe(400);
1322
- expect(((await res.json()) as { error: string }).error).toMatch(/^Pattern too expensive: /);
1454
+ it('never answers a line from a file the caller may not read, nor counts it against max_results', async () => {
1455
+ const base = await start('read', denyReads(new Set(['a-secret.md', 'Locked/b.md'])));
1456
+ await fs.writeFile(`${KB_DIR}/a-secret.md`, 'needle 1\nneedle 2\nneedle 3\n');
1457
+ await fs.writeFile(`${KB_DIR}/Locked/b.md`, 'needle locked\n');
1458
+ await fs.writeFile(`${KB_DIR}/open.md`, 'needle open\n');
1459
+ const res = await grep(base, { pattern: 'needle', max_results: 1 });
1460
+ expect(res.body.matches).toEqual([{ path: `${KB_DIR}/open.md`, line: 1, text: 'needle open' }]);
1461
+ expect(res.body.truncated).toBe(true);
1323
1462
  });
1324
1463
 
1325
- it('answers a directory walk 503 when no worker is free, rather than skipping every file as unreadable', async () => {
1326
- // One worker and room for one search to wait: two expensive greps fill both.
1327
- matcherOptions = { budgetMs: 1_000, poolMax: 1, maxWaiting: 1 };
1464
+ // Four million matching lines: a file ripgrep searched would spend far more
1465
+ // than the 1.5 s budget answering them.
1466
+ const huge = 'needle\n'.repeat(4_000_000);
1467
+
1468
+ it('never searches a file the caller may not read, so what it holds cannot turn the answer into a 400', async () => {
1469
+ matcherOptions = { budgetMs: 1_500 };
1470
+ const base = await start('read', denyReads(new Set(['Locked/huge.md'])));
1471
+ await fs.writeFile(`${KB_DIR}/Locked/huge.md`, huge);
1472
+ await fs.writeFile(`${KB_DIR}/open.md`, 'needle open\n');
1473
+ const res = await grep(base, { pattern: 'needle' });
1474
+ expect(res.status).toBe(200);
1475
+ expect(res.body.matches).toEqual([{ path: `${KB_DIR}/open.md`, line: 1, text: 'needle open' }]);
1476
+ }, 20_000);
1477
+
1478
+ it('spends the budget on judging who may read what too: a verdict that outlasts it answers the 400 at the budget', async () => {
1479
+ matcherOptions = { budgetMs: 1_000 };
1480
+ // An access check that takes ten seconds, as a cold one over a large tree could.
1481
+ const slowAccess = {
1482
+ ...allowAll,
1483
+ canReadBatch: (_w: string, _u: string, paths: string[]) =>
1484
+ new Promise<Map<string, boolean>>((resolve) => setTimeout(() => resolve(new Map(paths.map((p) => [p, true]))), 10_000)),
1485
+ } as unknown as IAccessControl;
1486
+ const base = await start('read', slowAccess);
1487
+ await fs.writeFile(`${KB_DIR}/open.md`, 'needle open\n');
1488
+ const started = Date.now();
1489
+ const res = await grep(base, { pattern: 'needle' });
1490
+ expect(res.status).toBe(400);
1491
+ expect(res.body.code).toBe('pattern_too_expensive');
1492
+ expect(Date.now() - started).toBeLessThan(3_000);
1493
+ }, 20_000);
1494
+
1495
+ it('stops the search at the file that fills max_results, so what comes after it costs nothing', async () => {
1496
+ matcherOptions = { budgetMs: 1_500 };
1328
1497
  const base = await start();
1329
- await fs.writeFile(`${KB_DIR}/redos.md`, `${LONG_LINE}\n`);
1330
- await fs.writeFile(`${KB_DIR}/hit.md`, 'needle\n');
1331
- const expensive = () =>
1332
- post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$', path: `${KB_DIR}/redos.md` }).then((r) => r.status);
1333
- const holding = expensive();
1334
- await new Promise((r) => setTimeout(r, 200));
1335
- const queued = expensive();
1336
- await new Promise((r) => setTimeout(r, 100));
1337
- const res = await post(`${base}/api/agent/tools/grep`, { pattern: 'needle' });
1338
- expect(res.status).toBe(503);
1339
- expect(((await res.json()) as { code?: string }).code).toBe('search_busy');
1340
- // The two that held the pool answered as they always would, and it serves again after.
1341
- expect(await Promise.all([holding, queued])).toEqual([400, 400]);
1342
- const after = await post(`${base}/api/agent/tools/grep`, { pattern: 'needle' });
1343
- expect(after.status).toBe(200);
1344
- });
1345
-
1346
- it('spends one budget across every file a search reads: each alone fits, all of them together do not', async () => {
1347
- // How long the pattern takes over one line IN A WORKER, which backtracks at
1348
- // its own pace, not this thread's: once on a fresh worker (`first`, which
1349
- // can be several times slower) and then again on the same one (`again`).
1350
- // Timed through a probe matcher of the same kind, on a line long enough
1351
- // that `again` is at least 50 ms.
1352
- let line = '';
1353
- let first = 0;
1354
- let again = 0;
1355
- for (let n = 16; again < 50; n++) {
1356
- line = `${'a'.repeat(n)}!`;
1357
- const probe = new WorkerPatternMatcher();
1358
- try {
1359
- await probe.search('x', '').matchingLines(['x'], 1); // the worker is up before anything is timed
1360
- let t = performance.now();
1361
- await probe.search('^(a+)+$', '').matchingLines([line], 1);
1362
- first = performance.now() - t;
1363
- t = performance.now();
1364
- await probe.search('^(a+)+$', '').matchingLines([line], 1);
1365
- again = performance.now() - t;
1366
- } finally {
1367
- await probe.close();
1368
- }
1498
+ await fs.writeFile(`${KB_DIR}/a.md`, 'needle first\n');
1499
+ await fs.writeFile(`${KB_DIR}/z/huge.md`, huge);
1500
+ const res = await grep(base, { pattern: 'needle', max_results: 1 });
1501
+ expect(res.status).toBe(200);
1502
+ expect(res.body.matches).toEqual([{ path: `${KB_DIR}/a.md`, line: 1, text: 'needle first' }]);
1503
+ expect(res.body.truncated).toBe(true);
1504
+ }, 20_000);
1505
+
1506
+ it('reads a file of millions of matching lines only as far as max_results, in a tree and named alone', async () => {
1507
+ matcherOptions = { budgetMs: 1_500 };
1508
+ const base = await start();
1509
+ await fs.writeFile(`${KB_DIR}/a/huge.md`, huge);
1510
+ await fs.writeFile(`${KB_DIR}/b.md`, 'needle b\n');
1511
+ const expected = Array.from({ length: 200 }, (_, i) => ({ path: `${KB_DIR}/a/huge.md`, line: i + 1, text: 'needle' }));
1512
+ for (const path of [KB_DIR, `${KB_DIR}/a/huge.md`]) {
1513
+ const res = await grep(base, { pattern: 'needle', path });
1514
+ expect(res.status).toBe(200);
1515
+ expect(res.body.matches).toEqual(expected);
1516
+ expect(res.body.truncated).toBe(true);
1517
+ }
1518
+ }, 20_000);
1519
+
1520
+ it('a named file whose only NUL lies past the first 64 KB has no searchable text; the same length of text has no matches and no note', async () => {
1521
+ matcherOptions = {};
1522
+ const base = await start();
1523
+ // ripgrep stops reading a file at the lines the answer can take, so a NUL
1524
+ // far past the pattern's last chance is found by the read that follows,
1525
+ // chunk by chunk — here in the fourth chunk.
1526
+ const filler = Buffer.alloc(200 * 1024, 'x');
1527
+ await fs.writeFile(`${KB_DIR}/late.md`, Buffer.concat([filler, Buffer.from([0])]));
1528
+ await fs.writeFile(`${KB_DIR}/plain.md`, filler);
1529
+ const binary = await grep(base, { pattern: 'needle', path: `${KB_DIR}/late.md` });
1530
+ expect(binary.status).toBe(200);
1531
+ expect(binary.body.matches).toEqual([]);
1532
+ expect(binary.body.note).toContain('no searchable text');
1533
+ const text = await grep(base, { pattern: 'needle', path: `${KB_DIR}/plain.md` });
1534
+ expect(text.status).toBe(200);
1535
+ expect(text.body.matches).toEqual([]);
1536
+ expect(text.body.note).toBeUndefined();
1537
+ });
1538
+
1539
+ it('stops the search when its caller goes away, leaving no ripgrep running', async () => {
1540
+ const slow = await slowRipgrep();
1541
+ try {
1542
+ matcherOptions = {};
1543
+ const base = await start();
1544
+ const gone = new AbortController();
1545
+ const call = fetch(`${base}/api/agent/tools/grep`, {
1546
+ method: 'POST',
1547
+ headers: { 'content-type': 'application/json', authorization: 'Bearer x' },
1548
+ body: JSON.stringify({ branch: 'main', pattern: 'needle' }),
1549
+ signal: gone.signal,
1550
+ }).catch(() => undefined);
1551
+ while ((await slow.alive()).length === 0) await new Promise((r) => setTimeout(r, 20));
1552
+ const left = Date.now();
1553
+ gone.abort();
1554
+ await call;
1555
+ // Well inside the 5 s budget the search would otherwise have run to.
1556
+ while ((await slow.alive()).length > 0 && Date.now() - left < 4_000) await new Promise((r) => setTimeout(r, 20));
1557
+ expect(await slow.alive()).toEqual([]);
1558
+ expect(Date.now() - left).toBeLessThan(1_000);
1559
+ } finally {
1560
+ await slow.remove();
1369
1561
  }
1370
- // A budget well above what any one file costs, and enough files that
1371
- // together they cost about twice the budget.
1372
- const budgetMs = Math.ceil(Math.max(first, again) * 1.5) + 200;
1373
- const files = Math.ceil((2 * budgetMs) / again);
1374
- matcherOptions = { budgetMs };
1562
+ }, 15_000);
1563
+
1564
+ it('searches node_modules and folders of any depth, and a link inside the workspace only where it points', async () => {
1375
1565
  const base = await start();
1376
- for (let i = 1; i <= files; i++) await fs.writeFile(`${KB_DIR}/slow-${String(i).padStart(3, '0')}.md`, `${line}\n`);
1377
- const all = await post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$' });
1378
- expect(all.status).toBe(400);
1379
- const body = (await all.json()) as { error: string; code?: string };
1380
- expect(body.code).toBe('pattern_too_expensive');
1381
- expect(body.error).toMatch(/^Pattern too expensive: /);
1382
- // Any one of the same files, searched alone, fits the same budget.
1383
- const one = await post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$', path: `${KB_DIR}/slow-001.md` });
1384
- expect(one.status).toBe(200);
1385
- expect(((await one.json()) as { matches: unknown[] }).matches).toEqual([]);
1386
- });
1387
-
1388
- it('a control pattern over the same line still matches', async () => {
1566
+ const deep = Array.from({ length: 15 }, (_, i) => `d${i}`).join('/');
1567
+ await fs.writeFile(`${KB_DIR}/node_modules/pkg/readme.md`, 'needle in node_modules\n');
1568
+ await fs.writeFile(`${KB_DIR}/${deep}/deep.md`, 'needle fifteen levels down\n');
1569
+ await fs.writeFile(`${KB_DIR}/real/target.md`, 'needle at the target\n');
1570
+ await symlink(join(tempDir, KB_DIR, 'real', 'target.md'), join(tempDir, KB_DIR, 'file-link.md'));
1571
+ await symlink(join(tempDir, KB_DIR, 'real'), join(tempDir, KB_DIR, 'folder-link'));
1572
+ const res = await grep(base, { pattern: 'needle' });
1573
+ expect(res.status).toBe(200);
1574
+ expect(res.body.matches.map((m) => m.path)).toEqual([
1575
+ `${KB_DIR}/${deep}/deep.md`,
1576
+ `${KB_DIR}/node_modules/pkg/readme.md`,
1577
+ `${KB_DIR}/real/target.md`,
1578
+ ]);
1579
+ });
1580
+
1581
+ it('leaves out a file whose name holds a line break, which no tool can read, and answers the rest', async () => {
1389
1582
  const base = await start();
1390
- await fs.writeFile(`${KB_DIR}/redos.md`, `intro\n${LONG_LINE}\n`);
1391
- const res = await post(`${base}/api/agent/tools/grep`, { pattern: '^a+!$', path: `${KB_DIR}/redos.md` });
1583
+ await writeFile(join(tempDir, KB_DIR, 'two\nlines.md'), 'needle in an odd name\n');
1584
+ await fs.writeFile(`${KB_DIR}/plain.md`, 'needle plain\n');
1585
+ expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/two\nlines.md` })).status).toBe(400);
1586
+ const res = await grep(base, { pattern: 'needle' });
1392
1587
  expect(res.status).toBe(200);
1393
- expect(((await res.json()) as { matches: unknown[] }).matches).toEqual([{ path: `${KB_DIR}/redos.md`, line: 2, text: LONG_LINE }]);
1588
+ expect(res.body.matches).toEqual([{ path: `${KB_DIR}/plain.md`, line: 1, text: 'needle plain' }]);
1589
+ });
1590
+
1591
+ it('answers the first max_results by path then line, whatever reads them, and marks the answer truncated', async () => {
1592
+ const docx = (text: string): Buffer => {
1593
+ const zip = new AdmZip();
1594
+ zip.addFile('[Content_Types].xml', Buffer.from('<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>'));
1595
+ zip.addFile(
1596
+ 'word/document.xml',
1597
+ Buffer.from(`<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>${text}</w:t></w:r></w:p></w:body></w:document>`),
1598
+ );
1599
+ return zip.toBuffer();
1600
+ };
1601
+ const base = await start();
1602
+ await fs.writeFile(`${KB_DIR}/b.md`, 'x\nneedle b2\nneedle b3\n');
1603
+ await fs.writeFile(`${KB_DIR}/a/z.md`, 'needle a/z\n');
1604
+ await fs.writeFile(`${KB_DIR}/a.md`, 'needle a.md\n');
1605
+ await fs.writeFile(`${KB_DIR}/a/m.docx`, docx('needle in a document'));
1606
+ await fs.writeFile(`${KB_DIR}/c.md`, `needle ${'c'.repeat(400)}\n`);
1607
+ const all = await grep(base, { pattern: 'needle' });
1608
+ expect(all.body.matches.map((m) => [m.path, m.line])).toEqual([
1609
+ [`${KB_DIR}/a/m.docx`, 2],
1610
+ [`${KB_DIR}/a/z.md`, 1],
1611
+ [`${KB_DIR}/a.md`, 1],
1612
+ [`${KB_DIR}/b.md`, 2],
1613
+ [`${KB_DIR}/b.md`, 3],
1614
+ [`${KB_DIR}/c.md`, 1],
1615
+ ]);
1616
+ expect(all.body.truncated).toBe(false);
1617
+ expect(all.body.matches[5]!.text).toHaveLength(300);
1618
+ const capped = await grep(base, { pattern: 'needle', max_results: 4 });
1619
+ expect(capped.body.matches).toEqual(all.body.matches.slice(0, 4));
1620
+ expect(capped.body.truncated).toBe(true);
1621
+ // The default cap is 200.
1622
+ await fs.writeFile(`${KB_DIR}/many.md`, 'needle\n'.repeat(250));
1623
+ const many = await grep(base, { pattern: 'needle', path: `${KB_DIR}/many.md` });
1624
+ expect(many.body.matches).toHaveLength(200);
1625
+ expect(many.body.truncated).toBe(true);
1626
+ });
1627
+
1628
+ it('honours ignore_case', async () => {
1629
+ const base = await start();
1630
+ await fs.writeFile(`${KB_DIR}/case.md`, 'Needle\nneedle\nNEEDLE\n');
1631
+ expect((await grep(base, { pattern: 'NEEDLE', path: `${KB_DIR}/case.md` })).body.matches.map((m) => m.line)).toEqual([3]);
1632
+ expect((await grep(base, { pattern: 'NEEDLE', path: `${KB_DIR}/case.md`, ignore_case: true })).body.matches.map((m) => m.line)).toEqual([1, 2, 3]);
1633
+ expect((await grep(base, { pattern: 'NEEDLE', ignore_case: true })).body.matches.map((m) => m.line)).toEqual([1, 2, 3]);
1634
+ });
1635
+
1636
+ it('searches a pattern and a path beginning with `-` as text, never as an option', async () => {
1637
+ const base = await start();
1638
+ await fs.writeFile(`${KB_DIR}/-dir/-n.md`, 'run it with -n\nnothing here\n');
1639
+ for (const path of [undefined, `${KB_DIR}/-dir`, `${KB_DIR}/-dir/-n.md`, '-dir']) {
1640
+ const res = await grep(base, path === undefined ? { pattern: '-n' } : { pattern: '-n', path });
1641
+ expect(res.status).toBe(200);
1642
+ expect(res.body.matches).toEqual([{ path: path === '-dir' ? `${KB_DIR}/-dir/-n.md` : `${KB_DIR}/-dir/-n.md`, line: 1, text: 'run it with -n' }]);
1643
+ }
1644
+ // `--files` as a pattern is a pattern too: it matches nothing here rather than listing files.
1645
+ expect((await grep(base, { pattern: '--files' })).body.matches).toEqual([]);
1646
+ });
1647
+
1648
+ it('a pattern carrying a NUL is refused with the escape to use instead', async () => {
1649
+ const base = await start();
1650
+ const res = await grep(base, { pattern: 'a\u0000b' });
1651
+ expect(res.status).toBe(400);
1652
+ expect(res.body.error).toContain('\\x00');
1394
1653
  });
1395
1654
  });
1396
1655
 
@@ -2539,7 +2798,7 @@ describe('branch is a required parameter in the tool contract', () => {
2539
2798
  undefined,
2540
2799
  undefined,
2541
2800
  // So `grep` is mounted; it spawns no worker, as nothing here searches.
2542
- new WorkerPatternMatcher(),
2801
+ new RipgrepPatternMatcher(),
2543
2802
  );
2544
2803
  return registry;
2545
2804
  }
@@ -4568,17 +4827,38 @@ describe('agent read/write hooks', () => {
4568
4827
  expect(writes).toEqual([]);
4569
4828
  });
4570
4829
 
4571
- it('grep tells the read hook about every file the walk opens, not only its root', async () => {
4830
+ it('grep tells the read hook about every file it opens — a file with no matching line included — not only its root', async () => {
4572
4831
  const base = await start();
4573
4832
  record();
4574
4833
  await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
4575
4834
  await fs.mkdir(`${KB_DIR}/KnowledgeBase/Legal/Knowledge`, { recursive: true });
4576
4835
  await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`, 'needle');
4577
4836
  await fs.writeFile(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`, 'needle');
4837
+ // Searched and read by ripgrep like the others; it answers no line.
4838
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/Quiet.md`, 'nothing of the sort');
4578
4839
  await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: KB_DIR, sessionId: 's1' });
4579
4840
  const paths = reads.map((r) => r.wsPath);
4580
4841
  expect(paths).toContain(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`);
4581
4842
  expect(paths).toContain(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`);
4843
+ expect(paths).toContain(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/Quiet.md`);
4844
+ expect(paths.filter((p) => p === `${KB_DIR}/KnowledgeBase/Legal/Knowledge/Quiet.md`)).toHaveLength(1);
4845
+ });
4846
+
4847
+ it('a read hook that refuses a file the search would open ends the grep as it ends a read_file, even one that matches nothing', async () => {
4848
+ const base = await start();
4849
+ record();
4850
+ hooks.onAgentRead(async (op) => {
4851
+ if (op.wsPath === `${KB_DIR}/KnowledgeBase/Legal/Knowledge/Quiet.md`) throw new ToolError('Quiet.md is off limits to this run', 403);
4852
+ });
4853
+ await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
4854
+ await fs.mkdir(`${KB_DIR}/KnowledgeBase/Legal/Knowledge`, { recursive: true });
4855
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`, 'needle');
4856
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/Quiet.md`, 'nothing of the sort');
4857
+ const res = await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: KB_DIR, sessionId: 's1' });
4858
+ expect(res.status).toBe(403);
4859
+ expect(await res.json()).toMatchObject({ error: 'Quiet.md is off limits to this run' });
4860
+ // The refusal came at admission: the hook heard of the file, and the search answered nothing from any file.
4861
+ expect(reads.map((r) => r.wsPath)).toContain(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/Quiet.md`);
4582
4862
  });
4583
4863
 
4584
4864
  it('the write hook covers write_file, edit_file and mkdir, once per path', async () => {
@@ -152,6 +152,16 @@ describe('DocExtractService — how long a failure is remembered', () => {
152
152
  expect(told).toHaveLength(1);
153
153
  });
154
154
 
155
+ it('two reads of the same bytes at different paths share one extraction, and each marker names its own path', async () => {
156
+ const extractor = scriptedExtractor(() => ({ ok: true, summary: '1 page', text: '[page 1]\nshared' }));
157
+ const service = new DocExtractService(cacheRoot, extractor, { now, onUnreadable });
158
+ const bytes = Buffer.from('same bytes, two names');
159
+ const [a, b] = await Promise.all([service.extract('a/deck.pdf', bytes, ORIGIN), service.extract('b/copy.pdf', bytes, ORIGIN)]);
160
+ expect(extractor.calls).toBe(1);
161
+ expect(a).toEqual({ ok: true, marker: '[extracted text of a/deck.pdf — 1 page]', text: '[page 1]\nshared' });
162
+ expect(b).toEqual({ ok: true, marker: '[extracted text of b/copy.pdf — 1 page]', text: '[page 1]\nshared' });
163
+ });
164
+
155
165
  it('still answers the read when whoever is told throws', async () => {
156
166
  const extractor = scriptedExtractor(() => TIMEOUT);
157
167
  const service = new DocExtractService(cacheRoot, extractor, {