@bevel-software/platform-core-backend 0.23.0 → 0.25.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 (238) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +17 -3
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +3 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +25 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/core/lifecycle.d.ts +12 -0
  9. package/dist/core/lifecycle.d.ts.map +1 -1
  10. package/dist/core/lifecycle.js +10 -0
  11. package/dist/core/lifecycle.js.map +1 -1
  12. package/dist/core-config.d.ts +16 -0
  13. package/dist/core-config.d.ts.map +1 -1
  14. package/dist/core-config.js +17 -0
  15. package/dist/core-config.js.map +1 -1
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +13 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/modules/access/access.routes.d.ts.map +1 -1
  21. package/dist/modules/access/access.routes.js +4 -1
  22. package/dist/modules/access/access.routes.js.map +1 -1
  23. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  24. package/dist/modules/access/directory-sync-bot.js +7 -3
  25. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  26. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
  27. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  28. package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
  29. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  30. package/dist/modules/agent-instructions/compose.d.ts +38 -6
  31. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  32. package/dist/modules/agent-instructions/compose.js +39 -6
  33. package/dist/modules/agent-instructions/compose.js.map +1 -1
  34. package/dist/modules/agent-instructions/index.d.ts +2 -1
  35. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  36. package/dist/modules/agent-instructions/index.js +2 -1
  37. package/dist/modules/agent-instructions/index.js.map +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
  39. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
  40. package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
  41. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
  42. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  43. package/dist/modules/audit/agent-audit.service.js +4 -2
  44. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  45. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  46. package/dist/modules/auth/account-erasure.service.js +57 -16
  47. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  48. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  49. package/dist/modules/auth/auth.service.js +25 -15
  50. package/dist/modules/auth/auth.service.js.map +1 -1
  51. package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
  52. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  53. package/dist/modules/code-mode/code-mode.tool.js +66 -35
  54. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  55. package/dist/modules/database/connection.d.ts +16 -0
  56. package/dist/modules/database/connection.d.ts.map +1 -1
  57. package/dist/modules/database/connection.js +117 -0
  58. package/dist/modules/database/connection.js.map +1 -1
  59. package/dist/modules/database/core-schema.d.ts +296 -96
  60. package/dist/modules/database/core-schema.d.ts.map +1 -1
  61. package/dist/modules/database/core-schema.js +81 -31
  62. package/dist/modules/database/core-schema.js.map +1 -1
  63. package/dist/modules/database/migrate.d.ts +95 -1
  64. package/dist/modules/database/migrate.d.ts.map +1 -1
  65. package/dist/modules/database/migrate.js +390 -2
  66. package/dist/modules/database/migrate.js.map +1 -1
  67. package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
  68. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  69. package/dist/modules/kb-fs/locking-filesystem.js +29 -0
  70. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  71. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  72. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp.service.js +38 -8
  74. package/dist/modules/mcp/mcp.service.js.map +1 -1
  75. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  76. package/dist/modules/plugins/join-request-records.store.js +7 -4
  77. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  78. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  79. package/dist/modules/tool-auth/external-api-key.service.js +8 -2
  80. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  82. package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
  83. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  84. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  85. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  86. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  87. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  88. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  89. package/dist/modules/tool-registry/description-length.js +108 -0
  90. package/dist/modules/tool-registry/description-length.js.map +1 -0
  91. package/dist/modules/workflow/git/git.service.d.ts +25 -0
  92. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/git/git.service.js +40 -1
  94. package/dist/modules/workflow/git/git.service.js.map +1 -1
  95. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/pending-commits.service.js +5 -1
  97. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  98. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  99. package/dist/modules/workflow/recovery-bot.js +7 -3
  100. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  101. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  103. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  104. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  105. package/dist/modules/workflow/workflow.service.js +4 -1
  106. package/dist/modules/workflow/workflow.service.js.map +1 -1
  107. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  108. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  109. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  110. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  111. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  112. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  113. package/dist/modules/workspace/agent-upload.store.js +553 -0
  114. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  115. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  116. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  117. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  118. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  119. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  120. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  121. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  122. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  123. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  124. package/dist/modules/workspace/upload-limits.js +13 -0
  125. package/dist/modules/workspace/upload-limits.js.map +1 -0
  126. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  127. package/dist/modules/workspace/workspace.routes.js +1 -1
  128. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  129. package/dist/modules/workspace/workspace.service.d.ts +13 -0
  130. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  131. package/dist/modules/workspace/workspace.service.js +61 -33
  132. package/dist/modules/workspace/workspace.service.js.map +1 -1
  133. package/dist/modules/workspace/workspace.tools.d.ts +11 -9
  134. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  135. package/dist/modules/workspace/workspace.tools.js +570 -134
  136. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  137. package/dist/modules/workspace/write-denial.d.ts +0 -6
  138. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  139. package/dist/modules/workspace/write-denial.js +0 -6
  140. package/dist/modules/workspace/write-denial.js.map +1 -1
  141. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  142. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  143. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  144. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  145. package/dist/shared/column-crypto.d.ts +194 -0
  146. package/dist/shared/column-crypto.d.ts.map +1 -0
  147. package/dist/shared/column-crypto.js +144 -0
  148. package/dist/shared/column-crypto.js.map +1 -0
  149. package/dist/shared/token-crypto.d.ts.map +1 -1
  150. package/dist/shared/token-crypto.js +25 -1
  151. package/dist/shared/token-crypto.js.map +1 -1
  152. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  153. package/dist/tenancy/static-tenant-source.js +1 -0
  154. package/dist/tenancy/static-tenant-source.js.map +1 -1
  155. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  156. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  157. package/dist/tenancy/tenant-secrets.js +4 -0
  158. package/dist/tenancy/tenant-secrets.js.map +1 -1
  159. package/kb-template/AGENTS.md +42 -0
  160. package/migrations/0016_pii_encryption.sql +20 -0
  161. package/migrations/meta/0016_snapshot.json +2327 -0
  162. package/migrations/meta/_journal.json +7 -0
  163. package/package.json +3 -3
  164. package/src/core/__tests__/lifecycle.test.ts +71 -4
  165. package/src/core/create-core-server.ts +21 -3
  166. package/src/core/create-core-services.ts +27 -1
  167. package/src/core/lifecycle.ts +19 -0
  168. package/src/core-config.ts +18 -0
  169. package/src/index.ts +25 -0
  170. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  171. package/src/modules/access/access.routes.ts +4 -1
  172. package/src/modules/access/directory-sync-bot.ts +7 -3
  173. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +8 -4
  174. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  175. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  176. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  177. package/src/modules/agent-instructions/compose.ts +50 -7
  178. package/src/modules/agent-instructions/index.ts +12 -0
  179. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  180. package/src/modules/audit/agent-audit.service.ts +4 -2
  181. package/src/modules/auth/__tests__/account-deactivation.test.ts +2 -1
  182. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  183. package/src/modules/auth/__tests__/account.routes.test.ts +6 -3
  184. package/src/modules/auth/__tests__/auth.service.test.ts +3 -1
  185. package/src/modules/auth/account-erasure.service.ts +69 -18
  186. package/src/modules/auth/auth.service.ts +33 -23
  187. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  188. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  189. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  190. package/src/modules/database/__tests__/connection.test.ts +12 -0
  191. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +780 -0
  192. package/src/modules/database/connection.ts +117 -0
  193. package/src/modules/database/core-schema.ts +81 -31
  194. package/src/modules/database/migrate.ts +540 -2
  195. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
  196. package/src/modules/kb-fs/locking-filesystem.ts +37 -0
  197. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  198. package/src/modules/mcp/__tests__/mcp.service.test.ts +47 -7
  199. package/src/modules/mcp/mcp.service.ts +46 -7
  200. package/src/modules/plugins/join-request-records.store.ts +7 -4
  201. package/src/modules/tool-auth/external-api-key.service.ts +8 -2
  202. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
  203. package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
  204. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  205. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  206. package/src/modules/tool-registry/description-length.ts +111 -0
  207. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  208. package/src/modules/workflow/git/git.service.ts +47 -1
  209. package/src/modules/workflow/pending-commits.service.ts +5 -1
  210. package/src/modules/workflow/recovery-bot.ts +7 -3
  211. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  212. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  213. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  214. package/src/modules/workflow/workflow.service.ts +4 -1
  215. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  216. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +24 -14
  217. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  218. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +77 -1
  219. package/src/modules/workspace/__tests__/workspace.service.test.ts +57 -0
  220. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +39 -36
  221. package/src/modules/workspace/__tests__/workspace.tools.test.ts +196 -46
  222. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  223. package/src/modules/workspace/agent-upload.store.ts +668 -0
  224. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  225. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  226. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  227. package/src/modules/workspace/upload-limits.ts +12 -0
  228. package/src/modules/workspace/workspace.routes.ts +1 -2
  229. package/src/modules/workspace/workspace.service.ts +63 -37
  230. package/src/modules/workspace/workspace.tools.ts +647 -148
  231. package/src/modules/workspace/write-denial.ts +0 -8
  232. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  233. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  234. package/src/shared/column-crypto.ts +218 -0
  235. package/src/shared/token-crypto.ts +28 -1
  236. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -0
  237. package/src/tenancy/static-tenant-source.ts +1 -0
  238. package/src/tenancy/tenant-secrets.ts +5 -1
