@bevel-software/platform-core-backend 0.23.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 (227) 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 +10 -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 +8 -0
  64. package/dist/modules/database/migrate.d.ts.map +1 -1
  65. package/dist/modules/database/migrate.js +271 -1
  66. package/dist/modules/database/migrate.js.map +1 -1
  67. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  68. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  69. package/dist/modules/mcp/mcp.service.js +38 -8
  70. package/dist/modules/mcp/mcp.service.js.map +1 -1
  71. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  72. package/dist/modules/plugins/join-request-records.store.js +7 -4
  73. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  74. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  75. package/dist/modules/tool-auth/external-api-key.service.js +8 -2
  76. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  77. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  78. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  79. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  80. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  81. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  82. package/dist/modules/tool-registry/description-length.js +108 -0
  83. package/dist/modules/tool-registry/description-length.js.map +1 -0
  84. package/dist/modules/workflow/git/git.service.d.ts +25 -0
  85. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/git.service.js +40 -1
  87. package/dist/modules/workflow/git/git.service.js.map +1 -1
  88. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  89. package/dist/modules/workflow/pending-commits.service.js +5 -1
  90. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  91. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  92. package/dist/modules/workflow/recovery-bot.js +7 -3
  93. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  94. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  95. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  96. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  97. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  98. package/dist/modules/workflow/workflow.service.js +4 -1
  99. package/dist/modules/workflow/workflow.service.js.map +1 -1
  100. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  101. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  102. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  103. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  104. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  105. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  106. package/dist/modules/workspace/agent-upload.store.js +553 -0
  107. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  108. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  109. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  110. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  111. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  112. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  113. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  114. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  115. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  116. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  117. package/dist/modules/workspace/upload-limits.js +13 -0
  118. package/dist/modules/workspace/upload-limits.js.map +1 -0
  119. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  120. package/dist/modules/workspace/workspace.routes.js +1 -1
  121. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  122. package/dist/modules/workspace/workspace.service.d.ts +13 -0
  123. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  124. package/dist/modules/workspace/workspace.service.js +61 -33
  125. package/dist/modules/workspace/workspace.service.js.map +1 -1
  126. package/dist/modules/workspace/workspace.tools.d.ts +11 -9
  127. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  128. package/dist/modules/workspace/workspace.tools.js +512 -113
  129. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  130. package/dist/modules/workspace/write-denial.d.ts +0 -6
  131. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  132. package/dist/modules/workspace/write-denial.js +0 -6
  133. package/dist/modules/workspace/write-denial.js.map +1 -1
  134. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  135. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  136. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  137. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  138. package/dist/shared/column-crypto.d.ts +194 -0
  139. package/dist/shared/column-crypto.d.ts.map +1 -0
  140. package/dist/shared/column-crypto.js +144 -0
  141. package/dist/shared/column-crypto.js.map +1 -0
  142. package/dist/shared/token-crypto.d.ts.map +1 -1
  143. package/dist/shared/token-crypto.js +25 -1
  144. package/dist/shared/token-crypto.js.map +1 -1
  145. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  146. package/dist/tenancy/static-tenant-source.js +1 -0
  147. package/dist/tenancy/static-tenant-source.js.map +1 -1
  148. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  149. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  150. package/dist/tenancy/tenant-secrets.js +4 -0
  151. package/dist/tenancy/tenant-secrets.js.map +1 -1
  152. package/kb-template/AGENTS.md +2 -0
  153. package/migrations/0016_pii_encryption.sql +20 -0
  154. package/migrations/meta/0016_snapshot.json +2327 -0
  155. package/migrations/meta/_journal.json +7 -0
  156. package/package.json +3 -3
  157. package/src/core/__tests__/lifecycle.test.ts +71 -4
  158. package/src/core/create-core-server.ts +21 -3
  159. package/src/core/create-core-services.ts +27 -1
  160. package/src/core/lifecycle.ts +19 -0
  161. package/src/core-config.ts +18 -0
  162. package/src/index.ts +18 -0
  163. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  164. package/src/modules/access/access.routes.ts +4 -1
  165. package/src/modules/access/directory-sync-bot.ts +7 -3
  166. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +8 -4
  167. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  168. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  169. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  170. package/src/modules/agent-instructions/compose.ts +50 -7
  171. package/src/modules/agent-instructions/index.ts +12 -0
  172. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  173. package/src/modules/audit/agent-audit.service.ts +4 -2
  174. package/src/modules/auth/__tests__/account-deactivation.test.ts +2 -1
  175. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  176. package/src/modules/auth/__tests__/account.routes.test.ts +6 -3
  177. package/src/modules/auth/__tests__/auth.service.test.ts +3 -1
  178. package/src/modules/auth/account-erasure.service.ts +69 -18
  179. package/src/modules/auth/auth.service.ts +33 -23
  180. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  181. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  182. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  183. package/src/modules/database/__tests__/connection.test.ts +12 -0
  184. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +561 -0
  185. package/src/modules/database/connection.ts +117 -0
  186. package/src/modules/database/core-schema.ts +81 -31
  187. package/src/modules/database/migrate.ts +353 -1
  188. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  189. package/src/modules/mcp/__tests__/mcp.service.test.ts +47 -7
  190. package/src/modules/mcp/mcp.service.ts +46 -7
  191. package/src/modules/plugins/join-request-records.store.ts +7 -4
  192. package/src/modules/tool-auth/external-api-key.service.ts +8 -2
  193. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  194. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  195. package/src/modules/tool-registry/description-length.ts +111 -0
  196. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  197. package/src/modules/workflow/git/git.service.ts +47 -1
  198. package/src/modules/workflow/pending-commits.service.ts +5 -1
  199. package/src/modules/workflow/recovery-bot.ts +7 -3
  200. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  201. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  202. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  203. package/src/modules/workflow/workflow.service.ts +4 -1
  204. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  205. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +24 -14
  206. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  207. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +77 -1
  208. package/src/modules/workspace/__tests__/workspace.service.test.ts +57 -0
  209. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +39 -36
  210. package/src/modules/workspace/__tests__/workspace.tools.test.ts +84 -45
  211. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  212. package/src/modules/workspace/agent-upload.store.ts +668 -0
  213. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  214. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  215. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  216. package/src/modules/workspace/upload-limits.ts +12 -0
  217. package/src/modules/workspace/workspace.routes.ts +1 -2
  218. package/src/modules/workspace/workspace.service.ts +63 -37
  219. package/src/modules/workspace/workspace.tools.ts +588 -127
  220. package/src/modules/workspace/write-denial.ts +0 -8
  221. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  222. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  223. package/src/shared/column-crypto.ts +218 -0
  224. package/src/shared/token-crypto.ts +28 -1
  225. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -0
  226. package/src/tenancy/static-tenant-source.ts +1 -0
  227. 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';
