@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
@@ -21,6 +21,7 @@ import type { IAccessControl } from '../../access/access-control.interface.js';
21
21
  import { createMcpRoutes } from '../../mcp/mcp.routes.js';
22
22
  import { McpService } from '../../mcp/mcp.service.js';
23
23
  import { testKbContext } from '../../../__tests__/kb-context.js';
24
+ import { sharedFileRules, sharedFileRulesSection, sharedRulesPointer } from '../../agent-instructions/shared-file-rules.js';
24
25
 
25
26
  /**
26
27
  * Who decodes escape sequences in written content a second time — settled on
@@ -361,24 +362,33 @@ describe('escape sequences survive a write — call_tool_chain', () => {
361
362
  });
362
363
 
363
364
  describe('what the write tools tell an agent about escape sequences', () => {
364
- it('warns on write_file, write_files and edit_file, and names the upload route', async () => {
365
+ it('states the warning once, in the shared rules, and in no description', async () => {
365
366
  const { descriptions } = await startPlatform();
366
367
  const served = await descriptions();
368
+ // It applies to the three tools that take content as a JSON string, so it is
369
+ // a SHARED rule: stated in the handshake instructions and in the managed
370
+ // agent guide, and repeated in no tool description — a description that
371
+ // carried it was long enough for a client to cut the end off.
372
+ const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'escape-sequences')!;
373
+ expect(rule.body).toContain('some clients decode escape sequences in arguments before sending');
374
+ // The way out is the upload route, which the rule after it spells out by
375
+ // the names of the tools this deployment serves.
376
+ expect(rule.body).toContain('by upload (see below), which lands it unchanged');
377
+ const upload = sharedFileRules(testKbContext().layout).find((r) => r.id === 'upload-route')!;
378
+ expect(upload.body).toContain('`request_file_upload`');
379
+ expect(upload.body).toContain('`apply_file_upload`');
380
+ // It names the three tools it is about, so an agent reading the section
381
+ // knows where it applies.
382
+ for (const name of ['write_file', 'write_files', 'edit_file']) expect(rule.body, name).toContain(name);
383
+ expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
367
384
 
385
+ const carrying = Object.entries(served)
386
+ .filter(([, d]) => d.includes('decode escape sequences in arguments before sending') || d.includes('some clients decode them in arguments'))
387
+ .map(([n]) => n);
388
+ expect(carrying).toEqual([]);
389
+ // Each of the three still points at where the warning is.
368
390
  for (const name of ['write_file', 'write_files', 'edit_file']) {
369
- const d = served[name];
370
- expect(d, name).toBeDefined();
371
- // Some clients decode before sending …
372
- expect(d, name).toContain('some clients decode them in arguments before sending');
373
- // … and the route that lands such content unchanged.
374
- expect(d, name).toContain('request_upload_token');
375
- expect(d, name).toContain('lands it unchanged');
391
+ expect(served[name], name).toContain(sharedRulesPointer(testKbContext().layout).trim());
376
392
  }
377
- // Said on the three tools that take content as a JSON string, and nowhere else.
378
- const carrying = Object.entries(served)
379
- .filter(([, d]) => d.includes('some clients decode them in arguments before sending'))
380
- .map(([n]) => n)
381
- .sort();
382
- expect(carrying).toEqual(['edit_file', 'write_file', 'write_files']);
383
393
  });
384
394
  });
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { testKbContext } from '../../../__tests__/kb-context.js';
8
+ import { NodeFs } from '../../kb-fs/node-fs.js';
9
+ import { WorkspaceService } from '../workspace.service.js';
10
+ import { NodeGitRunner } from '../../workflow/git/node-git-runner.js';
11
+ import { gitCredentials } from '../../../shared/git.contract.js';
12
+
13
+ const execFileAsync = promisify(execFile);
14
+
15
+ /**
16
+ * The working copy a repo-global operation is handed.
17
+ *
18
+ * Listing change requests, reading one by number, listing branches: none of
19
+ * them is about a particular draft, so they run in whichever clone is already
20
+ * on disk (`findAnyWorkspaceId`). That clone is one this process may never have
21
+ * opened — and a clone gets its credential helper when its branch is opened.
22
+ *
23
+ * A deployment's oldest clone had none: it predated the helper being persisted.
24
+ * It sorted first on disk, so every repo-global fetch ran in it, failed with
25
+ * "could not read Username", was swallowed, and came back as `unknown branch`
26
+ * for each open change request — thirty-four of them, on every list, while all
27
+ * thirty-four branches were on the remote. Opening that one branch in the app
28
+ * stamped the helper and the errors stopped.
29
+ */
30
+
31
+ const BRANCH = 'main';
32
+ const OLD_DRAFT = 'someone/an-old-draft';
33
+ const KB_DIR = 'knowledge-base';
34
+
35
+ async function git(cwd: string, args: string[]): Promise<string> {
36
+ const { stdout } = await execFileAsync('git', args, {
37
+ cwd,
38
+ env: {
39
+ ...process.env,
40
+ GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
41
+ GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
42
+ },
43
+ });
44
+ return stdout.toString();
45
+ }
46
+
47
+ /** The credential helpers a clone's own config carries; none is the empty list. */
48
+ async function helpersOf(repoDir: string): Promise<string[]> {
49
+ return git(repoDir, ['config', '--local', '--get-all', 'credential.helper']).then(
50
+ (out) => out.split('\n').filter((line) => line.trim() !== ''),
51
+ () => [],
52
+ );
53
+ }
54
+
55
+ let root: string;
56
+ let workspacesRoot: string;
57
+ let upstream: string;
58
+
59
+ beforeEach(async () => {
60
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-any-creds-'));
61
+ workspacesRoot = path.join(root, 'workspaces');
62
+ await fs.mkdir(workspacesRoot, { recursive: true });
63
+ upstream = path.join(root, 'upstream.git');
64
+ await git(root, ['init', '--bare', '-b', BRANCH, upstream]);
65
+ const seed = path.join(root, '.seed');
66
+ await fs.mkdir(seed);
67
+ await git(seed, ['init', '-b', BRANCH]);
68
+ await fs.writeFile(path.join(seed, 'README.md'), 'seed\n', 'utf8');
69
+ await git(seed, ['add', '-A']);
70
+ await git(seed, ['commit', '-m', 'init']);
71
+ await git(seed, ['remote', 'add', 'origin', upstream]);
72
+ await git(seed, ['push', 'origin', BRANCH]);
73
+ await git(seed, ['push', 'origin', `${BRANCH}:refs/heads/${OLD_DRAFT}`]);
74
+ });
75
+
76
+ afterEach(async () => {
77
+ await fs.rm(root, { recursive: true, force: true });
78
+ });
79
+
80
+ /** A clone as an older build left it: on disk, complete, and with no helper of ours. */
81
+ async function anOldCloneWithNoHelper(branch: string): Promise<string> {
82
+ const repoDir = path.join(workspacesRoot, encodeURIComponent(branch), KB_DIR);
83
+ await fs.mkdir(path.dirname(repoDir), { recursive: true });
84
+ await git(root, ['clone', '-b', branch, upstream, repoDir]);
85
+ expect(await helpersOf(repoDir)).toEqual([]);
86
+ return repoDir;
87
+ }
88
+
89
+ function service(token: string | null) {
90
+ return new WorkspaceService(
91
+ workspacesRoot,
92
+ () => upstream,
93
+ testKbContext({ kbDirName: KB_DIR, branchModel: { defaultBranch: BRANCH, protectedBranches: [BRANCH] } }),
94
+ new NodeFs(),
95
+ new NodeGitRunner(undefined, gitCredentials('x-access-token', token)),
96
+ );
97
+ }
98
+
99
+ describe('the working copy a repo-global operation runs in', () => {
100
+ it('is given the deployment’s credential helper before it is handed out', async () => {
101
+ const repoDir = await anOldCloneWithNoHelper(OLD_DRAFT);
102
+
103
+ const id = await service('a-token').findAnyWorkspaceId();
104
+
105
+ expect(id).toBe(encodeURIComponent(OLD_DRAFT));
106
+ // One helper, ours: the one that reads the token from the environment the
107
+ // runner hands each git call. The token itself is never written down.
108
+ const helpers = await helpersOf(repoDir);
109
+ expect(helpers).toHaveLength(1);
110
+ expect(helpers[0]).toContain('GITHUB_TOKEN');
111
+ expect(helpers[0]).not.toContain('a-token');
112
+ });
113
+
114
+ it('is the same clone it always picked: the first on disk', async () => {
115
+ await anOldCloneWithNoHelper(OLD_DRAFT);
116
+ await anOldCloneWithNoHelper(BRANCH);
117
+ const svc = service('a-token');
118
+ const entries = (await fs.readdir(workspacesRoot)).filter((name) => !name.startsWith('.'));
119
+ expect(await svc.findAnyWorkspaceId()).toBe(entries[0]);
120
+ });
121
+
122
+ it('keeps a helper an operator wrote themselves, beside ours', async () => {
123
+ const repoDir = await anOldCloneWithNoHelper(OLD_DRAFT);
124
+ await git(repoDir, ['config', '--add', 'credential.helper', 'store']);
125
+
126
+ await service('a-token').findAnyWorkspaceId();
127
+
128
+ const helpers = await helpersOf(repoDir);
129
+ expect(helpers).toContain('store');
130
+ expect(helpers.some((h) => h.includes('GITHUB_TOKEN'))).toBe(true);
131
+ });
132
+
133
+ it('writes the helper once, not on every call', async () => {
134
+ const repoDir = await anOldCloneWithNoHelper(OLD_DRAFT);
135
+ const svc = service('a-token');
136
+ await svc.findAnyWorkspaceId();
137
+ const config = path.join(repoDir, '.git', 'config');
138
+ const stampedAt = (await fs.stat(config)).mtimeMs;
139
+ await new Promise((resolve) => setTimeout(resolve, 20));
140
+
141
+ await svc.findAnyWorkspaceId();
142
+ await svc.findAnyWorkspaceId();
143
+
144
+ expect((await fs.stat(config)).mtimeMs).toBe(stampedAt);
145
+ });
146
+
147
+ it('stamps nothing on a deployment that has no token', async () => {
148
+ const repoDir = await anOldCloneWithNoHelper(OLD_DRAFT);
149
+ expect(await service(null).findAnyWorkspaceId()).toBe(encodeURIComponent(OLD_DRAFT));
150
+ expect(await helpersOf(repoDir)).toEqual([]);
151
+ });
152
+
153
+ it('still answers null when nothing is cloned', async () => {
154
+ expect(await service('a-token').findAnyWorkspaceId()).toBeNull();
155
+ });
156
+ });
@@ -75,18 +75,58 @@ function service(
75
75
  kbRepoUrl: () => string,
76
76
  setAsideRoot?: string,
77
77
  aroundSetAside?: (workspaceId: string, move: () => Promise<void>) => Promise<void>,
78
+ gitRunner: NodeGitRunner = new NodeGitRunner(),
78
79
  ) {
79
80
  return new WorkspaceService(
80
81
  workspacesRoot,
81
82
  kbRepoUrl,
82
83
  testKbContext({ branchModel: { defaultBranch: BRANCH, protectedBranches: [BRANCH] } }),
83
84
  new NodeFs(),
84
- new NodeGitRunner(),
85
+ gitRunner,
85
86
  setAsideRoot,
86
87
  aroundSetAside,
87
88
  );
88
89
  }
