@bevel-software/platform-core-backend 0.28.1 → 0.30.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 (274) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  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.d.ts.map +1 -1
  8. package/dist/core/catalog-revision.js +4 -2
  9. package/dist/core/catalog-revision.js.map +1 -1
  10. package/dist/core/compose-embed.d.ts +35 -0
  11. package/dist/core/compose-embed.d.ts.map +1 -0
  12. package/dist/core/compose-embed.js +14 -0
  13. package/dist/core/compose-embed.js.map +1 -0
  14. package/dist/core/core-ports.d.ts +10 -0
  15. package/dist/core/core-ports.d.ts.map +1 -1
  16. package/dist/core/core-ports.js.map +1 -1
  17. package/dist/core/create-core-server.d.ts.map +1 -1
  18. package/dist/core/create-core-server.js +8 -2
  19. package/dist/core/create-core-server.js.map +1 -1
  20. package/dist/core/create-core-services.d.ts +4 -1
  21. package/dist/core/create-core-services.d.ts.map +1 -1
  22. package/dist/core/create-core-services.js +57 -18
  23. package/dist/core/create-core-services.js.map +1 -1
  24. package/dist/core/lifecycle.d.ts +19 -6
  25. package/dist/core/lifecycle.d.ts.map +1 -1
  26. package/dist/core/lifecycle.js +17 -6
  27. package/dist/core/lifecycle.js.map +1 -1
  28. package/dist/modules/access/access-control.interface.d.ts +20 -0
  29. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  30. package/dist/modules/access/access-control.service.d.ts +68 -15
  31. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  32. package/dist/modules/access/access-control.service.js +282 -58
  33. package/dist/modules/access/access-control.service.js.map +1 -1
  34. package/dist/modules/access/access.routes.d.ts.map +1 -1
  35. package/dist/modules/access/access.routes.js +20 -6
  36. package/dist/modules/access/access.routes.js.map +1 -1
  37. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  39. package/dist/modules/auth/index.d.ts +15 -0
  40. package/dist/modules/auth/index.d.ts.map +1 -0
  41. package/dist/modules/auth/index.js +20 -0
  42. package/dist/modules/auth/index.js.map +1 -0
  43. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  44. package/dist/modules/diff/diff.service.js +7 -1
  45. package/dist/modules/diff/diff.service.js.map +1 -1
  46. package/dist/modules/embed/embed.interface.d.ts +22 -0
  47. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  48. package/dist/modules/embed/embed.service.d.ts +28 -14
  49. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  50. package/dist/modules/embed/embed.service.js +38 -17
  51. package/dist/modules/embed/embed.service.js.map +1 -1
  52. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  53. package/dist/modules/embed/embed.tools.js +10 -2
  54. package/dist/modules/embed/embed.tools.js.map +1 -1
  55. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  56. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  57. package/dist/modules/kb-fs/git-dir.js +58 -0
  58. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  59. package/dist/modules/kb-fs/index.d.ts +9 -0
  60. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  61. package/dist/modules/kb-fs/index.js +3 -0
  62. package/dist/modules/kb-fs/index.js.map +1 -0
  63. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  64. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  65. package/dist/modules/kb-fs/ref-files.js +249 -0
  66. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  67. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  68. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  69. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  70. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  71. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  72. package/dist/modules/plugins/teams.routes.js +9 -1
  73. package/dist/modules/plugins/teams.routes.js.map +1 -1
  74. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  75. package/dist/modules/skills/skills.tools.d.ts +8 -1
  76. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  77. package/dist/modules/skills/skills.tools.js +115 -11
  78. package/dist/modules/skills/skills.tools.js.map +1 -1
  79. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  80. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  81. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  82. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  83. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  84. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  85. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  86. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  87. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  88. package/dist/modules/tool-helpers/index.d.ts +1 -0
  89. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  90. package/dist/modules/tool-helpers/index.js +1 -0
  91. package/dist/modules/tool-helpers/index.js.map +1 -1
  92. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  93. package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
  94. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  95. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/file-lock.service.js +70 -18
  97. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  98. package/dist/modules/workflow/git/git.service.d.ts +42 -0
  99. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/git/git.service.js +201 -24
  101. package/dist/modules/workflow/git/git.service.js.map +1 -1
  102. package/dist/modules/workflow/index.d.ts +8 -0
  103. package/dist/modules/workflow/index.d.ts.map +1 -0
  104. package/dist/modules/workflow/index.js +8 -0
  105. package/dist/modules/workflow/index.js.map +1 -0
  106. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  107. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  108. package/dist/modules/workflow/pending-commits.service.js +4 -24
  109. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  110. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  111. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  112. package/dist/modules/workflow/workflow.routes.js +20 -1
  113. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  114. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/workflow.service.js +5 -3
  116. package/dist/modules/workflow/workflow.service.js.map +1 -1
  117. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  118. package/dist/modules/workspace/agent-access.gate.js +2 -2
  119. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  120. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  121. package/dist/modules/workspace/bounded-matcher.js +261 -0
  122. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  123. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  124. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  125. package/dist/modules/workspace/fetch-instant.js +13 -0
  126. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  127. package/dist/modules/workspace/index.d.ts +17 -0
  128. package/dist/modules/workspace/index.d.ts.map +1 -0
  129. package/dist/modules/workspace/index.js +16 -0
  130. package/dist/modules/workspace/index.js.map +1 -0
  131. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  132. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  133. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  134. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  135. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  136. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  137. package/dist/modules/workspace/kb-repository.js +811 -0
  138. package/dist/modules/workspace/kb-repository.js.map +1 -0
  139. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  140. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  141. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  142. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  143. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  144. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  145. package/dist/modules/workspace/routine-write-policy.js +0 -5
  146. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  147. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  148. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  149. package/dist/modules/workspace/set-aside-clone.js +139 -0
  150. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  151. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  152. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  153. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  154. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  155. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  156. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  157. package/dist/modules/workspace/workspace.service.js +240 -161
  158. package/dist/modules/workspace/workspace.service.js.map +1 -1
  159. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  160. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.tools.js +268 -356
  162. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  163. package/dist/shared/workspace-id.d.ts +17 -0
  164. package/dist/shared/workspace-id.d.ts.map +1 -1
  165. package/dist/shared/workspace-id.js +24 -0
  166. package/dist/shared/workspace-id.js.map +1 -1
  167. package/dist/test-setup.js +4 -0
  168. package/dist/test-setup.js.map +1 -1
  169. package/mcp-app/page.html +142 -8
  170. package/package.json +3 -3
  171. package/src/__tests__/test-repositories.ts +22 -0
  172. package/src/__tests__/workspace-service.ts +69 -0
  173. package/src/core/__tests__/compose-embed.test.ts +65 -0
  174. package/src/core/__tests__/lifecycle.test.ts +45 -26
  175. package/src/core/catalog-revision.ts +4 -2
  176. package/src/core/compose-embed.ts +50 -0
  177. package/src/core/core-ports.ts +10 -0
  178. package/src/core/create-core-server.ts +8 -2
  179. package/src/core/create-core-services.ts +63 -24
  180. package/src/core/lifecycle.ts +34 -12
  181. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
  182. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  183. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  184. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  185. package/src/modules/access/access-control.interface.ts +22 -0
  186. package/src/modules/access/access-control.service.ts +302 -58
  187. package/src/modules/access/access.routes.ts +22 -6
  188. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  189. package/src/modules/auth/index.ts +29 -0
  190. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  191. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  192. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  193. package/src/modules/diff/diff.service.ts +6 -4
  194. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  195. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  196. package/src/modules/embed/embed.interface.ts +24 -0
  197. package/src/modules/embed/embed.service.ts +44 -19
  198. package/src/modules/embed/embed.tools.ts +10 -2
  199. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  200. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  201. package/src/modules/kb-fs/git-dir.ts +53 -0
  202. package/src/modules/kb-fs/index.ts +8 -0
  203. package/src/modules/kb-fs/ref-files.ts +279 -0
  204. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  205. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  206. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  207. package/src/modules/plugins/teams.routes.ts +14 -1
  208. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  209. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  210. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  211. package/src/modules/skills/skills.contract.ts +4 -0
  212. package/src/modules/skills/skills.tools.ts +120 -10
  213. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  214. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  215. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  216. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  217. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  218. package/src/modules/tool-helpers/index.ts +5 -0
  219. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  220. package/src/modules/tool-helpers/tool.contract.ts +2 -2
  221. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +42 -3
  222. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  223. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  224. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  225. package/src/modules/workflow/file-lock.service.ts +79 -18
  226. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  227. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  228. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  229. package/src/modules/workflow/git/git.service.ts +207 -22
  230. package/src/modules/workflow/index.ts +7 -0
  231. package/src/modules/workflow/pending-commits.service.ts +4 -23
  232. package/src/modules/workflow/workflow-hooks.ts +1 -1
  233. package/src/modules/workflow/workflow.routes.ts +24 -1
  234. package/src/modules/workflow/workflow.service.ts +3 -2
  235. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  236. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  237. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  238. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  239. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  240. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  241. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  242. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  243. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  244. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  245. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  246. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  247. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  248. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  249. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  250. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  251. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  252. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  253. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  254. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  255. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  256. package/src/modules/workspace/__tests__/workspace.tools.test.ts +174 -309
  257. package/src/modules/workspace/agent-access.gate.ts +2 -2
  258. package/src/modules/workspace/bounded-matcher.ts +328 -0
  259. package/src/modules/workspace/fetch-instant.ts +13 -0
  260. package/src/modules/workspace/index.ts +26 -0
  261. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  262. package/src/modules/workspace/kb-repository.ts +844 -0
  263. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  264. package/src/modules/workspace/routine-write-policy.ts +0 -15
  265. package/src/modules/workspace/set-aside-clone.ts +140 -0
  266. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  267. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  268. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  269. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  270. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  271. package/src/modules/workspace/workspace.service.ts +247 -169
  272. package/src/modules/workspace/workspace.tools.ts +299 -385
  273. package/src/shared/workspace-id.ts +24 -0
  274. package/src/test-setup.ts +5 -0