@@ -920,19 +921,26 @@ describe('write modes and per-path outcomes', () => {
920
921
  });
921
922
  });
922
923
 
923
- it('both descriptions state the default and all three modes, and `mode` is an input on each', async () => {
924
+ it('states the three modes on the `mode` input of each write tool, and once in the shared rules', async () => {
924
925
  await start();
925
926
  const tools = await toolRegistry.listInternal();
927
+ // The paragraph that said this in BOTH descriptions is one shared rule now.
928
+ // What stays on the tool is the input the agent fills, which is where the
929
+ // decision is actually taken.
930
+ const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'write-mode')!;
931
+ expect(rule.body).toContain('DEFAULTS TO `create`');
932
+ expect(rule.body).toContain('On write_file and write_files');
933
+ expect(sharedFileRulesSection(testKbContext().layout).split(rule.body)).toHaveLength(2);
926
934
  for (const name of ['write_file', 'write_files']) {
927
935
  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
- }
936
+ expect(def.description, name).not.toContain('DEFAULTS TO `create`');
932
937
  const body = (def.inputs as { properties: { body: { properties: Record<string, { enum?: string[]; description?: string }> } } }).properties.body;
933
938
  expect(body.properties.mode, name).toBeDefined();
934
939
  expect(body.properties.mode.enum, name).toEqual(['create', 'overwrite', 'update']);
935
940
  expect(body.properties.mode.description, name).toContain('default `create`');
941
+ for (const mode of ['`create`', '`overwrite`', '`update`']) {
942
+ expect(body.properties.mode.description, `${name} ${mode}`).toContain(mode);
943
+ }
936
944
  }
937
945
  });
938
946
 
@@ -1595,28 +1603,36 @@ describe('office documents and PDFs', () => {
1595
1603
  expect(await readContent(base, `${KB_DIR}/deck.pptx`)).toContain('Original');
1596
1604
  });
