@bevel-software/platform-core-backend 0.22.0 → 0.23.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 (325) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +30 -11
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +9 -2
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +112 -23
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +34 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +89 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +0 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +11 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access.routes.d.ts.map +1 -1
  31. package/dist/modules/access/access.routes.js +13 -15
  32. package/dist/modules/access/access.routes.js.map +1 -1
  33. package/dist/modules/auth/account-admission.d.ts +58 -7
  34. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  35. package/dist/modules/auth/account-admission.js +44 -1
  36. package/dist/modules/auth/account-admission.js.map +1 -1
  37. package/dist/modules/auth/account.routes.d.ts +1 -1
  38. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  39. package/dist/modules/auth/account.routes.js +61 -5
  40. package/dist/modules/auth/account.routes.js.map +1 -1
  41. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  42. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.middleware.js +18 -7
  44. package/dist/modules/auth/auth.middleware.js.map +1 -1
  45. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  46. package/dist/modules/auth/auth.routes.js +8 -0
  47. package/dist/modules/auth/auth.routes.js.map +1 -1
  48. package/dist/modules/auth/auth.service.d.ts +70 -2
  49. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  50. package/dist/modules/auth/auth.service.js +176 -7
  51. package/dist/modules/auth/auth.service.js.map +1 -1
  52. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  53. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  54. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  55. package/dist/modules/database/core-schema.d.ts +41 -24
  56. package/dist/modules/database/core-schema.d.ts.map +1 -1
  57. package/dist/modules/database/core-schema.js +35 -27
  58. package/dist/modules/database/core-schema.js.map +1 -1
  59. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  60. package/dist/modules/kb-fs/branch-name.js +12 -2
  61. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  62. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  63. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  64. package/dist/modules/kb-fs/repo-path.js +79 -0
  65. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  66. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  67. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  68. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  69. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  70. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  71. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  72. package/dist/modules/kb-sync/sync-auth.js +1 -1
  73. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  74. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  75. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  76. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  77. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  78. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  79. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  80. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  81. package/dist/modules/settings/deployment-settings.service.js +8 -3
  82. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  83. package/dist/modules/settings/setup.routes.d.ts +52 -1
  84. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  85. package/dist/modules/settings/setup.routes.js +207 -19
  86. package/dist/modules/settings/setup.routes.js.map +1 -1
  87. package/dist/modules/skills/skills.contract.d.ts +90 -18
  88. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  89. package/dist/modules/skills/skills.contract.js +4 -1
  90. package/dist/modules/skills/skills.contract.js.map +1 -1
  91. package/dist/modules/skills/skills.service.d.ts +98 -9
  92. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  93. package/dist/modules/skills/skills.service.js +239 -37
  94. package/dist/modules/skills/skills.service.js.map +1 -1
  95. package/dist/modules/skills/skills.tools.d.ts +7 -0
  96. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  97. package/dist/modules/skills/skills.tools.js +71 -7
  98. package/dist/modules/skills/skills.tools.js.map +1 -1
  99. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  100. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  101. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  102. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  103. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  104. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  105. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  106. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  108. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  109. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  110. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  111. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  112. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  113. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  114. package/dist/modules/tool-helpers/validate-token.js +1 -1
  115. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  117. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  118. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  119. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  121. package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
  122. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  123. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  124. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  125. package/dist/modules/workflow/file-lock.service.js +30 -0
  126. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  127. package/dist/modules/workflow/git/git.service.d.ts +69 -0
  128. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  129. package/dist/modules/workflow/git/git.service.js +153 -2
  130. package/dist/modules/workflow/git/git.service.js.map +1 -1
  131. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  132. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  133. package/dist/modules/workflow/pending-commits.service.js +55 -0
  134. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  135. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  136. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  137. package/dist/modules/workflow/workflow-hooks.js +16 -1
  138. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  139. package/dist/modules/workflow/workflow.service.d.ts +60 -6
  140. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  141. package/dist/modules/workflow/workflow.service.js +111 -4
  142. package/dist/modules/workflow/workflow.service.js.map +1 -1
  143. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  144. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  145. package/dist/modules/workspace/agent-access.gate.js +123 -0
  146. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  147. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  148. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  149. package/dist/modules/workspace/routine-write-policy.js +5 -6
  150. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  151. package/dist/modules/workspace/session-sink.d.ts +5 -5
  152. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  153. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  154. package/dist/modules/workspace/set-aside-clone.js +92 -0
  155. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  156. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
  157. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  158. package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
  159. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  160. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.routes.js +92 -3
  162. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  163. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  164. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  165. package/dist/modules/workspace/workspace.service.js +255 -25
  166. package/dist/modules/workspace/workspace.service.js.map +1 -1
  167. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  168. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  169. package/dist/modules/workspace/workspace.tools.js +309 -115
  170. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  171. package/dist/modules/write-access/write-access.d.ts +60 -0
  172. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  173. package/dist/modules/write-access/write-access.js +129 -0
  174. package/dist/modules/write-access/write-access.js.map +1 -0
  175. package/dist/shared/domain-errors.d.ts +25 -0
  176. package/dist/shared/domain-errors.d.ts.map +1 -1
  177. package/dist/shared/domain-errors.js +28 -0
  178. package/dist/shared/domain-errors.js.map +1 -1
  179. package/dist/shared/git.contract.d.ts +20 -0
  180. package/dist/shared/git.contract.d.ts.map +1 -1
  181. package/dist/shared/git.contract.js +26 -0
  182. package/dist/shared/git.contract.js.map +1 -1
  183. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  184. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  185. package/dist/tenancy/static-tenant-source.js +0 -2
  186. package/dist/tenancy/static-tenant-source.js.map +1 -1
  187. package/migrations/0014_change_request_closed_reason.sql +1 -0
  188. package/migrations/0015_account_deactivation.sql +3 -0
  189. package/migrations/meta/0014_snapshot.json +2265 -0
  190. package/migrations/meta/0015_snapshot.json +2271 -0
  191. package/migrations/meta/_journal.json +14 -0
  192. package/package.json +3 -3
  193. package/src/__tests__/kb-layout-config.test.ts +0 -2
  194. package/src/__tests__/retired-settings.test.ts +96 -0
  195. package/src/core/__tests__/gated-boot.test.ts +132 -0
  196. package/src/core/__tests__/lifecycle.test.ts +170 -1
  197. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  198. package/src/core/core-ports.ts +11 -1
  199. package/src/core/create-core-server.ts +32 -14
  200. package/src/core/create-core-services.ts +129 -23
  201. package/src/core/lifecycle.ts +98 -13
  202. package/src/core-config.ts +13 -14
  203. package/src/index.ts +12 -1
  204. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  205. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  206. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  207. package/src/modules/access/access-control.interface.ts +43 -12
  208. package/src/modules/access/access-control.service.ts +234 -36
  209. package/src/modules/access/access.routes.ts +13 -15
  210. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  211. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  212. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  213. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  214. package/src/modules/auth/__tests__/auth.service.test.ts +1 -1
  215. package/src/modules/auth/account-admission.ts +78 -9
  216. package/src/modules/auth/account.routes.ts +63 -7
  217. package/src/modules/auth/auth.middleware.ts +19 -8
  218. package/src/modules/auth/auth.routes.ts +8 -0
  219. package/src/modules/auth/auth.service.ts +179 -6
  220. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  221. package/src/modules/database/core-schema.ts +35 -27
  222. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  223. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  224. package/src/modules/kb-fs/branch-name.ts +14 -1
  225. package/src/modules/kb-fs/repo-path.ts +85 -0
  226. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  227. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  228. package/src/modules/kb-sync/sync-auth.ts +2 -2
  229. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  230. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  231. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  232. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  233. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  234. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  235. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
  236. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
  237. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
  238. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  239. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  240. package/src/modules/settings/deployment-settings.service.ts +8 -3
  241. package/src/modules/settings/setup.routes.ts +263 -19
  242. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  243. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  244. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  245. package/src/modules/skills/skills.contract.ts +91 -18
  246. package/src/modules/skills/skills.service.ts +278 -41
  247. package/src/modules/skills/skills.tools.ts +80 -8
  248. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  249. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  250. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  251. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  252. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  253. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  254. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  255. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  256. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  257. package/src/modules/tool-helpers/validate-token.ts +1 -1
  258. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  259. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  260. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  261. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
  262. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  263. package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
  264. package/src/modules/workflow/file-lock.service.ts +31 -0
  265. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  266. package/src/modules/workflow/git/git.service.ts +168 -0
  267. package/src/modules/workflow/pending-commits.service.ts +59 -1
  268. package/src/modules/workflow/workflow-hooks.ts +64 -26
  269. package/src/modules/workflow/workflow.service.ts +121 -4
  270. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  271. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  272. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  273. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  274. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  275. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  276. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  278. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  279. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  280. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  281. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
  282. package/src/modules/workspace/__tests__/workspace.tools.test.ts +685 -14
  283. package/src/modules/workspace/agent-access.gate.ts +164 -0
  284. package/src/modules/workspace/routine-write-policy.ts +5 -6
  285. package/src/modules/workspace/session-sink.ts +5 -5
  286. package/src/modules/workspace/set-aside-clone.ts +96 -0
  287. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
  288. package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
  289. package/src/modules/workspace/workspace.routes.ts +95 -3
  290. package/src/modules/workspace/workspace.service.ts +256 -24
  291. package/src/modules/workspace/workspace.tools.ts +338 -119
  292. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  293. package/src/modules/write-access/write-access.ts +153 -0
  294. package/src/shared/domain-errors.ts +31 -0
  295. package/src/shared/git.contract.ts +27 -0
  296. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  297. package/src/tenancy/static-tenant-source.ts +0 -3
  298. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  299. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  300. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  301. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  302. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  303. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  304. package/dist/modules/workflow/session-ontology.service.js +0 -147
  305. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  306. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  307. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  308. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  309. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  310. package/dist/shared/kb-layout.d.ts +0 -39
  311. package/dist/shared/kb-layout.d.ts.map +0 -1
  312. package/dist/shared/kb-layout.js +0 -103
  313. package/dist/shared/kb-layout.js.map +0 -1
  314. package/dist/shared/kb-layout.test.d.ts +0 -2
  315. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  316. package/dist/shared/kb-layout.test.js +0 -75
  317. package/dist/shared/kb-layout.test.js.map +0 -1
  318. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  319. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  320. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  321. package/src/modules/workflow/session-ontology.service.ts +0 -183
  322. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  323. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  324. package/src/shared/kb-layout.test.ts +0 -98
  325. package/src/shared/kb-layout.ts +0 -102