89
90
 
91
+ /**
92
+ * A git runner that puts two callers' reads of a working copy's address in ONE
93
+ * order, the one that matters: both read the copy that is there, and the
94
+ * second is handed its answer only once `afterTheFirstMove` has come.
95
+ *
96
+ * Two holds, because holding the second answer back is only half of it. The
97
+ * FIRST answer is held until the second read has happened, so the first caller
98
+ * cannot move the copy before the second has looked at it — otherwise the
99
+ * second would read whatever replaced it, and the stale answer this is about
100
+ * would never exist. Then the second answer is held until the first move has
101
+ * ended. Neither can wait on the other forever: the first caller is held in its
102
+ * read, so no move is in flight for the second to wait on, and it always reads.
103
+ * Everything else runs as it is.
104
+ */
105
+ function withTheSecondAddressReadHeldPastTheMove(afterTheFirstMove: Promise<void>): NodeGitRunner {
106
+ let reads = 0;
107
+ let secondHasRead!: () => void;
108
+ const onceTheSecondHasRead = new Promise<void>((resolve) => {
109
+ secondHasRead = resolve;
110
+ });
111
+ return new Proxy(new NodeGitRunner(), {
112
+ get(target, prop, receiver) {
113
+ if (prop !== 'run') return Reflect.get(target, prop, receiver);
114
+ return async (cwd: string, args: string[], opts?: never) => {
115
+ const answer = await target.run(cwd, args, opts);
116
+ const readsTheAddress = args[0] === 'config' && args.includes('--get') && args.includes('remote.origin.url');
117
+ if (!readsTheAddress) return answer;
118
+ const mine = ++reads;
119
+ if (mine === 1) await onceTheSecondHasRead;
120
+ if (mine === 2) {
121
+ secondHasRead();
122
+ await afterTheFirstMove;
123
+ }
124
+ return answer;
125
+ };
126
+ },
127
+ });
128
+ }
129
+
90
130
  const cloneDir = () => path.join(workspacesRoot, encodeURIComponent(BRANCH), 'knowledge-base');
