@bevel-software/platform-core-backend 0.22.0 → 0.24.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 (466) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +46 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +12 -2
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +137 -24
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +46 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +99 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +16 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +28 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +6 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +12 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access.routes.d.ts.map +1 -1
  31. package/dist/modules/access/access.routes.js +17 -16
  32. package/dist/modules/access/access.routes.js.map +1 -1
  33. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  34. package/dist/modules/access/directory-sync-bot.js +7 -3
  35. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  36. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
  37. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  38. package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
  39. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  40. package/dist/modules/agent-instructions/compose.d.ts +38 -6
  41. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  42. package/dist/modules/agent-instructions/compose.js +39 -6
  43. package/dist/modules/agent-instructions/compose.js.map +1 -1
  44. package/dist/modules/agent-instructions/index.d.ts +2 -1
  45. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  46. package/dist/modules/agent-instructions/index.js +2 -1
  47. package/dist/modules/agent-instructions/index.js.map +1 -1
  48. package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
  49. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
  50. package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
  51. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
  52. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  53. package/dist/modules/audit/agent-audit.service.js +4 -2
  54. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  55. package/dist/modules/auth/account-admission.d.ts +58 -7
  56. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  57. package/dist/modules/auth/account-admission.js +44 -1
  58. package/dist/modules/auth/account-admission.js.map +1 -1
  59. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  60. package/dist/modules/auth/account-erasure.service.js +57 -16
  61. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  62. package/dist/modules/auth/account.routes.d.ts +1 -1
  63. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  64. package/dist/modules/auth/account.routes.js +61 -5
  65. package/dist/modules/auth/account.routes.js.map +1 -1
  66. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  67. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  68. package/dist/modules/auth/auth.middleware.js +18 -7
  69. package/dist/modules/auth/auth.middleware.js.map +1 -1
  70. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  71. package/dist/modules/auth/auth.routes.js +8 -0
  72. package/dist/modules/auth/auth.routes.js.map +1 -1
  73. package/dist/modules/auth/auth.service.d.ts +70 -2
  74. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  75. package/dist/modules/auth/auth.service.js +197 -18
  76. package/dist/modules/auth/auth.service.js.map +1 -1
  77. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  78. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  79. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  80. package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
  81. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  82. package/dist/modules/code-mode/code-mode.tool.js +66 -35
  83. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  84. package/dist/modules/database/connection.d.ts +16 -0
  85. package/dist/modules/database/connection.d.ts.map +1 -1
  86. package/dist/modules/database/connection.js +117 -0
  87. package/dist/modules/database/connection.js.map +1 -1
  88. package/dist/modules/database/core-schema.d.ts +337 -120
  89. package/dist/modules/database/core-schema.d.ts.map +1 -1
  90. package/dist/modules/database/core-schema.js +116 -58
  91. package/dist/modules/database/core-schema.js.map +1 -1
  92. package/dist/modules/database/migrate.d.ts +8 -0
  93. package/dist/modules/database/migrate.d.ts.map +1 -1
  94. package/dist/modules/database/migrate.js +271 -1
  95. package/dist/modules/database/migrate.js.map +1 -1
  96. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  97. package/dist/modules/kb-fs/branch-name.js +12 -2
  98. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  99. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  100. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  101. package/dist/modules/kb-fs/repo-path.js +79 -0
  102. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  104. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  105. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  106. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  108. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js +1 -1
  110. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  112. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  113. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  114. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  115. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  116. package/dist/modules/mcp/mcp.service.js +38 -8
  117. package/dist/modules/mcp/mcp.service.js.map +1 -1
  118. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  119. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  120. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  121. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-request-records.store.js +7 -4
  123. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  124. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  125. package/dist/modules/settings/deployment-settings.service.js +8 -3
  126. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  127. package/dist/modules/settings/setup.routes.d.ts +52 -1
  128. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  129. package/dist/modules/settings/setup.routes.js +207 -19
  130. package/dist/modules/settings/setup.routes.js.map +1 -1
  131. package/dist/modules/skills/skills.contract.d.ts +90 -18
  132. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  133. package/dist/modules/skills/skills.contract.js +4 -1
  134. package/dist/modules/skills/skills.contract.js.map +1 -1
  135. package/dist/modules/skills/skills.service.d.ts +98 -9
  136. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  137. package/dist/modules/skills/skills.service.js +239 -37
  138. package/dist/modules/skills/skills.service.js.map +1 -1
  139. package/dist/modules/skills/skills.tools.d.ts +7 -0
  140. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  141. package/dist/modules/skills/skills.tools.js +71 -7
  142. package/dist/modules/skills/skills.tools.js.map +1 -1
  143. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  144. package/dist/modules/tool-auth/external-api-key.service.js +12 -4
  145. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  146. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  147. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  148. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  149. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  150. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  151. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  152. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  153. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  154. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  155. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  156. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  157. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  158. package/dist/modules/tool-helpers/validate-token.js +1 -1
  159. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  160. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  161. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  162. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  163. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  164. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  165. package/dist/modules/tool-registry/description-length.js +108 -0
  166. package/dist/modules/tool-registry/description-length.js.map +1 -0
  167. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  168. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  169. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  170. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  171. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  172. package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
  173. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  174. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  175. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  176. package/dist/modules/workflow/file-lock.service.js +30 -0
  177. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  178. package/dist/modules/workflow/git/git.service.d.ts +94 -0
  179. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  180. package/dist/modules/workflow/git/git.service.js +193 -3
  181. package/dist/modules/workflow/git/git.service.js.map +1 -1
  182. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  183. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  184. package/dist/modules/workflow/pending-commits.service.js +60 -1
  185. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  186. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  187. package/dist/modules/workflow/recovery-bot.js +7 -3
  188. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  189. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  190. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  191. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  192. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  193. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  194. package/dist/modules/workflow/workflow-hooks.js +16 -1
  195. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  196. package/dist/modules/workflow/workflow.service.d.ts +60 -6
  197. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  198. package/dist/modules/workflow/workflow.service.js +115 -5
  199. package/dist/modules/workflow/workflow.service.js.map +1 -1
  200. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  201. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  202. package/dist/modules/workspace/agent-access.gate.js +123 -0
  203. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  204. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  205. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  206. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  207. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  208. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  209. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  210. package/dist/modules/workspace/agent-upload.store.js +553 -0
  211. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  212. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  213. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  214. package/dist/modules/workspace/routine-write-policy.js +5 -6
  215. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  216. package/dist/modules/workspace/session-sink.d.ts +5 -5
  217. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  218. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  219. package/dist/modules/workspace/set-aside-clone.js +92 -0
  220. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  221. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
  222. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  223. package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
  224. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  225. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  226. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  227. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  228. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  229. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  230. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  231. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  232. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  233. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  234. package/dist/modules/workspace/upload-limits.js +13 -0
  235. package/dist/modules/workspace/upload-limits.js.map +1 -0
  236. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  237. package/dist/modules/workspace/workspace.routes.js +93 -4
  238. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  239. package/dist/modules/workspace/workspace.service.d.ts +128 -5
  240. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  241. package/dist/modules/workspace/workspace.service.js +316 -58
  242. package/dist/modules/workspace/workspace.service.js.map +1 -1
  243. package/dist/modules/workspace/workspace.tools.d.ts +13 -11
  244. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  245. package/dist/modules/workspace/workspace.tools.js +791 -198
  246. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  247. package/dist/modules/workspace/write-denial.d.ts +0 -6
  248. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  249. package/dist/modules/workspace/write-denial.js +0 -6
  250. package/dist/modules/workspace/write-denial.js.map +1 -1
  251. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  252. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  253. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  254. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  255. package/dist/modules/write-access/write-access.d.ts +60 -0
  256. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  257. package/dist/modules/write-access/write-access.js +129 -0
  258. package/dist/modules/write-access/write-access.js.map +1 -0
  259. package/dist/shared/column-crypto.d.ts +194 -0
  260. package/dist/shared/column-crypto.d.ts.map +1 -0
  261. package/dist/shared/column-crypto.js +144 -0
  262. package/dist/shared/column-crypto.js.map +1 -0
  263. package/dist/shared/domain-errors.d.ts +25 -0
  264. package/dist/shared/domain-errors.d.ts.map +1 -1
  265. package/dist/shared/domain-errors.js +28 -0
  266. package/dist/shared/domain-errors.js.map +1 -1
  267. package/dist/shared/git.contract.d.ts +20 -0
  268. package/dist/shared/git.contract.d.ts.map +1 -1
  269. package/dist/shared/git.contract.js +26 -0
  270. package/dist/shared/git.contract.js.map +1 -1
  271. package/dist/shared/token-crypto.d.ts.map +1 -1
  272. package/dist/shared/token-crypto.js +25 -1
  273. package/dist/shared/token-crypto.js.map +1 -1
  274. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  275. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  276. package/dist/tenancy/static-tenant-source.js +1 -2
  277. package/dist/tenancy/static-tenant-source.js.map +1 -1
  278. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  279. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  280. package/dist/tenancy/tenant-secrets.js +4 -0
  281. package/dist/tenancy/tenant-secrets.js.map +1 -1
  282. package/kb-template/AGENTS.md +2 -0
  283. package/migrations/0014_change_request_closed_reason.sql +1 -0
  284. package/migrations/0015_account_deactivation.sql +3 -0
  285. package/migrations/0016_pii_encryption.sql +20 -0
  286. package/migrations/meta/0014_snapshot.json +2265 -0
  287. package/migrations/meta/0015_snapshot.json +2271 -0
  288. package/migrations/meta/0016_snapshot.json +2327 -0
  289. package/migrations/meta/_journal.json +21 -0
  290. package/package.json +3 -3
  291. package/src/__tests__/kb-layout-config.test.ts +0 -2
  292. package/src/__tests__/retired-settings.test.ts +96 -0
  293. package/src/core/__tests__/gated-boot.test.ts +132 -0
  294. package/src/core/__tests__/lifecycle.test.ts +241 -5
  295. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  296. package/src/core/core-ports.ts +11 -1
  297. package/src/core/create-core-server.ts +52 -16
  298. package/src/core/create-core-services.ts +156 -24
  299. package/src/core/lifecycle.ts +117 -13
  300. package/src/core-config.ts +31 -14
  301. package/src/index.ts +30 -1
  302. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  303. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  304. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  305. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  306. package/src/modules/access/access-control.interface.ts +43 -12
  307. package/src/modules/access/access-control.service.ts +234 -36
  308. package/src/modules/access/access.routes.ts +17 -16
  309. package/src/modules/access/directory-sync-bot.ts +7 -3
  310. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +11 -5
  311. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  312. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  313. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  314. package/src/modules/agent-instructions/compose.ts +50 -7
  315. package/src/modules/agent-instructions/index.ts +12 -0
  316. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  317. package/src/modules/audit/agent-audit.service.ts +4 -2
  318. package/src/modules/auth/__tests__/account-deactivation.test.ts +253 -0
  319. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  320. package/src/modules/auth/__tests__/account.routes.test.ts +87 -6
  321. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  322. package/src/modules/auth/__tests__/auth.service.test.ts +4 -2
  323. package/src/modules/auth/account-admission.ts +78 -9
  324. package/src/modules/auth/account-erasure.service.ts +69 -18
  325. package/src/modules/auth/account.routes.ts +63 -7
  326. package/src/modules/auth/auth.middleware.ts +19 -8
  327. package/src/modules/auth/auth.routes.ts +8 -0
  328. package/src/modules/auth/auth.service.ts +206 -23
  329. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  330. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  331. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  332. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  333. package/src/modules/database/__tests__/connection.test.ts +12 -0
  334. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +561 -0
  335. package/src/modules/database/connection.ts +117 -0
  336. package/src/modules/database/core-schema.ts +116 -58
  337. package/src/modules/database/migrate.ts +353 -1
  338. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  339. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  340. package/src/modules/kb-fs/branch-name.ts +14 -1
  341. package/src/modules/kb-fs/repo-path.ts +85 -0
  342. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  343. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  344. package/src/modules/kb-sync/sync-auth.ts +2 -2
  345. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  346. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  347. package/src/modules/mcp/__tests__/mcp.service.test.ts +115 -13
  348. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  349. package/src/modules/mcp/mcp.service.ts +46 -7
  350. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  351. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  352. package/src/modules/plugins/join-request-records.store.ts +7 -4
  353. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  354. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
  355. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
  356. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
  357. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  358. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  359. package/src/modules/settings/deployment-settings.service.ts +8 -3
  360. package/src/modules/settings/setup.routes.ts +263 -19
  361. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  362. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  363. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  364. package/src/modules/skills/skills.contract.ts +91 -18
  365. package/src/modules/skills/skills.service.ts +278 -41
  366. package/src/modules/skills/skills.tools.ts +80 -8
  367. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  368. package/src/modules/tool-auth/external-api-key.service.ts +12 -4
  369. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  370. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  371. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  372. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  373. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  374. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  375. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  376. package/src/modules/tool-helpers/validate-token.ts +1 -1
  377. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  378. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  379. package/src/modules/tool-registry/description-length.ts +111 -0
  380. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  381. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  382. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  383. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
  384. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  385. package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
  386. package/src/modules/workflow/file-lock.service.ts +31 -0
  387. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  388. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  389. package/src/modules/workflow/git/git.service.ts +215 -1
  390. package/src/modules/workflow/pending-commits.service.ts +64 -2
  391. package/src/modules/workflow/recovery-bot.ts +7 -3
  392. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  393. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  394. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  395. package/src/modules/workflow/workflow-hooks.ts +64 -26
  396. package/src/modules/workflow/workflow.service.ts +125 -5
  397. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  398. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  399. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +394 -0
  400. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  401. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  402. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  403. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  404. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  405. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  406. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  407. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +402 -0
  408. package/src/modules/workspace/__tests__/workspace.service.test.ts +77 -9
  409. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +41 -39
  410. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
  411. package/src/modules/workspace/__tests__/workspace.tools.test.ts +766 -56
  412. package/src/modules/workspace/agent-access.gate.ts +164 -0
  413. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  414. package/src/modules/workspace/agent-upload.store.ts +668 -0
  415. package/src/modules/workspace/routine-write-policy.ts +5 -6
  416. package/src/modules/workspace/session-sink.ts +5 -5
  417. package/src/modules/workspace/set-aside-clone.ts +96 -0
  418. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
  419. package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
  420. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  421. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  422. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  423. package/src/modules/workspace/upload-limits.ts +12 -0
  424. package/src/modules/workspace/workspace.routes.ts +96 -5
  425. package/src/modules/workspace/workspace.service.ts +319 -61
  426. package/src/modules/workspace/workspace.tools.ts +894 -214
  427. package/src/modules/workspace/write-denial.ts +0 -8
  428. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  429. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  430. package/src/modules/write-access/write-access.ts +153 -0
  431. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  432. package/src/shared/column-crypto.ts +218 -0
  433. package/src/shared/domain-errors.ts +31 -0
  434. package/src/shared/git.contract.ts +27 -0
  435. package/src/shared/token-crypto.ts +28 -1
  436. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -1
  437. package/src/tenancy/static-tenant-source.ts +1 -3
  438. package/src/tenancy/tenant-secrets.ts +5 -1
  439. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  440. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  441. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  442. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  443. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  444. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  445. package/dist/modules/workflow/session-ontology.service.js +0 -147
  446. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  447. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  448. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  449. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  450. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  451. package/dist/shared/kb-layout.d.ts +0 -39
  452. package/dist/shared/kb-layout.d.ts.map +0 -1
  453. package/dist/shared/kb-layout.js +0 -103
  454. package/dist/shared/kb-layout.js.map +0 -1
  455. package/dist/shared/kb-layout.test.d.ts +0 -2
  456. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  457. package/dist/shared/kb-layout.test.js +0 -75
  458. package/dist/shared/kb-layout.test.js.map +0 -1
  459. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  460. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  461. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  462. package/src/modules/workflow/session-ontology.service.ts +0 -183
  463. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  464. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  465. package/src/shared/kb-layout.test.ts +0 -98
  466. package/src/shared/kb-layout.ts +0 -102
