@bevel-software/platform-core-backend 0.29.0 → 0.31.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 (307) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.js +3 -3
  8. package/dist/core/create-core-server.d.ts.map +1 -1
  9. package/dist/core/create-core-server.js +3 -2
  10. package/dist/core/create-core-server.js.map +1 -1
  11. package/dist/core/create-core-services.d.ts +3 -0
  12. package/dist/core/create-core-services.d.ts.map +1 -1
  13. package/dist/core/create-core-services.js +35 -5
  14. package/dist/core/create-core-services.js.map +1 -1
  15. package/dist/core/lifecycle.d.ts +8 -0
  16. package/dist/core/lifecycle.d.ts.map +1 -1
  17. package/dist/core/lifecycle.js +4 -0
  18. package/dist/core/lifecycle.js.map +1 -1
  19. package/dist/modules/access/access-control.interface.d.ts +20 -0
  20. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.d.ts +32 -1
  22. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.js +135 -17
  24. package/dist/modules/access/access-control.service.js.map +1 -1
  25. package/dist/modules/access/access.routes.d.ts.map +1 -1
  26. package/dist/modules/access/access.routes.js +20 -6
  27. package/dist/modules/access/access.routes.js.map +1 -1
  28. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  29. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  30. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  31. package/dist/modules/audit/agent-audit.service.js +66 -6
  32. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  33. package/dist/modules/audit/audit.contract.d.ts +52 -8
  34. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  35. package/dist/modules/audit/audit.contract.js +37 -1
  36. package/dist/modules/audit/audit.contract.js.map +1 -1
  37. package/dist/modules/audit/audit.routes.d.ts +1 -1
  38. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  39. package/dist/modules/audit/audit.routes.js +15 -13
  40. package/dist/modules/audit/audit.routes.js.map +1 -1
  41. package/dist/modules/audit/request-audit.d.ts +21 -10
  42. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  43. package/dist/modules/audit/request-audit.js +26 -10
  44. package/dist/modules/audit/request-audit.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +22 -3
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +15 -6
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  50. package/dist/modules/diff/diff.service.js +7 -1
  51. package/dist/modules/diff/diff.service.js.map +1 -1
  52. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  53. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  54. package/dist/modules/kb-fs/git-dir.js +58 -0
  55. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  56. package/dist/modules/kb-fs/index.d.ts +3 -0
  57. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/index.js +3 -1
  59. package/dist/modules/kb-fs/index.js.map +1 -1
  60. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  61. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  62. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  63. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  64. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  65. package/dist/modules/kb-fs/mutex.js +14 -0
  66. package/dist/modules/kb-fs/mutex.js.map +1 -1
  67. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  68. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  69. package/dist/modules/kb-fs/ref-files.js +249 -0
  70. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  71. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  72. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  74. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  75. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  76. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  77. package/dist/modules/mcp/mcp.routes.js +67 -0
  78. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  79. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  80. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  81. package/dist/modules/mcp/mcp.service.js +257 -50
  82. package/dist/modules/mcp/mcp.service.js.map +1 -1
  83. package/dist/modules/mcp/request-context.d.ts +25 -0
  84. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  85. package/dist/modules/mcp/request-context.js +11 -0
  86. package/dist/modules/mcp/request-context.js.map +1 -0
  87. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  88. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  89. package/dist/modules/mcp/shared-definitions.js +117 -0
  90. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  91. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  92. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  93. package/dist/modules/mcp/surface-cache.js +104 -0
  94. package/dist/modules/mcp/surface-cache.js.map +1 -0
  95. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  96. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  97. package/dist/modules/plugins/teams.routes.js +9 -1
  98. package/dist/modules/plugins/teams.routes.js.map +1 -1
  99. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  100. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  101. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  102. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  103. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  104. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  105. package/dist/modules/tool-helpers/tool-context.js +33 -12
  106. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  108. package/dist/modules/tool-helpers/tool.contract.d.ts +10 -2
  109. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  110. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  114. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  115. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  117. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  118. package/dist/modules/workflow/git/git.service.d.ts +142 -4
  119. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  120. package/dist/modules/workflow/git/git.service.js +645 -128
  121. package/dist/modules/workflow/git/git.service.js.map +1 -1
  122. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  123. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  124. package/dist/modules/workflow/read-on-branch.js +58 -0
  125. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  126. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  127. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  128. package/dist/modules/workflow/workflow.routes.js +22 -41
  129. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  130. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  131. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  132. package/dist/modules/workflow/workflow.service.js +163 -16
  133. package/dist/modules/workflow/workflow.service.js.map +1 -1
  134. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  135. package/dist/modules/workspace/agent-access.gate.js +2 -2
  136. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  137. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  138. package/dist/modules/workspace/bounded-matcher.js +261 -0
  139. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  140. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  141. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  142. package/dist/modules/workspace/fetch-instant.js +13 -0
  143. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  144. package/dist/modules/workspace/index.d.ts +9 -0
  145. package/dist/modules/workspace/index.d.ts.map +1 -1
  146. package/dist/modules/workspace/index.js +10 -0
  147. package/dist/modules/workspace/index.js.map +1 -1
  148. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  149. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  150. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  151. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  152. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  153. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  154. package/dist/modules/workspace/kb-repository.js +811 -0
  155. package/dist/modules/workspace/kb-repository.js.map +1 -0
  156. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  157. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  158. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  159. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  160. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  161. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  162. package/dist/modules/workspace/routine-write-policy.js +0 -5
  163. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  164. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  165. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  166. package/dist/modules/workspace/set-aside-clone.js +139 -0
  167. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  168. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  169. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  170. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  171. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  172. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  173. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.service.js +240 -161
  175. package/dist/modules/workspace/workspace.service.js.map +1 -1
  176. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  177. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.tools.js +162 -336
  179. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  180. package/dist/shared/git.contract.d.ts +7 -0
  181. package/dist/shared/git.contract.d.ts.map +1 -1
  182. package/dist/shared/git.contract.js +9 -0
  183. package/dist/shared/git.contract.js.map +1 -1
  184. package/dist/shared/no-link-below.d.ts +10 -0
  185. package/dist/shared/no-link-below.d.ts.map +1 -0
  186. package/dist/shared/no-link-below.js +30 -0
  187. package/dist/shared/no-link-below.js.map +1 -0
  188. package/dist/test-setup.js +4 -0
  189. package/dist/test-setup.js.map +1 -1
  190. package/migrations/0018_agent_events_page.sql +11 -0
  191. package/migrations/meta/0018_snapshot.json +2429 -0
  192. package/migrations/meta/_journal.json +8 -1
  193. package/package.json +3 -3
  194. package/src/__tests__/test-repositories.ts +22 -0
  195. package/src/__tests__/workspace-service.ts +69 -0
  196. package/src/core/__tests__/lifecycle.test.ts +16 -0
  197. package/src/core/catalog-revision.ts +3 -3
  198. package/src/core/create-core-server.ts +3 -2
  199. package/src/core/create-core-services.ts +39 -4
  200. package/src/core/lifecycle.ts +10 -0
  201. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
  202. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  203. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  204. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  205. package/src/modules/access/access-control.interface.ts +22 -0
  206. package/src/modules/access/access-control.service.ts +138 -15
  207. package/src/modules/access/access.routes.ts +22 -6
  208. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  209. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  210. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  211. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  212. package/src/modules/audit/agent-audit.service.ts +66 -6
  213. package/src/modules/audit/audit.contract.ts +62 -10
  214. package/src/modules/audit/audit.routes.ts +14 -12
  215. package/src/modules/audit/request-audit.ts +28 -13
  216. package/src/modules/database/core-schema.ts +15 -6
  217. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  218. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  219. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  220. package/src/modules/diff/diff.service.ts +6 -4
  221. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  222. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  223. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  224. package/src/modules/kb-fs/git-dir.ts +53 -0
  225. package/src/modules/kb-fs/index.ts +3 -0
  226. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  227. package/src/modules/kb-fs/mutex.ts +16 -0
  228. package/src/modules/kb-fs/ref-files.ts +279 -0
  229. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  230. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  231. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  232. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  233. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  234. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  235. package/src/modules/mcp/mcp.routes.ts +68 -0
  236. package/src/modules/mcp/mcp.service.ts +315 -51
  237. package/src/modules/mcp/request-context.ts +34 -0
  238. package/src/modules/mcp/shared-definitions.ts +121 -0
  239. package/src/modules/mcp/surface-cache.ts +154 -0
  240. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  241. package/src/modules/plugins/teams.routes.ts +14 -1
  242. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  243. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  244. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  245. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  246. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  247. package/src/modules/tool-helpers/tool-context.ts +37 -18
  248. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  249. package/src/modules/tool-helpers/tool.contract.ts +10 -2
  250. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  251. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
  252. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  253. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  254. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  255. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  256. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  257. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  258. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  259. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  260. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  261. package/src/modules/workflow/git/git.service.ts +719 -126
  262. package/src/modules/workflow/read-on-branch.ts +79 -0
  263. package/src/modules/workflow/workflow-hooks.ts +1 -1
  264. package/src/modules/workflow/workflow.routes.ts +21 -39
  265. package/src/modules/workflow/workflow.service.ts +203 -16
  266. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  267. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  268. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  269. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  270. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  271. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  272. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  273. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  274. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  275. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  276. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  278. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  279. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  280. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  281. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  282. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  283. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  284. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  285. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  286. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  287. package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
  288. package/src/modules/workspace/agent-access.gate.ts +2 -2
  289. package/src/modules/workspace/bounded-matcher.ts +328 -0
  290. package/src/modules/workspace/fetch-instant.ts +13 -0
  291. package/src/modules/workspace/index.ts +23 -0
  292. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  293. package/src/modules/workspace/kb-repository.ts +844 -0
  294. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  295. package/src/modules/workspace/routine-write-policy.ts +0 -15
  296. package/src/modules/workspace/set-aside-clone.ts +140 -0
  297. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  298. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  299. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  300. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  301. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  302. package/src/modules/workspace/workspace.service.ts +247 -169
  303. package/src/modules/workspace/workspace.tools.ts +179 -365
  304. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  305. package/src/shared/git.contract.ts +10 -0
  306. package/src/shared/no-link-below.ts +29 -0
  307. package/src/test-setup.ts +5 -0