@@ -14,6 +14,7 @@ import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
14
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 { registerWorkspaceTools } from '../workspace.tools.js';
17
+ import { WorkerPatternMatcher, type WorkerPatternMatcherOptions } from '../bounded-matcher.js';
17
18
  import { sharedFileRules, sharedFileRulesSection } from '../../agent-instructions/shared-file-rules.js';
18
19
  import { composeAgentGuide } from '../../agent-guide/agent-guide.js';
19
20
  import { GUIDE_FIRST_SENTENCE } from '../../tool-registry/guide-first.js';
@@ -30,7 +31,6 @@ import { AccessControlService } from '../../access/access-control.service.js';
30
31
  import { NodeFs } from '../../kb-fs/node-fs.js';
31
32
  import type { WorkspaceService } from '../workspace.service.js';
32
33
  import { DEFAULT_KB_LAYOUT, isBranchAuthoredBy, isOwnSuggestionsBranch } from '@bevel-software/platform-shared';
33
- import { assertValidBranchName } from '../../kb-fs/branch-name.js';
34
34
  import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
35
35
  import { GIT_INTERNALS_MESSAGE, PathNotFoundError } from '../../../shared/domain-errors.js';
36
36
  import { AccessDeniedError } from '../../access-model/access-errors.js';
@@ -48,12 +48,18 @@ const allowAll = {
48
48
  canReadBatch: async (_w: string, _u: string, paths: string[]) =>
49
49
  new Map(paths.map((p) => [p, true])),
50
50
  // The verbs `file_stat` and the move/delete preflight ask about. Allowing
51
- // everything, and answering "no rules at HEAD" for the batch write check,
52
- // keeps these tests about what they test rather than about access.
51
+ // everything keeps these tests about what they test rather than about
52
+ // access.
53
53
  canWrite: async () => true,
54
+ canWriteBatch: async (_w: string, _u: string, paths: string[]) =>
55
+ new Map(paths.map((p) => [p, true])),
54
56
  canDownload: async () => true,
55
57
  canOwner: async () => true,
56
- canWriteBatchAtRef: async () => null,
58
+ // The `canWrite` the read tools report is judged at the default branch's
59
+ // HEAD, so a write-scope caller here gets a COMPUTED `true` — never the
60
+ // fail-closed `false` of a missing method or an unresolvable ref.
61
+ canWriteBatchAtRef: async (_w: string, _r: string, _u: string, paths: string[]) =>
62
+ new Map(paths.map((p) => [p, true])),
57
63
  // The `after` half of a move's or copy's preview. Allowing everything here
58
64
  // keeps it the same answer as the four gates above; what the relocation of
59
65
  // a folder's `access.md` files actually does to it is exercised against
@@ -72,7 +78,7 @@ function denyReads(denied: Set<string>): IAccessControl {
72
78
 
73
79
  /**
74
80
  * File primitives over a REAL LocalFilesystem on a temp dir — proves read_file /
75
- * write_file / edit_file / list_files / grep / file_stat / execute_command
81
+ * write_file / edit_file / list_files / grep / file_stat
76
82
  * actually work (the handlers re-expose the same filesystem methods Mastra
77
83
  * uses). No locking pipeline (plain LocalFilesystem), so writes don't commit.
78
84
  */
@@ -83,11 +89,9 @@ let tempDir = '';
83
89
  let docCacheDir = '';
84
90
  let fs: LocalFilesystem;
85
91
  /**
86
- * Every branch / workspaceId `execute_command`'s handler resolves a workspace
87
- * for. Resolving a workspace is what triggers the lazy per-branch CLONE in
88
- * production, so asserting this never contains `"undefined"` proves the
89
- * missing-branch guard short-circuits before any clone of a branch literally
90
- * named "undefined" is attempted.
92
+ * Every branch a tool resolved a workspace for through `getOrCreateForBranch`.
93
+ * Resolving a workspace is what triggers the lazy per-branch worktree add in
94
+ * production.
91
95
  */
92
96
  let workspacePathCalls: string[] = [];
93
97
  /** Every `(archive, destination)` pair `unzip` handed the service. */
@@ -108,8 +112,7 @@ let writePolicy: RoutineWritePolicyService;
108
112
  let guideText = 'THE PLATFORM GUIDE\n';
109
113
  /**
110
114
  * The focused branch the resolved `ToolContext` carries — mirrors the branch an
111
- * internal token bakes for the in-process agent. A test sets it to prove a
112
- * branch-less `execute_command` falls back to the session's own workspace.
115
+ * internal token bakes for the in-process agent.
113
116
  */
114
117
  let focusedBranch: string | undefined;
115
118
  /** The registry the tools were mounted into, so a test can inspect their defs. */
@@ -136,6 +139,12 @@ let callerSource: 'internal' | 'external' | 'session' = 'internal';
136
139
  * ONCE (it clears itself), so a test's later writes are ordinary ones.
137
140
  */
138
141
  let raceHook: (() => Promise<void>) | null = null;
142
+ /**
143
+ * The grep matcher `start()` wires, built from `matcherOptions` (the
144
+ * defaults, unless a test narrows the pool) and closed after each test.
145
+ */
146
+ let patternMatcher: WorkerPatternMatcher | undefined;
147
+ let matcherOptions: WorkerPatternMatcherOptions = {};
139
148
 
140
149
  async function start(
141
150
  scope: 'read' | 'write' = 'write',
@@ -144,6 +153,8 @@ async function start(
144
153
  userEmail = 'e@x',
145
154
  ): Promise<string> {
146
155
  raceHook = null;
156
+ await patternMatcher?.close();
157
+ patternMatcher = new WorkerPatternMatcher(matcherOptions);
147
158
  hooks = new WorkflowHooks();
148
159
  notes = new ToolDescriptionNotes();
149
160
  callerSource = 'internal';
@@ -305,7 +316,7 @@ async function start(
305
316
  recoveryBotEmail: RECOVERY_BOT,
306
317
  hooks,
307
318
  notes,
308
- }, writePolicy, {} as never /* sessionSink — start_session not exercised here */, undefined, undefined, undefined, async () => guideText);
319
+ }, writePolicy, {} as never /* sessionSink — start_session not exercised here */, undefined, undefined, undefined, async () => guideText, undefined, undefined, undefined, patternMatcher);
309
320
  app.use('/api', router);
310
321
  httpServer = await new Promise<HttpServer>((r) => {
311
322
  const s = app.listen(0, () => r(s));
@@ -336,10 +347,13 @@ const post = (url: string, body: unknown = {}) =>
336
347
  beforeEach(() => {
337
348
  /* fresh per test via start() */
338
349
  guideText = 'THE PLATFORM GUIDE\n';
350
+ matcherOptions = {};
339
351
  });
340
352
  afterEach(async () => {
341
353
  if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
342
354
  httpServer = undefined;
355
+ await patternMatcher?.close();
356
+ patternMatcher = undefined;
343
357
  if (tempDir) {
344
358
  await rm(tempDir, { recursive: true, force: true });
345
359
  tempDir = '';
@@ -350,29 +364,10 @@ afterEach(async () => {
350
364
  }
351
365
  });
352
366
 
353
- /**
354
- * True once `pid` has been killed: either it is gone, or it is a zombie
355
- * waiting on a reaper (Linux: `State:\tZ` in /proc). `kill(pid, 0)` alone
356
- * cannot tell a zombie from a live process.
357
- */
358
- async function isDeadOrZombie(pid: number): Promise<boolean> {
359
- try {
360
- process.kill(pid, 0);
361
- } catch {
362
- return true;
363
- }
364
- try {
365
- const status = await readFile(`/proc/${pid}/status`, 'utf8');
366
- return /^State:\s*Z/m.test(status);
367
- } catch {
368
- return false; // no /proc (macOS): alive as far as the signal says
369
- }
370
- }
371
-
372
367
  describe('workspace file primitives', () => {
373
368
  it('read_file returns the content', async () => {
374
369
  const base = await start();
375
- expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n' });
370
+ expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n', canWrite: true });
376
371
  });
377
372
 
378
373
  it('write_file then read_file round-trips', async () => {
@@ -505,7 +500,7 @@ describe('workspace file primitives', () => {
505
500
  // that same text into an agent: a leading slash names the same path.
506
501
  it('every path input accepts a leading slash as the same workspace path', async () => {
507
502
  const base = await start();
508
- expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `/${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n' });
503
+ expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `/${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n', canWrite: true });
509
504
  expect(await (await post(`${base}/api/agent/tools/file_stat`, { path: `/${KB_DIR}/a.md` })).json()).toMatchObject({ type: 'file' });
510
505
  await post(`${base}/api/agent/tools/write_file`, { path: `/${KB_DIR}/b.md`, content: 'fresh' });
511
506
  await post(`${base}/api/agent/tools/write_file`, { path: `/${KB_DIR}/c.md`, content: 'batch' });
@@ -597,248 +592,15 @@ describe('workspace file primitives', () => {
597
592
  expect(empty.matches.map((m) => m.path)).toEqual([`${KB_DIR}/n.md`]);
598
593
  });
599
594
 
600
- it('execute_command runs in the workspace dir', async () => {
601
- const base = await start();
602
- const res = (await (await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'echo hello-exec' })).json()) as { stdout: string; exitCode: number };
603
- expect(res.stdout).toContain('hello-exec');
604
- expect(res.exitCode).toBe(0);
605
- });
606
-
607
- // The command runs under `sh -c`; a timeout used to SIGKILL that shell and
608
- // leave what it had started running on as an orphan. The whole process
609
- // group goes now. POSIX only: Windows has no process groups to kill.
610
- it.skipIf(process.platform === 'win32')('execute_command kills what the command started, not just its shell, on timeout', async () => {
611
- const base = await start();
612
- // Print the grandchild's pid, then block on it so the timeout fires. The
613
- // timeout is the schema's minimum — inside the tool's declared contract,
614
- // and long enough that a slow spawn still prints the pid before it fires.
615
- const res = (await (
616
- await post(`${base}/api/agent/tools/execute_command`, {
617
- branch: 'main',
618
- command: 'sleep 30 & echo $!; wait',
619
- timeout_ms: 1000,
620
- })
621
- ).json()) as { stdout: string; exitCode: number };
622
- expect(res.exitCode).toBe(-1);
623
- const grandchild = Number(res.stdout.trim());
624
- expect(grandchild).toBeGreaterThan(0);
625
- // Dead means killed, not reaped: a SIGKILLed process keeps its pid — and
626
- // answers `kill(pid, 0)` as alive — until whoever inherited it reaps it,
627
- // and on a host with no reaper (the very thing this guards) that is
628
- // never. So where /proc exists, a zombie (`State: Z`) counts as dead; a
629
- // vanished pid counts everywhere. Poll to a deadline rather than trust
630
- // one fixed pause. Without the fix the grandchild is still sleeping at
631
- // the deadline, so what fails is the deadline — never an early check.
632
- const deadline = Date.now() + 3_000;
633
- let dead = false;
634
- while (!dead && Date.now() < deadline) {
635
- dead = await isDeadOrZombie(grandchild);
636
- if (!dead) await new Promise((r) => setTimeout(r, 50));
637
- }
638
- expect(dead).toBe(true);
639
- });
640
-
641
- it('execute_command 400s when no branch is given AND no focused branch (external caller)', async () => {
642
- const base = await start();
643
- // No `branch` arg and no `ctx.focusedBranch` (an external caller carries no
644
- // focused branch): the branch context is genuinely absent, so it must NOT
645
- // resolve the workspace id to "undefined" and try to clone a branch literally
646
- // named "undefined" — it fails closed with a clear 4xx naming the missing
647
- // branch context, BEFORE any workspace resolve.
648
- expect(focusedBranch).toBeUndefined();
649
- // `postRaw`: the absent field IS the test here, so the body goes as written.
650
- const res = await postRaw(`${base}/api/agent/tools/execute_command`, { command: 'echo should-not-run' });
651
- expect(res.status).toBe(400);
652
- expect((await res.json()).error).toMatch(/branch/i);
653
- // The guard runs before `getOrCreateForBranch`, so no workspace (least of all
654
- // the "undefined" one) is ever resolved — proving no clone is attempted.
655
- expect(workspacePathCalls).toEqual([]);
656
- });
657
-
658
- it('execute_command falls back to the session focused branch when branch is omitted', async () => {
659
- const base = await start();
660
- // The in-app chat agent, focused on `main`, may leave `branch` off a call.
661
- // For an internal session that carries a focused branch, the shell runs
662
- // against that branch's workspace and returns output end to end (AC2) —
663
- // rather than failing closed the way a context-less external call does.
664
- focusedBranch = 'main';
665
- const res = (await (await postRaw(`${base}/api/agent/tools/execute_command`, { command: 'echo hello-exec' })).json()) as { stdout: string; exitCode: number };
666
- expect(res.exitCode).toBe(0);
667
- expect(res.stdout).toContain('hello-exec');
668
- // Resolved against the focused branch — never an "undefined" workspace.
669
- expect(workspacePathCalls).toEqual(['main']);
670
- });
671
-
672
- it('execute_command does NOT fall back to the focused branch for an invalid value', async () => {
673
- const base = await start();
674
- // A present-but-broken branch ("undefined") must fail closed even when a
675
- // focused branch is available — a broken value is never silently reinterpreted
676
- // as the session's branch.
677
- focusedBranch = 'main';
678
- const res = await post(`${base}/api/agent/tools/execute_command`, { branch: 'undefined', command: 'echo should-not-run' });
679
- expect(res.status).toBe(400);
680
- expect((await res.json()).error).toMatch(/branch/i);
681
- expect(workspacePathCalls).toEqual([]);
682
- });
683
-
684
- // Every branch value that must fail closed without resolving a workspace.
685
- // `''` (like the missing field above) is refused a layer earlier by the input
686
- // schema's `minLength: 1`; the rest satisfy the schema and reach the handler,
687
- // so they exercise the guard itself. Both layers must produce the same 400.
688
- // The malformed refs below are rejected by the shared `assertValidBranchName`
689
- // shape check; the literal "undefined"/"null" — which that validator ACCEPTS
690
- // as ordinary git refs — are rejected by an explicit by-name check ahead of it
691
- // (AC3 requires them to fail closed; see the dedicated test below).
692
- const invalidBranches: Array<[label: string, branch: string]> = [
693
- ['empty', ''],
694
- ['whitespace-only', ' '],
695
- // Malformed refs — caught by the canonical `assertValidBranchName` shape
696
- // check, not by any hand-maintained list of literals.
697
- ['whitespace-padded (never silently trimmed)', ' main '],
698
- ['containing a space', 'alice/my draft'],
699
- ['a ".." ref', 'foo..bar'],
700
- ['containing ".."', 'alice/../../etc'],
701
- ['a leading dash (git would read it as a flag)', '-x'],
702
- ['starting with "--" (flag injection)', '--upload-pack=touch'],
703
- ['containing "//"', 'alice//draft'],
704
- ['ending with "/"', 'alice/'],
705
- ['a segment starting with "."', 'alice/.hidden'],
706
- ['a segment ending with ".lock"', 'alice/draft.lock'],
707
- ['containing "@{"', 'alice/draft@{0}'],
708
- ['containing a shell metacharacter', 'alice/draft;rm -rf /'],
709
- ];
710
- for (const [label, branch] of invalidBranches) {
711
- it(`execute_command 400s when branch is ${label}`, async () => {
712
- const base = await start();
713
- const res = await post(`${base}/api/agent/tools/execute_command`, { branch, command: 'echo should-not-run' });
714
- expect(res.status).toBe(400);
715
- expect((await res.json()).error).toMatch(/branch/i);
716
- // No workspace is resolved for the bad value — proving no clone is attempted.
717
- expect(workspacePathCalls).toEqual([]);
718
- });
719
- }
720
-
721
- /**
722
- * The production regression this ticket bounced on: the guard's hand-written
723
- * `"undefined"`/`"null"` rejection was replaced by the canonical
724
- * `assertValidBranchName` alone. Both literals are *syntactically valid* git
725
- * branch names, so the validator accepts them — a branch-less call then reached
726
- * `getOrCreateForBranch("undefined")`, cloned a branch literally named
727
- * `undefined` into `/app/workspaces/undefined/`, and 500'd in production.
728
- *
729
- * AC3 requires these literals to fail closed. Pinning the validator's own
730
- * behaviour here is the point: it documents WHY the literal check cannot be
731
- * folded into the shape check, so the next person who "simplifies" the guard
732
- * down to `assertValidBranchName` gets a red test naming the reason rather than
733
- * a fresh production 500.
734
- */
735
- it('rejects "undefined"/"null" even though the canonical validator accepts them', async () => {
736
- for (const literal of ['undefined', 'null']) {
737
- expect(() => assertValidBranchName(literal), `${literal} is a valid git ref`).not.toThrow();
738
- }
595
+ // An agent never composes a shell command line: there is no tool that runs
596
+ // one, and a call to the old one by name answers as any unknown tool.
597
+ it('offers no shell tool, and a call to execute_command by name finds nothing', async () => {
739
598
  const base = await start();
740
- for (const literal of ['undefined', 'null']) {
741
- const res = await post(`${base}/api/agent/tools/execute_command`, { branch: literal, command: 'echo should-not-run' });
742
- expect(res.status, `branch "${literal}" must 400`).toBe(400);
743
- // The message names the literal, so the agent can see what it actually sent.
744
- expect((await res.json()).error).toContain(literal);
745
- }
746
- expect(workspacePathCalls).toEqual([]);
747
- });
748
-
749
- it('answers a branch-less call with the same kind every other KB tool uses', async () => {
750
- const base = await start();
751
- // The MESSAGE here is this tool's own — only `execute_command` has a
752
- // focused-branch fallback to explain. The DISCRIMINATOR is shared, so a
753
- // client switches on one kind across the whole surface instead of matching
754
- // prose per tool.
755
- for (const body of [{ command: 'x' }, { branch: '', command: 'x' }, { branch: 'undefined', command: 'x' }]) {
756
- const res = await postRaw(`${base}/api/agent/tools/execute_command`, body);
757
- expect(res.status).toBe(400);
758
- expect((await res.json()).kind).toBe('branch-required');
759
- }
760
- expect(workspacePathCalls).toEqual([]);
761
- });
762
-
763
- it('execute_command validates the branch BEFORE the write-policy gate', async () => {
764
- const base = await start();
765
- // A routine-restricted session is refused by `assertUnrestricted` with 403.
766
- // The branch guard must still run first, so a malformed call gets the 400
767
- // that names the bad context instead of a 403 masking it.
768
- writePolicy.restrictToExtensions('restricted-run', ['.html']);
769
- const res = await post(`${base}/api/agent/tools/execute_command`, {
770
- branch: 'alice/../../etc',
771
- command: 'echo should-not-run',
772
- sessionId: 'restricted-run',
773
- });
774
- expect(res.status).toBe(400);
775
- expect((await res.json()).error).toMatch(/branch/i);
599
+ expect((await toolRegistry.listInternal()).map((t) => t.name)).not.toContain('execute_command');
600
+ expect((await toolRegistry.listExternal()).map((t) => t.name)).not.toContain('execute_command');
601
+ const res = await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'echo should-not-run' });
602
+ expect(res.status).toBe(404);
776
603
  expect(workspacePathCalls).toEqual([]);
777
- // …and the restriction really is live for that session (so the 400 above is
778
- // the guard winning the race, not an inert policy).
779
- const gated = await post(`${base}/api/agent/tools/execute_command`, {
780
- branch: 'main',
781
- command: 'echo should-not-run',
782
- sessionId: 'restricted-run',
783
- });
784
- expect(gated.status).toBe(403);
785
- });
786
-
787
- it('execute_command bootstraps the workspace through getOrCreateForBranch', async () => {
788
- const base = await start();
789
- // Per-branch bootstrap is the service's job: the shell passes the branch
790
- // itself rather than hand-encoding a workspace id.
791
- await post(`${base}/api/agent/tools/execute_command`, { branch: 'alice/draft', command: 'echo hi' });
792
- expect(workspacePathCalls).toEqual(['alice/draft']);
793
- });
794
-
795
- it('execute_command runs plain git against the nested KB clone', async () => {
796
- const base = await start();
797
- // Production layout: the repo lives one level BELOW the shell's cwd, at
798
- // <workspace>/<kbDirName>/.git. A bare `git …` (what the agent prompt
799
- // teaches) must still target that clone instead of failing with
800
- // "not a git repository".
801
- const execFileAsync = promisify(execFile);
802
- const repoDir = join(tempDir, KB_DIR);
803
- await mkdir(repoDir, { recursive: true });
804
- await execFileAsync('git', ['init'], { cwd: repoDir });
805
- await execFileAsync(
806
- 'git',
807
- ['-c', 'user.email=t@test', '-c', 'user.name=t', 'commit', '--allow-empty', '-m', 'kb-seed'],
808
- { cwd: repoDir },
809
- );
810
- const res = (await (await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'git log -1 --format=%s' })).json()) as { stdout: string; stderr: string; exitCode: number };
811
- expect(res.exitCode).toBe(0);
812
- expect(res.stdout.trim()).toBe('kb-seed');
813
- });
814
-
815
- // POSIX-only probes: `$GIT_DIR` expansion and `>/dev/null` are `sh`
816
- // semantics, and `shell: true` on Windows runs cmd.exe, where the probe
817
- // itself (not the scoping under test) is meaningless. CI runs Linux.
818
- it.skipIf(process.platform === 'win32')('execute_command does not leak GIT_DIR into non-git commands', async () => {
819
- const base = await start();
820
- // The KB-clone GIT_DIR/GIT_WORK_TREE override is scoped to bare `git …`
821
- // only; a non-git command (e.g. npm/pip that shells git internally) must
822
- // NOT inherit it, or the nested git child would target the KB clone.
823
- // Probe via `node -e` rather than shell expansion — the override lives in
824
- // the spawn env, and `$GIT_DIR` syntax doesn't expand under cmd.exe.
825
- const res = (await (await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: `node -e "console.log('GIT_DIR=['+(process.env.GIT_DIR||'')+']')"` })).json()) as { stdout: string; stderr: string; exitCode: number };
826
- expect(res.exitCode).toBe(0);
827
- expect(res.stdout.trim()).toBe('GIT_DIR=[]');
828
- });
829
-
830
- it.skipIf(process.platform === 'win32')('execute_command does not leak GIT_DIR into a chained step after git', async () => {
831
- const base = await start();
832
- // Runs under `shell: true`, so a command that merely STARTS with git but
833
- // chains another step must not export the KB git env to the whole shell —
834
- // otherwise `git … && npm ci` would leak the KB repo context into the npm/pip
835
- // git subprocess. `git --version` needs no repo, so exit stays 0. The
836
- // version line lands on stdout (`>/dev/null` isn't portable to cmd.exe),
837
- // so the leak probe is asserted on the LAST line.
838
- const res = (await (await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: `git --version && node -e "console.log('leak=['+(process.env.GIT_DIR||'')+']')"` })).json()) as { stdout: string; stderr: string; exitCode: number };
839
- expect(res.exitCode).toBe(0);
840
- const lines = res.stdout.trim().split(/\r?\n/);
841
- expect(lines[lines.length - 1].trim()).toBe('leak=[]');
842
604
  });
843
605
 
844
606
  it('read scope refuses write tools (403) but allows reads', async () => {
@@ -1117,14 +879,14 @@ describe('write modes and per-path outcomes', () => {
1117
879
  describe("the agent guide at the guide's name", () => {
1118
880
  const GUIDE = `${KB_DIR}/AGENTS.md`;
1119
881
  const read = (base: string, p = GUIDE, extra: Record<string, unknown> = {}) =>
1120
- post(`${base}/api/agent/tools/read_file`, { path: p, ...extra }).then((r) => r.json() as Promise<{ path: string; content: string }>);
882
+ post(`${base}/api/agent/tools/read_file`, { path: p, ...extra }).then((r) => r.json() as Promise<{ path: string; content: string; canWrite?: boolean }>);
1121
883
  const statOf = (base: string, p = GUIDE) =>
1122
884
  post(`${base}/api/agent/tools/file_stat`, { path: p }).then((r) => r.json() as Promise<Record<string, unknown>>);
1123
885
 
1124
886
  it('answers with the guide when the knowledge base has no file of that name', async () => {
1125
887
  guideText = 'THE PLATFORM GUIDE\n';
1126
888
  const base = await start();
1127
- expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n' });
889
+ expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n', canWrite: false });
1128
890
  // By the root-anchored and the prefix-less spellings too, like any path.
1129
891
  expect((await read(base, `/${GUIDE}`)).content).toBe('THE PLATFORM GUIDE\n');
1130
892
  expect((await read(base, 'AGENTS.md')).content).toBe('THE PLATFORM GUIDE\n');
@@ -1145,7 +907,9 @@ describe("the agent guide at the guide's name", () => {
1145
907
  guideText = 'THE PLATFORM GUIDE\n';
1146
908
  const base = await start();
1147
909
  await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n\nWrite tickets in the present tense.\n');
1148
- const { content } = await read(base);
910
+ const { content, canWrite } = await read(base);
911
+ // Its own file is a file like any other: the write verdict is the rules' answer for it.
912
+ expect(canWrite).toBe(true);
1149
913
  expect(content.startsWith('# Acme\n\nWrite tickets in the present tense.\n\n---\n')).toBe(true);
1150
914
  expect(content.endsWith('\n\nTHE PLATFORM GUIDE\n')).toBe(true);
1151
915
  expect(content).toContain("The text above is this knowledge base's own conventions file.");
@@ -1155,7 +919,7 @@ describe("the agent guide at the guide's name", () => {
1155
919
  guideText = 'THE PLATFORM GUIDE\n';
1156
920
  const base = await start();
1157
921
  await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Knowledge base\n\n> **This file is managed by the platform.** Stale.\n');
1158
- expect((await read(base)).content).toBe('THE PLATFORM GUIDE\n');
922
+ expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n', canWrite: false });
1159
923
  });
1160
924
 
1161
925
  it("never tells a caller who may not read the knowledge base's own file that it exists", async () => {
@@ -1199,7 +963,7 @@ describe("the agent guide at the guide's name", () => {
1199
963
  };
1200
964
  await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n');
1201
965
  vanish = true;
1202
- expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n' });
966
+ expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n', canWrite: false });
1203
967
  await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n');
1204
968
  vanish = true;
1205
969
  expect(await statOf(base)).toMatchObject({ platformGuide: true });
@@ -1502,6 +1266,123 @@ describe('grep with a path that names a file', () => {
1502
1266
  });
1503
1267
  });
1504
1268
 
1269
+ /**
1270
+ * A caller's pattern runs off the event loop under a budget (WorkerPatternMatcher):
1271
+ * one that backtracks for ever is stopped with a worded 400, and every other
1272
+ * request keeps being served meanwhile — here, a `read_file` on the same
1273
+ * server, which shares the one loop the pattern used to hold.
1274
+ */
1275
+ describe('a pattern too expensive to match', () => {
1276
+ const LONG_LINE = `${'a'.repeat(39)}!`;
1277
+
1278
+ it('answers 400 "too expensive" fast over a 40-character line, while other calls stay under a second', async () => {
1279
+ const base = await start();
1280
+ await fs.writeFile(`${KB_DIR}/redos.md`, `intro\n${LONG_LINE}\n`);
1281
+ const started = Date.now();
1282
+ const searching = post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$', path: `${KB_DIR}/redos.md` }).then(
1283
+ async (res) => ({ status: res.status, body: (await res.json()) as { error: string; code?: string }, took: Date.now() - started }),
1284
+ );
1285
+ // Probe while the search runs: each read must answer within a second.
1286
+ const probes: number[] = [];
1287
+ let done = false;
1288
+ void searching.finally(() => (done = true));
1289
+ while (!done) {
1290
+ const t = Date.now();
1291
+ const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` });
1292
+ expect(res.status).toBe(200);
1293
+ probes.push(Date.now() - t);
1294
+ await new Promise((r) => setTimeout(r, 50));
1295
+ }
1296
+ const { status, body, took } = await searching;
1297
+ expect(status).toBe(400);
1298
+ expect(body.error).toMatch(/^Pattern too expensive: /);
1299
+ expect(body.code).toBe('pattern_too_expensive');
1300
+ expect(body.error).toContain("this search's 5 s budget");
1301
+ expect(took).toBeLessThan(5_000 + 2_000);
1302
+ expect(probes.length).toBeGreaterThan(0);
1303
+ expect(Math.max(...probes)).toBeLessThan(1_000);
1304
+ });
1305
+
1306
+ it('stops it in a directory walk too, rather than skipping the file as unreadable', async () => {
1307
+ const base = await start();
1308
+ await fs.writeFile(`${KB_DIR}/redos.md`, `${LONG_LINE}\n`);
1309
+ const res = await post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$' });
1310
+ expect(res.status).toBe(400);
1311
+ expect(((await res.json()) as { error: string }).error).toMatch(/^Pattern too expensive: /);
1312
+ });
1313
+
1314
+ it('answers a directory walk 503 when no worker is free, rather than skipping every file as unreadable', async () => {
1315
+ // One worker and room for one search to wait: two expensive greps fill both.
1316
+ matcherOptions = { budgetMs: 1_000, poolMax: 1, maxWaiting: 1 };
1317
+ const base = await start();
1318
+ await fs.writeFile(`${KB_DIR}/redos.md`, `${LONG_LINE}\n`);
1319
+ await fs.writeFile(`${KB_DIR}/hit.md`, 'needle\n');
1320
+ const expensive = () =>
1321
+ post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$', path: `${KB_DIR}/redos.md` }).then((r) => r.status);
1322
+ const holding = expensive();
1323
+ await new Promise((r) => setTimeout(r, 200));
1324
+ const queued = expensive();
1325
+ await new Promise((r) => setTimeout(r, 100));
1326
+ const res = await post(`${base}/api/agent/tools/grep`, { pattern: 'needle' });
1327
+ expect(res.status).toBe(503);
1328
+ expect(((await res.json()) as { code?: string }).code).toBe('search_busy');
1329
+ // The two that held the pool answered as they always would, and it serves again after.
1330
+ expect(await Promise.all([holding, queued])).toEqual([400, 400]);
1331
+ const after = await post(`${base}/api/agent/tools/grep`, { pattern: 'needle' });
1332
+ expect(after.status).toBe(200);
1333
+ });
1334
+
1335
+ it('spends one budget across every file a search reads: each alone fits, all of them together do not', async () => {
1336
+ // How long the pattern takes over one line IN A WORKER, which backtracks at
1337
+ // its own pace, not this thread's: once on a fresh worker (`first`, which
1338
+ // can be several times slower) and then again on the same one (`again`).
1339
+ // Timed through a probe matcher of the same kind, on a line long enough
1340
+ // that `again` is at least 50 ms.
1341
+ let line = '';
1342
+ let first = 0;
1343
+ let again = 0;
1344
+ for (let n = 16; again < 50; n++) {
1345
+ line = `${'a'.repeat(n)}!`;
1346
+ const probe = new WorkerPatternMatcher();
1347
+ try {
1348
+ await probe.search('x', '').matchingLines(['x'], 1); // the worker is up before anything is timed
1349
+ let t = performance.now();
1350
+ await probe.search('^(a+)+$', '').matchingLines([line], 1);
1351
+ first = performance.now() - t;
1352
+ t = performance.now();
1353
+ await probe.search('^(a+)+$', '').matchingLines([line], 1);
1354
+ again = performance.now() - t;
1355
+ } finally {
1356
+ await probe.close();
1357
+ }
1358
+ }
1359
+ // A budget well above what any one file costs, and enough files that
1360
+ // together they cost about twice the budget.
1361
+ const budgetMs = Math.ceil(Math.max(first, again) * 1.5) + 200;
1362
+ const files = Math.ceil((2 * budgetMs) / again);
1363
+ matcherOptions = { budgetMs };
1364
+ const base = await start();
1365
+ for (let i = 1; i <= files; i++) await fs.writeFile(`${KB_DIR}/slow-${String(i).padStart(3, '0')}.md`, `${line}\n`);
1366
+ const all = await post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$' });
1367
+ expect(all.status).toBe(400);
1368
+ const body = (await all.json()) as { error: string; code?: string };
1369
+ expect(body.code).toBe('pattern_too_expensive');
1370
+ expect(body.error).toMatch(/^Pattern too expensive: /);
1371
+ // Any one of the same files, searched alone, fits the same budget.
1372
+ const one = await post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$', path: `${KB_DIR}/slow-001.md` });
1373
+ expect(one.status).toBe(200);
1374
+ expect(((await one.json()) as { matches: unknown[] }).matches).toEqual([]);
1375
+ });
1376
+
1377
+ it('a control pattern over the same line still matches', async () => {
1378
+ const base = await start();
1379
+ await fs.writeFile(`${KB_DIR}/redos.md`, `intro\n${LONG_LINE}\n`);
1380
+ const res = await post(`${base}/api/agent/tools/grep`, { pattern: '^a+!$', path: `${KB_DIR}/redos.md` });
1381
+ expect(res.status).toBe(200);
1382
+ expect(((await res.json()) as { matches: unknown[] }).matches).toEqual([{ path: `${KB_DIR}/redos.md`, line: 2, text: LONG_LINE }]);
1383
+ });
1384
+ });
1385
+
1505
1386
  /**
1506
1387
  * Document reading: read_file/grep consume the doc-extract service for office
1507
1388
  * documents and PDFs; the agent text-editing tools refuse them. Fixtures are
@@ -1926,9 +1807,7 @@ describe('office documents and PDFs', () => {
1926
1807
  it('every mounted tool opens with the one sentence sending the agent to the guide, and repeats none of the rules', async () => {
1927
1808
  await start();
1928
1809
  const tools = await toolRegistry.listInternal();
1929
- // The shell is in the list too: it carried the agent-guide reminder before,
1930
- // and that reminder is one of the rules that moved.
1931
- const mounted = ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip', 'execute_command'];
1810
+ const mounted = ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip'];
1932
1811
  for (const name of mounted) {
1933
1812
  const def = tools.find((t) => t.name === name);
1934
1813
  expect(def, name).toBeDefined();
@@ -2211,7 +2090,7 @@ describe('images', () => {
2211
2090
  const body = (await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/photo.jpg` })).json()) as ImageSentinel;
2212
2091
  expect(body.mimeType).toBe('image/jpeg');
2213
2092
  expect(body.data).toBe(bytes.toString('base64'));
2214
- expect(body.note).toBe(`[image: ${KB_DIR}/photo.jpg — image/jpeg, ${bytes.length} bytes]`);
2093
+ expect(body.note).toBe(`[image: ${KB_DIR}/photo.jpg — image/jpeg, ${bytes.length} bytes] (canWrite: true)`);
2215
2094
  });
2216
2095
 
2217
2096
  it('read_file refuses an image over 3.5 MiB raw with the downscale message, not a sentinel', async () => {
@@ -2237,6 +2116,7 @@ describe('images', () => {
2237
2116
  expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/icon.svg` })).json()).toEqual({
2238
2117
  path: `${KB_DIR}/icon.svg`,
2239
2118
  content: svg,
2119
+ canWrite: true,
2240
2120
  });
2241
2121
  });
2242
2122
 
@@ -2624,6 +2504,16 @@ describe('branch is a required parameter in the tool contract', () => {
2624
2504
  { recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
2625
2505
  new RoutineWritePolicyService(),
2626
2506
  {} as never,
2507
+ // Skill check, change gate, uploads, guide, downloads, starter packs, disk: not this harness's business.
2508
+ undefined,
2509
+ undefined,
2510
+ undefined,
2511
+ undefined,
2512
+ undefined,
2513
+ undefined,
2514
+ undefined,
2515
+ // So `grep` is mounted; it spawns no worker, as nothing here searches.
2516
+ new WorkerPatternMatcher(),
2627
2517
  );
2628
2518
  return registry;
2629
2519
  }
@@ -2634,19 +2524,6 @@ describe('branch is a required parameter in the tool contract', () => {
2634
2524
  return inputs?.properties?.body?.required ?? [];
2635
2525
  }
2636
2526
 
2637
- it('execute_command declares branch required on the INTERNAL manual', async () => {
2638
- const internal = await buildRegistry().listInternal();
2639
- const exec = internal.find((t) => t.name === 'execute_command');
2640
- expect(exec).toBeDefined();
2641
- expect(bodyRequired(exec)).toContain('branch');
2642
- expect(bodyRequired(exec)).toContain('command');
2643
- });
2644
-
2645
- it('execute_command is internal-only — never advertised on the external manual', async () => {
2646
- const external = await buildRegistry().listExternal();
2647
- expect(external.find((t) => t.name === 'execute_command')).toBeUndefined();
2648
- });
2649
-
2650
2527
  it('every workspace file tool requires branch on BOTH manuals (one convention everywhere)', async () => {
2651
2528
  const registry = buildRegistry();
2652
2529
  const [internal, external] = await Promise.all([registry.listInternal(), registry.listExternal()]);
@@ -2776,6 +2653,7 @@ describe('the tools place an unprefixed path inside the repository', () => {
2776
2653
  expect(await (await tool(base, 'read_file', { path: 'Notes.md' })).json()).toEqual({
2777
2654
  path: `${KB_DIR}/Notes.md`,
2778
2655
  content: 'hello\nworld\n',
2656
+ canWrite: true,
2779
2657
  });
2780
2658
  expect(await (await tool(base, 'file_stat', { path: 'Notes.md' })).json()).toMatchObject({ type: 'file' });
2781
2659
  const edited = await tool(base, 'edit_file', { path: 'Notes.md', old_string: 'world', new_string: 'earth' });
@@ -4713,16 +4591,6 @@ describe('agent read/write hooks', () => {
4713
4591
  expect(writes.map((w) => w.wsPath)).toEqual([`${KB_DIR}/out/one.md`, `${KB_DIR}/out/two.md`]);
4714
4592
  });
4715
4593
 
4716
- it('execute_command calls the write hook once, with no path', async () => {
4717
- const base = await start();
4718
- record();
4719
- await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'echo hi', sessionId: 's1' });
4720
- expect(writes).toHaveLength(1);
4721
- expect(writes[0].wsPath).toBeUndefined();
4722
- expect(writes[0]).toMatchObject({ sessionId: 's1', branch: 'main', source: 'internal' });
4723
- expect(reads).toEqual([]);
4724
- });
4725
-
4726
4594
  it('a write hook that throws refuses the write with its own message and status, and the file is unchanged', async () => {
4727
4595
  const base = await start();
4728
4596
  record();
@@ -4918,9 +4786,6 @@ describe('tool descriptions and the deployment note', () => {
4918
4786
  expect(all.get(name)!.description.endsWith(' One folder per conversation.'), name).toBe(true);
4919
4787
  expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(`${SESSION_ID_DESCRIPTION} It also pins that folder.`);
4920
4788
  }
4921
- // `execute_command` is internal-only, so it is checked on that surface.
4922
- const internal = new Map((await toolRegistry.listInternal()).map((t) => [t.name, t]));
4923
- expect(internal.get('execute_command')!.description.endsWith(' One folder per conversation.')).toBe(true);
4924
4789
  });
4925
4790
 
4926
4791
  it('a tool that is not gated carries no note', async () => {