@@ -11,16 +11,20 @@ import { testKbContext } from '../../../__tests__/kb-context.js';
11
11
  import { LocalFilesystem } from '@mastra/core/workspace';
12
12
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
13
13
  import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
14
- import type { ToolContext } from '../../tool-helpers/tool.contract.js';
14
+ import { ToolError, type ToolContext } from '../../tool-helpers/tool.contract.js';
15
15
  import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
16
16
  import { CONTENT_RULE, registerWorkspaceTools } from '../workspace.tools.js';
17
17
  import { RoutineWritePolicyService } from '../routine-write-policy.js';
18
18
  import { UuidSessionSink, type ISessionSink } from '../session-sink.js';
19
- import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
19
+ import { WorkflowHooks, type AgentOperationContext } from '../../workflow/workflow-hooks.js';
20
+ import { SESSION_ID_DESCRIPTION, ToolDescriptionNotes } from '../agent-access.gate.js';
20
21
  import { SpillStore } from '../spill-store.js';
21
22
  import { DocExtractService } from '../file-readers/doc-extract.service.js';
22
23
  import { OCTET_STREAM_FALLBACK_NOTE } from '../file-readers/content-mode.js';
23
24
  import type { IAccessControl } from '../../access/access-control.interface.js';
25
+ import { AccessControlService } from '../../access/access-control.service.js';
26
+ import { NodeFs } from '../../kb-fs/node-fs.js';
27
+ import type { WorkspaceService } from '../workspace.service.js';
24
28
  import { isBranchAuthoredBy, isOwnSuggestionsBranch } from '@bevel-software/platform-shared';
25
29
  import { assertValidBranchName } from '../../kb-fs/branch-name.js';
26
30
  import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
@@ -43,6 +47,11 @@ const allowAll = {
43
47
  canDownload: async () => true,
44
48
  canOwner: async () => true,
45
49
  canWriteBatchAtRef: async () => null,
50
+ // The `after` half of a move's or copy's preview. Allowing everything here
51
+ // keeps it the same answer as the four gates above; what the relocation of
52
+ // a folder's `access.md` files actually does to it is exercised against
53
+ // REAL rules in "a move preview judges the destination as it will be".
54
+ previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
46
55
  } as unknown as IAccessControl;
47
56
 
48
57
  /** Access control that denies `canRead` for an explicit set of repo-relative paths. */
@@ -76,6 +85,14 @@ let fs: LocalFilesystem;
76
85
  let workspacePathCalls: string[] = [];
77
86
  /** Every `(archive, destination)` pair `unzip` handed the service. */
78
87
  let unzipCalls: [string, string | undefined][] = [];
88
+ /**
89
+ * Workspace-relative targets the `unzipFile` stand-in pretends the archive
90
+ * holds. Empty by default (the stub extracts nothing); a test that is about
91
+ * the per-entry write guard sets it, and the stand-in then runs the guard once
92
+ * per entry the way the real service does — which is asserted where it lives,
93
+ * in `workspace.service.test.ts`.
94
+ */
95
+ let unzipEntries: string[] = [];
79
96
  /** Every folder turn a tool took, as `workspaceId:dir`. */
80
97
  let folderTurns: string[] = [];
81
98
  /** The policy instance the tools were mounted with, so a test can restrict a session. */
@@ -88,6 +105,21 @@ let writePolicy: RoutineWritePolicyService;
88
105
  let focusedBranch: string | undefined;
89
106
  /** The registry the tools were mounted into, so a test can inspect their defs. */
90
107
  let toolRegistry: ToolRegistry;
