@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.
- package/THIRD-PARTY-NOTICES.md +4 -4
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +4 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/modules/kb-fs/clone-config.d.ts +10 -0
- package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
- package/dist/modules/kb-fs/clone-config.js +11 -0
- package/dist/modules/kb-fs/clone-config.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +95 -13
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/workspace/file-readers/extract-pdf.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/extract-pdf.js +18 -3
- package/dist/modules/workspace/file-readers/extract-pdf.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +26 -4
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/package.json +4 -4
- package/src/core/create-core-server.ts +4 -2
- package/src/modules/kb-fs/__tests__/clone-config.test.ts +4 -1
- package/src/modules/kb-fs/clone-config.ts +11 -0
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +89 -0
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -34
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +8 -40
- package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +226 -0
- package/src/modules/mcp/mcp.routes.ts +482 -398
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +54 -1
- package/src/modules/workspace/file-readers/extract-pdf.ts +18 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
})
|
|
191
|
+
});
|
|
178
192
|
}
|
|
@@ -1069,14 +1069,32 @@ export function registerWorkspaceTools(
|
|
|
1069
1069
|
}
|
|
1070
1070
|
: process.env;
|
|
1071
1071
|
return new Promise((resolve) => {
|
|
1072
|
-
|
|
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
|
|
1076
|
-
|
|
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 =
|
|
1097
|
+
const onAbort = killTree;
|
|
1080
1098
|
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
1081
1099
|
const finish = (value: unknown) => {
|
|
1082
1100
|
clearTimeout(timer);
|