@@ -8,6 +8,9 @@ import { createToolAuthMiddleware } from '../../../tool-auth/tool-auth.middlewar
8
8
  import { createToolContextResolver } from '../../../tool-helpers/tool-context.js';
9
9
  import { createToolHandlerFactory } from '../../../tool-helpers/tool-handler.js';
10
10
  import { createManualRoutes } from '../../../tool-registry/manual.routes.js';
11
+ import { ToolError } from '../../../tool-helpers/index.js';
12
+ import { WorkflowHooks } from '../../workflow-hooks.js';
13
+ import { ToolDescriptionNotes, type AgentAccessGate } from '../../../workspace/index.js';
11
14
  import { registerWorkflowTools } from '../workflow.tools.js';
12
15
  import {
13
16
  BranchAuthorshipError,
@@ -182,6 +185,76 @@ const workflowService = {
182
185
  calls.push(['postComment', number, input.path]);
183
186
  return { id: 'c-1' };
184
187
  },
188
+ // The history and syncing reads, answered from a fixed script.
189
+ listChangesForFile: async (ws: string, path: string, limit?: number) => {
190
+ calls.push(['listChangesForFile', ws, path, limit]);
191
+ return [{ sha: 'abc1234', authorName: 'Ada', authorEmail: 'ada@x.io', subject: 'edit', committedAt: '2026-10-16T00:00:00Z' }];
192
+ },
193
+ fileAtChange: async (ws: string, path: string, sha: string) => {
194
+ calls.push(['fileAtChange', ws, path, sha]);
195
+ return { baseline: 'before\n', current: 'after\n' };
196
+ },
197
+ fileAtBranch: async (ws: string, path: string, branch: string, authorize?: (commits: string[]) => Promise<void>) => {
198
+ calls.push(['fileAtBranch', ws, path, branch]);
199
+ // As the service: the verdict at every commit the branch resolves to,
200
+ // before anything about the path there. `me/moved` is a branch whose
201
+ // local head has moved past its published tip: two commits.
202
+ await authorize?.(branch === 'me/moved' ? ['tip-of-me/moved', 'local-of-me/moved'] : ['tip-of-' + branch]);
203
+ return { sha: 'tip-of-' + branch, ref: `refs/remotes/origin/${branch}`, content: `on ${branch}\n` };
204
+ },
205
+ showFileAtChange: async (ws: string, path: string, sha: string) => {
206
+ calls.push(['showFileAtChange', ws, path, sha]);
207
+ return '@@ patch of ' + sha;
208
+ },
209
+ compareFile: async (ws: string, path: string, from: string, to: string, authorize?: (s: { from: string[]; to: string[] }) => Promise<void>) => {
210
+ calls.push(['compareFile', ws, path, from, to]);
211
+ const commitsOf = (b: string) => (b === 'me/moved' ? ['tip-of-me/moved', 'local-of-me/moved'] : ['tip-of-' + b]);
212
+ await authorize?.({ from: commitsOf(from), to: commitsOf(to) });
213
+ return { diff: `@@ ${from}..${to}`, from: 'tip-of-' + from, to: 'tip-of-' + to };
214
+ },
215
+ branchStatus: async (ws: string) => {
216
+ calls.push(['branchStatus', ws]);
217
+ return { branch: ws, hasUpstream: true, unmergedFromUpstream: true };
218
+ },
219
+ listPendingChangePaths: async (ws: string) => {
220
+ calls.push(['listPendingChangePaths', ws]);
221
+ return ['knowledge-base/KnowledgeBase/Pending.md'];
222
+ },
223
+ resolveForkBase: async (ws: string, branch: string) => {
224
+ calls.push(['resolveForkBase', ws, branch]);
225
+ return 'main';
226
+ },
227
+ changedPathsBetweenBranches: async (ws: string, base: string, branch: string) => {
228
+ calls.push(['changedPathsBetweenBranches', ws, base, branch]);
229
+ return ['KnowledgeBase/Changed.md'];
230
+ },
231
+ updateBranch: async (
232
+ ws: string,
233
+ user: { id: string },
234
+ opts: { from?: string; resolutions?: { path: string; content?: string }[]; validateWrite?: (wsPath: string, content: string) => Promise<void> },
235
+ ) => {
236
+ calls.push(['updateBranch', ws, user.id, opts]);
237
+ // As the service: the caller's write gate is asked for every resolution that brings content.
238
+ for (const r of opts.resolutions ?? []) if (r.content !== undefined && opts.validateWrite) await opts.validateWrite(`knowledge-base/${r.path}`, r.content);
239
+ if (opts.from === 'me/conflicting' && opts.resolutions?.length) {
240
+ return { kind: 'merged', sha: 'resolved-sha', from: 'me/conflicting', resolvedPaths: opts.resolutions.map((r) => r.path) };
241
+ }
242
+ if (opts.from === 'me/conflicting') {
243
+ return {
244
+ kind: 'conflicts',
245
+ from: 'me/conflicting',
246
+ mergeBase: 'b',
247
+ ours: 'o',
248
+ theirs: 't',
249
+ unresolved: ['KnowledgeBase/Open.md', 'Secret/Plan.md'],
250
+ files: [
251
+ { path: 'KnowledgeBase/Open.md', base: 'b\n', ours: 'o\n', theirs: 't\n', merged: '<<<<<<<\n' },
252
+ { path: 'Secret/Plan.md', base: 'b\n', ours: 'o\n', theirs: 't\n', merged: '<<<<<<<\n' },
253
+ ],
254
+ };
255
+ }
256
+ return { kind: 'merged', sha: 'merge-sha', from: 'main', resolvedPaths: [] };
257
+ },
185
258
  } as never;
