@bevel-software/platform-core-backend 0.14.0 → 0.19.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 (1307) hide show
  1. package/THIRD-PARTY-NOTICES.md +8 -8
  2. package/dist/core/catalog-cache-invalidation.d.ts +45 -0
  3. package/dist/core/catalog-cache-invalidation.d.ts.map +1 -0
  4. package/dist/core/catalog-cache-invalidation.js +76 -0
  5. package/dist/core/catalog-cache-invalidation.js.map +1 -0
  6. package/dist/core/catalog-revision.d.ts +39 -0
  7. package/dist/core/catalog-revision.d.ts.map +1 -0
  8. package/dist/core/catalog-revision.js +94 -0
  9. package/dist/core/catalog-revision.js.map +1 -0
  10. package/dist/core/create-core-server.d.ts +7 -0
  11. package/dist/core/create-core-server.d.ts.map +1 -1
  12. package/dist/core/create-core-server.js +279 -64
  13. package/dist/core/create-core-server.js.map +1 -1
  14. package/dist/core/create-core-services.d.ts +66 -2
  15. package/dist/core/create-core-services.d.ts.map +1 -1
  16. package/dist/core/create-core-services.js +290 -111
  17. package/dist/core/create-core-services.js.map +1 -1
  18. package/dist/core/lifecycle.d.ts +138 -0
  19. package/dist/core/lifecycle.d.ts.map +1 -0
  20. package/dist/core/lifecycle.js +218 -0
  21. package/dist/core/lifecycle.js.map +1 -0
  22. package/dist/core/public-config.d.ts +82 -0
  23. package/dist/core/public-config.d.ts.map +1 -0
  24. package/dist/core/public-config.js +78 -0
  25. package/dist/core/public-config.js.map +1 -0
  26. package/dist/core/readiness.d.ts +86 -0
  27. package/dist/core/readiness.d.ts.map +1 -0
  28. package/dist/core/readiness.js +56 -0
  29. package/dist/core/readiness.js.map +1 -0
  30. package/dist/core-config.d.ts +30 -0
  31. package/dist/core-config.d.ts.map +1 -1
  32. package/dist/core-config.js +64 -2
  33. package/dist/core-config.js.map +1 -1
  34. package/dist/index.d.ts +4 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +6 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/modules/access/access-control.interface.d.ts +231 -15
  39. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  40. package/dist/modules/access/access-control.service.d.ts +83 -12
  41. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  42. package/dist/modules/access/access-control.service.js +815 -212
  43. package/dist/modules/access/access-control.service.js.map +1 -1
  44. package/dist/modules/access/access-mutation.service.d.ts +71 -5
  45. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  46. package/dist/modules/access/access-mutation.service.js +161 -13
  47. package/dist/modules/access/access-mutation.service.js.map +1 -1
  48. package/dist/modules/access/access-view.d.ts +147 -0
  49. package/dist/modules/access/access-view.d.ts.map +1 -0
  50. package/dist/modules/access/access-view.js +211 -0
  51. package/dist/modules/access/access-view.js.map +1 -0
  52. package/dist/modules/access/access.routes.d.ts.map +1 -1
  53. package/dist/modules/access/access.routes.js +312 -84
  54. package/dist/modules/access/access.routes.js.map +1 -1
  55. package/dist/modules/access/admin-locked-commit.d.ts +2 -3
  56. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  57. package/dist/modules/access/admin-locked-commit.js +9 -8
  58. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  59. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -1
  60. package/dist/modules/access/admin-route-helpers.js +4 -4
  61. package/dist/modules/access/admin-route-helpers.js.map +1 -1
  62. package/dist/modules/access/change-read-gate.d.ts +53 -0
  63. package/dist/modules/access/change-read-gate.d.ts.map +1 -0
  64. package/dist/modules/access/change-read-gate.js +134 -0
  65. package/dist/modules/access/change-read-gate.js.map +1 -0
  66. package/dist/modules/access/creator-access.d.ts +33 -39
  67. package/dist/modules/access/creator-access.d.ts.map +1 -1
  68. package/dist/modules/access/creator-access.js +92 -119
  69. package/dist/modules/access/creator-access.js.map +1 -1
  70. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  71. package/dist/modules/access/directory-sync-bot.js +3 -1
  72. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  73. package/dist/modules/access/groups-admin.service.d.ts +3 -3
  74. package/dist/modules/access/groups-admin.service.js +2 -2
  75. package/dist/modules/access/reference-scan.d.ts +1 -1
  76. package/dist/modules/access/reference-scan.d.ts.map +1 -1
  77. package/dist/modules/access/reference-scan.js +5 -4
  78. package/dist/modules/access/reference-scan.js.map +1 -1
  79. package/dist/modules/access/roles-admin.service.d.ts +72 -13
  80. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  81. package/dist/modules/access/roles-admin.service.js +71 -13
  82. package/dist/modules/access/roles-admin.service.js.map +1 -1
  83. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -1
  84. package/dist/modules/access/synced-groups-committer.js +8 -6
  85. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  86. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -1
  87. package/dist/modules/access/synced-groups-writer.js +3 -8
  88. package/dist/modules/access/synced-groups-writer.js.map +1 -1
  89. package/dist/modules/access/user-access-removal.service.d.ts +209 -0
  90. package/dist/modules/access/user-access-removal.service.d.ts.map +1 -0
  91. package/dist/modules/access/user-access-removal.service.js +662 -0
  92. package/dist/modules/access/user-access-removal.service.js.map +1 -0
  93. package/dist/modules/access-model/access-errors.d.ts +32 -1
  94. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  95. package/dist/modules/access-model/access-errors.js +38 -11
  96. package/dist/modules/access-model/access-errors.js.map +1 -1
  97. package/dist/modules/access-model/access-grammar.d.ts +100 -25
  98. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  99. package/dist/modules/access-model/access-grammar.js +162 -48
  100. package/dist/modules/access-model/access-grammar.js.map +1 -1
  101. package/dist/modules/access-model/access-splice.d.ts.map +1 -1
  102. package/dist/modules/access-model/access-splice.js +18 -18
  103. package/dist/modules/access-model/access-splice.js.map +1 -1
  104. package/dist/modules/access-model/access-template.d.ts +22 -0
  105. package/dist/modules/access-model/access-template.d.ts.map +1 -0
  106. package/dist/modules/access-model/access-template.js +42 -0
  107. package/dist/modules/access-model/access-template.js.map +1 -0
  108. package/dist/modules/access-model/change-gate.d.ts +65 -0
  109. package/dist/modules/access-model/change-gate.d.ts.map +1 -0
  110. package/dist/modules/access-model/change-gate.js +25 -0
  111. package/dist/modules/access-model/change-gate.js.map +1 -0
  112. package/dist/modules/access-model/creator.d.ts +13 -16
  113. package/dist/modules/access-model/creator.d.ts.map +1 -1
  114. package/dist/modules/access-model/creator.js.map +1 -1
  115. package/dist/modules/access-model/frontmatter-lines.d.ts +27 -0
  116. package/dist/modules/access-model/frontmatter-lines.d.ts.map +1 -0
  117. package/dist/modules/access-model/frontmatter-lines.js +74 -0
  118. package/dist/modules/access-model/frontmatter-lines.js.map +1 -0
  119. package/dist/modules/access-model/group-files.d.ts.map +1 -1
  120. package/dist/modules/access-model/group-files.js +4 -1
  121. package/dist/modules/access-model/group-files.js.map +1 -1
  122. package/dist/modules/access-model/plugin-principals.d.ts +51 -0
  123. package/dist/modules/access-model/plugin-principals.d.ts.map +1 -0
  124. package/dist/modules/access-model/plugin-principals.js +119 -0
  125. package/dist/modules/access-model/plugin-principals.js.map +1 -0
  126. package/dist/modules/access-model/roles-yaml-guard.d.ts +73 -2
  127. package/dist/modules/access-model/roles-yaml-guard.d.ts.map +1 -1
  128. package/dist/modules/access-model/roles-yaml-guard.js +153 -3
  129. package/dist/modules/access-model/roles-yaml-guard.js.map +1 -1
  130. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  131. package/dist/modules/admin/admin-access.service.js +5 -2
  132. package/dist/modules/admin/admin-access.service.js.map +1 -1
  133. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +21 -0
  134. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -0
  135. package/dist/modules/agent-instructions/agent-instructions.routes.js +38 -0
  136. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -0
  137. package/dist/modules/agent-instructions/compose.d.ts +71 -0
  138. package/dist/modules/agent-instructions/compose.d.ts.map +1 -0
  139. package/dist/modules/agent-instructions/compose.js +234 -0
  140. package/dist/modules/agent-instructions/compose.js.map +1 -0
  141. package/dist/modules/agent-instructions/index.d.ts +4 -0
  142. package/dist/modules/agent-instructions/index.d.ts.map +1 -0
  143. package/dist/modules/agent-instructions/index.js +4 -0
  144. package/dist/modules/agent-instructions/index.js.map +1 -0
  145. package/dist/modules/agent-instructions/read-preamble.d.ts +37 -0
  146. package/dist/modules/agent-instructions/read-preamble.d.ts.map +1 -0
  147. package/dist/modules/agent-instructions/read-preamble.js +81 -0
  148. package/dist/modules/agent-instructions/read-preamble.js.map +1 -0
  149. package/dist/modules/auth/account-erasure.service.d.ts +30 -4
  150. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  151. package/dist/modules/auth/account-erasure.service.js +96 -10
  152. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  153. package/dist/modules/auth/account.routes.d.ts +6 -3
  154. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  155. package/dist/modules/auth/account.routes.js +109 -10
  156. package/dist/modules/auth/account.routes.js.map +1 -1
  157. package/dist/modules/auth/auth.routes.d.ts +10 -3
  158. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  159. package/dist/modules/auth/auth.routes.js +9 -4
  160. package/dist/modules/auth/auth.routes.js.map +1 -1
  161. package/dist/modules/auth/auth.service.d.ts +89 -5
  162. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  163. package/dist/modules/auth/auth.service.js +160 -25
  164. package/dist/modules/auth/auth.service.js.map +1 -1
  165. package/dist/modules/auth/oidc-auth-provider.d.ts +46 -3
  166. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  167. package/dist/modules/auth/oidc-auth-provider.js +99 -21
  168. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  169. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  170. package/dist/modules/code-mode/code-mode.tool.js +14 -2
  171. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  172. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
  173. package/dist/modules/connection-probe/connection-probe.service.js +56 -7
  174. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
  175. package/dist/modules/database/advisory-lock.d.ts +103 -0
  176. package/dist/modules/database/advisory-lock.d.ts.map +1 -0
  177. package/dist/modules/database/advisory-lock.js +320 -0
  178. package/dist/modules/database/advisory-lock.js.map +1 -0
  179. package/dist/modules/database/connection.d.ts.map +1 -1
  180. package/dist/modules/database/connection.js +9 -1
  181. package/dist/modules/database/connection.js.map +1 -1
  182. package/dist/modules/database/core-schema.d.ts +672 -0
  183. package/dist/modules/database/core-schema.d.ts.map +1 -1
  184. package/dist/modules/database/core-schema.js +171 -0
  185. package/dist/modules/database/core-schema.js.map +1 -1
  186. package/dist/modules/database/migrate.d.ts.map +1 -1
  187. package/dist/modules/database/migrate.js +26 -6
  188. package/dist/modules/database/migrate.js.map +1 -1
  189. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -1
  190. package/dist/modules/declared-variables/declared-variables.routes.js +4 -2
  191. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -1
  192. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  193. package/dist/modules/diff/diff.routes.js +2 -4
  194. package/dist/modules/diff/diff.routes.js.map +1 -1
  195. package/dist/modules/diff/diff.service.d.ts +4 -1
  196. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  197. package/dist/modules/diff/diff.service.js +79 -81
  198. package/dist/modules/diff/diff.service.js.map +1 -1
  199. package/dist/modules/kb-fs/bevel-ignore.d.ts +35 -0
  200. package/dist/modules/kb-fs/bevel-ignore.d.ts.map +1 -0
  201. package/dist/modules/kb-fs/bevel-ignore.js +71 -0
  202. package/dist/modules/kb-fs/bevel-ignore.js.map +1 -0
  203. package/dist/modules/kb-fs/file-change-notifier.d.ts.map +1 -1
  204. package/dist/modules/kb-fs/file-change-notifier.js +3 -1
  205. package/dist/modules/kb-fs/file-change-notifier.js.map +1 -1
  206. package/dist/modules/kb-fs/git-guarded-filesystem.d.ts +37 -0
  207. package/dist/modules/kb-fs/git-guarded-filesystem.d.ts.map +1 -0
  208. package/dist/modules/kb-fs/git-guarded-filesystem.js +104 -0
  209. package/dist/modules/kb-fs/git-guarded-filesystem.js.map +1 -0
  210. package/dist/modules/kb-fs/locking-filesystem.d.ts +113 -18
  211. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  212. package/dist/modules/kb-fs/locking-filesystem.js +353 -76
  213. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  214. package/dist/modules/kb-fs/node-fs.d.ts +23 -0
  215. package/dist/modules/kb-fs/node-fs.d.ts.map +1 -0
  216. package/dist/modules/kb-fs/node-fs.js +158 -0
  217. package/dist/modules/kb-fs/node-fs.js.map +1 -0
  218. package/dist/modules/kb-fs/read-only-filesystem.d.ts +10 -9
  219. package/dist/modules/kb-fs/read-only-filesystem.d.ts.map +1 -1
  220. package/dist/modules/kb-fs/read-only-filesystem.js +18 -9
  221. package/dist/modules/kb-fs/read-only-filesystem.js.map +1 -1
  222. package/dist/modules/kb-fs/repo-path.d.ts +100 -3
  223. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  224. package/dist/modules/kb-fs/repo-path.js +195 -3
  225. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  226. package/dist/modules/kb-sync/index.d.ts +6 -0
  227. package/dist/modules/kb-sync/index.d.ts.map +1 -0
  228. package/dist/modules/kb-sync/index.js +5 -0
  229. package/dist/modules/kb-sync/index.js.map +1 -0
  230. package/dist/modules/kb-sync/kb-sync.interface.d.ts +77 -0
  231. package/dist/modules/kb-sync/kb-sync.interface.d.ts.map +1 -0
  232. package/dist/modules/kb-sync/kb-sync.interface.js +2 -0
  233. package/dist/modules/kb-sync/kb-sync.interface.js.map +1 -0
  234. package/dist/modules/kb-sync/kb-sync.routes.d.ts +70 -0
  235. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -0
  236. package/dist/modules/kb-sync/kb-sync.routes.js +137 -0
  237. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -0
  238. package/dist/modules/kb-sync/kb-sync.service.d.ts +45 -0
  239. package/dist/modules/kb-sync/kb-sync.service.d.ts.map +1 -0
  240. package/dist/modules/kb-sync/kb-sync.service.js +185 -0
  241. package/dist/modules/kb-sync/kb-sync.service.js.map +1 -0
  242. package/dist/modules/kb-sync/sync-auth.d.ts +59 -0
  243. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -0
  244. package/dist/modules/kb-sync/sync-auth.js +62 -0
  245. package/dist/modules/kb-sync/sync-auth.js.map +1 -0
  246. package/dist/modules/kb-sync/sync-payload.d.ts +32 -0
  247. package/dist/modules/kb-sync/sync-payload.d.ts.map +1 -0
  248. package/dist/modules/kb-sync/sync-payload.js +105 -0
  249. package/dist/modules/kb-sync/sync-payload.js.map +1 -0
  250. package/dist/modules/marketplace/git-http.routes.d.ts +42 -0
  251. package/dist/modules/marketplace/git-http.routes.d.ts.map +1 -0
  252. package/dist/modules/marketplace/git-http.routes.js +192 -0
  253. package/dist/modules/marketplace/git-http.routes.js.map +1 -0
  254. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts +33 -0
  255. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts.map +1 -0
  256. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js +88 -0
  257. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js.map +1 -0
  258. package/dist/modules/marketplace/github-facade/github-facade-codes.store.d.ts +59 -0
  259. package/dist/modules/marketplace/github-facade/github-facade-codes.store.d.ts.map +1 -0
  260. package/dist/modules/marketplace/github-facade/github-facade-codes.store.js +96 -0
  261. package/dist/modules/marketplace/github-facade/github-facade-codes.store.js.map +1 -0
  262. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts +126 -0
  263. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts.map +1 -0
  264. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js +192 -0
  265. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js.map +1 -0
  266. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts +47 -0
  267. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts.map +1 -0
  268. package/dist/modules/marketplace/github-facade/github-facade.routes.js +252 -0
  269. package/dist/modules/marketplace/github-facade/github-facade.routes.js.map +1 -0
  270. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts +121 -0
  271. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts.map +1 -0
  272. package/dist/modules/marketplace/github-facade/github-facade.service.js +213 -0
  273. package/dist/modules/marketplace/github-facade/github-facade.service.js.map +1 -0
  274. package/dist/modules/marketplace/github-facade/index.d.ts +6 -0
  275. package/dist/modules/marketplace/github-facade/index.d.ts.map +1 -0
  276. package/dist/modules/marketplace/github-facade/index.js +6 -0
  277. package/dist/modules/marketplace/github-facade/index.js.map +1 -0
  278. package/dist/modules/marketplace/index.d.ts +4 -0
  279. package/dist/modules/marketplace/index.d.ts.map +1 -0
  280. package/dist/modules/marketplace/index.js +4 -0
  281. package/dist/modules/marketplace/index.js.map +1 -0
  282. package/dist/modules/marketplace/marketplace-repo.service.d.ts +135 -0
  283. package/dist/modules/marketplace/marketplace-repo.service.d.ts.map +1 -0
  284. package/dist/modules/marketplace/marketplace-repo.service.js +318 -0
  285. package/dist/modules/marketplace/marketplace-repo.service.js.map +1 -0
  286. package/dist/modules/mcp/downstream-pool.d.ts +107 -0
  287. package/dist/modules/mcp/downstream-pool.d.ts.map +1 -0
  288. package/dist/modules/mcp/downstream-pool.js +221 -0
  289. package/dist/modules/mcp/downstream-pool.js.map +1 -0
  290. package/dist/modules/mcp/downstream-token-refresh.d.ts +77 -0
  291. package/dist/modules/mcp/downstream-token-refresh.d.ts.map +1 -0
  292. package/dist/modules/mcp/downstream-token-refresh.js +166 -0
  293. package/dist/modules/mcp/downstream-token-refresh.js.map +1 -0
  294. package/dist/modules/mcp/first-call-probe.cli-args.d.ts +78 -0
  295. package/dist/modules/mcp/first-call-probe.cli-args.d.ts.map +1 -0
  296. package/dist/modules/mcp/first-call-probe.cli-args.js +151 -0
  297. package/dist/modules/mcp/first-call-probe.cli-args.js.map +1 -0
  298. package/dist/modules/mcp/first-call-probe.cli.d.ts +2 -0
  299. package/dist/modules/mcp/first-call-probe.cli.d.ts.map +1 -0
  300. package/dist/modules/mcp/first-call-probe.cli.js +62 -0
  301. package/dist/modules/mcp/first-call-probe.cli.js.map +1 -0
  302. package/dist/modules/mcp/first-call-probe.d.ts +165 -0
  303. package/dist/modules/mcp/first-call-probe.d.ts.map +1 -0
  304. package/dist/modules/mcp/first-call-probe.js +298 -0
  305. package/dist/modules/mcp/first-call-probe.js.map +1 -0
  306. package/dist/modules/mcp/manual-failure-memo.d.ts +42 -17
  307. package/dist/modules/mcp/manual-failure-memo.d.ts.map +1 -1
  308. package/dist/modules/mcp/manual-failure-memo.js +79 -23
  309. package/dist/modules/mcp/manual-failure-memo.js.map +1 -1
  310. package/dist/modules/mcp/mcp-auth.middleware.d.ts +9 -5
  311. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  312. package/dist/modules/mcp/mcp-auth.middleware.js +24 -16
  313. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  314. package/dist/modules/mcp/mcp.routes.d.ts +4 -4
  315. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  316. package/dist/modules/mcp/mcp.routes.js +83 -179
  317. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  318. package/dist/modules/mcp/mcp.service.d.ts +218 -61
  319. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  320. package/dist/modules/mcp/mcp.service.js +713 -159
  321. package/dist/modules/mcp/mcp.service.js.map +1 -1
  322. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  323. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +8 -2
  324. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  325. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +14 -8
  326. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -1
  327. package/dist/modules/mcp/oauth/oauth-consent.routes.js +16 -2
  328. package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -1
  329. package/dist/modules/mcp/oauth/oauth-state.d.ts +13 -2
  330. package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -1
  331. package/dist/modules/mcp/oauth/oauth-state.js +3 -1
  332. package/dist/modules/mcp/oauth/oauth-state.js.map +1 -1
  333. package/dist/modules/mcp/surface-log-throttle.d.ts +59 -0
  334. package/dist/modules/mcp/surface-log-throttle.d.ts.map +1 -0
  335. package/dist/modules/mcp/surface-log-throttle.js +94 -0
  336. package/dist/modules/mcp/surface-log-throttle.js.map +1 -0
  337. package/dist/modules/plugins/compile/compile-marketplace.d.ts +104 -0
  338. package/dist/modules/plugins/compile/compile-marketplace.d.ts.map +1 -0
  339. package/dist/modules/plugins/compile/compile-marketplace.js +323 -0
  340. package/dist/modules/plugins/compile/compile-marketplace.js.map +1 -0
  341. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts +73 -0
  342. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts.map +1 -0
  343. package/dist/modules/plugins/compile/marketplace-compiler.service.js +135 -0
  344. package/dist/modules/plugins/compile/marketplace-compiler.service.js.map +1 -0
  345. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts +37 -0
  346. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts.map +1 -0
  347. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js +158 -0
  348. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js.map +1 -0
  349. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts +65 -0
  350. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts.map +1 -0
  351. package/dist/modules/plugins/discovery/bundle-dialect/registry.js +204 -0
  352. package/dist/modules/plugins/discovery/bundle-dialect/registry.js.map +1 -0
  353. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts +39 -0
  354. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts.map +1 -0
  355. package/dist/modules/plugins/discovery/kb-plugin-source.js +110 -0
  356. package/dist/modules/plugins/discovery/kb-plugin-source.js.map +1 -0
  357. package/dist/modules/plugins/discovery/native.source.d.ts +16 -0
  358. package/dist/modules/plugins/discovery/native.source.d.ts.map +1 -0
  359. package/dist/modules/plugins/discovery/native.source.js +120 -0
  360. package/dist/modules/plugins/discovery/native.source.js.map +1 -0
  361. package/dist/modules/plugins/discovery/plugin-source.d.ts +110 -0
  362. package/dist/modules/plugins/discovery/plugin-source.d.ts.map +1 -0
  363. package/dist/modules/plugins/discovery/plugin-source.js +2 -0
  364. package/dist/modules/plugins/discovery/plugin-source.js.map +1 -0
  365. package/dist/modules/plugins/index.d.ts +15 -3
  366. package/dist/modules/plugins/index.d.ts.map +1 -1
  367. package/dist/modules/plugins/index.js +13 -2
  368. package/dist/modules/plugins/index.js.map +1 -1
  369. package/dist/modules/plugins/join-request-jobs.service.d.ts +250 -0
  370. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -0
  371. package/dist/modules/plugins/join-request-jobs.service.js +602 -0
  372. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -0
  373. package/dist/modules/plugins/join-request-records.store.d.ts +161 -0
  374. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -0
  375. package/dist/modules/plugins/join-request-records.store.js +179 -0
  376. package/dist/modules/plugins/join-request-records.store.js.map +1 -0
  377. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  378. package/dist/modules/plugins/join-requests.service.js +4 -2
  379. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  380. package/dist/modules/plugins/plugin-links.d.ts +69 -0
  381. package/dist/modules/plugins/plugin-links.d.ts.map +1 -0
  382. package/dist/modules/plugins/plugin-links.js +163 -0
  383. package/dist/modules/plugins/plugin-links.js.map +1 -0
  384. package/dist/modules/plugins/plugin-links.service.d.ts +111 -0
  385. package/dist/modules/plugins/plugin-links.service.d.ts.map +1 -0
  386. package/dist/modules/plugins/plugin-links.service.js +295 -0
  387. package/dist/modules/plugins/plugin-links.service.js.map +1 -0
  388. package/dist/modules/plugins/plugin-provision.service.d.ts +129 -21
  389. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -1
  390. package/dist/modules/plugins/plugin-provision.service.js +320 -75
  391. package/dist/modules/plugins/plugin-provision.service.js.map +1 -1
  392. package/dist/modules/plugins/plugin-rename.service.d.ts +71 -0
  393. package/dist/modules/plugins/plugin-rename.service.d.ts.map +1 -0
  394. package/dist/modules/plugins/plugin-rename.service.js +254 -0
  395. package/dist/modules/plugins/plugin-rename.service.js.map +1 -0
  396. package/dist/modules/plugins/plugins.contract.d.ts +78 -4
  397. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -1
  398. package/dist/modules/plugins/plugins.routes.d.ts +44 -5
  399. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  400. package/dist/modules/plugins/plugins.routes.js +297 -115
  401. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  402. package/dist/modules/plugins/plugins.service.d.ts +82 -3
  403. package/dist/modules/plugins/plugins.service.d.ts.map +1 -1
  404. package/dist/modules/plugins/plugins.service.js +178 -48
  405. package/dist/modules/plugins/plugins.service.js.map +1 -1
  406. package/dist/modules/plugins/plugins.tools.d.ts +25 -0
  407. package/dist/modules/plugins/plugins.tools.d.ts.map +1 -0
  408. package/dist/modules/plugins/plugins.tools.js +82 -0
  409. package/dist/modules/plugins/plugins.tools.js.map +1 -0
  410. package/dist/modules/plugins/teams.routes.d.ts +42 -0
  411. package/dist/modules/plugins/teams.routes.d.ts.map +1 -0
  412. package/dist/modules/plugins/teams.routes.js +79 -0
  413. package/dist/modules/plugins/teams.routes.js.map +1 -0
  414. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +40 -1
  415. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  416. package/dist/modules/secrets-vault/db-secrets-vault.service.js +202 -37
  417. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  418. package/dist/modules/secrets-vault/index.d.ts +1 -1
  419. package/dist/modules/secrets-vault/index.d.ts.map +1 -1
  420. package/dist/modules/secrets-vault/index.js.map +1 -1
  421. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  422. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +5 -3
  423. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  424. package/dist/modules/secrets-vault/secrets-variable-loader.d.ts.map +1 -1
  425. package/dist/modules/secrets-vault/secrets-variable-loader.js +3 -1
  426. package/dist/modules/secrets-vault/secrets-variable-loader.js.map +1 -1
  427. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +78 -0
  428. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
  429. package/dist/modules/secrets-vault/secrets-vault.contract.js +40 -0
  430. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
  431. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  432. package/dist/modules/secrets-vault/secrets-vault.routes.js +106 -52
  433. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  434. package/dist/modules/settings/connection-check.d.ts +118 -0
  435. package/dist/modules/settings/connection-check.d.ts.map +1 -0
  436. package/dist/modules/settings/connection-check.js +287 -0
  437. package/dist/modules/settings/connection-check.js.map +1 -0
  438. package/dist/modules/settings/deployment-settings.service.d.ts +157 -2
  439. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  440. package/dist/modules/settings/deployment-settings.service.js +412 -35
  441. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  442. package/dist/modules/settings/git-root-folders.d.ts +54 -0
  443. package/dist/modules/settings/git-root-folders.d.ts.map +1 -0
  444. package/dist/modules/settings/git-root-folders.js +130 -0
  445. package/dist/modules/settings/git-root-folders.js.map +1 -0
  446. package/dist/modules/settings/oidc-check.d.ts +85 -0
  447. package/dist/modules/settings/oidc-check.d.ts.map +1 -0
  448. package/dist/modules/settings/oidc-check.js +172 -0
  449. package/dist/modules/settings/oidc-check.js.map +1 -0
  450. package/dist/modules/settings/setup.routes.d.ts +57 -1
  451. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  452. package/dist/modules/settings/setup.routes.js +408 -108
  453. package/dist/modules/settings/setup.routes.js.map +1 -1
  454. package/dist/modules/skills/allowed-tools-check.d.ts +132 -0
  455. package/dist/modules/skills/allowed-tools-check.d.ts.map +1 -0
  456. package/dist/modules/skills/allowed-tools-check.js +260 -0
  457. package/dist/modules/skills/allowed-tools-check.js.map +1 -0
  458. package/dist/modules/skills/index.d.ts +4 -1
  459. package/dist/modules/skills/index.d.ts.map +1 -1
  460. package/dist/modules/skills/index.js +2 -0
  461. package/dist/modules/skills/index.js.map +1 -1
  462. package/dist/modules/skills/pending-skills.service.d.ts +6 -11
  463. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -1
  464. package/dist/modules/skills/pending-skills.service.js +24 -71
  465. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  466. package/dist/modules/skills/skill-access-requests.routes.d.ts +38 -0
  467. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -0
  468. package/dist/modules/skills/skill-access-requests.routes.js +171 -0
  469. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -0
  470. package/dist/modules/skills/skills.contract.d.ts +67 -2
  471. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  472. package/dist/modules/skills/skills.routes.d.ts +35 -3
  473. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  474. package/dist/modules/skills/skills.routes.js +57 -4
  475. package/dist/modules/skills/skills.routes.js.map +1 -1
  476. package/dist/modules/skills/skills.service.d.ts +31 -3
  477. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  478. package/dist/modules/skills/skills.service.js +236 -65
  479. package/dist/modules/skills/skills.service.js.map +1 -1
  480. package/dist/modules/skills/skills.tools.d.ts +4 -1
  481. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  482. package/dist/modules/skills/skills.tools.js +41 -6
  483. package/dist/modules/skills/skills.tools.js.map +1 -1
  484. package/dist/modules/tool-auth/connection-key-rejection.d.ts +18 -0
  485. package/dist/modules/tool-auth/connection-key-rejection.d.ts.map +1 -0
  486. package/dist/modules/tool-auth/connection-key-rejection.js +20 -0
  487. package/dist/modules/tool-auth/connection-key-rejection.js.map +1 -0
  488. package/dist/modules/tool-auth/connection-keys-admin.routes.d.ts +14 -0
  489. package/dist/modules/tool-auth/connection-keys-admin.routes.d.ts.map +1 -0
  490. package/dist/modules/tool-auth/connection-keys-admin.routes.js +67 -0
  491. package/dist/modules/tool-auth/connection-keys-admin.routes.js.map +1 -0
  492. package/dist/modules/tool-auth/external-api-key.interface.d.ts +66 -1
  493. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  494. package/dist/modules/tool-auth/external-api-key.interface.js +2 -1
  495. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
  496. package/dist/modules/tool-auth/external-api-key.service.d.ts +23 -4
  497. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  498. package/dist/modules/tool-auth/external-api-key.service.js +79 -19
  499. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  500. package/dist/modules/tool-auth/internal-token.service.d.ts +5 -2
  501. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
  502. package/dist/modules/tool-auth/internal-token.service.js +5 -2
  503. package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
  504. package/dist/modules/tool-auth/key-or-session.middleware.d.ts +23 -0
  505. package/dist/modules/tool-auth/key-or-session.middleware.d.ts.map +1 -0
  506. package/dist/modules/tool-auth/key-or-session.middleware.js +39 -0
  507. package/dist/modules/tool-auth/key-or-session.middleware.js.map +1 -0
  508. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +7 -1
  509. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  510. package/dist/modules/tool-auth/tool-auth.middleware.js +8 -5
  511. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  512. package/dist/modules/tool-helpers/tool-context.d.ts +14 -0
  513. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  514. package/dist/modules/tool-helpers/tool-context.js +28 -6
  515. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  516. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  517. package/dist/modules/tool-helpers/tool-handler.js +22 -5
  518. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  519. package/dist/modules/tool-helpers/tool.contract.d.ts +6 -1
  520. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  521. package/dist/modules/tool-helpers/tool.contract.js +7 -1
  522. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  523. package/dist/modules/tool-manuals/index.d.ts +2 -1
  524. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  525. package/dist/modules/tool-manuals/index.js +1 -0
  526. package/dist/modules/tool-manuals/index.js.map +1 -1
  527. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +36 -0
  528. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  529. package/dist/modules/tool-manuals/mcp-json-discovery.js +136 -96
  530. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  531. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +10 -2
  532. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  533. package/dist/modules/tool-manuals/mcp-server-edit.service.js +12 -15
  534. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  535. package/dist/modules/tool-manuals/pending-tools.service.d.ts +50 -0
  536. package/dist/modules/tool-manuals/pending-tools.service.d.ts.map +1 -0
  537. package/dist/modules/tool-manuals/pending-tools.service.js +149 -0
  538. package/dist/modules/tool-manuals/pending-tools.service.js.map +1 -0
  539. package/dist/modules/tool-manuals/tool-delete.service.d.ts +135 -0
  540. package/dist/modules/tool-manuals/tool-delete.service.d.ts.map +1 -0
  541. package/dist/modules/tool-manuals/tool-delete.service.js +303 -0
  542. package/dist/modules/tool-manuals/tool-delete.service.js.map +1 -0
  543. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +161 -0
  544. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  545. package/dist/modules/tool-manuals/tool-manuals.contract.js +9 -0
  546. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
  547. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +19 -2
  548. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  549. package/dist/modules/tool-manuals/tool-manuals.routes.js +127 -57
  550. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  551. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +121 -2
  552. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  553. package/dist/modules/tool-manuals/tool-manuals.service.js +415 -92
  554. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  555. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  556. package/dist/modules/tool-manuals/tool-manuals.tools.js +78 -6
  557. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  558. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  559. package/dist/modules/workflow/agent-tools/workflow.tools.js +88 -54
  560. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  561. package/dist/modules/workflow/event-bus.d.ts +10 -4
  562. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  563. package/dist/modules/workflow/event-bus.js +14 -11
  564. package/dist/modules/workflow/event-bus.js.map +1 -1
  565. package/dist/modules/workflow/events.routes.d.ts +17 -2
  566. package/dist/modules/workflow/events.routes.d.ts.map +1 -1
  567. package/dist/modules/workflow/events.routes.js +31 -12
  568. package/dist/modules/workflow/events.routes.js.map +1 -1
  569. package/dist/modules/workflow/file-lock.service.d.ts +22 -4
  570. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  571. package/dist/modules/workflow/file-lock.service.js +27 -4
  572. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  573. package/dist/modules/workflow/git/change-request-link.d.ts +20 -0
  574. package/dist/modules/workflow/git/change-request-link.d.ts.map +1 -0
  575. package/dist/modules/workflow/git/change-request-link.js +34 -0
  576. package/dist/modules/workflow/git/change-request-link.js.map +1 -0
  577. package/dist/modules/workflow/git/git-version.d.ts.map +1 -1
  578. package/dist/modules/workflow/git/git-version.js +3 -1
  579. package/dist/modules/workflow/git/git-version.js.map +1 -1
  580. package/dist/modules/workflow/git/git.service.d.ts +303 -6
  581. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  582. package/dist/modules/workflow/git/git.service.js +753 -170
  583. package/dist/modules/workflow/git/git.service.js.map +1 -1
  584. package/dist/modules/workflow/git/node-git-runner.d.ts +36 -0
  585. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -0
  586. package/dist/modules/workflow/git/node-git-runner.js +273 -0
  587. package/dist/modules/workflow/git/node-git-runner.js.map +1 -0
  588. package/dist/modules/workflow/git/pull-request.service.d.ts +57 -2
  589. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  590. package/dist/modules/workflow/git/pull-request.service.js +165 -25
  591. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  592. package/dist/modules/workflow/pending-commits.service.d.ts +11 -0
  593. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  594. package/dist/modules/workflow/pending-commits.service.js +24 -4
  595. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  596. package/dist/modules/workflow/pending-commits.worker.d.ts +15 -8
  597. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  598. package/dist/modules/workflow/pending-commits.worker.js +107 -58
  599. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  600. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  601. package/dist/modules/workflow/recovery-bot.js +3 -1
  602. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  603. package/dist/modules/workflow/review-workflow/approval-lock.d.ts +60 -0
  604. package/dist/modules/workflow/review-workflow/approval-lock.d.ts.map +1 -0
  605. package/dist/modules/workflow/review-workflow/approval-lock.js +116 -0
  606. package/dist/modules/workflow/review-workflow/approval-lock.js.map +1 -0
  607. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts +54 -7
  608. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts.map +1 -1
  609. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +64 -0
  610. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  611. package/dist/modules/workflow/review-workflow/review-workflow.service.js +269 -90
  612. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  613. package/dist/modules/workflow/sanitize-error.d.ts +5 -2
  614. package/dist/modules/workflow/sanitize-error.d.ts.map +1 -1
  615. package/dist/modules/workflow/sanitize-error.js +8 -4
  616. package/dist/modules/workflow/sanitize-error.js.map +1 -1
  617. package/dist/modules/workflow/workflow.routes.d.ts +2 -0
  618. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  619. package/dist/modules/workflow/workflow.routes.js +259 -29
  620. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  621. package/dist/modules/workflow/workflow.service.d.ts +217 -14
  622. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  623. package/dist/modules/workflow/workflow.service.js +1142 -109
  624. package/dist/modules/workflow/workflow.service.js.map +1 -1
  625. package/dist/modules/workspace/empty-dirs.d.ts +15 -0
  626. package/dist/modules/workspace/empty-dirs.d.ts.map +1 -0
  627. package/dist/modules/workspace/empty-dirs.js +90 -0
  628. package/dist/modules/workspace/empty-dirs.js.map +1 -0
  629. package/dist/modules/workspace/file-readers/content-mode.d.ts +50 -0
  630. package/dist/modules/workspace/file-readers/content-mode.d.ts.map +1 -0
  631. package/dist/modules/workspace/file-readers/content-mode.js +55 -0
  632. package/dist/modules/workspace/file-readers/content-mode.js.map +1 -0
  633. package/dist/modules/workspace/file-readers/doc-extract.service.d.ts +4 -1
  634. package/dist/modules/workspace/file-readers/doc-extract.service.d.ts.map +1 -1
  635. package/dist/modules/workspace/file-readers/doc-extract.service.js +4 -1
  636. package/dist/modules/workspace/file-readers/doc-extract.service.js.map +1 -1
  637. package/dist/modules/workspace/file-readers/doc-extract.types.d.ts +5 -2
  638. package/dist/modules/workspace/file-readers/doc-extract.types.d.ts.map +1 -1
  639. package/dist/modules/workspace/file-readers/doc-extract.types.js +7 -4
  640. package/dist/modules/workspace/file-readers/doc-extract.types.js.map +1 -1
  641. package/dist/modules/workspace/file-readers/document-reader.d.ts +2 -0
  642. package/dist/modules/workspace/file-readers/document-reader.d.ts.map +1 -1
  643. package/dist/modules/workspace/file-readers/document-reader.js +5 -0
  644. package/dist/modules/workspace/file-readers/document-reader.js.map +1 -1
  645. package/dist/modules/workspace/file-readers/extract-docx.d.ts +6 -0
  646. package/dist/modules/workspace/file-readers/extract-docx.d.ts.map +1 -1
  647. package/dist/modules/workspace/file-readers/extract-docx.js +6 -0
  648. package/dist/modules/workspace/file-readers/extract-docx.js.map +1 -1
  649. package/dist/modules/workspace/file-readers/file-reader.d.ts +24 -5
  650. package/dist/modules/workspace/file-readers/file-reader.d.ts.map +1 -1
  651. package/dist/modules/workspace/file-readers/file-reader.js +11 -6
  652. package/dist/modules/workspace/file-readers/file-reader.js.map +1 -1
  653. package/dist/modules/workspace/file-readers/file-reader.registry.d.ts.map +1 -1
  654. package/dist/modules/workspace/file-readers/file-reader.registry.js +6 -1
  655. package/dist/modules/workspace/file-readers/file-reader.registry.js.map +1 -1
  656. package/dist/modules/workspace/file-readers/image-reader.d.ts +7 -5
  657. package/dist/modules/workspace/file-readers/image-reader.d.ts.map +1 -1
  658. package/dist/modules/workspace/file-readers/image-reader.js +9 -5
  659. package/dist/modules/workspace/file-readers/image-reader.js.map +1 -1
  660. package/dist/modules/workspace/file-readers/ooxml-text.d.ts +3 -0
  661. package/dist/modules/workspace/file-readers/ooxml-text.d.ts.map +1 -1
  662. package/dist/modules/workspace/file-readers/ooxml-text.js +39 -2
  663. package/dist/modules/workspace/file-readers/ooxml-text.js.map +1 -1
  664. package/dist/modules/workspace/file-readers/text-reader.d.ts +29 -1
  665. package/dist/modules/workspace/file-readers/text-reader.d.ts.map +1 -1
  666. package/dist/modules/workspace/file-readers/text-reader.js +57 -3
  667. package/dist/modules/workspace/file-readers/text-reader.js.map +1 -1
  668. package/dist/modules/workspace/git-internals.middleware.d.ts +28 -0
  669. package/dist/modules/workspace/git-internals.middleware.d.ts.map +1 -0
  670. package/dist/modules/workspace/git-internals.middleware.js +93 -0
  671. package/dist/modules/workspace/git-internals.middleware.js.map +1 -0
  672. package/dist/modules/workspace/not-found.d.ts +80 -0
  673. package/dist/modules/workspace/not-found.d.ts.map +1 -0
  674. package/dist/modules/workspace/not-found.js +112 -0
  675. package/dist/modules/workspace/not-found.js.map +1 -0
  676. package/dist/modules/workspace/session-ontology.gate.d.ts +1 -1
  677. package/dist/modules/workspace/session-ontology.gate.d.ts.map +1 -1
  678. package/dist/modules/workspace/session-ontology.gate.js +1 -1
  679. package/dist/modules/workspace/session-ontology.gate.js.map +1 -1
  680. package/dist/modules/workspace/startup/beside-checkout.d.ts +42 -0
  681. package/dist/modules/workspace/startup/beside-checkout.d.ts.map +1 -0
  682. package/dist/modules/workspace/startup/beside-checkout.js +110 -0
  683. package/dist/modules/workspace/startup/beside-checkout.js.map +1 -0
  684. package/dist/modules/workspace/startup/kb-git.d.ts +7 -12
  685. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  686. package/dist/modules/workspace/startup/kb-git.js +34 -35
  687. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  688. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +95 -0
  689. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  690. package/dist/modules/workspace/startup/kb-startup-runner.js +257 -55
  691. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  692. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +45 -0
  693. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -1
  694. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +417 -385
  695. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -1
  696. package/dist/modules/workspace/startup/steps/personal-spaces.step.d.ts +48 -0
  697. package/dist/modules/workspace/startup/steps/personal-spaces.step.d.ts.map +1 -0
  698. package/dist/modules/workspace/startup/steps/personal-spaces.step.js +183 -0
  699. package/dist/modules/workspace/startup/steps/personal-spaces.step.js.map +1 -0
  700. package/dist/modules/workspace/startup/steps/plugin-display-names.step.d.ts +51 -0
  701. package/dist/modules/workspace/startup/steps/plugin-display-names.step.d.ts.map +1 -0
  702. package/dist/modules/workspace/startup/steps/plugin-display-names.step.js +158 -0
  703. package/dist/modules/workspace/startup/steps/plugin-display-names.step.js.map +1 -0
  704. package/dist/modules/workspace/startup/steps/plugin-layout.d.ts +64 -0
  705. package/dist/modules/workspace/startup/steps/plugin-layout.d.ts.map +1 -0
  706. package/dist/modules/workspace/startup/steps/plugin-layout.js +97 -0
  707. package/dist/modules/workspace/startup/steps/plugin-layout.js.map +1 -0
  708. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts +41 -0
  709. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts.map +1 -0
  710. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js +85 -0
  711. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js.map +1 -0
  712. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +3 -1
  713. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -1
  714. package/dist/modules/workspace/startup/steps/roles-yaml.step.js +4 -12
  715. package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -1
  716. package/dist/modules/workspace/startup/steps/seed-tree.d.ts +6 -1
  717. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  718. package/dist/modules/workspace/startup/steps/seed-tree.js +174 -54
  719. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  720. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +96 -24
  721. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  722. package/dist/modules/workspace/startup/steps/template-files.step.js +521 -126
  723. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  724. package/dist/modules/workspace/startup/steps/template-source.d.ts +94 -0
  725. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -0
  726. package/dist/modules/workspace/startup/steps/template-source.js +160 -0
  727. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -0
  728. package/dist/modules/workspace/workspace.routes.d.ts +14 -2
  729. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  730. package/dist/modules/workspace/workspace.routes.js +657 -246
  731. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  732. package/dist/modules/workspace/workspace.service.d.ts +323 -12
  733. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  734. package/dist/modules/workspace/workspace.service.js +1006 -257
  735. package/dist/modules/workspace/workspace.service.js.map +1 -1
  736. package/dist/modules/workspace/workspace.tools.d.ts +42 -1
  737. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  738. package/dist/modules/workspace/workspace.tools.js +1590 -146
  739. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  740. package/dist/modules/workspace/write-denial.d.ts +76 -0
  741. package/dist/modules/workspace/write-denial.d.ts.map +1 -0
  742. package/dist/modules/workspace/write-denial.js +138 -0
  743. package/dist/modules/workspace/write-denial.js.map +1 -0
  744. package/dist/shared/canonical-file-identity.d.ts +64 -0
  745. package/dist/shared/canonical-file-identity.d.ts.map +1 -0
  746. package/dist/shared/canonical-file-identity.js +92 -0
  747. package/dist/shared/canonical-file-identity.js.map +1 -0
  748. package/dist/shared/domain-errors.d.ts +128 -0
  749. package/dist/shared/domain-errors.d.ts.map +1 -1
  750. package/dist/shared/domain-errors.js +166 -0
  751. package/dist/shared/domain-errors.js.map +1 -1
  752. package/dist/shared/email-identity.d.ts +32 -0
  753. package/dist/shared/email-identity.d.ts.map +1 -0
  754. package/dist/shared/email-identity.js +37 -0
  755. package/dist/shared/email-identity.js.map +1 -0
  756. package/dist/shared/fs-errors.d.ts +8 -0
  757. package/dist/shared/fs-errors.d.ts.map +1 -0
  758. package/dist/shared/fs-errors.js +11 -0
  759. package/dist/shared/fs-errors.js.map +1 -0
  760. package/dist/shared/fs-walk.d.ts +20 -6
  761. package/dist/shared/fs-walk.d.ts.map +1 -1
  762. package/dist/shared/fs-walk.js +35 -27
  763. package/dist/shared/fs-walk.js.map +1 -1
  764. package/dist/shared/fs.contract.d.ts +206 -0
  765. package/dist/shared/fs.contract.d.ts.map +1 -0
  766. package/dist/shared/fs.contract.js +79 -0
  767. package/dist/shared/fs.contract.js.map +1 -0
  768. package/dist/shared/git-failure.d.ts +80 -0
  769. package/dist/shared/git-failure.d.ts.map +1 -0
  770. package/dist/shared/git-failure.js +139 -0
  771. package/dist/shared/git-failure.js.map +1 -0
  772. package/dist/shared/git-internals.d.ts +17 -0
  773. package/dist/shared/git-internals.d.ts.map +1 -0
  774. package/dist/shared/git-internals.js +123 -0
  775. package/dist/shared/git-internals.js.map +1 -0
  776. package/dist/shared/git.contract.d.ts +157 -0
  777. package/dist/shared/git.contract.d.ts.map +1 -0
  778. package/dist/shared/git.contract.js +103 -0
  779. package/dist/shared/git.contract.js.map +1 -0
  780. package/dist/shared/http-errors.d.ts +33 -0
  781. package/dist/shared/http-errors.d.ts.map +1 -0
  782. package/dist/shared/http-errors.js +35 -0
  783. package/dist/shared/http-errors.js.map +1 -0
  784. package/dist/shared/kb-walk.d.ts +52 -0
  785. package/dist/shared/kb-walk.d.ts.map +1 -0
  786. package/dist/shared/kb-walk.js +76 -0
  787. package/dist/shared/kb-walk.js.map +1 -0
  788. package/dist/shared/logger.contract.d.ts +63 -0
  789. package/dist/shared/logger.contract.d.ts.map +1 -0
  790. package/dist/shared/logger.contract.js +126 -0
  791. package/dist/shared/logger.contract.js.map +1 -0
  792. package/dist/shared/logging.d.ts +31 -0
  793. package/dist/shared/logging.d.ts.map +1 -0
  794. package/dist/shared/logging.js +92 -0
  795. package/dist/shared/logging.js.map +1 -0
  796. package/dist/shared/path-containment.d.ts +21 -0
  797. package/dist/shared/path-containment.d.ts.map +1 -0
  798. package/dist/shared/path-containment.js +34 -0
  799. package/dist/shared/path-containment.js.map +1 -0
  800. package/dist/shared/path-order.d.ts +18 -0
  801. package/dist/shared/path-order.d.ts.map +1 -0
  802. package/dist/shared/path-order.js +35 -0
  803. package/dist/shared/path-order.js.map +1 -0
  804. package/dist/shared/pending-proposals.d.ts +71 -0
  805. package/dist/shared/pending-proposals.d.ts.map +1 -0
  806. package/dist/shared/pending-proposals.js +98 -0
  807. package/dist/shared/pending-proposals.js.map +1 -0
  808. package/dist/shared/printable.d.ts +22 -0
  809. package/dist/shared/printable.d.ts.map +1 -0
  810. package/dist/shared/printable.js +65 -0
  811. package/dist/shared/printable.js.map +1 -0
  812. package/dist/shared/redact-secret.d.ts +30 -0
  813. package/dist/shared/redact-secret.d.ts.map +1 -0
  814. package/dist/shared/redact-secret.js +114 -0
  815. package/dist/shared/redact-secret.js.map +1 -0
  816. package/dist/shared/rename-no-replace.d.ts +119 -0
  817. package/dist/shared/rename-no-replace.d.ts.map +1 -0
  818. package/dist/shared/rename-no-replace.js +323 -0
  819. package/dist/shared/rename-no-replace.js.map +1 -0
  820. package/dist/shared/ssrf.d.ts.map +1 -1
  821. package/dist/shared/ssrf.js +49 -18
  822. package/dist/shared/ssrf.js.map +1 -1
  823. package/dist/shared/ttl-cache.d.ts +28 -1
  824. package/dist/shared/ttl-cache.d.ts.map +1 -1
  825. package/dist/shared/ttl-cache.js +33 -1
  826. package/dist/shared/ttl-cache.js.map +1 -1
  827. package/dist/version.d.ts +10 -6
  828. package/dist/version.d.ts.map +1 -1
  829. package/dist/version.js +10 -6
  830. package/dist/version.js.map +1 -1
  831. package/kb-template/.bevelignore +4 -3
  832. package/kb-template/AGENTS.md +364 -54
  833. package/kb-template/KnowledgeBase/How to get started.md +1 -1
  834. package/kb-template/Skills/.gitkeep +0 -0
  835. package/kb-template/access.md +5 -3
  836. package/kb-template/mcp-description.md +27 -0
  837. package/migrations/0005_github_facade.sql +25 -0
  838. package/migrations/0006_api_tokens_revoked_by.sql +1 -0
  839. package/migrations/0007_github_facade_registered.sql +1 -0
  840. package/migrations/0008_change_request_apply_failure.sql +4 -0
  841. package/migrations/0009_change_request_apply_failure_kind.sql +13 -0
  842. package/migrations/0010_plugin_join_requests.sql +15 -0
  843. package/migrations/0011_plugin_join_request_claim.sql +11 -0
  844. package/migrations/meta/0005_snapshot.json +1717 -0
  845. package/migrations/meta/0006_snapshot.json +1723 -0
  846. package/migrations/meta/0007_snapshot.json +1729 -0
  847. package/migrations/meta/0008_snapshot.json +1753 -0
  848. package/migrations/meta/0009_snapshot.json +1759 -0
  849. package/migrations/meta/0010_snapshot.json +1872 -0
  850. package/migrations/meta/0011_snapshot.json +1884 -0
  851. package/migrations/meta/_journal.json +49 -0
  852. package/package.json +8 -7
  853. package/src/__tests__/core-config.domain.test.ts +67 -2
  854. package/src/__tests__/kb-layout-config.test.ts +295 -0
  855. package/src/__tests__/open-change-gate.ts +15 -0
  856. package/src/core/__tests__/catalog-cache-invalidation.test.ts +226 -0
  857. package/src/core/__tests__/catalog-revision.test.ts +190 -0
  858. package/src/core/__tests__/lifecycle.test.ts +369 -0
  859. package/src/core/__tests__/public-config.test.ts +30 -0
  860. package/src/core/__tests__/readiness.test.ts +109 -0
  861. package/src/core/catalog-cache-invalidation.ts +111 -0
  862. package/src/core/catalog-revision.ts +110 -0
  863. package/src/core/create-core-server.ts +358 -63
  864. package/src/core/create-core-services.ts +449 -107
  865. package/src/core/lifecycle.ts +312 -0
  866. package/src/core/public-config.ts +86 -0
  867. package/src/core/readiness.ts +123 -0
  868. package/src/core-config.ts +70 -4
  869. package/src/index.ts +15 -0
  870. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +362 -0
  871. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +2 -1
  872. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +46 -22
  873. package/src/modules/access/__tests__/access-control.deny-dependents.test.ts +215 -0
  874. package/src/modules/access/__tests__/access-control.download-read.test.ts +268 -0
  875. package/src/modules/access/__tests__/access-control.everyone-read.test.ts +145 -0
  876. package/src/modules/access/__tests__/access-control.group-read.test.ts +163 -0
  877. package/src/modules/access/__tests__/access-control.invalidate-race.test.ts +88 -0
  878. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +152 -0
  879. package/src/modules/access/__tests__/access-control.prospective.test.ts +189 -0
  880. package/src/modules/access/__tests__/access-control.read-batch.test.ts +2 -1
  881. package/src/modules/access/__tests__/access-control.service.test.ts +142 -81
  882. package/src/modules/access/__tests__/access-groups.test.ts +47 -14
  883. package/src/modules/access/__tests__/access-md-format.test.ts +2 -1
  884. package/src/modules/access/__tests__/access-mutation.service.test.ts +52 -4
  885. package/src/modules/access/__tests__/access-own-read-grant.test.ts +2 -0
  886. package/src/modules/access/__tests__/access-personal-plugin.test.ts +153 -0
  887. package/src/modules/access/__tests__/access-plugin-principals.test.ts +385 -0
  888. package/src/modules/access/__tests__/access-restrict-here.test.ts +450 -0
  889. package/src/modules/access/__tests__/access.routes.batch.test.ts +139 -0
  890. package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +394 -0
  891. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +6 -2
  892. package/src/modules/access/__tests__/access.routes.overrides.test.ts +3 -2
  893. package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +234 -0
  894. package/src/modules/access/__tests__/access.routes.prospective.test.ts +216 -0
  895. package/src/modules/access/__tests__/access.routes.revoke.test.ts +7 -2
  896. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +360 -0
  897. package/src/modules/access/__tests__/change-read-gate.test.ts +272 -0
  898. package/src/modules/access/__tests__/creator-access.test.ts +81 -83
  899. package/src/modules/access/__tests__/grant-sources.test.ts +3 -2
  900. package/src/modules/access/__tests__/groups-admin.service.test.ts +14 -5
  901. package/src/modules/access/__tests__/roles-admin.security.test.ts +196 -0
  902. package/src/modules/access/__tests__/roles-admin.service.test.ts +127 -2
  903. package/src/modules/access/__tests__/roles-capabilities.test.ts +5 -4
  904. package/src/modules/access/__tests__/roles.routes.test.ts +5 -2
  905. package/src/modules/access/__tests__/user-access-removal.service.test.ts +402 -0
  906. package/src/modules/access/__tests__/users-db-double.ts +63 -0
  907. package/src/modules/access/access-control.interface.ts +269 -18
  908. package/src/modules/access/access-control.service.ts +943 -226
  909. package/src/modules/access/access-mutation.service.ts +182 -13
  910. package/src/modules/access/access-view.ts +316 -0
  911. package/src/modules/access/access.routes.ts +385 -105
  912. package/src/modules/access/admin-locked-commit.ts +12 -14
  913. package/src/modules/access/admin-route-helpers.ts +4 -4
  914. package/src/modules/access/change-read-gate.ts +148 -0
  915. package/src/modules/access/creator-access.ts +89 -124
  916. package/src/modules/access/directory-sync-bot.ts +4 -1
  917. package/src/modules/access/groups-admin.service.ts +3 -3
  918. package/src/modules/access/reference-scan.ts +5 -5
  919. package/src/modules/access/roles-admin.service.ts +94 -15
  920. package/src/modules/access/synced-groups-committer.ts +10 -15
  921. package/src/modules/access/synced-groups-writer.ts +3 -12
  922. package/src/modules/access/user-access-removal.service.ts +711 -0
  923. package/src/modules/access-model/__tests__/access-grammar.test.ts +18 -0
  924. package/src/modules/access-model/__tests__/access-splice.test.ts +63 -1
  925. package/src/modules/access-model/__tests__/frontmatter-fences.test.ts +71 -0
  926. package/src/modules/access-model/__tests__/plugin-token-canonical.test.ts +40 -0
  927. package/src/modules/access-model/__tests__/roles-yaml-guard.test.ts +131 -0
  928. package/src/modules/access-model/access-errors.ts +56 -15
  929. package/src/modules/access-model/access-grammar.ts +198 -49
  930. package/src/modules/access-model/access-splice.ts +18 -18
  931. package/src/modules/access-model/access-template.ts +44 -0
  932. package/src/modules/access-model/change-gate.ts +80 -0
  933. package/src/modules/access-model/creator.ts +18 -21
  934. package/src/modules/access-model/frontmatter-lines.ts +98 -0
  935. package/src/modules/access-model/group-files.ts +4 -0
  936. package/src/modules/access-model/plugin-principals.ts +130 -0
  937. package/src/modules/access-model/roles-yaml-guard.ts +182 -4
  938. package/src/modules/admin/admin-access.service.ts +6 -2
  939. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +100 -0
  940. package/src/modules/agent-instructions/__tests__/compose.test.ts +256 -0
  941. package/src/modules/agent-instructions/__tests__/read-preamble.test.ts +135 -0
  942. package/src/modules/agent-instructions/agent-instructions.routes.ts +41 -0
  943. package/src/modules/agent-instructions/compose.ts +271 -0
  944. package/src/modules/agent-instructions/index.ts +14 -0
  945. package/src/modules/agent-instructions/read-preamble.ts +88 -0
  946. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +209 -0
  947. package/src/modules/auth/__tests__/account.routes.test.ts +178 -2
  948. package/src/modules/auth/__tests__/auth.middleware.test.ts +78 -0
  949. package/src/modules/auth/__tests__/auth.service.test.ts +338 -5
  950. package/src/modules/auth/__tests__/oidc-auth-provider.test.ts +352 -12
  951. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +3 -1
  952. package/src/modules/auth/account-erasure.service.ts +117 -10
  953. package/src/modules/auth/account.routes.ts +115 -14
  954. package/src/modules/auth/auth.routes.ts +20 -7
  955. package/src/modules/auth/auth.service.ts +187 -28
  956. package/src/modules/auth/oidc-auth-provider.ts +120 -22
  957. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +109 -1
  958. package/src/modules/code-mode/code-mode.tool.ts +13 -2
  959. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +78 -0
  960. package/src/modules/connection-probe/connection-probe.service.ts +59 -7
  961. package/src/modules/database/__tests__/advisory-lease.test.ts +152 -0
  962. package/src/modules/database/__tests__/advisory-lock.test.ts +147 -0
  963. package/src/modules/database/advisory-lock.ts +346 -0
  964. package/src/modules/database/connection.ts +9 -1
  965. package/src/modules/database/core-schema.ts +177 -0
  966. package/src/modules/database/migrate.ts +27 -6
  967. package/src/modules/declared-variables/declared-variables.routes.ts +6 -3
  968. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +3 -0
  969. package/src/modules/diff/__tests__/diff.service.test.ts +3 -0
  970. package/src/modules/diff/diff.routes.ts +2 -4
  971. package/src/modules/diff/diff.service.ts +75 -79
  972. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +472 -9
  973. package/src/modules/kb-fs/__tests__/node-fs.test.ts +409 -0
  974. package/src/modules/kb-fs/__tests__/repo-path.test.ts +210 -1
  975. package/src/modules/{workspace → kb-fs}/bevel-ignore.ts +22 -15
  976. package/src/modules/kb-fs/file-change-notifier.ts +4 -1
  977. package/src/modules/kb-fs/git-guarded-filesystem.ts +123 -0
  978. package/src/modules/kb-fs/locking-filesystem.ts +399 -109
  979. package/src/modules/kb-fs/node-fs.ts +165 -0
  980. package/src/modules/kb-fs/read-only-filesystem.ts +18 -9
  981. package/src/modules/kb-fs/repo-path.ts +213 -3
  982. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +297 -0
  983. package/src/modules/kb-sync/__tests__/kb-sync.service.test.ts +295 -0
  984. package/src/modules/kb-sync/__tests__/sync-auth.test.ts +118 -0
  985. package/src/modules/kb-sync/__tests__/sync-payload.test.ts +148 -0
  986. package/src/modules/kb-sync/index.ts +19 -0
  987. package/src/modules/kb-sync/kb-sync.interface.ts +79 -0
  988. package/src/modules/kb-sync/kb-sync.routes.ts +200 -0
  989. package/src/modules/kb-sync/kb-sync.service.ts +204 -0
  990. package/src/modules/kb-sync/sync-auth.ts +118 -0
  991. package/src/modules/kb-sync/sync-payload.ts +143 -0
  992. package/src/modules/marketplace/__tests__/github-facade.test.ts +575 -0
  993. package/src/modules/marketplace/__tests__/marketplace-git.test.ts +180 -0
  994. package/src/modules/marketplace/__tests__/marketplace-repo.head.test.ts +47 -0
  995. package/src/modules/marketplace/git-http.routes.ts +196 -0
  996. package/src/modules/marketplace/github-facade/github-facade-admin.routes.ts +110 -0
  997. package/src/modules/marketplace/github-facade/github-facade-codes.store.ts +145 -0
  998. package/src/modules/marketplace/github-facade/github-facade-credentials.service.ts +263 -0
  999. package/src/modules/marketplace/github-facade/github-facade.routes.ts +294 -0
  1000. package/src/modules/marketplace/github-facade/github-facade.service.ts +282 -0
  1001. package/src/modules/marketplace/github-facade/index.ts +26 -0
  1002. package/src/modules/marketplace/index.ts +3 -0
  1003. package/src/modules/marketplace/marketplace-repo.service.ts +352 -0
  1004. package/src/modules/mcp/__tests__/downstream-pool.test.ts +278 -0
  1005. package/src/modules/mcp/__tests__/downstream-token-refresh.test.ts +178 -0
  1006. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +156 -0
  1007. package/src/modules/mcp/__tests__/first-call-probe.cli-args.test.ts +178 -0
  1008. package/src/modules/mcp/__tests__/first-call-probe.test.ts +414 -0
  1009. package/src/modules/mcp/__tests__/manual-failure-memo.test.ts +98 -6
  1010. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +8 -5
  1011. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +1 -3
  1012. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +1055 -59
  1013. package/src/modules/mcp/__tests__/mcp.routes.stateless.test.ts +182 -0
  1014. package/src/modules/mcp/__tests__/mcp.service.test.ts +274 -6
  1015. package/src/modules/mcp/__tests__/surface-log-throttle.test.ts +169 -0
  1016. package/src/modules/mcp/downstream-pool.ts +256 -0
  1017. package/src/modules/mcp/downstream-token-refresh.ts +170 -0
  1018. package/src/modules/mcp/first-call-probe.cli-args.ts +159 -0
  1019. package/src/modules/mcp/first-call-probe.cli.ts +60 -0
  1020. package/src/modules/mcp/first-call-probe.ts +418 -0
  1021. package/src/modules/mcp/manual-failure-memo.ts +81 -23
  1022. package/src/modules/mcp/mcp-auth.middleware.ts +25 -16
  1023. package/src/modules/mcp/mcp.routes.ts +376 -482
  1024. package/src/modules/mcp/mcp.service.ts +846 -176
  1025. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +8 -2
  1026. package/src/modules/mcp/oauth/oauth-consent.routes.ts +28 -6
  1027. package/src/modules/mcp/oauth/oauth-state.ts +15 -3
  1028. package/src/modules/mcp/surface-log-throttle.ts +120 -0
  1029. package/src/modules/plugins/__tests__/bundle-dialect.e2e.test.ts +166 -0
  1030. package/src/modules/plugins/__tests__/bundle-dialect.test.ts +165 -0
  1031. package/src/modules/plugins/__tests__/compile-marketplace.test.ts +423 -0
  1032. package/src/modules/plugins/__tests__/fake-join-request-store.ts +189 -0
  1033. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +608 -0
  1034. package/src/modules/plugins/__tests__/plugin-creation-display-name.test.ts +241 -0
  1035. package/src/modules/plugins/__tests__/plugin-index.service.test.ts +201 -16
  1036. package/src/modules/plugins/__tests__/plugin-links.service.test.ts +284 -0
  1037. package/src/modules/plugins/__tests__/plugin-names.test.ts +77 -0
  1038. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +330 -20
  1039. package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +456 -0
  1040. package/src/modules/plugins/__tests__/plugins.routes.test.ts +441 -43
  1041. package/src/modules/plugins/__tests__/plugins.tools.test.ts +131 -0
  1042. package/src/modules/plugins/__tests__/teams.routes.test.ts +248 -0
  1043. package/src/modules/plugins/compile/compile-marketplace.ts +500 -0
  1044. package/src/modules/plugins/compile/marketplace-compiler.service.ts +151 -0
  1045. package/src/modules/plugins/discovery/__tests__/kb-plugin-source.test.ts +365 -0
  1046. package/src/modules/plugins/discovery/bundle-dialect/bundle.source.ts +161 -0
  1047. package/src/modules/plugins/discovery/bundle-dialect/registry.ts +250 -0
  1048. package/src/modules/plugins/discovery/kb-plugin-source.ts +117 -0
  1049. package/src/modules/plugins/discovery/native.source.ts +140 -0
  1050. package/src/modules/plugins/discovery/plugin-source.ts +113 -0
  1051. package/src/modules/plugins/index.ts +25 -2
  1052. package/src/modules/plugins/join-request-jobs.service.ts +675 -0
  1053. package/src/modules/plugins/join-request-records.store.ts +353 -0
  1054. package/src/modules/plugins/join-requests.service.ts +7 -4
  1055. package/src/modules/plugins/plugin-links.service.ts +350 -0
  1056. package/src/modules/plugins/plugin-links.ts +187 -0
  1057. package/src/modules/plugins/plugin-provision.service.ts +356 -78
  1058. package/src/modules/plugins/plugin-rename.service.ts +310 -0
  1059. package/src/modules/plugins/plugins.contract.ts +79 -4
  1060. package/src/modules/plugins/plugins.routes.ts +320 -123
  1061. package/src/modules/plugins/plugins.service.ts +184 -57
  1062. package/src/modules/plugins/plugins.tools.ts +88 -0
  1063. package/src/modules/plugins/teams.routes.ts +129 -0
  1064. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +359 -15
  1065. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +248 -0
  1066. package/src/modules/secrets-vault/__tests__/namespace-secrets.test.ts +202 -0
  1067. package/src/modules/secrets-vault/db-secrets-vault.service.ts +217 -35
  1068. package/src/modules/secrets-vault/index.ts +1 -0
  1069. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +6 -3
  1070. package/src/modules/secrets-vault/secrets-variable-loader.ts +4 -4
  1071. package/src/modules/secrets-vault/secrets-vault.contract.ts +93 -0
  1072. package/src/modules/secrets-vault/secrets-vault.routes.ts +99 -38
  1073. package/src/modules/settings/__tests__/connection-check.test.ts +233 -0
  1074. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +240 -1
  1075. package/src/modules/settings/__tests__/git-root-folders.test.ts +247 -0
  1076. package/src/modules/settings/__tests__/oidc-check.test.ts +185 -0
  1077. package/src/modules/settings/__tests__/setup.routes.boot-failure.test.ts +119 -0
  1078. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +170 -0
  1079. package/src/modules/settings/__tests__/setup.routes.oidc.test.ts +555 -0
  1080. package/src/modules/settings/__tests__/setup.routes.sync-status.test.ts +77 -0
  1081. package/src/modules/settings/__tests__/setup.routes.test.ts +667 -16
  1082. package/src/modules/settings/connection-check.ts +351 -0
  1083. package/src/modules/settings/deployment-settings.service.ts +478 -39
  1084. package/src/modules/settings/git-root-folders.ts +171 -0
  1085. package/src/modules/settings/oidc-check.ts +229 -0
  1086. package/src/modules/settings/setup.routes.ts +480 -110
  1087. package/src/modules/skills/__tests__/allowed-tools-check.test.ts +224 -0
  1088. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +210 -0
  1089. package/src/modules/skills/__tests__/pending-skills.service.test.ts +3 -3
  1090. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +128 -0
  1091. package/src/modules/skills/__tests__/skills.routes.warnings.test.ts +91 -0
  1092. package/src/modules/skills/__tests__/skills.service.test.ts +308 -2
  1093. package/src/modules/skills/allowed-tools-check.ts +352 -0
  1094. package/src/modules/skills/index.ts +5 -0
  1095. package/src/modules/skills/pending-skills.service.ts +26 -85
  1096. package/src/modules/skills/skill-access-requests.routes.ts +203 -0
  1097. package/src/modules/skills/skills.contract.ts +67 -3
  1098. package/src/modules/skills/skills.routes.ts +75 -4
  1099. package/src/modules/skills/skills.service.ts +258 -66
  1100. package/src/modules/skills/skills.tools.ts +43 -5
  1101. package/src/modules/tool-auth/__tests__/connection-key-rejection.test.ts +192 -0
  1102. package/src/modules/tool-auth/__tests__/connection-keys-admin.routes.test.ts +116 -0
  1103. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +124 -0
  1104. package/src/modules/tool-auth/connection-key-rejection.ts +25 -0
  1105. package/src/modules/tool-auth/connection-keys-admin.routes.ts +76 -0
  1106. package/src/modules/tool-auth/external-api-key.interface.ts +73 -1
  1107. package/src/modules/tool-auth/external-api-key.service.ts +95 -29
  1108. package/src/modules/tool-auth/internal-token.service.ts +5 -2
  1109. package/src/modules/tool-auth/key-or-session.middleware.ts +45 -0
  1110. package/src/modules/tool-auth/tool-auth.middleware.ts +16 -7
  1111. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +228 -0
  1112. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +33 -4
  1113. package/src/modules/tool-helpers/tool-context.ts +46 -7
  1114. package/src/modules/tool-helpers/tool-handler.ts +23 -5
  1115. package/src/modules/tool-helpers/tool.contract.ts +5 -0
  1116. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +24 -1
  1117. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -1
  1118. package/src/modules/tool-manuals/__tests__/pending-tools.service.test.ts +411 -0
  1119. package/src/modules/tool-manuals/__tests__/tool-delete.route.test.ts +133 -0
  1120. package/src/modules/tool-manuals/__tests__/tool-delete.service.test.ts +469 -0
  1121. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +21 -2
  1122. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +11 -4
  1123. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +6 -1
  1124. package/src/modules/tool-manuals/__tests__/tool-manuals.invalid.routes.test.ts +254 -0
  1125. package/src/modules/tool-manuals/__tests__/tool-manuals.invalid.test.ts +577 -0
  1126. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +6 -1
  1127. package/src/modules/tool-manuals/__tests__/tool-manuals.pending.route.test.ts +102 -0
  1128. package/src/modules/tool-manuals/__tests__/tool-manuals.restart.test.ts +285 -0
  1129. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +178 -2
  1130. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +114 -2
  1131. package/src/modules/tool-manuals/index.ts +3 -0
  1132. package/src/modules/tool-manuals/mcp-json-discovery.ts +164 -95
  1133. package/src/modules/tool-manuals/mcp-server-edit.service.ts +11 -14
  1134. package/src/modules/tool-manuals/pending-tools.service.ts +179 -0
  1135. package/src/modules/tool-manuals/tool-delete.service.ts +401 -0
  1136. package/src/modules/tool-manuals/tool-manuals.contract.ts +165 -0
  1137. package/src/modules/tool-manuals/tool-manuals.routes.ts +138 -54
  1138. package/src/modules/tool-manuals/tool-manuals.service.ts +440 -104
  1139. package/src/modules/tool-manuals/tool-manuals.tools.ts +81 -6
  1140. package/src/modules/workflow/__tests__/apply-failure.test.ts +1038 -0
  1141. package/src/modules/workflow/__tests__/event-bus.test.ts +61 -9
  1142. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +199 -0
  1143. package/src/modules/workflow/__tests__/file-lock.service.path-identity.test.ts +237 -0
  1144. package/src/modules/workflow/__tests__/format-affected-owners.test.ts +18 -0
  1145. package/src/modules/workflow/__tests__/merge-announces-tree.test.ts +220 -0
  1146. package/src/modules/workflow/__tests__/pending-commits.worker.concurrency.test.ts +187 -0
  1147. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +3 -1
  1148. package/src/modules/workflow/__tests__/sanitize-error.test.ts +10 -0
  1149. package/src/modules/workflow/__tests__/workflow.routes.folder-proposals.test.ts +128 -0
  1150. package/src/modules/workflow/__tests__/workflow.routes.fork-point-file.test.ts +149 -0
  1151. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +305 -0
  1152. package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +2 -0
  1153. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +3 -0
  1154. package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +2 -0
  1155. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +296 -27
  1156. package/src/modules/workflow/__tests__/workflow.service.folder-proposals.test.ts +564 -0
  1157. package/src/modules/workflow/__tests__/workflow.service.platform-restore.test.ts +135 -0
  1158. package/src/modules/workflow/__tests__/workflow.service.read-gate.test.ts +172 -0
  1159. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +133 -2
  1160. package/src/modules/workflow/__tests__/workflow.service.sync.test.ts +442 -0
  1161. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +281 -0
  1162. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +158 -7
  1163. package/src/modules/workflow/agent-tools/workflow.tools.ts +93 -64
  1164. package/src/modules/workflow/event-bus.ts +26 -28
  1165. package/src/modules/workflow/events.routes.ts +56 -21
  1166. package/src/modules/workflow/file-lock.service.ts +27 -4
  1167. package/src/modules/workflow/git/__tests__/change-request-link.test.ts +43 -0
  1168. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +1 -0
  1169. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +238 -1
  1170. package/src/modules/workflow/git/__tests__/git.service.forkPoint.test.ts +245 -0
  1171. package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +35 -0
  1172. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +149 -0
  1173. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +3 -3
  1174. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +47 -4
  1175. package/src/modules/workflow/git/__tests__/git.service.revertPathsAndPush.test.ts +177 -0
  1176. package/src/modules/workflow/git/__tests__/git.service.syncFromRemote.test.ts +253 -0
  1177. package/src/modules/workflow/git/__tests__/node-git-runner.test.ts +114 -0
  1178. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +69 -2
  1179. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +121 -0
  1180. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +79 -0
  1181. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  1182. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +1 -1
  1183. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-update.test.ts +111 -0
  1184. package/src/modules/workflow/git/change-request-link.ts +35 -0
  1185. package/src/modules/workflow/git/git-version.ts +5 -2
  1186. package/src/modules/workflow/git/git.service.ts +922 -199
  1187. package/src/modules/workflow/git/node-git-runner.ts +315 -0
  1188. package/src/modules/workflow/git/pull-request.service.ts +188 -27
  1189. package/src/modules/workflow/pending-commits.service.ts +26 -7
  1190. package/src/modules/workflow/pending-commits.worker.ts +114 -69
  1191. package/src/modules/workflow/recovery-bot.ts +4 -1
  1192. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +180 -1
  1193. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  1194. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +520 -0
  1195. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +84 -0
  1196. package/src/modules/workflow/review-workflow/__tests__/merge-gate.test.ts +89 -77
  1197. package/src/modules/workflow/review-workflow/approval-lock.ts +122 -0
  1198. package/src/modules/workflow/review-workflow/review-workflow.interface.ts +59 -7
  1199. package/src/modules/workflow/review-workflow/review-workflow.service.ts +309 -105
  1200. package/src/modules/workflow/sanitize-error.ts +9 -4
  1201. package/src/modules/workflow/workflow.routes.ts +273 -29
  1202. package/src/modules/workflow/workflow.service.ts +1341 -174
  1203. package/src/modules/workspace/__tests__/empty-dirs.test.ts +44 -0
  1204. package/src/modules/workspace/__tests__/file-stat-access.test.ts +301 -0
  1205. package/src/modules/workspace/__tests__/git-internals.security.test.ts +792 -0
  1206. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +464 -0
  1207. package/src/modules/workspace/__tests__/workspace.routes.branch-errors.test.ts +139 -0
  1208. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +189 -37
  1209. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +51 -11
  1210. package/src/modules/workspace/__tests__/workspace.routes.download-implies-read.test.ts +161 -0
  1211. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +109 -10
  1212. package/src/modules/workspace/__tests__/workspace.routes.file-writes.test.ts +624 -0
  1213. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +409 -0
  1214. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +327 -0
  1215. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +31 -13
  1216. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +109 -0
  1217. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +94 -0
  1218. package/src/modules/workspace/__tests__/workspace.service.missing-branch.test.ts +28 -0
  1219. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +218 -0
  1220. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +98 -13
  1221. package/src/modules/workspace/__tests__/workspace.service.test.ts +811 -42
  1222. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +197 -0
  1223. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +128 -0
  1224. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +212 -0
  1225. package/src/modules/workspace/__tests__/workspace.tools.test.ts +2632 -110
  1226. package/src/modules/workspace/__tests__/write-denial.test.ts +75 -0
  1227. package/src/modules/workspace/empty-dirs.ts +79 -0
  1228. package/src/modules/workspace/file-readers/README.md +32 -0
  1229. package/src/modules/workspace/file-readers/__tests__/doc-extract.test.ts +59 -2
  1230. package/src/modules/workspace/file-readers/__tests__/file-reader.registry.test.ts +133 -4
  1231. package/src/modules/workspace/file-readers/__tests__/fixtures/soft-break.docx +0 -0
  1232. package/src/modules/workspace/file-readers/__tests__/fixtures/tab.docx +0 -0
  1233. package/src/modules/workspace/file-readers/content-mode.ts +87 -0
  1234. package/src/modules/workspace/file-readers/doc-extract.service.ts +4 -1
  1235. package/src/modules/workspace/file-readers/doc-extract.types.ts +8 -4
  1236. package/src/modules/workspace/file-readers/document-reader.ts +6 -0
  1237. package/src/modules/workspace/file-readers/extract-docx.ts +6 -0
  1238. package/src/modules/workspace/file-readers/file-reader.registry.ts +9 -1
  1239. package/src/modules/workspace/file-readers/file-reader.ts +27 -6
  1240. package/src/modules/workspace/file-readers/image-reader.ts +10 -5
  1241. package/src/modules/workspace/file-readers/ooxml-text.ts +38 -2
  1242. package/src/modules/workspace/file-readers/text-reader.ts +61 -4
  1243. package/src/modules/workspace/git-internals.middleware.ts +86 -0
  1244. package/src/modules/workspace/not-found.ts +119 -0
  1245. package/src/modules/workspace/session-ontology.gate.ts +1 -1
  1246. package/src/modules/workspace/startup/__tests__/beside-checkout.test.ts +181 -0
  1247. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +361 -0
  1248. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +53 -16
  1249. package/src/modules/workspace/startup/beside-checkout.ts +112 -0
  1250. package/src/modules/workspace/startup/kb-git.ts +43 -36
  1251. package/src/modules/workspace/startup/kb-startup-runner.ts +303 -63
  1252. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +1405 -48
  1253. package/src/modules/workspace/startup/steps/groups-to-plugins.step.ts +460 -417
  1254. package/src/modules/workspace/startup/steps/personal-spaces.step.ts +191 -0
  1255. package/src/modules/workspace/startup/steps/plugin-display-names.step.ts +169 -0
  1256. package/src/modules/workspace/startup/steps/plugin-layout.ts +110 -0
  1257. package/src/modules/workspace/startup/steps/plugin-manifests.step.ts +90 -0
  1258. package/src/modules/workspace/startup/steps/roles-yaml.step.ts +6 -12
  1259. package/src/modules/workspace/startup/steps/seed-tree.ts +177 -54
  1260. package/src/modules/workspace/startup/steps/template-files.step.ts +581 -126
  1261. package/src/modules/workspace/startup/steps/template-source.ts +168 -0
  1262. package/src/modules/workspace/workspace.routes.ts +691 -269
  1263. package/src/modules/workspace/workspace.service.ts +1071 -292
  1264. package/src/modules/workspace/workspace.tools.ts +1787 -180
  1265. package/src/modules/workspace/write-denial.ts +189 -0
  1266. package/src/shared/__tests__/canonical-file-identity.test.ts +154 -0
  1267. package/src/shared/__tests__/email-identity.test.ts +35 -0
  1268. package/src/shared/__tests__/filename.test.ts +0 -0
  1269. package/src/shared/__tests__/frontmatter-id.test.ts +5 -0
  1270. package/src/shared/__tests__/git-failure.test.ts +198 -0
  1271. package/src/shared/__tests__/git-internals.test.ts +115 -0
  1272. package/src/shared/__tests__/http-errors.test.ts +42 -0
  1273. package/src/shared/__tests__/logging.test.ts +185 -0
  1274. package/src/shared/__tests__/path-containment.test.ts +60 -0
  1275. package/src/shared/__tests__/path-order.test.ts +24 -0
  1276. package/src/shared/__tests__/pending-proposals.readAt.test.ts +102 -0
  1277. package/src/shared/__tests__/printable.test.ts +64 -0
  1278. package/src/shared/__tests__/redact-secret.test.ts +122 -0
  1279. package/src/shared/__tests__/rename-no-replace.test.ts +466 -0
  1280. package/src/shared/__tests__/ssrf.test.ts +20 -0
  1281. package/src/shared/__tests__/ttl-cache.test.ts +55 -0
  1282. package/src/shared/canonical-file-identity.ts +92 -0
  1283. package/src/shared/domain-errors.ts +178 -0
  1284. package/src/shared/email-identity.ts +38 -0
  1285. package/src/shared/fs.contract.ts +225 -0
  1286. package/src/shared/git-failure.ts +196 -0
  1287. package/src/shared/git-internals.ts +124 -0
  1288. package/src/shared/git.contract.ts +170 -0
  1289. package/src/shared/http-errors.ts +36 -0
  1290. package/src/shared/logger.contract.ts +141 -0
  1291. package/src/shared/logging.ts +96 -0
  1292. package/src/shared/path-containment.ts +34 -0
  1293. package/src/shared/path-order.ts +33 -0
  1294. package/src/shared/pending-proposals.ts +161 -0
  1295. package/src/shared/printable.ts +68 -0
  1296. package/src/shared/redact-secret.ts +118 -0
  1297. package/src/shared/rename-no-replace.ts +386 -0
  1298. package/src/shared/ssrf.ts +44 -16
  1299. package/src/shared/ttl-cache.ts +33 -1
  1300. package/src/version.ts +10 -6
  1301. package/src/modules/diff/diff-paths.ts +0 -9
  1302. package/src/modules/mcp/__tests__/mcp-session-store.test.ts +0 -151
  1303. package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +0 -226
  1304. package/src/modules/mcp/mcp-session-store.ts +0 -162
  1305. package/src/shared/__tests__/fs-walk.test.ts +0 -42
  1306. package/src/shared/fs-walk.ts +0 -30
  1307. package/src/shared/hash-email.ts +0 -14
