@bevel-software/platform-core-backend 0.13.1 → 0.13.5

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.
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
7
+ import { AccessControlService } from '../access-control.service.js';
8
+ import { registerAccessFrontmatterExtensions } from '../../access-model/access-grammar.js';
9
+
10
+ const KB = 'knowledge-base';
11
+
12
+ const ROLES_YAML = `roles:
13
+ Admin:
14
+ - razvan@bevel.software
15
+ Developer:
16
+ - coding-agent@bevel.software
17
+ Agent:
18
+ - coding-agent@bevel.software
19
+ `;
20
+
21
+ // Shaped like the real files: folded descriptions, literal blocks, nested
22
+ // maps, comments — everything the access grammar's subset parser stops at.
23
+ const PIPELINE = `---
24
+ apiVersion: bevel.software/v1
25
+ kind: Pipeline
26
+ id: coding-delivery-process
27
+ name: Coding Delivery
28
+ description: >-
29
+ End-to-end delivery of one coding ticket: workspace preparation, coding, local
30
+ testing, a two-check review gate, staged rollout, and production verification.
31
+ # Which tickets this runner works.
32
+ queue:
33
+ trigger: dataStatus
34
+ projects:
35
+ - KnowledgeBase/Engineering/Knowledge/Bevel-Platform.md
36
+ do:
37
+ - name: Coding
38
+ kind: agent
39
+ instructions: |
40
+ Implement the ticket in the workspace.
41
+ Commit locally; never push.
42
+ owner: razvan.radulescu <razvan@bevel.software>
43
+ read: coding-agent <coding-agent@bevel.software>
44
+ write: Developer
45
+ ---
46
+ `;
47
+
48
+ const AGENT = `---
49
+ apiVersion: bevel.software/v1
50
+ kind: Agent
51
+ id: delivery_coder
52
+ name: delivery-coder
53
+ description: >-
54
+ Writes and verifies the code for one delivery ticket.
55
+ systemPrompt:
56
+ extend: |
57
+ You are executing exactly ONE step of a pipeline.
58
+ env:
59
+ - { name: STAGING_URL, from: params, param: stagingUrl }
60
+ owner: razvan.radulescu <razvan@bevel.software>
61
+ read: coding-agent <coding-agent@bevel.software>
62
+ write: Developer
63
+ ---
64
+
65
+ # delivery-coder
66
+ `;
67
+
68
+ describe('a file-own read grant on a whole-document configuration file', () => {
69
+ let root: string;
70
+ let repo: string;
71
+ const ws = 'ws-1';
72
+
73
+ beforeEach(async () => {
74
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'own-read-'));
75
+ repo = path.join(root, ws, KB);
76
+ await fs.mkdir(path.join(repo, 'Pipelines'), { recursive: true });
77
+ await fs.mkdir(path.join(repo, 'Agents'), { recursive: true });
78
+ await fs.writeFile(path.join(repo, 'roles.yaml'), ROLES_YAML);
79
+ await fs.writeFile(path.join(repo, 'access.md'), '---\nwrite:\n - Admin\ndownload:\n - Admin\nowner: []\n---\n');
80
+ await fs.writeFile(path.join(repo, 'Pipelines', 'access.md'), '---\nwrite: []\n---\n');
81
+ await fs.writeFile(path.join(repo, 'Pipelines', 'Coding-Delivery.pipeline'), PIPELINE);
82
+ await fs.writeFile(path.join(repo, 'Agents', 'access.md'), '---\nwrite: []\n---\n');
83
+ await fs.writeFile(path.join(repo, 'Agents', 'delivery-coder.agent'), AGENT);
84
+ });
85
+ afterEach(() => fs.rm(root, { recursive: true, force: true }));
86
+
87
+ const svc = () =>
88
+ new AccessControlService(
89
+ { getWorkspacePath: async () => path.join(root, ws) } as unknown as WorkspaceService,
90
+ KB,
91
+ );
92
+
93
+ it('the grantee reads the file (registered kinds)', async () => {
94
+ registerAccessFrontmatterExtensions(['.pipeline', '.agent']);
95
+ const s = svc();
96
+ expect(await s.canRead(ws, 'coding-agent@bevel.software', 'Pipelines/Coding-Delivery.pipeline')).toBe(true);
97
+ expect(await s.canRead(ws, 'coding-agent@bevel.software', 'Agents/delivery-coder.agent')).toBe(true);
98
+ expect(await s.canWrite(ws, 'coding-agent@bevel.software', 'Pipelines/Coding-Delivery.pipeline')).toBe(true);
99
+ // Batch path, which the explorer and the tool catalog use.
100
+ const batch = await s.canReadBatch(ws, 'coding-agent@bevel.software', [
101
+ 'Pipelines/Coding-Delivery.pipeline',
102
+ 'Agents/delivery-coder.agent',
103
+ 'Pipelines/README.md',
104
+ ]);
105
+ expect([...batch.entries()]).toEqual([
106
+ ['Pipelines/Coding-Delivery.pipeline', true],
107
+ ['Agents/delivery-coder.agent', true],
108
+ ['Pipelines/README.md', false],
109
+ ]);
110
+ });
111
+
112
+ it('a stranger does not', async () => {
113
+ registerAccessFrontmatterExtensions(['.pipeline', '.agent']);
114
+ expect(await svc().canRead(ws, 'someone@else.io', 'Pipelines/Coding-Delivery.pipeline')).toBe(false);
115
+ });
116
+ });
@@ -129,3 +129,101 @@ describe('the access-frontmatter extension set', () => {
129
129
  expect(entries!.write).toEqual([]);
130
130
  });