@@ -13,7 +13,8 @@ import { ToolRegistry } from '../../tool-registry/tool-registry.js';
13
13
  import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
14
14
  import { ToolError, type ToolContext } from '../../tool-helpers/tool.contract.js';
15
15
  import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
16
- import { CONTENT_RULE, registerWorkspaceTools } from '../workspace.tools.js';
16
+ import { registerWorkspaceTools } from '../workspace.tools.js';
17
+ import { sharedFileRules, sharedFileRulesSection, sharedRulesPointer } from '../../agent-instructions/shared-file-rules.js';
17
18
  import { RoutineWritePolicyService } from '../routine-write-policy.js';
18
19
  import { UuidSessionSink, type ISessionSink } from '../session-sink.js';
19
20
  import { WorkflowHooks, type AgentOperationContext } from '../../workflow/workflow-hooks.js';
@@ -30,7 +31,7 @@ import { assertValidBranchName } from '../../kb-fs/branch-name.js';
30
31
  import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
31
32
  import { GIT_INTERNALS_MESSAGE, PathNotFoundError } from '../../../shared/domain-errors.js';
32
33
  import { AccessDeniedError } from '../../access-model/access-errors.js';
33
- import { PROPOSAL_ROUTE_NOTE, proposalTitleFor } from '../write-denial.js';
34
+ import { proposalTitleFor } from '../write-denial.js';
34
35
  import { NOT_FOUND_NEXT_STEP } from '../not-found.js';
35
36
 
36
37
  const KB_DIR = 'knowledge-base';
@@ -182,6 +183,25 @@ async function start(
182
183
  await check?.();
183
184
  return plainWriteFile(path, content, options as never);
184
185
  };