@@ -1,24 +1,32 @@
1
1
  import type { Server as HttpServer } from 'node:http';
2
2
  import { execFile } from 'node:child_process';
3
- import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
3
+ import { link, mkdir, mkdtemp, readdir as nodeReaddir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import AdmZip from 'adm-zip';
8
8
  import express from 'express';
9
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
10
  import { LocalFilesystem } from '@mastra/core/workspace';
11
11
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
12
12
  import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
13
13
  import type { ToolContext } from '../../tool-helpers/tool.contract.js';
14
14
  import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
15
- import { registerWorkspaceTools } from '../workspace.tools.js';
15
+ import { CONTENT_RULE, registerWorkspaceTools } from '../workspace.tools.js';
16
16
  import { RoutineWritePolicyService } from '../routine-write-policy.js';
17
+ import { UuidSessionSink, type ISessionSink } from '../session-sink.js';
17
18
  import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
18
19
  import { SpillStore } from '../spill-store.js';
19
20
  import { DocExtractService } from '../file-readers/doc-extract.service.js';
21
+ import { OCTET_STREAM_FALLBACK_NOTE } from '../file-readers/content-mode.js';
20
22
  import type { IAccessControl } from '../../access/access-control.interface.js';
23
+ import { isBranchAuthoredBy, isOwnSuggestionsBranch } from '@bevel-software/platform-shared';
21
24
  import { assertValidBranchName } from '../../kb-fs/branch-name.js';
25
+ import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
26
+ import { GIT_INTERNALS_MESSAGE, PathNotFoundError } from '../../../shared/domain-errors.js';
27
+ import { AccessDeniedError } from '../../access-model/access-errors.js';
28
+ import { PROPOSAL_ROUTE_NOTE, proposalTitleFor } from '../write-denial.js';
29
+ import { NOT_FOUND_NEXT_STEP } from '../not-found.js';
22
30
 
23
31
  const KB_DIR = 'knowledge-base';
24
32
 
@@ -27,6 +35,13 @@ const allowAll = {
27
35
  canRead: async () => true,
28
36
  canReadBatch: async (_w: string, _u: string, paths: string[]) =>
29
37
  new Map(paths.map((p) => [p, true])),
38
+ // The verbs `file_stat` and the move/delete preflight ask about. Allowing
39
+ // everything, and answering "no rules at HEAD" for the batch write check,
40
+ // keeps these tests about what they test rather than about access.
41
+ canWrite: async () => true,
42
+ canDownload: async () => true,
43
+ canOwner: async () => true,
44
+ canWriteBatchAtRef: async () => null,
30
45
  } as unknown as IAccessControl;
31
46
 
32
47
  /** Access control that denies `canRead` for an explicit set of repo-relative paths. */
@@ -58,6 +73,10 @@ let fs: LocalFilesystem;
58
73
  * named "undefined" is attempted.
59
74
  */
60
75
  let workspacePathCalls: string[] = [];
76
+ /** Every `(archive, destination)` pair `unzip` handed the service. */
77
+ let unzipCalls: [string, string | undefined][] = [];
78
+ /** Every folder turn a tool took, as `workspaceId:dir`. */
79
+ let folderTurns: string[] = [];
61
80
  /** The policy instance the tools were mounted with, so a test can restrict a session. */
62
81
  let writePolicy: RoutineWritePolicyService;
63
82
  /**
@@ -68,23 +87,89 @@ let writePolicy: RoutineWritePolicyService;
68
87
  let focusedBranch: string | undefined;
69
88
  /** The registry the tools were mounted into, so a test can inspect their defs. */
70
89
  let toolRegistry: ToolRegistry;
90
+ /**
91
+ * Another writer getting to a path in the window the real `LockingFilesystem`
92
+ * closes: the stand-ins below run it where that filesystem would be acquiring
93
+ * the lock — after the tool's preflight, before the under-lock `check`. Fires
94
+ * ONCE (it clears itself), so a test's later writes are ordinary ones.
95
+ */
96
+ let raceHook: (() => Promise<void>) | null = null;
71
97
 
72
98
  async function start(
73
99
  scope: 'read' | 'write' = 'write',
74
100
  access: IAccessControl = allowAll,
101
+ /** The signed-in caller's address — only the branch-naming tests vary it. */
102
+ userEmail = 'e@x',
75
103
  ): Promise<string> {
104
+ raceHook = null;
76
105
  tempDir = await mkdtemp(join(tmpdir(), 'ws-tools-'));
77
106
  docCacheDir = await mkdtemp(join(tmpdir(), 'ws-doc-cache-'));
78
107
  fs = new LocalFilesystem({ basePath: tempDir, contained: true });
108
+ // The harness speaks the paths the TOOLS speak. A fixture written here as
109
+ // `report.docx` must land where a tool asked for `report.docx` will read it:
110
+ // inside the checkout, through the one normaliser. Before the normaliser
111
+ // existed, this file's fixtures sat BESIDE the clone — the very bug the
112
+ // ticket is about — and every test would otherwise have to restate the
113
+ // prefix. Idempotent, so the tools' own already-prefixed paths pass through.
114
+ for (const method of ['readFile', 'writeFile', 'appendFile', 'deleteFile', 'mkdir', 'stat'] as const) {
115
+ const inner = (fs as unknown as Record<string, (...a: unknown[]) => unknown>)[method].bind(fs);
116
+ (fs as unknown as Record<string, unknown>)[method] = (path: string, ...rest: unknown[]) =>
117
+ inner(normalizeWorkspacePath(path, KB_DIR), ...rest);
118
+ }
119
+ // `LockingFilesystem.writeFiles` is what the real `delete_folder` and
120
+ // `write_files` land through — one lock cycle over every path, then one
121
+ // commit. A plain LocalFilesystem has no such method, so stand in for its
122
+ // DISK effect and let the tools be exercised end to end. This stand-in is
123
+ // NOT atomic, and these tests do not claim atomicity: they assert what the
124
+ // TOOL controls — that it hands the whole folder over in ONE batch. The
125
+ // batch's own all-or-none behaviour is asserted where it lives, in
126
+ // `locking-filesystem.test.ts` ("a DELETE batch whose later lock is
127
+ // contended deletes nothing").
128
+ // Both stand-ins honour the caller's under-lock `check` the way the real
129
+ // filesystem does — run it with the paths "locked" (here: just before any
130
+ // byte lands), let a refusal through untouched, and in the batch case write
131
+ // only what the check keeps. That is what makes the outcome the tools report
132
+ // a verdict about the state they actually wrote over.
133
+ const runRaceHook = async (): Promise<void> => {
134
+ const hook = raceHook;
135
+ raceHook = null;
136
+ if (hook) await hook();
137
+ };
138
+ const plainWriteFile = fs.writeFile.bind(fs);
139
+ (fs as unknown as Record<string, unknown>).writeFile = async (
140
+ path: string,
141
+ content: string,
142
+ options?: unknown,
143
+ check?: () => Promise<void>,
144
+ ) => {
145
+ await runRaceHook();
146
+ await check?.();
147
+ return plainWriteFile(path, content, options as never);
148
+ };
149
+ (fs as unknown as Record<string, unknown>).writeFiles = async (
150
+ writes: { path: string; content: string }[],
151
+ _summary: string,
152
+ deletes: string[] = [],
153
+ check?: (
154
+ pending: readonly { path: string; content: string }[],
155
+ ) => Promise<{ path: string; content: string }[]>,
156
+ ) => {
157
+ await runRaceHook();
158
+ const landing = check ? await check(writes) : writes;
159
+ for (const w of landing) await plainWriteFile(w.path, w.content);
160
+ for (const p of deletes) await fs.deleteFile(p);
161
+ };
79
162
  await fs.writeFile('a.md', 'hello\nworld\n');
80
163
  workspacePathCalls = [];
164
+ unzipCalls = [];
165
+ folderTurns = [];
81
166
  writePolicy = new RoutineWritePolicyService();
82
167
  focusedBranch = undefined;
83
168
 
84
169
  const registry = new ToolRegistry();
85
170
  toolRegistry = registry;
86
171
  const resolve = async (auth: ToolAuth, signal: AbortSignal, sessionId?: string): Promise<ToolContext> => ({
87
- user: { id: 'u', email: 'e@x', name: 'N' },
172
+ user: { id: 'u', email: userEmail, name: 'N' },
88
173
  scope: auth.scope,
89
174
  source: auth.source,
90
175
  sessionId,
@@ -101,6 +186,25 @@ async function start(
101
186
  workspacePathCalls.push(id);
102
187
  return tempDir;
103
188
  },
189
+ withFolderTurn: async <T>(id: string, dir: string, op: () => Promise<T>) => {
190
+ folderTurns.push(`${id}:${dir}`);
191
+ return op();
192
+ },
193
+ // Enough of `unzipFile` to exercise the TOOL: the one answer the tool
194
+ // shapes is absence, and the real service raises exactly this error for
195
+ // it (asserted in workspace.service.test.ts). Extraction itself lives
196
+ // there too — none of it is the tool's to decide.
197
+ unzipFile: async (_id: string, zipRel: string, destRel?: string) => {
198
+ // Recorded so a test can assert the PATHS the tool handed over — both
199
+ // ends normalised into the repository.
200
+ unzipCalls.push([zipRel, destRel]);
201
+ try {
202
+ await stat(join(tempDir, zipRel));
203
+ } catch {
204
+ throw new PathNotFoundError(zipRel);
205
+ }
206
+ return { destination: '', extracted: [], skipped: [] };
207
+ },
104
208
  } as never,
105
209
  workflowService: {} as never,
106
210
  events: {} as never,
@@ -169,38 +273,137 @@ async function isDeadOrZombie(pid: number): Promise<boolean> {
169
273
  describe('workspace file primitives', () => {
170
274
  it('read_file returns the content', async () => {
171
275
  const base = await start();
172
- expect(await (await post(`${base}/api/agent/tools/read_file`, { path: 'a.md' })).json()).toEqual({ path: 'a.md', content: 'hello\nworld\n' });
276
+ expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n' });
173
277
  });
174
278
 
175
279
  it('write_file then read_file round-trips', async () => {
176
280
  const base = await start();
177
- await post(`${base}/api/agent/tools/write_file`, { path: 'b.md', content: 'fresh' });
178
- expect(await (await post(`${base}/api/agent/tools/read_file`, { path: 'b.md' })).json()).toMatchObject({ content: 'fresh' });
281
+ await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/b.md`, content: 'fresh' });
282
+ expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/b.md` })).json()).toMatchObject({ content: 'fresh' });
179
283
  });
180
284
 
181
285
  it('edit_file replaces an exact unique string', async () => {
182
286
  const base = await start();
183
- const res = await post(`${base}/api/agent/tools/edit_file`, { path: 'a.md', old_string: 'world', new_string: 'earth' });
184
- expect(await res.json()).toMatchObject({ path: 'a.md', replaced: 1 });
185
- expect(await (await post(`${base}/api/agent/tools/read_file`, { path: 'a.md' })).json()).toMatchObject({ content: 'hello\nearth\n' });
287
+ const res = await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'world', new_string: 'earth' });
288
+ expect(await res.json()).toMatchObject({ path: `${KB_DIR}/a.md`, replaced: 1 });
289
+ expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).json()).toMatchObject({ content: 'hello\nearth\n' });
186
290
  });