186
259
  const events = {
187
260
  emit: (p: unknown) => {
@@ -189,6 +262,54 @@ const events = {
189
262
  return {};
190
263
  },
191
264
  } as never;
265
+ /**
266
+ * The per-file read gate the history tools take. `Secret/` is readable by
267
+ * nobody on any branch; `me/hidden` is a branch whose refs grant nothing;
268
+ * `me/unknown` a branch none of whose refs resolve.
269
+ */
270
+ const accessControl = {
271
+ canRead: async (ws: string, email: string, rel: string) => {
272
+ calls.push(['canRead', ws, email, rel]);
273
+ return !rel.startsWith('Secret/');
274
+ },
275
+ canReadAtRef: async (ws: string, ref: string, email: string, rel: string) => {
276
+ calls.push(['canReadAtRef', ws, ref, email, rel]);
277
+ if (ref.endsWith('me/unknown')) return null;
278
+ if (ref.endsWith('me/hidden')) return false;
279
+ // `me/moved`: its published tip grants, its local head (past it) denies.
280
+ if (ref === 'local-of-me/moved') return false;
281
+ return !rel.startsWith('Secret/');
282
+ },
283
+ canReadBatch: async (ws: string, email: string, rels: string[]) => {
284
+ calls.push(['canReadBatch', ws, email, rels]);
285
+ return new Map(rels.map((r) => [r, !r.startsWith('Secret/')]));
286
+ },
287
+ canReadBatchAtRef: async (ws: string, ref: string, email: string, rels: string[]) => {
288
+ calls.push(['canReadBatchAtRef', ws, ref, email, rels]);
289
+ if (ref.endsWith('me/unknown')) return null;
290
+ return new Map(rels.map((r) => [r, !r.startsWith('Secret/')]));
291
+ },
292
+ canWrite: async (ws: string, email: string, rel: string) => {
293
+ calls.push(['canWrite', ws, email, rel]);
294
+ return !rel.startsWith('ReadOnly/');
295
+ },
296
+ } as never;
297
+ /**
298
+ * The deployment's hooks: the read hook refuses `Hooked/`, fails outright on
299
+ * `Failing/` and records every read it is told of (with the session it was
300
+ * told for); the write hook refuses `Locked/`.
301
+ */
302
+ const agentHooks = new WorkflowHooks();
303
+ agentHooks.onAgentRead(async (op) => {
304
+ calls.push(['agentRead', op.branch, op.wsPath, op.sessionId]);
305
+ if (op.wsPath?.includes('Hooked/')) throw new ToolError('Not in this conversation.', 403);
306
+ if (op.wsPath?.includes('Failing/')) throw new Error('hook store down');
307
+ });
308
+ agentHooks.onPreWrite(async (op) => {
309
+ calls.push(['preWrite', op.branch, op.wsPath]);
310
+ if (op.wsPath?.includes('Locked/')) throw new ToolError('Not yours to write here.', 403);
311
+ });
312
+ const agentAccessGate: AgentAccessGate = { recoveryBotEmail: 'recovery@x', hooks: agentHooks, notes: new ToolDescriptionNotes() };
192
313
 
193
314
  const internalToken = new InternalTokenService({ secret: 's' });
194
315
  let httpServer: HttpServer | undefined;
@@ -204,10 +325,19 @@ async function start(): Promise<string> {
204
325
 
205
326
  const router = express.Router();
206
327
  // Request #13 is one the caller may not see.
207
- registerWorkflowTools(registry, router, toolAuth, toolHandler, testKbContext(), async (ctx, number) => {
208
- calls.push(['maySeeAs', ctx.user.id, number]);
209
- return number !== 13;
210
- });
328
+ registerWorkflowTools(
329
+ registry,
330
+ router,
331
+ toolAuth,
332
+ toolHandler,
333
+ testKbContext(),
334
+ async (ctx, number) => {
335
+ calls.push(['maySeeAs', ctx.user.id, number]);
336
+ return number !== 13;
337
+ },
338
+ accessControl,
339
+ agentAccessGate,
340
+ );
211
341
  router.use(createManualRoutes(registry, toolAuth));
212
342
 
213
343
  const app = express();
@@ -945,3 +1075,333 @@ describe('save_file and the repository folder', () => {
945
1075
  expect(body.properties.path.description).toContain('with or without a leading slash');
946
1076
  });
947
1077
  });