91
131
 
92
132
  /** Every working copy under a set-aside root, as repo directories. */
@@ -307,6 +347,42 @@ describe('what surrounds the setting aside of one working copy', () => {
307
347
  expect(await setAside()).toHaveLength(1);
308
348
  expect(await fs.readFile(path.join(cloneDir(), 'marker.txt'), 'utf8')).toBe('new repository');
309
349
  });
350
+
351
+ /**
352
+ * The same two callers, in the order the test above only sometimes got. The
353
+ * second reads the old copy's address, and is handed the answer after the
354
+ * first has finished moving that copy — when nothing says a move is in
355
+ * flight any more, and what sits at the path is the fresh clone the first is
356
+ * making. It moved that too: a second set-aside, of a working copy of the
357
+ * RIGHT repository, taken from under the clone that was writing it.
358
+ */
359
+ it('moves it once when the second caller comes back with the old address after the move has ended', async () => {
360
+ const { replacement } = await onTheOldRepository();
361
+ let moves = 0;
362
+ let firstMoveEnded!: () => void;
363
+ const afterTheFirstMove = new Promise<void>((resolve) => {
364
+ firstMoveEnded = resolve;
365
+ });
366
+ const svc = service(
367
+ () => replacement,
368
+ undefined,
369
+ async (_workspaceId, move) => {
370
+ moves += 1;
371
+ await move();
372
+ firstMoveEnded();
373
+ },
374
+ withTheSecondAddressReadHeldPastTheMove(afterTheFirstMove),
375
+ );
376
+
377
+ const [first, second] = await Promise.all([svc.getOrCreateForBranch(BRANCH), svc.getOrCreateForBranch(BRANCH)]);
378
+
379
+ expect(moves).toBe(1);
380
+ expect(first.repoDir).toBe(second.repoDir);
381
+ expect(await setAside()).toHaveLength(1);
382
+ expect(await fs.readFile(path.join(cloneDir(), 'marker.txt'), 'utf8')).toBe('new repository');
383
+ // And the clone both were served is whole: its address is the new one.
384
+ expect((await git(cloneDir(), ['config', '--get', 'remote.origin.url'])).trim()).toBe(replacement);
385
+ });
310
386
  });