187
291
 
188
292
  it('edit_file 400s when old_string is missing', async () => {
189
293
  const base = await start();
190
- expect((await post(`${base}/api/agent/tools/edit_file`, { path: 'a.md', old_string: 'nope', new_string: 'x' })).status).toBe(400);
294
+ expect((await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'nope', new_string: 'x' })).status).toBe(400);
191
295
  });
192
296
 
193
297
  it('list_files + file_stat', async () => {
194
298
  const base = await start();
195
- const list = (await (await post(`${base}/api/agent/tools/list_files`, {})).json()) as { entries: { name: string }[] };
299
+ const root = (await (await post(`${base}/api/agent/tools/list_files`, {})).json()) as { entries: { name: string }[] };
300
+ // The workspace root holds the checkout and nothing else now.
301
+ expect(root.entries.map((e) => e.name)).toEqual([KB_DIR]);
302
+ const list = (await (await post(`${base}/api/agent/tools/list_files`, { path: KB_DIR })).json()) as { entries: { name: string }[] };
196
303
  expect(list.entries.map((e) => e.name)).toContain('a.md');
197
- expect(await (await post(`${base}/api/agent/tools/file_stat`, { path: 'a.md' })).json()).toMatchObject({ type: 'file' });
304
+ expect(await (await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/a.md` })).json()).toMatchObject({ type: 'file' });
198
305
  });
199
306
 
200
307
  it('grep finds a match with line number', async () => {
201
308
  const base = await start();
202
309
  const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'wor' })).json()) as { matches: { path: string; line: number }[] };
203
- expect(res.matches).toContainEqual(expect.objectContaining({ path: 'a.md', line: 2 }));
310
+ expect(res.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/a.md`, line: 2 }));
311
+ });
312
+
313
+ // Copy path gives the root-anchored `/<kbDirName>/…`, and people paste
314
+ // that same text into an agent: a leading slash names the same path.
315
+ it('every path input accepts a leading slash as the same workspace path', async () => {
316
+ const base = await start();
317
+ expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `/${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n' });
318
+ expect(await (await post(`${base}/api/agent/tools/file_stat`, { path: `/${KB_DIR}/a.md` })).json()).toMatchObject({ type: 'file' });
319
+ await post(`${base}/api/agent/tools/write_file`, { path: `/${KB_DIR}/b.md`, content: 'fresh' });
320
+ await post(`${base}/api/agent/tools/write_file`, { path: `/${KB_DIR}/c.md`, content: 'batch' });
321
+ await post(`${base}/api/agent/tools/edit_file`, { path: `/${KB_DIR}/a.md`, old_string: 'world', new_string: 'earth' });
322
+ await post(`${base}/api/agent/tools/mkdir`, { path: `/${KB_DIR}/dir` });
323
+ await post(`${base}/api/agent/tools/copy_file`, { src: `/${KB_DIR}/b.md`, dest: `/${KB_DIR}/dir/b-copy.md` });
324
+ await post(`${base}/api/agent/tools/move_file`, { src: `/${KB_DIR}/c.md`, dest: `/${KB_DIR}/dir/c.md` });
325
+ expect(await readFile(join(tempDir, `${KB_DIR}/a.md`), 'utf8')).toBe('hello\nearth\n');
326
+ expect(await readFile(join(tempDir, `${KB_DIR}/b.md`), 'utf8')).toBe('fresh');
327
+ expect(await readFile(join(tempDir, KB_DIR, 'dir', 'b-copy.md'), 'utf8')).toBe('fresh');
328
+ expect(await readFile(join(tempDir, KB_DIR, 'dir', 'c.md'), 'utf8')).toBe('batch');
329
+ const list = (await (await post(`${base}/api/agent/tools/list_files`, { path: `/${KB_DIR}/dir` })).json()) as { path: string; entries: { name: string }[] };
330
+ expect(list.path).toBe(`${KB_DIR}/dir`);
331
+ expect(list.entries.map((e) => e.name).sort()).toEqual(['b-copy.md', 'c.md']);
332
+ const grep = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'earth', path: `/${KB_DIR}/a.md` })).json()) as { matches: { path: string }[] };
333
+ expect(grep.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/a.md` }));
334
+ await post(`${base}/api/agent/tools/delete_file`, { path: `/${KB_DIR}/b.md` });
335
+ await expect(readFile(join(tempDir, `${KB_DIR}/b.md`), 'utf8')).rejects.toThrow();
336
+ });
337
+
338
+ it('says so on the path inputs', async () => {
339
+ await start();
340
+ const tools = await toolRegistry.listInternal();
341
+ for (const name of ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'edit_file', 'delete_file', 'mkdir', 'unzip']) {
342
+ const def = tools.find((t) => t.name === name)!;
343
+ const body = (def.inputs as { properties: { body: { properties: Record<string, { description?: string }> } } }).properties.body;
344
+ expect(body.properties.path.description, name).toContain('with or without a leading slash');
345
+ }
346
+ for (const name of ['copy_file', 'move_file']) {
347
+ const def = tools.find((t) => t.name === name)!;
348
+ const body = (def.inputs as { properties: { body: { properties: Record<string, { description?: string }> } } }).properties.body;
349
+ expect(body.properties.src.description, name).toContain('with or without a leading slash');
350
+ expect(body.properties.dest.description, name).toContain('with or without a leading slash');
351
+ }
352
+ const batch = tools.find((t) => t.name === 'write_files')!;
353
+ const batchBody = (batch.inputs as { properties: { body: { properties: { files: { items: { properties: Record<string, { description?: string }> } } } } } }).properties.body;
354
+ expect(batchBody.properties.files.items.properties.path.description).toContain('with or without a leading slash');
355
+ });
356
+
357
+ // The routes meet this rule inside WorkspaceService; the tools write through
358
+ // the locking filesystem, which never enters it — so the rule has to hold on
359
+ // this surface on its own, or `write_file` could create the one folder no
360
+ // single-prefix path can name.
361
+ it('the checkout folder name is reserved at the repository root on the tool surface too', async () => {
362
+ const base = await start();
363
+ const reserved = `${KB_DIR}/${KB_DIR}`;
364
+ const refused = async (tool: string, body: Record<string, unknown>) => {
365
+ const res = await post(`${base}/api/agent/tools/${tool}`, body);
366
+ expect(res.status, tool).toBe(400);
367
+ expect(JSON.stringify(await res.json()), tool).toContain('is reserved');
368
+ };
369
+ await refused('write_file', { path: `${reserved}/x.md`, content: 'x' });
370
+ await refused('write_files', { files: [{ path: `${KB_DIR}/ok.md`, content: 'ok' }, { path: `${reserved}/y.md`, content: 'y' }] });
371
+ await refused('mkdir', { path: reserved });
372
+ await refused('copy_file', { src: `${KB_DIR}/a.md`, dest: `${reserved}/a.md` });
373
+ await refused('move_file', { src: `${KB_DIR}/a.md`, dest: `${reserved}/a.md` });
374
+ await refused('edit_file', { path: `${reserved}/x.md`, old_string: 'a', new_string: 'b' });
375
+ // `unzip` names its target under `destination`, the one key that is not
376
+ // `path`, `dest` or `files` — refused before any archive is looked at.
377
+ await refused('unzip', { path: `${KB_DIR}/archive.zip`, destination: reserved });
378
+ // Nothing landed — the batch's valid entry included, since the batch was
379
+ // refused as a whole before any write.
380
+ await expect(stat(join(tempDir, reserved))).rejects.toThrow();
381
+ await expect(stat(join(tempDir, KB_DIR, 'ok.md'))).rejects.toThrow();
382
+
383
+ // An existing reserved folder (an older build could have made one) can
384
+ // still be emptied and moved out of: the rule is about creating, not about
385
+ // trapping what is there.
386
+ await mkdir(join(tempDir, reserved), { recursive: true });
387
+ await writeFile(join(tempDir, reserved, 'old.md'), 'old');
388
+ await writeFile(join(tempDir, reserved, 'keep.md'), 'keep');
389
+ expect((await post(`${base}/api/agent/tools/move_file`, { src: `${reserved}/keep.md`, dest: `${KB_DIR}/keep.md` })).status).toBe(200);
390
+ expect((await post(`${base}/api/agent/tools/delete_file`, { path: `${reserved}/old.md` })).status).toBe(200);
391
+ expect(await readFile(join(tempDir, KB_DIR, 'keep.md'), 'utf8')).toBe('keep');
392
+ });
393
+
394
+ it('grep with no path searches the repository, never what sits beside the checkout', async () => {
395
+ const base = await start();
396
+ // A stray an older build left beside the checkout. The read gate has no
397
+ // rules for a path outside the repository and would call it readable, so
398
+ // the only safe root for a walk is the repository itself.
399
+ await writeFile(join(tempDir, 'stray.md'), 'needle in a stray\n');
400
+ await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/n.md`, content: 'needle in the repository\n' });
401
+ const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'needle' })).json()) as { matches: { path: string }[] };
402
+ expect(res.matches.map((m) => m.path)).toEqual([`${KB_DIR}/n.md`]);
403
+ // An explicit empty path is the same absence, not a spelling of the
404
+ // workspace directory.
405
+ const empty = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: '' })).json()) as { matches: { path: string }[] };
406
+ expect(empty.matches.map((m) => m.path)).toEqual([`${KB_DIR}/n.md`]);
204
407
  });
205
408
 
206
409
  it('execute_command runs in the workspace dir', async () => {
@@ -434,8 +637,260 @@ describe('workspace file primitives', () => {
434
637
 
435
638
  it('read scope refuses write tools (403) but allows reads', async () => {
436
639
  const base = await start('read');
437
- expect((await post(`${base}/api/agent/tools/write_file`, { path: 'c.md', content: 'x' })).status).toBe(403);
438
- expect((await post(`${base}/api/agent/tools/read_file`, { path: 'a.md' })).status).toBe(200);
640
+ expect((await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/c.md`, content: 'x' })).status).toBe(403);
641
+ expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).status).toBe(200);
642
+ });
643
+ });
644
+
645
+ /**
646
+ * `mode` on the two write tools. The default is `create`, so "write this
647
+ * content here" can no longer replace a page the agent never read: an
648
+ * existing path is refused, by name, with the one argument that would have
649
+ * made it a deliberate replacement. `write_files` answers for EVERY requested
650
+ * path, in the order it was given them, and a path it cannot write does not
651
+ * stop the ones it can.
652
+ */
653
+ describe('write modes and per-path outcomes', () => {
654
+ const writeFile = (base: string, body: Record<string, unknown>) => post(`${base}/api/agent/tools/write_file`, body);
655
+ const writeFiles = (base: string, body: Record<string, unknown>) => post(`${base}/api/agent/tools/write_files`, body);
656
+ const onDisk = (p: string) => readFile(join(tempDir, p), 'utf8');
657
+ interface BatchAnswer { count: number; files: { path: string; outcome: string; error?: string; message?: string }[] }
658
+
659
+ it('write_file with no mode creates a new file and says `created`', async () => {
660
+ const base = await start();
661
+ const res = await writeFile(base, { path: `${KB_DIR}/fresh.md`, content: 'new page' });
662
+ expect(res.status).toBe(200);
663
+ expect(await res.json()).toMatchObject({ path: `${KB_DIR}/fresh.md`, bytes: 8, outcome: 'created' });
664
+ expect(await onDisk(`${KB_DIR}/fresh.md`)).toBe('new page');
665
+ });
666
+
667
+ it('write_file with no mode refuses a path that exists, names it, says how to replace it, and leaves it alone', async () => {
668
+ const base = await start();
669
+ const res = await writeFile(base, { path: `${KB_DIR}/a.md`, content: 'clobbered' });
670
+ expect(res.status).toBe(409);
671
+ const body = (await res.json()) as { error: string; code: string; path: string };
672
+ expect(body.code).toBe('exists');
673
+ expect(body.path).toBe(`${KB_DIR}/a.md`);
674
+ expect(body.error).toContain(`${KB_DIR}/a.md`);
675
+ expect(body.error).toContain('pass mode: overwrite to replace it');
676
+ expect(await onDisk(`${KB_DIR}/a.md`)).toBe('hello\nworld\n');
677
+ });
678
+
679
+ it('write_file mode overwrite replaces and says `replaced`, and creates what is not there yet', async () => {
680
+ const base = await start();
681
+ const replaced = await writeFile(base, { path: `${KB_DIR}/a.md`, content: 'replacement' });
682
+ expect(replaced.status).toBe(409); // …without the mode.
683
+ const res = await writeFile(base, { path: `${KB_DIR}/a.md`, content: 'replacement', mode: 'overwrite' });
684
+ expect(res.status).toBe(200);
685
+ expect(await res.json()).toMatchObject({ path: `${KB_DIR}/a.md`, outcome: 'replaced' });
686
+ expect(await onDisk(`${KB_DIR}/a.md`)).toBe('replacement');
687
+ // `overwrite` on a path with nothing at it is still a create, and says so.
688
+ const created = await writeFile(base, { path: `${KB_DIR}/not-there.md`, content: 'x', mode: 'overwrite' });
689
+ expect(await created.json()).toMatchObject({ outcome: 'created' });
690
+ });
691
+
692
+ it('write_file mode update rewrites an existing file and refuses a missing one with `missing`', async () => {
693
+ const base = await start();
694
+ const updated = await writeFile(base, { path: `${KB_DIR}/a.md`, content: 'second draft', mode: 'update' });
695
+ expect(updated.status).toBe(200);
696
+ expect(await updated.json()).toMatchObject({ path: `${KB_DIR}/a.md`, outcome: 'updated' });
697
+ expect(await onDisk(`${KB_DIR}/a.md`)).toBe('second draft');
698
+
699
+ const missing = await writeFile(base, { path: `${KB_DIR}/nowhere.md`, content: 'x', mode: 'update' });
700
+ expect(missing.status).toBe(404);
701
+ const body = (await missing.json()) as { error: string; code: string; path: string };
702
+ expect(body.code).toBe('missing');
703
+ expect(body.path).toBe(`${KB_DIR}/nowhere.md`);
704
+ expect(body.error).toContain(`${KB_DIR}/nowhere.md`);
705
+ await expect(onDisk(`${KB_DIR}/nowhere.md`)).rejects.toThrow();
706
+ });
707
+
708
+ it('write_files answers for every requested path in input order, writes the rest, and counts only what landed', async () => {
709
+ const base = await start();
710
+ const res = await writeFiles(base, {
711
+ files: [
712
+ { path: `${KB_DIR}/one.md`, content: 'first' },
713
+ { path: `${KB_DIR}/a.md`, content: 'clobbered' }, // already there
714
+ { path: `${KB_DIR}/two.md`, content: 'second' },
715
+ ],
716
+ });
717
+ expect(res.status).toBe(200);
718
+ const body = (await res.json()) as BatchAnswer;
719
+ expect(body.count).toBe(2);
720
+ expect(body.files.map((f) => f.path)).toEqual([`${KB_DIR}/one.md`, `${KB_DIR}/a.md`, `${KB_DIR}/two.md`]);
721
+ expect(body.files.map((f) => f.outcome)).toEqual(['created', 'refused', 'created']);
722
+ expect(body.files[1].error).toBe('exists');
723
+ expect(body.files[1].message).toContain('pass mode: overwrite to replace it');
724
+ // The two it could write landed; the one it refused is untouched.
725
+ expect(await onDisk(`${KB_DIR}/one.md`)).toBe('first');
726
+ expect(await onDisk(`${KB_DIR}/two.md`)).toBe('second');
727
+ expect(await onDisk(`${KB_DIR}/a.md`)).toBe('hello\nworld\n');
728
+ });
729
+
730
+ it('write_files takes the same three modes', async () => {
731
+ const base = await start();
732
+ const overwritten = (await (await writeFiles(base, {
733
+ mode: 'overwrite',
734
+ files: [{ path: `${KB_DIR}/a.md`, content: 'replaced text' }, { path: `${KB_DIR}/brand-new.md`, content: 'new' }],
735
+ })).json()) as BatchAnswer;
736
+ expect(overwritten.count).toBe(2);
737
+ expect(overwritten.files.map((f) => f.outcome)).toEqual(['replaced', 'created']);
738
+ expect(await onDisk(`${KB_DIR}/a.md`)).toBe('replaced text');
739
+
740
+ const updated = (await (await writeFiles(base, {
741
+ mode: 'update',
742
+ files: [{ path: `${KB_DIR}/a.md`, content: 'again' }, { path: `${KB_DIR}/never-written.md`, content: 'x' }],
743
+ })).json()) as BatchAnswer;
744
+ expect(updated.count).toBe(1);
745
+ expect(updated.files.map((f) => f.outcome)).toEqual(['updated', 'refused']);
746
+ expect(updated.files[1].error).toBe('missing');
747
+ await expect(onDisk(`${KB_DIR}/never-written.md`)).rejects.toThrow();
748
+ });
749
+
750
+ it('write_files refuses a second create for a path an earlier entry in the SAME batch already claims', async () => {
751
+ const base = await start();
752
+ const body = (await (await writeFiles(base, {
753
+ files: [{ path: `${KB_DIR}/dup.md`, content: 'first' }, { path: `${KB_DIR}/dup.md`, content: 'second' }],
754
+ })).json()) as BatchAnswer;
755
+ expect(body.count).toBe(1);
756
+ expect(body.files.map((f) => f.outcome)).toEqual(['created', 'refused']);
757
+ expect(body.files[1].error).toBe('exists');
758
+ expect(await onDisk(`${KB_DIR}/dup.md`)).toBe('first');
759
+ });
760
+
761
+ it('an empty batch is still an answer with both fields', async () => {
762
+ const base = await start();
763
+ expect(await (await writeFiles(base, { files: [] })).json()).toEqual({ count: 0, files: [] });
764
+ });
765
+
766
+ it('a mode that is not one of the three is refused, not read as the nearest one', async () => {
767
+ const base = await start();
768
+ for (const res of [
769
+ await writeFile(base, { path: `${KB_DIR}/a.md`, content: 'x', mode: 'replace' }),
770
+ await writeFiles(base, { files: [{ path: `${KB_DIR}/a.md`, content: 'x' }], mode: 'replace' }),
771
+ ]) {
772
+ expect(res.status).toBe(400);
773
+ const body = (await res.json()) as { error: string; code: string };
774
+ expect(body.code).toBe('bad_mode');
775
+ expect(body.error).toContain('`create`, `overwrite`, `update`');
776
+ }
777
+ expect(await onDisk(`${KB_DIR}/a.md`)).toBe('hello\nworld\n');
778
+ });
779
+
780
+ /**
781
+ * The mode is a promise about the state the write lands on, so it has to be
782
+ * judged over a state no one else can move — i.e. with the path's lock held,
783
+ * not at a preflight anybody may invalidate before the bytes land. These
784
+ * four drive that window directly: `raceHook` is the other writer, running
785
+ * exactly where the real filesystem acquires the lock.
786
+ */
787
+ describe('the mode is judged over the state the write actually lands on', () => {
788
+ it('write_file create refuses a path another writer created after the preflight, and keeps their file', async () => {
789
+ const base = await start();
790
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/contested.md`, 'theirs\n'); };
791
+ const res = await writeFile(base, { path: `${KB_DIR}/contested.md`, content: 'mine' });
792
+ expect(res.status).toBe(409);
793
+ const body = (await res.json()) as { code: string; path: string; error: string };
794
+ expect(body.code).toBe('exists');
795
+ expect(body.path).toBe(`${KB_DIR}/contested.md`);
796
+ expect(body.error).toContain('pass mode: overwrite to replace it');
797
+ // The point of the whole feature: their bytes are still there.
798
+ expect(await onDisk(`${KB_DIR}/contested.md`)).toBe('theirs\n');
799
+ });
800
+
801
+ it('write_file update refuses a path another writer deleted after the preflight, and does not recreate it', async () => {
802
+ const base = await start();
803
+ raceHook = async () => { await fs.deleteFile(`${KB_DIR}/a.md`); };
804
+ const res = await writeFile(base, { path: `${KB_DIR}/a.md`, content: 'second draft', mode: 'update' });
805
+ expect(res.status).toBe(404);
806
+ expect(await res.json()).toMatchObject({ code: 'missing', path: `${KB_DIR}/a.md` });
807
+ await expect(onDisk(`${KB_DIR}/a.md`)).rejects.toThrow();
808
+ });
809
+
810
+ it('write_file overwrite reports `replaced`, not `created`, when the file appeared after the preflight', async () => {
811
+ const base = await start();
812
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/late.md`, 'theirs\n'); };
813
+ const res = await writeFile(base, { path: `${KB_DIR}/late.md`, content: 'mine', mode: 'overwrite' });
814
+ expect(res.status).toBe(200);
815
+ // The preflight saw nothing there and would have answered `created`.
816
+ expect(await res.json()).toMatchObject({ path: `${KB_DIR}/late.md`, outcome: 'replaced' });
817
+ expect(await onDisk(`${KB_DIR}/late.md`)).toBe('mine');
818
+ });
819
+
820
+ it('write_files drops only the path another writer took, lands the rest, and counts what landed', async () => {
821
+ const base = await start();
822
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/two.md`, 'theirs\n'); };
823
+ const body = (await (await writeFiles(base, {
824
+ files: [
825
+ { path: `${KB_DIR}/one.md`, content: 'first' },
826
+ { path: `${KB_DIR}/two.md`, content: 'second' },
827
+ { path: `${KB_DIR}/three.md`, content: 'third' },
828
+ ],
829
+ })).json()) as BatchAnswer;
830
+ expect(body.count).toBe(2);
831
+ expect(body.files.map((f) => f.path)).toEqual([`${KB_DIR}/one.md`, `${KB_DIR}/two.md`, `${KB_DIR}/three.md`]);
832
+ expect(body.files.map((f) => f.outcome)).toEqual(['created', 'refused', 'created']);
833
+ expect(body.files[1].error).toBe('exists');
834
+ expect(await onDisk(`${KB_DIR}/one.md`)).toBe('first');
835
+ expect(await onDisk(`${KB_DIR}/three.md`)).toBe('third');
836
+ expect(await onDisk(`${KB_DIR}/two.md`)).toBe('theirs\n');
837
+ });
838
+ });
839
+
840
+ it('both descriptions state the default and all three modes, and `mode` is an input on each', async () => {
841
+ await start();
842
+ const tools = await toolRegistry.listInternal();
843
+ for (const name of ['write_file', 'write_files']) {
844
+ const def = tools.find((t) => t.name === name)!;
845
+ expect(def.description, name).toContain('DEFAULTS TO `create`');
846
+ for (const mode of ['`create`', '`overwrite`', '`update`']) {
847
+ expect(def.description, `${name} ${mode}`).toContain(mode);
848
+ }
849
+ const body = (def.inputs as { properties: { body: { properties: Record<string, { enum?: string[]; description?: string }> } } }).properties.body;
850
+ expect(body.properties.mode, name).toBeDefined();
851
+ expect(body.properties.mode.enum, name).toEqual(['create', 'overwrite', 'update']);
852
+ expect(body.properties.mode.description, name).toContain('default `create`');
853
+ }
854
+ });
855
+
856
+ /**
857
+ * The audited-caller case, as a KB fixture: a skill whose step rewrites a
858
+ * ticket card it has just read (the shape every delivery skill uses to
859
+ * append to a ticket's log). It keeps working because the rewrite says
860
+ * `mode: overwrite` — and the same step WITHOUT the mode is refused, which
861
+ * is exactly the signal the audit was for.
862
+ */
863
+ it('a skill that rewrites a ticket card it just read still works, because it passes mode: overwrite', async () => {
864
+ const base = await start();
865
+ const card = `${KB_DIR}/Data/Tickets/Ship-It.md`;
866
+ await fs.mkdir(`${KB_DIR}/Data/Tickets`, { recursive: true });
867
+ await fs.writeFile(card, '# Ship It\n\n# Log\n- filed\n');
868
+
869
+ const read = (await (await post(`${base}/api/agent/tools/read_file`, { path: card })).json()) as { content: string };
870
+ const updatedCard = `${read.content}- coding done\n`;
871
+
872
+ // The step as the skill writes it today, with no mode: refused, card intact.
873
+ const blind = await writeFile(base, { path: card, content: updatedCard });
874
+ expect(blind.status).toBe(409);
875
+ expect(await onDisk(card)).toBe('# Ship It\n\n# Log\n- filed\n');
876
+
877
+ // The audited step, saying what it means: the rewrite lands.
878
+ const audited = await writeFile(base, { path: card, content: updatedCard, mode: 'overwrite' });
879
+ expect(audited.status).toBe(200);
880
+ expect(await audited.json()).toMatchObject({ path: card, outcome: 'replaced' });
881
+ expect(await onDisk(card)).toBe('# Ship It\n\n# Log\n- filed\n- coding done\n');
882
+
883
+ // …and the same step through the batch tool, which the skills use to land
884
+ // a card and its transcript in ONE commit.
885
+ const both = (await (await writeFiles(base, {
886
+ mode: 'overwrite',
887
+ files: [
888
+ { path: card, content: `${updatedCard}- local testing done\n` },
889
+ { path: `${KB_DIR}/Data/Tickets/Ship-It/transcripts/02-coding.md`, content: '# transcript\n' },
890
+ ],
891
+ })).json()) as BatchAnswer;
892
+ expect(both.count).toBe(2);
893
+ expect(both.files.map((f) => f.outcome)).toEqual(['replaced', 'created']);
439
894
  });
440
895
  });
441
896
 
@@ -474,10 +929,165 @@ describe('read-permission gating', () => {
474
929
  expect(paths).not.toContain(`${KB_DIR}/Knowledge/Secret.md`);
475
930
  });
476
931
 
477
- it('non-KB workspace files are never gated', async () => {
478
- // `a.md` lives at the workspace root, outside the KB dir → always readable.
479
- const base = await start('read', denyReads(new Set(['a.md'])));
480
- expect((await post(`${base}/api/agent/tools/read_file`, { path: 'a.md' })).status).toBe(200);
932
+ it('gates on the repository path, not on the spelling the caller sent', async () => {
933
+ // `a.md` is seeded through the normaliser like every path in this file, so
934
+ // it sits INSIDE the checkout, at `<tempDir>/knowledge-base/a.md`. Nothing
935
+ // is outside the KB dir any more. The gate keys on the repo-relative path,
936
+ // so a rule written on the workspace-relative spelling names no file…
937
+ const base = await start('read', denyReads(new Set([`${KB_DIR}/a.md`])));
938
+ expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).status).toBe(200);
939
+ // …and the same read, with the rule on the key the gate actually uses, is
940
+ // denied: what is checked is the path the read resolves, whichever of its
941
+ // spellings the caller sent.
942
+ const gated = await start('read', denyReads(new Set(['a.md'])));
943
+ for (const path of ['a.md', `${KB_DIR}/a.md`, `/${KB_DIR}/a.md`]) {
944
+ expect((await post(`${gated}/api/agent/tools/read_file`, { path })).status, path).toBe(403);
945
+ }
946
+ });
947
+ });
948
+
949
+ /**
950
+ * `grep` given a `path` that names a FILE. The walk begins with `readdir`,
951
+ * which fails on a file and on an absent path alike — so such a grep used to
952
+ * answer an empty match list, indistinguishable from "your pattern is not in
953
+ * there". Every case now answers honestly: the file's matches, a note when
954
+ * there was no text to search, or an error when there is nothing at the path.
955
+ */
956
+ describe('grep with a path that names a file', () => {
957
+ interface GrepResult {
958
+ matches: { path: string; line: number; text: string }[];
959
+ truncated: boolean;
960
+ note?: string;
961
+ }
962
+ const grep = async (base: string, body: Record<string, unknown>): Promise<GrepResult> =>
963
+ (await (await post(`${base}/api/agent/tools/grep`, body)).json()) as GrepResult;
964
+
965
+ it('searches exactly that file — the match carries that path and a 1-based line number', async () => {
966
+ const base = await start();
967
+ await fs.writeFile(`${KB_DIR}/notes/deep.md`, 'alpha\nbeta needle\ngamma\n');
968
+ // A sibling holding the same term: a file grep must not reach it.
969
+ await fs.writeFile(`${KB_DIR}/notes/other.md`, 'needle elsewhere\n');
970
+ const res = await grep(base, { pattern: 'needle', path: `${KB_DIR}/notes/deep.md` });
971
+ expect(res.matches).toEqual([{ path: `${KB_DIR}/notes/deep.md`, line: 2, text: 'beta needle' }]);
972
+ expect(res.truncated).toBe(false);
973
+ expect(res.note).toBeUndefined();
974
+ });
975
+
976
+ it('a file the pattern is simply not in is an empty SUCCESS — no note, no error', async () => {
977
+ const base = await start();
978
+ const res = await grep(base, { pattern: 'absent-term', path: `${KB_DIR}/a.md` });
979
+ expect(res.matches).toEqual([]);
980
+ expect(res.truncated).toBe(false);
981
+ expect(res.note).toBeUndefined();
982
+ });
983
+
984
+ it('caps a file grep at max_results and reports truncated, exactly as a directory grep does', async () => {
985
+ const base = await start();
986
+ await fs.writeFile(`${KB_DIR}/many.md`, 'needle\n'.repeat(5));
987
+ const res = await grep(base, { pattern: 'needle', path: `${KB_DIR}/many.md`, max_results: 2 });
988
+ expect(res.matches.map((m) => m.line)).toEqual([1, 2]);
989
+ expect(res.truncated).toBe(true);
990
+ });
991
+
992
+ it('a file with no searchable text returns empty matches plus a note saying so', async () => {
993
+ const base = await start();
994
+ // "needle" followed by a NUL byte: binary content, so nothing to search —
995
+ // the byte pattern is present but the file is not text.
996
+ await fs.writeFile(`${KB_DIR}/data.bin`, Buffer.from('needle\0tail', 'latin1'));
997
+ await fs.writeFile(
998
+ `${KB_DIR}/logo.png`,
999
+ Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'),
1000
+ );
1001
+ for (const path of [`${KB_DIR}/data.bin`, `${KB_DIR}/logo.png`]) {
1002
+ const res = await grep(base, { pattern: 'needle', path });
1003
+ expect(res.matches, path).toEqual([]);
1004
+ expect(res.note, path).toContain('no searchable text');
1005
+ expect(res.note, path).toContain(path);
1006
+ }
1007
+ });
1008
+
1009
+ it('a path with nothing at it fails with an error naming the path — never an empty success', async () => {
1010
+ const base = await start();
1011
+ const res = await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: `${KB_DIR}/notes/ghost.md` });
1012
+ expect(res.status).toBe(404);
1013
+ const { error } = (await res.json()) as { error: string };
1014
+ expect(error).toContain(`${KB_DIR}/notes/ghost.md`);
1015
+ });
1016
+
1017
+ it('a FILE the caller may not read answers exactly as read_file does — same status, same body', async () => {
1018
+ const secret = `${KB_DIR}/Knowledge/Secret.md`;
1019
+ // Denied AND absent: the two tools must agree here too, or grep's error
1020
+ // would reveal an existence read_file refuses to confirm.
1021
+ const ghost = `${KB_DIR}/Knowledge/Ghost.md`;
1022
+ const base = await start('write', denyReads(new Set(['Knowledge/Secret.md', 'Knowledge/Ghost.md'])));
1023
+ await fs.writeFile(secret, 'secret needle\n');
1024
+ for (const path of [secret, ghost]) {
1025
+ const [readRes, grepRes] = await Promise.all([
1026
+ post(`${base}/api/agent/tools/read_file`, { path }),
1027
+ post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path }),
1028
+ ]);
1029
+ expect(grepRes.status, path).toBe(403);
1030
+ expect(grepRes.status, path).toBe(readRes.status);
1031
+ expect(await grepRes.json(), path).toEqual(await readRes.json());
1032
+ }
1033
+ });
1034
+
1035
+ it('a path UNDER an existing file is nothing-there too — the same 404, not a raw failure', async () => {
1036
+ const base = await start();
1037
+ await fs.writeFile(`${KB_DIR}/notes/deep.md`, 'alpha\n');
1038
+ // `notes/deep.md` is a FILE, so the filesystem answers ENOTDIR rather than
1039
+ // ENOENT. Nothing can live at this path either, so it earns the same
1040
+ // honest 404 as a plainly absent one.
1041
+ const res = await post(`${base}/api/agent/tools/grep`, {
1042
+ pattern: 'needle',
1043
+ path: `${KB_DIR}/notes/deep.md/deeper.md`,
1044
+ });
1045
+ expect(res.status).toBe(404);
1046
+ const { error } = (await res.json()) as { error: string };
1047
+ expect(error).toContain(`${KB_DIR}/notes/deep.md/deeper.md`);
1048
+ });
1049
+
1050
+ it("a denied path the filesystem cannot even stat still answers with read_file's 403", async () => {
1051
+ const loop = `${KB_DIR}/Knowledge/Loop.md`;
1052
+ const base = await start('write', denyReads(new Set(['Knowledge/Loop.md'])));
1053
+ await mkdir(join(tempDir, KB_DIR, 'Knowledge'), { recursive: true });
1054
+ // A symlink pointing at ITSELF: stat fails with ELOOP — neither absence
1055
+ // nor a readable file. The permission verdict must still come FIRST, or
1056
+ // grep would leak a filesystem complaint where read_file says only 403.
1057
+ await symlink('Loop.md', join(tempDir, loop));
1058
+ const [readRes, grepRes] = await Promise.all([
1059
+ post(`${base}/api/agent/tools/read_file`, { path: loop }),
1060
+ post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: loop }),
1061
+ ]);
1062
+ expect(grepRes.status).toBe(403);
1063
+ expect(grepRes.status).toBe(readRes.status);
1064
+ expect(await grepRes.json()).toEqual(await readRes.json());
1065
+ });
1066
+
1067
+ it('a READABLE path the filesystem cannot resolve fails exactly as read_file fails', async () => {
1068
+ const loop = `${KB_DIR}/Knowledge/Tangle.md`;
1069
+ const base = await start();
1070
+ await mkdir(join(tempDir, KB_DIR, 'Knowledge'), { recursive: true });
1071
+ await symlink('Tangle.md', join(tempDir, loop));
1072
+ const [readRes, grepRes] = await Promise.all([
1073
+ post(`${base}/api/agent/tools/read_file`, { path: loop }),
1074
+ post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: loop }),
1075
+ ]);
1076
+ // The gate allows it, so the READ is what answers — and it is the same
1077
+ // `fs.readFile` read_file calls. grep must not dress that up as absence
1078
+ // (a false 404), nor invent a failure of its own: one path, one story.
1079
+ expect(grepRes.status).toBe(readRes.status);
1080
+ expect(await grepRes.json()).toEqual(await readRes.json());
1081
+ });
1082
+
1083
+ it('a DIRECTORY path still walks the whole subtree (unchanged)', async () => {
1084
+ const base = await start();
1085
+ await fs.writeFile(`${KB_DIR}/notes/one.md`, 'needle here\n');
1086
+ await fs.writeFile(`${KB_DIR}/notes/sub/two.md`, 'and needle there\n');
1087
+ await fs.writeFile(`${KB_DIR}/outside.md`, 'needle outside the subtree\n');
1088
+ const res = await grep(base, { pattern: 'needle', path: 'notes' });
1089
+ expect(res.matches.map((m) => m.path).sort()).toEqual([`${KB_DIR}/notes/one.md`, `${KB_DIR}/notes/sub/two.md`]);
1090
+ expect(res.note).toBeUndefined();
481
1091
  });
482
1092
  });
483
1093
 
@@ -586,45 +1196,77 @@ describe('office documents and PDFs', () => {
586
1196
 
587
1197
  it('read_file returns marker + extracted text for a docx', async () => {
588
1198
  const base = await start();
589
- await fs.writeFile('report.docx', docx('Hello from Word', 'Second paragraph'));
590
- const content = await readContent(base, 'report.docx');
1199
+ await fs.writeFile(`${KB_DIR}/report.docx`, docx('Hello from Word', 'Second paragraph'));
1200
+ const content = await readContent(base, `${KB_DIR}/report.docx`);
591
1201
  const lines = content.split('\n');
592
- expect(lines[0]).toMatch(/^\[extracted text of report\.docx — 2 paragraphs;/);
1202
+ expect(lines[0]).toMatch(new RegExp(`^\\[extracted text of ${KB_DIR}/report\\.docx — 2 paragraphs;`));
593
1203
  expect(lines[0]).toContain('layout, images and formatting omitted');
594
1204
  expect(lines.slice(1)).toEqual(['Hello from Word', 'Second paragraph']);
595
1205
  });
596
1206
 
597
1207
  it('read_file slices offset/limit AFTER assembling marker + text (unchanged semantics)', async () => {
598
1208
  const base = await start();
599
- await fs.writeFile('report.docx', docx('Sliceable content here'));
600
- const full = await readContent(base, 'report.docx');
601
- const sliced = await readContent(base, 'report.docx', { offset: 5, limit: 12 });
1209
+ await fs.writeFile(`${KB_DIR}/report.docx`, docx('Sliceable content here'));
1210
+ const full = await readContent(base, `${KB_DIR}/report.docx`);
1211
+ const sliced = await readContent(base, `${KB_DIR}/report.docx`, { offset: 5, limit: 12 });
602
1212
  expect(sliced).toBe(full.slice(5, 17));
603
1213
  });
604
1214
 
605
1215
  it('read_file returns [page N] text for a PDF', async () => {
606
1216
  const base = await start();
607
- await fs.writeFile('paper.pdf', pdf('Findings inside a PDF'));
608
- const content = await readContent(base, 'paper.pdf');
609
- expect(content).toMatch(/^\[extracted text of paper\.pdf — 1 page;/);
1217
+ await fs.writeFile(`${KB_DIR}/paper.pdf`, pdf('Findings inside a PDF'));
1218
+ const content = await readContent(base, `${KB_DIR}/paper.pdf`);
1219
+ expect(content).toMatch(new RegExp(`^\\[extracted text of ${KB_DIR}/paper\\.pdf — 1 page;`));
610
1220
  expect(content).toContain('[page 1]\nFindings inside a PDF');
611
1221
  });
612
1222
 
613
1223
  it('grep finds a term inside a pptx, with the [slide N] marker line locating it', async () => {
614
1224
  const base = await start();
615
- await fs.writeFile('deck.pptx', pptx([['Intro'], ['Roadmap 2026', 'Ship documents']]));
1225
+ await fs.writeFile(`${KB_DIR}/deck.pptx`, pptx([['Intro'], ['Roadmap 2026', 'Ship documents']]));
616
1226
  const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'Roadmap' })).json()) as {
617
1227
  matches: { path: string; line: number; text: string }[];
618
1228
  note?: string;
619
1229
  };
620
- expect(res.matches).toContainEqual(expect.objectContaining({ path: 'deck.pptx', text: 'Roadmap 2026' }));
1230
+ expect(res.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/deck.pptx`, text: 'Roadmap 2026' }));
621
1231
  // The extraction reads: marker line 1, [slide 1] line 2, Intro line 3,
622
1232
  // [slide 2] line 4, Roadmap line 5 — grep reports the extraction's numbers.
623
1233
  expect(res.matches.find((m) => m.text === 'Roadmap 2026')?.line).toBe(5);
624
1234
  // The structure markers themselves are searchable.
625
1235
  const markers = (await (await post(`${base}/api/agent/tools/grep`, { pattern: '\\[slide 2\\]' })).json()) as { matches: { path: string }[] };
626
- expect(markers.matches).toContainEqual(expect.objectContaining({ path: 'deck.pptx' }));
1236
+ expect(markers.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/deck.pptx` }));
1237
+ expect(res.note).toBeUndefined();
1238
+ });
1239
+
1240
+ it('grep on a path naming a DOCUMENT searches its extraction the way the walk does — markers included', async () => {
1241
+ const base = await start();
1242
+ await fs.writeFile(`${KB_DIR}/deck.pptx`, pptx([['Intro'], ['Roadmap 2026']]));
1243
+ // A second deck carrying the same term: a file grep must not reach it.
1244
+ await fs.writeFile(`${KB_DIR}/decoy.pptx`, pptx([['Roadmap 2026 decoy deck']]));
1245
+ const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'Roadmap', path: `${KB_DIR}/deck.pptx` })).json()) as {
1246
+ matches: { path: string; line: number; text: string }[];
1247
+ note?: string;
1248
+ };
1249
+ // Same line arithmetic as the directory grep: marker 1, [slide 1] 2,
1250
+ // Intro 3, [slide 2] 4, Roadmap 5.
1251
+ expect(res.matches).toEqual([{ path: `${KB_DIR}/deck.pptx`, line: 5, text: 'Roadmap 2026' }]);
627
1252
  expect(res.note).toBeUndefined();
1253
+ // The structure markers are searchable on the single-file path too.
1254
+ const markers = (await (await post(`${base}/api/agent/tools/grep`, { pattern: '\\[slide 2\\]', path: `${KB_DIR}/deck.pptx` })).json()) as {
1255
+ matches: { path: string; line: number }[];
1256
+ };
1257
+ expect(markers.matches).toEqual([expect.objectContaining({ path: `${KB_DIR}/deck.pptx`, line: 4 })]);
1258
+ });
1259
+
1260
+ it('grep on a path naming a CORRUPT document notes it has no searchable text', async () => {
1261
+ const base = await start();
1262
+ await fs.writeFile(`${KB_DIR}/broken.docx`, Buffer.from('not really a zip'));
1263
+ const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'zip', path: `${KB_DIR}/broken.docx` })).json()) as {
1264
+ matches: unknown[];
1265
+ note?: string;
1266
+ };
1267
+ expect(res.matches).toEqual([]);
1268
+ expect(res.note).toContain('no searchable text');
1269
+ expect(res.note).toContain(`${KB_DIR}/broken.docx`);
628
1270
  });
629
1271
 
630
1272
  it('grep extracts at most 20 uncached documents per call and notes the skipped rest; a re-run covers them', async () => {
@@ -651,41 +1293,41 @@ describe('office documents and PDFs', () => {
651
1293
 
652
1294
  it('read_file returns marker + extracted text for the three OpenDocument formats', async () => {
653
1295
  const base = await start();
654
- await fs.writeFile('memo.odt', odt('Hello from Writer', 'Second paragraph'));
655
- const odtContent = await readContent(base, 'memo.odt');
1296
+ await fs.writeFile(`${KB_DIR}/memo.odt`, odt('Hello from Writer', 'Second paragraph'));
1297
+ const odtContent = await readContent(base, `${KB_DIR}/memo.odt`);
656
1298
  expect(odtContent.split('\n')).toEqual([
657
- '[extracted text of memo.odt — 2 paragraphs; layout, images and formatting omitted]',
1299
+ `[extracted text of ${KB_DIR}/memo.odt — 2 paragraphs; layout, images and formatting omitted]`,
658
1300
  'Hello from Writer',
659
1301
  'Second paragraph',
660
1302
  ]);
661
1303
 
662
- await fs.writeFile('deck.odp', odp([['Impress intro'], ['Second page']]));
663
- const odpContent = await readContent(base, 'deck.odp');
664
- expect(odpContent).toMatch(/^\[extracted text of deck\.odp — 2 slides;/);
1304
+ await fs.writeFile(`${KB_DIR}/deck.odp`, odp([['Impress intro'], ['Second page']]));
1305
+ const odpContent = await readContent(base, `${KB_DIR}/deck.odp`);
1306
+ expect(odpContent).toMatch(new RegExp(`^\\[extracted text of ${KB_DIR}/deck\\.odp — 2 slides;`));
665
1307
  expect(odpContent).toContain('[slide 1]\nImpress intro\n[slide 2]\nSecond page');
666
1308
 
667
- await fs.writeFile('numbers.ods', ods('Inventory', [['Name', 'Qty'], ['Widget', '3']]));
668
- const odsContent = await readContent(base, 'numbers.ods');
669
- expect(odsContent).toMatch(/^\[extracted text of numbers\.ods — 1 sheet, rows as tab-separated values;/);
1309
+ await fs.writeFile(`${KB_DIR}/numbers.ods`, ods('Inventory', [['Name', 'Qty'], ['Widget', '3']]));
1310
+ const odsContent = await readContent(base, `${KB_DIR}/numbers.ods`);
1311
+ expect(odsContent).toMatch(new RegExp(`^\\[extracted text of ${KB_DIR}/numbers\\.ods — 1 sheet, rows as tab-separated values;`));
670
1312
  expect(odsContent).toContain('[sheet: Inventory]\nName\tQty\nWidget\t3');
671
1313
  });
672
1314
 
673
1315
  it('grep finds a term inside an odp, with the [slide N] marker line locating it', async () => {
674
1316
  const base = await start();
675
- await fs.writeFile('deck.odp', odp([['Intro'], ['Roadmap 2027', 'Ship OpenDocument']]));
1317
+ await fs.writeFile(`${KB_DIR}/deck.odp`, odp([['Intro'], ['Roadmap 2027', 'Ship OpenDocument']]));
676
1318
  const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'Roadmap' })).json()) as {
677
1319
  matches: { path: string; line: number; text: string }[];
678
1320
  };
679
1321
  // Extraction: marker line 1, [slide 1] 2, Intro 3, [slide 2] 4, Roadmap 5.
680
- expect(res.matches).toContainEqual(expect.objectContaining({ path: 'deck.odp', text: 'Roadmap 2027', line: 5 }));
1322
+ expect(res.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/deck.odp`, text: 'Roadmap 2027', line: 5 }));
681
1323
  const markers = (await (await post(`${base}/api/agent/tools/grep`, { pattern: '\\[slide 2\\]' })).json()) as { matches: { path: string }[] };
682
- expect(markers.matches).toContainEqual(expect.objectContaining({ path: 'deck.odp' }));
1324
+ expect(markers.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/deck.odp` }));
683
1325
  });
684
1326
 
685
1327
  it('read_file answers a corrupt odt zip with an honest could-not-parse message (no 500)', async () => {
686
1328
  const base = await start();
687
- await fs.writeFile('broken.odt', Buffer.from('not really a zip'));
688
- const res = await post(`${base}/api/agent/tools/read_file`, { path: 'broken.odt' });
1329
+ await fs.writeFile(`${KB_DIR}/broken.odt`, Buffer.from('not really a zip'));
1330
+ const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/broken.odt` });
689
1331
  expect(res.status).toBe(200);
690
1332
  const { content } = (await res.json()) as { content: string };
691
1333
  expect(content).toContain('could not be parsed as a .odt');
@@ -708,10 +1350,10 @@ describe('office documents and PDFs', () => {
708
1350
 
709
1351
  it('read_file returns marker + [from]/[subject] header block + body for a .eml email', async () => {
710
1352
  const base = await start();
711
- await fs.writeFile('Inbox/offer.eml', eml('Quarterly numbers', 'Please see the summary.'));
712
- const content = await readContent(base, 'Inbox/offer.eml');
1353
+ await fs.writeFile(`${KB_DIR}/Inbox/offer.eml`, eml('Quarterly numbers', 'Please see the summary.'));
1354
+ const content = await readContent(base, `${KB_DIR}/Inbox/offer.eml`);
713
1355
  expect(content.split('\n')).toEqual([
714
- '[extracted text of Inbox/offer.eml — email message; formatting and full headers omitted]',
1356
+ `[extracted text of ${KB_DIR}/Inbox/offer.eml — email message; formatting and full headers omitted]`,
715
1357
  '[from] Ada Lovelace <ada@example.com>',
716
1358
  '[to] bob@example.com',
717
1359
  '[subject] Quarterly numbers',
@@ -723,43 +1365,43 @@ describe('office documents and PDFs', () => {
723
1365
 
724
1366
  it('grep finds a term inside a .eml, with the [subject] header line itself searchable', async () => {
725
1367
  const base = await start();
726
- await fs.writeFile('Inbox/offer.eml', eml('Quarterly numbers', 'The needle-2026 is in the body.'));
1368
+ await fs.writeFile(`${KB_DIR}/Inbox/offer.eml`, eml('Quarterly numbers', 'The needle-2026 is in the body.'));
727
1369
  const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'needle-2026' })).json()) as {
728
1370
  matches: { path: string; line: number; text: string }[];
729
1371
  };
730
1372
  // Extraction: marker 1, [from] 2, [to] 3, [subject] 4, [date] 5, blank 6, body 7.
731
1373
  expect(res.matches).toContainEqual(
732
- expect.objectContaining({ path: 'Inbox/offer.eml', text: 'The needle-2026 is in the body.', line: 7 }),
1374
+ expect.objectContaining({ path: `${KB_DIR}/Inbox/offer.eml`, text: 'The needle-2026 is in the body.', line: 7 }),
733
1375
  );
734
1376
  const header = (await (await post(`${base}/api/agent/tools/grep`, { pattern: '\\[subject\\] Quarterly' })).json()) as {
735
1377
  matches: { path: string; line: number }[];
736
1378
  };
737
- expect(header.matches).toContainEqual(expect.objectContaining({ path: 'Inbox/offer.eml', line: 4 }));
1379
+ expect(header.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/Inbox/offer.eml`, line: 4 }));
738
1380
  });
739
1381
 
740
1382
  it('write_file / edit_file refuse email files with the snapshot explanation', async () => {
741
1383
  const base = await start();
742
- await fs.writeFile('Inbox/offer.eml', eml('original', 'original body'));
1384
+ await fs.writeFile(`${KB_DIR}/Inbox/offer.eml`, eml('original', 'original body'));
743
1385
  for (const [tool, body] of [
744
- ['write_file', { path: 'Inbox/offer.eml', content: 'rewritten' }],
745
- ['write_file', { path: 'Inbox/new-thread.msg', content: 'plain text' }],
746
- ['edit_file', { path: 'Inbox/offer.eml', old_string: 'original', new_string: 'changed' }],
1386
+ ['write_file', { path: `${KB_DIR}/Inbox/offer.eml`, content: 'rewritten' }],
1387
+ ['write_file', { path: `${KB_DIR}/Inbox/new-thread.msg`, content: 'plain text' }],
1388
+ ['edit_file', { path: `${KB_DIR}/Inbox/offer.eml`, old_string: 'original', new_string: 'changed' }],
747
1389
  ] as const) {
748
1390
  const res = await post(`${base}/api/agent/tools/${tool}`, body);
749
- expect(res.status, tool).toBe(400);
1391
+ expect(res.status, tool).toBe(415);
750
1392
  const { error } = (await res.json()) as { error: string };
751
1393
  expect(error, tool).toContain('email file');
752
1394
  expect(error, tool).toContain('snapshot');
753
1395
  expect(error, tool).toContain('uploading a new version');
754
1396
  }
755
1397
  // The email is untouched: reading it still extracts the original text.
756
- expect(await readContent(base, 'Inbox/offer.eml')).toContain('original body');
1398
+ expect(await readContent(base, `${KB_DIR}/Inbox/offer.eml`)).toContain('original body');
757
1399
  });
758
1400
 
759
1401
  it('read_file answers a corrupt .msg with an honest could-not-parse message (no 500)', async () => {
760
1402
  const base = await start();
761
- await fs.writeFile('Inbox/broken.msg', Buffer.from('not a CFB container'));
762
- const res = await post(`${base}/api/agent/tools/read_file`, { path: 'Inbox/broken.msg' });
1403
+ await fs.writeFile(`${KB_DIR}/Inbox/broken.msg`, Buffer.from('not a CFB container'));
1404
+ const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/Inbox/broken.msg` });
763
1405
  expect(res.status).toBe(200);
764
1406
  const { content } = (await res.json()) as { content: string };
765
1407
  expect(content).toContain('could not be parsed as a .msg');
@@ -768,8 +1410,8 @@ describe('office documents and PDFs', () => {
768
1410
 
769
1411
  it('read_file answers a corrupt docx with an honest could-not-parse message (no 500)', async () => {
770
1412
  const base = await start();
771
- await fs.writeFile('broken.docx', Buffer.from('not really a zip'));
772
- const res = await post(`${base}/api/agent/tools/read_file`, { path: 'broken.docx' });
1413
+ await fs.writeFile(`${KB_DIR}/broken.docx`, Buffer.from('not really a zip'));
1414
+ const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/broken.docx` });
773
1415
  expect(res.status).toBe(200);
774
1416
  const { content } = (await res.json()) as { content: string };
775
1417
  expect(content).toContain('could not be parsed as a .docx');
@@ -778,21 +1420,21 @@ describe('office documents and PDFs', () => {
778
1420
 
779
1421
  it('read_file answers a legacy .doc with the convert-to-modern hint', async () => {
780
1422
  const base = await start();
781
- await fs.writeFile('old.doc', Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0x00, 0x01, 0x02]));
782
- const content = await readContent(base, 'old.doc');
1423
+ await fs.writeFile(`${KB_DIR}/old.doc`, Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0x00, 0x01, 0x02]));
1424
+ const content = await readContent(base, `${KB_DIR}/old.doc`);
783
1425
  expect(content).toContain('legacy office format');
784
1426
  expect(content).toContain('.docx');
785
1427
  });
786
1428
 
787
1429
  it('edit_file / write_file refuse a legacy .doc with the convert-or-replace message — a binary the reader cannot extract must never be text-overwritten', async () => {
788
1430
  const base = await start();
789
- await fs.writeFile('old.doc', Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0x00, 0x01, 0x02]));
1431
+ await fs.writeFile(`${KB_DIR}/old.doc`, Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0x00, 0x01, 0x02]));
790
1432
  for (const [tool, body] of [
791
- ['edit_file', { path: 'old.doc', old_string: 'a', new_string: 'b' }],
792
- ['write_file', { path: 'old.doc', content: 'plain text' }],
1433
+ ['edit_file', { path: `${KB_DIR}/old.doc`, old_string: 'a', new_string: 'b' }],
1434
+ ['write_file', { path: `${KB_DIR}/old.doc`, content: 'plain text' }],
793
1435
  ] as const) {
794
1436
  const res = await post(`${base}/api/agent/tools/${tool}`, body);
795
- expect(res.status, tool).toBe(400);
1437
+ expect(res.status, tool).toBe(415);
796
1438
  const { error } = (await res.json()) as { error: string };
797
1439
  expect(error, tool).toContain('legacy binary office format');
798
1440
  expect(error, tool).toContain('.docx');
@@ -805,72 +1447,246 @@ describe('office documents and PDFs', () => {
805
1447
  // A write gate that only asked about the EXTENSION let an agent overwrite
806
1448
  // exactly those bytes — destroying a file it was never allowed to see.
807
1449
  const base = await start();
808
- await fs.writeFile('blob.dat', Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff]));
1450
+ await fs.writeFile(`${KB_DIR}/blob.dat`, Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff]));
809
1451
  for (const [tool, body] of [
810
- ['write_file', { path: 'blob.dat', content: 'plain text' }],
811
- ['edit_file', { path: 'blob.dat', old_string: 'a', new_string: 'b' }],
1452
+ ['write_file', { path: `${KB_DIR}/blob.dat`, content: 'plain text' }],
1453
+ ['edit_file', { path: `${KB_DIR}/blob.dat`, old_string: 'a', new_string: 'b' }],
812
1454
  ] as const) {
813
1455
  const res = await post(`${base}/api/agent/tools/${tool}`, body);
814
- expect(res.status, tool).toBe(400);
1456
+ expect(res.status, tool).toBe(415);
815
1457
  const { error } = (await res.json()) as { error: string };
816
1458
  expect(error, tool).toContain('binary content');
817
1459
  expect(error, tool).toContain('uploading a new version');
818
1460
  }
819
1461
  // …and a TEXT file under the same fallback reader still writes normally.
820
- const ok = await post(`${base}/api/agent/tools/write_file`, { path: 'notes.dat', content: 'hello' });
1462
+ const ok = await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/notes.dat`, content: 'hello' });
821
1463
  expect(ok.status).toBe(200);
822
1464
  });
823
1465
 
824
1466
  it('read_file answers other binary files with a one-line notice (zip names the unzip tool)', async () => {
825
1467
  const base = await start();
826
1468
  // .mp3, not an image: images return native MCP image content (see below).
827
- await fs.writeFile('song.mp3', Buffer.from([0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]));
828
- const mp3 = await readContent(base, 'song.mp3');
829
- expect(mp3).toBe('[song.mp3 is a binary file (audio/mpeg, 9 bytes) — not readable as text.]');
830
- await fs.writeFile('bundle.zip', Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]));
831
- const zip = await readContent(base, 'bundle.zip');
1469
+ await fs.writeFile(`${KB_DIR}/song.mp3`, Buffer.from([0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]));
1470
+ const mp3 = await readContent(base, `${KB_DIR}/song.mp3`);
1471
+ expect(mp3).toBe(`[${KB_DIR}/song.mp3 is a binary file (audio/mpeg, 9 bytes) — not readable as text.]`);
1472
+ await fs.writeFile(`${KB_DIR}/bundle.zip`, Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]));
1473
+ const zip = await readContent(base, `${KB_DIR}/bundle.zip`);
832
1474
  expect(zip).toContain('application/zip');
833
1475
  expect(zip).toContain('unzip tool');
834
1476
  });
835
1477
 
836
1478
  it('write_file / edit_file / write_files refuse document extensions with the round-trip explanation', async () => {
837
1479
  const base = await start();
838
- await fs.writeFile('deck.pptx', pptx([['Original']]));
1480
+ await fs.writeFile(`${KB_DIR}/deck.pptx`, pptx([['Original']]));
839
1481
  for (const [tool, body] of [
840
- ['write_file', { path: 'new.docx', content: 'plain text' }],
841
- ['write_file', { path: 'new.odt', content: 'plain text' }],
842
- ['write_file', { path: 'slides.odp', content: 'plain text' }],
843
- ['edit_file', { path: 'deck.pptx', old_string: 'Original', new_string: 'Changed' }],
844
- ['edit_file', { path: 'numbers.ods', old_string: 'a', new_string: 'b' }],
845
- ['write_files', { files: [{ path: 'ok.md', content: 'fine' }, { path: 'sheet.xlsx', content: 'nope' }] }],
846
- ['write_files', { files: [{ path: 'ok.md', content: 'fine' }, { path: 'sheet.ods', content: 'nope' }] }],
1482
+ ['write_file', { path: `${KB_DIR}/new.docx`, content: 'plain text' }],
1483
+ ['write_file', { path: `${KB_DIR}/new.odt`, content: 'plain text' }],
1484
+ ['write_file', { path: `${KB_DIR}/slides.odp`, content: 'plain text' }],
1485
+ ['edit_file', { path: `${KB_DIR}/deck.pptx`, old_string: 'Original', new_string: 'Changed' }],
1486
+ ['edit_file', { path: `${KB_DIR}/numbers.ods`, old_string: 'a', new_string: 'b' }],
847
1487
  ] as const) {
848
1488
  const res = await post(`${base}/api/agent/tools/${tool}`, body);
849
- expect(res.status, tool).toBe(400);
1489
+ expect(res.status, tool).toBe(415);
850
1490
  const { error } = (await res.json()) as { error: string };
851
1491
  expect(error, tool).toContain('EXTRACTED text');
852
1492
  expect(error, tool).toContain('uploading a new version');
853
1493
  }
854
- // The batch was refused atomically — the innocent .md was not written either.
855
- expect((await post(`${base}/api/agent/tools/read_file`, { path: 'ok.md' })).status).not.toBe(200);
1494
+ // In a BATCH the same refusal is per path: the document is refused with the
1495
+ // same explanation, and the innocent .md beside it is still written.
1496
+ for (const doc of [`${KB_DIR}/sheet.xlsx`, `${KB_DIR}/sheet.ods`]) {
1497
+ const res = await post(`${base}/api/agent/tools/write_files`, {
1498
+ files: [{ path: `${doc}.ok.md`, content: 'fine' }, { path: doc, content: 'nope' }],
1499
+ });
1500
+ expect(res.status, doc).toBe(200);
1501
+ const body = (await res.json()) as { count: number; files: { path: string; outcome: string; error?: string; message?: string }[] };
1502
+ expect(body.count, doc).toBe(1);
1503
+ expect(body.files.map((f) => f.path), doc).toEqual([`${doc}.ok.md`, doc]);
1504
+ expect(body.files[0], doc).toMatchObject({ outcome: 'created' });
1505
+ expect(body.files[1], doc).toMatchObject({ outcome: 'refused', error: 'binary_not_writable' });
1506
+ expect(body.files[1].message, doc).toContain('EXTRACTED text');
1507
+ expect(body.files[1].message, doc).toContain('uploading a new version');
1508
+ expect(await readContent(base, `${doc}.ok.md`)).toBe('fine');
1509
+ expect((await post(`${base}/api/agent/tools/file_stat`, { path: doc })).status, doc).not.toBe(200);
1510
+ }
856
1511
  // And the pptx is untouched: reading it still extracts the original text.
857
- expect(await readContent(base, 'deck.pptx')).toContain('Original');
1512
+ expect(await readContent(base, `${KB_DIR}/deck.pptx`)).toContain('Original');
858
1513
  });
859
1514
 
860
- it('the write tools DESCRIBE every refused family — modern, legacy binary, and the upload path — so agents learn before the call', async () => {
1515
+ it('every file tool states the SAME content rule — refused families, the byte tools and the upload path — so agents learn before the call', async () => {
861
1516
  await start();
862
1517
  const tools = await toolRegistry.listInternal();
863
- for (const name of ['write_file', 'write_files', 'edit_file']) {
1518
+ const fileTools = ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'mkdir', 'move_file', 'copy_file', 'unzip'];
1519
+ for (const name of fileTools) {
864
1520
  const def = tools.find((t) => t.name === name);
865
1521
  expect(def, name).toBeDefined();
1522
+ // One constant, verbatim — the description is what tools_info returns.
1523
+ expect(def!.description, name).toContain(CONTENT_RULE);
1524
+ expect(def!.description, name).toContain('`binary_not_writable`');
1525
+ expect(def!.description, name).toContain('copy_file, move_file, delete_file and unzip act on bytes of any kind');
1526
+ expect(def!.description, name).toContain('`request_upload_token` + `apply_upload`');
1527
+ expect(def!.description, name).toContain('`contentMode`');
866
1528
  // Modern extractable formats…
867
1529
  expect(def!.description, name).toContain('.docx/.pptx/.xlsx/.odt/.odp/.ods/.pdf');
868
1530
  // …email files (extractions too, so the same refusal applies)…
869
1531
  expect(def!.description, name).toContain('.eml/.msg');
870
1532
  // …the legacy binary family the refusal also covers…
871
1533
  expect(def!.description, name).toContain('.doc/.ppt/.xls');
872
- // …and the replace-by-upload way out.
873
- expect(def!.description, name).toContain('uploading a new version');
1534
+ }
1535
+ // The shell is not a file tool: it does not carry the rule.
1536
+ expect(tools.find((t) => t.name === 'execute_command')!.description).not.toContain(CONTENT_RULE);
1537
+ });
1538
+
1539
+ describe('binary capability contract: a text file, a document, an image and a zip', () => {
1540
+ const PNG = Buffer.from(
1541
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
1542
+ 'base64',
1543
+ );
1544
+ const zipBytes = (): Buffer => {
1545
+ const z = new AdmZip();
1546
+ z.addFile(`${KB_DIR}/inner.md`, Buffer.from('# inner\n'));
1547
+ return z.toBuffer();
1548
+ };
1549
+ /** Seed the four kinds; returns each path with its exact bytes and expected answers. */
1550
+ const seed = async () => {
1551
+ const files = [
1552
+ { path: `${KB_DIR}/notes.md`, bytes: Buffer.from('hello text\n'), mode: 'text', kind: null },
1553
+ { path: `${KB_DIR}/deck.pptx`, bytes: pptx([['Original']]), mode: 'document', kind: 'document' },
1554
+ { path: `${KB_DIR}/logo.png`, bytes: PNG, mode: 'binary', kind: 'image' },
1555
+ { path: `${KB_DIR}/bundle.zip`, bytes: zipBytes(), mode: 'binary', kind: 'archive' },
1556
+ ] as const;
1557
+ for (const f of files) await fs.writeFile(f.path, f.bytes);
1558
+ return files;
1559
+ };
1560
+ /** `path` is a repository path, which is workspace-relative already. */
1561
+ const onDisk = async (path: string) => Buffer.from(await readFile(join(tempDir, path)));
1562
+ /** A sibling of `path` under `dir`, inside the repository. */
1563
+ const under = (dir: string, path: string) => `${KB_DIR}/${dir}/${path.slice(KB_DIR.length + 1)}`;
1564
+ interface Refusal { error: string; kind: string; fileKind: string; useInstead: string[] }
1565
+ const expectRefusal = async (res: Response, fileKind: string, label: string) => {
1566
+ expect(res.status, label).toBe(415);
1567
+ const body = (await res.json()) as Refusal;
1568
+ expect(body.kind, label).toBe('binary_not_writable');
1569
+ expect(body.fileKind, label).toBe(fileKind);
1570
+ expect(body.useInstead, label).toEqual(['upload', 'copy_file', 'move_file']);
1571
+ // The prose names the kind and the alternatives too, for a caller that only sees the message.
1572
+ expect(body.error, label).toContain(`this file's kind is ${fileKind};`);
1573
+ expect(body.error, label).toContain('upload');
1574
+ expect(body.error, label).toContain('copy_file / move_file');
1575
+ };
1576
+ /** The same refusal, as `write_files` reports it: per path, inside a 200. */
1577
+ const expectBatchRefusal = async (res: Response, fileKind: string, label: string) => {
1578
+ expect(res.status, label).toBe(200);
1579
+ const body = (await res.json()) as { count: number; files: { outcome: string; error?: string; message?: string }[] };
1580
+ expect(body.count, label).toBe(0);
1581
+ expect(body.files[0].outcome, label).toBe('refused');
1582
+ expect(body.files[0].error, label).toBe('binary_not_writable');
1583
+ expect(body.files[0].message, label).toContain(`this file's kind is ${fileKind};`);
1584
+ };
1585
+
1586
+ it('file_stat reports contentMode text | document | binary', async () => {
1587
+ const base = await start();
1588
+ for (const f of await seed()) {
1589
+ const stat = (await (await post(`${base}/api/agent/tools/file_stat`, { path: f.path })).json()) as Record<string, unknown>;
1590
+ expect(stat.type, f.path).toBe('file');
1591
+ expect(stat.contentMode, f.path).toBe(f.mode);
1592
+ }
1593
+ // Binary content under a text name is binary: write_file would refuse it.
1594
+ await fs.writeFile(`${KB_DIR}/blob.dat`, Buffer.from([0x00, 0xff]));
1595
+ const blob = (await (await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/blob.dat` })).json()) as Record<string, unknown>;
1596
+ expect(blob.contentMode).toBe('binary');
1597
+ // A directory has no content mode.
1598
+ await fs.mkdir(`${KB_DIR}/dir`, { recursive: true });
1599
+ const dir = (await (await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/dir` })).json()) as Record<string, unknown>;
1600
+ expect(dir.contentMode).toBeUndefined();
1601
+ expect(dir.kind).toBeUndefined();
1602
+ });
1603
+
1604
+ it('file_stat classifies a file the way read_file does: kind, mime, mimeSource, textEditable', async () => {
1605
+ const base = await start();
1606
+ const stat = async (path: string) =>
1607
+ (await (await post(`${base}/api/agent/tools/file_stat`, { path })).json()) as Record<string, unknown>;
1608
+ // Extensionless UTF-8: read_file returns its text, so stat says text/plain.
1609
+ await fs.writeFile('Sample file', Buffer.from('plain words, no extension\n'));
1610
+ expect(await (await post(`${base}/api/agent/tools/read_file`, { path: 'Sample file' })).json()).toMatchObject({ content: 'plain words, no extension\n' });
1611
+ expect(await stat('Sample file')).toMatchObject({ type: 'file', kind: 'text', mime: 'text/plain', mimeSource: 'sniff', textEditable: true });
1612
+ expect(await stat('Sample file')).not.toHaveProperty('mimeNote');
1613
+ // The filesystem's own mimeType (a second extension table: octet-stream
1614
+ // here) is not passed through, so nothing in the answer contradicts `mime`.
1615
+ expect((await fs.stat('Sample file')).mimeType).toBeDefined();
1616
+ await fs.writeFile(`${KB_DIR}/notes.md`, Buffer.from('# notes\n'));
1617
+ await fs.mkdir('folder', { recursive: true });
1618
+ for (const p of ['Sample file', `${KB_DIR}/notes.md`, 'folder']) {
1619
+ expect(await stat(p), p).not.toHaveProperty('mimeType');
1620
+ }
1621
+ expect(await stat(`${KB_DIR}/notes.md`)).toMatchObject({ kind: 'text', mime: 'text/plain' });
1622
+ await fs.writeFile(`${KB_DIR}/plata.pdf`, Buffer.from('%PDF-1.4\n'));
1623
+ expect(await stat(`${KB_DIR}/plata.pdf`)).toMatchObject({ kind: 'document', mime: 'application/pdf', textEditable: false });
1624
+ await seed();
1625
+ expect(await stat(`${KB_DIR}/deck.pptx`)).toMatchObject({ kind: 'document', mimeSource: 'extension', textEditable: false });
1626
+ expect(await stat(`${KB_DIR}/logo.png`)).toMatchObject({ kind: 'image', mime: 'image/png', textEditable: false });
1627
+ // Real binary bytes without a known extension: the octet-stream fallback, and a note saying so.
1628
+ await fs.writeFile('Sample blob', Buffer.from([0x00, 0x01, 0xff]));
1629
+ const blob = await stat('Sample blob');
1630
+ expect(blob).toMatchObject({ kind: 'binary', mime: 'application/octet-stream', mimeSource: 'fallback', textEditable: false });
1631
+ expect(blob.mimeNote).toBe(OCTET_STREAM_FALLBACK_NOTE);
1632
+ });
1633
+
1634
+ it('write_file, write_files and edit_file accept the text file and refuse the other three with binary_not_writable, bytes untouched', async () => {
1635
+ const base = await start();
1636
+ // The plain test filesystem has no batch commit; give it one that lands
1637
+ // each write, so the text batch must actually SUCCEED past the gate.
1638
+ (fs as unknown as { writeFiles: (writes: { path: string; content: string }[]) => Promise<void> }).writeFiles = async (writes) => {
1639
+ for (const w of writes) await fs.writeFile(w.path, w.content);
1640
+ };
1641
+ const files = await seed();
1642
+ for (const f of files) {
1643
+ // Every seeded file already exists, so the write says it means to replace it.
1644
+ const write = await post(`${base}/api/agent/tools/write_file`, { path: f.path, content: 'plain text', mode: 'overwrite' });
1645
+ const batch = await post(`${base}/api/agent/tools/write_files`, { files: [{ path: f.path, content: 'plain text' }], mode: 'overwrite' });
1646
+ const edit = await post(`${base}/api/agent/tools/edit_file`, { path: f.path, old_string: 'a', new_string: 'b' });
1647
+ if (f.kind === null) {
1648
+ expect(write.status, f.path).toBe(200);
1649
+ expect(batch.status, f.path).toBe(200);
1650
+ // Content is now 'plain text', so 'a' is found once.
1651
+ expect(edit.status, f.path).toBe(200);
1652
+ expect((await onDisk(f.path)).toString('utf8')).toBe('plbin text');
1653
+ continue;
1654
+ }
1655
+ await expectRefusal(write, f.kind, `write_file ${f.path}`);
1656
+ await expectBatchRefusal(batch, f.kind, `write_files ${f.path}`);
1657
+ await expectRefusal(edit, f.kind, `edit_file ${f.path}`);
1658
+ expect((await onDisk(f.path)).equals(f.bytes), f.path).toBe(true);
1659
+ }
1660
+ // Creating a NEW image or zip by text is refused the same way — nothing lands.
1661
+ await expectRefusal(await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/new.png`, content: 'x' }), 'image', `${KB_DIR}/new.png`);
1662
+ await expectRefusal(await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/new.zip`, content: 'x' }), 'archive', `${KB_DIR}/new.zip`);
1663
+ expect((await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/new.png` })).status).not.toBe(200);
1664
+ });
1665
+
1666
+ it('copy_file and move_file carry every kind byte-for-byte', async () => {
1667
+ const base = await start();
1668
+ for (const f of await seed()) {
1669
+ const copied = await post(`${base}/api/agent/tools/copy_file`, { src: f.path, dest: under('copies', f.path) });
1670
+ expect(copied.status, f.path).toBe(200);
1671
+ expect((await onDisk(under('copies', f.path))).equals(f.bytes), `copy ${f.path}`).toBe(true);
1672
+ const moved = await post(`${base}/api/agent/tools/move_file`, { src: f.path, dest: under('moved', f.path) });
1673
+ expect(moved.status, f.path).toBe(200);
1674
+ expect((await onDisk(under('moved', f.path))).equals(f.bytes), `move ${f.path}`).toBe(true);
1675
+ }
1676
+ // The moved zip is still a real archive: unzip reads it as bytes.
1677
+ const z = new AdmZip(await onDisk(`${KB_DIR}/moved/bundle.zip`));
1678
+ expect(z.getEntry(`${KB_DIR}/inner.md`)?.getData().toString('utf8')).toBe('# inner\n');
1679
+ });
1680
+ });
1681
+
1682
+ it('the page-writing tools say where images go, so an agent writes the link a page will render', async () => {
1683
+ await start();
1684
+ const tools = await toolRegistry.listInternal();
1685
+ for (const name of ['write_file', 'write_files']) {
1686
+ const def = tools.find((t) => t.name === name);
1687
+ expect(def, name).toBeDefined();
1688
+ expect(def!.description, name).toContain('`assets/` folder next to the page');
1689
+ expect(def!.description, name).toContain('![Approval screen](./assets/approval-screen.png)');
874
1690
  }
875
1691
  });
876
1692
  });
@@ -899,14 +1715,14 @@ describe('images', () => {
899
1715
 
900
1716
  it('read_file on a png returns the image sentinel with base64, mimeType and a note naming path + dimensions + size', async () => {
901
1717
  const base = await start();
902
- await fs.writeFile('logo.png', PNG_1X1);
903
- const res = await post(`${base}/api/agent/tools/read_file`, { path: 'logo.png' });
1718
+ await fs.writeFile(`${KB_DIR}/logo.png`, PNG_1X1);
1719
+ const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/logo.png` });
904
1720
  expect(res.status).toBe(200);
905
1721
  const body = (await res.json()) as ImageSentinel;
906
1722
  expect(body.kind).toBe('bevel/mcp-image@v1');
907
1723
  expect(body.mimeType).toBe('image/png');
908
1724
  expect(body.data).toBe(PNG_1X1.toString('base64'));
909
- expect(body.note).toContain('logo.png');
1725
+ expect(body.note).toContain(`${KB_DIR}/logo.png`);
910
1726
  expect(body.note).toContain('image/png');
911
1727
  expect(body.note).toContain(`${PNG_1X1.length} bytes`);
912
1728
  expect(body.note).toContain('1×1 px');
@@ -914,8 +1730,8 @@ describe('images', () => {
914
1730
 
915
1731
  it('read_file passes a gif through whole under the same cap (first frame is the client’s concern)', async () => {
916
1732
  const base = await start();
917
- await fs.writeFile('anim.gif', GIF_1X1);
918
- const body = (await (await post(`${base}/api/agent/tools/read_file`, { path: 'anim.gif' })).json()) as ImageSentinel;
1733
+ await fs.writeFile(`${KB_DIR}/anim.gif`, GIF_1X1);
1734
+ const body = (await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/anim.gif` })).json()) as ImageSentinel;
919
1735
  expect(body.kind).toBe('bevel/mcp-image@v1');
920
1736
  expect(body.mimeType).toBe('image/gif');
921
1737
  expect(body.data).toBe(GIF_1X1.toString('base64'));
@@ -925,11 +1741,11 @@ describe('images', () => {
925
1741
  it('read_file maps .jpg/.jpeg to image/jpeg (dimensions omitted when the header has none to give)', async () => {
926
1742
  const base = await start();
927
1743
  const bytes = Buffer.from([0xff, 0xd8, 0xff, 0xd9]); // SOI + EOI, no frame header
928
- await fs.writeFile('photo.jpg', bytes);
929
- const body = (await (await post(`${base}/api/agent/tools/read_file`, { path: 'photo.jpg' })).json()) as ImageSentinel;
1744
+ await fs.writeFile(`${KB_DIR}/photo.jpg`, bytes);
1745
+ const body = (await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/photo.jpg` })).json()) as ImageSentinel;
930
1746
  expect(body.mimeType).toBe('image/jpeg');
931
1747
  expect(body.data).toBe(bytes.toString('base64'));
932
- expect(body.note).toBe(`[image: photo.jpg — image/jpeg, ${bytes.length} bytes]`);
1748
+ expect(body.note).toBe(`[image: ${KB_DIR}/photo.jpg — image/jpeg, ${bytes.length} bytes]`);
933
1749
  });
934
1750
 
935
1751
  it('read_file refuses an image over 3.5 MiB raw with the downscale message, not a sentinel', async () => {
@@ -937,11 +1753,11 @@ describe('images', () => {
937
1753
  // 3,670,016 is the cap; one byte over must refuse. PNG magic + zero fill.
938
1754
  const big = Buffer.alloc(3_670_017);
939
1755
  Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(big);
940
- await fs.writeFile('huge.png', big);
941
- const res = await post(`${base}/api/agent/tools/read_file`, { path: 'huge.png' });
1756
+ await fs.writeFile(`${KB_DIR}/huge.png`, big);
1757
+ const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/huge.png` });
942
1758
  expect(res.status).toBe(200);
943
1759
  const body = (await res.json()) as { path: string; content: string };
944
- expect(body.path).toBe('huge.png');
1760
+ expect(body.path).toBe(`${KB_DIR}/huge.png`);
945
1761
  expect(body.content).toContain('too large to return over MCP');
946
1762
  expect(body.content).toContain('3670016 bytes');
947
1763
  expect(body.content).toContain('Downscale');
@@ -951,9 +1767,9 @@ describe('images', () => {
951
1767
  it('read_file keeps .svg on the TEXT path — it is markup, not an image block', async () => {
952
1768
  const base = await start();
953
1769
  const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8"/></svg>';
954
- await fs.writeFile('icon.svg', svg);
955
- expect(await (await post(`${base}/api/agent/tools/read_file`, { path: 'icon.svg' })).json()).toEqual({
956
- path: 'icon.svg',
1770
+ await fs.writeFile(`${KB_DIR}/icon.svg`, svg);
1771
+ expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/icon.svg` })).json()).toEqual({
1772
+ path: `${KB_DIR}/icon.svg`,
957
1773
  content: svg,
958
1774
  });
959
1775
  });
@@ -977,7 +1793,14 @@ describe('start_session', () => {
977
1793
  let server: HttpServer | undefined;
978
1794
  let created: Array<{ userId: string; startedAt: Date }> = [];
979
1795
 
980
- async function startSessionApp(source: 'external' | 'internal' = 'external'): Promise<string> {
1796
+ async function startSessionApp(
1797
+ source: 'external' | 'internal' = 'external',
1798
+ // The default fake returns one fixed id, which is what most of these
1799
+ // assertions want. The load reproduction below substitutes the REAL
1800
+ // `UuidSessionSink`, because "did fifty first calls collide?" is a question
1801
+ // only the real minting can answer.
1802
+ sink?: ISessionSink,
1803
+ ): Promise<string> {
981
1804
  created = [];
982
1805
  const registry = new ToolRegistry();
983
1806
  const resolve = async (auth: ToolAuth, signal: AbortSignal): Promise<ToolContext> => ({
@@ -1011,7 +1834,7 @@ describe('start_session', () => {
1011
1834
  new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, KB_DIR,
1012
1835
  { service: {} as never, enabled: false, kbDirName: KB_DIR, recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks() },
1013
1836
  new RoutineWritePolicyService(),
1014
- fakeSessionSink,
1837
+ sink ?? fakeSessionSink,
1015
1838
  );
1016
1839
  app.use('/api', router);
1017
1840
  server = await new Promise<HttpServer>((r) => {
@@ -1048,6 +1871,107 @@ describe('start_session', () => {
1048
1871
  expect(res.status).toBe(403);
1049
1872
  expect(created).toHaveLength(0);
1050
1873
  });
1874
+
1875
+ /**
1876
+ * The load reproduction, platform side.
1877
+ *
1878
+ * The report behind this was one first call on a fresh connection failing
1879
+ * with a generic error, an immediate retry working, and the error carrying a
1880
+ * `req_011…` id — the Anthropic API's request-id shape, which nothing here
1881
+ * mints. The hypothesis was that the failure never reached the tool. These
1882
+ * run the real `UuidSessionSink` behind the real route, fifty calls at once,
1883
+ * so the hypothesis stops being the only account of what the route does
1884
+ * under a burst.
1885
+ *
1886
+ * `first-call-probe.ts` (and its suite) does the same over a real MCP
1887
+ * transport; this one strips the transport away so a future failure can be
1888
+ * placed on one side of it or the other.
1889
+ */
1890
+ describe('fifty first calls at once', () => {
1891
+ it('answers every one of them with a session id of its own', async () => {
1892
+ const base = await startSessionApp('external', new UuidSessionSink());
1893
+
1894
+ const responses = await Promise.all(Array.from({ length: 50 }, () => post(`${base}/api/agent/tools/start_session`)));
1895
+ const bodies = (await Promise.all(responses.map((r) => r.json()))) as Array<{ sessionId?: string }>;
1896
+
1897
+ expect(responses.map((r) => r.status)).toEqual(Array.from({ length: 50 }, () => 200));
1898
+ // Fifty ids, no collision and no blank: a burst is not one id handed out
1899
+ // repeatedly, and it is not a partially-served queue either.
1900
+ expect(new Set(bodies.map((b) => b.sessionId)).size).toBe(50);
1901
+ expect(bodies.every((b) => typeof b.sessionId === 'string' && b.sessionId.length > 0)).toBe(true);
1902
+ });
1903
+
1904
+ it('mints nothing when the call fails, so the retry the description promises is safe', async () => {
1905
+ // A sink that fails the first call and serves the second is the reported
1906
+ // sequence exactly. What the retry must NOT inherit is any state the
1907
+ // failed call left — and there is none to inherit, because a failed mint
1908
+ // never reached the point of producing an id.
1909
+ let calls = 0;
1910
+ const flaky: ISessionSink = {
1911
+ createSession: async () => {
1912
+ calls++;
1913
+ if (calls === 1) throw new Error('sink unavailable');
1914
+ return { sessionId: `session-${calls}` };
1915
+ },
1916
+ };
1917
+ const base = await startSessionApp('external', flaky);
1918
+
1919
+ // The route logs the sink's failure — correct behaviour, and expected
1920
+ // here, so it is kept out of the suite's output rather than left to look
1921
+ // like a real fault. Restored in a `finally`: if the request itself
1922
+ // rejected, an un-restored spy would go on swallowing console.error for
1923
+ // every later test in this file, and they would pass while saying nothing.
1924
+ const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {});
1925
+ let failed: Awaited<ReturnType<typeof post>>;
1926
+ try {
1927
+ failed = await post(`${base}/api/agent/tools/start_session`);
1928
+ } finally {
1929
+ errorLog.mockRestore();
1930
+ }
1931
+ expect(failed.status).toBeGreaterThanOrEqual(500);
1932
+
1933
+ const retried = (await (await post(`${base}/api/agent/tools/start_session`)).json()) as { sessionId: string };
1934
+ expect(retried.sessionId).toBe('session-2');
1935
+ expect(calls).toBe(2);
1936
+ });
1937
+
1938
+ it('gives a retry that lands after a success a NEW id, leaving the first one usable', async () => {
1939
+ // The harmless case the description calls out: a client that retries a
1940
+ // call which had in fact succeeded ends up holding two ids. Neither
1941
+ // supersedes the other — the run keeps using the one it already passed
1942
+ // to other tools, and the spare is simply never mentioned again.
1943
+ const base = await startSessionApp('external', new UuidSessionSink());
1944
+
1945
+ const first = (await (await post(`${base}/api/agent/tools/start_session`)).json()) as { sessionId: string };
1946
+ const retry = (await (await post(`${base}/api/agent/tools/start_session`)).json()) as { sessionId: string };
1947
+
1948
+ expect(retry.sessionId).not.toBe(first.sessionId);
1949
+ expect(first.sessionId).toBeTruthy();
1950
+ });
1951
+ });
1952
+
1953
+ it('tells the caller, in the tool description, that a failed call can be retried', async () => {
1954
+ const registry = new ToolRegistry();
1955
+ const router = express.Router();
1956
+ const noopAuth: express.RequestHandler = (_req, _res, next) => next();
1957
+ registerWorkspaceTools(
1958
+ registry, router, noopAuth, (() => () => {}) as never,
1959
+ new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, KB_DIR,
1960
+ { service: {} as never, enabled: false, kbDirName: KB_DIR, recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks() },
1961
+ new RoutineWritePolicyService(),
1962
+ {} as never,
1963
+ );
1964
+
1965
+ const description = (await registry.listExternal()).find((t) => t.name === 'start_session')?.description ?? '';
1966
+
1967
+ // The three things a caller has to be told, and the reason the ticket asked
1968
+ // for them: without the first two a transport hiccup reads as a dead end,
1969
+ // and without the third a client that already retried thinks it has
1970
+ // corrupted its own run.
1971
+ expect(description).toMatch(/retry/i);
1972
+ expect(description).toMatch(/created nothing/i);
1973
+ expect(description).toMatch(/harmless/i);
1974
+ });
1051
1975
  });
1052
1976
 
1053
1977
  /**
@@ -1154,6 +2078,30 @@ describe('path inputs tell the agent about the repository folder', () => {
1154
2078
  }
1155
2079
  });
1156
2080
 
2081
+ it('say the prefix is OPTIONAL, and what an unprefixed path means', async () => {
2082
+ await start();
2083
+ const tools = await toolRegistry.listInternal();
2084
+ const byName = (name: string) => tools.find((t) => t.name === name);
2085
+ const said = (name: string, ...keys: string[]) => inputDescription(byName(name), ...keys);
2086
+ // Every path input, the ones that used to carry the shorter "may name a
2087
+ // stray" wording included: a path without the prefix is PLACED under it,
2088
+ // and the description says which path that is.
2089
+ for (const name of ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'edit_file', 'delete_file', 'mkdir', 'unzip', 'delete_folder']) {
2090
+ expect(said(name, 'path'), name).toContain(`placed under \`${KB_DIR}/\``);
2091
+ }
2092
+ for (const name of ['move_file', 'copy_file']) {
2093
+ for (const key of ['src', 'dest']) {
2094
+ expect(said(name, key), `${name}.${key}`).toContain(`placed under \`${KB_DIR}/\``);
2095
+ }
2096
+ }
2097
+ expect(said('unzip', 'destination')).toContain(`placed under \`${KB_DIR}/\``);
2098
+ expect(inputDescription(byName('write_files'), 'files', 'path')).toContain(`placed under \`${KB_DIR}/\``);
2099
+ // And no input still claims a missing prefix is refused.
2100
+ for (const name of ['read_file', 'write_file', 'delete_file', 'move_file', 'copy_file', 'unzip']) {
2101
+ expect(said(name, 'path') + said(name, 'src') + said(name, 'dest'), name).not.toContain('is refused');
2102
+ }
2103
+ });
2104
+
1157
2105
  it('the root listing names the clone folder, so an agent that lists first learns the prefix', async () => {
1158
2106
  await start();
1159
2107
  const tools = await toolRegistry.listInternal();
@@ -1163,3 +2111,1577 @@ describe('path inputs tell the agent about the repository folder', () => {
1163
2111
  expect(inputDescription(list, 'path')).toContain(`\`${KB_DIR}/\``);
1164
2112
  });
1165
2113
  });
2114
+
2115
+ /**
2116
+ * The MCP tools ACCEPT an unprefixed path now, by placing it inside the
2117
+ * checkout, where they used to refuse it with a message naming the corrected
2118
+ * spelling. One normaliser does it for all of them, at the tool boundary, so a
2119
+ * tool cannot drift from a route again — and the answer names the repository
2120
+ * path, so a caller always learns where its bytes went.
2121
+ */
2122
+ describe('the tools place an unprefixed path inside the repository', () => {
2123
+ const tool = (base: string, name: string, body: Record<string, unknown>) =>
2124
+ post(`${base}/api/agent/tools/${name}`, { branch: 'main', ...body });
2125
+ const onDisk = (repoRel: string) => readFile(join(tempDir, KB_DIR, repoRel), 'utf8');
2126
+ /** Nothing beside the checkout, ever. */
2127
+ const besideCheckout = async () => (await nodeReaddir(tempDir)).filter((n) => n !== KB_DIR).sort();
2128
+
2129
+ it('write_file writes into the repository and reports the repository path', async () => {
2130
+ const base = await start();
2131
+ const res = await tool(base, 'write_file', { path: 'KnowledgeBase/Report.md', content: 'body' });
2132
+ expect(res.status).toBe(200);
2133
+ expect(await res.json()).toMatchObject({ path: `${KB_DIR}/KnowledgeBase/Report.md`, outcome: 'created' });
2134
+ expect(await onDisk('KnowledgeBase/Report.md')).toBe('body');
2135
+ expect(await besideCheckout()).toEqual([]);
2136
+ });
2137
+
2138
+ it('write_files places every entry of a batch', async () => {
2139
+ const base = await start();
2140
+ const res = await tool(base, 'write_files', {
2141
+ files: [{ path: 'one.md', content: '1' }, { path: 'Nested/two.md', content: '2' }],
2142
+ });
2143
+ expect(res.status).toBe(200);
2144
+ const body = (await res.json()) as { files: { path: string }[] };
2145
+ expect(body.files.map((f) => f.path)).toEqual([`${KB_DIR}/one.md`, `${KB_DIR}/Nested/two.md`]);
2146
+ expect(await onDisk('Nested/two.md')).toBe('2');
2147
+ expect(await besideCheckout()).toEqual([]);
2148
+ });
2149
+
2150
+ it('read_file, file_stat and edit_file all read the same repository file', async () => {
2151
+ const base = await start();
2152
+ await fs.writeFile(`${KB_DIR}/Notes.md`, 'hello\nworld\n');
2153
+
2154
+ expect(await (await tool(base, 'read_file', { path: 'Notes.md' })).json()).toEqual({
2155
+ path: `${KB_DIR}/Notes.md`,
2156
+ content: 'hello\nworld\n',
2157
+ });
2158
+ expect(await (await tool(base, 'file_stat', { path: 'Notes.md' })).json()).toMatchObject({ type: 'file' });
2159
+ const edited = await tool(base, 'edit_file', { path: 'Notes.md', old_string: 'world', new_string: 'earth' });
2160
+ expect(await edited.json()).toMatchObject({ path: `${KB_DIR}/Notes.md`, replaced: 1 });
2161
+ expect(await onDisk('Notes.md')).toBe('hello\nearth\n');
2162
+ });
2163
+
2164
+ it('mkdir creates the folder in the repository', async () => {
2165
+ const base = await start();
2166
+ expect((await tool(base, 'mkdir', { path: 'KnowledgeBase/Reports' })).status).toBe(200);
2167
+ expect(await fs.exists(`${KB_DIR}/KnowledgeBase/Reports`)).toBe(true);
2168
+ expect(await besideCheckout()).toEqual([]);
2169
+ });
2170
+
2171
+ it('move_file and copy_file place both ends', async () => {
2172
+ const base = await start();
2173
+ await fs.writeFile(`${KB_DIR}/Src/a.md`, 'a');
2174
+
2175
+ expect((await tool(base, 'copy_file', { src: 'Src/a.md', dest: 'Copies/a.md' })).status).toBe(200);
2176
+ expect(await onDisk('Copies/a.md')).toBe('a');
2177
+ expect((await tool(base, 'move_file', { src: 'Src/a.md', dest: 'Moved/a.md' })).status).toBe(200);
2178
+ expect(await onDisk('Moved/a.md')).toBe('a');
2179
+ expect(await besideCheckout()).toEqual([]);
2180
+ });
2181
+
2182
+ it('delete_file and delete_folder act inside the repository', async () => {
2183
+ const base = await start();
2184
+ await fs.writeFile(`${KB_DIR}/Doomed/x.md`, 'x');
2185
+ expect((await tool(base, 'delete_file', { path: 'Doomed/x.md' })).status).toBe(200);
2186
+ expect(await fs.exists(`${KB_DIR}/Doomed/x.md`)).toBe(false);
2187
+
2188
+ await fs.writeFile(`${KB_DIR}/Gone/y.md`, 'y');
2189
+ expect((await tool(base, 'delete_folder', { path: 'Gone', confirm: true })).status).toBe(200);
2190
+ expect(await fs.exists(`${KB_DIR}/Gone/y.md`)).toBe(false);
2191
+ });
2192
+
2193
+ it('unzip hands the service the repository path for archive and destination alike', async () => {
2194
+ const base = await start();
2195
+ const zip = new AdmZip();
2196
+ zip.addFile('in.md', Buffer.from('z'));
2197
+ await fs.writeFile(`${KB_DIR}/drop.zip`, zip.toBuffer());
2198
+
2199
+ expect((await tool(base, 'unzip', { path: 'drop.zip', destination: 'Out' })).status).toBe(200);
2200
+ expect(unzipCalls).toContainEqual([`${KB_DIR}/drop.zip`, `${KB_DIR}/Out`]);
2201
+ });
2202
+
2203
+ it('still refuses the spellings no prefix can rescue, on every tool', async () => {
2204
+ const base = await start();
2205
+ for (const p of ['../etc/hostname', 'KnowledgeBase\\x', '/tmp/x', 'KnowledgeBase/../../escape.md']) {
2206
+ for (const [name, body] of [
2207
+ ['read_file', { path: p }],
2208
+ ['file_stat', { path: p }],
2209
+ ['write_file', { path: p, content: 'x' }],
2210
+ ['edit_file', { path: p, old_string: 'a', new_string: 'b' }],
2211
+ ['mkdir', { path: p }],
2212
+ ['delete_file', { path: p }],
2213
+ ['move_file', { src: p, dest: `${KB_DIR}/x.md` }],
2214
+ ['copy_file', { src: `${KB_DIR}/a.md`, dest: p }],
2215
+ ['unzip', { path: p }],
2216
+ ] as [string, Record<string, unknown>][]) {
2217
+ const res = await tool(base, name, body);
2218
+ expect(res.status, `${name} ${p}`).toBe(400);
2219
+ expect((await res.json()).error, `${name} ${p}`).toContain('outside the knowledge base repository');
2220
+ }
2221
+ }
2222
+ expect(await besideCheckout()).toEqual([]);
2223
+ });
2224
+
2225
+ it('leaves a spill ref alone — it belongs to no workspace', async () => {
2226
+ const base = await start();
2227
+ const res = await tool(base, 'read_file', { path: '__tool_chain_spill__/nope.json' });
2228
+ // Absent, not refused as a path: the ref reached the spill store as written.
2229
+ expect(res.status).not.toBe(400);
2230
+ });
2231
+
2232
+ it('gives that exception to read_file alone — for every other tool the ref is a path', async () => {
2233
+ // `read_file` is the only tool that consumes a spill ref. Anywhere else,
2234
+ // `__tool_chain_spill__/…` is an ordinary string, and leaving it unnormalised
2235
+ // would be a workspace-relative path that never reached the repository —
2236
+ // beside the checkout, which is the whole bug. So it is placed under the
2237
+ // checkout like any other prefix-less path, and names a file that is not there.
2238
+ const base = await start();
2239
+ const ref = '__tool_chain_spill__/nope.json';
2240
+ for (const [name, body] of [
2241
+ ['file_stat', { path: ref }],
2242
+ ['delete_file', { path: ref }],
2243
+ ['copy_file', { src: ref, dest: `${KB_DIR}/copied.md` }],
2244
+ ['list_files', { path: ref }],
2245
+ ] as [string, Record<string, unknown>][]) {
2246
+ const res = await tool(base, name, body);
2247
+ const answer = JSON.stringify(await res.json());
2248
+ // The answer names the path the tool actually looked at, and it is the
2249
+ // one under the checkout — the ref was normalised, not exempted.
2250
+ expect(answer, name).toContain(`${KB_DIR}/__tool_chain_spill__`);
2251
+ expect(res.status, `${name}: ${answer}`).not.toBe(200);
2252
+ }
2253
+ // And nothing was written beside the checkout on the way.
2254
+ expect(await besideCheckout()).toEqual([]);
2255
+ });
2256
+ });
2257
+
2258
+ /**
2259
+ * A folder exists until someone deletes it, and its placeholder is never shown
2260
+ * as content — the agent tools' half (the UI routes' half lives in
2261
+ * `workspace.routes.folders.test.ts`). The filesystem here is a plain
2262
+ * LocalFilesystem, so "committed" is proven the way git sees it: a real
2263
+ * repository in the clone folder, committed and freshly cloned.
2264
+ */
2265
+ describe('folders never vanish', () => {
2266
+ const KB = (p: string) => `${KB_DIR}/${p}`;
2267
+ const tool = async (base: string, name: string, body: Record<string, unknown>) => {
2268
+ const res = await post(`${base}/api/agent/tools/${name}`, body);
2269
+ return { status: res.status, body: (await res.json()) as Record<string, unknown> };
2270
+ };
2271
+ const names = async (base: string, dir: string) =>
2272
+ ((await tool(base, 'list_files', { path: dir })).body.entries as { name: string; type: string }[]).map(
2273
+ (e) => `${e.name}:${e.type}`,
2274
+ );
2275
+ const gitIn = (cwd: string, args: string[]) =>
2276
+ promisify(execFile)('git', args, {
2277
+ cwd,
2278
+ env: {
2279
+ ...process.env,
2280
+ GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@x', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@x',
2281
+ },
2282
+ });
2283
+
2284
+ it('list_files, file_stat and grep never show the placeholder', async () => {
2285
+ const base = await start();
2286
+ await fs.writeFile(KB('Docs/note.md'), 'marker');
2287
+ await fs.writeFile(KB('Docs/.gitkeep'), 'marker');
2288
+ await fs.writeFile(KB('Docs/Empty/.gitkeep'), '');
2289
+
2290
+ expect((await names(base, KB('Docs'))).sort()).toEqual(['Empty:directory', 'note.md:file']);
2291
+ expect(await names(base, KB('Docs/Empty'))).toEqual([]);
2292
+
2293
+ const missing = await tool(base, 'file_stat', { path: KB('Docs/nothing-here.md') });
2294
+ const placeholder = await tool(base, 'file_stat', { path: KB('Docs/.gitkeep') });
2295
+ expect(missing.status).toBe(404);
2296
+ // The placeholder is nothing, and it says so in the one shape every file
2297
+ // tool uses for a path with nothing at it (see not-found.ts).
2298
+ expect(placeholder).toEqual({
2299
+ status: 404,
2300
+ body: {
2301
+ kind: 'not_found',
2302
+ path: KB('Docs/.gitkeep'),
2303
+ error: `There is no file or directory at "${KB('Docs/.gitkeep')}" in this workspace. ${NOT_FOUND_NEXT_STEP}`,
2304
+ },
2305
+ });
2306
+ expect((await tool(base, 'file_stat', { path: KB('Docs/Empty') })).body).toMatchObject({ type: 'directory' });
2307
+
2308
+ const grep = await tool(base, 'grep', { pattern: 'marker' });
2309
+ expect((grep.body.matches as { path: string }[]).map((m) => m.path)).toEqual([KB('Docs/note.md')]);
2310
+ expect((await tool(base, 'grep', { pattern: 'marker', path: KB('Docs/.gitkeep') })).status).toBe(404);
2311
+ });
2312
+
2313
+ it('delete_folder removes its folder but keeps the one that held it, placeholder not counted as content', async () => {
2314
+ const base = await start();
2315
+ await fs.writeFile(KB('Parent/Child/a.md'), 'a');
2316
+ await fs.writeFile(KB('Parent/Child/.gitkeep'), '');
2317
+
2318
+ // The placeholder goes with the folder, but it is not a file the caller
2319
+ // is told about or asked to confirm.
2320
+ const dry = await tool(base, 'delete_folder', { path: KB('Parent/Child'), dryRun: true });
2321
+ expect(dry.body).toMatchObject({ descendants: 1, files: [KB('Parent/Child/a.md')] });
2322
+ expect((await tool(base, 'file_stat', { path: KB('Parent/Child') })).body).toMatchObject({ descendants: 1 });
2323
+
2324
+ const run = await tool(base, 'delete_folder', { path: KB('Parent/Child'), confirm: true });
2325
+ expect(run.body).toMatchObject({ deleted: true });
2326
+ expect(await fs.exists(KB('Parent/Child'))).toBe(false);
2327
+ // `Parent` was not deleted: now empty, it stays, listed as an empty folder.
2328
+ expect(await names(base, KB(''))).toContain('Parent:directory');
2329
+ expect(await names(base, KB('Parent'))).toEqual([]);
2330
+ expect(await fs.exists(KB('Parent/.gitkeep'))).toBe(true);
2331
+ });
2332
+
2333
+ it('a folder holding only its placeholder is empty: deleted without confirm, reporting no files', async () => {
2334
+ const base = await start();
2335
+ await fs.writeFile(KB('Holder/Empty/.gitkeep'), '');
2336
+ await fs.writeFile(KB('Holder/keep.md'), 'k');
2337
+
2338
+ const run = await tool(base, 'delete_folder', { path: KB('Holder/Empty') });
2339
+ expect(run.body).toMatchObject({ deleted: true, descendants: 0, files: [] });
2340
+ expect(await fs.exists(KB('Holder/Empty'))).toBe(false);
2341
+ // `Holder` still has content of its own, so it needs no placeholder.
2342
+ expect(await fs.exists(KB('Holder/.gitkeep'))).toBe(false);
2343
+ });
2344
+
2345
+ it('move_file of a whole folder out keeps the folder it came from', async () => {
2346
+ const base = await start();
2347
+ await fs.writeFile(KB('Src/Inner/x.md'), 'x');
2348
+
2349
+ expect((await tool(base, 'move_file', { src: KB('Src/Inner'), dest: KB('Dst/Inner') })).status).toBe(200);
2350
+ expect(await fs.exists(KB('Dst/Inner/x.md'))).toBe(true);
2351
+ expect(await names(base, KB('Src'))).toEqual([]);
2352
+ expect(await fs.exists(KB('Src/.gitkeep'))).toBe(true);
2353
+ });
2354
+
2355
+ it('delete_file on the last file keeps the folder, and it survives a fresh clone', async () => {
2356
+ const base = await start();
2357
+ const repo = join(tempDir, KB_DIR);
2358
+ await fs.writeFile(KB('nested/level-two/notes.md'), 'x');
2359
+ await gitIn(repo, ['init', '-q', '-b', 'main']);
2360
+ await gitIn(repo, ['add', '-A']);
2361
+ await gitIn(repo, ['commit', '-qm', 'seed']);
2362
+
2363
+ expect((await tool(base, 'delete_file', { path: KB('nested/level-two/notes.md') })).status).toBe(200);
2364
+
2365
+ expect(await names(base, KB('nested'))).toEqual(['level-two:directory']);
2366
+ expect(await names(base, KB('nested/level-two'))).toEqual([]);
2367
+ // What the lock-aware filesystem commits on release, committed here by hand.
2368
+ await gitIn(repo, ['add', '-A']);
2369
+ await gitIn(repo, ['commit', '-qm', 'delete']);
2370
+ await gitIn(tempDir, ['clone', '-q', repo, 'fresh-clone']);
2371
+ // Beside the checkout on purpose — a clone OF it, not content in it — so it
2372
+ // is read from disk rather than through a workspace path.
2373
+ expect((await stat(join(tempDir, 'fresh-clone', 'nested', 'level-two'))).isDirectory()).toBe(true);
2374
+ });
2375
+
2376
+ it('delete_file with siblings left, or at the clone folder itself, writes no placeholder', async () => {
2377
+ const base = await start();
2378
+ await fs.writeFile(KB('full/a.md'), 'a');
2379
+ await fs.writeFile(KB('full/b.md'), 'b');
2380
+ await fs.writeFile(KB('top.md'), 't');
2381
+
2382
+ expect((await tool(base, 'delete_file', { path: KB('full/a.md') })).status).toBe(200);
2383
+ expect((await tool(base, 'delete_file', { path: KB('top.md') })).status).toBe(200);
2384
+ expect((await tool(base, 'delete_file', { path: 'a.md' })).status).toBe(200);
2385
+
2386
+ expect(await names(base, KB('full'))).toEqual(['b.md:file']);
2387
+ await expect(fs.exists(KB('full/.gitkeep'))).resolves.toBe(false);
2388
+ await expect(fs.exists(KB('.gitkeep'))).resolves.toBe(false);
2389
+ await expect(fs.exists('.gitkeep')).resolves.toBe(false);
2390
+ });
2391
+
2392
+ it('delete_file keeps the folder in its folder turn, on the branch it was given', async () => {
2393
+ const base = await start();
2394
+ await fs.writeFile(KB('turned/only.md'), 'x');
2395
+
2396
+ expect((await tool(base, 'delete_file', { path: KB('turned/only.md'), branch: 'alice/draft' })).status).toBe(200);
2397
+ expect(folderTurns).toEqual([`alice%2Fdraft:${KB('turned')}`]);
2398
+ });
2399
+
2400
+ it('delete_file fails when the emptied folder cannot be kept, and says the file is gone', async () => {
2401
+ const base = await start();
2402
+ await fs.writeFile(KB('unkeepable/only.md'), 'x');
2403
+ const write = fs.writeFile.bind(fs);
2404
+ const spy = vi.spyOn(fs, 'writeFile').mockImplementation(async (p, ...rest) => {
2405
+ if (p.endsWith('.gitkeep')) throw new Error('disk full');
2406
+ return write(p, ...rest);
2407
+ });
2408
+
2409
+ let res: Awaited<ReturnType<typeof tool>>;
2410
+ try {
2411
+ res = await tool(base, 'delete_file', { path: KB('unkeepable/only.md') });
2412
+ } finally {
2413
+ spy.mockRestore();
2414
+ }
2415
+ expect(res.status).toBe(500);
2416
+ expect(res.body.error).toBe(
2417
+ `"${KB('unkeepable/only.md')}" was removed, but its folder "${KB('unkeepable')}" could not be kept: disk full`,
2418
+ );
2419
+ await expect(fs.exists(KB('unkeepable/only.md'))).resolves.toBe(false);
2420
+ });
2421
+
2422
+ it('move_file of the last file keeps the source folder', async () => {
2423
+ const base = await start();
2424
+ await fs.writeFile(KB('from/a.md'), 'a');
2425
+
2426
+ expect((await tool(base, 'move_file', { src: KB('from/a.md'), dest: KB('to/a.md') })).status).toBe(200);
2427
+
2428
+ expect((await names(base, KB(''))).sort()).toEqual(['a.md:file', 'from:directory', 'to:directory']);
2429
+ expect(await names(base, KB('from'))).toEqual([]);
2430
+ await expect(fs.exists(KB('from/.gitkeep'))).resolves.toBe(true);
2431
+ });
2432
+
2433
+ it('mkdir, a nested write, and an emptied folder converge on the same state', async () => {
2434
+ const base = await start();
2435
+ await tool(base, 'mkdir', { path: KB('Made') });
2436
+ await tool(base, 'write_file', { path: KB('Written/n.md'), content: 'n' });
2437
+
2438
+ expect((await names(base, KB(''))).sort()).toEqual(['Made:directory', 'Written:directory', 'a.md:file']);
2439
+ expect(await names(base, KB('Made'))).toEqual([]);
2440
+
2441
+ await tool(base, 'delete_file', { path: KB('Written/n.md') });
2442
+ expect((await names(base, KB(''))).sort()).toEqual(['Made:directory', 'Written:directory', 'a.md:file']);
2443
+ expect(await names(base, KB('Written'))).toEqual(await names(base, KB('Made')));
2444
+ expect((await tool(base, 'file_stat', { path: KB('Written') })).body).toMatchObject({ type: 'directory' });
2445
+ });
2446
+ });
2447
+
2448
+ /**
2449
+ * The safety contract an agent gets before a move or delete: `file_stat` says
2450
+ * what an item is and what the caller may do with it, `move_file` and
2451
+ * `delete_folder` answer a dry run with the impact and wait for `confirm`, and
2452
+ * a permission refusal says whether and how to propose instead. The access
2453
+ * double below stands in for the rules, per top-level KB folder:
2454
+ * Sales/ — everything (the default for anything unlisted)
2455
+ * HR/ — read + write, no download, no owner: a move here changes access
2456
+ * Locked/ — read only: writes are refused, proposing is possible
2457
+ * Secret/ — nothing: writes are refused, proposing is not
2458
+ * …/sealed.md — read only, wherever it sits: a denied file inside a writable folder
2459
+ * Sales/outbox/nested/, Sales/moved/nested/ — read only: a denial at only the old, or only the new, path of a moved folder
2460
+ */
2461
+ describe('preflight for moves and deletes', () => {
2462
+ const PROTECTED = 'target-company-state';
2463
+ const KB = (p: string) => `${KB_DIR}/${p}`;
2464
+
2465
+ const verbsFor = (rel: string) => {
2466
+ if (rel.startsWith('Sales/outbox/nested/') || rel.startsWith('Sales/moved/nested/')) {
2467
+ return { read: true, write: false, download: false, owner: false };
2468
+ }
2469
+ if (rel === 'sealed.md' || rel.endsWith('/sealed.md')) return { read: true, write: false, download: false, owner: false };
2470
+ if (rel.startsWith('HR/')) return { read: true, write: true, download: false, owner: false };
2471
+ if (rel.startsWith('Locked/')) return { read: true, write: false, download: false, owner: false };
2472
+ if (rel.startsWith('Secret/')) return { read: false, write: false, download: false, owner: false };
2473
+ return { read: true, write: true, download: true, owner: true };
2474
+ };
2475
+ const rules = {
2476
+ canRead: async (_w: string, _u: string, rel: string) => verbsFor(rel).read,
2477
+ canReadBatch: async (_w: string, _u: string, paths: string[]) => new Map(paths.map((p) => [p, verbsFor(p).read])),
2478
+ canWrite: async (_w: string, _u: string, rel: string) => verbsFor(rel).write,
2479
+ canDownload: async (_w: string, _u: string, rel: string) => verbsFor(rel).download,
2480
+ canOwner: async (_w: string, _u: string, rel: string) => verbsFor(rel).owner,
2481
+ canWriteBatchAtRef: async (_w: string, _r: string, _u: string, rels: string[]) =>
2482
+ new Map(rels.map((p) => [p, verbsFor(p).write])),
2483
+ eligibleWritersAtRef: async () => ({ roles: ['Admin'], users: [] }),
2484
+ } as unknown as IAccessControl;
2485
+
2486
+ const call = async (base: string, tool: string, body: Record<string, unknown>) => {
2487
+ const res = await post(`${base}/api/agent/tools/${tool}`, { branch: PROTECTED, ...body });
2488
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- a tool body is free-form JSON, probed field by field
2489
+ return { status: res.status, body: (await res.json()) as Record<string, any> };
2490
+ };
2491
+ const exists = async (p: string) => fs.exists(p);
2492
+
2493
+ async function seeded(): Promise<string> {
2494
+ const base = await start('write', rules);
2495
+ await fs.writeFile(KB('access.md'), '---\nread: everyone\n---\n');
2496
+ await fs.writeFile(KB('KnowledgeBase/Team/a.md'), 'a');
2497
+ await fs.writeFile(KB('Sales/deal.md'), 'deal');
2498
+ await fs.writeFile(KB('Sales/archive/nested/old.md'), 'old');
2499
+ await fs.writeFile(KB('Sales/archive/nested/older.md'), 'older');
2500
+ await fs.writeFile(KB('Sales/archive/top.md'), 'top');
2501
+ await fs.writeFile(KB('HR/policy.md'), 'policy');
2502
+ await fs.writeFile(KB('Locked/rules.md'), 'rules');
2503
+ return base;
2504
+ }
2505
+
2506
+ describe('file_stat', () => {
2507
+ it('a platform file is managed, not movable, not deletable', async () => {
2508
+ const base = await seeded();
2509
+ const { status, body } = await call(base, 'file_stat', { path: KB('access.md') });
2510
+ expect(status).toBe(200);
2511
+ expect(body).toMatchObject({ type: 'file', managed: true, movable: false, deletable: false });
2512
+ expect(body.access).toEqual({ read: true, write: true, download: true, owner: true });
2513
+ expect(body.descendants).toBeUndefined();
2514
+ });
2515
+
2516
+ it('a plain file is movable and deletable, with the caller\'s verdicts', async () => {
2517
+ const base = await seeded();
2518
+ expect((await call(base, 'file_stat', { path: KB('Sales/deal.md') })).body).toMatchObject({
2519
+ type: 'file', managed: false, movable: true, deletable: true,
2520
+ access: { read: true, write: true, download: true, owner: true },
2521
+ });
2522
+ expect((await call(base, 'file_stat', { path: KB('Locked/rules.md') })).body).toMatchObject({
2523
+ managed: false, movable: false, deletable: false,
2524
+ access: { read: true, write: false, download: false, owner: false },
2525
+ });
2526
+ });
2527
+
2528
+ it('a folder counts its files at any depth; a reserved root folder is managed', async () => {
2529
+ const base = await seeded();
2530
+ expect((await call(base, 'file_stat', { path: KB('Sales/archive') })).body).toMatchObject({
2531
+ type: 'directory', managed: false, movable: true, deletable: true, descendants: 3,
2532
+ });
2533
+ expect((await call(base, 'file_stat', { path: KB('KnowledgeBase') })).body).toMatchObject({
2534
+ type: 'directory', managed: true, movable: false, deletable: false, descendants: 1,
2535
+ });
2536
+ });
2537
+ });
2538
+
2539
+ describe('move_file', () => {
2540
+ it('a same-access move: the dry run changes nothing, the real call runs without confirm', async () => {
2541
+ const base = await seeded();
2542
+ const args = { src: KB('Sales/deal.md'), dest: KB('Sales/2026/deal.md') };
2543
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
2544
+ expect(dry.status).toBe(200);
2545
+ expect(dry.body).toMatchObject({
2546
+ ...args, kind: 'file', descendants: 1, accessChanges: false, allowed: true, dryRun: true, moved: false,
2547
+ });
2548
+ expect(dry.body.access.before).toEqual(dry.body.access.after);
2549
+ expect(dry.body.reason).toBeUndefined();
2550
+ expect(await exists(args.src)).toBe(true);
2551
+ expect(await exists(args.dest)).toBe(false);
2552
+
2553
+ const run = await call(base, 'move_file', args);
2554
+ expect(run.body).toMatchObject({ moved: true });
2555
+ expect(await exists(args.dest)).toBe(true);
2556
+ expect(await exists(args.src)).toBe(false);
2557
+ });
2558
+
2559
+ it('an access-changing move shows before/after and runs only with confirm: true', async () => {
2560
+ const base = await seeded();
2561
+ const args = { src: KB('Sales/deal.md'), dest: KB('HR/deal.md') };
2562
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
2563
+ expect(dry.body).toMatchObject({
2564
+ accessChanges: true,
2565
+ allowed: true,
2566
+ access: {
2567
+ before: { read: true, write: true, download: true, owner: true },
2568
+ after: { read: true, write: true, download: false, owner: false },
2569
+ },
2570
+ });
2571
+
2572
+ const unconfirmed = await call(base, 'move_file', args);
2573
+ expect(unconfirmed.status).toBe(200);
2574
+ expect(unconfirmed.body).toMatchObject({ accessChanges: true, confirmationRequired: true, moved: false });
2575
+ expect(unconfirmed.body.message).toContain('confirm: true');
2576
+ expect(await exists(args.src)).toBe(true);
2577
+ expect(await exists(args.dest)).toBe(false);
2578
+
2579
+ const confirmed = await call(base, 'move_file', { ...args, confirm: true });
2580
+ expect(confirmed.body).toMatchObject({ moved: true });
2581
+ expect(await exists(args.dest)).toBe(true);
2582
+ });
2583
+
2584
+ it('a folder moves recursively', async () => {
2585
+ const base = await seeded();
2586
+ const args = { src: KB('Sales/archive'), dest: KB('Sales/old-archive') };
2587
+ expect((await call(base, 'move_file', { ...args, dryRun: true })).body).toMatchObject({ kind: 'folder', descendants: 3 });
2588
+ expect((await call(base, 'move_file', args)).body).toMatchObject({ moved: true });
2589
+ expect(await exists(KB('Sales/old-archive/nested/old.md'))).toBe(true);
2590
+ expect(await exists(args.src)).toBe(false);
2591
+ });
2592
+
2593
+ /**
2594
+ * "A file named rules.md already exists in Locked." is a fact about a
2595
+ * folder. Answering it before the write verdict turned these tools into an
2596
+ * existence oracle: a caller who may not write `Locked/` — and on a
2597
+ * protected branch that is most callers — could ask for a name and read
2598
+ * off whether it is taken. The two calls below differ ONLY in whether the
2599
+ * destination exists, and must be indistinguishable.
2600
+ */
2601
+ it('a destination the caller may not write answers the same whether the name is taken or free', async () => {
2602
+ const base = await seeded();
2603
+ const taken = { src: KB('Sales/deal.md'), dest: KB('Locked/rules.md') };
2604
+ const free = { src: KB('Sales/deal.md'), dest: KB('Locked/free.md') };
2605
+
2606
+ const ontoTaken = await call(base, 'move_file', taken);
2607
+ const ontoFree = await call(base, 'move_file', free);
2608
+ expect(ontoTaken.status).toBe(403);
2609
+ expect(ontoTaken.status).toBe(ontoFree.status);
2610
+ expect(ontoTaken.body.kind).toBe('write-denied');
2611
+ expect(ontoTaken.body.error).not.toContain('already exists');
2612
+ // Same shape, same words — only the path each names differs.
2613
+ expect(ontoTaken.body.error.replace('rules.md', 'free.md')).toBe(ontoFree.body.error);
2614
+
2615
+ // The dry run is the easier oracle to reach, and says the same.
2616
+ const dryTaken = await call(base, 'move_file', { ...taken, dryRun: true });
2617
+ const dryFree = await call(base, 'move_file', { ...free, dryRun: true });
2618
+ expect(dryTaken.body).toMatchObject({ allowed: false });
2619
+ expect(dryTaken.body.reason).not.toContain('already exists');
2620
+ expect(dryTaken.body.reason.replace('rules.md', 'free.md')).toBe(dryFree.body.reason);
2621
+
2622
+ // And nothing was moved onto the name that was taken.
2623
+ expect(await fs.readFile(KB('Locked/rules.md'), { encoding: 'utf-8' })).toBe('rules');
2624
+ expect(await exists(KB('Sales/deal.md'))).toBe(true);
2625
+ });
2626
+
2627
+ it('copy_file keeps the same order: the write refusal, not what is in the folder', async () => {
2628
+ const base = await seeded();
2629
+
2630
+ const ontoTaken = await call(base, 'copy_file', { src: KB('Sales/deal.md'), dest: KB('Locked/rules.md') });
2631
+ const ontoFree = await call(base, 'copy_file', { src: KB('Sales/deal.md'), dest: KB('Locked/free.md') });
2632
+ expect(ontoTaken.status).toBe(403);
2633
+ expect(ontoTaken.status).toBe(ontoFree.status);
2634
+ expect(ontoTaken.body.kind).toBe('write-denied');
2635
+ expect(ontoTaken.body.error).not.toContain('already exists');
2636
+ expect(ontoTaken.body.error.replace('rules.md', 'free.md')).toBe(ontoFree.body.error);
2637
+
2638
+ expect(await fs.readFile(KB('Locked/rules.md'), { encoding: 'utf-8' })).toBe('rules');
2639
+ expect(await exists(KB('Locked/free.md'))).toBe(false);
2640
+ });
2641
+
2642
+ it('a move the caller may not write: the dry run says so, the real call is a write-denied with proposal steps', async () => {
2643
+ const base = await seeded();
2644
+ const args = { src: KB('Sales/deal.md'), dest: KB('Locked/deal.md') };
2645
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
2646
+ expect(dry.body).toMatchObject({ allowed: false });
2647
+ expect(dry.body.reason).toContain(KB('Locked/deal.md'));
2648
+
2649
+ const run = await call(base, 'move_file', args);
2650
+ expect(run.status).toBe(403);
2651
+ expect(run.body).toMatchObject({ kind: 'write-denied', path: KB('Locked/deal.md'), canPropose: true });
2652
+ expect(run.body.reason).toContain('Eligible: Admin');
2653
+ // `reason` is what the refusal said; `error` is the sentence the agent
2654
+ // reads, which carries it plus the invitation to propose.
2655
+ expect(run.body.error).toContain('Eligible: Admin');
2656
+ expect(run.body.error).toContain('You may propose this change instead');
2657
+ expect(run.body.proposal.targetBranch).toBe(PROTECTED);
2658
+ expect(run.body.proposal.steps.map((s: { tool: string }) => s.tool)).toEqual([
2659
+ 'create_branch',
2660
+ 'move_file',
2661
+ 'open_change_request',
2662
+ ]);
2663
+ expect(await exists(args.src)).toBe(true);
2664
+ });
2665
+
2666
+ it('a folder move judges every file under it: one denied file blocks the move, in the dry run and for real', async () => {
2667
+ const base = await seeded();
2668
+ await fs.writeFile(KB('Sales/archive/nested/sealed.md'), 'sealed');
2669
+ const args = { src: KB('Sales/archive'), dest: KB('Sales/archive-2026') };
2670
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
2671
+ expect(dry.body).toMatchObject({ kind: 'folder', descendants: 4, accessChanges: false, allowed: false });
2672
+ expect(dry.body.reason).toContain('sealed.md');
2673
+ const run = await call(base, 'move_file', { ...args, confirm: true });
2674
+ expect(run.status).toBe(403);
2675
+ expect(run.body).toMatchObject({ kind: 'write-denied', path: KB('Sales/archive/nested/sealed.md'), canPropose: true });
2676
+ expect(await exists(KB('Sales/archive/nested/sealed.md'))).toBe(true);
2677
+ expect(await exists(args.dest)).toBe(false);
2678
+ });
2679
+
2680
+ it('file_stat on a folder answers exactly what the move and delete dry runs answer, a denied file inside included', async () => {
2681
+ const base = await seeded();
2682
+ const path = KB('Sales/archive');
2683
+ const verdicts = async () => ({
2684
+ stat: (await call(base, 'file_stat', { path })).body,
2685
+ move: (await call(base, 'move_file', { src: path, dest: KB('Sales/archive-2026'), dryRun: true })).body,
2686
+ del: (await call(base, 'delete_folder', { path, dryRun: true })).body,
2687
+ });
2688
+ // All writable: every answer says yes.
2689
+ let v = await verdicts();
2690
+ expect([v.stat.movable, v.move.allowed, v.stat.deletable, v.del.allowed]).toEqual([true, true, true, true]);
2691
+ // One file its own rules deny, deep inside the writable folder: every answer says no.
2692
+ await fs.writeFile(KB('Sales/archive/nested/sealed.md'), 'sealed');
2693
+ v = await verdicts();
2694
+ expect(v.stat).toMatchObject({ access: { write: true }, descendants: 4 });
2695
+ expect(v.stat.movable).toBe(v.move.allowed);
2696
+ expect(v.stat.deletable).toBe(v.del.allowed);
2697
+ expect([v.stat.movable, v.stat.deletable]).toEqual([false, false]);
2698
+ });
2699
+
2700
+ it('a folder move judges each file at its old path and at its new path, separately', async () => {
2701
+ const base = await seeded();
2702
+ // Denied only at the old path: the file under Sales/outbox/nested/ may not be removed.
2703
+ await fs.writeFile(KB('Sales/outbox/nested/letter.md'), 'letter');
2704
+ const oldSide = await call(base, 'move_file', { src: KB('Sales/outbox'), dest: KB('Sales/sent'), dryRun: true });
2705
+ expect(oldSide.body).toMatchObject({ allowed: false });
2706
+ expect(oldSide.body.reason).toContain(KB('Sales/outbox/nested/letter.md'));
2707
+ // Denied only at the new path: Sales/archive is writable, Sales/moved/nested/ is not.
2708
+ const newSide = await call(base, 'move_file', { src: KB('Sales/archive'), dest: KB('Sales/moved'), dryRun: true });
2709
+ expect(newSide.body).toMatchObject({ allowed: false });
2710
+ expect(newSide.body.reason).toContain(KB('Sales/moved/nested/'));
2711
+ const run = await call(base, 'move_file', { src: KB('Sales/archive'), dest: KB('Sales/moved'), confirm: true });
2712
+ expect(run.status).toBe(403);
2713
+ expect(run.body).toMatchObject({ kind: 'write-denied' });
2714
+ expect(await exists(KB('Sales/archive/nested/old.md'))).toBe(true);
2715
+ });
2716
+
2717
+ it('trailing slashes do not change which paths a folder move is judged on', async () => {
2718
+ const base = await seeded();
2719
+ const args = { src: `${KB('Sales/archive')}/`, dest: `${KB('Sales/moved')}/` };
2720
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
2721
+ expect(dry.body).toMatchObject({ src: KB('Sales/archive'), dest: KB('Sales/moved'), descendants: 3, allowed: false });
2722
+ expect(dry.body.reason).toContain(KB('Sales/moved/nested/'));
2723
+ expect((await call(base, 'move_file', { ...args, confirm: true })).status).toBe(403);
2724
+ expect(await exists(KB('Sales/archive/nested/old.md'))).toBe(true);
2725
+ expect(await exists(KB('Sales/moved'))).toBe(false);
2726
+ });
2727
+
2728
+ it('a denied move into a path the caller cannot read cannot be proposed, and says why', async () => {
2729
+ const base = await seeded();
2730
+ const run = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Secret/deal.md'), confirm: true });
2731
+ expect(run.status).toBe(403);
2732
+ expect(run.body).toMatchObject({ kind: 'write-denied', canPropose: false });
2733
+ expect(run.body.proposal).toBeUndefined();
2734
+ expect(run.body.cannotProposeReason).toContain('you cannot read this path');
2735
+ });
2736
+
2737
+ it('a draft branch is not write-gated, so the same move is allowed there', async () => {
2738
+ const base = await seeded();
2739
+ const dry = await call(base, 'move_file', { branch: 'someone/draft', src: KB('Sales/deal.md'), dest: KB('Locked/deal.md'), dryRun: true });
2740
+ expect(dry.body).toMatchObject({ allowed: true, accessChanges: true });
2741
+ });
2742
+
2743
+ it('a platform file is refused with the platform-file sentence, in the dry run and for real', async () => {
2744
+ const base = await seeded();
2745
+ const args = { src: KB('access.md'), dest: KB('Sales/access.md') };
2746
+ const sentence = 'access.md is a platform file and stays in its folder.';
2747
+ expect((await call(base, 'move_file', { ...args, dryRun: true })).body).toMatchObject({ allowed: false, reason: sentence });
2748
+ const run = await call(base, 'move_file', { ...args, confirm: true });
2749
+ expect(run.status).toBe(400);
2750
+ expect(run.body.error).toBe(sentence);
2751
+ expect(await exists(args.src)).toBe(true);
2752
+ });
2753
+
2754
+ it('every one of the four platform files gets the same sentence, and the agent never gets the admin restore', async () => {
2755
+ // The agent move tool has no exception: the recovery move is a person's,
2756
+ // made as an admin, and an agent is neither.
2757
+ const base = await seeded();
2758
+ await fs.writeFile(KB('roles.yaml'), 'roles: {}\n');
2759
+ await fs.writeFile(KB('AGENTS.md'), 'agents\n');
2760
+ await fs.writeFile(KB('.bevelignore'), '*.tmp\n');
2761
+ await fs.writeFile(KB('Misplaced/access.md'), '---\nread: everyone\n---\n');
2762
+ const cases: [string, string][] = [
2763
+ [KB('access.md'), KB('Sales/access.md')],
2764
+ [KB('roles.yaml'), KB('Sales/roles.yaml')],
2765
+ [KB('.bevelignore'), KB('Sales/.bevelignore')],
2766
+ [KB('AGENTS.md'), KB('Sales/AGENTS.md')],
2767
+ // Including the shape of the admin's recovery move: a misplaced
2768
+ // access.md into a folder that has none. A person holding the Admin
2769
+ // role is allowed exactly this move from the UI; the agent is not.
2770
+ [KB('Misplaced/access.md'), KB('HR/access.md')],
2771
+ ];
2772
+ for (const [src, dest] of cases) {
2773
+ const name = src.slice(src.lastIndexOf('/') + 1);
2774
+ const sentence = `${name} is a platform file and stays in its folder.`;
2775
+ expect((await call(base, 'move_file', { src, dest, dryRun: true })).body)
2776
+ .toMatchObject({ allowed: false, reason: sentence });
2777
+ const run = await call(base, 'move_file', { src, dest, confirm: true });
2778
+ expect(run.status).toBe(400);
2779
+ expect(run.body.error).toBe(sentence);
2780
+ expect(await exists(src)).toBe(true);
2781
+ }
2782
+ });
2783
+
2784
+ it('an existing destination is refused, never overwritten', async () => {
2785
+ const base = await seeded();
2786
+ const args = { src: KB('Sales/deal.md'), dest: KB('Sales/archive/top.md') };
2787
+ expect((await call(base, 'move_file', { ...args, dryRun: true })).body).toMatchObject({ allowed: false });
2788
+ expect((await call(base, 'move_file', args)).status).toBe(409);
2789
+ expect(await fs.readFile(args.dest, { encoding: 'utf-8' })).toBe('top');
2790
+ });
2791
+ });
2792
+
2793
+ describe('delete_folder', () => {
2794
+ it('the dry run lists the files and deletes nothing; without confirm nothing is deleted; with confirm the folder is gone', async () => {
2795
+ const base = await seeded();
2796
+ const path = KB('Sales/archive');
2797
+ const dry = await call(base, 'delete_folder', { path, dryRun: true });
2798
+ expect(dry.status).toBe(200);
2799
+ expect(dry.body).toMatchObject({ path, kind: 'folder', descendants: 3, allowed: true, dryRun: true, filesTruncated: false });
2800
+ expect([...dry.body.files].sort()).toEqual([
2801
+ KB('Sales/archive/nested/old.md'), KB('Sales/archive/nested/older.md'), KB('Sales/archive/top.md'),
2802
+ ]);
2803
+ expect(await exists(KB('Sales/archive/top.md'))).toBe(true);
2804
+
2805
+ const unconfirmed = await call(base, 'delete_folder', { path });
2806
+ expect(unconfirmed.body).toMatchObject({ confirmationRequired: true, deleted: false });
2807
+ expect(unconfirmed.body.message).toContain('confirm: true');
2808
+ expect(await exists(KB('Sales/archive/nested/old.md'))).toBe(true);
2809
+
2810
+ const confirmed = await call(base, 'delete_folder', { path, confirm: true });
2811
+ expect(confirmed.body).toMatchObject({ deleted: true, descendants: 3 });
2812
+ expect(await exists(path)).toBe(false);
2813
+ const listing = await call(base, 'list_files', { path: KB('Sales') });
2814
+ expect(listing.body.entries.map((e: { name: string }) => e.name)).toEqual(['deal.md']);
2815
+ });
2816
+
2817
+ it('a platform folder is refused with its reason', async () => {
2818
+ const base = await seeded();
2819
+ const dry = await call(base, 'delete_folder', { path: KB('KnowledgeBase'), dryRun: true });
2820
+ expect(dry.body).toMatchObject({ allowed: false, reason: 'KnowledgeBase/ is a platform folder and cannot be moved or deleted.' });
2821
+ const run = await call(base, 'delete_folder', { path: KB('KnowledgeBase'), confirm: true });
2822
+ expect(run.status).toBe(400);
2823
+ expect(await exists(KB('KnowledgeBase/Team/a.md'))).toBe(true);
2824
+ });
2825
+
2826
+ it('a folder the caller cannot write is refused with the structured denial', async () => {
2827
+ const base = await seeded();
2828
+ expect((await call(base, 'delete_folder', { path: KB('Locked'), dryRun: true })).body).toMatchObject({ allowed: false });
2829
+ const run = await call(base, 'delete_folder', { path: KB('Locked'), confirm: true });
2830
+ expect(run.status).toBe(403);
2831
+ expect(run.body).toMatchObject({ kind: 'write-denied', canPropose: true });
2832
+ expect(await exists(KB('Locked/rules.md'))).toBe(true);
2833
+ });
2834
+
2835
+ it('an empty folder is deleted without confirm; a file is pointed to delete_file', async () => {
2836
+ const base = await seeded();
2837
+ await fs.mkdir(KB('Sales/empty'), { recursive: true });
2838
+ expect((await call(base, 'delete_folder', { path: KB('Sales/empty') })).body).toMatchObject({ deleted: true, descendants: 0 });
2839
+ expect(await exists(KB('Sales/empty'))).toBe(false);
2840
+ const file = await call(base, 'delete_folder', { path: KB('Sales/deal.md') });
2841
+ expect(file.status).toBe(400);
2842
+ expect(file.body.error).toContain('delete_file');
2843
+ });
2844
+ });
2845
+
2846
+ describe('delete_file', () => {
2847
+ it('on a folder answers with delete_folder instead of the filesystem error', async () => {
2848
+ const base = await seeded();
2849
+ const run = await call(base, 'delete_file', { path: KB('Sales/archive') });
2850
+ expect(run.status).toBe(400);
2851
+ expect(run.body.error).toContain('use delete_folder');
2852
+ expect(await exists(KB('Sales/archive/top.md'))).toBe(true);
2853
+ });
2854
+
2855
+ it('a denied delete is the structured denial', async () => {
2856
+ const base = await seeded();
2857
+ const run = await call(base, 'delete_file', { path: KB('Locked/rules.md') });
2858
+ expect(run.status).toBe(403);
2859
+ expect(run.body).toMatchObject({ kind: 'write-denied', path: KB('Locked/rules.md'), canPropose: true });
2860
+ expect(run.body.proposal.steps.map((s: { tool: string }) => s.tool)).toContain('delete_file');
2861
+ });
2862
+ it('a refusal from the lock gate itself (rules changed after the preflight) is the structured denial too', async () => {
2863
+ const base = await seeded();
2864
+ fs.deleteFile = async (p: string) => {
2865
+ throw new AccessDeniedError({ path: p, eligibleRoles: ['Admin'], eligibleUsers: [] });
2866
+ };
2867
+ const run = await call(base, 'delete_file', { path: KB('Sales/deal.md') });
2868
+ expect(run.status).toBe(403);
2869
+ expect(run.body).toMatchObject({ kind: 'write-denied', path: KB('Sales/deal.md'), canPropose: true });
2870
+ });
2871
+ });
2872
+
2873
+ describe('what counts as the platform\'s own, and what a move or delete may reach', () => {
2874
+ const caseSensitiveDisk = process.platform === 'linux';
2875
+
2876
+ it('roles.yaml and AGENTS.md are platform files only at the root; access.md and .bevelignore at any depth', async () => {
2877
+ const base = await seeded();
2878
+ await fs.writeFile(KB('roles.yaml'), 'roles: {}\n');
2879
+ await fs.writeFile(KB('Sales/roles.yaml'), 'content');
2880
+ await fs.writeFile(KB('Sales/AGENTS.md'), 'content');
2881
+ await fs.writeFile(KB('Sales/.bevelignore'), '*.tmp\n');
2882
+ const managed = async (p: string) => (await call(base, 'file_stat', { path: KB(p) })).body.managed;
2883
+ expect(await managed('roles.yaml')).toBe(true);
2884
+ expect(await managed('Sales/roles.yaml')).toBe(false);
2885
+ expect(await managed('Sales/AGENTS.md')).toBe(false);
2886
+ expect(await managed('Sales/.bevelignore')).toBe(true);
2887
+ expect((await call(base, 'move_file', { src: KB('Sales/roles.yaml'), dest: KB('Sales/old-roles.yaml') })).body).toMatchObject({ moved: true });
2888
+ });
2889
+
2890
+ it.skipIf(!caseSensitiveDisk)('a differently cased access.md is content on a case-sensitive disk', async () => {
2891
+ const base = await seeded();
2892
+ await fs.writeFile(KB('Sales/Access.md'), 'notes');
2893
+ expect((await call(base, 'file_stat', { path: KB('Sales/Access.md') })).body).toMatchObject({ managed: false, movable: true });
2894
+ });
2895
+
2896
+ // The git folder is not merely "managed": it is not reachable at all, so
2897
+ // every one of these is the one sanitized refusal (`shared/git-internals.ts`)
2898
+ // rather than this preflight's own "git metadata" answer — a dry run
2899
+ // included, since even describing what is in there is not on offer.
2900
+ it('git metadata is refused, and a folder walk never enters it', async () => {
2901
+ const base = await seeded();
2902
+ await fs.writeFile(KB('.git/HEAD'), 'ref: refs/heads/main\n');
2903
+ await fs.writeFile(KB('Sales/sub/.git/HEAD'), 'ref: refs/heads/main\n');
2904
+ for (const call_ of [
2905
+ { tool: 'delete_folder', args: { path: KB('.git'), dryRun: true } },
2906
+ { tool: 'delete_folder', args: { path: KB('.git'), confirm: true } },
2907
+ { tool: 'delete_file', args: { path: KB('.git/HEAD') } },
2908
+ { tool: 'move_file', args: { src: KB('.git'), dest: KB('Sales/git') } },
2909
+ ]) {
2910
+ const res = await call(base, call_.tool, call_.args);
2911
+ expect({ tool: call_.tool, status: res.status, error: res.body.error }).toEqual({
2912
+ tool: call_.tool,
2913
+ status: 403,
2914
+ error: GIT_INTERNALS_MESSAGE,
2915
+ });
2916
+ }
2917
+ expect(await exists(KB('.git/HEAD'))).toBe(true);
2918
+ expect((await call(base, 'file_stat', { path: KB('Sales/sub') })).body).toMatchObject({ descendants: 0 });
2919
+ });
2920
+
2921
+ it('a folder\'s own access.md goes with it, in the same single change as every other file', async () => {
2922
+ const base = await seeded();
2923
+ await fs.writeFile(KB('Sales/archive/access.md'), '---\nread: everyone\n---\n');
2924
+ // What the TOOL decides is the shape it hands the filesystem: ONE batch
2925
+ // carrying every file, the folder's own access.md among them. That the
2926
+ // batch then lands all-or-nothing is the batch's own property, asserted
2927
+ // in locking-filesystem.test.ts; the harness stand-in here only applies
2928
+ // the deletes.
2929
+ const batches: string[][] = [];
2930
+ const fsAny = fs as unknown as Record<string, unknown>;
2931
+ const writeFiles = fsAny.writeFiles as (w: unknown[], s: string, d: string[]) => Promise<void>;
2932
+ fsAny.writeFiles = async (w: unknown[], s: string, d: string[] = []) => {
2933
+ batches.push([...d]);
2934
+ return writeFiles(w, s, d);
2935
+ };
2936
+
2937
+ const run = await call(base, 'delete_folder', { path: KB('Sales/archive'), confirm: true });
2938
+
2939
+ expect(run.body).toMatchObject({ deleted: true, descendants: 4 });
2940
+ expect(batches).toHaveLength(1);
2941
+ expect([...batches[0]].sort()).toEqual([
2942
+ KB('Sales/archive/access.md'),
2943
+ KB('Sales/archive/nested/old.md'),
2944
+ KB('Sales/archive/nested/older.md'),
2945
+ KB('Sales/archive/top.md'),
2946
+ ]);
2947
+ expect(await exists(KB('Sales/archive/access.md'))).toBe(false);
2948
+ expect(await exists(KB('Sales/archive'))).toBe(false);
2949
+ });
2950
+
2951
+ it('a restricted run is judged on the files a folder delete or move would write, not the folder path', async () => {
2952
+ const base = await seeded();
2953
+ writePolicy.restrictToExtensions('restricted-run', ['.html']);
2954
+ await fs.writeFile(KB('Sales/views/a.html'), '<p>a</p>');
2955
+ await fs.writeFile(KB('Sales/views/b.html'), '<p>b</p>');
2956
+ const moved = await call(base, 'move_file', { src: KB('Sales/views'), dest: KB('Sales/dashboards'), sessionId: 'restricted-run' });
2957
+ expect(moved.body).toMatchObject({ moved: true, descendants: 2 });
2958
+ const deleted = await call(base, 'delete_folder', { path: KB('Sales/dashboards'), confirm: true, sessionId: 'restricted-run' });
2959
+ expect(deleted.body).toMatchObject({ deleted: true });
2960
+ const refused = await call(base, 'delete_folder', { path: KB('Sales/archive'), confirm: true, sessionId: 'restricted-run' });
2961
+ expect(refused.status).toBe(403);
2962
+ expect(await exists(KB('Sales/archive/top.md'))).toBe(true);
2963
+ });
2964
+
2965
+ it.skipIf(process.platform === 'win32')('a move or folder delete through a symbolic link that leaves the repository is refused, a dangling one included', async () => {
2966
+ const base = await seeded();
2967
+ await mkdir(join(tempDir, 'beside-the-clone'), { recursive: true });
2968
+ await symlink(join(tempDir, 'beside-the-clone'), join(tempDir, KB('Sales/out')));
2969
+ const escaped = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/out/deal.md'), dryRun: true });
2970
+ expect(escaped.status).toBe(400);
2971
+ expect(escaped.body.error).toContain('symbolic link');
2972
+ expect((await call(base, 'delete_folder', { path: KB('Sales/out'), dryRun: true })).status).toBe(400);
2973
+ await symlink(join(tempDir, 'nowhere'), join(tempDir, KB('Sales/dangling.md')));
2974
+ const collided = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/dangling.md') });
2975
+ expect(collided.status).toBe(400);
2976
+ expect(collided.body.error).toContain('symbolic link');
2977
+ expect(await exists(KB('Sales/deal.md'))).toBe(true);
2978
+ });
2979
+
2980
+ it('a path with "." or ".." segments is refused by every move and delete, before anything changes', async () => {
2981
+ const base = await seeded();
2982
+ const sneaky = KB('Sales/../Locked/rules.md');
2983
+ const del = await call(base, 'delete_file', { path: sneaky });
2984
+ expect(del.status).toBe(400);
2985
+ expect(del.body.error).toContain('".." segments');
2986
+ expect((await call(base, 'move_file', { src: sneaky, dest: KB('Sales/rules.md'), dryRun: true })).status).toBe(400);
2987
+ expect((await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/../Locked/deal.md') })).status).toBe(400);
2988
+ expect((await call(base, 'delete_folder', { path: KB('Sales/../Locked'), confirm: true })).status).toBe(400);
2989
+ // `file_stat` used to answer with a body saying the path was neither
2990
+ // movable nor deletable. The normaliser refuses the spelling before the
2991
+ // tool runs at all now, which is the stronger answer: nothing is judged
2992
+ // under a path that names one folder and opens another.
2993
+ const stat = await call(base, 'file_stat', { path: KB('Sales/./deal.md') });
2994
+ expect(stat.status).toBe(400);
2995
+ expect(stat.body.error).toContain('"." or ".." segments');
2996
+ expect(await exists(KB('Locked/rules.md'))).toBe(true);
2997
+ expect(await exists(KB('Sales/deal.md'))).toBe(true);
2998
+ });
2999
+
3000
+ it.skipIf(process.platform === 'win32')('file_stat agrees with move and delete about links on the way, a dangling one included', async () => {
3001
+ const base = await seeded();
3002
+ await symlink(join(tempDir, KB('Locked')), join(tempDir, KB('Sales/alias')));
3003
+ expect((await call(base, 'file_stat', { path: KB('Sales/alias/rules.md') })).body).toMatchObject({ movable: false, deletable: false });
3004
+ await symlink(join(tempDir, 'nowhere'), join(tempDir, KB('Sales/gone')));
3005
+ const dangling = await call(base, 'file_stat', { path: KB('Sales/gone/x.md') });
3006
+ expect(dangling.status).toBe(400);
3007
+ expect(dangling.body.error).toContain(`the symbolic link "${KB('Sales/gone')}"`);
3008
+ });
3009
+
3010
+ it.skipIf(process.platform === 'win32')('a link past the descendants cap still makes a folder not deletable', async () => {
3011
+ const base = await seeded();
3012
+ const dir = join(tempDir, KB('Sales/bulk'));
3013
+ await mkdir(join(dir, 'a'), { recursive: true });
3014
+ const names = Array.from({ length: 10_001 }, (_, i) => `f${i}.md`);
3015
+ for (let i = 0; i < names.length; i += 500) {
3016
+ await Promise.all(names.slice(i, i + 500).map((n) => writeFile(join(dir, 'a', n), '')));
3017
+ }
3018
+ await mkdir(join(dir, 'z'), { recursive: true });
3019
+ await symlink(join(tempDir, 'nowhere'), join(dir, 'z', 'link'));
3020
+ const stat = await call(base, 'file_stat', { path: KB('Sales/bulk') });
3021
+ expect(stat.body).toMatchObject({ descendants: 10_000, descendantsTruncated: true, deletable: false });
3022
+ expect((await call(base, 'delete_folder', { path: KB('Sales/bulk'), dryRun: true })).body).toMatchObject({ allowed: false });
3023
+ }, 60_000);
3024
+
3025
+ it.skipIf(process.platform === 'win32')('a link inside the repository is not followed either, so a move cannot write into a folder whose rules it never judged', async () => {
3026
+ const base = await seeded();
3027
+ await symlink(join(tempDir, KB('Locked')), join(tempDir, KB('Sales/alias')));
3028
+ const run = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/alias/deal.md'), dryRun: true });
3029
+ expect(run.status).toBe(400);
3030
+ expect(run.body.error).toContain(`the symbolic link "${KB('Sales/alias')}"`);
3031
+ expect((await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/alias/deal.md') })).status).toBe(400);
3032
+ expect((await call(base, 'delete_file', { path: KB('Sales/alias/rules.md') })).status).toBe(400);
3033
+ expect(await exists(KB('Locked/rules.md'))).toBe(true);
3034
+ expect(await exists(KB('Locked/deal.md'))).toBe(false);
3035
+ });
3036
+
3037
+ it.skipIf(process.platform === 'win32')('a folder holding a symbolic link is refused in the dry run and deletes nothing, instead of half-deleting', async () => {
3038
+ const base = await seeded();
3039
+ // The Local Testing shape: a file sorted before the link, the link (to a
3040
+ // folder, so the per-file delete cannot remove it), a file after it.
3041
+ await fs.writeFile(KB('Sales/links/aaa.md'), 'a');
3042
+ await fs.writeFile(KB('Sales/links/real.md'), 'r');
3043
+ await symlink('../archive', join(tempDir, KB('Sales/links/inside')));
3044
+ const dry = await call(base, 'delete_folder', { path: KB('Sales/links'), dryRun: true });
3045
+ expect(dry.status).toBe(200);
3046
+ expect(dry.body).toMatchObject({ allowed: false });
3047
+ expect(dry.body.reason).toContain(`"${KB('Sales/links/inside')}"`);
3048
+ const run = await call(base, 'delete_folder', { path: KB('Sales/links'), confirm: true });
3049
+ expect(run.status).toBe(400);
3050
+ expect(run.body.error).toBe(dry.body.reason);
3051
+ for (const p of ['Sales/links/aaa.md', 'Sales/links/real.md', 'Sales/archive/top.md']) {
3052
+ expect(await exists(KB(p)), p).toBe(true);
3053
+ }
3054
+ expect((await call(base, 'file_stat', { path: KB('Sales/links') })).body).toMatchObject({ deletable: false });
3055
+ });
3056
+
3057
+ it.skipIf(process.platform === 'win32')('a link itself is answered as a link by delete_file, move_file and file_stat, not "not found"', async () => {
3058
+ const base = await seeded();
3059
+ await symlink(join(tempDir, 'nowhere'), join(tempDir, KB('Sales/dangling.md')));
3060
+ await symlink('deal.md', join(tempDir, KB('Sales/alias.md')));
3061
+ for (const path of [KB('Sales/dangling.md'), KB('Sales/alias.md')]) {
3062
+ const del = await call(base, 'delete_file', { path });
3063
+ expect(del.status, path).toBe(400);
3064
+ expect(del.body.error, path).toBe(`"${path}" is a symbolic link; the agent tools never follow or remove links.`);
3065
+ const mv = await call(base, 'move_file', { src: path, dest: KB('Sales/moved.md'), dryRun: true });
3066
+ expect(mv.status, path).toBe(400);
3067
+ expect(mv.body.error, path).toContain('symbolic link');
3068
+ }
3069
+ expect((await call(base, 'file_stat', { path: KB('Sales/alias.md') })).body).toMatchObject({ movable: false, deletable: false });
3070
+ expect(await exists(KB('Sales/deal.md'))).toBe(true);
3071
+ });
3072
+
3073
+ it('a move cannot create a platform file or folder at the destination', async () => {
3074
+ const base = await seeded();
3075
+ const dry = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/archive/access.md'), dryRun: true });
3076
+ expect(dry.body).toMatchObject({ allowed: false, reason: 'access.md is a platform file name; a move cannot create a platform file.' });
3077
+ expect((await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/archive/access.md') })).status).toBe(400);
3078
+ expect((await call(base, 'move_file', { src: KB('Sales/archive'), dest: KB('Skills'), dryRun: true })).body).toMatchObject({ allowed: false });
3079
+ expect(await exists(KB('Sales/deal.md'))).toBe(true);
3080
+ expect(await exists(KB('Sales/archive/access.md'))).toBe(false);
3081
+ });
3082
+
3083
+ it.skipIf(process.platform !== 'linux')('on a case-sensitive disk a case-distinct destination is a collision, not an overwrite', async () => {
3084
+ const base = await seeded();
3085
+ await fs.writeFile(KB('Sales/Deal.md'), 'other deal');
3086
+ const run = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/Deal.md') });
3087
+ expect(run.status).toBe(409);
3088
+ expect(await fs.readFile(KB('Sales/Deal.md'), { encoding: 'utf-8' })).toBe('other deal');
3089
+ expect(await exists(KB('Sales/deal.md'))).toBe(true);
3090
+ });
3091
+
3092
+ /**
3093
+ * The agent hears the sentence the sidebar shows — one refusal, named by
3094
+ * what is in the way, so a user reading a tool result and a user reading
3095
+ * the rename box are told the same thing.
3096
+ */
3097
+ it('a taken destination is refused with the one sentence, naming what is already there', async () => {
3098
+ const base = await seeded();
3099
+ await fs.writeFile(KB('Sales/notes.md'), '# Notes\n');
3100
+ await fs.writeFile(KB('Sales/report.docx'), 'docx bytes');
3101
+
3102
+ const ontoFile = await call(base, 'move_file', { src: KB('Sales/report.docx'), dest: KB('Sales/notes.md') });
3103
+ expect(ontoFile.status).toBe(409);
3104
+ expect(ontoFile.body.error).toBe('A file named notes.md already exists in Sales.');
3105
+ expect(await fs.readFile(KB('Sales/notes.md'), { encoding: 'utf-8' })).toBe('# Notes\n');
3106
+ expect(await exists(KB('Sales/report.docx'))).toBe(true);
3107
+
3108
+ // Onto a FOLDER of that name: the same sentence, said of a folder.
3109
+ const ontoFolder = await call(base, 'move_file', { src: KB('Sales/notes.md'), dest: KB('Sales/archive') });
3110
+ expect(ontoFolder.status).toBe(409);
3111
+ expect(ontoFolder.body.error).toBe('A folder named archive already exists in Sales.');
3112
+ expect(await exists(KB('Sales/archive/top.md'))).toBe(true);
3113
+
3114
+ // A folder onto a file: named for what is in the way, not for what moves.
3115
+ const folderOntoFile = await call(base, 'move_file', { src: KB('Sales/archive'), dest: KB('Sales/notes.md') });
3116
+ expect(folderOntoFile.status).toBe(409);
3117
+ expect(folderOntoFile.body.error).toBe('A file named notes.md already exists in Sales.');
3118
+ expect(await fs.readFile(KB('Sales/notes.md'), { encoding: 'utf-8' })).toBe('# Notes\n');
3119
+
3120
+ // The dry run says the same thing before anything is attempted.
3121
+ const dry = await call(base, 'move_file', { src: KB('Sales/report.docx'), dest: KB('Sales/notes.md'), dryRun: true });
3122
+ expect(dry.body).toMatchObject({ allowed: false, reason: 'A file named notes.md already exists in Sales.' });
3123
+ });
3124
+
3125
+ it('copy_file refuses a taken destination with the same sentence, and copies nothing', async () => {
3126
+ const base = await seeded();
3127
+ await fs.writeFile(KB('Sales/notes.md'), '# Notes\n');
3128
+
3129
+ const onto = await call(base, 'copy_file', { src: KB('Sales/deal.md'), dest: KB('Sales/notes.md') });
3130
+ expect(onto.status).toBe(409);
3131
+ expect(onto.body.error).toBe('A file named notes.md already exists in Sales.');
3132
+ expect(await fs.readFile(KB('Sales/notes.md'), { encoding: 'utf-8' })).toBe('# Notes\n');
3133
+
3134
+ const ontoFolder = await call(base, 'copy_file', { src: KB('Sales/deal.md'), dest: KB('Sales/archive') });
3135
+ expect(ontoFolder.status).toBe(409);
3136
+ expect(ontoFolder.body.error).toBe('A folder named archive already exists in Sales.');
3137
+
3138
+ // A free name still copies, exactly as before.
3139
+ const free = await call(base, 'copy_file', { src: KB('Sales/deal.md'), dest: KB('Sales/deal-copy.md') });
3140
+ expect(free.status).toBe(200);
3141
+ expect(await fs.readFile(KB('Sales/deal-copy.md'), { encoding: 'utf-8' })).toBe('deal');
3142
+ });
3143
+
3144
+ // Only where the two spellings are distinct entries: on a case-insensitive
3145
+ // disk `link(deal.md, Deal.md)` is EEXIST at setup, and the case-only
3146
+ // rename it stands in for is covered by the service's own suite.
3147
+ it.skipIf(!caseSensitiveDisk)('a hard link under a case-variant name is still a second entry, so still a clash', async () => {
3148
+ // The one destination a move may land on is the source ITSELF, which is
3149
+ // what a case-insensitive disk shows for `deal.md` → `Deal.md`. This
3150
+ // disk is not that: `Deal.md` is a directory entry of its own, hard link
3151
+ // or no hard link, and a move onto it would take that name away. One
3152
+ // inode does not make it the same name — the folder listing does, and
3153
+ // here the folder lists both.
3154
+ const base = await seeded();
3155
+ await link(join(tempDir, KB('Sales/deal.md')), join(tempDir, KB('Sales/Deal.md')));
3156
+
3157
+ const run = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/Deal.md') });
3158
+ expect(run.status).toBe(409);
3159
+ expect(run.body.error).toBe('A file named Deal.md already exists in Sales.');
3160
+ expect(await exists(KB('Sales/Deal.md'))).toBe(true);
3161
+ expect(await exists(KB('Sales/deal.md'))).toBe(true);
3162
+
3163
+ const copied = await call(base, 'copy_file', { src: KB('Sales/Deal.md'), dest: KB('Sales/deal.md') });
3164
+ expect(copied.status).toBe(409);
3165
+ });
3166
+
3167
+ it.skipIf(!caseSensitiveDisk)('a hard link under an unrelated name is a clash, inode or no inode', async () => {
3168
+ // The same reading, without the case-variance to confuse it: two names
3169
+ // a user can see separately, and moving onto the second one would take
3170
+ // it away.
3171
+ const base = await seeded();
3172
+ await link(join(tempDir, KB('Sales/deal.md')), join(tempDir, KB('Sales/twin.md')));
3173
+
3174
+ const run = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/twin.md') });
3175
+ expect(run.status).toBe(409);
3176
+ expect(run.body.error).toBe('A file named twin.md already exists in Sales.');
3177
+ expect(await exists(KB('Sales/twin.md'))).toBe(true);
3178
+ expect(await exists(KB('Sales/deal.md'))).toBe(true);
3179
+ });
3180
+ });
3181
+
3182
+ describe('descriptions match behaviour', () => {
3183
+ type Schema = { properties?: Record<string, Schema> };
3184
+ const def = async (name: string) => {
3185
+ const d = (await toolRegistry.listInternal()).find((t) => t.name === name);
3186
+ expect(d, name).toBeDefined();
3187
+ return d as unknown as { description: string; inputs: Schema; outputs: Schema };
3188
+ };
3189
+ const declaredOutputs = (d: { outputs: Schema }) => Object.keys(d.outputs.properties ?? {});
3190
+
3191
+ it('move_file states folders, recursion, collisions, platform files and the confirm rule, and declares every field it returns', async () => {
3192
+ const base = await seeded();
3193
+ const d = await def('move_file');
3194
+ expect(d.description).toMatch(/FILE or FOLDER/);
3195
+ expect(d.description).toMatch(/folder moves recursively/);
3196
+ expect(d.description).toMatch(/destination must not exist/);
3197
+ expect(d.description).toContain('is a platform file and stays in its folder.');
3198
+ expect(d.description).toContain('`dryRun: true`');
3199
+ expect(d.description).toContain('`confirm: true`');
3200
+ expect(Object.keys(d.inputs.properties.body.properties)).toEqual(expect.arrayContaining(['dryRun', 'confirm']));
3201
+ // What the description promises a dry run returns is what it returns.
3202
+ const dry = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('HR/deal.md'), dryRun: true });
3203
+ for (const key of ['src', 'dest', 'kind', 'descendants', 'access', 'accessChanges', 'allowed']) {
3204
+ expect(d.description, key).toContain(key);
3205
+ expect(dry.body, key).toHaveProperty(key);
3206
+ }
3207
+ const unconfirmed = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('HR/deal.md') });
3208
+ expect(d.description).toContain('confirmationRequired: true');
3209
+ expect(unconfirmed.body.confirmationRequired).toBe(true);
3210
+ for (const body of [dry.body, unconfirmed.body]) {
3211
+ expect(declaredOutputs(d)).toEqual(expect.arrayContaining(Object.keys(body)));
3212
+ }
3213
+ });
3214
+
3215
+ it('delete_folder states the confirm rule and refusals, and declares every field it returns', async () => {
3216
+ const base = await seeded();
3217
+ const d = await def('delete_folder');
3218
+ expect(d.description).toContain('`dryRun: true`');
3219
+ expect(d.description).toContain('A non-empty folder is deleted only with `confirm: true`');
3220
+ expect(d.description).toContain('platform folder');
3221
+ const dry = await call(base, 'delete_folder', { path: KB('Sales/archive'), dryRun: true });
3222
+ const unconfirmed = await call(base, 'delete_folder', { path: KB('Sales/archive') });
3223
+ const confirmed = await call(base, 'delete_folder', { path: KB('Sales/archive'), confirm: true });
3224
+ for (const body of [dry.body, unconfirmed.body, confirmed.body]) {
3225
+ expect(declaredOutputs(d)).toEqual(expect.arrayContaining(Object.keys(body)));
3226
+ }
3227
+ });
3228
+
3229
+ it('delete_file names delete_folder, file_stat names its new fields, and every destructive tool names the proposal route', async () => {
3230
+ const base = await seeded();
3231
+ expect((await def('delete_file')).description).toContain('`delete_folder`');
3232
+ const stat = await def('file_stat');
3233
+ const body = (await call(base, 'file_stat', { path: KB('Sales/archive') })).body;
3234
+ for (const key of ['managed', 'movable', 'deletable', 'access', 'descendants']) {
3235
+ expect(stat.description, key).toContain(`\`${key}`);
3236
+ expect(body, key).toHaveProperty(key);
3237
+ }
3238
+ for (const name of ['move_file', 'delete_file', 'delete_folder']) {
3239
+ expect((await def(name)).description, name).toContain('`write-denied`');
3240
+ }
3241
+ });
3242
+ });
3243
+ });
3244
+
3245
+ describe('a write refused for permissions says whether and how to propose it', () => {
3246
+ const TARGET = 'target-company-state';
3247
+ const DENIED = `${KB_DIR}/Sales/deal.md`;
3248
+ const KEY = 'hx_live_Zm9vYmFyU2VjcmV0S2V5';
3249
+ const SECRET_CONTENT = 'password=hunter2-do-not-echo';
3250
+
3251
+ it('the suggested change-request title fits the limit and never splits an emoji', () => {
3252
+ const lead = 'Propose a change to ';
3253
+ expect(proposalTitleFor('Sales/deal.md')).toBe(`${lead}Sales/deal.md`);
3254
+ // The emoji's high surrogate lands on the 255th unit, the last one kept before the ellipsis.
3255
+ const path = `${'a'.repeat(254 - lead.length)}😀${'b'.repeat(40)}`;
3256
+ const title = proposalTitleFor(path);
3257
+ expect(title).toBe(`${lead}${'a'.repeat(254 - lead.length)}…`);
3258
+ expect(title.length).toBeLessThanOrEqual(256);
3259
+ expect(title).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/);
3260
+ });
3261
+
3262
+ /** Access control whose read verdict is `readable` and which records every call. */
3263
+ const readVerdict = (readable: boolean | 'throws') => {
3264
+ const calls: string[] = [];
3265
+ const ac = {
3266
+ canRead: async (_w: string, _u: string, rel: string) => {
3267
+ calls.push(rel);
3268
+ if (readable === 'throws') throw new Error('git failed');
3269
+ return readable;
3270
+ },
3271
+ canReadBatch: async (_w: string, _u: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
3272
+ // The move/delete preflight asks these before it ever reaches the
3273
+ // filesystem. `canWriteBatchAtRef` answering null is "no rules resolve
3274
+ // at HEAD", so the preflight blocks nothing and the refusal comes from
3275
+ // the lock gate below — which is the denial these tests are about.
3276
+ canWrite: async () => true,
3277
+ canDownload: async () => true,
3278
+ canOwner: async () => true,
3279
+ canWriteBatchAtRef: async () => null,
3280
+ eligibleWritersAtRef: async () => ({ roles: ['Sales Lead'], users: [{ name: 'Owner', email: 'owner@x' }] }),
3281
+ } as unknown as IAccessControl;
3282
+ return { ac, calls };
3283
+ };
3284
+
3285
+ /** Make every mutating filesystem method refuse like the lock gate on a protected branch. */
3286
+ const denyWrites = (message?: string) => {
3287
+ const refuse = async (p?: unknown) => {
3288
+ const err = new AccessDeniedError({
3289
+ path: typeof p === 'string' ? p : DENIED,
3290
+ eligibleRoles: ['Sales Lead'],
3291
+ eligibleUsers: [{ name: 'Owner', email: 'owner@x' }],
3292
+ });
3293
+ if (message) Object.defineProperty(err, 'message', { value: message });
3294
+ throw err;
3295
+ };
3296
+ const target = fs as unknown as Record<string, unknown>;
3297
+ for (const m of ['writeFile', 'deleteFile', 'moveFile', 'mkdir']) target[m] = refuse;
3298
+ target.copyFile = async (_src: string, dest: string) => refuse(dest);
3299
+ // `delete_folder` lands through the same batch with NO writes and the
3300
+ // folder's files as `deletes` — in production the refusal comes from the
3301
+ // lock it takes on one of them, so the denial names that file.
3302
+ target.writeFiles = async (writes: { path: string }[], _summary?: string, deletes?: string[]) =>
3303
+ refuse(writes[0]?.path ?? deletes?.[0]);
3304
+ };
3305
+
3306
+ const call = async (base: string, tool: string, body: Record<string, unknown>) => {
3307
+ const res = await fetch(`${base}/api/agent/tools/${tool}`, {
3308
+ method: 'POST',
3309
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${KEY}` },
3310
+ body: JSON.stringify(body),
3311
+ });
3312
+ const text = await res.text();
3313
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- a JSON body read field by field
3314
+ return { status: res.status, text, json: JSON.parse(text) as Record<string, any> };
3315
+ };
3316
+
3317
+ const CALLS: Array<[string, Record<string, unknown>]> = [
3318
+ // The denied path already exists, so the writes say they mean to replace
3319
+ // it: the mode gate is not what these tests are about.
3320
+ ['write_file', { branch: TARGET, path: DENIED, content: SECRET_CONTENT, mode: 'overwrite' }],
3321
+ ['write_files', { branch: TARGET, files: [{ path: DENIED, content: SECRET_CONTENT }], mode: 'overwrite' }],
3322
+ ['edit_file', { branch: TARGET, path: DENIED, old_string: 'old', new_string: SECRET_CONTENT }],
3323
+ ['move_file', { branch: TARGET, src: DENIED, dest: `${KB_DIR}/Sales/moved.md` }],
3324
+ ['delete_file', { branch: TARGET, path: DENIED }],
3325
+ // The folder HOLDING the denied file: confirmed, so the call goes past the
3326
+ // impact preflight and reaches the gate that refuses.
3327
+ ['delete_folder', { branch: TARGET, path: `${KB_DIR}/Sales`, confirm: true }],
3328
+ ['copy_file', { branch: TARGET, src: `${KB_DIR}/a.md`, dest: DENIED }],
3329
+ ['mkdir', { branch: TARGET, path: DENIED }],
3330
+ ];
3331
+
3332
+ /**
3333
+ * A copy onto a name that is TAKEN is refused for the name, before any
3334
+ * permission is read (as a move is) — so the case that is about the
3335
+ * permission gives it a free destination. Every other tool here acts on the
3336
+ * file at `DENIED` and needs it present.
3337
+ */
3338
+ const freeDestinationFor = async (tool: string) => {
3339
+ if (tool === 'copy_file') await rm(join(tempDir, DENIED), { force: true });
3340
+ // An EMPTY folder needs no batch, so it would never reach the gate: put the
3341
+ // denied file back (past the refusing stub, straight to disk) whatever the
3342
+ // case before it did.
3343
+ if (tool === 'delete_folder') await writeFile(join(tempDir, DENIED), 'old text\n');
3344
+ };
3345
+
3346
+ it.each(CALLS)('%s: a reader gets canPropose and the three steps, and nothing is created', async (tool, body) => {
3347
+ const { ac, calls } = readVerdict(true);
3348
+ const base = await start('write', ac);
3349
+ await fs.mkdir(`${KB_DIR}/Sales`, { recursive: true });
3350
+ await fs.writeFile(DENIED, 'old text\n');
3351
+ await freeDestinationFor(tool);
3352
+ denyWrites();
3353
+ const workflowCalls = workspacePathCalls.length;
3354
+
3355
+ const { status, json } = await call(base, tool, body);
3356
+
3357
+ expect(status).toBe(403);
3358
+ expect(json).toMatchObject({
3359
+ kind: 'write-denied',
3360
+ path: DENIED,
3361
+ reason: 'Eligible: Sales Lead; Owner <owner@x>.',
3362
+ canPropose: true,
3363
+ });
3364
+ expect(json.cannotProposeReason).toBeUndefined();
3365
+ expect(json.error).toContain('You may propose this change instead');
3366
+ const draft = json.proposal.draftBranch as string;
3367
+ expect(json.proposal.targetBranch).toBe(TARGET);
3368
+ expect(json.proposal.steps.map((s: { tool: string }) => s.tool)).toEqual(['create_branch', tool, 'open_change_request']);
3369
+ expect(json.proposal.steps[0].args).toEqual({ name: draft, branch: TARGET });
3370
+ expect(json.proposal.steps[1].args).toEqual({ branch: draft });
3371
+ // Every argument open_change_request requires is present, so the step works as given.
3372
+ expect(json.proposal.steps[2].args).toEqual({ sourceBranch: draft, targetBranch: TARGET, title: 'Propose a change to Sales/deal.md' });
3373
+ // The read verdict was asked of the repo-relative path.
3374
+ expect(calls).toContain('Sales/deal.md');
3375
+ // Nothing was created: no workspace was resolved for the DRAFT the answer
3376
+ // suggests, and the context's workflow service is an empty stub, so a
3377
+ // create_branch or change request would have 500d. (The tools that
3378
+ // preflight do resolve the CALLER's own workspace on the way — to read
3379
+ // the on-disk spelling and refuse links — which is not a draft.)
3380
+ const forDraft = workspacePathCalls
3381
+ .slice(workflowCalls)
3382
+ .filter((b) => b.includes(draft) || b.includes(encodeURIComponent(draft)));
3383
+ expect(forDraft).toEqual([]);
3384
+ });
3385
+
3386
+ it('the suggested draft is a branch the caller owns, for an address the convention rewrites', async () => {
3387
+ // `john.doe@` is the common corporate shape, and the one that catches a
3388
+ // prefix derived by a second spelling of the rule: the platform judges
3389
+ // authorship on `john-doe/`, so a suggested `john.doe/…` would be a draft
3390
+ // the agent creates, proposes from, and is then refused permission to
3391
+ // delete. Asserted through the platform's own predicate, not the regex.
3392
+ const email = 'John.Doe+kb@example.com';
3393
+ const { ac } = readVerdict(true);
3394
+ const base = await start('write', ac, email);
3395
+ denyWrites();
3396
+
3397
+ const { json } = await call(base, 'write_file', CALLS[0][1]);
3398
+
3399
+ const draft = json.proposal.draftBranch as string;
3400
+ expect(isBranchAuthoredBy(draft, email)).toBe(true);
3401
+ expect(draft).toBe('john-doe-kb/propose-deal');
3402
+ // The step the agent actually runs carries that same name.
3403
+ expect(json.proposal.steps[0].args).toEqual({ name: draft, branch: TARGET });
3404
+ });
3405
+
3406
+ it('an address with no usable localpart is given a draft under its own suggestions prefix, still one it owns', async () => {
3407
+ // `branchAuthorLocalpart` answers null for a local part with no letter or
3408
+ // digit, rather than inventing an identity. The draft must still be one
3409
+ // the caller can later delete, so it comes from the second authorship
3410
+ // convention — keyed by user id — and is judged by that convention's own
3411
+ // predicate, as the branch-delete path judges it.
3412
+ const email = '+++@example.com';
3413
+ const { ac } = readVerdict(true);
3414
+ const base = await start('write', ac, email);
3415
+ denyWrites();
3416
+
3417
+ const { json } = await call(base, 'write_file', CALLS[0][1]);
3418
+
3419
+ const draft = json.proposal.draftBranch as string;
3420
+ expect(isBranchAuthoredBy(draft, email)).toBe(false);
3421
+ expect(isOwnSuggestionsBranch(draft, { email, id: 'u' })).toBe(true);
3422
+ expect(draft.startsWith('suggestions/')).toBe(true);
3423
+ expect(draft.endsWith('/propose-deal')).toBe(true);
3424
+ });
3425
+
3426
+ it('without read access the denial says so in one sentence and offers no proposal', async () => {
3427
+ const { ac } = readVerdict(false);
3428
+ const base = await start('write', ac);
3429
+ denyWrites();
3430
+ const { status, json } = await call(base, 'write_file', CALLS[0][1]);
3431
+ expect(status).toBe(403);
3432
+ expect(json).toMatchObject({ kind: 'write-denied', path: DENIED, canPropose: false });
3433
+ expect(json.proposal).toBeUndefined();
3434
+ expect(json.cannotProposeReason).toBe('Proposing is not available: you cannot read this path.');
3435
+ expect(json.error).toContain('you cannot read this path');
3436
+ });
3437
+
3438
+ it('on a branch that takes no change requests the denial says the branch is not proposable', async () => {
3439
+ const { ac } = readVerdict(true);
3440
+ const base = await start('write', ac);
3441
+ denyWrites();
3442
+ const { json } = await call(base, 'write_file', { ...CALLS[0][1], branch: 'someone/draft' });
3443
+ expect(json).toMatchObject({ kind: 'write-denied', canPropose: false });
3444
+ expect(json.cannotProposeReason).toContain('not a branch that accepts change requests');
3445
+ });
3446
+
3447
+ it('fails closed when the read verdict cannot be reached', async () => {
3448
+ const { ac } = readVerdict('throws');
3449
+ const base = await start('write', ac);
3450
+ denyWrites();
3451
+ const { json } = await call(base, 'write_file', CALLS[0][1]);
3452
+ expect(json).toMatchObject({ kind: 'write-denied', canPropose: false });
3453
+ expect(json.proposal).toBeUndefined();
3454
+ });
3455
+
3456
+ it('carries the excluded-principal sentence as the reason when the refusal gives one', async () => {
3457
+ const { ac } = readVerdict(true);
3458
+ const base = await start('write', ac);
3459
+ denyWrites(`You don't have permission to write to "${DENIED}". The Sales role is excluded at this folder.`);
3460
+ const { json } = await call(base, 'write_file', CALLS[0][1]);
3461
+ expect(json.reason).toBe('The Sales role is excluded at this folder.');
3462
+ });
3463
+
3464
+ it('never echoes the key, the content or the session id', async () => {
3465
+ const { ac } = readVerdict(true);
3466
+ const base = await start('write', ac);
3467
+ await fs.mkdir(`${KB_DIR}/Sales`, { recursive: true });
3468
+ await fs.writeFile(DENIED, 'old text\n');
3469
+ denyWrites();
3470
+ for (const [tool, body] of CALLS) {
3471
+ await freeDestinationFor(tool);
3472
+ const { text } = await call(base, tool, { ...body, sessionId: 'sess-secret-123' });
3473
+ expect(text, tool).toContain('write-denied');
3474
+ for (const secret of [KEY, SECRET_CONTENT, 'sess-secret-123', 'Bearer']) expect(text, tool).not.toContain(secret);
3475
+ }
3476
+ });
3477
+
3478
+ it('other failures pass through unchanged', async () => {
3479
+ const base = await start('write', readVerdict(true).ac);
3480
+ const res = await call(base, 'edit_file', { branch: TARGET, path: 'a.md', old_string: 'nope', new_string: 'x' });
3481
+ expect(res.status).toBe(400);
3482
+ expect(res.json).toEqual({ error: 'old_string not found in the file.' });
3483
+ });
3484
+
3485
+ it('each write tool mentions the proposal route in its description; read tools do not', async () => {
3486
+ await start();
3487
+ const tools = await toolRegistry.listInternal();
3488
+ for (const name of ['write_file', 'edit_file', 'write_files', 'move_file', 'delete_file', 'delete_folder', 'copy_file', 'mkdir']) {
3489
+ expect(tools.find((t) => t.name === name)?.description, name).toContain(PROPOSAL_ROUTE_NOTE.trim());
3490
+ }
3491
+ for (const name of ['read_file', 'grep', 'list_files']) {
3492
+ expect(tools.find((t) => t.name === name)?.description, name).not.toContain(PROPOSAL_ROUTE_NOTE.trim());
3493
+ }
3494
+ });
3495
+ });
3496
+
3497
+ /**
3498
+ * A path with nothing at it is an ordinary answer, not a server failure.
3499
+ *
3500
+ * Every file tool used to reach that conclusion its own way — `file_stat`,
3501
+ * `grep` and `move_file` with a 404 of their own wording, the rest by letting
3502
+ * the filesystem's `ENOENT` escape as a 500 — so an agent could not tell "your
3503
+ * path is wrong" from "this deployment is broken". These tests pin ONE answer
3504
+ * for all of them: 404, `kind: 'not_found'`, the requested path echoed back,
3505
+ * and the one next-step sentence.
3506
+ *
3507
+ * The four path kinds each tool is asked about: never existed, cannot be read,
3508
+ * deleted on this branch, and malformed.
3509
+ */
3510
+ describe('a path with nothing at it answers 404 not_found on every file tool', () => {
3511
+ const FOLDER = `${KB_DIR}/Knowledge`;
3512
+ const MISSING = `${FOLDER}/NoSuchFile.md`;
3513
+
3514
+ /** Every file tool the ticket names, called against `path` / `src`. */
3515
+ const callsFor = (path: string): [string, Record<string, unknown>][] => [
3516
+ ['read_file', { path }],
3517
+ ['file_stat', { path }],
3518
+ ['grep', { pattern: 'anything', path }],
3519
+ ['edit_file', { path, old_string: 'a', new_string: 'b' }],
3520
+ ['delete_file', { path }],
3521
+ ['move_file', { src: path, dest: `${FOLDER}/Moved.md` }],
3522
+ ['copy_file', { src: path, dest: `${FOLDER}/Copied.md` }],
3523
+ ['unzip', { path: `${path}.zip` }],
3524
+ ];
3525
+
3526
+ /** The one answer, asserted the same way for every tool. */
3527
+ async function expectNotFound(base: string, tool: string, body: Record<string, unknown>, named: string): Promise<void> {
3528
+ const res = await post(`${base}/api/agent/tools/${tool}`, { branch: 'main', ...body });
3529
+ const json = (await res.json()) as { error?: string; kind?: string; path?: string };
3530
+ expect(res.status, `${tool} status`).toBe(404);
3531
+ expect(json.kind, `${tool} kind`).toBe('not_found');
3532
+ expect(json.path, `${tool} path`).toBe(named);
3533
+ expect(json.error, `${tool} message`).toContain(`"${named}"`);
3534
+ expect(json.error, `${tool} next step`).toContain(NOT_FOUND_NEXT_STEP);
3535
+ }
3536
+
3537
+ it('a path that never existed', async () => {
3538
+ const base = await start();
3539
+ await fs.mkdir(FOLDER, { recursive: true });
3540
+ for (const [tool, body] of callsFor(MISSING)) {
3541
+ // unzip is asked about `<path>.zip`, so it names that path, not MISSING.
3542
+ await expectNotFound(base, tool, body, (body.path as string) ?? (body.src as string));
3543
+ }
3544
+ });
3545
+
3546
+ it('a file deleted on this branch', async () => {
3547
+ const base = await start();
3548
+ const gone = `${FOLDER}/Gone.md`;
3549
+ await fs.mkdir(FOLDER, { recursive: true });
3550
+ await fs.writeFile(gone, 'here for now\n');
3551
+ await fs.writeFile(`${gone}.zip`, 'not really a zip');
3552
+ expect((await post(`${base}/api/agent/tools/delete_file`, { branch: 'main', path: gone })).status).toBe(200);
3553
+ await fs.deleteFile(`${gone}.zip`);
3554
+ for (const [tool, body] of callsFor(gone)) {
3555
+ await expectNotFound(base, tool, body, (body.path as string) ?? (body.src as string));
3556
+ }
3557
+ });
3558
+
3559
+ // ENOTDIR, not ENOENT: nothing can live under a FILE, so the path is as
3560
+ // absent as a name nobody used — and the raw errno used to escape as a 500
3561
+ // carrying the server's own absolute path in its message.
3562
+ it('a path whose parent segment is a file', async () => {
3563
+ const base = await start();
3564
+ await fs.mkdir(FOLDER, { recursive: true });
3565
+ await fs.writeFile(`${FOLDER}/Note.md`, 'a real file\n');
3566
+ const under = `${FOLDER}/Note.md/nested.md`;
3567
+ for (const [tool, body] of callsFor(under)) {
3568
+ await expectNotFound(base, tool, body, (body.path as string) ?? (body.src as string));
3569
+ }
3570
+ });
3571
+
3572
+ // A copy has TWO ends and the filesystem blames the source for both: it
3573
+ // re-throws every ENOENT as `FileNotFoundError(src)`, and a destination
3574
+ // segment that is a file escapes raw as ENOTDIR from the parent mkdir. With
3575
+ // the source sitting right there, the absence can only be the destination's
3576
+ // — so the 404 names the destination. It must not be left to escape as a
3577
+ // 500 either: that is the answer whose message carries the server's own
3578
+ // absolute path, and a mis-spelled destination is the caller's to fix.
3579
+ it('copy_file names the DESTINATION when the source is there and the destination is not', async () => {
3580
+ const base = await start('write');
3581
+ await fs.mkdir(FOLDER, { recursive: true });
3582
+ await fs.writeFile(`${FOLDER}/Note.md`, 'a real file\n');
3583
+ await fs.writeFile(`${FOLDER}/Source.md`, 'copy me\n');
3584
+ const dest = `${FOLDER}/Note.md/deeper/copy.md`;
3585
+ const res = await post(`${base}/api/agent/tools/copy_file`, { branch: 'main', src: `${FOLDER}/Source.md`, dest });
3586
+ const json = (await res.json()) as { error?: string; kind?: string; path?: string };
3587
+ expect(res.status).toBe(404);
3588
+ expect(json.kind).toBe('not_found');
3589
+ expect(json.path).toBe(dest);
3590
+ expect(json.error).toContain(NOT_FOUND_NEXT_STEP);
3591
+ // The source is not what is wrong, and the answer never says it is.
3592
+ expect(json.error).not.toContain('Source.md');
3593
+ // Nor does the raw errno — and the server's own absolute path with it —
3594
+ // reach the caller, which is what the old 500 handed over.
3595
+ expect(json.error).not.toContain('ENOTDIR');
3596
+ });
3597
+
3598
+ // A backslash is a filename character on THIS disk and a separator on
3599
+ // Windows, so it is a path nobody can read the same way twice. The read tools
3600
+ // used to meet it as plain absence; the one normaliser refuses it up front
3601
+ // now, on every tool alike — the refusal the move and delete tools always
3602
+ // gave, extended to the rest by the fact that they all go through it.
3603
+ it('a malformed path: backslashes are refused up front, on every tool', async () => {
3604
+ const base = await start();
3605
+ const odd = `${KB_DIR}\\Knowledge\\NoSuchFile.md`;
3606
+ const tools: [string, Record<string, unknown>][] = [
3607
+ ...callsFor(odd).filter(([name]) => ['read_file', 'file_stat', 'grep', 'edit_file', 'copy_file'].includes(name)),
3608
+ ['delete_file', { path: odd }],
3609
+ ['move_file', { src: odd, dest: `${FOLDER}/Moved.md` }],
3610
+ ];
3611
+ for (const [tool, body] of tools) {
3612
+ const res = await post(`${base}/api/agent/tools/${tool}`, { branch: 'main', ...body });
3613
+ expect(res.status, tool).toBe(400);
3614
+ expect((await res.json()).error, tool).toContain('backslashes');
3615
+ }
3616
+ });
3617
+
3618
+ // THE ordering rule: the read gate answers before absence does. A caller who
3619
+ // may not read a path must not learn from the answer whether anything is
3620
+ // there — so a missing denied path and an existing denied one are the SAME
3621
+ // response, byte for byte.
3622
+ it('a path the caller may not read answers the denial, never 404', async () => {
3623
+ const base = await start('write', denyReads(new Set(['Knowledge/Secret.md', 'Knowledge/Ghost.md'])));
3624
+ await fs.mkdir(FOLDER, { recursive: true });
3625
+ await fs.writeFile(`${FOLDER}/Secret.md`, 'real content\n');
3626
+ for (const tool of ['read_file', 'file_stat', 'grep'] as const) {
3627
+ const ask = async (name: string): Promise<{ status: number; body: string }> => {
3628
+ const body = callsFor(`${FOLDER}/${name}`).find(([t]) => t === tool)![1];
3629
+ const res = await post(`${base}/api/agent/tools/${tool}`, { branch: 'main', ...body });
3630
+ return { status: res.status, body: (await res.text()).replace(name, '<name>') };
3631
+ };
3632
+ const present = await ask('Secret.md');
3633
+ const absent = await ask('Ghost.md');
3634
+ expect(present.status, tool).toBe(403);
3635
+ expect(absent, tool).toEqual(present);
3636
+ expect(absent.body, tool).not.toContain('not_found');
3637
+ }
3638
+ });
3639
+
3640
+ // The same ordering rule on the WRITE side. A refusal to write must not
3641
+ // report what is on disk, or the refusal itself becomes the disclosure: a
3642
+ // caller who may not copy into a folder would learn from the answer whether
3643
+ // the source they named exists. So the write denial comes first and absence
3644
+ // is only asked about once the copy was allowed to be attempted — the 404
3645
+ // this ticket adds must not push in front of the 403 that was already there.
3646
+ it('copy_file refuses a denied destination with the write denial, even when the source is missing', async () => {
3647
+ const base = await start('write');
3648
+ await fs.mkdir(FOLDER, { recursive: true });
3649
+ const denied = async (src: string): Promise<{ status: number; body: string }> => {
3650
+ const copySpy = vi.spyOn(fs, 'copyFile').mockRejectedValue(
3651
+ new AccessDeniedError({ path: `${FOLDER}/Denied.md`, eligibleRoles: ['Owner'], eligibleUsers: [] }),
3652
+ );
3653
+ try {
3654
+ const res = await post(`${base}/api/agent/tools/copy_file`, { branch: 'main', src, dest: `${FOLDER}/Denied.md` });
3655
+ return { status: res.status, body: await res.text() };
3656
+ } finally {
3657
+ copySpy.mockRestore();
3658
+ }
3659
+ };
3660
+ await fs.writeFile(`${FOLDER}/Present.md`, 'here\n');
3661
+ const present = await denied(`${FOLDER}/Present.md`);
3662
+ const absent = await denied(MISSING);
3663
+ expect(present.status).toBe(403);
3664
+ // Byte for byte the same refusal: the source's existence changes nothing.
3665
+ expect(absent.status).toBe(403);
3666
+ expect(absent.body).toBe(present.body);
3667
+ expect(absent.body).toContain('write-denied');
3668
+ expect(absent.body).not.toContain('not_found');
3669
+ });
3670
+
3671
+ // Absence is ENOENT and ENOTDIR and nothing else. A path that cannot be READ
3672
+ // is not a path the caller should be told to go and re-spell.
3673
+ it('a filesystem failure that is not absence stays a 500', async () => {
3674
+ const base = await start();
3675
+ await fs.mkdir(FOLDER, { recursive: true });
3676
+ const boom = Object.assign(new Error('EIO: i/o error, read'), { code: 'EIO' });
3677
+ const readFileSpy = vi.spyOn(fs, 'readFile').mockRejectedValue(boom);
3678
+ try {
3679
+ const res = await post(`${base}/api/agent/tools/read_file`, { branch: 'main', path: `${FOLDER}/Unreadable.md` });
3680
+ expect(res.status).toBe(500);
3681
+ const json = (await res.json()) as { kind?: string };
3682
+ expect(json.kind).toBeUndefined();
3683
+ } finally {
3684
+ readFileSpy.mockRestore();
3685
+ }
3686
+ });
3687
+ });