1078
+
1079
+ /**
1080
+ * The history and syncing tools: what the app's History tab, compare view and
1081
+ * "update from target" do, each taking the per-file read the file itself
1082
+ * takes, and the branch update handing the service exactly what it was given.
1083
+ */
1084
+ describe('history and syncing tools', () => {
1085
+ const DRAFT = 'me/draft';
1086
+ const DRAFT_WS = 'me%2Fdraft';
1087
+ const tool = (base: string, name: string) => `${base}/api/agent/tools/${name}`;
1088
+ const SHA = 'a'.repeat(40);
1089
+
1090
+ it('list_file_changes answers the file\'s commits after the read gate, repository-relative', async () => {
1091
+ const base = await start();
1092
+ const res = await post(tool(base, 'list_file_changes'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', limit: 5 });
1093
+ expect(res.status).toBe(200);
1094
+ expect(await res.json()).toEqual({
1095
+ path: 'KnowledgeBase/Foo.md',
1096
+ changes: [{ sha: 'abc1234', authorName: 'Ada', authorEmail: 'ada@x.io', subject: 'edit', committedAt: '2026-10-16T00:00:00Z' }],
1097
+ });
1098
+ expect(calls).toContainEqual(['canRead', DRAFT_WS, 'e@x', 'KnowledgeBase/Foo.md']);
1099
+ expect(calls).toContainEqual(['listChangesForFile', DRAFT_WS, 'knowledge-base/KnowledgeBase/Foo.md', 5]);
1100
+ });
1101
+
1102
+ it('list_file_changes refuses a file the caller may not read, without reading its history', async () => {
1103
+ const base = await start();
1104
+ const res = await post(tool(base, 'list_file_changes'), writeTok(), { branch: DRAFT, path: 'Secret/Plan.md' });
1105
+ expect(res.status).toBe(403);
1106
+ expect(calls.some((c) => c[0] === 'listChangesForFile')).toBe(false);
1107
+ });
1108
+
1109
+ it('read_file_at reads a commit on the branch\'s own history, with the version before it', async () => {
1110
+ const base = await start();
1111
+ const res = await post(tool(base, 'read_file_at'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at: SHA });
1112
+ expect(res.status).toBe(200);
1113
+ expect(await res.json()).toEqual({ path: 'KnowledgeBase/Foo.md', at: SHA, kind: 'commit', sha: SHA, content: 'after\n', before: 'before\n' });
1114
+ expect(calls).toContainEqual(['canRead', DRAFT_WS, 'e@x', 'KnowledgeBase/Foo.md']);
1115
+ expect(calls).toContainEqual(['fileAtChange', DRAFT_WS, 'knowledge-base/KnowledgeBase/Foo.md', SHA]);
1116
+ });
1117
+
1118
+ it('read_file_at reads another branch at its published tip and authorizes at that very commit, never at a ref', async () => {
1119
+ const base = await start();
1120
+ const res = await post(tool(base, 'read_file_at'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at: 'main' });
1121
+ expect(res.status).toBe(200);
1122
+ expect(await res.json()).toEqual({ path: 'KnowledgeBase/Foo.md', at: 'main', kind: 'branch', sha: 'tip-of-main', content: 'on main\n' });
1123
+ expect(calls).toContainEqual(['fileAtBranch', DRAFT_WS, 'knowledge-base/KnowledgeBase/Foo.md', 'main']);
1124
+ expect(calls).toContainEqual(['canReadAtRef', DRAFT_WS, 'tip-of-main', 'e@x', 'KnowledgeBase/Foo.md']);
1125
+ expect(calls.filter((c) => c[0] === 'canReadAtRef').map((c) => c[2])).toEqual(['tip-of-main']);
1126
+ });
1127
+
1128
+ it('read_file_at and compare_file require every commit a branch resolves to grant: a served tip that grants never outruns a local head that denies', async () => {
1129
+ const base = await start();
1130
+ const read = await post(tool(base, 'read_file_at'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at: 'me/moved' });
1131
+ expect(read.status).toBe(403);
1132
+ expect(JSON.stringify(await read.json())).not.toContain('on me/moved');
1133
+ expect(calls.filter((c) => c[0] === 'canReadAtRef').map((c) => c[2])).toEqual(['tip-of-me/moved', 'local-of-me/moved']);
1134
+
1135
+ calls = [];
1136
+ const diff = await post(tool(base, 'compare_file'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', from: 'me/moved', to: DRAFT });
1137
+ expect(diff.status).toBe(403);
1138
+ expect(JSON.stringify(await diff.json())).not.toContain('@@');
1139
+ expect(calls.filter((c) => c[0] === 'canReadAtRef').map((c) => c[2])).toEqual(['tip-of-me/moved', 'local-of-me/moved']);
1140
+ });
1141
+
1142
+ it('read_file_at refuses a branch whose commit denies the read, and one whose rules cannot be read, disclosing nothing', async () => {
1143
+ const base = await start();
1144
+ for (const at of ['me/hidden', 'me/unknown']) {
1145
+ const res = await post(tool(base, 'read_file_at'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at });
1146
+ expect(res.status, at).toBe(403);
1147
+ expect(JSON.stringify(await res.json())).not.toContain(`on ${at}`);
1148
+ }
1149
+ });
1150
+
1151
+ it('compare_file diffs one commit, or two branches after gating both', async () => {
1152
+ const base = await start();
1153
+ const atCommit = await post(tool(base, 'compare_file'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at: SHA });
1154
+ expect(atCommit.status).toBe(200);
1155
+ expect(await atCommit.json()).toEqual({ path: 'KnowledgeBase/Foo.md', diff: `@@ patch of ${SHA}` });
1156
+
1157
+ const between = await post(tool(base, 'compare_file'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', from: 'main', to: DRAFT });
1158
+ expect(between.status).toBe(200);
1159
+ expect(await between.json()).toEqual({ path: 'KnowledgeBase/Foo.md', diff: '@@ main..me/draft' });
1160
+ // Authorized at the two commits the diff was made between, never at a ref.
1161
+ for (const b of ['main', DRAFT]) {
1162
+ expect(calls).toContainEqual(['canReadAtRef', DRAFT_WS, `tip-of-${b}`, 'e@x', 'KnowledgeBase/Foo.md']);
1163
+ }
1164
+ expect(calls.filter((c) => c[0] === 'canReadAtRef').map((c) => c[2])).toEqual(['tip-of-main', `tip-of-${DRAFT}`]);
1165
+
1166
+ const neither = await post(tool(base, 'compare_file'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md' });
1167
+ expect(neither.status).toBe(400);
1168
+ const hidden = await post(tool(base, 'compare_file'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', from: 'me/hidden', to: DRAFT });
1169
+ expect(hidden.status).toBe(403);
1170
+ expect(JSON.stringify(await hidden.json())).not.toContain('@@');
1171
+ });
1172
+
1173
+ it('branch_status composes the branch\'s standing, against the fork base unless a base is named', async () => {
1174
+ const base = await start();
1175
+ const res = await post(tool(base, 'branch_status'), writeTok(), { branch: DRAFT });
1176
+ expect(res.status).toBe(200);
1177
+ expect(await res.json()).toEqual({
1178
+ branch: DRAFT,
1179
+ // The listing double names only `main`, so the draft's own entry is unknown.
1180
+ isProtected: false,
1181
+ hasRemote: true,
1182
+ ahead: null,
1183
+ behind: null,
1184
+ behindRemote: true,
1185
+ uncommittedPaths: ['KnowledgeBase/Pending.md'],
1186
+ forkBase: 'main',
1187
+ base: 'main',
1188
+ changedPaths: ['KnowledgeBase/Changed.md'],
1189
+ withheldPaths: 0,
1190
+ });
1191
+ expect(calls).toContainEqual(['changedPathsBetweenBranches', DRAFT_WS, 'main', DRAFT]);
1192
+
1193
+ calls = [];
1194
+ const named = await post(tool(base, 'branch_status'), writeTok(), { branch: DRAFT, base: 'release' });
1195
+ expect(named.status).toBe(200);
1196
+ expect(await named.json()).toMatchObject({ base: 'release' });
1197
+ expect(calls).toContainEqual(['changedPathsBetweenBranches', DRAFT_WS, 'release', DRAFT]);
1198
+ });
1199
+
1200
+ it('update_branch hands the service the branch, the source and the resolutions as given', async () => {
1201
+ const base = await start();
1202
+ const res = await post(tool(base, 'update_branch'), writeTok(), {
1203
+ branch: DRAFT,
1204
+ from: 'main',
1205
+ resolutions: [
1206
+ { path: 'KnowledgeBase/Foo.md', content: 'resolved\n' },
1207
+ { path: 'KnowledgeBase/Bar.md', choose: 'theirs' },
1208
+ ],
1209
+ });
1210
+ expect(res.status).toBe(200);
1211
+ expect(await res.json()).toEqual({ kind: 'merged', sha: 'merge-sha', from: 'main', resolvedPaths: [] });
1212
+ // Handed as given, plus the agent's write gate for the content one brings.
1213
+ expect(calls).toContainEqual([
1214
+ 'updateBranch',
1215
+ DRAFT_WS,
1216
+ 'user-A',
1217
+ expect.objectContaining({
1218
+ from: 'main',
1219
+ resolutions: [{ path: 'KnowledgeBase/Foo.md', content: 'resolved\n' }, { path: 'KnowledgeBase/Bar.md', choose: 'theirs' }],
1220
+ validateWrite: expect.any(Function),
1221
+ }),
1222
+ ]);
1223
+
1224
+ calls = [];
1225
+ const own = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT });
1226
+ expect(own.status).toBe(200);
1227
+ expect(calls).toContainEqual(['updateBranch', DRAFT_WS, 'user-A', {}]);
1228
+ });
1229
+
1230
+ it('update_branch refuses a malformed resolution before the service is asked', async () => {
1231
+ const base = await start();
1232
+ for (const resolutions of [
1233
+ 'not a list',
1234
+ [{ content: 'no path' }],
1235
+ [{ path: 'A.md', choose: 'mine' }],
1236
+ [{ path: 'A.md', content: 42 }],
1237
+ ]) {
1238
+ const res = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT, resolutions });
1239
+ expect(res.status, JSON.stringify(resolutions)).toBe(400);
1240
+ }
1241
+ expect(calls.some((c) => c[0] === 'updateBranch')).toBe(false);
1242
+ });
1243
+
1244
+ it("tells the deployment's read hook of every history read, and a hook that refuses refuses the history", async () => {
1245
+ const base = await start();
1246
+ const res = await post(tool(base, 'list_file_changes'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md' });
1247
+ expect(res.status).toBe(200);
1248
+ expect(calls).toContainEqual(['agentRead', DRAFT, 'knowledge-base/KnowledgeBase/Foo.md', undefined]);
1249
+ const hooked = { branch: DRAFT, path: 'Hooked/Foo.md' };
1250
+ for (const [name, body] of [
1251
+ ['list_file_changes', hooked],
1252
+ ['read_file_at', { ...hooked, at: SHA }],
1253
+ ['compare_file', { ...hooked, from: 'main', to: DRAFT }],
1254
+ ] as const) {
1255
+ const refused = await post(tool(base, name), writeTok(), body);
1256
+ expect(refused.status, name).toBe(403);
1257
+ }
1258
+ expect(
1259
+ calls.some((c) => ['listChangesForFile', 'fileAtChange', 'showFileAtChange', 'compareFile'].includes(c[0] as string) && String(c[2]).includes('Hooked/')),
1260
+ ).toBe(false);
1261
+ });
1262
+
1263
+ it("compare_file takes the workspace's own read on the path before the branches' refs, as the compare route does", async () => {
1264
+ const base = await start();
1265
+ const res = await post(tool(base, 'compare_file'), writeTok(), { branch: DRAFT, path: 'Secret/Plan.md', from: 'main', to: DRAFT });
1266
+ expect(res.status).toBe(403);
1267
+ expect(calls).toContainEqual(['canRead', DRAFT_WS, 'e@x', 'Secret/Plan.md']);
1268
+ expect(calls.some((c) => c[0] === 'canReadAtRef')).toBe(false);
1269
+ });
1270
+
1271
+ it('read_file_at takes a full commit id; a short hex string is a branch name', async () => {
1272
+ const base = await start();
1273
+ const res = await post(tool(base, 'read_file_at'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at: 'abc1234' });
1274
+ expect(res.status).toBe(200);
1275
+ expect(await res.json()).toMatchObject({ kind: 'branch', at: 'abc1234' });
1276
+ expect(calls).toContainEqual(['fileAtBranch', DRAFT_WS, 'knowledge-base/KnowledgeBase/Foo.md', 'abc1234']);
1277
+ });
1278
+
1279
+ it('branch_status shows only the changed and uncommitted paths the caller may read, and counts the rest', async () => {
1280
+ const base = await start();
1281
+ calls = [];
1282
+ const res = await post(tool(base, 'branch_status'), writeTok(), { branch: DRAFT, base: 'main' });
1283
+ expect(res.status).toBe(200);
1284
+ expect(await res.json()).toMatchObject({ changedPaths: ['KnowledgeBase/Changed.md'], uncommittedPaths: ['KnowledgeBase/Pending.md'], withheldPaths: 0 });
1285
+ expect(calls).toContainEqual(['canReadBatchAtRef', DRAFT_WS, 'origin/main', 'e@x', ['KnowledgeBase/Changed.md']]);
1286
+ expect(calls).toContainEqual(['canReadBatch', DRAFT_WS, 'e@x', ['KnowledgeBase/Pending.md']]);
1287
+
1288
+ // A base whose rules do not resolve withholds every changed path.
1289
+ calls = [];
1290
+ const unknown = await post(tool(base, 'branch_status'), writeTok(), { branch: DRAFT, base: 'me/unknown' });
1291
+ expect(unknown.status).toBe(200);
1292
+ expect(await unknown.json()).toMatchObject({ changedPaths: [], withheldPaths: 1 });
1293
+ });
1294
+
1295
+ it('update_branch leaves a conflicted file the caller may not read on both sides out by name, and refuses to let them settle it', async () => {
1296
+ const base = await start();
1297
+ const res = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT, from: 'me/conflicting' });
1298
+ expect(res.status).toBe(200);
1299
+ const body = await res.json();
1300
+ expect(body.kind).toBe('conflicts');
1301
+ expect(body.unresolved).toEqual(['KnowledgeBase/Open.md']);
1302
+ expect(body.files).toEqual([{ path: 'KnowledgeBase/Open.md', base: 'b\n', ours: 'o\n', theirs: 't\n', merged: '<<<<<<<\n' }]);
1303
+ expect(body.withheldConflicts).toBe(1);
1304
+ expect(JSON.stringify(body)).not.toContain('Secret/Plan.md');
1305
+ // (Whether the caller may settle a file is the service's verdict, made
1306
+ // inside the merge against the source as fetched — see the facade test.)
1307
+ // A readable one is settled, and the service gets exactly that.
1308
+ calls = [];
1309
+ const side = await post(tool(base, 'update_branch'), writeTok(), {
1310
+ branch: DRAFT,
1311
+ from: 'me/conflicting',
1312
+ resolutions: [{ path: 'KnowledgeBase/Open.md', choose: 'theirs' }],
1313
+ });
1314
+ expect(side.status).toBe(200);
1315
+ expect(await side.json()).toEqual({ kind: 'merged', sha: 'resolved-sha', from: 'me/conflicting', resolvedPaths: ['KnowledgeBase/Open.md'] });
1316
+ expect(calls).toContainEqual([
1317
+ 'updateBranch',
1318
+ DRAFT_WS,
1319
+ 'user-A',
1320
+ expect.objectContaining({ from: 'me/conflicting', resolutions: [{ path: 'KnowledgeBase/Open.md', choose: 'theirs' }], validateWrite: expect.any(Function) }),
1321
+ ]);
1322
+ });
1323
+
1324
+ it("update_branch takes the agent's pre-disk write gate for a resolution's content: a roles.yaml that creates a role is refused as the file tools refuse it", async () => {
1325
+ const base = await start();
1326
+ const res = await post(tool(base, 'update_branch'), writeTok(), {
1327
+ branch: DRAFT,
1328
+ from: 'me/conflicting',
1329
+ // Well-formed, and declaring a role the draft's (absent) roles.yaml does not: the agent gate's new-role refusal.
1330
+ resolutions: [{ path: 'roles.yaml', content: 'roles:\n Admin:\n - a@x.eu\n' }],
1331
+ });
1332
+ expect(res.status).toBe(422);
1333
+ expect(((await res.json()) as { error: string }).error).toMatch(/never create app roles/);
1334
+ // The gate travels with every resolution set (the service refuses one
1335
+ // without); a side kept whole brings no content and is never asked.
1336
+ const call = calls.find((c) => c[0] === 'updateBranch') as unknown[] | undefined;
1337
+ expect(typeof (call?.[3] as { validateWrite?: unknown })?.validateWrite).toBe('function');
1338
+ calls = [];
1339
+ const side = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT, from: 'me/conflicting', resolutions: [{ path: 'roles.yaml', choose: 'theirs' }] });
1340
+ expect(side.status).toBe(200);
1341
+ expect(calls).toContainEqual([
1342
+ 'updateBranch',
1343
+ DRAFT_WS,
1344
+ 'user-A',
1345
+ expect.objectContaining({ from: 'me/conflicting', resolutions: [{ path: 'roles.yaml', choose: 'theirs' }], validateWrite: expect.any(Function) }),
1346
+ ]);
1347
+ });
1348
+
1349
+ it("update_branch asks the deployment's hooks for a resolution as for any read and any write", async () => {
1350
+ const base = await start();
1351
+ for (const [path, status] of [
1352
+ ['Locked/A.md', 403],
1353
+ ['Hooked/A.md', 403],
1354
+ ['KnowledgeBase/Open.md', 200],
1355
+ ] as const) {
1356
+ calls = [];
1357
+ const res = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT, from: 'me/conflicting', resolutions: [{ path, content: 'x\n' }] });
1358
+ expect(res.status, path).toBe(status);
1359
+ expect(calls, path).toContainEqual(['agentRead', DRAFT, `knowledge-base/${path}`, undefined]);
1360
+ // The write hook is asked after the read hook let the path through:
1361
+ // it is what refuses `Locked/`, and is never reached for `Hooked/`.
1362
+ if (path !== 'Hooked/A.md') expect(calls, path).toContainEqual(['preWrite', DRAFT, `knowledge-base/${path}`]);
1363
+ else expect(calls.some((c) => c[0] === 'preWrite')).toBe(false);
1364
+ if (status !== 200) expect(calls.some((c) => c[0] === 'updateBranch')).toBe(false);
1365
+ }
1366
+ });
1367
+
1368
+ it("update_branch tells the deployment's read hook of each conflicted file before showing it; a hook that fails is not an answer", async () => {
1369
+ const base = await start();
1370
+ calls = [];
1371
+ const res = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT, from: 'me/conflicting' });
1372
+ expect(res.status).toBe(200);
1373
+ expect(calls).toContainEqual(['agentRead', DRAFT, 'knowledge-base/KnowledgeBase/Open.md', undefined]);
1374
+ expect(calls).toContainEqual(['agentRead', DRAFT, 'knowledge-base/Secret/Plan.md', undefined]);
1375
+ // A failing hook propagates as a failure (500), not as a withheld file.
1376
+ const failing = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT, from: 'me/conflicting', resolutions: [{ path: 'Failing/A.md', content: 'x' }] });
1377
+ expect(failing.status).toBe(500);
1378
+ });
1379
+
1380
+ it('carries the conversation id of an external caller to the read hook, as every file tool does', async () => {
1381
+ const base = await start();
1382
+ calls = [];
1383
+ const res = await post(tool(base, 'list_file_changes'), 'bevel_key', { branch: DRAFT, path: 'KnowledgeBase/Foo.md', sessionId: 'conv-7' });
1384
+ expect(res.status).toBe(200);
1385
+ expect(calls).toContainEqual(['agentRead', DRAFT, 'knowledge-base/KnowledgeBase/Foo.md', 'conv-7']);
1386
+ for (const [name, body] of [
1387
+ ['read_file_at', { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at: 'main', sessionId: 'conv-7' }],
1388
+ ['compare_file', { branch: DRAFT, path: 'KnowledgeBase/Foo.md', from: 'main', to: DRAFT, sessionId: 'conv-7' }],
1389
+ ] as const) {
1390
+ calls = [];
1391
+ const r = await post(tool(base, name), 'bevel_key', body);
1392
+ expect(r.status, name).toBe(200);
1393
+ expect(calls, name).toContainEqual(['agentRead', DRAFT, 'knowledge-base/KnowledgeBase/Foo.md', 'conv-7']);
1394
+ }
1395
+ // `branch_status` reads no file content, so it tells the hook nothing; it still takes the id.
1396
+ const status = await post(tool(base, 'branch_status'), 'bevel_key', { branch: DRAFT, sessionId: 'conv-7' });
1397
+ expect(status.status).toBe(200);
1398
+ });
1399
+
1400
+ it('lists the five on the external catalog too: a remote agent reads history and syncs its drafts', async () => {
1401
+ await start();
1402
+ const external = await registryRef!.listExternal();
1403
+ for (const name of ['list_file_changes', 'read_file_at', 'compare_file', 'branch_status', 'update_branch']) {
1404
+ expect(external.some((t) => t.name === name), name).toBe(true);
1405
+ }
1406
+ });
1407
+ });
@@ -11,6 +11,7 @@ import { toolDef } from '../../tool-helpers/tool-def.js';
11
11
  import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