108
+ /** The hook registry the tools were mounted against, so a test can register one. */
109
+ let hooks: WorkflowHooks;
110
+ /** The note registry the tools were mounted against, so a test can register a note. */
111
+ let notes: ToolDescriptionNotes;
112
+ /**
113
+ * The recovery/merge bot's address, as the mounted gate knows it — the one
114
+ * identity that never reaches a hook.
115
+ */
116
+ const RECOVERY_BOT = 'recovery-bot@bevel.local';
117
+ /**
118
+ * What the auth layer resolves this caller's source to. `internal` (the
119
+ * in-process agent) unless a test is about a person in the app (`session`) or
120
+ * an external agent.
121
+ */
122
+ let callerSource: 'internal' | 'external' | 'session' = 'internal';
91
123
  /**
92
124
  * Another writer getting to a path in the window the real `LockingFilesystem`
93
125
  * closes: the stand-ins below run it where that filesystem would be acquiring
@@ -103,6 +135,9 @@ async function start(
103
135
  userEmail = 'e@x',
104
136
  ): Promise<string> {
105
137
  raceHook = null;
138
+ hooks = new WorkflowHooks();
139
+ notes = new ToolDescriptionNotes();
140
+ callerSource = 'internal';
106
141
  tempDir = await mkdtemp(join(tmpdir(), 'ws-tools-'));
107
142
  docCacheDir = await mkdtemp(join(tmpdir(), 'ws-doc-cache-'));
108
143
  fs = new LocalFilesystem({ basePath: tempDir, contained: true });
@@ -163,6 +198,7 @@ async function start(
163
198
  await fs.writeFile('a.md', 'hello\nworld\n');
164
199
  workspacePathCalls = [];
165
200
  unzipCalls = [];
201
+ unzipEntries = [];
166
202
  folderTurns = [];
167
203
  writePolicy = new RoutineWritePolicyService();
168
204
  focusedBranch = undefined;
@@ -195,7 +231,12 @@ async function start(
195
231
  // shapes is absence, and the real service raises exactly this error for
196
232
  // it (asserted in workspace.service.test.ts). Extraction itself lives
197
233
  // there too — none of it is the tool's to decide.
198
- unzipFile: async (_id: string, zipRel: string, destRel?: string) => {
234
+ unzipFile: async (
235
+ _id: string,
236
+ zipRel: string,
237
+ destRel?: string,
238
+ guardWrite?: (wsRelativePath: string) => Promise<void>,
239
+ ) => {
199
240
  // Recorded so a test can assert the PATHS the tool handed over — both
200
241
  // ends normalised into the repository.
201
242
  unzipCalls.push([zipRel, destRel]);
@@ -204,7 +245,17 @@ async function start(
204
245
  } catch {
205
246
  throw new PathNotFoundError(zipRel);
206
247
  }
207
- return { destination: '', extracted: [], skipped: [] };
248
+ const extracted: string[] = [];
249
+ const skipped: { path: string; reason: string }[] = [];
250
+ for (const entry of unzipEntries) {
251
+ try {
252
+ await guardWrite?.(entry);
253
+ extracted.push(entry);
254
+ } catch (err) {
255
+ skipped.push({ path: entry, reason: err instanceof Error ? err.message : 'refused' });
256
+ }
257
+ }
258
+ return { destination: '', extracted, skipped };
208
259
  },
209
260
  } as never,
210
261
  workflowService: {} as never,
@@ -213,18 +264,19 @@ async function start(
213
264
  });
214
265
  const toolHandler = createToolHandlerFactory(resolve);
215
266
  const fakeAuth = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
216
- req.toolAuth = { source: 'internal', userId: 'u', scope };
267
+ req.toolAuth = { source: callerSource, userId: 'u', scope };
217
268
  next();
218
269
  };
219
270
  const app = express();
220
271
  app.use(express.json());
221
272
  const router = express.Router();
222
273
  registerWorkspaceTools(registry, router, fakeAuth, toolHandler, new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(docCacheDir), access, testKbContext({ kbDirName: KB_DIR }), {
223
- service: {} as never,
224
- enabled: false, // these tests predate and don't exercise the ontology boundary
225
- kb: testKbContext({ kbDirName: KB_DIR }),
226
- recoveryBotEmail: 'recovery-bot@bevel.local',
227
- hooks: new WorkflowHooks(),
274
+ // The hooks and the notes a test registers against; with neither
275
+ // registered (the default, and every Hexis-only deployment) the gate
276
+ // refuses nothing and the descriptions say nothing extra.
277
+ recoveryBotEmail: RECOVERY_BOT,
278
+ hooks,
279
+ notes,
228
280
  }, writePolicy, {} as never /* sessionSink — start_session not exercised here */);
229
281
  app.use('/api', router);