131
131
  });
132
+
133
+ describe('parseOwnAccessEntries — whole-document YAML the subset parser cannot read', () => {
134
+ // A `.tool` / `.pipeline` / `.agent` is one `---` fenced YAML document, and
135
+ // real ones use folded and literal scalars and nested maps. The subset
136
+ // parser stops at the first such line; the verbs must still be read.
137
+ const doc = [
138
+ '---',
139
+ 'apiVersion: bevel.software/v1',
140
+ 'kind: Agent',
141
+ 'id: delivery_coder',
142
+ 'description: >-',
143
+ ' Writes and verifies the code for one delivery ticket. Runs the agent steps',
144
+ ' of a coding pipeline.',
145
+ 'systemPrompt:',
146
+ ' extend: |',
147
+ ' You are executing exactly ONE step of a pipeline.',
148
+ ' When your verdict is recorded, stop.',
149
+ 'env:',
150
+ ' - { name: STAGING_URL, from: params, param: stagingUrl }',
151
+ 'owner: razvan.radulescu <razvan@bevel.software>',
152
+ 'read: coding-agent <coding-agent@bevel.software>',
153
+ 'write: Developer',
154
+ '---',
155
+ '',
156
+ '# notes after the fence',
157
+ '',
158
+ ].join('\n');
159
+
160
+ it('reads the verbs out of a document with folded and literal scalars', () => {
161
+ const own = parseOwnAccessEntries(doc);
162
+ expect(own).not.toBeNull();
163
+ expect(own!.owner).toMatchObject([{ kind: 'user', email: 'razvan@bevel.software', deny: false }]);
164
+ expect(own!.read).toMatchObject([{ kind: 'user', email: 'coding-agent@bevel.software', deny: false }]);
165
+ expect(own!.write).toMatchObject([{ kind: 'role', role: 'developer', deny: false }]);
166
+ });
167
+
168
+ it('still answers null for a document that declares no verb, and for broken YAML', () => {
169
+ expect(parseOwnAccessEntries('---\ndescription: >-\n folded\n text\n---\n')).toBeNull();
170
+ // Broken for BOTH parsers: the folded scalar stops the subset one, the
171
+ // unclosed flow sequence the full one.
172
+ expect(parseOwnAccessEntries('---\ndescription: >-\n folded\nread: [unclosed\n---\n')).toBeNull();
173
+ });
174
+
175
+ it('does not change what the subset parser already read', () => {
176
+ // A node's frontmatter: the historical path, byte for byte.
177
+ const node = '---\nnodeType: "[Project](../NodeTypes/Project.md)"\nid: project-x\nowner: Someone <s@x.io>\n---\n# Name\n';
178
+ expect(parseOwnAccessEntries(node)!.owner).toMatchObject([{ kind: 'user', email: 's@x.io' }]);
179
+ });
180
+ });
181
+
182
+ describe('parseOwnAccessEntries — quoted and flow-sequence verb values', () => {
183
+ // The subset parser SUCCEEDS on these and hands the brackets or quotes to
184
+ // the entry parser as text, so before the fallback they were dropped — or a
185
+ // quoted role kept its quotes as part of its name. They are real YAML and
186
+ // must resolve to the grants they spell.
187
+ it('reads a flow sequence', () => {
188
+ const own = parseOwnAccessEntries('---\nread: [coding-agent <coding-agent@bevel.software>, Developer]\n---\n');
189
+ expect(own!.read).toMatchObject([
190
+ { kind: 'user', email: 'coding-agent@bevel.software' },
191
+ { kind: 'role', role: 'developer' },
192
+ ]);
193
+ });
194
+
195
+ it('reads quoted scalars, inline and in a block list', () => {
196
+ expect(parseOwnAccessEntries('---\nread: "coding-agent <coding-agent@bevel.software>"\n---\n')!.read).toMatchObject([
197
+ { kind: 'user', email: 'coding-agent@bevel.software' },
198
+ ]);
199
+ expect(parseOwnAccessEntries("---\nwrite: 'Developer'\n---\n")!.write).toMatchObject([{ kind: 'role', role: 'developer' }]);
200
+ expect(parseOwnAccessEntries('---\nowner:\n - "Someone <s@x.io>"\n---\n')!.owner).toMatchObject([
201
+ { kind: 'user', email: 's@x.io' },
202
+ ]);
203
+ });
204
+
205
+ it('keeps the plain entries beside a quoted one exactly as the subset parser read them', () => {
206
+ // One quoted value sends the whole document to the full parser, which
207
+ // TYPES scalars: `true` and `42` would come back as a boolean and a number
208
+ // and never reach the entry grammar. They must resolve exactly as they do
209
+ // on the subset path — as the (odd, but legal) role names they spell.
210
+ const viaFull = parseOwnAccessEntries(
211
+ ['---', 'read: "coding-agent <coding-agent@bevel.software>"', 'write: true', 'owner:', ' - 42', ' - 0x10', ' - 1e3', '---', ''].join('\n'),
212
+ );
213
+ const viaSubset = parseOwnAccessEntries(['---', 'write: true', 'owner:', ' - 42', ' - 0x10', ' - 1e3', '---', ''].join('\n'));
214
+ expect(viaFull!.write).toEqual(viaSubset!.write);
215
+ expect(viaFull!.owner).toEqual(viaSubset!.owner);
216
+ expect(viaFull!.write).toMatchObject([{ kind: 'role', role: 'true' }]);
217
+ // Source spelling, not the number it parses to: `0x10` stays `0x10`.
218
+ expect(viaFull!.owner.map((e) => (e as { role: string }).role)).toEqual(['42', '0x10', '1e3']);
219
+ });
220
+
221
+ it('leaves the plain forms on the subset path', () => {
222
+ // An empty flow collection and bare scalars are the subset parser\'s own
223
+ // grammar; nothing about them asks for the full parser.
224
+ expect(parseOwnAccessEntries('---\nread: []\nwrite: Developer\n---\n')).toMatchObject({
225
+ read: [],
226
+ write: [{ kind: 'role', role: 'developer' }],
227
+ });
228
+ });
229
+ });
@@ -11,6 +11,7 @@
11
11
  * `./group-files.js` import below is access-model's own file, not a breach.)