12
12
  import type { KbContext } from '../../../shared/kb-context.js';
13
13
  import type { IAccessControl } from '../../access/access-control.interface.js';
14
+ import { readablePathsAt } from '../read-on-branch.js';
14
15
  import {
15
16
  PER_PAGE_DEFAULT,
16
17
  PER_PAGE_MAX,
@@ -668,28 +669,9 @@ export function changeRequestScope(
668
669
  const repoGlobalWorkspaceId = async (ctx: ToolContext): Promise<string> =>
669
670
  (await ctx.workspaceService.findAnyWorkspaceId()) ?? kb.defaultWorkspaceId();
670
671
 
671
- /**
672
- * Read verdicts for `paths` at `origin/<base>`, as a membership set of what
673
- * the caller MAY read. A null answer (the ref or its `roles.yaml` does not
674
- * resolve) withholds everything, which is the same reading the app's
675
- * fork-point route gives it.
676
- */
677
- const readablePaths = async (
678
- ctx: ToolContext,
679
- workspaceId: string,
680
- base: string,
681
- paths: string[],
682
- ): Promise<Set<string>> => {
683
- if (paths.length === 0) return new Set();
684
- const verdicts = await accessControl.canReadBatchAtRef(
685
- workspaceId,
686
- `origin/${base}`,
687
- ctx.user.email,
688
- [...new Set(paths)],
689
- );
690
- if (!verdicts) return new Set();
691
- return new Set([...verdicts].filter(([, allowed]) => allowed).map(([p]) => p));
692
- };
672
+ /** What the caller MAY read of `paths` at `origin/<base>` — the one rule, `readablePathsAt`. */
673
+ const readablePaths = (ctx: ToolContext, workspaceId: string, base: string, paths: string[]): Promise<Set<string>> =>
674
+ readablePathsAt(accessControl, workspaceId, ctx.user.email, base, paths);
693
675
 
694
676
  const scopedDetail = async (
695
677
  ctx: ToolContext,