@bevel-software/platform-core-backend 0.13.3 → 0.13.6

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 (29) hide show
  1. package/THIRD-PARTY-NOTICES.md +4 -4
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +4 -2
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/modules/kb-fs/clone-config.d.ts +10 -0
  6. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
  7. package/dist/modules/kb-fs/clone-config.js +11 -0
  8. package/dist/modules/kb-fs/clone-config.js.map +1 -1
  9. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  10. package/dist/modules/mcp/mcp.routes.js +95 -13
  11. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  12. package/dist/modules/workspace/file-readers/extract-pdf.d.ts.map +1 -1
  13. package/dist/modules/workspace/file-readers/extract-pdf.js +18 -3
  14. package/dist/modules/workspace/file-readers/extract-pdf.js.map +1 -1
  15. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  16. package/dist/modules/workspace/workspace.tools.js +26 -4
  17. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  18. package/package.json +4 -4
  19. package/src/core/create-core-server.ts +4 -2
  20. package/src/modules/kb-fs/__tests__/clone-config.test.ts +4 -1
  21. package/src/modules/kb-fs/clone-config.ts +11 -0
  22. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +89 -0
  23. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -34
  24. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +8 -40
  25. package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +226 -0
  26. package/src/modules/mcp/mcp.routes.ts +482 -398
  27. package/src/modules/workspace/__tests__/workspace.tools.test.ts +54 -1
  28. package/src/modules/workspace/file-readers/extract-pdf.ts +18 -4
  29. package/src/modules/workspace/workspace.tools.ts +22 -4
@@ -1,6 +1,6 @@
1
1
  import type { Server as HttpServer } from 'node:http';
2
2
  import { execFile } from 'node:child_process';
3
- import { mkdir, mkdtemp, rm } from 'node:fs/promises';
3
+ import { mkdir, mkdtemp, readFile, rm } 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';
@@ -147,6 +147,25 @@ afterEach(async () => {
147
147
  }
148
148
  });
149
149
 
