@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.
- package/THIRD-PARTY-NOTICES.md +435 -641
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +59 -4
- package/dist/modules/access-model/access-grammar.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/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/modules/access/__tests__/access-own-read-grant.test.ts +116 -0
- package/src/modules/access-model/__tests__/access-grammar.test.ts +98 -0
- package/src/modules/access-model/access-grammar.ts +56 -3
- 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/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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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);
|