@bevel-software/platform-core-backend 0.34.0 → 0.35.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 (179) hide show
  1. package/agent-guide/access-control.md +146 -300
  2. package/agent-guide/conventions.md +23 -25
  3. package/agent-guide/directory-structure.md +72 -120
  4. package/agent-guide/drafts-and-change-requests.md +92 -0
  5. package/agent-guide/for-git-users.md +47 -0
  6. package/agent-guide/glossary.md +36 -0
  7. package/agent-guide/how-agents-find-things.md +49 -0
  8. package/agent-guide/html-views.md +16 -11
  9. package/agent-guide/new-knowledge-base.md +9 -8
  10. package/agent-guide/skills.md +67 -42
  11. package/agent-guide/start-here.md +65 -0
  12. package/agent-guide/tool-manuals.md +132 -154
  13. package/agent-guide/tools.md +67 -0
  14. package/agent-guide/where-a-new-file-goes.md +45 -28
  15. package/dist/core/create-core-server.js +1 -1
  16. package/dist/core/create-core-server.js.map +1 -1
  17. package/dist/core/create-core-services.d.ts +6 -0
  18. package/dist/core/create-core-services.d.ts.map +1 -1
  19. package/dist/core/create-core-services.js +7 -4
  20. package/dist/core/create-core-services.js.map +1 -1
  21. package/dist/modules/access/access-control.interface.d.ts +22 -0
  22. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.d.ts +3 -1
  24. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  25. package/dist/modules/access/access-control.service.js +75 -0
  26. package/dist/modules/access/access-control.service.js.map +1 -1
  27. package/dist/modules/access/access.routes.d.ts.map +1 -1
  28. package/dist/modules/access/access.routes.js +23 -4
  29. package/dist/modules/access/access.routes.js.map +1 -1
  30. package/dist/modules/access/index.d.ts +1 -1
  31. package/dist/modules/access/index.d.ts.map +1 -1
  32. package/dist/modules/agent-guide/agent-guide.d.ts +47 -7
  33. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -1
  34. package/dist/modules/agent-guide/agent-guide.js +103 -13
  35. package/dist/modules/agent-guide/agent-guide.js.map +1 -1
  36. package/dist/modules/agent-guide/agent-guide.tools.d.ts +6 -2
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -1
  38. package/dist/modules/agent-guide/agent-guide.tools.js +45 -31
  39. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -1
  40. package/dist/modules/agent-guide/guide-budget.d.ts +48 -0
  41. package/dist/modules/agent-guide/guide-budget.d.ts.map +1 -0
  42. package/dist/modules/agent-guide/guide-budget.js +67 -0
  43. package/dist/modules/agent-guide/guide-budget.js.map +1 -0
  44. package/dist/modules/agent-guide/index.d.ts +2 -1
  45. package/dist/modules/agent-guide/index.d.ts.map +1 -1
  46. package/dist/modules/agent-guide/index.js +2 -1
  47. package/dist/modules/agent-guide/index.js.map +1 -1
  48. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  49. package/dist/modules/agent-instructions/compose.js +4 -0
  50. package/dist/modules/agent-instructions/compose.js.map +1 -1
  51. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  52. package/dist/modules/agent-instructions/shared-file-rules.js +10 -8
  53. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  54. package/dist/modules/code-mode/code-mode.tool.js +2 -2
  55. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  56. package/dist/modules/connection-probe/connection-probe.service.d.ts +0 -12
  57. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
  58. package/dist/modules/connection-probe/connection-probe.service.js +1 -38
  59. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
  60. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  61. package/dist/modules/mcp/mcp.service.js +6 -35
  62. package/dist/modules/mcp/mcp.service.js.map +1 -1
  63. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +11 -1
  64. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  65. package/dist/modules/secrets-vault/db-secrets-vault.service.js +159 -24
  66. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  67. package/dist/modules/secrets-vault/index.d.ts +1 -1
  68. package/dist/modules/secrets-vault/index.d.ts.map +1 -1
  69. package/dist/modules/secrets-vault/index.js +1 -1
  70. package/dist/modules/secrets-vault/index.js.map +1 -1
  71. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +53 -11
  72. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
  73. package/dist/modules/secrets-vault/secrets-vault.contract.js +27 -0
  74. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
  75. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  76. package/dist/modules/secrets-vault/secrets-vault.routes.js +85 -39
  77. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  78. package/dist/modules/settings/oidc-check.d.ts.map +1 -1
  79. package/dist/modules/settings/oidc-check.js +5 -17
  80. package/dist/modules/settings/oidc-check.js.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +1 -1
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-helpers/tool-def.js +1 -1
  84. package/dist/modules/tool-helpers/tool-def.js.map +1 -1
  85. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +7 -1
  86. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  87. package/dist/modules/tool-manuals/mcp-json-discovery.js +69 -38
  88. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +27 -0
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +12 -8
  92. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  93. package/dist/modules/tool-manuals/tool-manuals.service.js +115 -44
  94. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  95. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +8 -2
  96. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.tools.js +31 -10
  98. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  99. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  100. package/dist/modules/workflow/agent-tools/workflow.tools.js +22 -18
  101. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  102. package/dist/modules/workspace/agent-access.gate.d.ts +1 -1
  103. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -1
  104. package/dist/modules/workspace/agent-access.gate.js +1 -1
  105. package/dist/modules/workspace/agent-access.gate.js.map +1 -1
  106. package/dist/modules/workspace/workspace.tools.d.ts +3 -2
  107. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  108. package/dist/modules/workspace/workspace.tools.js +71 -21
  109. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  110. package/dist/shared/oauth-token-error.d.ts +23 -0
  111. package/dist/shared/oauth-token-error.d.ts.map +1 -0
  112. package/dist/shared/oauth-token-error.js +47 -0
  113. package/dist/shared/oauth-token-error.js.map +1 -0
  114. package/dist/shared/read-capped.d.ts +19 -0
  115. package/dist/shared/read-capped.d.ts.map +1 -0
  116. package/dist/shared/read-capped.js +45 -0
  117. package/dist/shared/read-capped.js.map +1 -0
  118. package/package.json +4 -3
  119. package/src/__tests__/hexis-catalog.ts +144 -0
  120. package/src/__tests__/real-disk.ts +21 -0
  121. package/src/__tests__/tokens.ts +19 -0
  122. package/src/__tests__/tool-manuals.ts +32 -0
  123. package/src/__tests__/tool-wiring.ts +25 -0
  124. package/src/core/__tests__/oauth-sign-in-real-file.test.ts +179 -0
  125. package/src/core/create-core-server.ts +1 -1
  126. package/src/core/create-core-services.ts +13 -3
  127. package/src/modules/access/__tests__/access.routes.prospective.test.ts +52 -12
  128. package/src/modules/access/__tests__/holder-changes.test.ts +127 -0
  129. package/src/modules/access/access-control.interface.ts +26 -0
  130. package/src/modules/access/access-control.service.ts +92 -0
  131. package/src/modules/access/access.routes.ts +23 -4
  132. package/src/modules/access/index.ts +1 -1
  133. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +79 -25
  134. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +38 -11
  135. package/src/modules/agent-guide/__tests__/guide-budget.test.ts +95 -0
  136. package/src/modules/agent-guide/__tests__/guide-eval.test.ts +182 -0
  137. package/src/modules/agent-guide/__tests__/guide-matches-code.test.ts +263 -0
  138. package/src/modules/agent-guide/__tests__/tool-surface.json +514 -0
  139. package/src/modules/agent-guide/__tests__/tool-surface.test.ts +75 -0
  140. package/src/modules/agent-guide/agent-guide.tools.ts +49 -31
  141. package/src/modules/agent-guide/agent-guide.ts +120 -13
  142. package/src/modules/agent-guide/guide-budget.ts +73 -0
  143. package/src/modules/agent-guide/index.ts +12 -0
  144. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +4 -4
  145. package/src/modules/agent-instructions/compose.ts +4 -0
  146. package/src/modules/agent-instructions/shared-file-rules.ts +10 -8
  147. package/src/modules/code-mode/code-mode.tool.ts +2 -2
  148. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +5 -24
  149. package/src/modules/connection-probe/connection-probe.service.ts +1 -34
  150. package/src/modules/mcp/mcp.service.ts +5 -35
  151. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +430 -3
  152. package/src/modules/secrets-vault/__tests__/oauth-live-declaration.route.test.ts +218 -0
  153. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +39 -6
  154. package/src/modules/secrets-vault/db-secrets-vault.service.ts +170 -27
  155. package/src/modules/secrets-vault/index.ts +1 -1
  156. package/src/modules/secrets-vault/secrets-vault.contract.ts +61 -11
  157. package/src/modules/secrets-vault/secrets-vault.routes.ts +91 -39
  158. package/src/modules/settings/oidc-check.ts +5 -18
  159. package/src/modules/skills/skills.tools.ts +1 -1
  160. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +19 -11
  161. package/src/modules/tool-helpers/__tests__/route-tool-schemas.catalog.test.ts +38 -0
  162. package/src/modules/tool-helpers/tool-def.ts +1 -1
  163. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +197 -11
  164. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +96 -5
  165. package/src/modules/tool-manuals/mcp-json-discovery.ts +73 -34
  166. package/src/modules/tool-manuals/tool-manuals.contract.ts +22 -0
  167. package/src/modules/tool-manuals/tool-manuals.service.ts +138 -46
  168. package/src/modules/tool-manuals/tool-manuals.tools.ts +53 -14
  169. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -113
  170. package/src/modules/workflow/agent-tools/workflow.tools.ts +22 -18
  171. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +8 -10
  172. package/src/modules/workspace/__tests__/workspace.tools.test.ts +156 -49
  173. package/src/modules/workspace/agent-access.gate.ts +1 -1
  174. package/src/modules/workspace/workspace.tools.ts +74 -21
  175. package/src/shared/__tests__/read-capped.test.ts +68 -0
  176. package/src/shared/oauth-token-error.ts +46 -0
  177. package/src/shared/read-capped.ts +40 -0
  178. package/agent-guide/finding-things.md +0 -7
  179. package/agent-guide/introduction.md +0 -27