@@ -0,0 +1,1604 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdir, mkdtemp, readdir, readFile, rm, symlink, utimes, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, join } from 'node:path';
6
+ import AdmZip from 'adm-zip';
7
+ import express from 'express';
8
+ import { afterEach, describe, expect, it } from 'vitest';
9
+ import { LocalFilesystem } from '@mastra/core/workspace';
10
+ import { testKbContext } from '../../../__tests__/kb-context.js';
11
+ import { ToolRegistry } from '../../tool-registry/tool-registry.js';
12
+ import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
13
+ import type { ToolContext } from '../../tool-helpers/tool.contract.js';
14
+ import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
15
+ import { registerWorkspaceTools } from '../workspace.tools.js';
16
+ import { RoutineWritePolicyService } from '../routine-write-policy.js';
17
+ import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
18
+ import { ToolDescriptionNotes } from '../agent-access.gate.js';
19
+ import { SpillStore } from '../spill-store.js';
20
+ import { DocExtractService } from '../file-readers/doc-extract.service.js';
21
+ import type { IAccessControl } from '../../access/access-control.interface.js';
22
+ import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
23
+ import {
24
+ AgentUploadStore,
25
+ assertUploadsRootOutsideWorkspaces,
26
+ UPLOAD_TOKEN_REFUSAL,
27
+ } from '../agent-upload.store.js';
28
+ import { createAgentUploadRoutes, isAgentUploadRawBodyPath } from '../agent-upload.routes.js';
29
+ import { READ_ONLY_CODE, type IWriteAccess } from '../../write-access/write-access.js';
30
+ import { MAX_UPLOAD_BYTES } from '../upload-limits.js';
31
+ import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
32
+ import { sharedFileRules } from '../../agent-instructions/shared-file-rules.js';
33
+
34
+ /**
35
+ * The upload route an agent lands files by, end to end: a token, bytes sent to
36
+ * the address with no session of any kind, and an apply that commits them on a
37
+ * branch.
38
+ *
39
+ * Every Scenario in `Agent uploads files: Specification` has a test here. The
40
+ * filesystem is a REAL `LocalFilesystem` over a temp dir, with a `writeFiles`
41
+ * stand-in for the locking batch (as `workspace.tools.test.ts` uses): the
42
+ * tests assert what the TOOL controls — one batch for the whole upload, a
43
+ * per-path outcome, and the exact bytes on disk. That the batch is atomic is
44
+ * asserted where it lives, in `locking-filesystem.test.ts`.
45
+ */
46
+
47
+ const KB_DIR = 'knowledge-base';
48
+ /** A draft branch — not one `testKbContext` protects. */
49
+ const DRAFT = 'draft';
50
+ /** A branch the test context protects, where the lock gate's write rules apply. */
51
+ const PROTECTED = 'target-company-state';
52
+ const PUBLIC_BASE = 'https://kb.example.com';
53
+
54
+ const allowAll = {
55
+ canRead: async () => true,
56
+ canReadBatch: async (_w: string, _u: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
57
+ canWrite: async () => true,
58
+ canDownload: async () => true,
59
+ canOwner: async () => true,
60
+ canWriteBatchAtRef: async () => null,
61
+ eligibleWritersAtRef: async () => ({ roles: ['Admin'], users: [] }),
62
+ } as unknown as IAccessControl;
63
+
64
+ /**
65
+ * Access control that, at HEAD on a protected branch, denies `write` for an
66
+ * explicit set of repo-relative paths — which is the only verdict the write
67
+ * tools' preflight consults (`writeBlocked`). Everything else is allowed, so a
68
+ * test says exactly which path it is about.
69
+ */
70
+ function denyWritesAtHead(denied: (rel: string) => boolean): IAccessControl {
71
+ return {
72
+ ...allowAll,
73
+ canRead: async () => true,
74
+ canWriteBatchAtRef: async (_w: string, _ref: string, _u: string, rels: string[]) =>
75
+ new Map(rels.map((rel) => [rel, !denied(rel)])),
76
+ eligibleWritersAtRef: async () => ({ roles: ['Editor'], users: [{ name: 'Ada', email: 'ada@x' }] }),
77
+ } as unknown as IAccessControl;
78
+ }
79
+
80
+ /** Every server a test started — a second one stands for a second caller. */
81
+ let servers: HttpServer[] = [];
82
+ let tempDir = '';
83
+ let uploadsDir = '';
84
+ let docCacheDir = '';
85
+ let fs: LocalFilesystem;
86
+ let uploads: AgentUploadStore;
87
+ let toolRegistry: ToolRegistry;
88
+ /** One entry per `writeFiles` batch the tools landed: the paths it carried. */
89
+ let batches: string[][] = [];
90
+ /** One entry per folder turn a tool took: the folder it named. */
91
+ let folderTurns: string[] = [];
92
+ /** How many folder turns are held right now — a batch reads it to prove it ran inside one. */
93
+ let turnsHeld = 0;
94
+ /** The depth `turnsHeld` stood at while each `writeFiles` batch landed. */
95
+ let batchTurnDepth: number[] = [];
96
+ /**
97
+ * Held by a test that wants an apply to WAIT, standing in for the folder turn
98
+ * a concurrent `delete_folder` would be holding over the same subtree.
99
+ */
100
+ let folderTurnGate: Promise<void> | undefined;
101
+ /**
102
+ * Resolved the moment a tool ASKS for a folder turn — before any wait on
103
+ * `folderTurnGate`. A test awaits this instead of sleeping: when it resolves,
104
+ * the apply is parked on the gate with certainty, not with probability.
105
+ */
106
+ let folderTurnAsked: Promise<void>;
107
+ let announceFolderTurnAsked: () => void;
108
+
109
+ interface StartOptions {
110
+ access?: IAccessControl;
111
+ userId?: string;
112
+ userEmail?: string;
113
+ ttlMs?: number;
114
+ maxBytes?: number;
115
+ /** How many tokens one user may hold open at once. */
116
+ maxOpenPerUser?: number;
117
+ /** Start the periodic sweep at this interval BEFORE any token is issued. */
118
+ sweepEveryMs?: number;
119
+ /** The store's listing seam, for a test that needs to hold a sweep open. */
120
+ listRoot?: (root: string) => Promise<string[]>;
121
+ /**
122
+ * Mount against a store that is already running, so a second server can
123
+ * stand for a second caller — a different connection key, the same server's
124
+ * store — without the first one's state being thrown away.
125
+ */
126
+ store?: AgentUploadStore;
127
+ /**
128
+ * The deployment’s write verdict, as a host fills it. Default (absent) is
129
+ * core’s own: always writable.
130
+ */
131
+ writeAccess?: IWriteAccess;
132
+ }
133
+
134
+ async function start(options: StartOptions = {}): Promise<string> {
135
+ const { access = allowAll, userId = 'u', userEmail = 'e@x' } = options;
136
+ if (options.store === undefined) {
137
+ tempDir = await mkdtemp(join(tmpdir(), 'ws-upload-'));
138
+ uploadsDir = await mkdtemp(join(tmpdir(), 'agent-uploads-'));
139
+ docCacheDir = await mkdtemp(join(tmpdir(), 'ws-upload-doc-'));
140
+ batches = [];
141
+ folderTurns = [];
142
+ batchTurnDepth = [];
143
+ turnsHeld = 0;
144
+ folderTurnGate = undefined;
145
+ folderTurnAsked = new Promise<void>((resolve) => (announceFolderTurnAsked = resolve));
146
+ }
147
+ fs = new LocalFilesystem({ basePath: tempDir, contained: true });
148
+ // The harness speaks the paths the TOOLS speak: a fixture written here as
149
+ // `Skills/x.md` lands where a tool asking for `Skills/x.md` will read it.
150
+ for (const method of ['readFile', 'writeFile', 'appendFile', 'deleteFile', 'mkdir', 'stat'] as const) {
151
+ const inner = (fs as unknown as Record<string, (...a: unknown[]) => unknown>)[method].bind(fs);
152
+ (fs as unknown as Record<string, unknown>)[method] = (path: string, ...rest: unknown[]) =>
153
+ inner(normalizeWorkspacePath(path, KB_DIR), ...rest);
154
+ }
155
+ const plainWriteFile = fs.writeFile.bind(fs);
156
+ // Stands in for `LockingFilesystem.writeFiles`: honours the caller's
157
+ // under-lock `check` (run with the paths "locked", just before any byte
158
+ // lands) and writes only what the check keeps. Content arrives as a Buffer
159
+ // from an apply, and is written through untouched — which is the property
160
+ // byte-for-byte landing rests on.
161
+ (fs as unknown as Record<string, unknown>).writeFiles = async (
162
+ writes: { path: string; content: string | Buffer }[],
163
+ _summary: string,
164
+ deletes: string[] = [],
165
+ check?: (
166
+ pending: readonly { path: string; content: string | Buffer }[],
167
+ ) => Promise<{ path: string; content: string | Buffer }[]>,
168
+ ) => {
169
+ const landing = check ? await check(writes) : writes;
170
+ batches.push(landing.map((w) => w.path));
171
+ batchTurnDepth.push(turnsHeld);
172
+ for (const w of landing) await plainWriteFile(w.path, w.content as never);
173
+ for (const p of deletes) await fs.deleteFile(p);
174
+ };
175
+
176
+ uploads =
177
+ options.store ??
178
+ new AgentUploadStore({
179
+ root: uploadsDir,
180
+ publicBaseUrl: PUBLIC_BASE,
181
+ tokenPrefix: 'bevel-up_',
182
+ ...(options.ttlMs !== undefined ? { ttlMs: options.ttlMs } : {}),
183
+ ...(options.maxBytes !== undefined ? { maxBytes: options.maxBytes } : {}),
184
+ ...(options.maxOpenPerUser !== undefined ? { maxOpenPerUser: options.maxOpenPerUser } : {}),
185
+ ...(options.listRoot !== undefined ? { listRoot: options.listRoot } : {}),
186
+ });
187
+ if (options.sweepEveryMs !== undefined) uploads.startSweeping(options.sweepEveryMs);
188
+
189
+ const registry = new ToolRegistry();
190
+ toolRegistry = registry;
191
+ const resolve = async (auth: ToolAuth, signal: AbortSignal, sessionId?: string): Promise<ToolContext> => ({
192
+ user: { id: userId, email: userEmail, name: 'N' },
193
+ scope: auth.scope,
194
+ source: auth.source,
195
+ sessionId,
196
+ abortSignal: signal,
197
+ workspaceService: {
198
+ getOrCreateForBranch: async (branch: string) => ({
199
+ id: encodeURIComponent(branch),
200
+ name: branch,
201
+ absolutePath: tempDir,
202
+ createdAt: '',
203
+ kbDirName: KB_DIR,
204
+ }),
205
+ getWorkspacePath: async () => tempDir,
206
+ hasBootstrappedWorkspace: async () => false,
207
+ // Stands in for `WorkspaceService.withFolderTurn`: records the folder
208
+ // whose turn was taken, and waits on `folderTurnGate` on the way in so a
209
+ // test can hold the subtree the way a concurrent `delete_folder` holds
210
+ // it. The real one serialises overlapping subtrees; what a tool test can
211
+ // check is that the tool TAKES the turn, over the right folder, and that
212
+ // it lands nothing until the turn is its own.
213
+ withFolderTurn: async <T>(_id: string, dir: string, op: () => Promise<T>): Promise<T> => {
214
+ folderTurns.push(dir);
215
+ announceFolderTurnAsked();
216
+ if (folderTurnGate !== undefined) await folderTurnGate;
217
+ turnsHeld++;
218
+ try {
219
+ return await op();
220
+ } finally {
221
+ turnsHeld--;
222
+ }
223
+ },
224
+ } as never,
225
+ workflowService: {} as never,
226
+ events: {} as never,
227
+ getFilesystem: async () => fs,
228
+ });
229
+ const toolHandler =
230
+ options.writeAccess !== undefined
231
+ ? createToolHandlerFactory(resolve, options.writeAccess)
232
+ : createToolHandlerFactory(resolve);
233
+ const fakeAuth = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
234
+ req.toolAuth = { source: 'external', userId, scope: 'write' };
235
+ next();
236
+ };
237
+ const app = express();
238
+ app.use((req, res, next) => (isAgentUploadRawBodyPath(req.path) ? next() : express.json()(req, res, next)));
239
+ const router = express.Router();
240
+ registerWorkspaceTools(
241
+ registry,
242
+ router,
243
+ fakeAuth,
244
+ toolHandler,
245
+ new SpillStore(join(tmpdir(), 'bevel-test-spills')),
246
+ new DocExtractService(docCacheDir),
247
+ access,
248
+ testKbContext({ kbDirName: KB_DIR }),
249
+ { recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
250
+ new RoutineWritePolicyService(),
251
+ {} as never,
252
+ undefined,
253
+ undefined,
254
+ uploads,
255
+ );
256
+ router.use(createAgentUploadRoutes({ uploads }));
257
+ app.use('/api', router);
258
+ const httpServer = await new Promise<HttpServer>((r) => {
259
+ const s = app.listen(0, () => r(s));
260
+ });
261
+ servers.push(httpServer);
262
+ return `http://127.0.0.1:${(httpServer.address() as { port: number }).port}`;
263
+ }
264
+
265
+ afterEach(async () => {
266
+ uploads?.stopSweeping();
267
+ for (const server of servers) await new Promise<void>((r) => server.close(() => r()));
268
+ servers = [];
269
+ for (const dir of [tempDir, uploadsDir, docCacheDir]) {
270
+ if (dir) await rm(dir, { recursive: true, force: true });
271
+ }
272
+ tempDir = '';
273
+ uploadsDir = '';
274
+ docCacheDir = '';
275
+ });
276
+
277
+ const call = (base: string, tool: string, body: unknown = {}) =>
278
+ fetch(`${base}/api/agent/tools/${tool}`, {
279
+ method: 'POST',
280
+ headers: { 'content-type': 'application/json', authorization: 'Bearer x' },
281
+ body: JSON.stringify(body),
282
+ });
283
+
284
+ const json = async <T>(res: Response): Promise<T> => (await res.json()) as T;
285
+
286
+ interface Issued {
287
+ uploadUrl: string;
288
+ token: string;
289
+ expiresAt: string;
290
+ expiresInSeconds: number;
291
+ maxBytes: number;
292
+ }
293
+
294
+ const request = async (base: string): Promise<Issued> =>
295
+ json<Issued>(await call(base, 'request_file_upload', {}));
296
+
297
+ /** POST bytes to the token's address, exactly as `curl --data-binary` would. */
298
+ const send = (
299
+ base: string,
300
+ token: string,
301
+ filename: string,
302
+ data: Buffer,
303
+ contentType = 'application/octet-stream',
304
+ ) =>
305
+ fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}?filename=${encodeURIComponent(filename)}`, {
306
+ method: 'POST',
307
+ headers: { 'content-type': contentType },
308
+ body: data,
309
+ });
310
+
311
+ interface ApplyAnswer {
312
+ destination: string;
313
+ count: number;
314
+ total: number;
315
+ files: { path: string; outcome: string; error?: string; message?: string }[];
316
+ truncated?: boolean;
317
+ error?: string;
318
+ proposal?: { targetBranch: string; draftBranch: string; steps: { tool: string }[] };
319
+ canPropose?: boolean;
320
+ }
321
+
322
+ const apply = async (base: string, body: Record<string, unknown>): Promise<ApplyAnswer> =>
323
+ json<ApplyAnswer>(await call(base, 'apply_file_upload', body));
324
+
325
+ const sha = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex');
326
+
327
+ function zipOf(entries: Record<string, Buffer | string>): Buffer {
328
+ const zip = new AdmZip();
329
+ for (const [name, content] of Object.entries(entries)) {
330
+ zip.addFile(name, Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8'));
331
+ }
332
+ return zip.toBuffer();
333
+ }
334
+
335
+ /**
336
+ * A zip whose members keep the names given here VERBATIM. `AdmZip.addFile`
337
+ * sanitizes what it is handed — `../outside.md` goes in as `outside.md` — so a
338
+ * test about an entry name that climbs out has to set the name afterwards, as
339
+ * the tools that write such archives do.
340
+ */
341
+ function zipWithRawNames(entries: Record<string, Buffer | string>): Buffer {
342
+ const zip = new AdmZip();
343
+ for (const [name, content] of Object.entries(entries)) {
344
+ const placeholder = `__raw_${zip.getEntries().length}`;
345
+ zip.addFile(placeholder, Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8'));
346
+ const added = zip.getEntries().find((e) => e.entryName === placeholder);
347
+ if (!added) throw new Error(`could not add "${name}" to the test archive`);
348
+ added.entryName = name;
349
+ }
350
+ return zip.toBuffer();
351
+ }
352
+
353
+ /**
354
+ * A one-entry zip whose headers CLAIM `declaredSize` uncompressed bytes while
355
+ * holding only `content`.
356
+ *
357
+ * What a zip bomb looks like to a reader before it decompresses anything, and
358
+ * the only way to write one in a test without allocating the bytes it claims:
359
+ * the uncompressed-size field is patched in both the local file header and the
360
+ * central directory, which is where every reader takes an entry's size from.
361
+ * An apply that believed the field only after inflating would have to hold the
362
+ * expansion in memory to find out.
363
+ */
364
+ function zipWithDeclaredSize(name: string, content: string, declaredSize: number): Buffer {
365
+ const buffer = zipOf({ [name]: content });
366
+ // The uncompressed-size field: 22 bytes into a local file header, 24 into a
367
+ // central-directory one (see APPNOTE 4.3.7 and 4.3.12).
368
+ for (const [signature, offset] of [
369
+ [0x04034b50, 22],
370
+ [0x02014b50, 24],
371
+ ] as const) {
372
+ for (let at = 0; at + 4 <= buffer.length; at++) {
373
+ if (buffer.readUInt32LE(at) === signature) buffer.writeUInt32LE(declaredSize, at + offset);
374
+ }
375
+ }
376
+ return buffer;
377
+ }
378
+
379
+ /** A real PNG: the 1×1 transparent one, so the bytes are a genuine image. */
380
+ const PNG = Buffer.from(
381
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
382
+ 'base64',
383
+ );
384
+
385
+ const outcomeAt = (answer: ApplyAnswer, path: string) => answer.files.find((f) => f.path === path);
386
+
387
+ describe('request_file_upload', () => {
388
+ it('answers a one-time address, a token, the expiry and the size limit', async () => {
389
+ const base = await start();
390
+ const issued = await request(base);
391
+ expect(issued.uploadUrl).toBe(`${PUBLIC_BASE}/api/agent/uploads/${encodeURIComponent(issued.token)}`);
392
+ expect(issued.token.startsWith('bevel-up_')).toBe(true);
393
+ // 32 random bytes, base64url — nothing derived from the user or the clock.
394
+ expect(issued.token.length).toBeGreaterThan(40);
395
+ expect(Date.parse(issued.expiresAt)).toBeGreaterThan(Date.now());
396
+ expect(issued.expiresInSeconds).toBeGreaterThan(0);
397
+ expect(issued.maxBytes).toBe(MAX_UPLOAD_BYTES);
398
+ });
399
+
400
+ it('mints a different token every time', async () => {
401
+ const base = await start();
402
+ const [a, b] = [await request(base), await request(base)];
403
+ expect(a.token).not.toBe(b.token);
404
+ });
405
+
406
+ /**
407
+ * A token is permission to put the deployment's whole upload limit on its
408
+ * disk for a TTL. Unbounded, one caller asks for a hundred and sends against
409
+ * all of them at once.
410
+ */
411
+ it('refuses another token once one user holds the most they may, and gives one back when it is applied', async () => {
412
+ const base = await start({ maxOpenPerUser: 2 });
413
+ const first = await request(base);
414
+ await request(base);
415
+ const refused = await call(base, 'request_file_upload', {});
416
+ expect(refused.status).toBe(429);
417
+ expect((await json<{ error: string }>(refused)).error).toContain('already hold 2 upload tokens');
418
+
419
+ // Applying one spends it, and its place is free again.
420
+ await send(base, first.token, 'a.md', Buffer.from('a'));
421
+ const landed = await apply(base, { branch: DRAFT, token: first.token, destination: `${KB_DIR}/Drop` });
422
+ expect(outcomeAt(landed, `${KB_DIR}/Drop/a.md`)).toMatchObject({ outcome: 'created' });
423
+ expect((await call(base, 'request_file_upload', {})).status).toBe(200);
424
+ });
425
+
426
+ /**
427
+ * A token whose expiry has passed is not held any more — unless its upload
428
+ * is still arriving. That one keeps a connection and a file open exactly as
429
+ * a live token does, and letting it fall out of the count at expiry let a
430
+ * caller keep a full set of slow uploads going and ask for another.
431
+ */
432
+ it('counts an upload still arriving as held, past its token\'s expiry', async () => {
433
+ const ttlMs = 1_000;
434
+ const base = await start({ ttlMs, maxOpenPerUser: 1 });
435
+ const { token } = await request(base);
436
+ const issuedAt = Date.now();
437
+ let finish!: () => void;
438
+ const body = new ReadableStream<Uint8Array>({
439
+ async start(controller) {
440
+ controller.enqueue(new Uint8Array([1, 2, 3]));
441
+ await new Promise<void>((resolve) => {
442
+ finish = resolve;
443
+ });
444
+ controller.close();
445
+ },
446
+ });
447
+ const arriving = fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}?filename=slow.bin`, {
448
+ method: 'POST',
449
+ body,
450
+ // Node's fetch requires this for a streamed request body.
451
+ duplex: 'half',
452
+ } as RequestInit & { duplex: 'half' });
453
+ // The upload has REACHED the store — its first bytes are staged — while the
454
+ // token was still good. Waited for, not assumed from a delay: a request
455
+ // that arrived only after the expiry would be refused at the door, pinned
456
+ // by nothing, and this would be measuring something else.
457
+ const staged = Date.now() + 5_000;
458
+ while ((await readdir(uploadsDir).catch(() => [])).length === 0) {
459
+ expect(Date.now(), 'the upload never reached the store').toBeLessThan(staged);
460
+ await new Promise((r) => setTimeout(r, 10));
461
+ }
462
+ expect(Date.now() - issuedAt, 'the upload arrived after its token had expired').toBeLessThan(ttlMs);
463
+ // Now past the expiry, with the body still open.
464
+ await new Promise((r) => setTimeout(r, Math.max(0, issuedAt + ttlMs + 150 - Date.now())));
465
+ expect((await call(base, 'request_file_upload', {})).status).toBe(429);
466
+
467
+ // Once it has ended (refused: its token expired meanwhile), the place is free.
468
+ finish();
469
+ expect((await arriving).status).toBe(404);
470
+ expect((await call(base, 'request_file_upload', {})).status).toBe(200);
471
+ });
472
+
473
+ it('is on both tool surfaces, with apply_file_upload', async () => {
474
+ await start();
475
+ const names = (await toolRegistry.listExternal()).map((t) => t.name);
476
+ expect(names).toContain('request_file_upload');
477
+ expect(names).toContain('apply_file_upload');
478
+ const internal = (await toolRegistry.listInternal()).map((t) => t.name);
479
+ expect(internal).toContain('request_file_upload');
480
+ expect(internal).toContain('apply_file_upload');
481
+ });
482
+ });
483
+
484
+ describe('the upload route', () => {
485
+ it('stores the bytes outside every workspace and names what it received', async () => {
486
+ const base = await start();
487
+ const { token } = await request(base);
488
+ const res = await send(base, token, 'notes.md', Buffer.from('hello'));
489
+ expect(res.status).toBe(200);
490
+ expect(await json(res)).toEqual({ filename: 'notes.md', bytes: 5, kind: 'file' });
491
+ // BESIDE the workspaces root, never inside one: the bytes are content the
492
+ // server has accepted and not yet judged.
493
+ expect(uploadsDir.startsWith(tempDir)).toBe(false);
494
+ const stored = await readdir(uploadsDir);
495
+ expect(stored).toHaveLength(1);
496
+ expect(await readFile(join(uploadsDir, stored[0]), 'utf8')).toBe('hello');
497
+ });
498
+
499
+ it('counts a zip\'s entries in the answer', async () => {
500
+ const base = await start();
501
+ const { token } = await request(base);
502
+ const zip = zipOf({ 'SKILL.md': '# Skill\n', 'docs/a.md': 'a', 'logo.png': PNG });
503
+ const res = await send(base, token, 'skill.zip', zip);
504
+ expect(await json(res)).toEqual({ filename: 'skill.zip', bytes: zip.byteLength, kind: 'zip', entries: 3 });
505
+ });
506
+
507
+ it('needs no session of any kind — the token is the whole credential', async () => {
508
+ const base = await start();
509
+ const { token } = await request(base);
510
+ // No Authorization header at all, which is what an agent's `curl` sends.
511
+ const res = await fetch(`${base}/api/agent/uploads/${token}?filename=x.md`, {
512
+ method: 'POST',
513
+ body: Buffer.from('x'),
514
+ });
515
+ expect(res.status).toBe(200);
516
+ });
517
+
518
+ it('keeps the exact bytes even when the sender claims a JSON content-type', async () => {
519
+ const base = await start();
520
+ const { token } = await request(base);
521
+ // `curl --data-binary @f -H 'content-type: application/json'` — the global
522
+ // JSON parser must not have drained the stream (isAgentUploadRawBodyPath).
523
+ const body = Buffer.from('{"not":"json at all\\u0000"}\u0000\u0001', 'utf8');
524
+ const res = await send(base, token, 'raw.bin', body, 'application/json');
525
+ expect(await json(res)).toMatchObject({ bytes: body.byteLength });
526
+ });
527
+
528
+ it('refuses an upload over the limit, naming the limit', async () => {
529
+ const base = await start({ maxBytes: 1024 });
530
+ const { token, maxBytes } = await request(base);
531
+ expect(maxBytes).toBe(1024);
532
+ const res = await send(base, token, 'big.bin', Buffer.alloc(2048, 7));
533
+ expect(res.status).toBe(413);
534
+ expect((await json<{ error: string }>(res)).error).toContain('1024 byte upload limit');
535
+ expect(await readdir(uploadsDir)).toEqual([]);
536
+ });
537
+
538
+ it('refuses a second upload against the same token', async () => {
539
+ const base = await start();
540
+ const { token } = await request(base);
541
+ expect((await send(base, token, 'a.md', Buffer.from('a'))).status).toBe(200);
542
+ const second = await send(base, token, 'b.md', Buffer.from('b'));
543
+ expect(second.status).toBe(404);
544
+ expect((await json<{ error: string }>(second)).error).toBe(UPLOAD_TOKEN_REFUSAL);
545
+ });
546
+
547
+ it('refuses an unknown token with one message that says nothing about it', async () => {
548
+ const base = await start();
549
+ const res = await send(base, 'bevel-up_nope', 'a.md', Buffer.from('a'));
550
+ expect(res.status).toBe(404);
551
+ expect((await json<{ error: string }>(res)).error).toBe(UPLOAD_TOKEN_REFUSAL);
552
+ });
553
+
554
+ it('tells an unknown token nothing about its OTHER mistakes either', async () => {
555
+ const base = await start();
556
+ // No file name at all, and a name no filesystem would keep — both
557
+ // ordinarily a 400 that says which. Against a token that does not exist
558
+ // they get the one refusal instead: "that is not a usable file name" is an
559
+ // answer only somebody entitled to send a file should be able to collect.
560
+ for (const url of [
561
+ `${base}/api/agent/uploads/bevel-up_nope`,
562
+ `${base}/api/agent/uploads/bevel-up_nope?filename=${encodeURIComponent('../escape.md')}`,
563
+ ]) {
564
+ const res = await fetch(url, { method: 'POST', body: Buffer.from('x') });
565
+ expect(res.status).toBe(404);
566
+ expect((await json<{ error: string }>(res)).error).toBe(UPLOAD_TOKEN_REFUSAL);
567
+ }
568
+ });
569
+
570
+ it('does not read a parameter that merely ENDS in "filename" as the name', async () => {
571
+ const base = await start();
572
+ const { token } = await request(base);
573
+ // `xfilename` is a parameter of its own, and `filename` is its suffix. The
574
+ // name is asked for instead of taken from it.
575
+ const res = await fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}`, {
576
+ method: 'POST',
577
+ headers: { 'content-disposition': 'inline; xfilename=wrong.md' },
578
+ body: Buffer.from('x'),
579
+ });
580
+ expect(res.status).toBe(400);
581
+ expect((await json<{ error: string }>(res)).error).toContain('filename');
582
+ expect(await readdir(uploadsDir)).toEqual([]);
583
+ });
584
+
585
+ it('asks for a file name when none was given', async () => {
586
+ const base = await start();
587
+ const { token } = await request(base);
588
+ const res = await fetch(`${base}/api/agent/uploads/${token}`, { method: 'POST', body: Buffer.from('x') });
589
+ expect(res.status).toBe(400);
590
+ expect((await json<{ error: string }>(res)).error).toContain('filename');
591
+ });
592
+
593
+ it('refuses a file name that is a path', async () => {
594
+ const base = await start();
595
+ const { token } = await request(base);
596
+ const res = await send(base, token, '../escape.md', Buffer.from('x'));
597
+ expect(res.status).toBe(400);
598
+ });
599
+
600
+ it('refuses the second of two uploads sent against one token at the same time', async () => {
601
+ const base = await start();
602
+ const { token } = await request(base);
603
+ // Both in flight before either has finished: the token is reserved before
604
+ // the first `await` of the attach, so exactly one of them is accepted and
605
+ // the other meets the one refusal. Without the reservation both stored
606
+ // their bytes and the apply landed whichever finished last.
607
+ const [first, second] = await Promise.all([
608
+ send(base, token, 'first.md', Buffer.from('first')),
609
+ send(base, token, 'second.md', Buffer.from('second')),
610
+ ]);
611
+ const statuses = [first.status, second.status].sort();
612
+ expect(statuses).toEqual([200, 404]);
613
+ expect(await readdir(uploadsDir)).toHaveLength(1);
614
+ // And the file the accepted answer named is the file the apply lands.
615
+ const accepted = first.status === 200 ? first : second;
616
+ const { filename } = await json<{ filename: string }>(accepted);
617
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Race` });
618
+ expect(answer.files).toEqual([{ path: `${KB_DIR}/Race/${filename}`, outcome: 'created' }]);
619
+ expect(await readFile(join(tempDir, KB_DIR, 'Race', filename), 'utf8')).toBe(filename.replace('.md', ''));
620
+ });
621
+
622
+ it('refuses an unknown token before it has read a single byte of the body', async () => {
623
+ const base = await start({ maxBytes: 64 });
624
+ // Over the limit AND against a token that does not exist. The 404 proves
625
+ // the token was judged first: had the body been buffered, the size check
626
+ // inside the read would have answered 413 instead. That ordering is what
627
+ // keeps an invented token from making the process hold the deployment's
628
+ // whole upload limit in memory.
629
+ const res = await fetch(`${base}/api/agent/uploads/bevel-up_invented?filename=big.md`, {
630
+ method: 'POST',
631
+ headers: { 'content-type': 'application/octet-stream' },
632
+ body: Buffer.alloc(200, 7),
633
+ });
634
+ expect(res.status).toBe(404);
635
+ expect((await json<{ error: string }>(res)).error).toBe(UPLOAD_TOKEN_REFUSAL);
636
+ expect(await readdir(uploadsDir)).toEqual([]);
637
+ });
638
+
639
+ it('keeps a quoted content-disposition name that holds a semicolon', async () => {
640
+ const base = await start();
641
+ const { token } = await request(base);
642
+ const res = await fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}`, {
643
+ method: 'POST',
644
+ headers: { 'content-disposition': 'attachment; filename="report;final.md"' },
645
+ body: Buffer.from('r'),
646
+ });
647
+ expect(await json<{ filename: string }>(res)).toMatchObject({ filename: 'report;final.md' });
648
+ });
649
+
650
+ it('reads the extended content-disposition name, percent-decoded', async () => {
651
+ const base = await start();
652
+ const { token } = await request(base);
653
+ const res = await fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}`, {
654
+ method: 'POST',
655
+ headers: { 'content-disposition': "attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.md" },
656
+ body: Buffer.from('r'),
657
+ });
658
+ expect(await json<{ filename: string }>(res)).toMatchObject({ filename: 'résumé.md' });
659
+ });
660
+
661
+ it('reads a plain content-disposition name as the name itself, percent signs and all', async () => {
662
+ const base = await start();
663
+ const { token } = await request(base);
664
+ const res = await fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}`, {
665
+ method: 'POST',
666
+ headers: { 'content-disposition': 'attachment; filename=50%20off.md' },
667
+ body: Buffer.from('r'),
668
+ });
669
+ expect(await json<{ filename: string }>(res)).toMatchObject({ filename: '50%20off.md' });
670
+ });
671
+
672
+ it('answers an unexpected failure without quoting the filesystem back', async () => {
673
+ const base = await start();
674
+ const { token } = await request(base);
675
+ // The store's root replaced by a FILE, so the attach's `mkdir` fails with
676
+ // an ENOTDIR whose message carries the absolute path of the staging root —
677
+ // a place the caller is told nothing else about.
678
+ await rm(uploadsDir, { recursive: true, force: true });
679
+ await writeFile(uploadsDir, 'not a directory');
680
+ const res = await send(base, token, 'a.md', Buffer.from('a'));
681
+ expect(res.status).toBe(500);
682
+ expect(await json<{ error: string }>(res)).toEqual({ error: 'Upload failed' });
683
+ // Removed here, because `afterEach` deletes a directory, not a file.
684
+ await rm(uploadsDir, { force: true });
685
+ uploadsDir = '';
686
+ });
687
+
688
+ it('refuses a .zip whose bytes are not an archive, while the caller still holds the file', async () => {
689
+ const base = await start();
690
+ const { token } = await request(base);
691
+ const res = await send(base, token, 'broken.zip', Buffer.from('not a zip at all'));
692
+ expect(res.status).toBe(422);
693
+ expect((await json<{ error: string }>(res)).error).toContain('not a readable .zip');
694
+ });
695
+ });
696
+
697
+ describe('apply_file_upload', () => {
698
+ it('lands a zip\'s entries in ONE commit, each created, keeping the folder structure', async () => {
699
+ const base = await start();
700
+ const { token } = await request(base);
701
+ await send(base, token, 'skill.zip', zipOf({
702
+ 'SKILL.md': '# My skill\n',
703
+ 'references/guide.md': 'guide\n',
704
+ 'assets/logo.png': PNG,
705
+ }));
706
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Skills/my-skill` });
707
+ expect(answer.destination).toBe(`${KB_DIR}/Skills/my-skill`);
708
+ expect(answer.total).toBe(3);
709
+ expect(answer.count).toBe(3);
710
+ expect(answer.files.map((f) => f.outcome)).toEqual(['created', 'created', 'created']);
711
+ expect(answer.files.map((f) => f.path).sort()).toEqual([
712
+ `${KB_DIR}/Skills/my-skill/SKILL.md`,
713
+ `${KB_DIR}/Skills/my-skill/assets/logo.png`,
714
+ `${KB_DIR}/Skills/my-skill/references/guide.md`,
715
+ ]);
716
+ // One batch, so one commit for the whole upload.
717
+ expect(batches).toHaveLength(1);
718
+ expect(batches[0]).toHaveLength(3);
719
+ expect(await readFile(join(tempDir, KB_DIR, 'Skills/my-skill/references/guide.md'), 'utf8')).toBe('guide\n');
720
+ });
721
+
722
+ it('lands a single file under the name it was sent with', async () => {
723
+ const base = await start();
724
+ const { token } = await request(base);
725
+ await send(base, token, 'page.html', Buffer.from('<p>hi</p>'));
726
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Pages` });
727
+ expect(answer.files).toEqual([{ path: `${KB_DIR}/Pages/page.html`, outcome: 'created' }]);
728
+ expect(await readFile(join(tempDir, KB_DIR, 'Pages/page.html'), 'utf8')).toBe('<p>hi</p>');
729
+ });
730
+
731
+ // Scenario: the zip holds SKILL.md and SKILL.md already exists, `mode` left out.
732
+ it('refuses an existing path with `exists` under the default mode, and lands the others', async () => {
733
+ const base = await start();
734
+ await mkdir(join(tempDir, KB_DIR, 'Skills/my-skill'), { recursive: true });
735
+ await writeFile(join(tempDir, KB_DIR, 'Skills/my-skill/SKILL.md'), 'mine\n');
736
+ const { token } = await request(base);
737
+ await send(base, token, 'skill.zip', zipOf({ 'SKILL.md': '# new\n', 'extra.md': 'extra\n' }));
738
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Skills/my-skill` });
739
+ expect(answer.count).toBe(1);
740
+ expect(answer.total).toBe(2);
741
+ expect(outcomeAt(answer, `${KB_DIR}/Skills/my-skill/SKILL.md`)).toMatchObject({
742
+ outcome: 'refused',
743
+ error: 'exists',
744
+ });
745
+ expect(outcomeAt(answer, `${KB_DIR}/Skills/my-skill/extra.md`)).toMatchObject({ outcome: 'created' });
746
+ // The refused path is left exactly as it was.
747
+ expect(await readFile(join(tempDir, KB_DIR, 'Skills/my-skill/SKILL.md'), 'utf8')).toBe('mine\n');
748
+ expect(await readFile(join(tempDir, KB_DIR, 'Skills/my-skill/extra.md'), 'utf8')).toBe('extra\n');
749
+ });
750
+
751
+ it('replaces an existing path with `mode: overwrite`', async () => {
752
+ const base = await start();
753
+ await mkdir(join(tempDir, KB_DIR, 'Skills/my-skill'), { recursive: true });
754
+ await writeFile(join(tempDir, KB_DIR, 'Skills/my-skill/SKILL.md'), 'mine\n');
755
+ const { token } = await request(base);
756
+ await send(base, token, 'skill.zip', zipOf({ 'SKILL.md': '# new\n' }));
757
+ const answer = await apply(base, {
758
+ branch: DRAFT,
759
+ token,
760
+ destination: `${KB_DIR}/Skills/my-skill`,
761
+ mode: 'overwrite',
762
+ });
763
+ expect(answer.files).toEqual([{ path: `${KB_DIR}/Skills/my-skill/SKILL.md`, outcome: 'replaced' }]);
764
+ expect(await readFile(join(tempDir, KB_DIR, 'Skills/my-skill/SKILL.md'), 'utf8')).toBe('# new\n');
765
+ });
766
+
767
+ it('`mode: update` refuses a path that does not exist and updates one that does', async () => {
768
+ const base = await start();
769
+ await mkdir(join(tempDir, KB_DIR, 'Pages'), { recursive: true });
770
+ await writeFile(join(tempDir, KB_DIR, 'Pages/there.md'), 'old\n');
771
+ const { token } = await request(base);
772
+ await send(base, token, 'pair.zip', zipOf({ 'there.md': 'new\n', 'missing.md': 'nope\n' }));
773
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Pages`, mode: 'update' });
774
+ expect(outcomeAt(answer, `${KB_DIR}/Pages/there.md`)).toMatchObject({ outcome: 'updated' });
775
+ expect(outcomeAt(answer, `${KB_DIR}/Pages/missing.md`)).toMatchObject({
776
+ outcome: 'refused',
777
+ error: 'missing',
778
+ });
779
+ expect(await readFile(join(tempDir, KB_DIR, 'Pages/there.md'), 'utf8')).toBe('new\n');
780
+ });
781
+
782
+ it('refuses a mode that is not one of the three', async () => {
783
+ const base = await start();
784
+ const { token } = await request(base);
785
+ await send(base, token, 'a.md', Buffer.from('a'));
786
+ const res = await call(base, 'apply_file_upload', {
787
+ branch: DRAFT,
788
+ token,
789
+ destination: `${KB_DIR}/Pages`,
790
+ mode: 'replace',
791
+ });
792
+ expect(res.status).toBe(400);
793
+ });
794
+
795
+ // Scenario: a 40 KB page of unicode escapes, backslashes and quotes — the
796
+ // content a JSON tool parameter cannot carry — lands with its own checksum.
797
+ it('lands an escape-heavy text file byte for byte', async () => {
798
+ const base = await start();
799
+ const line = 'const re = /\\u00e9\\\\[a-z]+"quoted"\\n\\t/g; // "\\u2028" \\\\ \'é中文
\n';
800
+ const page = Buffer.from(line.repeat(Math.ceil(40 * 1024 / line.length)), 'utf8');
801
+ expect(page.byteLength).toBeGreaterThan(40 * 1024);
802
+ const before = sha(page);
803
+ const { token } = await request(base);
804
+ await send(base, token, 'page.html', page);
805
+ await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Pages` });
806
+ const landed = await readFile(join(tempDir, KB_DIR, 'Pages/page.html'));
807
+ expect(sha(landed)).toBe(before);
808
+ });
809
+
810
+ it('lands a PNG byte for byte', async () => {
811
+ const base = await start();
812
+ const before = sha(PNG);
813
+ const { token } = await request(base);
814
+ await send(base, token, 'logo.png', PNG);
815
+ await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Pages/assets` });
816
+ const landed = await readFile(join(tempDir, KB_DIR, 'Pages/assets/logo.png'));
817
+ expect(sha(landed)).toBe(before);
818
+ expect(landed.equals(PNG)).toBe(true);
819
+ });
820
+
821
+ it('lands a PNG and a text file out of one zip, both with their own checksums', async () => {
822
+ const base = await start();
823
+ const markdown = Buffer.from('Backslashes \\\\ and "quotes" and \\u00e9 as text.\n', 'utf8');
824
+ const { token } = await request(base);
825
+ await send(base, token, 'mixed.zip', zipOf({ 'logo.png': PNG, 'notes.md': markdown }));
826
+ await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Mixed` });
827
+ expect(sha(await readFile(join(tempDir, KB_DIR, 'Mixed/logo.png')))).toBe(sha(PNG));
828
+ expect(sha(await readFile(join(tempDir, KB_DIR, 'Mixed/notes.md')))).toBe(sha(markdown));
829
+ });
830
+
831
+ // Scenario: the destination is on a protected branch the caller may not
832
+ // write directly.
833
+ it('lands nothing on a protected branch it may not write, and names the change-request route', async () => {
834
+ const base = await start({ access: denyWritesAtHead(() => true) });
835
+ const { token } = await request(base);
836
+ await send(base, token, 'skill.zip', zipOf({ 'a.md': 'a', 'b.md': 'b' }));
837
+ const res = await call(base, 'apply_file_upload', {
838
+ branch: PROTECTED,
839
+ token,
840
+ destination: `${KB_DIR}/Skills/my-skill`,
841
+ });
842
+ expect(res.status).toBe(403);
843
+ const body = await json<ApplyAnswer & { kind?: string }>(res);
844
+ expect(body.kind).toBe('write-denied');
845
+ expect(body.canPropose).toBe(true);
846
+ expect(body.proposal?.steps.map((s) => s.tool)).toEqual([
847
+ 'create_branch',
848
+ 'apply_file_upload',
849
+ 'open_change_request',
850
+ ]);
851
+ expect(batches).toEqual([]);
852
+ // The token survives a refusal that landed nothing, so the bytes need not
853
+ // be sent again to try a draft branch instead.
854
+ const retry = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Skills/my-skill` });
855
+ expect(retry.count).toBe(2);
856
+ });
857
+
858
+ // Scenario: one entry targets a folder the caller may not write.
859
+ it('refuses the one entry whose own path is denied, and lands the others', async () => {
860
+ const base = await start({ access: denyWritesAtHead((rel) => rel.startsWith('Locked/')) });
861
+ const { token } = await request(base);
862
+ await send(base, token, 'drop.zip', zipOf({ 'open.md': 'open', 'Locked/secret.md': 'secret' }));
863
+ const answer = await apply(base, { branch: PROTECTED, token, destination: KB_DIR });
864
+ expect(outcomeAt(answer, `${KB_DIR}/Locked/secret.md`)).toMatchObject({
865
+ outcome: 'refused',
866
+ error: 'write-denied',
867
+ });
868
+ expect(outcomeAt(answer, `${KB_DIR}/Locked/secret.md`)?.message).toContain('Eligible:');
869
+ expect(outcomeAt(answer, `${KB_DIR}/open.md`)).toMatchObject({ outcome: 'created' });
870
+ expect(answer.count).toBe(1);
871
+ });
872
+
873
+ // Scenario: an entry named `access.md`, or one that would create a platform
874
+ // folder.
875
+ it('refuses a platform file with the platform-file sentence, and lands the others', async () => {
876
+ const base = await start();
877
+ const { token } = await request(base);
878
+ await send(base, token, 'drop.zip', zipOf({
879
+ 'access.md': '---\nread: everyone\n---\n',
880
+ 'roles.yaml': 'Admin:\n - e@x\n',
881
+ 'ok.md': 'ok',
882
+ }));
883
+ const answer = await apply(base, { branch: DRAFT, token, destination: KB_DIR });
884
+ expect(outcomeAt(answer, `${KB_DIR}/access.md`)).toMatchObject({
885
+ outcome: 'refused',
886
+ error: 'platform_file',
887
+ message: 'access.md is a platform file and is never landed by an upload — change it with edit_file or write_file, where the change is checked.',
888
+ });
889
+ expect(outcomeAt(answer, `${KB_DIR}/roles.yaml`)).toMatchObject({ outcome: 'refused', error: 'platform_file' });
890
+ expect(outcomeAt(answer, `${KB_DIR}/ok.md`)).toMatchObject({ outcome: 'created' });
891
+ expect(await readdir(join(tempDir, KB_DIR))).toEqual(['ok.md']);
892
+ });
893
+
894
+ it('refuses an entry that would create the checkout folder\'s own name at the root', async () => {
895
+ const base = await start();
896
+ const { token } = await request(base);
897
+ await send(base, token, 'drop.zip', zipOf({ [`${KB_DIR}/inner.md`]: 'inner', 'ok.md': 'ok' }));
898
+ const answer = await apply(base, { branch: DRAFT, token, destination: KB_DIR });
899
+ expect(outcomeAt(answer, `${KB_DIR}/${KB_DIR}/inner.md`)).toMatchObject({
900
+ outcome: 'refused',
901
+ error: 'reserved-root-name',
902
+ });
903
+ expect(outcomeAt(answer, `${KB_DIR}/ok.md`)).toMatchObject({ outcome: 'created' });
904
+ });
905
+
906
+ // Scenario: a zip entry is `../outside.md` or a symbolic link.
907
+ it('refuses an entry that would land outside the destination, and lands the others', async () => {
908
+ const base = await start();
909
+ const { token } = await request(base);
910
+ await send(base, token, 'drop.zip', zipWithRawNames({
911
+ '../outside.md': 'escaped',
912
+ '/rooted.md': 'rooted',
913
+ 'inside.md': 'inside',
914
+ }));
915
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
916
+ expect(answer.total).toBe(3);
917
+ expect(answer.count).toBe(1);
918
+ expect(outcomeAt(answer, '../outside.md')).toMatchObject({ outcome: 'refused', error: 'invalid_entry' });
919
+ expect(outcomeAt(answer, '/rooted.md')).toMatchObject({ outcome: 'refused', error: 'invalid_entry' });
920
+ expect(outcomeAt(answer, `${KB_DIR}/Drop/inside.md`)).toMatchObject({ outcome: 'created' });
921
+ // Nothing landed beside the destination.
922
+ expect(await readdir(join(tempDir, KB_DIR, 'Drop'))).toEqual(['inside.md']);
923
+ expect(await readdir(join(tempDir, KB_DIR))).toEqual(['Drop']);
924
+ });
925
+
926
+ it('refuses a zip entry that is a symbolic link, and lands the others', async () => {
927
+ const base = await start();
928
+ const { token } = await request(base);
929
+ const zip = new AdmZip();
930
+ zip.addFile('link.md', Buffer.from('../../etc/passwd'));
931
+ // The unix mode a zip records for a symbolic link (`S_IFLNK | 0777`), in
932
+ // the high half of the external attributes.
933
+ const entry = zip.getEntry('link.md');
934
+ if (entry) entry.header.attr = ((0o120000 | 0o777) << 16) >>> 0;
935
+ zip.addFile('real.md', Buffer.from('real'));
936
+ await send(base, token, 'links.zip', zip.toBuffer());
937
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
938
+ expect(outcomeAt(answer, 'link.md')).toMatchObject({ outcome: 'refused', error: 'link' });
939
+ expect(outcomeAt(answer, `${KB_DIR}/Drop/real.md`)).toMatchObject({ outcome: 'created' });
940
+ expect(await readdir(join(tempDir, KB_DIR, 'Drop'))).toEqual(['real.md']);
941
+ });
942
+
943
+ it('refuses an entry whose path goes through a symbolic link already on disk', async () => {
944
+ const base = await start();
945
+ await mkdir(join(tempDir, KB_DIR, 'Real'), { recursive: true });
946
+ await mkdir(join(tempDir, 'outside'), { recursive: true });
947
+ await symlink(join(tempDir, 'outside'), join(tempDir, KB_DIR, 'Away'), 'dir');
948
+ const { token } = await request(base);
949
+ await send(base, token, 'drop.zip', zipOf({ 'Away/x.md': 'away', 'Real/y.md': 'real' }));
950
+ const answer = await apply(base, { branch: DRAFT, token, destination: KB_DIR });
951
+ expect(outcomeAt(answer, `${KB_DIR}/Away/x.md`)).toMatchObject({ outcome: 'refused', error: 'symlink' });
952
+ expect(outcomeAt(answer, `${KB_DIR}/Real/y.md`)).toMatchObject({ outcome: 'created' });
953
+ expect(await readdir(join(tempDir, 'outside'))).toEqual([]);
954
+ });
955
+
956
+ it('refuses an entry naming the git folder, and lands the others', async () => {
957
+ const base = await start();
958
+ const { token } = await request(base);
959
+ await send(base, token, 'drop.zip', zipOf({ '.git/config': 'evil', 'fine.md': 'fine' }));
960
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
961
+ expect(outcomeAt(answer, '.git/config')).toMatchObject({ outcome: 'refused', error: 'invalid_entry' });
962
+ expect(outcomeAt(answer, `${KB_DIR}/Drop/fine.md`)).toMatchObject({ outcome: 'created' });
963
+ });
964
+
965
+ it('refuses a SINGLE file named after the git folder as that path\'s own outcome', async () => {
966
+ const base = await start();
967
+ const { token } = await request(base);
968
+ // `.git` is a name `validateFilename` accepts, so the upload route lets it
969
+ // through; it reaches the apply as a name of the upload's, not of the
970
+ // call's, so the preflight that reads the caller's arguments never sees it.
971
+ // Judged per path, like every other refusal, rather than failing the whole
972
+ // apply from inside the batch write.
973
+ await send(base, token, '.git', Buffer.from('evil'));
974
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
975
+ expect(answer.count).toBe(0);
976
+ expect(outcomeAt(answer, `${KB_DIR}/Drop/.git`)).toMatchObject({
977
+ outcome: 'refused',
978
+ error: 'git-internals',
979
+ });
980
+ expect(batches).toEqual([]);
981
+ });
982
+
983
+ it('refuses an entry that CLAIMS more than one commit lands, without inflating it', async () => {
984
+ const base = await start();
985
+ const { token } = await request(base);
986
+ // 200 MB declared against a 128 MB apply budget. Judged on the header,
987
+ // before `getData`, because a deflate stream expands by three orders of
988
+ // magnitude and an entry inflated to be measured is an entry already held
989
+ // in memory. The bytes behind this header are a handful.
990
+ const bomb = zipWithDeclaredSize('bomb.md', 'tiny', 200 * 1024 * 1024);
991
+ await send(base, token, 'bomb.zip', bomb);
992
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Bomb` });
993
+ expect(outcomeAt(answer, 'bomb.md')).toMatchObject({ outcome: 'refused', error: 'too_large' });
994
+ expect(outcomeAt(answer, 'bomb.md')?.message).toContain('one commit');
995
+ expect(batches).toEqual([]);
996
+ });
997
+
998
+ /**
999
+ * The other way to lie about a size. The archive reader caps an inflation at
1000
+ * the size the header declares — but only when that size is above zero, so an
1001
+ * entry declaring ZERO passed the check above and was then inflated with no
1002
+ * cap at all: four megabytes out of four kilobytes here, tens of gigabytes
1003
+ * out of an upload at the limit.
1004
+ */
1005
+ it('refuses an entry whose header says it is empty and whose stream is not', async () => {
1006
+ const base = await start();
1007
+ const { token } = await request(base);
1008
+ const bomb = zipWithDeclaredSize('bomb.md', 'a'.repeat(4 * 1024 * 1024), 0);
1009
+ expect(bomb.byteLength).toBeLessThan(16 * 1024);
1010
+ await send(base, token, 'bomb.zip', bomb);
1011
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Bomb` });
1012
+ expect(outcomeAt(answer, 'bomb.md')).toMatchObject({ outcome: 'refused', error: 'unreadable_entry' });
1013
+ expect(outcomeAt(answer, 'bomb.md')?.message).toContain('declares an empty file');
1014
+ expect(answer.count).toBe(0);
1015
+ expect(batches).toEqual([]);
1016
+ });
1017
+
1018
+ it('lands a file that really is empty', async () => {
1019
+ const base = await start();
1020
+ const { token } = await request(base);
1021
+ await send(base, token, 'drop.zip', zipOf({ 'empty.md': '', 'full.md': 'x' }));
1022
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
1023
+ expect(outcomeAt(answer, `${KB_DIR}/Drop/empty.md`)).toMatchObject({ outcome: 'created' });
1024
+ expect(outcomeAt(answer, `${KB_DIR}/Drop/full.md`)).toMatchObject({ outcome: 'created' });
1025
+ });
1026
+
1027
+ it('refuses the one entry that fails its checksum, and lands the others', async () => {
1028
+ const base = await start();
1029
+ const { token } = await request(base);
1030
+ const archive = zipOf({ 'bad.md': 'this entry will not match its checksum', 'fine.md': 'fine' });
1031
+ // The CRC field of the FIRST entry, in both of its headers: 14 bytes into
1032
+ // the local one, 16 into the central-directory one. A reader checks one or
1033
+ // the other depending on the entry's flags.
1034
+ const firstLocal = archive.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04]));
1035
+ const firstCentral = archive.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
1036
+ archive.writeUInt32LE((archive.readUInt32LE(firstLocal + 14) ^ 0xffffffff) >>> 0, firstLocal + 14);
1037
+ archive.writeUInt32LE((archive.readUInt32LE(firstCentral + 16) ^ 0xffffffff) >>> 0, firstCentral + 16);
1038
+ await send(base, token, 'drop.zip', archive);
1039
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
1040
+ expect(outcomeAt(answer, 'bad.md')).toMatchObject({ outcome: 'refused', error: 'unreadable_entry' });
1041
+ expect(outcomeAt(answer, `${KB_DIR}/Drop/fine.md`)).toMatchObject({ outcome: 'created' });
1042
+ });
1043
+
1044
+ // Scenario: a zip holds 60 entries.
1045
+ it('lists 25 paths with the total, and all of them when asked', async () => {
1046
+ const base = await start();
1047
+ const { token } = await request(base);
1048
+ const entries: Record<string, string> = {};
1049
+ for (let i = 0; i < 60; i++) entries[`f${String(i).padStart(2, '0')}.md`] = `file ${i}\n`;
1050
+ await send(base, token, 'many.zip', zipOf(entries));
1051
+ const cut = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Many` });
1052
+ expect(cut.total).toBe(60);
1053
+ expect(cut.count).toBe(60);
1054
+ expect(cut.files).toHaveLength(25);
1055
+ expect(cut.truncated).toBe(true);
1056
+ // Every one of the 60 landed, even though only 25 are named.
1057
+ expect(await readdir(join(tempDir, KB_DIR, 'Many'))).toHaveLength(60);
1058
+
1059
+ const { token: second } = await request(base);
1060
+ await send(base, second, 'many.zip', zipOf(entries));
1061
+ const all = await apply(base, {
1062
+ branch: DRAFT,
1063
+ token: second,
1064
+ destination: `${KB_DIR}/Many`,
1065
+ mode: 'overwrite',
1066
+ all: true,
1067
+ });
1068
+ expect(all.total).toBe(60);
1069
+ expect(all.files).toHaveLength(60);
1070
+ expect(all.truncated).toBeUndefined();
1071
+ });
1072
+
1073
+ it('drops a macOS archive\'s sidecar entries without reporting them', async () => {
1074
+ const base = await start();
1075
+ const { token } = await request(base);
1076
+ await send(base, token, 'mac.zip', zipOf({
1077
+ '__MACOSX/._SKILL.md': 'junk',
1078
+ '.DS_Store': 'junk',
1079
+ 'SKILL.md': '# real\n',
1080
+ }));
1081
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
1082
+ expect(answer.total).toBe(1);
1083
+ expect(answer.files).toEqual([{ path: `${KB_DIR}/Drop/SKILL.md`, outcome: 'created' }]);
1084
+ });
1085
+
1086
+ it('refuses a destination that is a file, not a folder', async () => {
1087
+ const base = await start();
1088
+ await mkdir(join(tempDir, KB_DIR), { recursive: true });
1089
+ await writeFile(join(tempDir, KB_DIR, 'page.md'), 'page');
1090
+ const { token } = await request(base);
1091
+ await send(base, token, 'a.md', Buffer.from('a'));
1092
+ const res = await call(base, 'apply_file_upload', {
1093
+ branch: DRAFT,
1094
+ token,
1095
+ destination: `${KB_DIR}/page.md`,
1096
+ });
1097
+ expect(res.status).toBe(409);
1098
+ expect((await json<{ error: string }>(res)).error).toContain('is a file, not a folder');
1099
+ });
1100
+
1101
+ it('refuses a destination outside the repository', async () => {
1102
+ const base = await start();
1103
+ const { token } = await request(base);
1104
+ await send(base, token, 'a.md', Buffer.from('a'));
1105
+ // The normaliser PLACES an unprefixed path under the checkout, so the
1106
+ // refusal is about one that cannot be rescued by any spelling.
1107
+ const res = await call(base, 'apply_file_upload', {
1108
+ branch: DRAFT,
1109
+ token,
1110
+ destination: `${KB_DIR}/../beside`,
1111
+ });
1112
+ expect(res.status).toBe(400);
1113
+ expect(batches).toEqual([]);
1114
+ });
1115
+
1116
+ it('refuses an apply with no token at all', async () => {
1117
+ const base = await start();
1118
+ const res = await call(base, 'apply_file_upload', { branch: DRAFT, destination: KB_DIR });
1119
+ expect(res.status).toBe(400);
1120
+ });
1121
+
1122
+ it('refuses a token whose bytes were never sent', async () => {
1123
+ const base = await start();
1124
+ const { token } = await request(base);
1125
+ const res = await call(base, 'apply_file_upload', { branch: DRAFT, token, destination: KB_DIR });
1126
+ expect(res.status).toBe(404);
1127
+ expect((await json<{ error: string }>(res)).error).toBe(UPLOAD_TOKEN_REFUSAL);
1128
+ });
1129
+ });
1130
+
1131
+ describe('a token is good once, for one user, for a limited time', () => {
1132
+ it('is refused the second time it is applied', async () => {
1133
+ const base = await start();
1134
+ const { token } = await request(base);
1135
+ await send(base, token, 'a.md', Buffer.from('a'));
1136
+ expect((await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/One` })).count).toBe(1);
1137
+ const second = await call(base, 'apply_file_upload', {
1138
+ branch: DRAFT,
1139
+ token,
1140
+ destination: `${KB_DIR}/Two`,
1141
+ mode: 'overwrite',
1142
+ });
1143
+ expect(second.status).toBe(404);
1144
+ expect((await json<{ error: string }>(second)).error).toBe(UPLOAD_TOKEN_REFUSAL);
1145
+ expect(await readdir(join(tempDir, KB_DIR))).toEqual(['One']);
1146
+ });
1147
+
1148
+ it('deletes the stored bytes once it has been applied', async () => {
1149
+ const base = await start();
1150
+ const { token } = await request(base);
1151
+ await send(base, token, 'a.md', Buffer.from('a'));
1152
+ expect(await readdir(uploadsDir)).toHaveLength(1);
1153
+ await apply(base, { branch: DRAFT, token, destination: KB_DIR });
1154
+ expect(await readdir(uploadsDir)).toEqual([]);
1155
+ });
1156
+
1157
+ it('is refused when another user presents it, saying nothing about whether it exists', async () => {
1158
+ const owner = await start({ userId: 'owner', userEmail: 'owner@x' });
1159
+ const { token } = await request(owner);
1160
+ await send(owner, token, 'a.md', Buffer.from('a'));
1161
+ // A second caller on the SAME server's store — a different connection key.
1162
+ const stranger = await start({ userId: 'stranger', userEmail: 'stranger@x', store: uploads });
1163
+ const refused = await call(stranger, 'apply_file_upload', {
1164
+ branch: DRAFT,
1165
+ token,
1166
+ destination: `${KB_DIR}/Theirs`,
1167
+ });
1168
+ expect(refused.status).toBe(404);
1169
+ const { error } = await json<{ error: string }>(refused);
1170
+ expect(error).toBe(UPLOAD_TOKEN_REFUSAL);
1171
+ // Says nothing about whether the token exists — the same sentence an
1172
+ // invented token gets, so a stranger learns nothing from the difference.
1173
+ const invented = await call(stranger, 'apply_file_upload', {
1174
+ branch: DRAFT,
1175
+ token: 'bevel-up_invented',
1176
+ destination: `${KB_DIR}/Theirs`,
1177
+ });
1178
+ expect((await json<{ error: string }>(invented)).error).toBe(error);
1179
+ expect(batches).toEqual([]);
1180
+ // And the stranger's attempt did not spend it: the owner still can.
1181
+ expect((await apply(owner, { branch: DRAFT, token, destination: `${KB_DIR}/Mine` })).count).toBe(1);
1182
+ });
1183
+
1184
+ it('is refused once it has expired, and the stored upload is gone', async () => {
1185
+ // A TTL comfortably longer than the two HTTP round-trips below: at 30ms a
1186
+ // loaded machine could expire the token mid-`send`, and the test would
1187
+ // fail on an upload that was never stored rather than on the expiry it is
1188
+ // about.
1189
+ const base = await start({ ttlMs: 1_000 });
1190
+ const { token } = await request(base);
1191
+ await send(base, token, 'a.md', Buffer.from('a'));
1192
+ expect(await readdir(uploadsDir)).toHaveLength(1);
1193
+ await new Promise((r) => setTimeout(r, 1_500));
1194
+ const res = await call(base, 'apply_file_upload', { branch: DRAFT, token, destination: KB_DIR });
1195
+ expect(res.status).toBe(404);
1196
+ expect((await json<{ error: string }>(res)).error).toBe(UPLOAD_TOKEN_REFUSAL);
1197
+ await uploads.sweepNow();
1198
+ expect(await readdir(uploadsDir)).toEqual([]);
1199
+ });
1200
+
1201
+ it('deletes an upload nobody applied, on its own, when the token expires', async () => {
1202
+ // The periodic sweep, started before the first token is issued so the
1203
+ // store's own default interval does not win the race.
1204
+ // The TTL has the same margin over the round-trips as above; the sweep
1205
+ // interval stays short, and the deadline loop below is what makes the
1206
+ // sweep-side timing robust once the send has landed.
1207
+ const base = await start({ ttlMs: 1_000, sweepEveryMs: 10 });
1208
+ const { token } = await request(base);
1209
+ await send(base, token, 'forgotten.zip', zipOf({ 'a.md': 'a' }));
1210
+ expect(await readdir(uploadsDir)).toHaveLength(1);
1211
+ const deadline = Date.now() + 6_500;
1212
+ while (Date.now() < deadline && (await readdir(uploadsDir)).length > 0) {
1213
+ await new Promise((r) => setTimeout(r, 10));
1214
+ }
1215
+ expect(await readdir(uploadsDir)).toEqual([]);
1216
+ expect((await call(base, 'apply_file_upload', { branch: DRAFT, token, destination: KB_DIR })).status).toBe(404);
1217
+ });
1218
+ });
1219
+
1220
+ describe('the sweep', () => {
1221
+ it('leaves alone the bytes of a token issued while it was listing the directory', async () => {
1222
+ // THE WINDOW, HELD OPEN. Whether a sweep running alongside a brand-new
1223
+ // upload deletes its bytes turns on the order of two steps — the listing
1224
+ // of the staging root, and the set of ids the live records hold — and in
1225
+ // production the gap between them is a filesystem round-trip no test can
1226
+ // time. So the listing is the seam: this one is paused on its way in, a
1227
+ // whole token-issue-and-upload happens inside the pause, and only then is
1228
+ // the directory read and the live set taken.
1229
+ //
1230
+ // That ordering is what makes this a test and not a hope. A sweep that
1231
+ // took its live set BEFORE listing would have taken it before the second
1232
+ // record existed, then listed a directory holding the second file, and
1233
+ // deleted bytes whose sender had just been answered "received".
1234
+ let reached: () => void = () => undefined;
1235
+ const listing = new Promise<void>((resolve) => {
1236
+ reached = resolve;
1237
+ });
1238
+ let release: () => void = () => undefined;
1239
+ let barrier: Promise<void> | null = null;
1240
+ const base = await start({
1241
+ listRoot: async (root) => {
1242
+ if (barrier !== null) {
1243
+ const held = barrier;
1244
+ barrier = null; // one-shot: only the sweep this test arms is held
1245
+ reached();
1246
+ await held;
1247
+ }
1248
+ return readdir(root);
1249
+ },
1250
+ });
1251
+ const first = await request(base);
1252
+ await send(base, first.token, 'first.md', Buffer.from('first'));
1253
+
1254
+ barrier = new Promise<void>((resolve) => {
1255
+ release = resolve;
1256
+ });
1257
+ const sweeping = uploads.sweepNow();
1258
+ await listing;
1259
+ const second = await request(base);
1260
+ const landed = await send(base, second.token, 'second.md', Buffer.from('second'));
1261
+ release();
1262
+ await sweeping;
1263
+
1264
+ expect(landed.status).toBe(200);
1265
+ expect(await readdir(uploadsDir)).toHaveLength(2);
1266
+ // And both are still applicable — the sweep took neither record with it.
1267
+ expect((await apply(base, { branch: DRAFT, token: first.token, destination: `${KB_DIR}/A` })).count).toBe(1);
1268
+ expect((await apply(base, { branch: DRAFT, token: second.token, destination: `${KB_DIR}/B` })).count).toBe(1);
1269
+ });
1270
+
1271
+ it('refuses an upload whose token expired while the body was still arriving', async () => {
1272
+ // The TTL passes mid-request, and the periodic sweep fires inside it. The
1273
+ // answer is the one refusal rather than a success the sender could do
1274
+ // nothing with — and nothing is left staged.
1275
+ const base = await start({ ttlMs: 250, sweepEveryMs: 20 });
1276
+ const { token } = await request(base);
1277
+ const body = new ReadableStream<Uint8Array>({
1278
+ async start(controller) {
1279
+ controller.enqueue(new Uint8Array([1, 2, 3]));
1280
+ await new Promise((r) => setTimeout(r, 600));
1281
+ controller.enqueue(new Uint8Array([4, 5, 6]));
1282
+ controller.close();
1283
+ },
1284
+ });
1285
+ const res = await fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}?filename=slow.bin`, {
1286
+ method: 'POST',
1287
+ body,
1288
+ // Node's fetch requires this for a streamed request body.
1289
+ duplex: 'half',
1290
+ } as RequestInit & { duplex: 'half' });
1291
+ expect(res.status).toBe(404);
1292
+ expect((await json<{ error: string }>(res)).error).toBe(UPLOAD_TOKEN_REFUSAL);
1293
+ await uploads.drainSweep();
1294
+ expect(await readdir(uploadsDir)).toEqual([]);
1295
+ });
1296
+
1297
+ it('leaves the bytes an apply is holding alone, even past the expiry', async () => {
1298
+ const base = await start({ ttlMs: 1_000 });
1299
+ const { token } = await request(base);
1300
+ await send(base, token, 'slow.md', Buffer.from('slow'));
1301
+ // Exactly what an apply holds while it resolves the branch, reads the
1302
+ // bytes, judges every path and commits — work that can outlast a token
1303
+ // whose TTL was nearly up when it started. A sweep that deleted the source
1304
+ // here would make the commit land short with nothing saying why.
1305
+ const claimed = uploads.claim(token, 'u');
1306
+ await new Promise((r) => setTimeout(r, 1_200));
1307
+ await uploads.sweepNow();
1308
+ expect(await readFile(claimed.absolutePath, 'utf8')).toBe('slow');
1309
+ // And a second apply arriving meanwhile still finds the token unusable.
1310
+ expect(() => uploads.claim(token, 'u')).toThrow(UPLOAD_TOKEN_REFUSAL);
1311
+ // Released without landing anything, the claim is gone and so are the bytes.
1312
+ uploads.release(token);
1313
+ await uploads.sweepNow();
1314
+ expect(await readdir(uploadsDir)).toEqual([]);
1315
+ });
1316
+
1317
+ it('runs once the moment it starts, so bytes a dead process left behind long ago go now', async () => {
1318
+ const base = await start();
1319
+ // A file no record of THIS store names, older than any token could still
1320
+ // be good for: what a restart finds, since the records live in memory and
1321
+ // went with the process that issued them.
1322
+ const orphan = join(uploadsDir, 'upload-from-a-dead-process');
1323
+ await writeFile(orphan, 'orphan');
1324
+ const longAgo = new Date(Date.now() - 60 * 60 * 1000);
1325
+ await utimes(orphan, longAgo, longAgo);
1326
+ const { token } = await request(base);
1327
+ await uploads.drainSweep();
1328
+ expect(await readdir(uploadsDir)).toEqual([]);
1329
+ // The token issued alongside it is untouched — the sweep reclaims by what
1330
+ // the live records do NOT name.
1331
+ expect((await send(base, token, 'a.md', Buffer.from('a'))).status).toBe(200);
1332
+ });
1333
+
1334
+ /**
1335
+ * The records are one process's memory, and two processes share the staging
1336
+ * directory whenever a deployment starts the new one before the old one has
1337
+ * stopped. To each, the other's uploads are files no record names — and a
1338
+ * sweep that deleted those at once took away bytes somebody had just been
1339
+ * told were received, from under the apply about to land them.
1340
+ */
1341
+ it('leaves alone a fresh file it has no record of: it may be another process\'s upload', async () => {
1342
+ const base = await start();
1343
+ await writeFile(join(uploadsDir, 'upload-another-process-just-received'), 'theirs');
1344
+ await request(base);
1345
+ await uploads.drainSweep();
1346
+ await uploads.sweepNow();
1347
+ expect(await readdir(uploadsDir)).toEqual(['upload-another-process-just-received']);
1348
+ });
1349
+
1350
+ it('stops deleting once it is stopped, so an evicted graph cannot sweep its replacement', async () => {
1351
+ await start();
1352
+ uploads.stopSweeping();
1353
+ await writeFile(join(uploadsDir, 'upload-the-next-store-issued'), 'theirs');
1354
+ await uploads.sweepNow();
1355
+ expect(await readdir(uploadsDir)).toEqual(['upload-the-next-store-issued']);
1356
+ });
1357
+ });
1358
+
1359
+ describe('the staging root', () => {
1360
+ it('refuses a boot that would stage uploads inside a workspace', async () => {
1361
+ const workspaces = await mkdtemp(join(tmpdir(), 'ws-root-'));
1362
+ try {
1363
+ for (const inside of [workspaces, join(workspaces, 'agent-uploads'), join(workspaces, 'a', 'b')]) {
1364
+ await expect(assertUploadsRootOutsideWorkspaces(inside, workspaces)).rejects.toThrow(
1365
+ /AGENT_UPLOADS_ROOT/,
1366
+ );
1367
+ }
1368
+ // Through a LINK, too: where the path resolves to is what decides it.
1369
+ const linked = join(workspaces, '..', `link-${basename(workspaces)}`);
1370
+ await symlink(workspaces, linked);
1371
+ try {
1372
+ await expect(assertUploadsRootOutsideWorkspaces(join(linked, 'uploads'), workspaces)).rejects.toThrow(
1373
+ /AGENT_UPLOADS_ROOT/,
1374
+ );
1375
+ } finally {
1376
+ await rm(linked, { force: true });
1377
+ }
1378
+ // A sibling — the default — is what the invariant asks for.
1379
+ await assertUploadsRootOutsideWorkspaces(join(workspaces, '..', 'agent-uploads'), workspaces);
1380
+ } finally {
1381
+ await rm(workspaces, { recursive: true, force: true });
1382
+ }
1383
+ });
1384
+ });
1385
+
1386
+ describe('an upload is never readable through a file tool', () => {
1387
+ it('sits outside every workspace, and no path reaches it', async () => {
1388
+ const base = await start();
1389
+ const { token } = await request(base);
1390
+ await send(base, token, 'secret.md', Buffer.from('held outside'));
1391
+ const [stored] = await readdir(uploadsDir);
1392
+ // Not in the workspace tree at all.
1393
+ const names = await json<{ entries: { name: string }[] }>(
1394
+ await call(base, 'list_files', { branch: DRAFT, path: KB_DIR }),
1395
+ );
1396
+ expect((names.entries ?? []).map((e) => e.name)).not.toContain(stored);
1397
+ // And no spelling of a workspace path leaves the repository to find it.
1398
+ for (const path of [`${KB_DIR}/../../agent-uploads/${stored}`, `../${stored}`, `/${stored}`]) {
1399
+ const res = await call(base, 'read_file', { branch: DRAFT, path });
1400
+ expect(res.status).not.toBe(200);
1401
+ }
1402
+ });
1403
+ });
1404
+
1405
+ describe('the write tools name the upload route', () => {
1406
+ it('says so on write_file, write_files and edit_file', async () => {
1407
+ await start();
1408
+ const tools = await toolRegistry.listExternal();
1409
+ for (const name of ['write_file', 'write_files', 'edit_file']) {
1410
+ const def = tools.find((t) => t.name === name);
1411
+ expect(def, name).toBeDefined();
1412
+ const description = def!.description;
1413
+ expect(description, name).toContain('request_file_upload');
1414
+ expect(description, name).toContain('apply_file_upload');
1415
+ // And what goes that way, in the one sentence a description has room for.
1416
+ expect(description, name).toMatch(/large, escape-heavy or binary/i);
1417
+ }
1418
+ });
1419
+
1420
+ it('says why, and how, once — in the rules every file tool shares', () => {
1421
+ // The three failures the route exists for are named where the full
1422
+ // explanation lives: said on each of three descriptions, it took every one
1423
+ // of them past the length a client cuts at.
1424
+ const rule = sharedFileRules(DEFAULT_KB_LAYOUT).find((r) => r.id === 'upload-route');
1425
+ expect(rule).toBeDefined();
1426
+ expect(rule!.body).toMatch(/cut off mid-answer/);
1427
+ expect(rule!.body).toMatch(/escapes fails to parse/);
1428
+ expect(rule!.body).toMatch(/an image, a PDF or a zip/);
1429
+ expect(rule!.body).toContain('`request_file_upload`');
1430
+ expect(rule!.body).toContain('`apply_file_upload`');
1431
+ });
1432
+ });
1433
+
1434
+ describe('the token travels in the path or in a header', () => {
1435
+ /** POST bytes to the BARE address, the token in `x-upload-token`. */
1436
+ const sendWithHeader = (
1437
+ base: string,
1438
+ token: string,
1439
+ filename: string,
1440
+ data: Buffer,
1441
+ contentType = 'application/octet-stream',
1442
+ ) =>
1443
+ fetch(`${base}/api/agent/uploads?filename=${encodeURIComponent(filename)}`, {
1444
+ method: 'POST',
1445
+ headers: { 'content-type': contentType, 'x-upload-token': token },
1446
+ body: data,
1447
+ });
1448
+
1449
+ it('takes the token as a header on the bare address, and the apply lands those bytes', async () => {
1450
+ const base = await start();
1451
+ const { token } = await request(base);
1452
+ const res = await sendWithHeader(base, token, 'notes.md', Buffer.from('hello'));
1453
+ expect(res.status).toBe(200);
1454
+ expect(await json(res)).toEqual({ filename: 'notes.md', bytes: 5, kind: 'file' });
1455
+ const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Pages` });
1456
+ expect(answer.files).toEqual([{ path: `${KB_DIR}/Pages/notes.md`, outcome: 'created' }]);
1457
+ expect(await readFile(join(tempDir, KB_DIR, 'Pages/notes.md'), 'utf8')).toBe('hello');
1458
+ });
1459
+
1460
+ it('keeps the bytes a stream on the bare address too, whatever content-type is claimed', async () => {
1461
+ const base = await start();
1462
+ const { token } = await request(base);
1463
+ // The global `express.json()` would drain this body — the header form has
1464
+ // to be exempt by the same rule the path form is, or a zip sent as
1465
+ // `application/json` reaches the handler with nothing left in it.
1466
+ const zip = zipOf({ 'a.md': 'a' });
1467
+ const res = await sendWithHeader(base, token, 'archive.zip', zip, 'application/json');
1468
+ expect(await json(res)).toEqual({ filename: 'archive.zip', bytes: zip.byteLength, kind: 'zip', entries: 1 });
1469
+ });
1470
+
1471
+ it('exempts both spellings of the address from the JSON parser', () => {
1472
+ expect(isAgentUploadRawBodyPath('/api/agent/uploads')).toBe(true);
1473
+ expect(isAgentUploadRawBodyPath('/api/agent/uploads/')).toBe(true);
1474
+ expect(isAgentUploadRawBodyPath('/API/Agent/Uploads')).toBe(true);
1475
+ expect(isAgentUploadRawBodyPath('/api/agent/uploads/bevel-up_abc')).toBe(true);
1476
+ expect(isAgentUploadRawBodyPath('/api/agent/uploadsomething')).toBe(false);
1477
+ expect(isAgentUploadRawBodyPath('/api/agent/tools/write_file')).toBe(false);
1478
+ });
1479
+
1480
+ it('gives the bare address with NO token the same single refusal an unknown token gets', async () => {
1481
+ const base = await start();
1482
+ const bare = await fetch(`${base}/api/agent/uploads?filename=notes.md`, {
1483
+ method: 'POST',
1484
+ headers: { 'content-type': 'application/octet-stream' },
1485
+ body: Buffer.from('hello'),
1486
+ });
1487
+ const unknown = await send(base, 'bevel-up_nosuchtoken', 'notes.md', Buffer.from('hello'));
1488
+ expect(bare.status).toBe(404);
1489
+ expect(unknown.status).toBe(404);
1490
+ // The same words: a caller holding no token learns nothing from the
1491
+ // difference, not even that a header would have been read.
1492
+ expect(await json(bare)).toEqual(await json(unknown));
1493
+ });
1494
+
1495
+ it('lets the path win when a stale header names a different token', async () => {
1496
+ const base = await start();
1497
+ const first = await request(base);
1498
+ const second = await request(base);
1499
+ const res = await fetch(`${base}/api/agent/uploads/${encodeURIComponent(first.token)}?filename=notes.md`, {
1500
+ method: 'POST',
1501
+ headers: { 'content-type': 'application/octet-stream', 'x-upload-token': second.token },
1502
+ body: Buffer.from('hello'),
1503
+ });
1504
+ expect(res.status).toBe(200);
1505
+ // The bytes belong to the token in the address that was POSTed to; the
1506
+ // other token is still open and still holds nothing.
1507
+ const landed = await apply(base, { branch: DRAFT, token: first.token, destination: `${KB_DIR}/Pages` });
1508
+ expect(landed.files).toEqual([{ path: `${KB_DIR}/Pages/notes.md`, outcome: 'created' }]);
1509
+ const empty = await apply(base, { branch: DRAFT, token: second.token, destination: `${KB_DIR}/Pages` });
1510
+ expect(empty.error).toBeDefined();
1511
+ });
1512
+ });
1513
+
1514
+ describe('an apply takes the destination folder\'s turn', () => {
1515
+ it('lands its batch INSIDE the turn, over the destination it was given', async () => {
1516
+ const base = await start();
1517
+ const { token } = await request(base);
1518
+ await send(base, token, 'skill.zip', zipOf({ 'SKILL.md': '# s\n', 'docs/a.md': 'a' }));
1519
+ await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Skills/my-skill` });
1520
+ // One turn, over the destination — the same folder `delete_folder` takes
1521
+ // its turn over, which is what makes the two wait for each other.
1522
+ expect(folderTurns).toEqual([`${KB_DIR}/Skills/my-skill`]);
1523
+ // And the batch landed while that turn was held, not before or after it:
1524
+ // a turn taken around nothing would serialise nothing.
1525
+ expect(batchTurnDepth).toEqual([1]);
1526
+ });
1527
+
1528
+ it('lands nothing while another caller holds a turn over that subtree', async () => {
1529
+ const base = await start();
1530
+ const { token } = await request(base);
1531
+ await send(base, token, 'page.md', Buffer.from('mine\n'));
1532
+ // The turn a concurrent `delete_folder` would be holding over the
1533
+ // destination, held open until this test lets go.
1534
+ let release!: () => void;
1535
+ folderTurnGate = new Promise<void>((resolve) => (release = resolve));
1536
+ const pending = apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Pages` });
1537
+ // Until the apply ASKS for the turn — no sleep, so a loaded runner cannot
1538
+ // turn this into a flake, and nothing here depends on how long the judging
1539
+ // took. Racing the apply itself means a regression that never asks for a
1540
+ // turn fails on the assertions below instead of hanging to the timeout.
1541
+ await Promise.race([folderTurnAsked, pending]);
1542
+ // Asked for, and not yet granted: the apply is parked on the gate, which
1543
+ // is what makes every assertion that follows a statement about waiting.
1544
+ expect(folderTurns).toEqual([`${KB_DIR}/Pages`]);
1545
+ expect(turnsHeld).toBe(0);
1546
+ expect(batches).toEqual([]);
1547
+ await expect(readFile(join(tempDir, KB_DIR, 'Pages/page.md'), 'utf8')).rejects.toThrow();
1548
+ // Now the folder is free, and the same apply finishes on it.
1549
+ release();
1550
+ const answer = await pending;
1551
+ expect(answer.files).toEqual([{ path: `${KB_DIR}/Pages/page.md`, outcome: 'created' }]);
1552
+ expect(batches).toHaveLength(1);
1553
+ expect(await readFile(join(tempDir, KB_DIR, 'Pages/page.md'), 'utf8')).toBe('mine\n');
1554
+ });
1555
+ });
1556
+
1557
+ describe('a read-only deployment lands no upload', () => {
1558
+ /** A host's verdict: the deployment may be read, not changed. */
1559
+ const readOnly: IWriteAccess = {
1560
+ canWrite: async () => ({ ok: false, message: 'This workspace is read-only until an admin adds seats.' }),
1561
+ };
1562
+
1563
+ /**
1564
+ * Both upload tools must be WRITE tools, because the read-only gate's HTTP
1565
+ * half cannot refuse them: `/api/agent/` is on its always-writable list, on
1566
+ * the stated grounds that "every tool call is judged by the tool layer
1567
+ * itself, which knows a write tool from a read". That makes `write: true` on
1568
+ * these two mounts the only thing standing between a read-only deployment
1569
+ * and a commit — worth a test that fails if either loses the flag, rather
1570
+ * than a comment hoping nobody does.
1571
+ */
1572
+ it('refuses request_file_upload and apply_file_upload at the tool layer', async () => {
1573
+ const base = await start({ writeAccess: readOnly });
1574
+ for (const tool of ['request_file_upload', 'apply_file_upload']) {
1575
+ const res = await call(base, tool, { branch: DRAFT, token: 'bevel-up_x', destination: KB_DIR });
1576
+ expect(res.status, tool).toBe(403);
1577
+ expect(await json<{ code?: string }>(res), tool).toMatchObject({ code: READ_ONLY_CODE });
1578
+ }
1579
+ });
1580
+
1581
+ /**
1582
+ * The raw upload route is NOT a tool and never reaches the tool layer, so
1583
+ * the always-writable prefix does let its bytes through. That is harmless
1584
+ * and deliberate: it stages bytes beside the workspaces root and commits
1585
+ * nothing, no new token can be issued while the deployment is read-only
1586
+ * (`request_file_upload` is refused above), and bytes nobody can apply are
1587
+ * deleted when their token expires. Asserted so the reasoning is on record
1588
+ * where the behaviour is.
1589
+ */
1590
+ it('still takes the bytes of a token issued before the deployment went read-only, and lands none of them', async () => {
1591
+ const writable = await start();
1592
+ const { token } = await request(writable);
1593
+ const readOnlyServer = await start({ store: uploads, writeAccess: readOnly });
1594
+ expect((await send(readOnlyServer, token, 'notes.md', Buffer.from('hello'))).status).toBe(200);
1595
+ const refused = await call(readOnlyServer, 'apply_file_upload', {
1596
+ branch: DRAFT,
1597
+ token,
1598
+ destination: `${KB_DIR}/Pages`,
1599
+ });
1600
+ expect(refused.status).toBe(403);
1601
+ expect(batches).toEqual([]);
1602
+ await expect(readFile(join(tempDir, KB_DIR, 'Pages/notes.md'), 'utf8')).rejects.toThrow();
1603
+ });
1604
+ });