311
387
 
312
388
  describe('forgetClone', () => {
@@ -1181,6 +1181,63 @@ describe('WorkspaceService.unzipFile — per-entry write guard', () => {
1181
1181
  await expect(fs.stat(path.join(repoDir, 'out', 'blocked-dir'))).rejects.toThrow();
1182
1182
  });
1183
1183
 
1184
+ /**
1185
+ * An archive reader caps the inflation at the size the header DECLARES, but
1186
+ * only when that size is above zero: an entry declaring zero is inflated with
1187
+ * no cap. So "check the declared size, then read" let a few kilobytes expand
1188
+ * to whatever memory there was. Both headers are patched to say "empty" here,
1189
+ * over four megabytes that deflate to four kilobytes.
1190
+ */
1191
+ it('refuses an entry whose header says it is empty and whose stream is not, and extracts the rest', async () => {
1192
+ const { default: AdmZip } = await import('adm-zip');
1193
+ const zip = new AdmZip();
1194
+ zip.addFile('bomb.md', Buffer.alloc(4 * 1024 * 1024, 0x61));
1195
+ const bomb = zip.toBuffer();
1196
+ // The uncompressed-size field: 22 bytes into the local header, 24 into the
1197
+ // central-directory one.
1198
+ bomb.writeUInt32LE(0, bomb.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04])) + 22);
1199
+ bomb.writeUInt32LE(0, bomb.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])) + 24);
1200
+ await fs.writeFile(path.join(repoDir, 'bomb.zip'), bomb);
1201
+
1202
+ const res = await svc.unzipFile(workspaceId, 'bomb.zip', 'out');
1203
+
1204
+ expect(res.extracted).toEqual([]);
1205
+ expect(res.skipped).toHaveLength(1);
1206
+ expect(res.skipped[0]).toMatchObject({ path: 'bomb.md' });
1207
+ expect(res.skipped[0].reason).toContain('declares an empty file');
1208
+ await expect(fs.stat(path.join(repoDir, 'out', 'bomb.md'))).rejects.toThrow();
1209
+ });
1210
+
1211
+ it('extracts the other entries of an archive in which one fails its checksum', async () => {
1212
+ const { default: AdmZip } = await import('adm-zip');
1213
+ const zip = new AdmZip();
1214
+ zip.addFile('bad.md', Buffer.from('this entry will not match its checksum'));
1215
+ zip.addFile('fine.md', Buffer.from('fine'));
1216
+ const archive = zip.toBuffer();
1217
+ // The CRC field of the FIRST entry, in both of its headers: 14 bytes into
1218
+ // the local one, 16 into the central-directory one. A reader checks one or
1219
+ // the other depending on the entry's flags.
1220
+ const firstLocal = archive.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04]));
1221
+ const firstCentral = archive.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
1222
+ archive.writeUInt32LE((archive.readUInt32LE(firstLocal + 14) ^ 0xffffffff) >>> 0, firstLocal + 14);
1223
+ archive.writeUInt32LE((archive.readUInt32LE(firstCentral + 16) ^ 0xffffffff) >>> 0, firstCentral + 16);
1224
+ await fs.writeFile(path.join(repoDir, 'crc.zip'), archive);
1225
+
1226
+ const res = await svc.unzipFile(workspaceId, 'crc.zip', 'out');
1227
+
1228
+ expect(res.extracted).toEqual(['knowledge-base/out/fine.md']);
1229
+ expect(res.skipped).toHaveLength(1);
1230
+ expect(res.skipped[0].path).toBe('bad.md');
1231
+ expect(res.skipped[0].reason).toContain('could not be read');
1232
+ });
1233
+
1234
+ it('still extracts a file that really is empty', async () => {
1235
+ await writeZip('empty.zip', { 'empty.md': '', 'full.md': 'x' });
1236
+ const res = await svc.unzipFile(workspaceId, 'empty.zip', 'out');
1237
+ expect(res.extracted.sort()).toEqual(['knowledge-base/out/empty.md', 'knowledge-base/out/full.md']);
1238
+ expect((await fs.readFile(path.join(repoDir, 'out', 'empty.md'))).byteLength).toBe(0);
1239
+ });
1240
+
1184
1241
  it('does not create the destination directory when every entry is blocked', async () => {
1185
1242
  await writeZip('d.zip', { 'a.md': '1', 'b.md': '2' });
1186
1243
  const res = await svc.unzipFile(workspaceId, 'd.zip', 'out', async () => {
@@ -14,16 +14,19 @@ import { SpillStore } from '../spill-store.js';
14
14
  import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
15
15
  import { ToolDescriptionNotes } from '../../workspace/agent-access.gate.js';
16
16
  import { registerWorkspaceTools } from '../workspace.tools.js';
17
+ import { sharedFileRulesSection, sharedRulesPointer } from '../../agent-instructions/shared-file-rules.js';
17
18
 
18
19
  /**
19
20
  * What the platform TELLS AN AGENT about the conventions file, under the
20
21
  * default name and under a deployment's own.
21
22
  *
22
- * Descriptions only: the behaviour behind them is covered in
23
- * `workspace.tools.test.ts`, and what matters here is the text a remote agent
24
- * reads, which is the only place it learns that the organisation's own
25
- * `AGENTS.md` exists at all — it has no checkout, so no harness reads that file
26
- * for it.
23
+ * The conventions reminder and the platform-file list are SHARED RULES now —
24
+ * stated in the handshake instructions and in the managed agent guide, once
25
+ * each — so their wording is asserted against that one text. What each tool
26
+ * description still has to get right is the POINTER at the end, which names the
27
+ * guide by the name this deployment gave it; that is the only place a remote
28
+ * agent learns which file to open, and it has no checkout, so no harness reads
29
+ * that file for it.
27
30
  */
28
31
  const KB_DIR = 'knowledge-base';
29
32
  /** The context the tools read; a case applies a deployment's own names to it. */
@@ -61,21 +64,25 @@ async function listed(registry: ToolRegistry): Promise<Map<string, string>> {
61
64
 
62
65
  afterEach(() => kb.applyLayout({ ...DEFAULT_KB_LAYOUT }));
63
66
 
64
- describe('the conventions note every workspace tool carries', () => {
65
- it('names AGENTS.md, and CLAUDE.md beside it, under the default name', async () => {
67
+ describe("what names the guide, under the default name and under a deployment's own", () => {
68
+ it('names AGENTS.md, and CLAUDE.md beside it, under the default name', () => {
69
+ const rules = sharedFileRulesSection(kb.layout);
70
+ expect(rules).toContain(
71
+ 'read `AGENTS.md` at the KB root — or `CLAUDE.md` on a knowledge base seeded before it was renamed',
72
+ );
73
+ });
74
+
75
+ it("points every entrypoint at the section, by the guide's own name", async () => {
66
76
  const byName = await descriptions();
67
77
  // On EVERY entrypoint, reads included: any of them can be a session's first.
68
78
  for (const name of ['grep', 'list_files', 'file_stat', 'write_file', 'move_file']) {
69
- expect(byName.get(name), name).toContain(
70
- 'read `AGENTS.md` at the KB root — or `CLAUDE.md` on a knowledge base seeded before it was renamed',
71
- );
79
+ expect(byName.get(name), name).toContain('see "Working with files" in AGENTS.md.');
72
80
  }
73
81
  });
74
82
 
75
- it('names the configured file first and the organisation\'s own AGENTS.md second', async () => {
83
+ it("names the configured file first and the organisation's own AGENTS.md second", () => {
76
84
  kb.applyLayout({ ...DEFAULT_KB_LAYOUT, agentsFile: 'HEXIS.md' });
77
- const byName = await descriptions();
78
- const note = byName.get('grep') ?? '';
85
+ const note = sharedFileRulesSection(kb.layout);
79
86
  expect(note).toContain('read `HEXIS.md` at the KB root, then `AGENTS.md` if it also exists');
80
87
  expect(note).toContain("the organisation's own conventions");
81
88
  // Ours first: an agent that reads only one must read the platform's.
@@ -84,27 +91,23 @@ describe('the conventions note every workspace tool carries', () => {
84
91
  expect(note).toContain('`CLAUDE.md`');
85
92
  });
86
93
 
87
- it('describes the platform files under the configured name, and no longer under AGENTS.md', async () => {
94
+ it('lists the platform files under the configured name, and no longer under AGENTS.md', () => {
88
95
  kb.applyLayout({ ...DEFAULT_KB_LAYOUT, agentsFile: 'HEXIS.md' });
89
- const byName = await descriptions();
90
- const stat = byName.get('file_stat') ?? '';
91
- expect(stat).toContain('`access.md`, `roles.yaml`, `.bevelignore`, `HEXIS.md`');
92
-
93
- for (const name of ['delete_file', 'move_file']) {
94
- const text = byName.get(name) ?? '';
95
- expect(text, name).toContain('`roles.yaml` or `HEXIS.md` at the repository root');
96
- // The customer's file is content on such a deployment, so the rule that
97
- // refuses a move must not claim it.
98
- expect(text.replace(/`AGENTS\.md` if it also exists/g, ''), name).not.toContain(
99
- 'or `AGENTS.md` at the repository root',
100
- );
101
- }
96
+ const rules = sharedFileRulesSection(kb.layout);
97
+ // Listed with the depth each one counts at — `access.md` and
98
+ // `.bevelignore` govern the folder they sit in, the other two are read from
99
+ // the root — because the names alone would have an agent refuse a nested
100
+ // `HEXIS.md` it may rename.
101
+ expect(rules).toContain('`access.md` or `.bevelignore` in any folder, `roles.yaml` or `HEXIS.md` at the repository root');
102
+ // The customer's file is content on such a deployment, so the rule that
103
+ // refuses a move must not claim it.
104
+ expect(rules.replace(/`AGENTS\.md` if it also exists/g, '')).not.toContain('`AGENTS.md`');
102
105
  });
103
106
 
104
- it('keeps naming AGENTS.md as a platform file under the default name', async () => {
105
- const byName = await descriptions();
106
- expect(byName.get('file_stat')).toContain('`access.md`, `roles.yaml`, `.bevelignore`, `AGENTS.md`');
107
- expect(byName.get('delete_file')).toContain('`roles.yaml` or `AGENTS.md` at the repository root');
107
+ it('keeps naming AGENTS.md as a platform file under the default name', () => {
108
+ expect(sharedFileRulesSection(kb.layout)).toContain(
109
+ '`access.md` or `.bevelignore` in any folder, `roles.yaml` or `AGENTS.md` at the repository root',
110
+ );
108
111
  });
109
112
 
110
113
  /**
@@ -117,14 +120,14 @@ describe('the conventions note every workspace tool carries', () => {
117
120
  it('follows a layout applied after the tools were mounted', async () => {
118
121
  const registry = new ToolRegistry();
119
122
  const atMount = await descriptions(registry);
120
- expect(atMount.get('grep')).toContain('read `AGENTS.md` at the KB root');
123
+ expect(atMount.get('grep')).toContain('in AGENTS.md.');
121
124
 
122
125
  kb.applyLayout({ ...DEFAULT_KB_LAYOUT, agentsFile: 'HEXIS.md' });
123
126
 
124
127
  const now = await listed(registry);
125
- expect(now.get('grep')).toContain('read `HEXIS.md` at the KB root, then `AGENTS.md` if it also exists');
126
- expect(now.get('file_stat')).toContain('`access.md`, `roles.yaml`, `.bevelignore`, `HEXIS.md`');
127
- expect(now.get('delete_file')).toContain('`roles.yaml` or `HEXIS.md` at the repository root');
128
- expect(now.get('move_file')).toContain('`roles.yaml` or `HEXIS.md` at the repository root');
128
+ for (const name of ['grep', 'file_stat', 'delete_file', 'move_file']) {
129
+ expect(now.get(name), name).toContain(sharedRulesPointer(kb.layout));
130
+ expect(now.get(name), name).not.toContain('in AGENTS.md.');
131
+ }
129
132
  });
130
133
  });