186
+ // `LockingFilesystem.rewriteFile`: read, compute and write with the path
187
+ // "locked". The other writer runs first — where the real filesystem would be
188
+ // acquiring the lock — so `rewrite` reads what that writer left.
189
+ (fs as unknown as Record<string, unknown>).rewriteFile = async (
190
+ path: string,
191
+ rewrite: (current: Buffer | null) => string | Promise<string>,
192
+ ) => {
193
+ await runRaceHook();
194
+ // Absent is null; any other read failure is a failure, as in the real one.
195
+ const current = await fs.readFile(path).then(
196
+ (c) => (Buffer.isBuffer(c) ? c : Buffer.from(String(c), 'utf8')),
197
+ (err: unknown) => {
198
+ const code = (err as NodeJS.ErrnoException).code;
199
+ if (code === 'ENOENT' || code === 'ENOTDIR') return null;
200
+ throw err;
201
+ },
202
+ );
203
+ return plainWriteFile(path, await rewrite(current));
204
+ };
185
205
  (fs as unknown as Record<string, unknown>).writeFiles = async (
186
206
  writes: { path: string; content: string }[],
187
207
  _summary: string,
@@ -362,6 +382,98 @@ describe('workspace file primitives', () => {
362
382
  expect((await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'nope', new_string: 'x' })).status).toBe(400);
363
383
  });
364
384
 
385
+ it('edit_file writes new_string exactly as sent, `$` patterns included', async () => {
386
+ const base = await start();
387
+ const res = await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'world', new_string: "cost: $& $1 $' $$" });
388
+ expect(res.status).toBe(200);
389
+ expect(String(await fs.readFile(`${KB_DIR}/a.md`))).toBe("hello\ncost: $& $1 $' $$\n");
390
+ });
391
+
392
+ /**
393
+ * An edit is a promise about the text it replaces, so that text has to be
394
+ * found in the file as it is when the write lands — read with the path's
395
+ * lock held, not at a preflight anybody may invalidate. `raceHook` is the
396
+ * other writer, running exactly where the real filesystem acquires the lock.
397
+ */
398
+ describe('edit_file looks for old_string in the file the write actually lands on', () => {
399
+ const EMPTY_OWNER = '# Assignee\n\n# Log';
400
+ const claim = (base: string, who: string) =>
401
+ post(`${base}/api/agent/tools/edit_file`, {
402
+ path: `${KB_DIR}/ticket.md`,
403
+ old_string: EMPTY_OWNER,
404
+ new_string: `# Assignee\n${who}\n\n# Log`,
405
+ });
406
+
407
+ it('refuses when another writer replaced that text after the preflight, and keeps their write', async () => {
408
+ const base = await start();
409
+ await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
410
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\ncoder1\n\n# Log\n- filed\n'); };
411
+ const res = await claim(base, 'coder2');
412
+ expect(res.status).toBe(400);
413
+ expect(((await res.json()) as { error: string }).error).toContain('old_string not found');
414
+ expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe('# Assignee\ncoder1\n\n# Log\n- filed\n');
415
+ });
416
+
417
+ it('applies the edit to what the other writer left when the text is still there', async () => {
418
+ const base = await start();
419
+ await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
420
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n- a line added meanwhile\n'); };
421
+ const res = await claim(base, 'coder2');
422
+ expect(res.status).toBe(200);
423
+ // Their line is kept: the new content was computed from the file as it was under the lock.
424
+ expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe('# Assignee\ncoder2\n\n# Log\n- filed\n- a line added meanwhile\n');
425
+ });
426
+
427
+ it('refuses when the text became ambiguous after the preflight', async () => {
428
+ const base = await start();
429
+ await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
430
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, `${EMPTY_OWNER}\n${EMPTY_OWNER}\n`); };
431
+ const res = await claim(base, 'coder2');
432
+ expect(res.status).toBe(400);
433
+ expect(((await res.json()) as { error: string }).error).toContain('appears 2 times');
434
+ expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe(`${EMPTY_OWNER}\n${EMPTY_OWNER}\n`);
435
+ });
436
+
437
+ it('answers not found when the file was deleted after the preflight, and does not recreate it', async () => {
438
+ const base = await start();
439
+ await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
440
+ raceHook = async () => { await fs.deleteFile(`${KB_DIR}/ticket.md`); };
441
+ const res = await claim(base, 'coder2');
442
+ expect(res.status).toBe(404);
443
+ await expect(fs.readFile(`${KB_DIR}/ticket.md`)).rejects.toThrow();
444
+ });
445
+
446
+ // Every question the tool asks of the file is asked of the bytes it is
447
+ // about to replace. An extensionless file may hold anything, and the app's
448
+ // own upload can swap it for a binary between the preflight and the lock.
449
+ // The binary still CONTAINS `old_string` here, so nothing but the
450
+ // "may these bytes be edited as text" question can refuse it.
451
+ it('refuses when the file became binary after the preflight, and leaves those bytes alone', async () => {
452
+ const base = await start();
453
+ await fs.writeFile(`${KB_DIR}/TICKET`, '# Assignee\n\n# Log\n- filed\n');
454
+ const binary = Buffer.concat([Buffer.from([0x00, 0xff, 0xfe, 0x00]), Buffer.from(`${EMPTY_OWNER}\n`, 'utf8')]);
455
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/TICKET`, binary); };
456
+ const res = await post(`${base}/api/agent/tools/edit_file`, {
457
+ path: `${KB_DIR}/TICKET`,
458
+ old_string: EMPTY_OWNER,
459
+ new_string: '# Assignee\ncoder2\n\n# Log',
460
+ });
461
+ expect(res.status).toBe(415);
462
+ expect(await res.json()).toMatchObject({ kind: 'binary_not_writable' });
463
+ const after = await fs.readFile(`${KB_DIR}/TICKET`);
464
+ expect(Buffer.from(after as Buffer).equals(binary)).toBe(true);
465
+ });
466
+
467
+ it('counts replace_all over the file as it is under the lock', async () => {
468
+ const base = await start();
469
+ await fs.writeFile(`${KB_DIR}/ticket.md`, 'x x\n');
470
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, 'x x x\n'); };
471
+ const res = await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/ticket.md`, old_string: 'x', new_string: 'y', replace_all: true });
472
+ expect(await res.json()).toMatchObject({ replaced: 3 });
473
+ expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe('y y y\n');
474
+ });
475
+ });
476
+
365
477
  it('list_files + file_stat', async () => {
366
478
  const base = await start();
367
479
  const root = (await (await post(`${base}/api/agent/tools/list_files`, {})).json()) as { entries: { name: string }[] };
@@ -920,19 +1032,26 @@ describe('write modes and per-path outcomes', () => {
920
1032
  });
921
1033
  });
