@bevel-software/platform-core-backend 0.21.0 → 0.22.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 (177) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +32 -2
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +13 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +51 -5
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/modules/access/access-requests.contract.d.ts +75 -0
  9. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  10. package/dist/modules/access/access-requests.contract.js +20 -0
  11. package/dist/modules/access/access-requests.contract.js.map +1 -0
  12. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  13. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  14. package/dist/modules/access/access-requests.routes.js +237 -0
  15. package/dist/modules/access/access-requests.routes.js.map +1 -0
  16. package/dist/modules/access/access-requests.service.d.ts +123 -0
  17. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  18. package/dist/modules/access/access-requests.service.js +337 -0
  19. package/dist/modules/access/access-requests.service.js.map +1 -0
  20. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +24 -0
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/auth/auth.service.d.ts +26 -1
  25. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  26. package/dist/modules/auth/auth.service.js +11 -2
  27. package/dist/modules/auth/auth.service.js.map +1 -1
  28. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  29. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  30. package/dist/modules/github-app/github-app.client.js +222 -0
  31. package/dist/modules/github-app/github-app.client.js.map +1 -0
  32. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  33. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  34. package/dist/modules/github-app/github-app.connection.js +185 -0
  35. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  36. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  37. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  38. package/dist/modules/github-app/github-app.routes.js +323 -0
  39. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  40. package/dist/modules/github-app/index.d.ts +4 -0
  41. package/dist/modules/github-app/index.d.ts.map +1 -0
  42. package/dist/modules/github-app/index.js +4 -0
  43. package/dist/modules/github-app/index.js.map +1 -0
  44. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  45. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  46. package/dist/modules/kb-fs/remote-url.js +35 -0
  47. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  48. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  49. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  50. package/dist/modules/plugins/join-proposals.js +76 -22
  51. package/dist/modules/plugins/join-proposals.js.map +1 -1
  52. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  53. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  54. package/dist/modules/plugins/join-requests.service.js +173 -29
  55. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  56. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  57. package/dist/modules/plugins/plugins.routes.js +3 -2
  58. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  59. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  60. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  61. package/dist/modules/settings/deployment-settings.service.js +93 -4
  62. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  63. package/dist/modules/settings/managed-repository.d.ts +43 -0
  64. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  65. package/dist/modules/settings/managed-repository.js +60 -0
  66. package/dist/modules/settings/managed-repository.js.map +1 -0
  67. package/dist/modules/settings/repository-source.d.ts +128 -0
  68. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  69. package/dist/modules/settings/repository-source.js +150 -0
  70. package/dist/modules/settings/repository-source.js.map +1 -0
  71. package/dist/modules/settings/setup.routes.d.ts +27 -4
  72. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  73. package/dist/modules/settings/setup.routes.js +187 -13
  74. package/dist/modules/settings/setup.routes.js.map +1 -1
  75. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  76. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  77. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  78. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  79. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  80. package/dist/modules/tool-helpers/tool-context.js +15 -0
  81. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  82. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  83. package/dist/modules/workflow/agent-tools/workflow.tools.js +38 -2
  84. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  85. package/dist/modules/workflow/git/git.service.d.ts +3 -1
  86. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  87. package/dist/modules/workflow/git/git.service.js +17 -4
  88. package/dist/modules/workflow/git/git.service.js.map +1 -1
  89. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  90. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  91. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  92. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  93. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  94. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  95. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  96. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  97. package/dist/modules/workflow/workflow.routes.js +3 -1
  98. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  99. package/dist/modules/workflow/workflow.service.d.ts +34 -7
  100. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  101. package/dist/modules/workflow/workflow.service.js +148 -48
  102. package/dist/modules/workflow/workflow.service.js.map +1 -1
  103. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +66 -0
  104. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  105. package/dist/modules/workspace/startup/kb-startup-runner.js +148 -0
  106. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  107. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  108. package/dist/modules/workspace/workspace.service.js +17 -1
  109. package/dist/modules/workspace/workspace.service.js.map +1 -1
  110. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  111. package/dist/modules/workspace/workspace.tools.js +28 -2
  112. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  113. package/dist/shared/domain-errors.d.ts +39 -0
  114. package/dist/shared/domain-errors.d.ts.map +1 -1
  115. package/dist/shared/domain-errors.js +55 -0
  116. package/dist/shared/domain-errors.js.map +1 -1
  117. package/dist/shared/git.contract.d.ts +10 -0
  118. package/dist/shared/git.contract.d.ts.map +1 -1
  119. package/dist/shared/git.contract.js.map +1 -1
  120. package/package.json +3 -3
  121. package/src/core/create-core-server.ts +37 -1
  122. package/src/core/create-core-services.ts +65 -8
  123. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  124. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  125. package/src/modules/access/access-requests.contract.ts +93 -0
  126. package/src/modules/access/access-requests.routes.ts +286 -0
  127. package/src/modules/access/access-requests.service.ts +420 -0
  128. package/src/modules/access-model/access-grammar.ts +22 -0
  129. package/src/modules/auth/__tests__/auth.service.test.ts +38 -0
  130. package/src/modules/auth/auth.service.ts +28 -1
  131. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  132. package/src/modules/github-app/github-app.client.ts +268 -0
  133. package/src/modules/github-app/github-app.connection.ts +204 -0
  134. package/src/modules/github-app/github-app.routes.ts +359 -0
  135. package/src/modules/github-app/index.ts +19 -0
  136. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  137. package/src/modules/kb-fs/remote-url.ts +35 -0
  138. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  139. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  140. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  141. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  142. package/src/modules/plugins/join-proposals.ts +87 -19
  143. package/src/modules/plugins/join-requests.service.ts +199 -39
  144. package/src/modules/plugins/plugins.routes.ts +3 -2
  145. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  146. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +353 -0
  147. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +282 -0
  148. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +233 -0
  149. package/src/modules/settings/deployment-settings.service.ts +109 -3
  150. package/src/modules/settings/managed-repository.ts +68 -0
  151. package/src/modules/settings/repository-source.ts +197 -0
  152. package/src/modules/settings/setup.routes.ts +214 -10
  153. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  154. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  155. package/src/modules/tool-helpers/tool-context.ts +15 -0
  156. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  157. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  158. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  159. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +141 -1
  160. package/src/modules/workflow/agent-tools/workflow.tools.ts +49 -3
  161. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  162. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  163. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  164. package/src/modules/workflow/git/git.service.ts +25 -4
  165. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  166. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  167. package/src/modules/workflow/workflow.routes.ts +3 -1
  168. package/src/modules/workflow/workflow.service.ts +170 -52
  169. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  170. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +233 -7
  171. package/src/modules/workspace/__tests__/workspace.tools.test.ts +35 -3
  172. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +211 -0
  173. package/src/modules/workspace/startup/kb-startup-runner.ts +159 -0
  174. package/src/modules/workspace/workspace.service.ts +17 -1
  175. package/src/modules/workspace/workspace.tools.ts +35 -0
  176. package/src/shared/domain-errors.ts +58 -0
  177. package/src/shared/git.contract.ts +10 -0