1597
1605
 
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 () => {
1606
+ it('every mounted tool ends with the one sentence pointing at the shared rules, and repeats none of them', async () => {
1599
1607
  await start();
1600
1608
  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) {
1609
+ const pointer = sharedRulesPointer(testKbContext().layout);
1610
+ // The shell is in the list too: it carried the agent-guide reminder before,
1611
+ // and that reminder is one of the rules that moved.
1612
+ 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'];
1613
+ for (const name of mounted) {
1603
1614
  const def = tools.find((t) => t.name === name);
1604
1615
  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');
1616
+ expect(def!.description!.endsWith(pointer), name).toBe(true);
1617
+ // Once, at the end — not once per paragraph that used to be appended.
1618
+ expect(def!.description!.split(pointer), name).toHaveLength(2);
1619
+ // EVERY shared rule, in full, is in the two shared places now (see
1620
+ // agent-instructions/__tests__/shared-file-rules.test.ts) and in no
1621
+ // description. Checked on the whole body rather than on a phrase: the
1622
+ // drift this PR exists to prevent is a paragraph pasted back onto a
1623
+ // tool, and naming only two marker phrases would catch two of eight.
1624
+ for (const rule of sharedFileRules(testKbContext().layout)) {
1625
+ expect(def!.description, `${name} / ${rule.id}`).not.toContain(rule.body);
1626
+ }
1627
+ // The two lead-in labels the paragraphs used to arrive under are gone
1628
+ // with them — a description carrying one is carrying the old text.
1629
+ expect(def!.description, name).not.toContain('Content rule (the same on every file tool)');
1630
+ expect(def!.description, name).not.toContain('Before your first read or change in a workspace');
1617
1631
  }
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);
1632
+ // start_session carried none of the shared paragraphs and gains no pointer:
1633
+ // it touches no file. External-only, so it is looked up on that surface.
1634
+ const external = await toolRegistry.listExternal();
1635
+ expect(external.find((t) => t.name === 'start_session')!.description).not.toContain(pointer);
1620
1636
  });
1621
1637
 
1622
1638
  describe('binary capability contract: a text file, a document, an image and a zip', () => {
@@ -1762,14 +1778,15 @@ describe('office documents and PDFs', () => {
1762
1778
  });
1763
1779
  });
1764
1780
 
1765
- it('the page-writing tools say where images go, so an agent writes the link a page will render', async () => {
1781
+ it('says where the images a page uses go — once, in the shared rules', async () => {
1766
1782
  await start();
1767
1783
  const tools = await toolRegistry.listInternal();
1784
+ const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'images-in-pages')!;
1785
+ expect(rule.body).toContain('`assets/` folder next to the page');
1786
+ expect(rule.body).toContain('![Approval screen](./assets/approval-screen.png)');
1787
+ expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
1768
1788
  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)');
1789
+ expect(tools.find((t) => t.name === name)!.description, name).not.toContain('`assets/` folder next to the page');
1773
1790
  }
1774
1791
  });
1775
1792
  });
@@ -3313,8 +3330,12 @@ describe('preflight for moves and deletes', () => {
3313
3330
  expect(d.description).toMatch(/FILE or FOLDER/);
3314
3331
  expect(d.description).toMatch(/folder moves recursively/);
3315
3332
  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`');
3333
+ // What refuses a move, and the dry-run/confirm protocol it shares with the
3334
+ // deletes, are shared rules — stated once, in the two shared places.
3335
+ const rules = sharedFileRulesSection(testKbContext().layout);
3336
+ expect(rules).toContain('is a platform file and stays in its folder.');
3337
+ expect(rules).toContain('`dryRun: true`');
3338
+ expect(rules).toContain('`confirm: true`');
3318
3339
  expect(d.description).toContain('`confirm: true`');
3319
3340
  expect(Object.keys(d.inputs.properties.body.properties)).toEqual(expect.arrayContaining(['dryRun', 'confirm']));
3320
3341
  // What the description promises a dry run returns is what it returns.
@@ -3324,7 +3345,10 @@ describe('preflight for moves and deletes', () => {
3324
3345
  expect(dry.body, key).toHaveProperty(key);
3325
3346
  }
3326
3347
  const unconfirmed = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('HR/deal.md') });
3327
- expect(d.description).toContain('confirmationRequired: true');
3348
+ // What an unconfirmed call answers is the shared protocol's promise; the
3349
+ // field is still declared in this tool's own `outputs`.
3350
+ expect(rules).toContain('confirmationRequired: true');
3351
+ expect(declaredOutputs(d)).toContain('confirmationRequired');
3328
3352
  expect(unconfirmed.body.confirmationRequired).toBe(true);
3329
3353
  for (const body of [dry.body, unconfirmed.body]) {
3330
3354
  expect(declaredOutputs(d)).toEqual(expect.arrayContaining(Object.keys(body)));
@@ -3334,9 +3358,12 @@ describe('preflight for moves and deletes', () => {
3334
3358
  it('delete_folder states the confirm rule and refusals, and declares every field it returns', async () => {
3335
3359
  const base = await seeded();
3336
3360
  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');
3361
+ expect(d.description).toContain('a non-empty folder wants `confirm: true`');
3362
+ // The protocol itself, and what a platform folder does to it, are shared.
3363
+ const rules = sharedFileRulesSection(testKbContext().layout);
3364
+ expect(rules).toContain('`dryRun: true`');
3365
+ expect(rules).toContain('A non-empty folder is deleted, and a move that changes your access runs, only with `confirm: true`');
3366
+ expect(rules).toContain('platform folder');
3340
3367
  const dry = await call(base, 'delete_folder', { path: KB('Sales/archive'), dryRun: true });
3341
3368
  const unconfirmed = await call(base, 'delete_folder', { path: KB('Sales/archive') });
3342
3369
  const confirmed = await call(base, 'delete_folder', { path: KB('Sales/archive'), confirm: true });
@@ -3354,8 +3381,12 @@ describe('preflight for moves and deletes', () => {
3354
3381
  expect(stat.description, key).toContain(`\`${key}`);
3355
3382
  expect(body, key).toHaveProperty(key);
3356
3383
  }