@@ -249,6 +249,14 @@ const int = (description: string): JsonSchema => ({ type: 'integer', description
249
249
 
250
250
  const str = (description: string): JsonSchema => ({ type: 'string', description });
251
251
 
252
+ /**
253
+ * A write to the default branch is live at once, so the agent asks first. Said on the four
254
+ * tools behind 87 of the 91 writes there in the agent-guide benchmark (2026-10-26); `start-here`
255
+ * says it for the other write tools.
256
+ */
257
+ const ASK_BEFORE_DEFAULT_BRANCH =
258
+ ' On the default branch a write is live for everyone at once: unless the person already said where, ask them first whether to write there or propose it on a draft.';
259
+
252
260
  /**
253
261
  * The upload route, named on every tool that takes content as a JSON string.
254
262
  *
@@ -1113,8 +1121,9 @@ export function registerWorkspaceTools(
1113
1121
  */
1114
1122
  uploads?: AgentUploadStore,
1115
1123
  /**
1116
- * The platform's agent guide (see `modules/agent-guide`), which `read_file`
1117
- * and `file_stat` answer at the guide's name in the repository root — after
1124
+ * The start of the platform's agent guide and the list of its sections
1125
+ * (`guideStartText` in `modules/agent-guide`), which `read_file` and
1126
+ * `file_stat` answer at the guide's name in the repository root — after
1118
1127
  * the knowledge base's own file of that name, when it has one. Optional for
1119
1128
  * the harnesses that are about the file primitives; without it the two
1120
1129
  * tools read the disk and nothing else.
@@ -2010,13 +2019,13 @@ export function registerWorkspaceTools(
2010
2019
  const startSessionDef = toolDef({
2011
2020
  name: 'start_session',
2012
2021
  description:
2013
- 'Mint this conversation\'s id: the `sessionId` KnowledgeBase tools take. Call this ONCE, at the start of your work — minting a new id mid-run starts a second conversation as far as the server is concerned. The id is also a chat session in the app, so you can hand the SAME id to the `ask` tool: your reads and your questions are then one conversation. Pass the returned id as `sessionId` on every later KnowledgeBase tool call, inside `call_tool_chain` too. RETRYING IS SAFE: a call that fails created nothing, so retry it. If a retry lands after a success you hold two independent ids, which is harmless: keep passing the one you already used and ignore the other. Returns `{ sessionId }`, plus `firstRun` while the knowledge base is still empty: a note to act on.',
2022
+ 'Mint this conversation\'s id: the `sessionId` KnowledgeBase tools take. Call this ONCE, at the start of your work — minting a new id mid-run starts a second conversation as far as the server is concerned. Pass the returned id as `sessionId` to every tool whose arguments list it, inside `call_tool_chain` too; the others refuse it. Where the deployment offers an `ask` tool, hand it the SAME id: your reads and your questions are then one conversation. RETRYING IS SAFE: a call that fails created nothing, so retry it. If a retry lands after a success you hold two independent ids, which is harmless: keep passing the one you already used and ignore the other. Returns `{ sessionId }`, plus `firstRun` while the knowledge base is still empty: a note to act on.',
2014
2023
  path: '/api/agent/tools/start_session',
2015
2024
  inputs: { type: 'object', properties: {}, additionalProperties: false },
2016
2025
  outputs: {
2017
2026
  type: 'object',
2018
2027
  properties: {
2019
- sessionId: str('The minted session id — pass it as `sessionId` on subsequent KnowledgeBase tool calls and to `ask`.'),
2028
+ sessionId: str('The minted session id — pass it as `sessionId` to the tools that take one, and to `ask` where there is one.'),
2020
2029
  firstRun: str(
2021
2030
  `Present only while the knowledge base holds nothing but its starter guide (and a starter pack's untouched pages): what to offer the person (the guide's \`${FIRST_RUN_SECTION_ID}\` section says how).`,
2022
2031
  ),
@@ -2234,7 +2243,7 @@ export function registerWorkspaceTools(
2234
2243
  'Read a workspace file as text. Returns `{ path, content, canWrite }`. Per the shared content rule: text as ' +
2235
2244
  'text, documents and email as extracted text, an image as the picture, anything else as a one-line ' +
2236
2245
  'description. Also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: it ' +
2237
- 'belongs to no workspace, so `branch` is ignored and no `canWrite` comes back. `offset`/`limit` slice the ' +
2246
+ 'belongs to no workspace: any existing `branch` will do, and no `canWrite` comes back. `offset`/`limit` slice the ' +
2238
2247
  'content (characters; bytes for a spill ref; ignored for an image): page through large files and ' +
2239
2248
  'spills, never read multi-MB whole. `canWrite`: may you change this file directly on the default branch. ' +
2240
2249
  CAN_WRITE_CLAUSE,
@@ -2243,8 +2252,8 @@ export function registerWorkspaceTools(
2243
2252
  properties: {
2244
2253
  branch: BRANCH_INPUT,
2245
2254
  path: str(`Path to read, under \`${kbDirName}/\` (e.g. \`${kbDirName}/KnowledgeBase/Foo.md\`), with or without a leading slash — a path without that prefix is placed under \`${kbDirName}/\` — or a \`__tool_chain_spill__/…\` ref from a truncated \`call_tool_chain\`, or a \`__tmp__/…\` ref to a file you staged in the workspace's tmp folder.`),
2246
- offset: int('Start character index (default 0).'),
2247
- limit: int('Max characters to return from `offset`.'),
2255
+ offset: int('Start character index (bytes for a spill ref; default 0).'),
2256
+ limit: int('Max characters to return from `offset` (bytes for a spill ref).'),
2248
2257
  sessionId: SESSION_ID_INPUT,
2249
2258
  },
2250
2259
  required: ['branch', 'path'],
@@ -2919,6 +2928,7 @@ export function registerWorkspaceTools(
2919
2928
  description:
2920
2929
  'Write a workspace TEXT file. The change is committed + pushed as you. Returns `{ path, bytes, outcome }`, where `outcome` is ' +
2921
2930
  '`created`, `replaced` or `updated`.' +
2931
+ ASK_BEFORE_DEFAULT_BRANCH +
2922
2932
  UPLOAD_ROUTE_NOTE,
2923
2933
  inputs: {
2924
2934
  type: 'object',
@@ -3009,6 +3019,7 @@ export function registerWorkspaceTools(
3009
3019
  '`created` / `replaced` / `updated` for a path it wrote, or `refused` with `error` (the code) and `message` (why) for a ' +
3010
3020
  'path it could not. `count` is how many were written. A path it refuses — the mode said no, or the file is not text — ' +
3011
3021
  'does not stop the others; read `files` to see what landed.' +
3022
+ ASK_BEFORE_DEFAULT_BRANCH +
3012
3023
  UPLOAD_ROUTE_NOTE,
3013
3024
  inputs: {
3014
3025
  type: 'object',
@@ -3046,7 +3057,7 @@ export function registerWorkspaceTools(
3046
3057
  enum: ['created', 'replaced', 'updated', 'refused'],
3047
3058
  description: 'What happened at this path. `refused` means nothing was written there and the file is untouched.',
3048
3059
  },
3049
- error: str('Present when `outcome` is `refused`: the refusal code — `exists`, `missing` or `binary_not_writable`.'),
3060
+ error: str('Present when `outcome` is `refused`: the refusal code, such as `exists`, `missing`, `binary_not_writable` or a code of the deployment\'s own write check.'),
3050
3061
  message: str('Present when `outcome` is `refused`: the full refusal, the same one write_file would have given.'),
3051
3062
  },
3052
3063
  required: ['path', 'outcome'],
@@ -3181,6 +3192,7 @@ export function registerWorkspaceTools(
3181
3192
  gated: true,
3182
3193
  description:
3183
3194
  'Replace an exact string in a workspace TEXT file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you.' +
3195
+ ASK_BEFORE_DEFAULT_BRANCH +
3184
3196
  UPLOAD_ROUTE_NOTE,
3185
3197
  inputs: {
3186
3198
  type: 'object',
@@ -3260,7 +3272,8 @@ export function registerWorkspaceTools(
3260
3272
  name: 'delete_file',
3261
3273
  gated: true,
3262
3274
  description:
3263
- 'Delete ONE workspace file. Committed + pushed as you. Its folder stays, even when this was its last file. Files only: a folder is refused with a pointer to `delete_folder`.',
3275
+ 'Delete ONE workspace file. Committed + pushed as you. Its folder stays, even when this was its last file. Files only: a folder is refused with a pointer to `delete_folder`.' +
3276
+ ASK_BEFORE_DEFAULT_BRANCH,
3264
3277
  inputs: {
3265
3278
  type: 'object',
3266
3279
  properties: {
@@ -3484,10 +3497,11 @@ export function registerWorkspaceTools(
3484
3497
  description:
3485
3498
  'Move or rename a workspace FILE or FOLDER; a folder moves recursively, with everything under it. `dest` is the full new path, not the folder to move into. Committed + pushed as you. ' +
3486
3499
  'The destination must not exist — a move never overwrites a file or merges into a folder. Access follows the ' +
3487
- 'DESTINATION folder, so a move can change what you (and others) may do with the file: the dry run answers ' +
3488
- '`{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }`, where `access` is your ' +
3489
- 'own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the move has landed, ' +
3490
- 'with every `access.md` inside a moved folder counted at its new place, and a move whose `accessChanges` is true wants `confirm: true`. ' +
3500
+ 'DESTINATION folder, so a move can change who may open or edit the file. You must be able to read what moves and the folder it lands in, ' +
3501
+ 'or it is refused as read_file refuses. The dry run answers ' +
3502
+ '`{ src, dest, kind, descendants, access: { before, after }, accessChanges, holderChanges?, allowed, reason? }`: your own ' +
3503
+ 'access at both ends, and who else loses or gains it. A move that changes either wants `confirm: true`; tell the person ' +
3504
+ 'who loses and who gains before you confirm. ' +
3491
3505
  'Links are rewritten by default, in one commit with the move: those in the moved files and those in other markdown files pointing at them (`links` reports them). `rewriteLinks: false` turns that off. Path mentions in plain prose or code are never changed.',
3492
3506
  inputs: {
3493
3507
  type: 'object',
@@ -3496,7 +3510,7 @@ export function registerWorkspaceTools(
3496
3510
  src: wsPath(kbDirName, 'Source path (file or folder)'),
3497
3511
  dest: wsPath(kbDirName, 'Destination path — the full new path; must not exist yet'),
3498
3512
  dryRun: { type: 'boolean', description: 'Answer with the impact and change nothing.' },
3499
- confirm: { type: 'boolean', description: 'Required when the move changes your access. Set it only after a dry run.' },
3513
+ confirm: { type: 'boolean', description: 'Required when the move changes your access or anyone else\'s. Set it only after a dry run.' },
3500
3514
  rewriteLinks: { type: 'boolean', description: 'Rewrite the links into, out of and between the moved files (default true). `false` moves without touching any link.' },
3501
3515
  sessionId: SESSION_ID_INPUT,
3502
3516
  },
@@ -3510,8 +3524,14 @@ export function registerWorkspaceTools(
3510
3524
  dest: str('Destination path (echoes the input).'),
3511
3525
  kind: str('`file` or `folder`.'),
3512
3526
  descendants: int('Files that move: 1 for a file, the file count under a folder.'),
3513
- access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and at the destination once the move has landed (`after`).' },
3527
+ access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and at the destination once the move has landed (`after`), with every `access.md` inside a moved folder counted at its new place.' },
3514
3528
  accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after`.' },
3529
+ holderChanges: {
3530
+ type: 'object',
3531
+ description:
3532
+ 'Who loses and who gains opening (`read`) or editing (`write`) it once the move has landed: `{ lose, gain }`, each ' +
3533
+ '`[{ kind, name, email?, verb }]` with `kind` `group`, `role`, `plugin` or `person`. Absent when nobody\'s access changes.',
3534
+ },
3515
3535
  allowed: { type: 'boolean', description: 'Whether the move may run.' },
3516
3536
  reason: str('Why it may not, when `allowed` is false.'),
3517
3537
  dryRun: { type: 'boolean', description: 'True on a dry run.' },
@@ -3541,6 +3561,23 @@ export function registerWorkspaceTools(
3541
3561
  // source already deleted.
3542
3562
  await assertAgentWriteAllowed(agentAccessGate, ctx, branch, src);
3543
3563
  await assertAgentWriteAllowed(agentAccessGate, ctx, branch, dest);
3564
+ // What moves and the folder it lands in must both be readable to the
3565
+ // caller, refused as read_file refuses them, on every branch: a move
3566
+ // preview reads both ends (the caller's own verdicts there, who else is
3567
+ // affected), and the folder's rules are what the names are made of. The
3568
+ // verdict comes BEFORE every other look, so neither end's existence is
3569
+ // told to someone who may not know it. The deployment's read hook is
3570
+ // asked first, as read_file asks it, and its refusal is the answer: a
3571
+ // move the hook would not let read is not previewed at all, since even
3572
+ // "this move asks for confirm" would tell what the comparison found. A
3573
+ // folder the caller may read moves with everything in it, files they
3574
+ // may not read included: those travel, they are not read.
3575
+ const destFolder = dest.slice(0, dest.lastIndexOf('/'));
3576
+ await notifyAgentRead(agentAccessGate, ctx, branch, src);
3577
+ await notifyAgentRead(agentAccessGate, ctx, branch, destFolder);
3578
+ const gate = readGateFor(branch, ctx);
3579
+ await assertCanRead(gate, src);
3580
+ await assertCanRead(gate, destFolder);
3544
3581
  const fs = await ctx.getFilesystem(branch);
3545
3582
  const root = await workspaceRoot(branch, ctx);
3546
3583
  // Before the kind check, which stats THROUGH a link: a dangling link at
@@ -3564,6 +3601,17 @@ export function registerWorkspaceTools(
3564
3601
  accessAfter(branch, ctx, src, dest),
3565
3602
  ]);
3566
3603
  const accessChanges = verbsDiffer(before, after);
3604
+ // Who ELSE gains or loses opening or editing it: the lists the app's move
3605
+ // confirmation shows people. The caller may read both ends (the hook and
3606
+ // the rules, gated above, as the app's route gates), so the names go to
3607
+ // them, and a change asks for `confirm`. Outside the repository no rules
3608
+ // travel.
3609
+ const fromRel = toKbRelative(src, kbDirName);
3610
+ const toRel = toKbRelative(dest, kbDirName);
3611
+ const changes = fromRel !== null && toRel !== null
3612
+ ? await accessControl.prospectiveHolderChanges(workspaceIdForBranch(branch), fromRel, toRel)
3613
+ : undefined;
3614
+ const othersChange = changes !== undefined && (changes.lose.length > 0 || changes.gain.length > 0);
3567
3615
  // The placeholder moves with its folder, but it is never content.
3568
3616
  const descendants = srcFiles.filter((f) => !isFolderPlaceholder(f)).length;
3569
3617
  // Neither end may be the platform's own: a move neither takes a platform
@@ -3671,6 +3719,7 @@ export function registerWorkspaceTools(
3671
3719
  descendants,
3672
3720
  access: { before, after },
3673
3721
  accessChanges,
3722
+ ...(othersChange ? { holderChanges: changes } : {}),
3674
3723
  allowed: moveReason === undefined,
3675
3724
  ...(moveReason !== undefined ? { reason: moveReason } : {}),
3676
3725
  ...(linkPlan ? { links: linkPlan.report } : {}),
@@ -3686,12 +3735,16 @@ export function registerWorkspaceTools(
3686
3735
  }
3687
3736
  if (collision) throw new ToolError(reason!, 409);
3688
3737
  if (linkPlan?.overCap !== undefined) throw new ToolError(linkPlan.overCap, 400);
3689
- if (accessChanges && a.confirm !== true) {
3738
+ if ((accessChanges || othersChange) && a.confirm !== true) {
3739
+ const why = [
3740
+ ...(accessChanges ? [`your access at "${dest}" differs from "${src}"`] : []),
3741
+ ...(othersChange ? ['it changes who else may open or edit it (`holderChanges`)'] : []),
3742
+ ];
3690
3743
  return {
3691
3744
  ...impact,
3692
3745
  confirmationRequired: true,
3693
3746
  moved: false,
3694
- message: `Nothing was moved: your access at "${dest}" differs from "${src}", so this move requires confirm: true.`,
3747
+ message: `Nothing was moved: ${why.join(', and ')}, so this move requires confirm: true.`,
3695
3748
  };
3696
3749
  }
3697
3750
  // The look above is what produces the sentence; the filesystem's own
@@ -3741,8 +3794,8 @@ export function registerWorkspaceTools(
3741
3794
  gated: true,
3742
3795
  description:
3743
3796
  'Copy a workspace FILE to a new path. The destination must not exist — like a move, a copy never overwrites a file or a folder; to change what is in a file that already exists, write it. Committed + pushed as you. `src` may be a `__tmp__/<path>` ref to a file you staged in the workspace\'s tmp folder: the copy is how such a file is kept, as a normal repository file. ' +
3744
- 'Preflight first: `dryRun: true` changes nothing and answers `{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }` — `access` is your own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the copy has landed, with every `access.md` inside a copied folder counted at its new place. ' +
3745
- 'One exception to that shape: when the destination is one you may not write, the answer is the refusal alone — `allowed: false` with `reason`, and no `kind` and no `descendants`, because nothing about the source is read before that verdict.',
3797
+ 'Preflight first: `dryRun: true` changes nothing and answers `{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }` — `access` is your own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the copy has landed, with the copy\'s own rules counted at its new place. ' +
3798
+ 'One exception to that shape: when the destination is one you may not write, the answer is the refusal (`allowed: false` with `reason`, and the access figures) with no `kind` and no `descendants`, because nothing about the source is read before that verdict.',
3746
3799
  inputs: {
3747
3800
  type: 'object',
3748
3801
  properties: {
@@ -4203,7 +4256,7 @@ export function registerWorkspaceTools(
4203
4256
  '`replaced` / `updated`, or `refused` with `error` (the code) and `message` (why). `count` is how many landed and ' +
4204
4257
  '`total` how many paths there were; `files` is cut to the first 25 unless you pass `all: true`. ' +
4205
4258
  'Every path is judged one by one — by your write access, the platform-file rules and what is already there — ' +
4206
- 'exactly as `write_file` judges it, and a refused path does not stop the others. ' +
4259
+ 'as `write_file` judges it, except that platform files are refused and binary content is fine; a refused path does not stop the others. ' +
4207
4260
  'The token is single-use: it is spent by the apply that lands it, and refused if you use it twice, let it ' +
4208
4261
  'expire, or present one issued to somebody else. `mode` means what it means on `write_file`.',
4209
4262
  inputs: {
@@ -4287,7 +4340,7 @@ export function registerWorkspaceTools(
4287
4340
  'each one inside a folder too, is included only if you may read AND download that file. Returns ' +
4288
4341
  '`{ expiresAt, expiresInSeconds, files: [{ path, bytes, sha256, downloadUrl }], folders: [{ path, bytes, ' +
4289
4342
  'downloadUrl, files }], refused: [{ path, reason }] }`: a link per file, with its own content type, and per ' +
4290
- `folder a zip at full repository paths (\`apply_file_upload\` it at \`${kbDirName}/\` to put every file back). ` +
4343
+ `folder a zip at full repository paths (\`apply_file_upload\` it at \`${kbDirName}/\` to put them back). ` +
4291
4344
  'Fetch with any HTTP client (`curl -o <name> "<downloadUrl>"`, or the address without its last segment and an ' +
4292
4345
  '`x-download-token` header). Each link works ONCE, for 15 minutes, and serves the files as they are now. ' +
4293
4346
  'Refused: `not found` (missing or named-but-unreadable; folders omit unreadable files), ' +
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readCapped } from '../read-capped.js';
3
+
4
+ describe('readCapped', () => {
5
+ /**
6
+ * Counting a chunk against the budget after decoding it lets a provider that
7
+ * answers in one huge chunk put the whole thing through the decoder — the
8
+ * bound bypassed by exactly the party it exists to bound. Asserted on the
9
+ * reader directly because a caller's short snippet is identical either way.
10
+ */
11
+ it('holds one oversized chunk to the byte budget, not just the loop', async () => {
12
+ const oneHugeChunk = new ReadableStream<Uint8Array>({
13
+ start(controller) {
14
+ controller.enqueue(new TextEncoder().encode('x'.repeat(5_000_000)));
15
+ controller.close();
16
+ },
17
+ });
18
+
19
+ const out = await readCapped(new Response(oneHugeChunk), 8 * 1024);
20
+
21
+ expect(out.length).toBe(8 * 1024);
22
+ });
23
+
24
+ it('stops pulling at the budget and cancels a stream that never ends', async () => {
25
+ // Without the cap this never returns: the stream has no end.
26
+ let pulls = 0;
27
+ let cancelled = false;
28
+ const endless = new ReadableStream<Uint8Array>({
29
+ pull(controller) {
30
+ pulls += 1;
31
+ controller.enqueue(new TextEncoder().encode('z'.repeat(1024)));
32
+ },
33
+ cancel() {
34
+ cancelled = true;
35
+ },
36
+ });
37
+
38
+ const out = await readCapped(new Response(endless), 2048);
39
+
40
+ expect(out).toBe('z'.repeat(2048));
41
+ expect(pulls).toBe(2);
42
+ expect(cancelled).toBe(true);
43
+ });
44
+
45
+ it('returns at once on a body of exactly the budget whose stream stays open', async () => {
46
+ // Asking for one more byte here would wait on a chunk that never comes.
47
+ let pulls = 0;
48
+ const openAfterFill = new ReadableStream<Uint8Array>({
49
+ pull(controller) {
50
+ pulls += 1;
51
+ if (pulls === 1) {
52
+ controller.enqueue(new TextEncoder().encode('a'.repeat(8)));
53
+ return undefined;
54
+ }
55
+ return new Promise<void>(() => {});
56
+ },
57
+ });
58
+ expect(await readCapped(new Response(openAfterFill), 8)).toBe('a'.repeat(8));
59
+ expect(pulls).toBe(1);
60
+ });
61
+
62
+ it('reads a stream-less response whole, cut to the budget', async () => {
63
+ const res = new Response('abcdef');
64
+ // Consume the stream so only `text()` is left, as a mocked response may be.
65
+ Object.defineProperty(res, 'body', { value: null });
66
+ expect(await readCapped(res, 4)).toBe('abcd');
67
+ });
68
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The error codes a token endpoint may answer with, as RFC 6749 §5.2 and OIDC
3
+ * Core §3.1.2.6 define them. A finite list, on purpose: a code is taken from
4
+ * a refusing provider's body only when it is exactly one of these, so no other
5
+ * string a provider puts in that field (a secret, a secret with a suffix, free
6
+ * text) reaches a message, a verdict or a log.
7
+ *
8
+ * Shared, not a module's own: the OIDC configuration check reads a provider's
9
+ * refusal through it and the secrets vault a token endpoint's, and one list is
10
+ * the only way the two cannot disagree about which codes are standard. Each
11
+ * caller keeps its own sentence per code (what a tool owner should do differs
12
+ * from what a sign-in administrator should do); the set of codes is one.
13
+ */
14
+ export const OAUTH_TOKEN_ERROR_CODES: readonly string[] = Object.freeze([
15
+ 'invalid_request',
16
+ 'invalid_client',
17
+ 'invalid_grant',
18
+ 'unauthorized_client',
19
+ 'unsupported_grant_type',
20
+ 'invalid_scope',
21
+ 'access_denied',
22
+ 'server_error',
23
+ 'temporarily_unavailable',
24
+ ]);
25
+
26
+ /** Whether `code` is one of the standard token-endpoint error codes, and so safe to echo. */
27
+ export function isStandardTokenErrorCode(code: unknown): code is string {
28
+ return typeof code === 'string' && OAUTH_TOKEN_ERROR_CODES.includes(code);
29
+ }
30
+
31
+ /**
32
+ * The standard `error` code in a refusing token endpoint's JSON body, or null:
33
+ * when the body is not JSON, not an object, carries no string `error`, or
34
+ * carries one that is not a standard code.
35
+ */
36
+ export function standardTokenErrorCode(bodyText: string): string | null {
37
+ let json: unknown;
38
+ try {
39
+ json = JSON.parse(bodyText);
40
+ } catch {
41
+ return null;
42
+ }
43
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return null;
44
+ const { error } = json as Record<string, unknown>;
45
+ return isStandardTokenErrorCode(error) ? error : null;
46
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * At most `limit` bytes of a response body, decoded as text, the rest of the
3
+ * stream cancelled.
4
+ *
5
+ * Each CHUNK is sliced to the remaining budget before being decoded, not just
6
+ * counted against it: a provider that answers in one 5MB chunk would otherwise
7
+ * put the whole thing through the decoder before the loop noticed it was over,
8
+ * which is the bound bypassed by the very party it is meant to bound. Never a
9
+ * byte past the limit: once the budget is spent the read stops without asking
10
+ * for more, so a body of exactly `limit` bytes on a stream that stays open
11
+ * returns at once. And the read never falls back to `res.text()` on a live
12
+ * stream: a cap applied after that read bounds nothing.
13
+ *
14
+ * Shared, not a module's own: the connection probe reads a provider's
15
+ * rejection with it and the secrets vault a token endpoint's refusal, and one
16
+ * reader is the only way the two cannot disagree about what "bounded" means.
17
+ */
18
+ export async function readCapped(res: Response, limit: number): Promise<string> {
19
+ const reader = res.body?.getReader();
20
+ // No stream (a mocked or already-consumed response): fall back to the whole
21
+ // body, which for those cases is ours and small.
22
+ if (!reader) return (await res.text()).slice(0, limit);
23
+ const decoder = new TextDecoder();
24
+ let out = '';
25
+ let read = 0;
26
+ try {
27
+ while (read < limit) {
28
+ const { done, value } = await reader.read();
29
+ if (done) break;
30
+ const remaining = limit - read;
31
+ const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value;
32
+ read += chunk.byteLength;
33
+ out += decoder.decode(chunk, { stream: true });
34
+ if (chunk.byteLength < value.byteLength) break;
35
+ }
36
+ } finally {
37
+ await reader.cancel().catch(() => {});
38
+ }
39
+ return out;
40
+ }
@@ -1,7 +0,0 @@
1
- ## Finding things
2
-
3
- - `grep` for keywords across `{{knowledgeBaseDir}}/`.
4
- - Follow markdown links: when you read `[Some Page](relative/path/Some Page.md)`,
5
- that path is relative to the file you are reading.
6
- - `list_files` to see the shape of a folder before assuming where something
7
- lives.
@@ -1,27 +0,0 @@
1
- # Knowledge base
2
-
3
- This is a git-backed knowledge base. You are the primary agent responsible for
4
- maintaining it.
5
-
6
- > **This guide is served by the platform.** It is not a file in the
7
- > repository: `get_agent_guide` returns it, whole or one section at a time,
8
- > and so does `read_file` on `AGENTS.md` at the repository root — after the
9
- > knowledge base's own `AGENTS.md`, when it has one; `grep` searches it there
10
- > too. An `AGENTS.md` you find on disk is the organisation's own conventions
11
- > file, written by its people; follow it, and never write this text into it.
12
- > Deployment- or team-specific conventions belong there, or in files of your
13
- > own anywhere under `{{knowledgeBaseDir}}/`, linked from wherever they are
14
- > needed.
15
-
16
- **Read `mcp-description.md` at the repository root first.** It says what this
17
- knowledge base contains and when to consult it. Agents connected over MCP
18
- receive the default branch's copy inline at the start of every session; a
19
- clone reads the copy on its own branch.
20
-
21
- **There is no required format for knowledge.** Write markdown the way the
22
- subject wants to be written: prose, tables, checklists, diagrams, whatever
23
- serves the reader. Nothing here parses your files into a schema or rejects a
24
- document for having the wrong shape. If a deployment layers a structured
25
- knowledge graph on top, it brings its own conventions, in sections of this
26
- guide of its own; what follows describes the platform underneath, which stores
27
- files and controls who may change them.