922
1034
 
923
- it('both descriptions state the default and all three modes, and `mode` is an input on each', async () => {
1035
+ it('states the three modes on the `mode` input of each write tool, and once in the shared rules', async () => {
924
1036
  await start();
925
1037
  const tools = await toolRegistry.listInternal();
1038
+ // The paragraph that said this in BOTH descriptions is one shared rule now.
1039
+ // What stays on the tool is the input the agent fills, which is where the
1040
+ // decision is actually taken.
1041
+ const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'write-mode')!;
1042
+ expect(rule.body).toContain('DEFAULTS TO `create`');
1043
+ expect(rule.body).toContain('On write_file and write_files');
1044
+ expect(sharedFileRulesSection(testKbContext().layout).split(rule.body)).toHaveLength(2);
926
1045
  for (const name of ['write_file', 'write_files']) {
927
1046
  const def = tools.find((t) => t.name === name)!;
928
- expect(def.description, name).toContain('DEFAULTS TO `create`');
929
- for (const mode of ['`create`', '`overwrite`', '`update`']) {
930
- expect(def.description, `${name} ${mode}`).toContain(mode);
931
- }
1047
+ expect(def.description, name).not.toContain('DEFAULTS TO `create`');
932
1048
  const body = (def.inputs as { properties: { body: { properties: Record<string, { enum?: string[]; description?: string }> } } }).properties.body;
933
1049
  expect(body.properties.mode, name).toBeDefined();
934
1050
  expect(body.properties.mode.enum, name).toEqual(['create', 'overwrite', 'update']);
935
1051
  expect(body.properties.mode.description, name).toContain('default `create`');
1052
+ for (const mode of ['`create`', '`overwrite`', '`update`']) {
1053
+ expect(body.properties.mode.description, `${name} ${mode}`).toContain(mode);
1054
+ }
936
1055
  }
937
1056
  });
938
1057
 
@@ -1595,28 +1714,36 @@ describe('office documents and PDFs', () => {
1595
1714
  expect(await readContent(base, `${KB_DIR}/deck.pptx`)).toContain('Original');
1596
1715
  });
1597
1716
 
1598
- it('every file tool states the SAME content rule — refused families, the byte tools and the upload path — so agents learn before the call', async () => {
1717
+ it('every mounted tool ends with the one sentence pointing at the shared rules, and repeats none of them', async () => {
1599
1718
  await start();
1600
1719
  const tools = await toolRegistry.listInternal();
1601
- const fileTools = ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'mkdir', 'move_file', 'copy_file', 'unzip'];
1602
- for (const name of fileTools) {
1720
+ const pointer = sharedRulesPointer(testKbContext().layout);
1721
+ // The shell is in the list too: it carried the agent-guide reminder before,
1722
+ // and that reminder is one of the rules that moved.
1723
+ const mounted = ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip', 'execute_command'];
1724
+ for (const name of mounted) {
1603
1725
  const def = tools.find((t) => t.name === name);
1604
1726
  expect(def, name).toBeDefined();
1605
- // One constant, verbatim — the description is what tools_info returns.
1606
- expect(def!.description, name).toContain(CONTENT_RULE);
1607
- expect(def!.description, name).toContain('`binary_not_writable`');
1608
- expect(def!.description, name).toContain('copy_file, move_file, delete_file and unzip act on bytes of any kind');
1609
- expect(def!.description, name).toContain('`request_upload_token` + `apply_upload`');
1610
- expect(def!.description, name).toContain('`contentMode`');
1611
- // Modern extractable formats…
1612
- expect(def!.description, name).toContain('.docx/.pptx/.xlsx/.odt/.odp/.ods/.pdf');
1613
- // …email files (extractions too, so the same refusal applies)…
1614
- expect(def!.description, name).toContain('.eml/.msg');
1615
- // …the legacy binary family the refusal also covers…
1616
- expect(def!.description, name).toContain('.doc/.ppt/.xls');
1727
+ expect(def!.description!.endsWith(pointer), name).toBe(true);
1728
+ // Once, at the end — not once per paragraph that used to be appended.
1729
+ expect(def!.description!.split(pointer), name).toHaveLength(2);
1730
+ // EVERY shared rule, in full, is in the two shared places now (see
1731
+ // agent-instructions/__tests__/shared-file-rules.test.ts) and in no
1732
+ // description. Checked on the whole body rather than on a phrase: the
1733
+ // drift this PR exists to prevent is a paragraph pasted back onto a
1734
+ // tool, and naming only two marker phrases would catch two of eight.
1735
+ for (const rule of sharedFileRules(testKbContext().layout)) {
1736
+ expect(def!.description, `${name} / ${rule.id}`).not.toContain(rule.body);
1737
+ }
1738
+ // The two lead-in labels the paragraphs used to arrive under are gone
1739
+ // with them — a description carrying one is carrying the old text.
1740
+ expect(def!.description, name).not.toContain('Content rule (the same on every file tool)');
1741
+ expect(def!.description, name).not.toContain('Before your first read or change in a workspace');
1617
1742
  }
1618
- // The shell is not a file tool: it does not carry the rule.
1619
- expect(tools.find((t) => t.name === 'execute_command')!.description).not.toContain(CONTENT_RULE);
1743
+ // start_session carried none of the shared paragraphs and gains no pointer:
1744
+ // it touches no file. External-only, so it is looked up on that surface.
1745
+ const external = await toolRegistry.listExternal();
1746
+ expect(external.find((t) => t.name === 'start_session')!.description).not.toContain(pointer);
1620
1747
  });
1621
1748
 
1622
1749
  describe('binary capability contract: a text file, a document, an image and a zip', () => {
@@ -1762,14 +1889,15 @@ describe('office documents and PDFs', () => {
1762
1889
  });
1763
1890
  });
1764
1891
 
1765
- it('the page-writing tools say where images go, so an agent writes the link a page will render', async () => {
1892
+ it('says where the images a page uses go — once, in the shared rules', async () => {
1766
1893
  await start();
1767
1894
  const tools = await toolRegistry.listInternal();
1895
+ const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'images-in-pages')!;
1896
+ expect(rule.body).toContain('`assets/` folder next to the page');
1897
+ expect(rule.body).toContain('![Approval screen](./assets/approval-screen.png)');
1898
+ expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
1768
1899
  for (const name of ['write_file', 'write_files']) {
1769
- const def = tools.find((t) => t.name === name);
1770
- expect(def, name).toBeDefined();
1771
- expect(def!.description, name).toContain('`assets/` folder next to the page');
1772
- expect(def!.description, name).toContain('![Approval screen](./assets/approval-screen.png)');
1900
+ expect(tools.find((t) => t.name === name)!.description, name).not.toContain('`assets/` folder next to the page');
1773
1901
  }
1774
1902
  });
1775
1903
  });
@@ -3313,8 +3441,12 @@ describe('preflight for moves and deletes', () => {
3313
3441
  expect(d.description).toMatch(/FILE or FOLDER/);
3314
3442
  expect(d.description).toMatch(/folder moves recursively/);
3315
3443
  expect(d.description).toMatch(/destination must not exist/);
3316
- expect(d.description).toContain('is a platform file and stays in its folder.');
3317
- expect(d.description).toContain('`dryRun: true`');
3444
+ // What refuses a move, and the dry-run/confirm protocol it shares with the
3445
+ // deletes, are shared rules — stated once, in the two shared places.
3446
+ const rules = sharedFileRulesSection(testKbContext().layout);
3447
+ expect(rules).toContain('is a platform file and stays in its folder.');
3448
+ expect(rules).toContain('`dryRun: true`');
3449
+ expect(rules).toContain('`confirm: true`');
3318
3450
  expect(d.description).toContain('`confirm: true`');
3319
3451
  expect(Object.keys(d.inputs.properties.body.properties)).toEqual(expect.arrayContaining(['dryRun', 'confirm']));
3320
3452
  // What the description promises a dry run returns is what it returns.
@@ -3324,7 +3456,10 @@ describe('preflight for moves and deletes', () => {
3324
3456
  expect(dry.body, key).toHaveProperty(key);
3325
3457
  }
3326
3458
  const unconfirmed = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('HR/deal.md') });
3327
- expect(d.description).toContain('confirmationRequired: true');
3459
+ // What an unconfirmed call answers is the shared protocol's promise; the
3460
+ // field is still declared in this tool's own `outputs`.
3461
+ expect(rules).toContain('confirmationRequired: true');
3462
+ expect(declaredOutputs(d)).toContain('confirmationRequired');
3328
3463
  expect(unconfirmed.body.confirmationRequired).toBe(true);
3329
3464
  for (const body of [dry.body, unconfirmed.body]) {
3330
3465
  expect(declaredOutputs(d)).toEqual(expect.arrayContaining(Object.keys(body)));
@@ -3334,9 +3469,12 @@ describe('preflight for moves and deletes', () => {
3334
3469
  it('delete_folder states the confirm rule and refusals, and declares every field it returns', async () => {
3335
3470
  const base = await seeded();
3336
3471
  const d = await def('delete_folder');
3337
- expect(d.description).toContain('`dryRun: true`');
3338
- expect(d.description).toContain('A non-empty folder is deleted only with `confirm: true`');
3339
- expect(d.description).toContain('platform folder');
3472
+ expect(d.description).toContain('a non-empty folder wants `confirm: true`');
3473
+ // The protocol itself, and what a platform folder does to it, are shared.
3474
+ const rules = sharedFileRulesSection(testKbContext().layout);
3475
+ expect(rules).toContain('`dryRun: true`');
3476
+ expect(rules).toContain('A non-empty folder is deleted, and a move that changes your access runs, only with `confirm: true`');
3477
+ expect(rules).toContain('platform folder');
3340
3478
  const dry = await call(base, 'delete_folder', { path: KB('Sales/archive'), dryRun: true });
3341
3479
  const unconfirmed = await call(base, 'delete_folder', { path: KB('Sales/archive') });
3342
3480
  const confirmed = await call(base, 'delete_folder', { path: KB('Sales/archive'), confirm: true });
@@ -3354,8 +3492,12 @@ describe('preflight for moves and deletes', () => {
3354
3492
  expect(stat.description, key).toContain(`\`${key}`);
3355
3493
  expect(body, key).toHaveProperty(key);
3356
3494
  }
3495
+ expect(stat.description).toContain('shared rules on what these tools never move or delete');
3496
+ // The proposal route applies to every tool a permission can refuse, so it
3497
+ // is stated once in the shared rules rather than on each of them.
3498
+ expect(sharedFileRulesSection(testKbContext().layout)).toContain('`write-denied`');
3357
3499
  for (const name of ['move_file', 'delete_file', 'delete_folder']) {
3358
- expect((await def(name)).description, name).toContain('`write-denied`');
3500
+ expect((await def(name)).description, name).toContain(sharedRulesPointer(testKbContext().layout));
3359
3501
  }
3360
3502
  });
3361
3503
  });
@@ -3414,7 +3556,7 @@ describe('a write refused for permissions says whether and how to propose it', (
3414
3556
  throw err;
3415
3557
  };
3416
3558
  const target = fs as unknown as Record<string, unknown>;
3417
- for (const m of ['writeFile', 'deleteFile', 'moveFile', 'mkdir']) target[m] = refuse;
3559
+ for (const m of ['writeFile', 'rewriteFile', 'deleteFile', 'moveFile', 'mkdir']) target[m] = refuse;
3418
3560
  target.copyFile = async (_src: string, dest: string) => refuse(dest);
3419
3561
  // `delete_folder` lands through the same batch with NO writes and the
3420
3562
  // folder's files as `deletes` — in production the refusal comes from the
@@ -3602,14 +3744,18 @@ describe('a write refused for permissions says whether and how to propose it', (
3602
3744
  expect(res.json).toEqual({ error: 'old_string not found in the file.' });
3603
3745
  });
3604
3746
 
3605
- it('each write tool mentions the proposal route in its description; read tools do not', async () => {
3747
+ it('states the proposal route in the shared rules, not in each write tool description', async () => {
3606
3748
  await start();
3607
3749
  const tools = await toolRegistry.listInternal();
3608
- for (const name of ['write_file', 'edit_file', 'write_files', 'move_file', 'delete_file', 'delete_folder', 'copy_file', 'mkdir']) {
3609
- expect(tools.find((t) => t.name === name)?.description, name).toContain(PROPOSAL_ROUTE_NOTE.trim());
3610
- }
3611
- for (const name of ['read_file', 'grep', 'list_files']) {
3612
- expect(tools.find((t) => t.name === name)?.description, name).not.toContain(PROPOSAL_ROUTE_NOTE.trim());
3750
+ // It applies to every tool a permission can refuse, so it is a shared rule:
3751
+ // stated in the handshake instructions and in the managed guide, and in no
3752
+ // description. The refusal ITSELF still spells the steps out — that is what
3753
+ // the tests above this one assert.
3754
+ const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'refused-for-permissions')!;
3755
+ expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
3756
+ for (const def of tools) {
3757
+ expect(def.description ?? '', def.name).not.toContain('If this is refused for permissions');
3758
+ expect(def.description ?? '', def.name).not.toContain(rule.body);
3613
3759
  }
3614
3760
  });
3615
3761
  });
@@ -4129,18 +4275,22 @@ describe('tool descriptions and the deployment note', () => {
4129
4275
  expect(all.get('read_file')!.description).not.toContain('Stay within one');
4130
4276
  });
4131
4277
 
4132
- it('a registered note lands at the END of every gated tool\'s description and on the sessionId input', async () => {
4278
+ it("a registered note lands after every gated tool's own text, ahead of the shared-rules pointer, and on the sessionId input", async () => {
4133
4279
  await start();
4134
4280
  notes.registerGatedToolNote(' One folder per conversation.');
4135
4281
  notes.registerSessionIdNote(' It also pins that folder.');
4136
4282
  const all = await defs();
4283
+ // The POINTER is last, always: that is the one sentence an agent needs to
4284
+ // find the shared rules, and a description cut short must not lose it. The
4285
+ // deployment's note sits directly before it, after the tool's own text.
4286
+ const pointer = sharedRulesPointer(testKbContext().layout);
4137
4287
  for (const name of ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip']) {
4138
- expect(all.get(name)!.description.endsWith(' One folder per conversation.'), name).toBe(true);
4288
+ expect(all.get(name)!.description.endsWith(` One folder per conversation.${pointer}`), name).toBe(true);
4139
4289
  expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(`${SESSION_ID_DESCRIPTION} It also pins that folder.`);
4140
4290
  }
4141
4291
  // `execute_command` is internal-only, so it is checked on that surface.
4142
4292
  const internal = new Map((await toolRegistry.listInternal()).map((t) => [t.name, t]));
4143
- expect(internal.get('execute_command')!.description.endsWith(' One folder per conversation.')).toBe(true);
4293
+ expect(internal.get('execute_command')!.description.endsWith(` One folder per conversation.${pointer}`)).toBe(true);
4144
4294
  });
4145
4295
 
4146
4296
  it('a tool that is not gated carries no note', async () => {
@@ -0,0 +1,214 @@
1
+ import express from 'express';
2
+ import { validateFilename } from '@bevel-software/platform-shared';
3
+ import { logger } from '../../shared/logging.js';
4
+ import { AgentUploadStore, UploadTokenError, overLimit } from './agent-upload.store.js';
5
+
6
+ const log = logger('agent-uploads');
7
+
8
+ /** The route's own prefix, under `/api`. One spelling, shared with the raw-body test below. */
9
+ export const AGENT_UPLOAD_ROUTE = '/agent/uploads/:token';
10
+
11
+ /**
12
+ * The same endpoint with the token in the `x-upload-token` HEADER instead of
13
+ * the path — the address `uploadUrl` is, without its last segment.
14
+ *
15
+ * Two spellings because a token in a URL is a credential in a place that keeps
16
+ * copies: an access log, a proxy log, a shell history, a `ps` listing of the
17
+ * `curl` that sent it. The path form is what `request_file_upload` answers,
18
+ * because one address an agent can paste into any client is the thing that
19
+ * makes this route usable at all; the header form is for a caller that would
20
+ * rather its credential not be written down on the way. Both reach the same
21
+ * handler and are judged identically — same token, same single use.
22
+ */
23
+ export const AGENT_UPLOAD_HEADER_ROUTE = '/agent/uploads';
24
+
25
+ /**
26
+ * Whether `path` is the agent upload route, and so must reach its handler with
27
+ * the body still a STREAM.
28
+ *
29
+ * The app installs a global `express.json()`. It only claims a JSON
30
+ * content-type, but `curl --data-binary @file.zip -H 'content-type:
31
+ * application/json'` is a request an agent can and will make — and once the
32
+ * parser has drained the stream there are no bytes left to store, so the
33
+ * upload would answer "0 bytes received" for a file that was sent in full.
34
+ * Exempting the path is the same move `/api/sync` makes for its HMAC body, and
35
+ * for the same reason: whoever needs the exact bytes has to see them first.
36
+ *
37
+ * Case-insensitive, because Express routing is: `/api/Agent/uploads/x` reaches
38
+ * this router, and a check that said no would let the parser eat that body.
39
+ */
40
+ export function isAgentUploadRawBodyPath(path: string): boolean {
41
+ // Trailing slashes trimmed first, so the header form's bare address matches
42
+ // in every spelling Express routes to it (`/api/agent/uploads` and
43
+ // `/api/agent/uploads/` are one route): missing one of them would hand that
44
+ // request to the JSON parser, and the bytes it drains are gone.
45
+ const lower = path.toLowerCase().replace(/\/+$/, '');
46
+ return lower === '/api/agent/uploads' || lower.startsWith('/api/agent/uploads/');
47
+ }
48
+
49
+ export interface AgentUploadRouteDeps {
50
+ uploads: AgentUploadStore;
51
+ }
52
+
53
+ /**
54
+ * `POST /api/agent/uploads/:token` — the one endpoint on this server
55
+ * authenticated by a single-use token and nothing else.
56
+ *
57
+ * It exists because MCP tool arguments are JSON, so every byte an agent sends
58
+ * through a tool passes through the model first. A 37 KB page gets truncated
59
+ * mid-response; a file of regex backslashes fails to parse as a JSON string; a
60
+ * PNG cannot be sent at all. Here the agent asks for a token, sends the file
61
+ * with any HTTP client it has, and then names the token in
62
+ * `apply_file_upload`. The bytes never enter a prompt.
63
+ *
64
+ * What the route itself may do is deliberately almost nothing: it stores bytes
65
+ * against a token, in a directory beside the workspaces root, and answers what
66
+ * it received. It resolves no workspace, writes nothing into one, and commits
67
+ * nothing — every access, platform-file and branch rule is applied later, by
68
+ * the apply tool, against the branch the caller then names. A token that is
69
+ * unknown, spent, expired or someone else's gets one 404 that says which of
70
+ * those it was: none of them.
71
+ */
72
+ export function createAgentUploadRoutes(deps: AgentUploadRouteDeps): express.Router {
73
+ const router = express.Router();
74
+ const { uploads } = deps;
75
+
76
+ const handle: express.RequestHandler = async (req, res) => {
77
+ const token = tokenOf(req);
78
+ try {
79
+ // THE TOKEN FIRST, before anything about the request is read, parsed,
80
+ // judged or quoted back. This route is authenticated by the token and
81
+ // nothing else, which cuts two ways. Without the check up here, anyone
82
+ // could make the process buffer the deployment's whole upload limit per
83
+ // request against tokens they invented, as many at a time as they liked;
84
+ // and a caller holding no token could learn which of its OTHER guesses
85
+ // were well formed — "that is not a usable file name" is an answer only
86
+ // somebody entitled to send a file should get. One refusal, nothing else.
87
+ // `receive` below asks again, under the same record, because that is
88
+ // where the token is actually spent.
89
+ uploads.assertOpen(token);
90
+ const filename = fileNameOf(req);
91
+ if (filename === null) {
92
+ res.status(400).json({
93
+ error:
94
+ 'Name the file you are sending: add `?filename=<name>` to the upload URL (or send it as the ' +
95
+ '`x-upload-filename` header). A single file lands under that name, and a name ending in `.zip` is ' +
96
+ 'read as an archive.',
97
+ });
98
+ return;
99
+ }
100
+ const invalid = validateFilename(filename);
101
+ if (invalid !== null || filename.includes('/')) {
102
+ res.status(400).json({
103
+ error: `"${filename}" is not a usable file name: ${invalid ?? 'a name cannot contain "/"'}. Send one plain file name.`,
104
+ });
105
+ return;
106
+ }
107
+ // Then the declared length, so a caller sending something far too large
108
+ // is told the limit before it spends the bandwidth. The real total is
109
+ // counted by the store as the bytes arrive — `content-length` is the
110
+ // sender's claim, not a fact.
111
+ const declared = Number.parseInt(req.headers['content-length'] ?? '', 10);
112
+ if (Number.isFinite(declared) && declared > uploads.maxBytes) {
113
+ res.status(413).json({ error: overLimit(declared, uploads.maxBytes) });
114
+ return;
115
+ }
116
+ // The request itself, as a stream: the store writes the bytes to disk as
117
+ // they arrive and never holds the body. It refuses an empty body, one
118
+ // past the limit and an unreadable archive with its own status.
119
+ res.json(await uploads.receive(token, filename, req));
120
+ } catch (err) {
121
+ if (err instanceof UploadTokenError) {
122
+ res.status(err.status).json({ error: err.message });
123
+ return;
124
+ }
125
+ // The detail stays in the log. An unexpected failure here is a
126
+ // filesystem error, and its message quotes the absolute path of the
127
+ // store's root — a place the caller is told nothing else about.
128
+ log.error('upload failed:', { err });
129
+ res.status(500).json({ error: 'Upload failed' });
130
+ }
131
+ };
132
+
133
+ // Both spellings of the one endpoint: the token in the path, or in the
134
+ // `x-upload-token` header on the bare address.
135
+ router.post(AGENT_UPLOAD_ROUTE, handle);
136
+ router.post(AGENT_UPLOAD_HEADER_ROUTE, handle);
137
+
138
+ return router;
139
+ }
140
+
141
+ /**
142
+ * The token the sender presented: the `:token` path segment, or the
143
+ * `x-upload-token` header when the bytes went to the bare address.
144
+ *
145
+ * The path wins when both are present — it is the address the sender actually
146
+ * POSTed to, and a header left over from an earlier upload must not quietly
147
+ * redirect these bytes onto a different token.
148
+ *
149
+ * No token at all answers the empty string rather than its own refusal, so it
150
+ * goes through `assertOpen` like any other unusable token and gets the same
151
+ * single 404. A caller holding nothing is told nothing it did not already
152
+ * know — not even whether this endpoint wanted a header.
153
+ */
154
+ function tokenOf(req: express.Request): string {
155
+ const inPath = req.params.token;
156
+ if (typeof inPath === 'string' && inPath.trim() !== '') return inPath.trim();
157
+ const header = req.headers['x-upload-token'];
158
+ if (typeof header === 'string' && header.trim() !== '') return header.trim();
159
+ return '';
160
+ }
161
+
162
+ /**
163
+ * The name the sender gave the file: the `filename` query parameter, the
164
+ * `x-upload-filename` header, or a `content-disposition`'s own `filename=`.
165
+ * Three spellings because three kinds of client are expected to use this —
166
+ * `curl` with a query string, a scripted `fetch` with a header, and a client
167
+ * that sends the disposition it would send to any upload endpoint.
168
+ */
169
+ function fileNameOf(req: express.Request): string | null {
170
+ const query = req.query.filename;
171
+ if (typeof query === 'string' && query.trim() !== '') return query.trim();
172
+ const header = req.headers['x-upload-filename'];
173
+ if (typeof header === 'string' && header.trim() !== '') return header.trim();
174
+ const disposition = req.headers['content-disposition'];
175
+ if (typeof disposition === 'string') return dispositionFilename(disposition);
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * The `filename` a `content-disposition` names, or null when it names none.
181
+ *
182
+ * A QUOTED value is read to its closing quote, not to the first semicolon: a
183
+ * semicolon separates the header's parameters only OUTSIDE the quotes, and
184
+ * `filename="report;final.md"` is one perfectly ordinary name that a
185
+ * semicolon-first reading landed as `report`. An unquoted value ends at the
186
+ * next parameter, as it must.
187
+ *
188
+ * Percent-decoded only in the extended `filename*=UTF-8''…` form, which is the
189
+ * only one where the encoding is part of the grammar. A plain `filename=` value
190
+ * is the name itself, so `50%20off.md` stays `50%20off.md` rather than losing
191
+ * its `%20` to a decode nobody asked for.
192
+ *
193
+ * The parameter name is matched at a boundary — the start of the header or a
194
+ * `;` — because `filename` is a suffix of other perfectly legal parameter
195
+ * names: without it, `inline; xfilename=wrong.md` read `wrong.md` as the name
196
+ * the sender gave, from a parameter that says nothing of the kind.
197
+ */
198
+ export function dispositionFilename(disposition: string): string | null {
199
+ const match = /(?:^|;)\s*filename(\*?)\s*=\s*(?:"([^"]*)"|([^;]*))/i.exec(disposition);
200
+ if (!match) return null;
201
+ const extended = match[1] === '*';
202
+ const raw = (match[2] ?? match[3] ?? '').trim();
203
+ if (!extended) return raw === '' ? null : raw;
204
+ // `UTF-8''name`, or any other charset and language the sender declares.
205
+ const encoded = raw.replace(/^[^']*'[^']*'/, '');
206
+ try {
207
+ const decoded = decodeURIComponent(encoded).trim();
208
+ if (decoded !== '') return decoded;
209
+ } catch {
210
+ const kept = encoded.trim();
211
+ if (kept !== '') return kept;
212
+ }
213
+ return null;
214
+ }