150
+ /**
151
+ * True once `pid` has been killed: either it is gone, or it is a zombie
152
+ * waiting on a reaper (Linux: `State:\tZ` in /proc). `kill(pid, 0)` alone
153
+ * cannot tell a zombie from a live process.
154
+ */
155
+ async function isDeadOrZombie(pid: number): Promise<boolean> {
156
+ try {
157
+ process.kill(pid, 0);
158
+ } catch {
159
+ return true;
160
+ }
161
+ try {
162
+ const status = await readFile(`/proc/${pid}/status`, 'utf8');
163
+ return /^State:\s*Z/m.test(status);
164
+ } catch {
165
+ return false; // no /proc (macOS): alive as far as the signal says
166
+ }
167
+ }
168
+
150
169
  describe('workspace file primitives', () => {
151
170
  it('read_file returns the content', async () => {
152
171
  const base = await start();
@@ -191,6 +210,40 @@ describe('workspace file primitives', () => {
191
210
  expect(res.exitCode).toBe(0);
192
211
  });
193
212
 
213
+ // The command runs under `sh -c`; a timeout used to SIGKILL that shell and
214
+ // leave what it had started running on as an orphan. The whole process
215
+ // group goes now. POSIX only: Windows has no process groups to kill.
216
+ it.skipIf(process.platform === 'win32')('execute_command kills what the command started, not just its shell, on timeout', async () => {
217
+ const base = await start();
218
+ // Print the grandchild's pid, then block on it so the timeout fires. The
219
+ // timeout is the schema's minimum — inside the tool's declared contract,
220
+ // and long enough that a slow spawn still prints the pid before it fires.
221
+ const res = (await (
222
+ await post(`${base}/api/agent/tools/execute_command`, {
223
+ branch: 'main',
224
+ command: 'sleep 30 & echo $!; wait',
225
+ timeout_ms: 1000,
226
+ })
227
+ ).json()) as { stdout: string; exitCode: number };
228
+ expect(res.exitCode).toBe(-1);
229
+ const grandchild = Number(res.stdout.trim());
230
+ expect(grandchild).toBeGreaterThan(0);
231
+ // Dead means killed, not reaped: a SIGKILLed process keeps its pid — and
232
+ // answers `kill(pid, 0)` as alive — until whoever inherited it reaps it,
233
+ // and on a host with no reaper (the very thing this guards) that is
234
+ // never. So where /proc exists, a zombie (`State: Z`) counts as dead; a
235
+ // vanished pid counts everywhere. Poll to a deadline rather than trust
236
+ // one fixed pause. Without the fix the grandchild is still sleeping at
237
+ // the deadline, so what fails is the deadline — never an early check.
238
+ const deadline = Date.now() + 3_000;
239
+ let dead = false;
240
+ while (!dead && Date.now() < deadline) {
241
+ dead = await isDeadOrZombie(grandchild);
242
+ if (!dead) await new Promise((r) => setTimeout(r, 50));
243
+ }
244
+ expect(dead).toBe(true);
245
+ });
246
+
194
247
  it('execute_command 400s when no branch is given AND no focused branch (external caller)', async () => {
195
248
  const base = await start();
196
249
  // No `branch` arg and no `ctx.focusedBranch` (an external caller carries no
@@ -64,12 +64,24 @@ export async function extractPdf(bytes: Buffer): Promise<ExtractResult> {
64
64
  message: `could not be extracted as a PDF (the file is ${bytes.length} bytes — over the ${MAX_DOC_PART_BYTES}-byte (50 MB) extraction limit)`,
65
65
  };
66
66
  }
67
- let doc: Awaited<ReturnType<typeof openPdf>>;
67
+ // The loading TASK is what gets destroyed at the end, not the document:
68
+ // pdf.js 6 dropped `PDFDocumentProxy.destroy()`, and destroying the task
69
+ // frees the document (and its pages) with it — the shape the viewer uses.
70
+ let task: Awaited<ReturnType<typeof openPdf>>;
68
71
  try {
69
- doc = await openPdf(bytes);
72
+ task = await openPdf(bytes);
70
73
  } catch (err) {
71
74
  return { ok: false, message: `could not be parsed as a PDF (${(err as Error).message})` };
72
75
  }
76
+ let doc: Awaited<typeof task.promise>;
77
+ try {
78
+ doc = await task.promise;
79
+ } catch (err) {
80
+ // A task exists even when the document never will (malformed, truncated):
81
+ // destroy it, or its worker-side state outlives every failed extraction.
82
+ await task.destroy().catch(() => undefined);
83
+ return { ok: false, message: `could not be parsed as a PDF (${(err as Error).message})` };
84
+ }
73
85
  try {
74
86
  if (doc.numPages > MAX_PDF_PAGES) {
75
87
  return {
@@ -151,7 +163,7 @@ export async function extractPdf(bytes: Buffer): Promise<ExtractResult> {
151
163
  } catch (err) {
152
164
  return { ok: false, message: `could not extract the PDF's text (${(err as Error).message})` };
153
165
  } finally {
154
- await doc.destroy();
166
+ await task.destroy();
155
167
  }
156
168
  }
157
169
 
@@ -167,6 +179,8 @@ async function openPdf(bytes: Buffer) {
167
179
  throw err;
168
180
  });
169
181
  const { getDocument } = await pdfjsPromise;
182
+ // Returns the loading task; the caller awaits `.promise` for the document
183
+ // and destroys the task when done (see `extractPdf`).
170
184
  return getDocument({
171
185
  // Copy into a fresh Uint8Array: pdf.js TRANSFERS the buffer it is given
172
186
  // (detaching it), and the caller's Buffer must stay usable for hashing.
@@ -174,5 +188,5 @@ async function openPdf(bytes: Buffer) {
174
188
  // Server side: no font rendering — text content is all we consume.
175
189
  disableFontFace: true,
176
190
  useSystemFonts: true,
177
- }).promise;
191
+ });
178
192
  }
@@ -1069,14 +1069,32 @@ export function registerWorkspaceTools(
1069
1069
  }
1070
1070
  : process.env;
1071
1071
  return new Promise((resolve) => {
1072
- const child = spawn(a.command as string, { cwd, shell: true, env });
1072
+ // Own process group on POSIX, so a timeout or abort kills the WHOLE
1073
+ // tree. `shell: true` puts an `sh` between us and the command; killing
1074
+ // only that shell left whatever it had started running on — orphaned,
1075
+ // unreaped, still holding the workspace and its memory. A self-hosted
1076
+ // deployment leaked ~4,500 tasks that way before nothing could fork.
1077
+ const detached = process.platform !== 'win32';
1078
+ const child = spawn(a.command as string, { cwd, shell: true, env, detached });
1073
1079
  let stdout = '';
1074
1080
  let stderr = '';
1075
- const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
1076
- // If the caller disconnects (request aborted), kill the child instead of
1081
+ const killTree = () => {
1082
+ try {
1083
+ if (detached && child.pid) process.kill(-child.pid, 'SIGKILL');
1084
+ else child.kill('SIGKILL');
1085
+ } catch {
1086
+ try {
1087
+ child.kill('SIGKILL');
1088
+ } catch {
1089
+ // Already gone.
1090
+ }
1091
+ }
1092
+ };
1093
+ const timer = setTimeout(killTree, timeoutMs);
1094
+ // If the caller disconnects (request aborted), kill the tree instead of
1077
1095
  // letting it run to the timeout; the resulting `close`/`error` settles
1078
1096
  // the promise through `finish`.
1079
- const onAbort = () => child.kill('SIGKILL');
1097
+ const onAbort = killTree;
1080
1098
  ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
1081
1099
  const finish = (value: unknown) => {
1082
1100
  clearTimeout(timer);