12
12
  */
13
13
 
14
+ import { parse as parseFullYaml } from 'yaml';
14
15
  import type { GroupsIndex } from './group-files.js';
15
16
 
16
17
  // ---------------------------------------------------------------------------
@@ -799,6 +800,60 @@ export function parseAccessFile(
799
800
  * scope — applied after every directory `access.md` in the chain.
800
801
  */
801
802
  export type OwnEntries = Record<Verb, ParsedEntry[]>;
803
+ /**
804
+ * The top-level mapping of a file's own frontmatter, for the verb keys.
805
+ *
806
+ * The subset parser is tried first: it is what every `access.md` and node
807
+ * frontmatter has always been read with, and its answer must not change. But
808
+ * a whole-document configuration file (`.tool`, and whatever an overlay
809
+ * registers) is real YAML — folded descriptions (`>-`), literal blocks (`|`),
810
+ * nested maps — and the subset parser stops at the first line it does not
811
+ * understand. Before this fallback that meant the file's `owner:` / `read:` /
812
+ * `write:` were silently dropped: a grant that was reviewed and merged did
813
+ * not exist, and the file was unreadable by the very principal it named.
814
+ * So on a subset failure the frontmatter is read by the full parser and only
815
+ * its top-level mapping is used — the verb keys are looked up exactly as
816
+ * before, everything else is ignored exactly as before.
817
+ */
818
+ function ownEntriesRoot(frontmatter: string): unknown {
819
+ const subset = parseYamlSubset(frontmatter);
820
+ if (subset.ok && !verbValuesNeedFullYaml(subset.value)) return subset.value;
821
+ try {
822
+ // The FAILSAFE schema: every scalar stays the text it was written as —
823
+ // `true`, `42`, `0x10`, `2026-01-01` — which is all the subset parser has
824
+ // ever handed the entry grammar. The core schema would type them, and a
825
+ // role spelled `0x10` would come back as `16` the moment another verb on
826
+ // the same file used a quoted value.
827
+ const full = parseFullYaml(frontmatter, { schema: 'failsafe' });
828
+ // A document the full parser rejects but the subset parser accepted keeps
829
+ // the subset answer — whatever it read is what has always been read.
830
+ return full == null ? (subset.ok ? subset.value : null) : full;
831
+ } catch {
832
+ return subset.ok ? subset.value : null;
833
+ }
834
+ }
835
+
836
+ /**
837
+ * Whether a verb's value, as the subset parser read it, is really YAML syntax
838
+ * the subset parser does not understand and passed through as text: a flow
839
+ * sequence (`read: [A, B]`) or a quoted scalar (`read: "A <a@x>"`, `- 'Role'`).
840
+ * The subset parser SUCCEEDS on these — it just hands the brackets and quotes
841
+ * to the entry parser, which then drops the entry, or worse, keeps the quotes
842
+ * as part of a role name. Such a value means the full parser has to read the
843
+ * document.
844
+ */
845
+ function verbValuesNeedFullYaml(root: unknown): boolean {
846
+ if (!root || typeof root !== 'object' || Array.isArray(root)) return false;
847
+ const looksLikeSyntax = (v: unknown): boolean =>
848
+ typeof v === 'string' && /^\s*(\[\s*\S|\{\s*\S|"|')/.test(v);
849
+ for (const [key, value] of Object.entries(root as Record<string, unknown>)) {
850
+ if (!KNOWN_VERBS_SET.has(key)) continue;
851
+ if (looksLikeSyntax(value)) return true;
852
+ if (Array.isArray(value) && value.some(looksLikeSyntax)) return true;
853
+ }
854
+ return false;
855
+ }
856
+
802
857
  /**
803
858
  * Parse the access verbs a node file declares in its own YAML frontmatter.
804
859
  * Returns the per-verb entry lists, or null when the file has no frontmatter
@@ -812,9 +867,7 @@ export type OwnEntries = Record<Verb, ParsedEntry[]>;
812
867
  export function parseOwnAccessEntries(text: string): OwnEntries | null {
813
868
  const fm = extractFrontmatter(text);
814
869
  if (!fm.ok) return null;
815
- const parsed = parseYamlSubset(fm.frontmatter);
816
- if (!parsed.ok) return null;
817
- const root = parsed.value;
870
+ const root = ownEntriesRoot(fm.frontmatter);
818
871
  if (root == null || typeof root !== 'object' || Array.isArray(root)) return null;
819
872
 
820
873
  const entries = emptyEntries();
@@ -11,11 +11,14 @@ describe('cloneTrackingConfigArgs', () => {
11
11
  // A slashed draft name is the everyday case (`<email-localpart>/<slug>`), and
12
12
  // the branch lands verbatim inside the config KEY — pin the exact tuples so a
13
13
  // future quoting/encoding change can't silently re-point tracking.
14
- it('emits the three --replace-all tuples for a slashed branch', () => {
14
+ it('emits the tracking tuples for a slashed branch, and keeps gc in the foreground', () => {
15
15
  expect(cloneTrackingConfigArgs('feature/x')).toEqual([
16
16
  ['config', '--replace-all', 'remote.origin.fetch', ORIGIN_FETCH_REFSPEC],
17
17
  ['config', '--replace-all', 'branch.feature/x.remote', 'origin'],
18
18
  ['config', '--replace-all', 'branch.feature/x.merge', 'refs/heads/feature/x'],
19
+ // A detached `gc --auto` outlives the command this process waits on and
20
+ // is never reaped; stamped here so existing clones get it on next pull.
21
+ ['config', '--replace-all', 'gc.autoDetach', 'false'],
19
22
  ]);
20
23
  });
21
24
  });
@@ -68,12 +68,23 @@ export const SAFE_IMPLICIT_FETCH_ARGS: readonly string[] = [
68
68
  * Returns the arguments rather than running them so each caller can use its own
69
69
  * git runner (the git service's mutex-aware wrapper, `git -C` from the
70
70
  * workspace service) — no process is spawned here.
71
+ *
72
+ * `gc.autoDetach=false` rides along because it is stamped in the same two
73
+ * places — on adopt, and before every pull — which is how clones already on
74
+ * disk pick it up. After a fetch or a commit git may decide to run
75
+ * `gc --auto`, and by default it forks that into the BACKGROUND, detached from
76
+ * the git command this process is waiting on. The detached gc reparents to
77
+ * PID 1 and, when it exits, stays a zombie unless PID 1 reaps it — a node
78
+ * server does not. One shared host was found carrying 1,500 zombie `git`
79
+ * processes under a single platform container. With auto-detach off the gc
80
+ * runs inside the command that triggered it, and that command's exit reaps it.
71
81
  */
72
82
  export function cloneTrackingConfigArgs(branch: string): string[][] {
73
83
  return [
74
84
  ['config', '--replace-all', 'remote.origin.fetch', ORIGIN_FETCH_REFSPEC],
75
85
  ['config', '--replace-all', `branch.${branch}.remote`, 'origin'],
76
86
  ['config', '--replace-all', `branch.${branch}.merge`, `refs/heads/${branch}`],
87
+ ['config', '--replace-all', 'gc.autoDetach', 'false'],
77
88
  ];
78
89
  }
79
90
 
@@ -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);