230
282
  httpServer = await new Promise<HttpServer>((r) => {
@@ -1816,7 +1868,7 @@ describe('images', () => {
1816
1868
  /**
1817
1869
  * start_session must mint a REAL chat thread and return its id (not a bare
1818
1870
  * random UUID), so the same id works for KB reads AND for `ask` (whose
1819
- * sessionId IS a chat thread). This is what unifies the ontology boundary
1871
+ * sessionId IS a chat thread). This is what unifies the conversation
1820
1872
  * across reads + ask — see workspace.tools.ts start_session comment.
1821
1873
  */
1822
1874
  describe('start_session', () => {
@@ -1863,7 +1915,7 @@ describe('start_session', () => {
1863
1915
  registerWorkspaceTools(
1864
1916
  registry, router, auth, toolHandler,
1865
1917
  new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, testKbContext({ kbDirName: KB_DIR }),
1866
- { service: {} as never, enabled: false, kb: testKbContext({ kbDirName: KB_DIR }), recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks() },
1918
+ { recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
1867
1919
  new RoutineWritePolicyService(),
1868
1920
  sink ?? fakeSessionSink,
1869
1921
  );
@@ -1988,7 +2040,7 @@ describe('start_session', () => {
1988
2040
  registerWorkspaceTools(
1989
2041
  registry, router, noopAuth, (() => () => {}) as never,
1990
2042
  new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, testKbContext({ kbDirName: KB_DIR }),
1991
- { service: {} as never, enabled: false, kb: testKbContext({ kbDirName: KB_DIR }), recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks() },
2043
+ { recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
1992
2044
  new RoutineWritePolicyService(),
1993
2045
  {} as never,
1994
2046
  );
@@ -2030,7 +2082,7 @@ describe('branch is a required parameter in the tool contract', () => {
2030
2082
  new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')),
2031
2083
  allowAll,
2032
2084
  testKbContext({ kbDirName: KB_DIR }),
2033
- { service: {} as never, enabled: false, kb: testKbContext({ kbDirName: KB_DIR }), recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks() },
2085
+ { recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
2034
2086
  new RoutineWritePolicyService(),
2035
2087
  {} as never,
2036
2088
  );
@@ -2514,6 +2566,11 @@ describe('preflight for moves and deletes', () => {
2514
2566
  canWriteBatchAtRef: async (_w: string, _r: string, _u: string, rels: string[]) =>
2515
2567
  new Map(rels.map((p) => [p, verbsFor(p).write])),
2516
2568
  eligibleWritersAtRef: async () => ({ roles: ['Admin'], users: [] }),
2569
+ // This double has no access files to carry, so the destination as it will
2570
+ // be IS the destination as it is — the answer `accessAt` gave before the
2571
+ // preview learned to relocate them, which keeps every ordering and
2572
+ // oracle test below about what it is about.
2573
+ previewAccessAfterRelocation: async (_w: string, _u: string, _from: string, to: string) => verbsFor(to),
2517
2574
  } as unknown as IAccessControl;
2518
2575
 
2519
2576
  const call = async (base: string, tool: string, body: Record<string, unknown>) => {
@@ -2657,6 +2714,35 @@ describe('preflight for moves and deletes', () => {
2657
2714
  expect(await exists(KB('Sales/deal.md'))).toBe(true);
2658
2715
  });
2659
2716
 
2717
+ /**
2718
+ * The dry run keeps that order too, and it is the easier oracle to reach:
2719
+ * it answers 200 rather than throwing, so a denied caller could read the
2720
+ * source's kind and its file count off it — or a 404 saying whether a
2721
+ * source they may not copy from exists at all. Nothing on disk is probed
2722
+ * until the write verdict on the destination has been taken.
2723
+ */
2724
+ it('copy_file\'s dry run refuses a denied destination the same way whatever the source is', async () => {
2725
+ const base = await seeded();
2726
+ const dry = async (src: string) =>
2727
+ (await call(base, 'copy_file', { src, dest: KB('Locked/new.md'), dryRun: true })).body;
2728
+
2729
+ const file = await dry(KB('Sales/deal.md'));
2730
+ const folder = await dry(KB('Sales/archive'));
2731
+ const missing = await dry(KB('Sales/nothing-here.md'));
2732
+
2733
+ // One sentence, three sources: the refusal says nothing about any of them.
2734
+ for (const answer of [file, folder, missing]) {
2735
+ expect(answer).toMatchObject({ allowed: false, dryRun: true, copied: false });
2736
+ expect(answer.reason).toContain(KB('Locked/new.md'));
2737
+ expect(answer.kind).toBeUndefined();
2738
+ expect(answer.descendants).toBeUndefined();
2739
+ }
2740
+ expect(folder.reason).toBe(file.reason);
2741
+ expect(missing.reason).toBe(file.reason);
2742
+ // And the caller's own verbs are still answered — those are theirs.
2743
+ expect(Object.keys(file.access).sort()).toEqual(['after', 'before']);
2744
+ });
2745
+
2660
2746
  it('copy_file keeps the same order: the write refusal, not what is in the folder', async () => {
2661
2747
  const base = await seeded();
2662
2748
 
@@ -3310,6 +3396,7 @@ describe('a write refused for permissions says whether and how to propose it', (
3310
3396
  canDownload: async () => true,
3311
3397
  canOwner: async () => true,
3312
3398
  canWriteBatchAtRef: async () => null,
3399
+ previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
3313
3400
  eligibleWritersAtRef: async () => ({ roles: ['Sales Lead'], users: [{ name: 'Owner', email: 'owner@x' }] }),
3314
3401
  } as unknown as IAccessControl;
3315
3402
  return { ac, calls };
@@ -3718,3 +3805,587 @@ describe('a path with nothing at it answers 404 not_found on every file tool', (
3718
3805
  }
3719
3806
  });
3720
3807
  });
3808
+
3809
+ /**
3810
+ * The neutral session hooks, through the tool routes.
3811
+ *
3812
+ * Hexis decides nothing about which paths a conversation may touch: it calls a
3813
+ * registered hook before every agent read and every agent write of a
3814
+ * knowledge-base path, hands it what it knows about the call, and lets a hook
3815
+ * that throws refuse the operation with its own message and status. With no
3816
+ * hook registered — every Hexis-only deployment — nothing is refused, nothing
3817
+ * is recorded, and a call without a `sessionId` is an ordinary call.
3818
+ */
3819
+ describe('agent read/write hooks', () => {
3820
+ /** Every call the read hook saw, in order. */
3821
+ let reads: AgentOperationContext[];
3822
+ /** Every call the write hook saw, in order. */
3823
+ let writes: AgentOperationContext[];
3824
+
3825
+ /** Register recording hooks on the mounted registry (after `start()`). */
3826
+ const record = (): void => {
3827
+ reads = [];
3828
+ writes = [];
3829
+ hooks.onAgentRead(async (op) => {
3830
+ reads.push(op);
3831
+ });
3832
+ hooks.onPreWrite(async (op) => {
3833
+ writes.push(op);
3834
+ });
3835
+ };
3836
+
3837
+ /** Register a write hook that refuses `path` (or every path) with a 403. */
3838
+ const refuseWrites = (message: string, path?: string): void => {
3839
+ hooks.onPreWrite(async (op) => {
3840
+ if (path === undefined || op.wsPath === path) throw new ToolError(message, 403);
3841
+ });
3842
+ };
3843
+
3844
+ beforeEach(() => {
3845
+ reads = [];
3846
+ writes = [];
3847
+ });
3848
+
3849
+ it('read_file calls the read hook once with the session, path, branch, user and source', async () => {
3850
+ const base = await start();
3851
+ record();
3852
+ await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
3853
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/Roadmap.md`, 'plans');
3854
+ const res = await post(`${base}/api/agent/tools/read_file`, {
3855
+ branch: 'draft-1',
3856
+ path: `${KB_DIR}/KnowledgeBase/Product/Knowledge/Roadmap.md`,
3857
+ sessionId: 's1',
3858
+ });
3859
+ expect(res.status).toBe(200);
3860
+ expect(reads).toEqual([
3861
+ {
3862
+ sessionId: 's1',
3863
+ wsPath: `${KB_DIR}/KnowledgeBase/Product/Knowledge/Roadmap.md`,
3864
+ branch: 'draft-1',
3865
+ user: { id: 'u', email: 'e@x', name: 'N' },
3866
+ source: 'internal',
3867
+ },
3868
+ ]);
3869
+ expect(writes).toEqual([]);
3870
+ });
3871
+
3872
+ it('the read hook covers list_files, file_stat, grep, delete_file and delete_folder', async () => {
3873
+ const base = await start();
3874
+ record();
3875
+ await fs.mkdir(`${KB_DIR}/Folder`, { recursive: true });
3876
+ await fs.writeFile(`${KB_DIR}/Folder/one.md`, 'needle');
3877
+ await fs.writeFile(`${KB_DIR}/gone.md`, 'x');
3878
+ await post(`${base}/api/agent/tools/list_files`, { path: `${KB_DIR}/Folder`, sessionId: 's1' });
3879
+ await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/Folder/one.md`, sessionId: 's1' });
3880
+ await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: `${KB_DIR}/Folder`, sessionId: 's1' });
3881
+ await post(`${base}/api/agent/tools/delete_file`, { path: `${KB_DIR}/gone.md`, sessionId: 's1' });
3882
+ await post(`${base}/api/agent/tools/delete_folder`, { path: `${KB_DIR}/Folder`, confirm: true, sessionId: 's1' });
3883
+ const paths = reads.map((r) => r.wsPath);
3884
+ expect(paths).toContain(`${KB_DIR}/Folder`);
3885
+ expect(paths).toContain(`${KB_DIR}/Folder/one.md`);
3886
+ expect(paths).toContain(`${KB_DIR}/gone.md`);
3887
+ // A delete carries no bytes from elsewhere, so it is a read, never a write.
3888
+ expect(writes).toEqual([]);
3889
+ });
3890
+
3891
+ it('grep tells the read hook about every file the walk opens, not only its root', async () => {
3892
+ const base = await start();
3893
+ record();
3894
+ await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
3895
+ await fs.mkdir(`${KB_DIR}/KnowledgeBase/Legal/Knowledge`, { recursive: true });
3896
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`, 'needle');
3897
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`, 'needle');
3898
+ await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: KB_DIR, sessionId: 's1' });
3899
+ const paths = reads.map((r) => r.wsPath);
3900
+ expect(paths).toContain(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`);
3901
+ expect(paths).toContain(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`);
3902
+ });
3903
+
3904
+ it('the write hook covers write_file, edit_file and mkdir, once per path', async () => {
3905
+ const base = await start();
3906
+ record();
3907
+ await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/new.md`, content: 'hi', sessionId: 's1' });
3908
+ await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'world', new_string: 'earth', sessionId: 's1' });
3909
+ await post(`${base}/api/agent/tools/mkdir`, { path: `${KB_DIR}/dir`, sessionId: 's1' });
3910
+ expect(writes.map((w) => w.wsPath)).toEqual([`${KB_DIR}/new.md`, `${KB_DIR}/a.md`, `${KB_DIR}/dir`]);
3911
+ expect(writes.every((w) => w.sessionId === 's1' && w.branch === 'main')).toBe(true);
3912
+ });
3913
+
3914
+ it('move_file and copy_file call the write hook for the source path AND the destination', async () => {
3915
+ const base = await start();
3916
+ record();
3917
+ await fs.writeFile(`${KB_DIR}/src.md`, 'body');
3918
+ await fs.writeFile(`${KB_DIR}/copy-me.md`, 'body');
3919
+ await post(`${base}/api/agent/tools/move_file`, { src: `${KB_DIR}/src.md`, dest: `${KB_DIR}/moved.md`, confirm: true, sessionId: 's1' });
3920
+ await post(`${base}/api/agent/tools/copy_file`, { src: `${KB_DIR}/copy-me.md`, dest: `${KB_DIR}/copied.md`, sessionId: 's1' });
3921
+ expect(writes.map((w) => w.wsPath)).toEqual([
3922
+ `${KB_DIR}/src.md`,
3923
+ `${KB_DIR}/moved.md`,
3924
+ `${KB_DIR}/copy-me.md`,
3925
+ `${KB_DIR}/copied.md`,
3926
+ ]);
3927
+ });
3928
+
3929
+ it('unzip reads the archive and calls the write hook once per extracted entry', async () => {
3930
+ const base = await start();
3931
+ record();
3932
+ await fs.writeFile(`${KB_DIR}/a.zip`, 'not-really-a-zip');
3933
+ unzipEntries = [`${KB_DIR}/out/one.md`, `${KB_DIR}/out/two.md`];
3934
+ const res = await post(`${base}/api/agent/tools/unzip`, { path: `${KB_DIR}/a.zip`, sessionId: 's1' });
3935
+ expect(res.status).toBe(200);
3936
+ expect(reads.map((r) => r.wsPath)).toEqual([`${KB_DIR}/a.zip`]);
3937
+ expect(writes.map((w) => w.wsPath)).toEqual([`${KB_DIR}/out/one.md`, `${KB_DIR}/out/two.md`]);
3938
+ });
3939
+
3940
+ it('execute_command calls the write hook once, with no path', async () => {
3941
+ const base = await start();
3942
+ record();
3943
+ await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'echo hi', sessionId: 's1' });
3944
+ expect(writes).toHaveLength(1);
3945
+ expect(writes[0].wsPath).toBeUndefined();
3946
+ expect(writes[0]).toMatchObject({ sessionId: 's1', branch: 'main', source: 'internal' });
3947
+ expect(reads).toEqual([]);
3948
+ });
3949
+
3950
+ it('a write hook that throws refuses the write with its own message and status, and the file is unchanged', async () => {
3951
+ const base = await start();
3952
+ record();
3953
+ refuseWrites('Not in this conversation.');
3954
+ const res = await post(`${base}/api/agent/tools/write_file`, {
3955
+ path: `${KB_DIR}/a.md`,
3956
+ // `overwrite`, so the unchanged file below is the hook's doing: with no
3957
+ // hook this call WOULD replace `a.md`.
3958
+ mode: 'overwrite',
3959
+ content: 'clobbered',
3960
+ sessionId: 's1',
3961
+ });
3962
+ expect(res.status).toBe(403);
3963
+ expect(((await res.json()) as { error: string }).error).toBe('Not in this conversation.');
3964
+ expect(await readFile(join(tempDir, `${KB_DIR}/a.md`), 'utf8')).toBe('hello\nworld\n');
3965
+ });
3966
+
3967
+ it('a read hook that throws refuses the read with its own message and status', async () => {
3968
+ const base = await start();
3969
+ hooks.onAgentRead(async () => {
3970
+ throw new ToolError('Not in this conversation.', 403);
3971
+ });
3972
+ const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md`, sessionId: 's1' });
3973
+ expect(res.status).toBe(403);
3974
+ expect(((await res.json()) as { error: string }).error).toBe('Not in this conversation.');
3975
+ });
3976
+
3977
+ it('write_files reports the refused path only; the other two land', async () => {
3978
+ const base = await start();
3979
+ record();
3980
+ refuseWrites('Not in this conversation.', `${KB_DIR}/two.md`);
3981
+ const res = await post(`${base}/api/agent/tools/write_files`, {
3982
+ files: [
3983
+ { path: `${KB_DIR}/one.md`, content: '1' },
3984
+ { path: `${KB_DIR}/two.md`, content: '2' },
3985
+ { path: `${KB_DIR}/three.md`, content: '3' },
3986
+ ],
3987
+ sessionId: 's1',
3988
+ });
3989
+ expect(res.status).toBe(200);
3990
+ const body = (await res.json()) as { count: number; files: { path: string; outcome: string; message?: string }[] };
3991
+ expect(body.files.map((f) => f.outcome)).toEqual(['created', 'refused', 'created']);
3992
+ expect(body.files[1].message).toBe('Not in this conversation.');
3993
+ expect(body.count).toBe(2);
3994
+ expect(await readFile(join(tempDir, `${KB_DIR}/one.md`), 'utf8')).toBe('1');
3995
+ expect(await readFile(join(tempDir, `${KB_DIR}/three.md`), 'utf8')).toBe('3');
3996
+ });
3997
+
3998
+ it('a write hook that fails unexpectedly fails the whole write_files batch, rather than reading as a refusal', async () => {
3999
+ const base = await start();
4000
+ record();
4001
+ // Not a ToolError: the hook did not JUDGE this path, the gate itself
4002
+ // broke. Reporting that as `refused` would let the other paths commit
4003
+ // past a gate that never ran.
4004
+ hooks.onPreWrite(async (op) => {
4005
+ if (op.wsPath === `${KB_DIR}/two.md`) throw new Error('the hook store is down');
4006
+ });
4007
+ const res = await post(`${base}/api/agent/tools/write_files`, {
4008
+ files: [
4009
+ { path: `${KB_DIR}/one.md`, content: '1' },
4010
+ { path: `${KB_DIR}/two.md`, content: '2' },
4011
+ { path: `${KB_DIR}/three.md`, content: '3' },
4012
+ ],
4013
+ sessionId: 's1',
4014
+ });
4015
+ expect(res.status).toBe(500);
4016
+ for (const name of ['one.md', 'two.md', 'three.md']) {
4017
+ await expect(readFile(join(tempDir, `${KB_DIR}/${name}`), 'utf8')).rejects.toThrow();
4018
+ }
4019
+ });
4020
+
4021
+ it('unzip skips the refused entry with the hook\'s message and extracts the others', async () => {
4022
+ const base = await start();
4023
+ record();
4024
+ await fs.writeFile(`${KB_DIR}/a.zip`, 'not-really-a-zip');
4025
+ unzipEntries = [`${KB_DIR}/out/keep.md`, `${KB_DIR}/out/blocked.md`, `${KB_DIR}/out/also-keep.md`];
4026
+ refuseWrites('Not in this conversation.', `${KB_DIR}/out/blocked.md`);
4027
+ const res = await post(`${base}/api/agent/tools/unzip`, { path: `${KB_DIR}/a.zip`, sessionId: 's1' });
4028
+ const body = (await res.json()) as { extracted: string[]; skipped: { path: string; reason: string }[] };
4029
+ expect(body.extracted).toEqual([`${KB_DIR}/out/keep.md`, `${KB_DIR}/out/also-keep.md`]);
4030
+ expect(body.skipped).toEqual([{ path: `${KB_DIR}/out/blocked.md`, reason: 'Not in this conversation.' }]);
4031
+ });
4032
+
4033
+ it('a hook is called with no session id when the call carried none — what happens next is its decision', async () => {
4034
+ const base = await start();
4035
+ record();
4036
+ await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` });
4037
+ await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/fresh.md`, content: 'x' });
4038
+ expect(reads).toHaveLength(1);
4039
+ expect(reads[0].sessionId).toBeUndefined();
4040
+ expect(writes).toHaveLength(1);
4041
+ expect(writes[0].sessionId).toBeUndefined();
4042
+ });
4043
+
4044
+ it('a person saving in the app never reaches the hooks', async () => {
4045
+ const base = await start();
4046
+ record();
4047
+ callerSource = 'session';
4048
+ await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md`, sessionId: 's1' });
4049
+ await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/fresh.md`, content: 'x', sessionId: 's1' });
4050
+ expect(reads).toEqual([]);
4051
+ expect(writes).toEqual([]);
4052
+ });
4053
+
4054
+ it('the recovery bot never reaches the hooks', async () => {
4055
+ const base = await start('write', allowAll, RECOVERY_BOT);
4056
+ record();
4057
+ refuseWrites('Not in this conversation.');
4058
+ const res = await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/fresh.md`, content: 'x', sessionId: 's1' });
4059
+ expect(res.status).toBe(200);
4060
+ expect(reads).toEqual([]);
4061
+ expect(writes).toEqual([]);
4062
+ });
4063
+
4064
+ /**
4065
+ * The Hexis-only deployment: nothing registered at all. No call is refused,
4066
+ * and a file tool call without a `sessionId` succeeds — including one that
4067
+ * read under two different folders first.
4068
+ */
4069
+ it('with no hook registered, reads and writes without a sessionId all succeed', async () => {
4070
+ const base = await start();
4071
+ await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
4072
+ await fs.mkdir(`${KB_DIR}/KnowledgeBase/Legal/Knowledge`, { recursive: true });
4073
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`, 'p');
4074
+ await fs.writeFile(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`, 'l');
4075
+ expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md` })).status).toBe(200);
4076
+ expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md` })).status).toBe(200);
4077
+ const wrote = await post(`${base}/api/agent/tools/write_file`, {
4078
+ path: `${KB_DIR}/KnowledgeBase/Product/Knowledge/New.md`,
4079
+ content: 'fresh',
4080
+ });
4081
+ expect(wrote.status).toBe(200);
4082
+ expect(await readFile(join(tempDir, `${KB_DIR}/KnowledgeBase/Product/Knowledge/New.md`), 'utf8')).toBe('fresh');
4083
+ });
4084
+ });
4085
+
4086
+ /**
4087
+ * What an agent READS about these tools. A Hexis-only deployment names no
4088
+ * boundary and no ontology; a deployment that has one registers the wording
4089
+ * for it, and it lands on the gated tools and on the `sessionId` input.
4090
+ */
4091
+ describe('tool descriptions and the deployment note', () => {
4092
+ /** Every tool def on the external surface, by name. */
4093
+ const defs = async (): Promise<Map<string, { description: string; inputs: unknown }>> =>
4094
+ new Map((await toolRegistry.listExternal()).map((t) => [t.name, t as unknown as { description: string; inputs: unknown }]));
4095
+
4096
+ /** The `sessionId` input's description on a built def, or undefined. */
4097
+ const sessionIdDescriptionOf = (def: { inputs: unknown }): string | undefined =>
4098
+ (def.inputs as { properties?: { body?: { properties?: Record<string, { description?: string }> } } })
4099
+ .properties?.body?.properties?.sessionId?.description;
4100
+
4101
+ it('no tool description and no input description contains the word "ontology"', async () => {
4102
+ await start();
4103
+ for (const tool of await toolRegistry.listExternal()) {
4104
+ expect(tool.description.toLowerCase(), `${tool.name} description`).not.toContain('ontolog');
4105
+ expect(JSON.stringify(tool.inputs).toLowerCase(), `${tool.name} inputs`).not.toContain('ontolog');
4106
+ }
4107
+ });
4108
+
4109
+ it('start_session says what the id is — the conversation\'s, shared with `ask` — and names no ontology', async () => {
4110
+ await start();
4111
+ const description = (await defs()).get('start_session')?.description ?? '';
4112
+ expect(description).toMatch(/conversation/i);
4113
+ expect(description).toContain('`ask`');
4114
+ expect(description.toLowerCase()).not.toContain('ontolog');
4115
+ });
4116
+
4117
+ it('the file tools still accept a sessionId, described as this conversation\'s id', async () => {
4118
+ await start();
4119
+ const all = await defs();
4120
+ for (const name of ['read_file', 'write_file', 'edit_file', 'grep', 'unzip']) {
4121
+ expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(SESSION_ID_DESCRIPTION);
4122
+ }
4123
+ });
4124
+
4125
+ it('a Hexis-only deployment registers no note, so nothing is appended', async () => {
4126
+ await start();
4127
+ const all = await defs();
4128
+ expect(notes.gatedToolNote()).toBe('');
4129
+ expect(all.get('read_file')!.description).not.toContain('Stay within one');
4130
+ });
4131
+
4132
+ it('a registered note lands at the END of every gated tool\'s description and on the sessionId input', async () => {
4133
+ await start();
4134
+ notes.registerGatedToolNote(' One folder per conversation.');
4135
+ notes.registerSessionIdNote(' It also pins that folder.');
4136
+ const all = await defs();
4137
+ for (const name of ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip']) {
4138
+ expect(all.get(name)!.description.endsWith(' One folder per conversation.'), name).toBe(true);
4139
+ expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(`${SESSION_ID_DESCRIPTION} It also pins that folder.`);
4140
+ }
4141
+ // `execute_command` is internal-only, so it is checked on that surface.
4142
+ const internal = new Map((await toolRegistry.listInternal()).map((t) => [t.name, t]));
4143
+ expect(internal.get('execute_command')!.description.endsWith(' One folder per conversation.')).toBe(true);
4144
+ });
4145
+
4146
+ it('a tool that is not gated carries no note', async () => {
4147
+ await start();
4148
+ notes.registerGatedToolNote(' One folder per conversation.');
4149
+ const description = (await defs()).get('start_session')?.description ?? '';
4150
+ expect(description).not.toContain('One folder per conversation.');
4151
+ });
4152
+ });
4153
+
4154
+ /**
4155
+ * The bug Juan reported: the dry run of renaming a top-level folder warned he
4156
+ * would lose owner access, and after the move he was still owner. The folder
4157
+ * carried its own `access.md`, which moved with it, and the preview judged
4158
+ * the destination as it stood — where neither the folder nor its rules were
4159
+ * yet.
4160
+ *
4161
+ * These run against the REAL resolver over a real tree, because the thing
4162
+ * under test is what the rules say about a path nothing is at. Every preview
4163
+ * is then held against the operation it predicted: the caller's access at the
4164
+ * destination afterwards must be what the preview answered, or the preview is
4165
+ * wrong again and nobody will find out until the next report.
4166
+ */
4167
+ describe('a move preview judges the destination as it will be', () => {
4168
+ const KB = (p: string) => `${KB_DIR}/${p}`;
4169
+ const MOVER = 'mover@x.io';
4170
+ const rules = (body: string) => `---\n${body}---\n`;
4171
+
4172
+ /**
4173
+ * The real `AccessControlService`, re-read from disk on every question.
4174
+ * In production the file-change notifier invalidates the model after a
4175
+ * write; a test leaning on the five-second cache would be asserting the
4176
+ * cache rather than what the tree now says.
4177
+ */
4178
+ const liveRules = (): IAccessControl => {
4179
+ const fresh = () =>
4180
+ new AccessControlService(
4181
+ {
4182
+ getWorkspacePath: async () => tempDir,
4183
+ ensureRemotesFetched: async () => undefined,
4184
+ } as unknown as WorkspaceService,
4185
+ KB_DIR,
4186
+ new NodeFs(),
4187
+ );
4188
+ return {
4189
+ canRead: (w: string, u: string, r: string) => fresh().canRead(w, u, r),
4190
+ canReadBatch: (w: string, u: string, r: string[]) => fresh().canReadBatch(w, u, r),
4191
+ canWrite: (w: string, u: string, r: string) => fresh().canWrite(w, u, r),
4192
+ canDownload: (w: string, u: string, r: string) => fresh().canDownload(w, u, r),
4193
+ canOwner: (w: string, u: string, r: string) => fresh().canOwner(w, u, r),
4194
+ canWriteBatchAtRef: async () => null,
4195
+ previewAccessAfterRelocation: (
4196
+ w: string, u: string, from: string, to: string, opts?: { sourceRemains?: boolean },
4197
+ ) => fresh().previewAccessAfterRelocation(w, u, from, to, opts),
4198
+ } as unknown as IAccessControl;
4199
+ };
4200
+
4201
+ const call = async (base: string, tool: string, body: Record<string, unknown>) => {
4202
+ const res = await post(`${base}/api/agent/tools/${tool}`, body);
4203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- a tool body is free-form JSON, probed field by field
4204
+ return { status: res.status, body: (await res.json()) as Record<string, any> };
4205
+ };
4206
+
4207
+ /**
4208
+ * `Sales/` grants the caller write and owner in its OWN access.md. `Work/`
4209
+ * grants both too, so `Work/Team` — which grants only write of its own —
4210
+ * holds owner by inheritance and loses it on the way out. `Legal/` grants
4211
+ * read to everyone and nothing else. The root grants read and nothing else,
4212
+ * so a folder that arrives there arrives with only what it brought.
4213
+ */
4214
+ async function seeded(): Promise<string> {
4215
+ const base = await start('write', liveRules(), MOVER);
4216
+ await fs.writeFile(KB('roles.yaml'), 'roles:\n Admin:\n - admin@x.io\n');
4217
+ await fs.writeFile(KB('access.md'), rules('read:\n - everyone\n'));
4218
+ await fs.writeFile(KB('Sales/access.md'), rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`));
4219
+ await fs.writeFile(KB('Sales/deal.md'), 'deal');
4220
+ await fs.writeFile(KB('Work/access.md'), rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`));
4221
+ await fs.writeFile(KB('Work/Notes/note.md'), 'note');
4222
+ await fs.writeFile(KB('Work/Team/access.md'), rules(`write:\n - Mover <${MOVER}>\n`));
4223
+ await fs.writeFile(KB('Work/Team/plan.md'), 'plan');
4224
+ await fs.writeFile(KB('Legal/access.md'), rules('read:\n - everyone\n'));
4225
+ return base;
4226
+ }
4227
+
4228
+ /** The caller's verbs where the thing really is now, through `file_stat`. */
4229
+ const verbsAt = async (base: string, path: string) =>
4230
+ (await call(base, 'file_stat', { path })).body.access;
4231
+
4232
+ it('renaming a folder that names the caller owner previews no loss at all', async () => {
4233
+ const base = await seeded();
4234
+ const args = { src: KB('Sales'), dest: KB('Revenue') };
4235
+
4236
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
4237
+ expect(dry.status).toBe(200);
4238
+ expect(dry.body).toMatchObject({ kind: 'folder', accessChanges: false, allowed: true, moved: false });
4239
+ expect(dry.body.access.after).toEqual(dry.body.access.before);
4240
+ expect(dry.body.access.after.owner).toBe(true);
4241
+ // Nothing moved and nothing was written to answer the question.
4242
+ expect(await fs.exists(args.dest)).toBe(false);
4243
+ expect(await fs.exists(KB('Sales/access.md'))).toBe(true);
4244
+
4245
+ // `accessChanges: false`, so the move itself needs no confirmation.
4246
+ expect((await call(base, 'move_file', args)).body).toMatchObject({ moved: true });
4247
+ expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
4248
+ });
4249
+
4250
+ it('moving a folder with no access.md of its own still previews the loss', async () => {
4251
+ const base = await seeded();
4252
+ const args = { src: KB('Work/Notes'), dest: KB('Legal/Notes') };
4253
+
4254
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
4255
+ expect(dry.body).toMatchObject({ accessChanges: true, allowed: true });
4256
+ expect(dry.body.access.before.write).toBe(true);
4257
+ expect(dry.body.access.after.write).toBe(false);
4258
+
4259
+ // A changed answer still asks before it moves.
4260
+ expect((await call(base, 'move_file', args)).body).toMatchObject({ confirmationRequired: true, moved: false });
4261
+ expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
4262
+ expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
4263
+ });
4264
+
4265
+ it('what the folder inherited is left behind; what its own access.md gives comes along', async () => {
4266
+ const base = await seeded();
4267
+ const args = { src: KB('Work/Team'), dest: KB('Legal/Team') };
4268
+
4269
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
4270
+ // Write is the folder's own and travels; owner came from `Work/`.
4271
+ expect(dry.body.access.before).toMatchObject({ write: true, owner: true });
4272
+ expect(dry.body.access.after).toMatchObject({ write: true, owner: false });
4273
+
4274
+ expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
4275
+ expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
4276
+ });
4277
+
4278
+ it('a nested access.md governs where it lands, not the folder above it', async () => {
4279
+ const base = await seeded();
4280
+ const args = { src: KB('Work/Team'), dest: KB('Legal/Team') };
4281
+ const withoutNested = (await call(base, 'move_file', { ...args, dryRun: true })).body.access.after;
4282
+
4283
+ // `Sub/` shuts the caller out and moves too, but it governs `Legal/Team/Sub`.
4284
+ await fs.writeFile(KB('Work/Team/Sub/access.md'), rules(`read:\n - deny Mover <${MOVER}>\n`));
4285
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
4286
+ expect(dry.body.access.after).toEqual(withoutNested);
4287
+
4288
+ expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
4289
+ expect(await verbsAt(base, args.dest)).toEqual(withoutNested);
4290
+ // The nested file did land and does govern where it landed: the caller
4291
+ // it shuts out cannot even stat the folder now.
4292
+ expect((await call(base, 'file_stat', { path: KB('Legal/Team/Sub') })).status).toBe(403);
4293
+ });
4294
+
4295
+ it('moving a single file previews as it always did', async () => {
4296
+ const base = await seeded();
4297
+ const args = { src: KB('Sales/deal.md'), dest: KB('Legal/deal.md') };
4298
+
4299
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
4300
+ expect(dry.body).toMatchObject({ kind: 'file', descendants: 1 });
4301
+ // Nothing travels with a file but its own bytes, so the answer is the
4302
+ // destination's own rules — what `file_stat` says there already.
4303
+ expect(dry.body.access.after).toEqual(await verbsAt(base, KB('Legal')));
4304
+
4305
+ expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
4306
+ expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
4307
+ });
4308
+
4309
+ it('the preview answers the caller\'s own verbs and nothing more', async () => {
4310
+ const base = await seeded();
4311
+ const dry = await call(base, 'move_file', { src: KB('Sales'), dest: KB('Revenue'), dryRun: true });
4312
+
4313
+ expect(Object.keys(dry.body.access).sort()).toEqual(['after', 'before']);
4314
+ for (const side of ['before', 'after'] as const) {
4315
+ expect(Object.keys(dry.body.access[side]).sort()).toEqual(['download', 'owner', 'read', 'write']);
4316
+ }
4317
+ });
4318
+
4319
+ describe('copy_file previews the same way', () => {
4320
+ it('a folder copy counts the copied access.md at the destination', async () => {
4321
+ const base = await seeded();
4322
+ const args = { src: KB('Sales'), dest: KB('Sales-Copy') };
4323
+
4324
+ const dry = await call(base, 'copy_file', { ...args, dryRun: true });
4325
+ expect(dry.status).toBe(200);
4326
+ expect(dry.body).toMatchObject({ kind: 'folder', accessChanges: false, copied: false, dryRun: true });
4327
+ expect(dry.body.access.after.owner).toBe(true);
4328
+ expect(dry.body.access.after).toEqual(dry.body.access.before);
4329
+
4330
+ // `copy_file` copies ONE FILE, which the preview says rather than
4331
+ // promising a copy that cannot land — and the call itself refuses with
4332
+ // the same sentence, so preflight and execution never disagree.
4333
+ expect(dry.body.allowed).toBe(false);
4334
+ const run = await call(base, 'copy_file', args);
4335
+ expect(run.status).toBe(400);
4336
+ expect(run.body.error).toBe(dry.body.reason);
4337
+ expect(await fs.exists(args.dest)).toBe(false);
4338
+ });
4339
+
4340
+ it('a file copy previews what the caller will have at the destination', async () => {
4341
+ const base = await seeded();
4342
+ const args = { src: KB('Sales/deal.md'), dest: KB('Legal/deal.md') };
4343
+
4344
+ const dry = await call(base, 'copy_file', { ...args, dryRun: true });
4345
+ expect(dry.body).toMatchObject({ kind: 'file', descendants: 1, allowed: true, copied: false });
4346
+ expect(dry.body.access.before.write).toBe(true);
4347
+ expect(dry.body.access.after.write).toBe(false);
4348
+ expect(dry.body.accessChanges).toBe(true);
4349
+ expect(await fs.exists(args.dest)).toBe(false);
4350
+
4351
+ expect((await call(base, 'copy_file', args)).body).toMatchObject({ copied: true });
4352
+ expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
4353
+ // A copy leaves the source exactly as it was.
4354
+ expect(await verbsAt(base, args.src)).toEqual(dry.body.access.before);
4355
+ });
4356
+
4357
+ /**
4358
+ * `copy_file` will copy a lone `access.md`, and the moment it lands it
4359
+ * governs the folder it landed in. The preview counts it: the rules a
4360
+ * copy carries are the point of this ticket whether they travel inside a
4361
+ * folder or on their own.
4362
+ */
4363
+ it('a lone access.md previews the access it will give at the destination', async () => {
4364
+ const base = await seeded();
4365
+ // A folder with no rules of its own, so the destination's answer today
4366
+ // is the root's: read and nothing else. (`Legal/access.md` is taken, and
4367
+ // a copy onto a name that exists is refused long before access is asked.)
4368
+ await fs.writeFile(KB('Open/note.md'), 'note');
4369
+ const args = { src: KB('Sales/access.md'), dest: KB('Open/access.md') };
4370
+
4371
+ const dry = await call(base, 'copy_file', { ...args, dryRun: true });
4372
+ expect(dry.body).toMatchObject({ kind: 'file', descendants: 1, allowed: true });
4373
+ expect(await verbsAt(base, KB('Open'))).toMatchObject({ write: false, owner: false });
4374
+ expect(dry.body.access.after).toMatchObject({ write: true, owner: true });
4375
+ expect(dry.body.accessChanges).toBe(false);
4376
+
4377
+ expect((await call(base, 'copy_file', args)).body).toMatchObject({ copied: true });
4378
+ expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
4379
+ });
4380
+
4381
+ it('a destination that is taken is named, and the dry run changes nothing', async () => {
4382
+ const base = await seeded();
4383
+ const args = { src: KB('Sales/deal.md'), dest: KB('Work/Notes/note.md') };
4384
+
4385
+ const dry = await call(base, 'copy_file', { ...args, dryRun: true });
4386
+ expect(dry.body.allowed).toBe(false);
4387
+ expect(dry.body.reason).toContain('already exists');
4388
+ expect(String(await fs.readFile(KB('Work/Notes/note.md'), { encoding: 'utf-8' }))).toContain('note');
4389
+ });
4390
+ });
4391
+ });