@@ -1,11 +1,15 @@
1
1
  import type { Server as HttpServer } from 'node:http';
2
2
  import { execFile } from 'node:child_process';
3
- import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import express from 'express';
8
8
  import { afterEach, describe, expect, it } from 'vitest';
9
+ import '@utcp/http'; // registers the http CallTemplate serializer + protocol
10
+ import { UtcpManualSerializer } from '@utcp/sdk';
11
+ import { HttpCallTemplateSerializer } from '@utcp/http';
12
+ import { CodeModeUtcpClient } from '../../code-mode/index.js';
9
13
  import { testKbContext } from '../../../__tests__/kb-context.js';
10
14
  import { NodeFs } from '../../kb-fs/node-fs.js';
11
15
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
@@ -86,7 +90,12 @@ describe('workspace tools — a branch that cannot be opened', () => {
86
90
  * real tool-context resolver — so a tool call clones (or fails to clone)
87
91
  * exactly as it does in production.
88
92
  */
89
- async function start(opts: { repoUrl?: 'origin' | 'unreachable' } = {}): Promise<string> {
93
+ /** The tool defs the mounted registry serves, kept for the sweep below. */
94
+ let internalTools: { name: string; inputs: unknown }[] = [];
95
+
96
+ async function start(
97
+ opts: { repoUrl?: 'origin' | 'unreachable'; scope?: 'read' | 'write'; source?: 'internal' | 'external' } = {},
98
+ ): Promise<string> {
90
99
  root = await mkdtemp(join(tmpdir(), 'ws-tools-branch-'));
91
100
  spillRoot = await mkdtemp(join(tmpdir(), 'ws-tools-spill-'));
92
101
  docCacheDir = await mkdtemp(join(tmpdir(), 'ws-tools-doc-'));
@@ -128,11 +137,33 @@ describe('workspace tools — a branch that cannot be opened', () => {
128
137
  const app = express();
129
138
  app.use(express.json());
130
139
  const router = express.Router();
140
+ const registry = new ToolRegistry();
141
+ // The internal discovery endpoint, exactly as `manual.routes` serves it —
142
+ // what a real `CodeModeUtcpClient` reads to learn the tools it can call
143
+ // from inside a chain. Served here so the chain test below drives the SAME
144
+ // routes as every direct call in this file.
145
+ router.get('/agent/internal/utcp', async (_req, res) => {
146
+ res.json(
147
+ new UtcpManualSerializer().validateDict({
148
+ utcp_version: '1.1.0',
149
+ manual_version: '1.0.0',
150
+ tools: await registry.listInternal(),
151
+ }),
152
+ );
153
+ });
131
154
  registerWorkspaceTools(
132
- new ToolRegistry(),
155
+ registry,
133
156
  router,
134
157
  (req: express.Request, _res: express.Response, next: express.NextFunction) => {
135
- req.toolAuth = { source: 'external', userId: 'u', scope: 'read' } as ToolAuth;
158
+ req.toolAuth = {
159
+ // The sweep below asks for `internal` + `write`, which is what reaches
160
+ // the internal-only tools and gets past the write-scope refusal that
161
+ // sits in FRONT of every handler. Everything else in this file stays
162
+ // on the external read surface the bug was reported from.
163
+ source: opts.source ?? 'external',
164
+ userId: 'u',
165
+ scope: opts.scope ?? 'read',
166
+ } as ToolAuth;
136
167
  next();
137
168
  },
138
169
  createToolHandlerFactory(resolve),
@@ -151,21 +182,34 @@ describe('workspace tools — a branch that cannot be opened', () => {
151
182
  {} as never,
152
183
  );
153
184
  app.use('/api', router);
185
+ internalTools = (await registry.listInternal()) as unknown as { name: string; inputs: unknown }[];
154
186
  httpServer = await new Promise<HttpServer>((r) => {
155
187
  const s = app.listen(0, () => r(s));
156
188
  });
157
189
  return `http://127.0.0.1:${(httpServer.address() as { port: number }).port}`;
158
190
  }
159
191
 
160
- const listFiles = async (base: string, branch: string): Promise<{ status: number; body: Record<string, unknown> }> => {
161
- const res = await fetch(`${base}/api/agent/tools/list_files`, {
192
+ /** Call a tool with an arbitrary body — including one that omits `branch`. */
193
+ const callTool = async (
194
+ base: string,
195
+ tool: string,
196
+ body: unknown,
197
+ ): Promise<{ status: number; body: Record<string, unknown> }> => {
198
+ const res = await fetch(`${base}/api/agent/tools/${tool}`, {
162
199
  method: 'POST',
163
200
  headers: { 'content-type': 'application/json', authorization: 'Bearer x' },
164
- body: JSON.stringify({ branch, path: '' }),
201
+ body: JSON.stringify(body),
165
202
  });
166
203
  return { status: res.status, body: (await res.json()) as Record<string, unknown> };
167
204
  };
168
205
 
206
+ const listFiles = async (base: string, branch: string): Promise<{ status: number; body: Record<string, unknown> }> =>
207
+ callTool(base, 'list_files', { branch, path: '' });
208
+
209
+ /** Every entry currently under the workspaces root. */
210
+ const workspaceDirs = async (): Promise<string[]> =>
211
+ (await readdir(join(root, 'workspaces'))).sort();
212
+
169
213
  const deleteOnHost = (branch: string): Promise<void> =>
170
214
  runGit(join(root, 'upstream.git'), ['branch', '-D', branch]);
171
215
 
@@ -182,6 +226,188 @@ describe('workspace tools — a branch that cannot be opened', () => {
182
226
  });
183
227
  });
184
228
 
229
+ /**
230
+ * The reported bug, from the outside: every tool call made without a
231
+ * `branch` answered `Branch "undefined" no longer exists on the remote.` —
232
+ * the missing input was carried all the way down, turned into a workspace
233
+ * directory named `undefined`, and the failed clone of that "branch" was
234
+ * then explained as a branch that had been deleted.
235
+ *
236
+ * The input is refused BY NAME instead, at the boundary, before any of that
237
+ * can happen. `list_files` stands in for every knowledge-base tool here
238
+ * because they share the one choke point (`getFilesystem`) — the refusal is
239
+ * the context resolver's, not this tool's.
240
+ */
241
+ describe('a call that names no branch', () => {
242
+ // Every shape of "the caller sent no branch". The literals are in here
243
+ // because a client that interpolates a variable it never set sends the
244
+ // STRING "undefined" — indistinguishable, in intent, from omitting it, and
245
+ // the one value that must never reach a clone.
246
+ const absent: Array<[label: string, body: Record<string, unknown>]> = [
247
+ ['the field is missing entirely', { path: '' }],
248
+ ['the field is empty', { branch: '', path: '' }],
249
+ ['the field is null', { branch: null, path: '' }],
250
+ ['the field is a number', { branch: 42, path: '' }],
251
+ ['the field is an object', { branch: { name: 'main' }, path: '' }],
252
+ ['the field is an array', { branch: ['main'], path: '' }],
253
+ ['the field is the literal "undefined"', { branch: 'undefined', path: '' }],
254
+ ['the field is the literal "null"', { branch: 'null', path: '' }],
255
+ ];
256
+
257
+ for (const [label, body] of absent) {
258
+ it(`400s branch-required when ${label}`, async () => {
259
+ const base = await start();
260
+
261
+ const res = await callTool(base, 'list_files', body);
262
+
263
+ expect(res.status).toBe(400);
264
+ expect(res.body).toEqual({
265
+ kind: 'branch-required',
266
+ error: '`branch` is required: pass the branch (draft) you are working on.',
267
+ });
268
+ // The refusal is the whole story: nothing was created and nothing was
269
+ // cloned. An empty workspaces root is the strongest form of that
270
+ // claim — there is no directory named for any branch, least of all one
271
+ // named `undefined`.
272
+ expect(await workspaceDirs()).toEqual([]);
273
+ });
274
+ }
275
+
276
+ it('never writes `undefined` or `null` as a branch name into the answer', async () => {
277
+ const base = await start();
278
+
279
+ const res = await callTool(base, 'list_files', { path: '' });
280
+
281
+ // AC3, checked against the whole response rather than one field: the
282
+ // message names the INPUT and what to pass, never a stringified value of
283
+ // what was missing. `Branch "undefined" no longer exists` is exactly the
284
+ // sentence this must never produce again.
285
+ const serialized = JSON.stringify(res.body);
286
+ expect(serialized).not.toContain('undefined');
287
+ expect(serialized).not.toContain('null');
288
+ });
289
+
290
+ /**
291
+ * `call_tool_chain` is the other way every one of these tools gets called,
292
+ * and it is where the bug was reported from just as often. It is not a
293
+ * second implementation to guard: the chain's UTCP http transport POSTs to
294
+ * the SAME `/agent/tools/...` route this file has been calling directly, so
295
+ * the one refusal covers both. This test is what proves that claim rather
296
+ * than assuming it — with a real `CodeModeUtcpClient` reading a real
297
+ * manual and running real JavaScript.
298
+ */
299
+ it('400s branch-required from inside call_tool_chain, and creates no workspace', async () => {
300
+ const base = await start();
301
+ const client = await CodeModeUtcpClient.create(process.cwd(), {
302
+ variables: { bevel_API_URL: base, bevel_CONNECTION_KEY: 'internal-token' },
303
+ } as never);
304
+ await client.registerManual(
305
+ new HttpCallTemplateSerializer().validateDict({
306
+ name: 'bevel',
307
+ call_template_type: 'http',
308
+ http_method: 'GET',
309
+ url: '${API_URL}/api/agent/internal/utcp',
310
+ content_type: 'application/json',
311
+ headers: { Authorization: 'Bearer ${CONNECTION_KEY}' },
312
+ }),
313
+ );
314
+
315
+ // The exact call from the specification: a chain that names the path and
316
+ // forgets the branch. The tool THROWS inside the chain, so the failure is
317
+ // caught and its message returned — the runtime's own contract for
318
+ // surfacing a tool error to the model.
319
+ const { result } = await client.callToolChain(
320
+ `try { return { ok: bevel.list_files({ body: { path: 'knowledge-base' } }) }; }
321
+ catch (err) { return { failed: err.message }; }`,
322
+ 30_000,
323
+ );
324
+
325
+ // The tool did not run.
326
+ expect(result.ok).toBeUndefined();
327
+ // The message the chain hands the model carries the whole refusal: the
328
+ // status, the sentence, and the `kind` the direct caller switches on.
329
+ // (The status and payload are IN the message rather than on the thrown
330
+ // object — the sandboxed error exposes no own properties — which is why
331
+ // this asserts on the text the model actually reads.)
332
+ expect(result.failed).toContain('HTTP 400');
333
+ expect(result.failed).toContain('`branch` is required: pass the branch (draft) you are working on.');
334
+ expect(result.failed).toContain('"kind":"branch-required"');
335
+ // Not the sentence the bug produced — and no invented branch name in it.
336
+ expect(result.failed).not.toContain('no longer exists');
337
+ expect(result.failed).not.toContain('undefined');
338
+ // And the chain path refuses just as early: nothing was cloned.
339
+ expect(await workspaceDirs()).toEqual([]);
340
+ });
341
+
342
+ /**
343
+ * AC1 says "any knowledge-base tool", so this asserts it of EVERY tool the
344
+ * module mounts rather than of the one this file otherwise drives. The list
345
+ * comes from the registry, so a tool added later is swept in automatically
346
+ * and cannot quietly reintroduce the bug on its own route. The guard runs
347
+ * ahead of every other check, which is what lets one empty body stand in
348
+ * for a valid call to each of them.
349
+ */
350
+ it('400s branch-required on every mounted tool that declares `branch`', async () => {
351
+ const base = await start({ source: 'internal', scope: 'write' });
352
+ const declaresBranch = internalTools
353
+ .filter((t) => {
354
+ const body = (t.inputs as { properties?: { body?: { required?: string[] } } }).properties?.body;
355
+ return (body?.required ?? []).includes('branch');
356
+ })
357
+ .map((t) => t.name);
358
+
359
+ // The whole branch-taking surface, not a sample of it.
360
+ expect(declaresBranch.length).toBeGreaterThan(10);
361
+
362
+ for (const tool of declaresBranch) {
363
+ const res = await callTool(base, tool, {});
364
+ expect(res.status, `${tool} must 400 on a branch-less call`).toBe(400);
365
+ expect(res.body.kind, `${tool} must answer kind branch-required`).toBe('branch-required');
366
+ // No tool's refusal may name the value it did not get.
367
+ expect(JSON.stringify(res.body), `${tool} must not echo an absent value`).not.toContain('undefined');
368
+ }
369
+ expect(await workspaceDirs()).toEqual([]);
370
+ });
371
+
372
+ it('still serves the same tool once a branch is named', async () => {
373
+ const base = await start();
374
+ // The guard refuses an absent input, not the tool: the same call with a
375
+ // real branch works, so the refusal cannot be a tool that simply broke.
376
+ expect((await callTool(base, 'list_files', { path: '' })).status).toBe(400);
377
+
378
+ const { status, body } = await listFiles(base, 'alice/draft');
379
+
380
+ expect(status).toBe(200);
381
+ expect((body.entries as { name: string }[]).map((e) => e.name)).toContain(KB_DIR);
382
+ });
383
+ });
384
+
385
+ /**
386
+ * The second half of the bug: a bootstrap that failed to clone used to leave
387
+ * a directory behind, and a directory named after a branch is how the
388
+ * platform remembers having known one. The first attempt's leftovers turned
389
+ * the second attempt's honest "no such branch" into "it was deleted".
390
+ */
391
+ it('answers 404 again on a second attempt at a name that never existed', async () => {
392
+ const base = await start();
393
+
394
+ const first = await listFiles(base, 'nobody/never-made-this');
395
+ expect(first.status).toBe(404);
396
+ // Nothing survived the failed clone — not the repo directory, not the
397
+ // workspace shell around it. This is what keeps the next attempt honest.
398
+ expect(await workspaceDirs()).toEqual([]);
399
+
400
+ const second = await listFiles(base, 'nobody/never-made-this');
401
+
402
+ expect(second.status).toBe(404);
403
+ expect(second.body).toEqual({
404
+ kind: 'branch-not-found',
405
+ branch: 'nobody/never-made-this',
406
+ error: 'There is no branch named nobody/never-made-this.',
407
+ });
408
+ expect(await workspaceDirs()).toEqual([]);
409
+ });
410
+
185
411
  it('410s a branch a listing showed us and origin no longer has', async () => {
186
412
  const base = await start();
187
413
  workspaces!.noteBranchesListed(['target-company-state', 'alice/draft']);
@@ -233,9 +233,24 @@ async function start(
233
233
  return `http://127.0.0.1:${(httpServer.address() as { port: number }).port}`;
234
234
  }
235
235
 
236
- const post = (url: string, body: unknown = {}) =>
236
+ /** POST a body EXACTLY as given — for the tests about a malformed call. */
237
+ const postRaw = (url: string, body: unknown = {}) =>
237
238
  fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer x' }, body: JSON.stringify(body) });
238
239
 
240
+ /**
241
+ * POST a well-formed call. Every KB tool requires `branch` — a call that names
242
+ * none is refused at the mount with 400 `branch-required` — so this names one
243
+ * unless the test already did. A test ABOUT the missing input uses `postRaw`,
244
+ * so the thing under test is never papered over by the helper.
245
+ */
246
+ const post = (url: string, body: unknown = {}) =>
247
+ postRaw(
248
+ url,
249
+ body !== null && typeof body === 'object' && !Array.isArray(body) && !('branch' in body)
250
+ ? { branch: 'main', ...body }
251
+ : body,
252
+ );
253
+
239
254
  beforeEach(() => {
240
255
  /* fresh per test via start() */
241
256
  });
@@ -456,7 +471,8 @@ describe('workspace file primitives', () => {
456
471
  // named "undefined" — it fails closed with a clear 4xx naming the missing
457
472
  // branch context, BEFORE any workspace resolve.
458
473
  expect(focusedBranch).toBeUndefined();
459
- const res = await post(`${base}/api/agent/tools/execute_command`, { command: 'echo should-not-run' });
474
+ // `postRaw`: the absent field IS the test here, so the body goes as written.
475
+ const res = await postRaw(`${base}/api/agent/tools/execute_command`, { command: 'echo should-not-run' });
460
476
  expect(res.status).toBe(400);
461
477
  expect((await res.json()).error).toMatch(/branch/i);
462
478
  // The guard runs before `getOrCreateForBranch`, so no workspace (least of all
@@ -471,7 +487,7 @@ describe('workspace file primitives', () => {
471
487
  // against that branch's workspace and returns output end to end (AC2) —
472
488
  // rather than failing closed the way a context-less external call does.
473
489
  focusedBranch = 'main';
474
- const res = (await (await post(`${base}/api/agent/tools/execute_command`, { command: 'echo hello-exec' })).json()) as { stdout: string; exitCode: number };
490
+ const res = (await (await postRaw(`${base}/api/agent/tools/execute_command`, { command: 'echo hello-exec' })).json()) as { stdout: string; exitCode: number };
475
491
  expect(res.exitCode).toBe(0);
476
492
  expect(res.stdout).toContain('hello-exec');
477
493
  // Resolved against the focused branch — never an "undefined" workspace.
@@ -555,6 +571,20 @@ describe('workspace file primitives', () => {
555
571
  expect(workspacePathCalls).toEqual([]);
556
572
  });
557
573
 
574
+ it('answers a branch-less call with the same kind every other KB tool uses', async () => {
575
+ const base = await start();
576
+ // The MESSAGE here is this tool's own — only `execute_command` has a
577
+ // focused-branch fallback to explain. The DISCRIMINATOR is shared, so a
578
+ // client switches on one kind across the whole surface instead of matching
579
+ // prose per tool.
580
+ for (const body of [{ command: 'x' }, { branch: '', command: 'x' }, { branch: 'undefined', command: 'x' }]) {
581
+ const res = await postRaw(`${base}/api/agent/tools/execute_command`, body);
582
+ expect(res.status).toBe(400);
583
+ expect((await res.json()).kind).toBe('branch-required');
584
+ }
585
+ expect(workspacePathCalls).toEqual([]);
586
+ });
587
+
558
588
  it('execute_command validates the branch BEFORE the write-policy gate', async () => {
559
589
  const base = await start();
560
590
  // A routine-restricted session is refused by `assertUnrestricted` with 403.
@@ -2266,6 +2296,8 @@ describe('the tools place an unprefixed path inside the repository', () => {
2266
2296
  describe('folders never vanish', () => {
2267
2297
  const KB = (p: string) => `${KB_DIR}/${p}`;
2268
2298
  const tool = async (base: string, name: string, body: Record<string, unknown>) => {
2299
+ // `post` names the branch every KB tool requires; these tests are about
2300
+ // folders, not about the branch input.
2269
2301
  const res = await post(`${base}/api/agent/tools/${name}`, body);
2270
2302
  return { status: res.status, body: (await res.json()) as Record<string, unknown> };
2271
2303
  };
@@ -79,6 +79,7 @@ function runnerOpts(steps: OnServerStart[], overrides: Record<string, unknown> =
79
79
  gitRunner: new NodeGitRunner(undefined, credentials),
80
80
  kbRepoUrl: () => upstream,
81
81
  workspacesRoot,
82
+ setAsideRoot: path.join(root, 'set-aside'),
82
83
  kbDirName: 'knowledge-base',
83
84
  templateDir: path.join(root, 'template'),
84
85
  defaultBranch: () => DEFAULT_BRANCH,
@@ -103,6 +104,216 @@ async function checkout(branch: string): Promise<string> {
103
104
  return dir;
104
105
  }
105
106
 
107
+ /**
108
+ * The configured repository was replaced while working copies of the old one
109
+ * were still on disk. A clone fetches through its OWN stored address, so
110
+ * every one of them kept asking the repository that was gone: the boot
111
+ * stopped on "repository not found" and took the setup screen with it.
112
+ */
113
+ describe('KbStartupRunner — the repository was replaced', () => {
114
+ const touchDefault = step('touch', async (ctx) => {
115
+ await (await ctx.defaultBranch()).repoDir();
116
+ return { outcome: 'ok' };
117
+ });
118
+ const cloneDir = (branch: string) => path.join(workspacesRoot, encodeURIComponent(branch), 'knowledge-base');
119
+
120
+ /** A second, unrelated repository with the same branch names. */
121
+ async function replacementUpstream(): Promise<string> {
122
+ const other = path.join(root, 'replacement.git');
123
+ await git(root, ['init', '--bare', '-b', DEFAULT_BRANCH, other]);
124
+ const seed = path.join(root, '.seed-replacement');
125
+ await fs.mkdir(seed);
126
+ await git(seed, ['init', '-b', DEFAULT_BRANCH]);
127
+ await fs.writeFile(path.join(seed, 'marker.txt'), 'replacement', 'utf8');
128
+ await git(seed, ['add', '-A']);
129
+ await git(seed, ['commit', '-m', 'init']);
130
+ await git(seed, ['branch', PROTECTED[1]!]);
131
+ await git(seed, ['remote', 'add', 'origin', other]);
132
+ await git(seed, ['push', 'origin', ...PROTECTED]);
133
+ return other;
134
+ }
135
+
136
+ /** Boot once against the first repository, and leave a draft's clone beside it. */
137
+ async function bootedOnTheOldRepository(): Promise<void> {
138
+ await populatedUpstream();
139
+ await makeRunner([touchDefault]).runAll();
140
+ await fs.mkdir(path.dirname(cloneDir('someone/draft')), { recursive: true });
141
+ await git(root, ['clone', '-b', DEFAULT_BRANCH, upstream, cloneDir('someone/draft')]);
142
+ }
143
+
144
+ /** Where one run set its working copies aside: the single dated folder under the root. */
145
+ async function setAside(): Promise<string> {
146
+ const runs = await fs.readdir(path.join(root, 'set-aside'));
147
+ expect(runs).toHaveLength(1);
148
+ return path.join(root, 'set-aside', runs[0]!);
149
+ }
150
+
151
+ it('clones the new repository in place of the old one\'s clones, even with the old one gone', async () => {
152
+ await bootedOnTheOldRepository();
153
+ const replacement = await replacementUpstream();
154
+ // Gone, as it was in production: any fetch through the old address fails.
155
+ await fs.rm(upstream, { recursive: true, force: true });
156
+
157
+ await makeRunner([touchDefault], { kbRepoUrl: () => replacement }).runAll();
158
+
159
+ const repo = cloneDir(DEFAULT_BRANCH);
160
+ expect((await git(repo, ['config', '--get', 'remote.origin.url'])).trim()).toBe(replacement);
161
+ expect(await fs.readFile(path.join(repo, 'marker.txt'), 'utf8')).toBe('replacement');
162
+ // A clone this boot had no reason to touch leaves the workspaces root
163
+ // too: the workspace service would otherwise adopt it and fetch from the
164
+ // old address.
165
+ await expect(fs.access(cloneDir('someone/draft'))).rejects.toThrow();
166
+ });
167
+
168
+ /**
169
+ * The old repository is GONE, so its clones are the last copy of what it
170
+ * held, and the commit nobody pushed exists nowhere else at all. They
171
+ * leave the workspaces root and nothing else happens to them.
172
+ */
173
+ it('sets the old repository\'s clones aside whole, unpushed work included, and deletes nothing', async () => {
174
+ await bootedOnTheOldRepository();
175
+ const old = cloneDir(DEFAULT_BRANCH);
176
+ await fs.writeFile(path.join(old, 'unpushed.md'), 'the only copy', 'utf8');
177
+ await git(old, ['add', '-A']);
178
+ await git(old, ['commit', '-m', 'never pushed']);
179
+ const head = (await git(old, ['rev-parse', 'HEAD'])).trim();
180
+ const replacement = await replacementUpstream();
181
+ await fs.rm(upstream, { recursive: true, force: true });
182
+
183
+ await makeRunner([touchDefault], { kbRepoUrl: () => replacement }).runAll();
184
+
185
+ const kept = await setAside();
186
+ const keptDefault = path.join(kept, encodeURIComponent(DEFAULT_BRANCH));
187
+ expect((await git(keptDefault, ['rev-parse', 'HEAD'])).trim()).toBe(head);
188
+ expect(await fs.readFile(path.join(keptDefault, 'unpushed.md'), 'utf8')).toBe('the only copy');
189
+ expect(await fs.readFile(path.join(keptDefault, 'marker.txt'), 'utf8')).toBe('seeded');
190
+ // The draft's clone too, under its own workspace's name.
191
+ await expect(fs.access(path.join(kept, encodeURIComponent('someone/draft'), '.git'))).resolves.toBeUndefined();
192
+ // And the probe that compared the histories left no refs behind in it.
193
+ expect((await git(keptDefault, ['for-each-ref', 'refs/hexis-probe/'])).trim()).toBe('');
194
+ });
195
+
196
+ /**
197
+ * The shipped compose files mount the backups root as a volume of its own,
198
+ * where a rename across to it is refused. That is the path production
199
+ * takes: a copy, and the original removed only once the copy is whole.
200
+ */
201
+ it('sets a clone aside across volumes, by copying it whole', async () => {
202
+ await bootedOnTheOldRepository();
203
+ const old = cloneDir(DEFAULT_BRANCH);
204
+ await fs.writeFile(path.join(old, 'unpushed.md'), 'the only copy', 'utf8');
205
+ await git(old, ['add', '-A']);
206
+ await git(old, ['commit', '-m', 'never pushed']);
207
+ const head = (await git(old, ['rev-parse', 'HEAD'])).trim();
208
+ const replacement = await replacementUpstream();
209
+ await fs.rm(upstream, { recursive: true, force: true });
210
+ const crossDevice = Object.assign(new Error('EXDEV: cross-device link not permitted'), { code: 'EXDEV' });
211
+ const rename = vi.spyOn(fs, 'rename').mockRejectedValue(crossDevice);
212
+ try {
213
+ await makeRunner([touchDefault], { kbRepoUrl: () => replacement }).runAll();
214
+ } finally {
215
+ rename.mockRestore();
216
+ }
217
+
218
+ const keptDefault = path.join(await setAside(), encodeURIComponent(DEFAULT_BRANCH));
219
+ expect((await git(keptDefault, ['rev-parse', 'HEAD'])).trim()).toBe(head);
220
+ expect(await fs.readFile(path.join(keptDefault, 'unpushed.md'), 'utf8')).toBe('the only copy');
221
+ expect(await fs.readFile(path.join(cloneDir(DEFAULT_BRANCH), 'marker.txt'), 'utf8')).toBe('replacement');
222
+ });
223
+
224
+ /**
225
+ * A clone that cannot be set aside stays exactly where it is, and the boot
226
+ * stops saying why: deleting it to get past would be the one outcome this
227
+ * whole path exists to rule out.
228
+ */
229
+ it('deletes nothing when a clone cannot be set aside, and says why', async () => {
230
+ await bootedOnTheOldRepository();
231
+ const old = cloneDir(DEFAULT_BRANCH);
232
+ const head = (await git(old, ['rev-parse', 'HEAD'])).trim();
233
+ const replacement = await replacementUpstream();
234
+ await fs.rm(upstream, { recursive: true, force: true });
235
+ const rename = vi.spyOn(fs, 'rename').mockRejectedValue(Object.assign(new Error('EXDEV'), { code: 'EXDEV' }));
236
+ const cp = vi.spyOn(fs, 'cp').mockRejectedValue(Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' }));
237
+ try {
238
+ await expect(makeRunner([touchDefault], { kbRepoUrl: () => replacement }).runAll()).rejects.toThrow(
239
+ /Could not set aside the working copy.*no space left.*Nothing was deleted/s,
240
+ );
241
+ } finally {
242
+ rename.mockRestore();
243
+ cp.mockRestore();
244
+ }
245
+ expect((await git(old, ['rev-parse', 'HEAD'])).trim()).toBe(head);
246
+ });
247
+
248
+ /**
249
+ * The one change the setup screen tells an admin to make: the same
250
+ * repository, moved. The clone is pointed at the new address and kept, and
251
+ * the commit that was never pushed goes on to be pushed there.
252
+ */
253
+ it('keeps a clone of the same repository at a new address, and points it there', async () => {
254
+ await bootedOnTheOldRepository();
255
+ const repo = cloneDir(DEFAULT_BRANCH);
256
+ await fs.writeFile(path.join(repo, 'unpushed.md'), 'local work', 'utf8');
257
+ await git(repo, ['add', '-A']);
258
+ await git(repo, ['commit', '-m', 'local work']);
259
+ const head = (await git(repo, ['rev-parse', 'HEAD'])).trim();
260
+ // Moved: everything the old address held is at the new one, and the old
261
+ // one answers nothing any more.
262
+ const moved = path.join(root, 'moved.git');
263
+ await git(root, ['clone', '--bare', upstream, moved]);
264
+ await fs.rm(upstream, { recursive: true, force: true });
265
+
266
+ await makeRunner([touchDefault], { kbRepoUrl: () => moved }).runAll();
267
+
268
+ expect((await git(repo, ['config', '--get', 'remote.origin.url'])).trim()).toBe(moved);
269
+ expect((await git(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
270
+ expect((await git(cloneDir('someone/draft'), ['config', '--get', 'remote.origin.url'])).trim()).toBe(moved);
271
+ expect((await git(repo, ['for-each-ref', 'refs/hexis-probe/'])).trim()).toBe('');
272
+ // Nothing was another repository's, so nothing was set aside.
273
+ await expect(fs.access(path.join(root, 'set-aside'))).rejects.toThrow();
274
+ });
275
+
276
+ it('leaves a clone of the configured repository alone, unpushed work included', async () => {
277
+ await bootedOnTheOldRepository();
278
+ const repo = cloneDir(DEFAULT_BRANCH);
279
+ await fs.writeFile(path.join(repo, 'unpushed.md'), 'local work', 'utf8');
280
+ await git(repo, ['add', '-A']);
281
+ await git(repo, ['commit', '-m', 'local work']);
282
+ const head = (await git(repo, ['rev-parse', 'HEAD'])).trim();
283
+
284
+ await makeRunner([touchDefault]).runAll();
285
+
286
+ expect((await git(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
287
+ await expect(fs.access(cloneDir('someone/draft'))).resolves.toBeUndefined();
288
+ });
289
+
290
+ it('does not take a different spelling of the same address for another repository', async () => {
291
+ await bootedOnTheOldRepository();
292
+ const repo = cloneDir(DEFAULT_BRANCH);
293
+ await fs.writeFile(path.join(repo, 'unpushed.md'), 'local work', 'utf8');
294
+ await git(repo, ['add', '-A']);
295
+ await git(repo, ['commit', '-m', 'local work']);
296
+ const head = (await git(repo, ['rev-parse', 'HEAD'])).trim();
297
+
298
+ // `upstream` ends in `.git`; the same repository without it, and with a
299
+ // trailing slash.
300
+ await makeRunner([touchDefault], { kbRepoUrl: () => `${upstream.replace(/\.git$/, '')}/` }).runAll().catch(() => {});
301
+
302
+ expect((await git(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
303
+ });
304
+
305
+ it('keeps every clone when the configured repository cannot be reached', async () => {
306
+ // A typo in the address must not cost the working copies: the sweep runs
307
+ // only once the configured remote has answered.
308
+ await bootedOnTheOldRepository();
309
+ await expect(
310
+ makeRunner([touchDefault], { kbRepoUrl: () => path.join(root, 'no-such-repository.git') }).runAll(),
311
+ ).rejects.toThrow();
312
+ await expect(fs.access(path.join(cloneDir(DEFAULT_BRANCH), '.git'))).resolves.toBeUndefined();
313
+ await expect(fs.access(cloneDir('someone/draft'))).resolves.toBeUndefined();
314
+ });
315
+ });
316
+
106
317
  describe('KbStartupRunner', () => {
107
318
  it('seeds an empty remote with every protected branch before any step runs', async () => {
108
319
  const seen: string[] = [];