@bevel-software/platform-core-backend 0.29.0 → 0.30.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.
- package/dist/__tests__/test-repositories.d.ts +3 -0
- package/dist/__tests__/test-repositories.d.ts.map +1 -0
- package/dist/__tests__/test-repositories.js +18 -0
- package/dist/__tests__/test-repositories.js.map +1 -0
- package/dist/core/create-core-server.js +1 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +32 -4
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +8 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +4 -0
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +20 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +32 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +138 -16
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +20 -6
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +7 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/kb-fs/git-dir.d.ts +21 -0
- package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
- package/dist/modules/kb-fs/git-dir.js +58 -0
- package/dist/modules/kb-fs/git-dir.js.map +1 -0
- package/dist/modules/kb-fs/index.d.ts +2 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +2 -1
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/ref-files.d.ts +63 -0
- package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
- package/dist/modules/kb-fs/ref-files.js +249 -0
- package/dist/modules/kb-fs/ref-files.js.map +1 -0
- package/dist/modules/plugins/teams.routes.d.ts +4 -0
- package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
- package/dist/modules/plugins/teams.routes.js +9 -1
- package/dist/modules/plugins/teams.routes.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
- package/dist/modules/workflow/git/git.service.d.ts +29 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +177 -24
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
- package/dist/modules/workspace/agent-access.gate.js +2 -2
- package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
- package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
- package/dist/modules/workspace/bounded-matcher.js +261 -0
- package/dist/modules/workspace/bounded-matcher.js.map +1 -0
- package/dist/modules/workspace/fetch-instant.d.ts +9 -0
- package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
- package/dist/modules/workspace/fetch-instant.js +13 -0
- package/dist/modules/workspace/fetch-instant.js.map +1 -0
- package/dist/modules/workspace/index.d.ts +7 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +6 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
- package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.contract.js +19 -0
- package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
- package/dist/modules/workspace/kb-repository.d.ts +236 -0
- package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.js +811 -0
- package/dist/modules/workspace/kb-repository.js.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
- package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +0 -5
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
- package/dist/modules/workspace/set-aside-clone.js +139 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +68 -32
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +240 -161
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +13 -4
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +162 -336
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/test-repositories.ts +22 -0
- package/src/__tests__/workspace-service.ts +69 -0
- package/src/core/__tests__/lifecycle.test.ts +16 -0
- package/src/core/create-core-server.ts +1 -1
- package/src/core/create-core-services.ts +36 -3
- package/src/core/lifecycle.ts +10 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
- package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
- package/src/modules/access/access-control.interface.ts +22 -0
- package/src/modules/access/access-control.service.ts +142 -14
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
- package/src/modules/diff/diff.service.ts +6 -4
- package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
- package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
- package/src/modules/kb-fs/git-dir.ts +53 -0
- package/src/modules/kb-fs/index.ts +2 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/teams.routes.ts +14 -1
- package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
- package/src/modules/tool-auth/internal-token.service.ts +1 -1
- package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
- package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
- package/src/modules/tool-helpers/tool-handler.ts +2 -2
- package/src/modules/tool-helpers/tool.contract.ts +2 -2
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -1
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +184 -22
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
- package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
- package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
- package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
- package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
- package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
- package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
- package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
- package/src/modules/workspace/agent-access.gate.ts +2 -2
- package/src/modules/workspace/bounded-matcher.ts +328 -0
- package/src/modules/workspace/fetch-instant.ts +13 -0
- package/src/modules/workspace/index.ts +12 -0
- package/src/modules/workspace/kb-repository.contract.ts +124 -0
- package/src/modules/workspace/kb-repository.ts +844 -0
- package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
- package/src/modules/workspace/routine-write-policy.ts +0 -15
- package/src/modules/workspace/set-aside-clone.ts +140 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
- package/src/modules/workspace/workspace.service.ts +247 -169
- package/src/modules/workspace/workspace.tools.ts +179 -365
- package/src/test-setup.ts +5 -0
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import nodeFs from 'node:fs/promises';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import AdmZip from 'adm-zip';
|
|
@@ -21,7 +20,6 @@ import { workspaceIdForBranch } from '../../shared/workspace-id.js';
|
|
|
21
20
|
import { GitInternalsError, WorkflowValidationError } from '../../shared/domain-errors.js';
|
|
22
21
|
// Leaf-level shared primitive (same exception `workspace.service.ts` already
|
|
23
22
|
// relies on) — not a workflow service, so this stays inside the module boundary.
|
|
24
|
-
import { assertValidBranchName } from '../kb-fs/branch-name.js';
|
|
25
23
|
import {
|
|
26
24
|
assertInsideRepo,
|
|
27
25
|
assertRepoRootNameFree,
|
|
@@ -70,6 +68,7 @@ import {
|
|
|
70
68
|
type AgentGuideReader,
|
|
71
69
|
} from '../agent-guide/agent-guide.js';
|
|
72
70
|
import { removeEmptyDirs } from './empty-dirs.js';
|
|
71
|
+
import { endsTheSearch, type IPatternMatcher, type IPatternSearch } from './pattern-matcher.contract.js';
|
|
73
72
|
import { FIRST_RUN_SECTION_ID, firstRunNote, knowledgeFolderIsNew, type FirstRunStarterSource } from './first-run.js';
|
|
74
73
|
import { planMoveLinks } from './move-links.js';
|
|
75
74
|
import { MoveLockedError, MoveRacedError, type LockingFilesystem } from '../kb-fs/locking-filesystem.js';
|
|
@@ -674,7 +673,7 @@ type FileGrepOutcome =
|
|
|
674
673
|
async function grepOneFile(
|
|
675
674
|
fs: LocalFilesystem,
|
|
676
675
|
path: string,
|
|
677
|
-
|
|
676
|
+
matcher: IPatternSearch,
|
|
678
677
|
out: { path: string; line: number; text: string }[],
|
|
679
678
|
max: number,
|
|
680
679
|
docs: DocGrepState,
|
|
@@ -697,9 +696,10 @@ async function grepOneFile(
|
|
|
697
696
|
}
|
|
698
697
|
if (content === null) return 'no-text';
|
|
699
698
|
const lines = content.split('\n');
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
699
|
+
// matchingLines posts these to the worker in bounded batches, so no one
|
|
700
|
+
// copy of a large file runs on the request thread.
|
|
701
|
+
for (const i of await matcher.matchingLines(lines, max - out.length)) {
|
|
702
|
+
out.push({ path, line: i + 1, text: lines[i]!.slice(0, 300) });
|
|
703
703
|
}
|
|
704
704
|
return 'searched';
|
|
705
705
|
}
|
|
@@ -742,7 +742,7 @@ async function searchRootKind(
|
|
|
742
742
|
async function grepWalk(
|
|
743
743
|
fs: LocalFilesystem,
|
|
744
744
|
dir: string,
|
|
745
|
-
|
|
745
|
+
matcher: IPatternSearch,
|
|
746
746
|
out: { path: string; line: number; text: string }[],
|
|
747
747
|
max: number,
|
|
748
748
|
depth: number,
|
|
@@ -768,7 +768,7 @@ async function grepWalk(
|
|
|
768
768
|
if (e.type !== 'directory' && isFolderPlaceholder(e.name)) continue;
|
|
769
769
|
const p = dir ? `${dir}/${e.name}` : e.name;
|
|
770
770
|
if (e.type === 'directory') {
|
|
771
|
-
await grepWalk(fs, p,
|
|
771
|
+
await grepWalk(fs, p, matcher, out, max, depth + 1, gate, notifyRead, docs, skip);
|
|
772
772
|
} else if (skip(p)) {
|
|
773
773
|
continue;
|
|
774
774
|
} else {
|
|
@@ -778,9 +778,12 @@ async function grepWalk(
|
|
|
778
778
|
await notifyRead(p);
|
|
779
779
|
// A file the walk cannot read is silently skipped: one unreadable entry
|
|
780
780
|
// must not fail a search over the whole tree.
|
|
781
|
+
// A pattern too expensive to finish, or no worker free to run it, is not
|
|
782
|
+
// an unreadable file: either ends the whole search.
|
|
781
783
|
try {
|
|
782
|
-
await grepOneFile(fs, p,
|
|
783
|
-
} catch {
|
|
784
|
+
await grepOneFile(fs, p, matcher, out, max, docs);
|
|
785
|
+
} catch (err) {
|
|
786
|
+
if (endsTheSearch(err)) throw err;
|
|
784
787
|
continue;
|
|
785
788
|
}
|
|
786
789
|
}
|
|
@@ -869,9 +872,10 @@ export interface WorkspaceToolsPorts {
|
|
|
869
872
|
* Workspace tools) + unzip. Most just re-expose the SAME `LocalFilesystem`
|
|
870
873
|
* methods Mastra's tools call (via `ctx.getFilesystem(a.branch as string)`), so behaviour is
|
|
871
874
|
* identical and write-side ops still flow through the lock/commit pipeline.
|
|
872
|
-
* `edit_file
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
+
* `edit_file` and `grep` are tool-level (no filesystem method), so they're
|
|
876
|
+
* implemented here. There is no shell tool: an agent never composes a command
|
|
877
|
+
* line, and every git operation it needs is a platform tool with fixed
|
|
878
|
+
* arguments.
|
|
875
879
|
*/
|
|
876
880
|
export function registerWorkspaceTools(
|
|
877
881
|
registry: IToolRegistry,
|
|
@@ -935,6 +939,13 @@ export function registerWorkspaceTools(
|
|
|
935
939
|
* file primitives; without it `start_session` answers the id alone.
|
|
936
940
|
*/
|
|
937
941
|
disk?: ITreeWalker,
|
|
942
|
+
/**
|
|
943
|
+
* What runs a caller's `grep` pattern off the request thread, under a
|
|
944
|
+
* budget (see `pattern-matcher.contract.ts`). Owned by the composition
|
|
945
|
+
* root, which closes it with the graph. Optional for the harnesses about the
|
|
946
|
+
* other file primitives; without it `grep` is not mounted.
|
|
947
|
+
*/
|
|
948
|
+
patternMatcher?: IPatternMatcher,
|
|
938
949
|
): WorkspaceToolsPorts {
|
|
939
950
|
const { kbDirName } = kb;
|
|
940
951
|
/**
|
|
@@ -1586,15 +1597,6 @@ export function registerWorkspaceTools(
|
|
|
1586
1597
|
* read-only key may still not make (`request_file_download`).
|
|
1587
1598
|
*/
|
|
1588
1599
|
writeScope?: boolean;
|
|
1589
|
-
/**
|
|
1590
|
-
* This tool resolves `branch` ITSELF and must not be pre-checked here.
|
|
1591
|
-
* Only `execute_command` sets it: for an internal session that leaves the
|
|
1592
|
-
* argument off, it falls back to the caller's own focused branch (from its
|
|
1593
|
-
* signed token) rather than refusing. It still answers the same
|
|
1594
|
-
* `branch-required` kind when there is nothing to fall back on — see its
|
|
1595
|
-
* handler. Every other tool takes the check below.
|
|
1596
|
-
*/
|
|
1597
|
-
resolvesBranchItself?: boolean;
|
|
1598
1600
|
/** Arguments this tool refuses by name itself, with its own wording (see `ToolDefSpec.refusesItself`). */
|
|
1599
1601
|
refusesItself?: string[];
|
|
1600
1602
|
handler: ToolHandler;
|
|
@@ -1653,9 +1655,9 @@ export function registerWorkspaceTools(
|
|
|
1653
1655
|
redescribe();
|
|
1654
1656
|
kb.onLayoutApplied(redescribe);
|
|
1655
1657
|
agentAccessGate.notes.onChange(redescribe);
|
|
1656
|
-
// Internal-only tools
|
|
1657
|
-
//
|
|
1658
|
-
//
|
|
1658
|
+
// Internal-only tools keep their route mounted — our agent calls them over
|
|
1659
|
+
// the same loopback — but gate them to internal-source callers so an
|
|
1660
|
+
// external connection key can't invoke one by name.
|
|
1659
1661
|
router.post(
|
|
1660
1662
|
path.slice('/api'.length),
|
|
1661
1663
|
toolAuth,
|
|
@@ -1706,8 +1708,7 @@ export function registerWorkspaceTools(
|
|
|
1706
1708
|
);
|
|
1707
1709
|
}
|
|
1708
1710
|
},
|
|
1709
|
-
|
|
1710
|
-
{ write: spec.write, writeScope: spec.writeScope, ...(spec.resolvesBranchItself ? { branch: 'own' as const } : {}) },
|
|
1711
|
+
{ write: spec.write, writeScope: spec.writeScope },
|
|
1711
1712
|
),
|
|
1712
1713
|
);
|
|
1713
1714
|
};
|
|
@@ -2385,171 +2386,172 @@ export function registerWorkspaceTools(
|
|
|
2385
2386
|
},
|
|
2386
2387
|
});
|
|
2387
2388
|
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2389
|
+
// Mounted only when the composition supplied a matcher — see the
|
|
2390
|
+
// `patternMatcher` parameter. Core always does.
|
|
2391
|
+
if (patternMatcher) {
|
|
2392
|
+
const patterns = patternMatcher;
|
|
2393
|
+
mount({
|
|
2394
|
+
name: 'grep',
|
|
2395
|
+
gated: true,
|
|
2396
|
+
description:
|
|
2397
|
+
'Regex search across the workspace. Returns `{ matches: [{ path, line, text }] }` (capped). Use to find where something is defined/referenced. `path` may name a DIRECTORY (searches the subtree) or a single FILE (searches just that file); a path with nothing at it is an error, never an empty result. Searches INSIDE Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods), PDFs and email files (.eml/.msg) via their extracted text — matches there carry the extraction\'s line numbers, and the `[slide N]`/`[sheet: Name]`/`[page N]`/`[from]`/`[subject]` marker lines locate them; a bounded number of not-yet-extracted documents is extracted per call, and the result notes how many were skipped (re-run to cover them).',
|
|
2398
|
+
inputs: {
|
|
2399
|
+
type: 'object',
|
|
2400
|
+
properties: {
|
|
2401
|
+
branch: BRANCH_INPUT,
|
|
2402
|
+
pattern: str('JavaScript regular expression.'),
|
|
2403
|
+
path: str(`Subtree to search, or a single file to search on its own, with or without a leading slash — a path without the \`${kbDirName}/\` prefix is placed under \`${kbDirName}/\` (default: the whole repository, \`${kbDirName}/\`).`),
|
|
2404
|
+
ignore_case: { type: 'boolean', description: 'Case-insensitive match.' },
|
|
2405
|
+
max_results: { type: 'integer', minimum: 1, maximum: 1000, description: 'Cap on matches (default 200).' },
|
|
2406
|
+
sessionId: SESSION_ID_INPUT,
|
|
2407
|
+
},
|
|
2408
|
+
required: ['branch', 'pattern'],
|
|
2409
|
+
additionalProperties: false,
|
|
2402
2410
|
},
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
properties: { path: str('Workspace-relative file path.'), line: int('1-based line number.'), text: str('The matching line (truncated to 300 chars).') },
|
|
2415
|
-
required: ['path', 'line', 'text'],
|
|
2411
|
+
outputs: {
|
|
2412
|
+
type: 'object',
|
|
2413
|
+
properties: {
|
|
2414
|
+
matches: {
|
|
2415
|
+
type: 'array',
|
|
2416
|
+
description: 'Matching lines (capped by `max_results`).',
|
|
2417
|
+
items: {
|
|
2418
|
+
type: 'object',
|
|
2419
|
+
properties: { path: str('Workspace-relative file path.'), line: int('1-based line number.'), text: str('The matching line (truncated to 300 chars).') },
|
|
2420
|
+
required: ['path', 'line', 'text'],
|
|
2421
|
+
},
|
|
2416
2422
|
},
|
|
2423
|
+
truncated: { type: 'boolean', description: 'True if the match cap was hit and results may be incomplete.' },
|
|
2424
|
+
note: str('Present when the empty/partial result needs explaining: a `path` naming a file with no searchable text (image, binary, corrupt document), or documents (office/PDF/email files) left unsearched because their text was not yet extracted and the per-call extraction budget ran out — re-run grep to cover those.'),
|
|
2417
2425
|
},
|
|
2418
|
-
|
|
2419
|
-
note: str('Present when the empty/partial result needs explaining: a `path` naming a file with no searchable text (image, binary, corrupt document), or documents (office/PDF/email files) left unsearched because their text was not yet extracted and the per-call extraction budget ran out — re-run grep to cover those.'),
|
|
2426
|
+
required: ['matches', 'truncated'],
|
|
2420
2427
|
},
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2428
|
+
write: false,
|
|
2429
|
+
handler: async (a, ctx: ToolContext) => {
|
|
2430
|
+
// The caller's pattern never runs on the request thread: a backtracking
|
|
2431
|
+
// one would hold the event loop for everyone (see IPatternMatcher).
|
|
2432
|
+
const matcher = patterns.search(a.pattern as string, a.ignore_case ? 'i' : '');
|
|
2433
|
+
// No path means the whole REPOSITORY, not the workspace directory above
|
|
2434
|
+
// it. The workspace root also holds whatever an older build left beside
|
|
2435
|
+
// the checkout, and the read gate has no rules for a path outside the
|
|
2436
|
+
// repository — it answers "readable" — so a walk from there would hand
|
|
2437
|
+
// any caller the contents of every stray, including documents that were
|
|
2438
|
+
// uploaded to a restricted folder and landed beside it instead. The
|
|
2439
|
+
// boot note names those for an operator; no tool reads them.
|
|
2440
|
+
// An EMPTY string is the same absence: the normaliser leaves it alone
|
|
2441
|
+
// (an empty path is the handler's to explain), and here it would
|
|
2442
|
+
// otherwise name the workspace directory by another spelling.
|
|
2443
|
+
const searchRoot = typeof a.path === 'string' && a.path.length > 0 ? a.path : kbDirName;
|
|
2444
|
+
const fs = await ctx.getFilesystem(a.branch as string);
|
|
2445
|
+
const gate = readGateFor(a.branch as string, ctx);
|
|
2446
|
+
const out: { path: string; line: number; text: string }[] = [];
|
|
2447
|
+
const max = typeof a.max_results === 'number' ? Math.min(a.max_results, 1000) : 200;
|
|
2448
|
+
const docs: DocGrepState = { readers, uncachedBudget: UNCACHED_DOCS_PER_GREP, skippedUncached: 0 };
|
|
2449
|
+
// The empty root is the workspace itself — always a directory, and never
|
|
2450
|
+
// worth a stat.
|
|
2451
|
+
// A placeholder named on its own is searched as what it is to every
|
|
2452
|
+
// other tool: nothing.
|
|
2453
|
+
const kind =
|
|
2454
|
+
searchRoot === ''
|
|
2455
|
+
? 'directory'
|
|
2456
|
+
: isFolderPlaceholder(searchRoot)
|
|
2457
|
+
? 'missing'
|
|
2458
|
+
: await searchRootKind(fs, searchRoot);
|
|
2459
|
+
/** Why a single-file search found nothing, when "no matches" would be a lie. */
|
|
2460
|
+
let fileNote: string | undefined;
|
|
2461
|
+
// The guide is searched where it is read: a search of the repository
|
|
2462
|
+
// root covers it, and a search of its own path is a search of what
|
|
2463
|
+
// `read_file` answers there. The composed text is what is searched —
|
|
2464
|
+
// the knowledge base's own readable file first, then the platform's
|
|
2465
|
+
// guide — under the guide's path and with the line numbers a read of
|
|
2466
|
+
// it gives. The walk leaves that one file to this: a match the walk
|
|
2467
|
+
// made there would be the same line again, and one it COUNTED against
|
|
2468
|
+
// `max_results` would be a file later in the tree never searched while
|
|
2469
|
+
// the answer says nothing was cut. A file the caller may not read is
|
|
2470
|
+
// absent from it, as it is from the read.
|
|
2471
|
+
const guidePath = `${kbDirName}/${AGENT_GUIDE_FILE}`;
|
|
2472
|
+
const rel = toKbRelative(searchRoot, kbDirName);
|
|
2473
|
+
const coversGuide =
|
|
2474
|
+
agentGuide !== undefined && (kind === 'directory' ? rel === null : isAgentGuidePath(rel ?? ''));
|
|
2475
|
+
// The search root goes to the read hook — once, and only when it is
|
|
2476
|
+
// repository content: a folder, or a file of the knowledge base's own.
|
|
2477
|
+
// The guide's own path is not told here, because `guideAt` tells the
|
|
2478
|
+
// hook of the organisation's file there exactly as `read_file` does
|
|
2479
|
+
// (after the gate), and the platform's guide alone is nobody's file to
|
|
2480
|
+
// note. Each file the walk opens goes to the hook per file below, so a
|
|
2481
|
+
// hook sees every path a grep reached rather than only its root.
|
|
2482
|
+
if (!(coversGuide && kind !== 'directory')) {
|
|
2483
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch as string, searchRoot);
|
|
2484
|
+
}
|
|
2485
|
+
if (coversGuide) {
|
|
2486
|
+
if (kind === 'directory') {
|
|
2487
|
+
await grepWalk(
|
|
2488
|
+
fs,
|
|
2489
|
+
searchRoot,
|
|
2490
|
+
matcher,
|
|
2491
|
+
out,
|
|
2492
|
+
max,
|
|
2493
|
+
0,
|
|
2494
|
+
gate,
|
|
2495
|
+
(p) => notifyAgentRead(agentAccessGate, ctx, a.branch as string, p),
|
|
2496
|
+
docs,
|
|
2497
|
+
(p) => p === guidePath,
|
|
2498
|
+
);
|
|
2499
|
+
}
|
|
2500
|
+
const lines = (await guideAt(a.branch as string, ctx, guidePath)).split('\n');
|
|
2501
|
+
for (const i of await matcher.matchingLines(lines, max - out.length)) {
|
|
2502
|
+
out.push({ path: guidePath, line: i + 1, text: lines[i]!.slice(0, 300) });
|
|
2503
|
+
}
|
|
2504
|
+
} else if (kind === 'directory') {
|
|
2485
2505
|
await grepWalk(
|
|
2486
2506
|
fs,
|
|
2487
2507
|
searchRoot,
|
|
2488
|
-
|
|
2508
|
+
matcher,
|
|
2489
2509
|
out,
|
|
2490
2510
|
max,
|
|
2491
2511
|
0,
|
|
2492
2512
|
gate,
|
|
2493
2513
|
(p) => notifyAgentRead(agentAccessGate, ctx, a.branch as string, p),
|
|
2494
2514
|
docs,
|
|
2495
|
-
(p) => p === guidePath,
|
|
2496
2515
|
);
|
|
2516
|
+
} else {
|
|
2517
|
+
// Not a directory: the permission verdict comes BEFORE every other
|
|
2518
|
+
// one, and it is `read_file`'s own gate on the same path — so grep
|
|
2519
|
+
// answers a path the caller may not read exactly as read_file does,
|
|
2520
|
+
// and can never confirm the existence of one read_file would hide.
|
|
2521
|
+
// That ordering holds even when the stat itself failed: a denied path
|
|
2522
|
+
// gets the 403, never the filesystem's complaint about it.
|
|
2523
|
+
await assertCanRead(gate, searchRoot);
|
|
2524
|
+
if (kind === 'missing') throw notFound(searchRoot, 'Nothing to search');
|
|
2525
|
+
// A named FILE is searched directly: routing it through the walk would
|
|
2526
|
+
// fail its readdir and answer an empty match list, which the caller
|
|
2527
|
+
// cannot tell from "the pattern is not in this file".
|
|
2528
|
+
const outcome = await orNotFound(
|
|
2529
|
+
searchRoot,
|
|
2530
|
+
() => grepOneFile(fs, searchRoot, matcher, out, max, docs),
|
|
2531
|
+
'Nothing to search',
|
|
2532
|
+
);
|
|
2533
|
+
if (outcome === 'no-text') {
|
|
2534
|
+
fileNote =
|
|
2535
|
+
`"${displayPath(searchRoot)}" has no searchable text — it is an image, binary content, or a document ` +
|
|
2536
|
+
'whose text could not be extracted. There were no matches because there was nothing to search, not ' +
|
|
2537
|
+
'because the pattern is absent.';
|
|
2538
|
+
}
|
|
2497
2539
|
}
|
|
2498
|
-
const
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
docs,
|
|
2514
|
-
);
|
|
2515
|
-
} else {
|
|
2516
|
-
// Not a directory: the permission verdict comes BEFORE every other
|
|
2517
|
-
// one, and it is `read_file`'s own gate on the same path — so grep
|
|
2518
|
-
// answers a path the caller may not read exactly as read_file does,
|
|
2519
|
-
// and can never confirm the existence of one read_file would hide.
|
|
2520
|
-
// That ordering holds even when the stat itself failed: a denied path
|
|
2521
|
-
// gets the 403, never the filesystem's complaint about it.
|
|
2522
|
-
await assertCanRead(gate, searchRoot);
|
|
2523
|
-
if (kind === 'missing') throw notFound(searchRoot, 'Nothing to search');
|
|
2524
|
-
// A named FILE is searched directly: routing it through the walk would
|
|
2525
|
-
// fail its readdir and answer an empty match list, which the caller
|
|
2526
|
-
// cannot tell from "the pattern is not in this file".
|
|
2527
|
-
const outcome = await orNotFound(
|
|
2528
|
-
searchRoot,
|
|
2529
|
-
() => grepOneFile(fs, searchRoot, re, out, max, docs),
|
|
2530
|
-
'Nothing to search',
|
|
2531
|
-
);
|
|
2532
|
-
if (outcome === 'no-text') {
|
|
2533
|
-
fileNote =
|
|
2534
|
-
`"${displayPath(searchRoot)}" has no searchable text — it is an image, binary content, or a document ` +
|
|
2535
|
-
'whose text could not be extracted. There were no matches because there was nothing to search, not ' +
|
|
2536
|
-
'because the pattern is absent.';
|
|
2537
|
-
}
|
|
2538
|
-
}
|
|
2539
|
-
const note =
|
|
2540
|
-
fileNote ??
|
|
2541
|
-
(docs.skippedUncached > 0
|
|
2542
|
-
? `${docs.skippedUncached} document(s) (office/PDF/email files) were not searched: their text was not yet ` +
|
|
2543
|
-
`extracted and this call's extraction budget (${UNCACHED_DOCS_PER_GREP}) ran out. Re-run the ` +
|
|
2544
|
-
'same grep to extract and search the next batch.'
|
|
2545
|
-
: undefined);
|
|
2546
|
-
return {
|
|
2547
|
-
matches: out,
|
|
2548
|
-
truncated: out.length >= max,
|
|
2549
|
-
...(note !== undefined ? { note } : {}),
|
|
2550
|
-
};
|
|
2551
|
-
},
|
|
2552
|
-
});
|
|
2540
|
+
const note =
|
|
2541
|
+
fileNote ??
|
|
2542
|
+
(docs.skippedUncached > 0
|
|
2543
|
+
? `${docs.skippedUncached} document(s) (office/PDF/email files) were not searched: their text was not yet ` +
|
|
2544
|
+
`extracted and this call's extraction budget (${UNCACHED_DOCS_PER_GREP}) ran out. Re-run the ` +
|
|
2545
|
+
'same grep to extract and search the next batch.'
|
|
2546
|
+
: undefined);
|
|
2547
|
+
return {
|
|
2548
|
+
matches: out,
|
|
2549
|
+
truncated: out.length >= max,
|
|
2550
|
+
...(note !== undefined ? { note } : {}),
|
|
2551
|
+
};
|
|
2552
|
+
},
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2553
2555
|
|
|
2554
2556
|
// ── writes (through the lock/commit pipeline) ───────────────────────────
|
|
2555
2557
|
mount({
|
|
@@ -4065,194 +4067,6 @@ export function registerWorkspaceTools(
|
|
|
4065
4067
|
});
|
|
4066
4068
|
}
|
|
4067
4069
|
|
|
4068
|
-
// ── shell (internal-only) ───────────────────────────────────────────────
|
|
4069
|
-
mount({
|
|
4070
|
-
name: 'execute_command',
|
|
4071
|
-
gated: true,
|
|
4072
|
-
description:
|
|
4073
|
-
'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.',
|
|
4074
|
-
internalOnly: true,
|
|
4075
|
-
fileTool: false,
|
|
4076
|
-
// The one tool the mount's branch check skips: the handler below resolves an
|
|
4077
|
-
// omitted `branch` to the internal caller's focused branch before refusing.
|
|
4078
|
-
resolvesBranchItself: true,
|
|
4079
|
-
inputs: {
|
|
4080
|
-
type: 'object',
|
|
4081
|
-
properties: {
|
|
4082
|
-
branch: BRANCH_INPUT,
|
|
4083
|
-
command: str('The shell command line to run.'),
|
|
4084
|
-
timeout_ms: { type: 'integer', minimum: 1000, maximum: 120000, description: 'Timeout in ms (default 30000).' },
|
|
4085
|
-
sessionId: SESSION_ID_INPUT,
|
|
4086
|
-
},
|
|
4087
|
-
// `branch` stays REQUIRED here on purpose, and must not be relaxed to make
|
|
4088
|
-
// the handler's focused-branch fallback "reachable". This list is the
|
|
4089
|
-
// contract the model is TAUGHT — declaring it required is what makes the
|
|
4090
|
-
// caller always name its branch, which is the fix for the workspace this
|
|
4091
|
-
// tool used to resolve as "undefined". It is not a runtime gate: nothing in
|
|
4092
|
-
// the tool route validates inputs against this schema, so a branch-less call
|
|
4093
|
-
// still reaches the handler and still hits the `ctx.focusedBranch` fallback
|
|
4094
|
-
// (covered by "falls back to the session focused branch when branch is
|
|
4095
|
-
// omitted"). The fallback is a safety net for a model that disobeys the
|
|
4096
|
-
// contract — not a sanctioned calling convention to advertise.
|
|
4097
|
-
required: ['branch', 'command'],
|
|
4098
|
-
additionalProperties: false,
|
|
4099
|
-
},
|
|
4100
|
-
outputs: {
|
|
4101
|
-
type: 'object',
|
|
4102
|
-
properties: {
|
|
4103
|
-
stdout: str('Captured standard output (truncated to 50,000 chars).'),
|
|
4104
|
-
stderr: str('Captured standard error (truncated to 50,000 chars).'),
|
|
4105
|
-
exitCode: int('Process exit code; -1 if it was killed (timeout/abort) or failed to spawn.'),
|
|
4106
|
-
},
|
|
4107
|
-
required: ['stdout', 'stderr', 'exitCode'],
|
|
4108
|
-
},
|
|
4109
|
-
write: true,
|
|
4110
|
-
handler: async (a, ctx: ToolContext) => {
|
|
4111
|
-
// Resolve the branch FIRST — before the policy gates below — so a malformed
|
|
4112
|
-
// call always gets the 400 that names what is missing, rather than a 403
|
|
4113
|
-
// from a restricted session masking it. Unlike the file tools (which route
|
|
4114
|
-
// through `getOrCreateForUser` and fall back to the default branch),
|
|
4115
|
-
// execute_command turns `branch` straight into a workspace — so a bad value
|
|
4116
|
-
// would `encodeURIComponent(undefined)` to the string "undefined", then try
|
|
4117
|
-
// to CLONE a branch literally named "undefined", 500ing and leaving an
|
|
4118
|
-
// `<workspaces>/undefined/` shell behind.
|
|
4119
|
-
//
|
|
4120
|
-
// Two distinct cases:
|
|
4121
|
-
// - branch OMITTED (`undefined`): the in-app chat agent may leave it off a
|
|
4122
|
-
// call. For an internal session we fall back to the caller's own focused
|
|
4123
|
-
// branch (`ctx.focusedBranch`, from its signed token), so the command runs
|
|
4124
|
-
// against the workspace the session is on (e.g. `main`) end to end. An
|
|
4125
|
-
// external caller carries no focused branch, so it stays absent → 400.
|
|
4126
|
-
// - branch PRESENT but invalid (the literal "undefined"/"null", empty, or
|
|
4127
|
-
// malformed): a broken value, never a real branch — fail closed, never
|
|
4128
|
-
// silently reinterpret it as the focused branch.
|
|
4129
|
-
const raw = a.branch;
|
|
4130
|
-
const branch = raw === undefined ? ctx.focusedBranch : raw;
|
|
4131
|
-
if (typeof branch !== 'string' || branch.length === 0) {
|
|
4132
|
-
throw new ToolError(
|
|
4133
|
-
'execute_command requires a `branch`: pass the branch (draft) whose workspace to run the command in — the one you are currently working on.',
|
|
4134
|
-
400,
|
|
4135
|
-
// The same discriminator every other KB tool answers a branch-less
|
|
4136
|
-
// call with, so a client switches on one kind across the surface.
|
|
4137
|
-
// The MESSAGE stays this tool's own: it can name the focused-branch
|
|
4138
|
-
// fallback that only applies here.
|
|
4139
|
-
{ kind: 'branch-required' },
|
|
4140
|
-
);
|
|
4141
|
-
}
|
|
4142
|
-
// A stringified absent value. Both are syntactically valid git branch names,
|
|
4143
|
-
// so `assertValidBranchName` below happily accepts them — and accepting one
|
|
4144
|
-
// is the whole bug this tool is guarded for: `getOrCreateForBranch("undefined")`
|
|
4145
|
-
// clones a branch literally named "undefined" into `<workspaces>/undefined/`
|
|
4146
|
-
// and 500s. Reject them by name, ahead of the shape check.
|
|
4147
|
-
if (branch === 'undefined' || branch === 'null') {
|
|
4148
|
-
throw new ToolError(
|
|
4149
|
-
`execute_command got the literal string "${branch}" as \`branch\` — that is a stringified absent value, not a branch. ` +
|
|
4150
|
-
'Pass the real branch (draft) whose workspace to run the command in.',
|
|
4151
|
-
400,
|
|
4152
|
-
{ kind: 'branch-required' },
|
|
4153
|
-
);
|
|
4154
|
-
}
|
|
4155
|
-
// Then the SHAPE, via the one canonical validator every other branch path
|
|
4156
|
-
// uses — no hand-maintained list of suspicious literals, which would both
|
|
4157
|
-
// miss malformed refs (`..`, `-x`, `foo/.lock`) and reserve names git
|
|
4158
|
-
// considers perfectly valid. Runs on the RAW string, so a whitespace-padded
|
|
4159
|
-
// `" main "` is refused rather than silently trimmed into a different
|
|
4160
|
-
// branch than the caller passed.
|
|
4161
|
-
try {
|
|
4162
|
-
assertValidBranchName(branch);
|
|
4163
|
-
} catch (err) {
|
|
4164
|
-
throw new ToolError(
|
|
4165
|
-
`execute_command got an invalid \`branch\`: ${(err as Error).message} — ` +
|
|
4166
|
-
'pass the exact branch (draft) whose workspace to run the command in.',
|
|
4167
|
-
400,
|
|
4168
|
-
);
|
|
4169
|
-
}
|
|
4170
|
-
// Shell is a write path with no single target path to check, so the
|
|
4171
|
-
// write hook is asked once for the call itself, with no path — and the
|
|
4172
|
-
// run must not be restricted to a file type either, since shell could
|
|
4173
|
-
// write anything.
|
|
4174
|
-
writePolicy.assertUnrestricted(ctx.sessionId);
|
|
4175
|
-
await assertAgentWriteAllowed(agentAccessGate, ctx, branch);
|
|
4176
|
-
// Canonical per-branch bootstrap entry point — it owns the workspace-id
|
|
4177
|
-
// encoding and the single-flight clone, so the shell never derives a
|
|
4178
|
-
// workspace path by hand.
|
|
4179
|
-
const cwd = (await ctx.workspaceService.getOrCreateForBranch(branch)).absolutePath;
|
|
4180
|
-
const timeoutMs = typeof a.timeout_ms === 'number' ? a.timeout_ms : 30_000;
|
|
4181
|
-
// cwd is the workspace ROOT so shell paths match the workspace-relative
|
|
4182
|
-
// paths every other file tool uses — but the git clone lives one level
|
|
4183
|
-
// deeper, at <workspace>/<kbDirName>/.git. For a bare `git status` /
|
|
4184
|
-
// `git diff` (what the agent prompt teaches) point git there explicitly so
|
|
4185
|
-
// it resolves the KB clone instead of failing with "not a git repository".
|
|
4186
|
-
//
|
|
4187
|
-
// Because the command runs under `shell: true`, GIT_DIR/GIT_WORK_TREE
|
|
4188
|
-
// apply to the WHOLE shell, so restrict the override to a *standalone* bare
|
|
4189
|
-
// `git …` invocation. Otherwise the KB repo context would (a) leak into a
|
|
4190
|
-
// chained step that spawns its own git — `git commit && npm ci` — and (b)
|
|
4191
|
-
// override a caller's explicit `git -C …` / `--git-dir` / `--work-tree`.
|
|
4192
|
-
const command = (a.command as string).trim();
|
|
4193
|
-
const isStandaloneGit =
|
|
4194
|
-
/^git(\s|$)/.test(command) &&
|
|
4195
|
-
!/[;&|`\n]|\$\(/.test(command) &&
|
|
4196
|
-
!/(?:^|\s)(?:-C|--git-dir|--work-tree)(?:[=\s]|$)/.test(command);
|
|
4197
|
-
const env = isStandaloneGit
|
|
4198
|
-
? {
|
|
4199
|
-
...process.env,
|
|
4200
|
-
GIT_DIR: join(cwd, kbDirName, '.git'),
|
|
4201
|
-
GIT_WORK_TREE: join(cwd, kbDirName),
|
|
4202
|
-
}
|
|
4203
|
-
: process.env;
|
|
4204
|
-
return new Promise((resolve) => {
|
|
4205
|
-
// Own process group on POSIX, so a timeout or abort kills the WHOLE
|
|
4206
|
-
// tree. `shell: true` puts an `sh` between us and the command; killing
|
|
4207
|
-
// only that shell left whatever it had started running on — orphaned,
|
|
4208
|
-
// unreaped, still holding the workspace and its memory. A self-hosted
|
|
4209
|
-
// deployment leaked ~4,500 tasks that way before nothing could fork.
|
|
4210
|
-
const detached = process.platform !== 'win32';
|
|
4211
|
-
const child = spawn(a.command as string, { cwd, shell: true, env, detached });
|
|
4212
|
-
let stdout = '';
|
|
4213
|
-
let stderr = '';
|
|
4214
|
-
const killTree = () => {
|
|
4215
|
-
try {
|
|
4216
|
-
if (detached && child.pid) process.kill(-child.pid, 'SIGKILL');
|
|
4217
|
-
else child.kill('SIGKILL');
|
|
4218
|
-
} catch {
|
|
4219
|
-
try {
|
|
4220
|
-
child.kill('SIGKILL');
|
|
4221
|
-
} catch {
|
|
4222
|
-
// Already gone.
|
|
4223
|
-
}
|
|
4224
|
-
}
|
|
4225
|
-
};
|
|
4226
|
-
const timer = setTimeout(killTree, timeoutMs);
|
|
4227
|
-
// If the caller disconnects (request aborted), kill the tree instead of
|
|
4228
|
-
// letting it run to the timeout; the resulting `close`/`error` settles
|
|
4229
|
-
// the promise through `finish`.
|
|
4230
|
-
const onAbort = killTree;
|
|
4231
|
-
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
4232
|
-
const finish = (value: unknown) => {
|
|
4233
|
-
clearTimeout(timer);
|
|
4234
|
-
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
4235
|
-
resolve(value);
|
|
4236
|
-
};
|
|
4237
|
-
// Cap accumulation while streaming so a verbose command can't exhaust
|
|
4238
|
-
// memory before `close`; the final slice keeps the output bound.
|
|
4239
|
-
const OUTPUT_CAP = 50_000;
|
|
4240
|
-
child.stdout.on('data', (d) => {
|
|
4241
|
-
if (stdout.length < OUTPUT_CAP) stdout += d.toString();
|
|
4242
|
-
});
|
|
4243
|
-
child.stderr.on('data', (d) => {
|
|
4244
|
-
if (stderr.length < OUTPUT_CAP) stderr += d.toString();
|
|
4245
|
-
});
|
|
4246
|
-
child.on('close', (code) => {
|
|
4247
|
-
finish({ stdout: stdout.slice(0, 50_000), stderr: stderr.slice(0, 50_000), exitCode: code ?? -1 });
|
|
4248
|
-
});
|
|
4249
|
-
child.on('error', (err) => {
|
|
4250
|
-
finish({ stdout, stderr: `${stderr}\n${String(err)}`.slice(0, 50_000), exitCode: -1 });
|
|
4251
|
-
});
|
|
4252
|
-
});
|
|
4253
|
-
},
|
|
4254
|
-
});
|
|
4255
|
-
|
|
4256
4070
|
// The one read `open_page` borrows — see `readForTool` above.
|
|
4257
4071
|
return { readForTool };
|
|
4258
4072
|
}
|