3384
+ expect(stat.description).toContain('shared rules on what these tools never move or delete');
3385
+ // The proposal route applies to every tool a permission can refuse, so it
3386
+ // is stated once in the shared rules rather than on each of them.
3387
+ expect(sharedFileRulesSection(testKbContext().layout)).toContain('`write-denied`');
3357
3388
  for (const name of ['move_file', 'delete_file', 'delete_folder']) {
3358
- expect((await def(name)).description, name).toContain('`write-denied`');
3389
+ expect((await def(name)).description, name).toContain(sharedRulesPointer(testKbContext().layout));
3359
3390
  }
3360
3391
  });
3361
3392
  });
@@ -3602,14 +3633,18 @@ describe('a write refused for permissions says whether and how to propose it', (
3602
3633
  expect(res.json).toEqual({ error: 'old_string not found in the file.' });
3603
3634
  });
3604
3635
 
3605
- it('each write tool mentions the proposal route in its description; read tools do not', async () => {
3636
+ it('states the proposal route in the shared rules, not in each write tool description', async () => {
3606
3637
  await start();
3607
3638
  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());
3639
+ // It applies to every tool a permission can refuse, so it is a shared rule:
3640
+ // stated in the handshake instructions and in the managed guide, and in no
3641
+ // description. The refusal ITSELF still spells the steps out — that is what
3642
+ // the tests above this one assert.
3643
+ const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'refused-for-permissions')!;
3644
+ expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
3645
+ for (const def of tools) {
3646
+ expect(def.description ?? '', def.name).not.toContain('If this is refused for permissions');
3647
+ expect(def.description ?? '', def.name).not.toContain(rule.body);
3613
3648
  }
3614
3649
  });
3615
3650
  });
@@ -4129,18 +4164,22 @@ describe('tool descriptions and the deployment note', () => {
4129
4164
  expect(all.get('read_file')!.description).not.toContain('Stay within one');
4130
4165
  });
4131
4166
 
4132
- it('a registered note lands at the END of every gated tool\'s description and on the sessionId input', async () => {
4167
+ 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
4168
  await start();
4134
4169
  notes.registerGatedToolNote(' One folder per conversation.');
4135
4170
  notes.registerSessionIdNote(' It also pins that folder.');
4136
4171
  const all = await defs();
4172
+ // The POINTER is last, always: that is the one sentence an agent needs to
4173
+ // find the shared rules, and a description cut short must not lose it. The
4174
+ // deployment's note sits directly before it, after the tool's own text.
4175
+ const pointer = sharedRulesPointer(testKbContext().layout);
4137
4176
  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);
4177
+ expect(all.get(name)!.description.endsWith(` One folder per conversation.${pointer}`), name).toBe(true);
4139
4178
  expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(`${SESSION_ID_DESCRIPTION} It also pins that folder.`);
4140
4179
  }
4141
4180
  // `execute_command` is internal-only, so it is checked on that surface.
4142
4181
  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);
4182
+ expect(internal.get('execute_command')!.description.endsWith(` One folder per conversation.${pointer}`)).toBe(true);
4144
4183
  });
4145
4184
 
4146
4185
  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
+ }