@bevel-software/platform-core-backend 0.28.1 → 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/agent-guide/access-control.md +15 -0
- package/agent-guide/skills.md +8 -0
- 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/catalog-revision.d.ts.map +1 -1
- package/dist/core/catalog-revision.js +4 -2
- package/dist/core/catalog-revision.js.map +1 -1
- package/dist/core/compose-embed.d.ts +35 -0
- package/dist/core/compose-embed.d.ts.map +1 -0
- package/dist/core/compose-embed.js +14 -0
- package/dist/core/compose-embed.js.map +1 -0
- package/dist/core/core-ports.d.ts +10 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +4 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +57 -18
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +19 -6
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +17 -6
- 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 +68 -15
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +282 -58
- 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/auth/index.d.ts +15 -0
- package/dist/modules/auth/index.d.ts.map +1 -0
- package/dist/modules/auth/index.js +20 -0
- package/dist/modules/auth/index.js.map +1 -0
- 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/embed/embed.interface.d.ts +22 -0
- package/dist/modules/embed/embed.interface.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.d.ts +28 -14
- package/dist/modules/embed/embed.service.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.js +38 -17
- package/dist/modules/embed/embed.service.js.map +1 -1
- package/dist/modules/embed/embed.tools.d.ts.map +1 -1
- package/dist/modules/embed/embed.tools.js +10 -2
- package/dist/modules/embed/embed.tools.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 +9 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -0
- package/dist/modules/kb-fs/index.js +3 -0
- package/dist/modules/kb-fs/index.js.map +1 -0
- 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/join-request-jobs.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-jobs.service.js +43 -8
- package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
- 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/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +8 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +115 -11
- package/dist/modules/skills/skills.tools.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/default-branch-write.d.ts +69 -0
- package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
- package/dist/modules/tool-helpers/default-branch-write.js +63 -0
- package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +1 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -1
- package/dist/modules/tool-helpers/index.js +1 -0
- package/dist/modules/tool-helpers/index.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/file-lock.service.d.ts +27 -5
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +70 -18
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +42 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +201 -24
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/index.d.ts +8 -0
- package/dist/modules/workflow/index.d.ts.map +1 -0
- package/dist/modules/workflow/index.js +8 -0
- package/dist/modules/workflow/index.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +4 -24
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +20 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +5 -3
- package/dist/modules/workflow/workflow.service.js.map +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 +17 -0
- package/dist/modules/workspace/index.d.ts.map +1 -0
- package/dist/modules/workspace/index.js +16 -0
- package/dist/modules/workspace/index.js.map +1 -0
- 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 +268 -356
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/workspace-id.d.ts +17 -0
- package/dist/shared/workspace-id.d.ts.map +1 -1
- package/dist/shared/workspace-id.js +24 -0
- package/dist/shared/workspace-id.js.map +1 -1
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/mcp-app/page.html +142 -8
- 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__/compose-embed.test.ts +65 -0
- package/src/core/__tests__/lifecycle.test.ts +45 -26
- package/src/core/catalog-revision.ts +4 -2
- package/src/core/compose-embed.ts +50 -0
- package/src/core/core-ports.ts +10 -0
- package/src/core/create-core-server.ts +8 -2
- package/src/core/create-core-services.ts +63 -24
- package/src/core/lifecycle.ts +34 -12
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
- 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 +302 -58
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/auth/index.ts +29 -0
- 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/embed/__tests__/embed.service.test.ts +76 -3
- package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
- package/src/modules/embed/embed.interface.ts +24 -0
- package/src/modules/embed/embed.service.ts +44 -19
- package/src/modules/embed/embed.tools.ts +10 -2
- 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 +8 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/join-request-jobs.service.ts +45 -8
- 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/skills/__tests__/branch-skills.tools.test.ts +1 -1
- package/src/modules/skills/skills.contract.ts +4 -0
- package/src/modules/skills/skills.tools.ts +120 -10
- 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/__tests__/can-write.tools.test.ts +581 -0
- package/src/modules/tool-helpers/default-branch-write.ts +90 -0
- package/src/modules/tool-helpers/index.ts +5 -0
- 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 +42 -3
- package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
- package/src/modules/workflow/file-lock.service.ts +79 -18
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +207 -22
- package/src/modules/workflow/index.ts +7 -0
- package/src/modules/workflow/pending-commits.service.ts +4 -23
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workflow/workflow.routes.ts +24 -1
- package/src/modules/workflow/workflow.service.ts +3 -2
- 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 +174 -309
- 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 +26 -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 +299 -385
- package/src/shared/workspace-id.ts +24 -0
- package/src/test-setup.ts +5 -0
|
@@ -14,6 +14,7 @@ import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
|
14
14
|
import { ToolError, type ToolContext } from '../../tool-helpers/tool.contract.js';
|
|
15
15
|
import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
|
|
16
16
|
import { registerWorkspaceTools } from '../workspace.tools.js';
|
|
17
|
+
import { WorkerPatternMatcher, type WorkerPatternMatcherOptions } from '../bounded-matcher.js';
|
|
17
18
|
import { sharedFileRules, sharedFileRulesSection } from '../../agent-instructions/shared-file-rules.js';
|
|
18
19
|
import { composeAgentGuide } from '../../agent-guide/agent-guide.js';
|
|
19
20
|
import { GUIDE_FIRST_SENTENCE } from '../../tool-registry/guide-first.js';
|
|
@@ -30,7 +31,6 @@ import { AccessControlService } from '../../access/access-control.service.js';
|
|
|
30
31
|
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
31
32
|
import type { WorkspaceService } from '../workspace.service.js';
|
|
32
33
|
import { DEFAULT_KB_LAYOUT, isBranchAuthoredBy, isOwnSuggestionsBranch } from '@bevel-software/platform-shared';
|
|
33
|
-
import { assertValidBranchName } from '../../kb-fs/branch-name.js';
|
|
34
34
|
import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
|
|
35
35
|
import { GIT_INTERNALS_MESSAGE, PathNotFoundError } from '../../../shared/domain-errors.js';
|
|
36
36
|
import { AccessDeniedError } from '../../access-model/access-errors.js';
|
|
@@ -48,12 +48,18 @@ const allowAll = {
|
|
|
48
48
|
canReadBatch: async (_w: string, _u: string, paths: string[]) =>
|
|
49
49
|
new Map(paths.map((p) => [p, true])),
|
|
50
50
|
// The verbs `file_stat` and the move/delete preflight ask about. Allowing
|
|
51
|
-
// everything
|
|
52
|
-
//
|
|
51
|
+
// everything keeps these tests about what they test rather than about
|
|
52
|
+
// access.
|
|
53
53
|
canWrite: async () => true,
|
|
54
|
+
canWriteBatch: async (_w: string, _u: string, paths: string[]) =>
|
|
55
|
+
new Map(paths.map((p) => [p, true])),
|
|
54
56
|
canDownload: async () => true,
|
|
55
57
|
canOwner: async () => true,
|
|
56
|
-
|
|
58
|
+
// The `canWrite` the read tools report is judged at the default branch's
|
|
59
|
+
// HEAD, so a write-scope caller here gets a COMPUTED `true` — never the
|
|
60
|
+
// fail-closed `false` of a missing method or an unresolvable ref.
|
|
61
|
+
canWriteBatchAtRef: async (_w: string, _r: string, _u: string, paths: string[]) =>
|
|
62
|
+
new Map(paths.map((p) => [p, true])),
|
|
57
63
|
// The `after` half of a move's or copy's preview. Allowing everything here
|
|
58
64
|
// keeps it the same answer as the four gates above; what the relocation of
|
|
59
65
|
// a folder's `access.md` files actually does to it is exercised against
|
|
@@ -72,7 +78,7 @@ function denyReads(denied: Set<string>): IAccessControl {
|
|
|
72
78
|
|
|
73
79
|
/**
|
|
74
80
|
* File primitives over a REAL LocalFilesystem on a temp dir — proves read_file /
|
|
75
|
-
* write_file / edit_file / list_files / grep / file_stat
|
|
81
|
+
* write_file / edit_file / list_files / grep / file_stat
|
|
76
82
|
* actually work (the handlers re-expose the same filesystem methods Mastra
|
|
77
83
|
* uses). No locking pipeline (plain LocalFilesystem), so writes don't commit.
|
|
78
84
|
*/
|
|
@@ -83,11 +89,9 @@ let tempDir = '';
|
|
|
83
89
|
let docCacheDir = '';
|
|
84
90
|
let fs: LocalFilesystem;
|
|
85
91
|
/**
|
|
86
|
-
* Every branch
|
|
87
|
-
*
|
|
88
|
-
* production
|
|
89
|
-
* missing-branch guard short-circuits before any clone of a branch literally
|
|
90
|
-
* named "undefined" is attempted.
|
|
92
|
+
* Every branch a tool resolved a workspace for through `getOrCreateForBranch`.
|
|
93
|
+
* Resolving a workspace is what triggers the lazy per-branch worktree add in
|
|
94
|
+
* production.
|
|
91
95
|
*/
|
|
92
96
|
let workspacePathCalls: string[] = [];
|
|
93
97
|
/** Every `(archive, destination)` pair `unzip` handed the service. */
|
|
@@ -108,8 +112,7 @@ let writePolicy: RoutineWritePolicyService;
|
|
|
108
112
|
let guideText = 'THE PLATFORM GUIDE\n';
|
|
109
113
|
/**
|
|
110
114
|
* The focused branch the resolved `ToolContext` carries — mirrors the branch an
|
|
111
|
-
* internal token bakes for the in-process agent.
|
|
112
|
-
* branch-less `execute_command` falls back to the session's own workspace.
|
|
115
|
+
* internal token bakes for the in-process agent.
|
|
113
116
|
*/
|
|
114
117
|
let focusedBranch: string | undefined;
|
|
115
118
|
/** The registry the tools were mounted into, so a test can inspect their defs. */
|
|
@@ -136,6 +139,12 @@ let callerSource: 'internal' | 'external' | 'session' = 'internal';
|
|
|
136
139
|
* ONCE (it clears itself), so a test's later writes are ordinary ones.
|
|
137
140
|
*/
|
|
138
141
|
let raceHook: (() => Promise<void>) | null = null;
|
|
142
|
+
/**
|
|
143
|
+
* The grep matcher `start()` wires, built from `matcherOptions` (the
|
|
144
|
+
* defaults, unless a test narrows the pool) and closed after each test.
|
|
145
|
+
*/
|
|
146
|
+
let patternMatcher: WorkerPatternMatcher | undefined;
|
|
147
|
+
let matcherOptions: WorkerPatternMatcherOptions = {};
|
|
139
148
|
|
|
140
149
|
async function start(
|
|
141
150
|
scope: 'read' | 'write' = 'write',
|
|
@@ -144,6 +153,8 @@ async function start(
|
|
|
144
153
|
userEmail = 'e@x',
|
|
145
154
|
): Promise<string> {
|
|
146
155
|
raceHook = null;
|
|
156
|
+
await patternMatcher?.close();
|
|
157
|
+
patternMatcher = new WorkerPatternMatcher(matcherOptions);
|
|
147
158
|
hooks = new WorkflowHooks();
|
|
148
159
|
notes = new ToolDescriptionNotes();
|
|
149
160
|
callerSource = 'internal';
|
|
@@ -305,7 +316,7 @@ async function start(
|
|
|
305
316
|
recoveryBotEmail: RECOVERY_BOT,
|
|
306
317
|
hooks,
|
|
307
318
|
notes,
|
|
308
|
-
}, writePolicy, {} as never /* sessionSink — start_session not exercised here */, undefined, undefined, undefined, async () => guideText);
|
|
319
|
+
}, writePolicy, {} as never /* sessionSink — start_session not exercised here */, undefined, undefined, undefined, async () => guideText, undefined, undefined, undefined, patternMatcher);
|
|
309
320
|
app.use('/api', router);
|
|
310
321
|
httpServer = await new Promise<HttpServer>((r) => {
|
|
311
322
|
const s = app.listen(0, () => r(s));
|
|
@@ -336,10 +347,13 @@ const post = (url: string, body: unknown = {}) =>
|
|
|
336
347
|
beforeEach(() => {
|
|
337
348
|
/* fresh per test via start() */
|
|
338
349
|
guideText = 'THE PLATFORM GUIDE\n';
|
|
350
|
+
matcherOptions = {};
|
|
339
351
|
});
|
|
340
352
|
afterEach(async () => {
|
|
341
353
|
if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
|
|
342
354
|
httpServer = undefined;
|
|
355
|
+
await patternMatcher?.close();
|
|
356
|
+
patternMatcher = undefined;
|
|
343
357
|
if (tempDir) {
|
|
344
358
|
await rm(tempDir, { recursive: true, force: true });
|
|
345
359
|
tempDir = '';
|
|
@@ -350,29 +364,10 @@ afterEach(async () => {
|
|
|
350
364
|
}
|
|
351
365
|
});
|
|
352
366
|
|
|
353
|
-
/**
|
|
354
|
-
* True once `pid` has been killed: either it is gone, or it is a zombie
|
|
355
|
-
* waiting on a reaper (Linux: `State:\tZ` in /proc). `kill(pid, 0)` alone
|
|
356
|
-
* cannot tell a zombie from a live process.
|
|
357
|
-
*/
|
|
358
|
-
async function isDeadOrZombie(pid: number): Promise<boolean> {
|
|
359
|
-
try {
|
|
360
|
-
process.kill(pid, 0);
|
|
361
|
-
} catch {
|
|
362
|
-
return true;
|
|
363
|
-
}
|
|
364
|
-
try {
|
|
365
|
-
const status = await readFile(`/proc/${pid}/status`, 'utf8');
|
|
366
|
-
return /^State:\s*Z/m.test(status);
|
|
367
|
-
} catch {
|
|
368
|
-
return false; // no /proc (macOS): alive as far as the signal says
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
367
|
describe('workspace file primitives', () => {
|
|
373
368
|
it('read_file returns the content', async () => {
|
|
374
369
|
const base = await start();
|
|
375
|
-
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n' });
|
|
370
|
+
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n', canWrite: true });
|
|
376
371
|
});
|
|
377
372
|
|
|
378
373
|
it('write_file then read_file round-trips', async () => {
|
|
@@ -505,7 +500,7 @@ describe('workspace file primitives', () => {
|
|
|
505
500
|
// that same text into an agent: a leading slash names the same path.
|
|
506
501
|
it('every path input accepts a leading slash as the same workspace path', async () => {
|
|
507
502
|
const base = await start();
|
|
508
|
-
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `/${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n' });
|
|
503
|
+
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `/${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n', canWrite: true });
|
|
509
504
|
expect(await (await post(`${base}/api/agent/tools/file_stat`, { path: `/${KB_DIR}/a.md` })).json()).toMatchObject({ type: 'file' });
|
|
510
505
|
await post(`${base}/api/agent/tools/write_file`, { path: `/${KB_DIR}/b.md`, content: 'fresh' });
|
|
511
506
|
await post(`${base}/api/agent/tools/write_file`, { path: `/${KB_DIR}/c.md`, content: 'batch' });
|
|
@@ -597,248 +592,15 @@ describe('workspace file primitives', () => {
|
|
|
597
592
|
expect(empty.matches.map((m) => m.path)).toEqual([`${KB_DIR}/n.md`]);
|
|
598
593
|
});
|
|
599
594
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
expect(res.stdout).toContain('hello-exec');
|
|
604
|
-
expect(res.exitCode).toBe(0);
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
// The command runs under `sh -c`; a timeout used to SIGKILL that shell and
|
|
608
|
-
// leave what it had started running on as an orphan. The whole process
|
|
609
|
-
// group goes now. POSIX only: Windows has no process groups to kill.
|
|
610
|
-
it.skipIf(process.platform === 'win32')('execute_command kills what the command started, not just its shell, on timeout', async () => {
|
|
611
|
-
const base = await start();
|
|
612
|
-
// Print the grandchild's pid, then block on it so the timeout fires. The
|
|
613
|
-
// timeout is the schema's minimum — inside the tool's declared contract,
|
|
614
|
-
// and long enough that a slow spawn still prints the pid before it fires.
|
|
615
|
-
const res = (await (
|
|
616
|
-
await post(`${base}/api/agent/tools/execute_command`, {
|
|
617
|
-
branch: 'main',
|
|
618
|
-
command: 'sleep 30 & echo $!; wait',
|
|
619
|
-
timeout_ms: 1000,
|
|
620
|
-
})
|
|
621
|
-
).json()) as { stdout: string; exitCode: number };
|
|
622
|
-
expect(res.exitCode).toBe(-1);
|
|
623
|
-
const grandchild = Number(res.stdout.trim());
|
|
624
|
-
expect(grandchild).toBeGreaterThan(0);
|
|
625
|
-
// Dead means killed, not reaped: a SIGKILLed process keeps its pid — and
|
|
626
|
-
// answers `kill(pid, 0)` as alive — until whoever inherited it reaps it,
|
|
627
|
-
// and on a host with no reaper (the very thing this guards) that is
|
|
628
|
-
// never. So where /proc exists, a zombie (`State: Z`) counts as dead; a
|
|
629
|
-
// vanished pid counts everywhere. Poll to a deadline rather than trust
|
|
630
|
-
// one fixed pause. Without the fix the grandchild is still sleeping at
|
|
631
|
-
// the deadline, so what fails is the deadline — never an early check.
|
|
632
|
-
const deadline = Date.now() + 3_000;
|
|
633
|
-
let dead = false;
|
|
634
|
-
while (!dead && Date.now() < deadline) {
|
|
635
|
-
dead = await isDeadOrZombie(grandchild);
|
|
636
|
-
if (!dead) await new Promise((r) => setTimeout(r, 50));
|
|
637
|
-
}
|
|
638
|
-
expect(dead).toBe(true);
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
it('execute_command 400s when no branch is given AND no focused branch (external caller)', async () => {
|
|
642
|
-
const base = await start();
|
|
643
|
-
// No `branch` arg and no `ctx.focusedBranch` (an external caller carries no
|
|
644
|
-
// focused branch): the branch context is genuinely absent, so it must NOT
|
|
645
|
-
// resolve the workspace id to "undefined" and try to clone a branch literally
|
|
646
|
-
// named "undefined" — it fails closed with a clear 4xx naming the missing
|
|
647
|
-
// branch context, BEFORE any workspace resolve.
|
|
648
|
-
expect(focusedBranch).toBeUndefined();
|
|
649
|
-
// `postRaw`: the absent field IS the test here, so the body goes as written.
|
|
650
|
-
const res = await postRaw(`${base}/api/agent/tools/execute_command`, { command: 'echo should-not-run' });
|
|
651
|
-
expect(res.status).toBe(400);
|
|
652
|
-
expect((await res.json()).error).toMatch(/branch/i);
|
|
653
|
-
// The guard runs before `getOrCreateForBranch`, so no workspace (least of all
|
|
654
|
-
// the "undefined" one) is ever resolved — proving no clone is attempted.
|
|
655
|
-
expect(workspacePathCalls).toEqual([]);
|
|
656
|
-
});
|
|
657
|
-
|
|
658
|
-
it('execute_command falls back to the session focused branch when branch is omitted', async () => {
|
|
659
|
-
const base = await start();
|
|
660
|
-
// The in-app chat agent, focused on `main`, may leave `branch` off a call.
|
|
661
|
-
// For an internal session that carries a focused branch, the shell runs
|
|
662
|
-
// against that branch's workspace and returns output end to end (AC2) —
|
|
663
|
-
// rather than failing closed the way a context-less external call does.
|
|
664
|
-
focusedBranch = 'main';
|
|
665
|
-
const res = (await (await postRaw(`${base}/api/agent/tools/execute_command`, { command: 'echo hello-exec' })).json()) as { stdout: string; exitCode: number };
|
|
666
|
-
expect(res.exitCode).toBe(0);
|
|
667
|
-
expect(res.stdout).toContain('hello-exec');
|
|
668
|
-
// Resolved against the focused branch — never an "undefined" workspace.
|
|
669
|
-
expect(workspacePathCalls).toEqual(['main']);
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
it('execute_command does NOT fall back to the focused branch for an invalid value', async () => {
|
|
673
|
-
const base = await start();
|
|
674
|
-
// A present-but-broken branch ("undefined") must fail closed even when a
|
|
675
|
-
// focused branch is available — a broken value is never silently reinterpreted
|
|
676
|
-
// as the session's branch.
|
|
677
|
-
focusedBranch = 'main';
|
|
678
|
-
const res = await post(`${base}/api/agent/tools/execute_command`, { branch: 'undefined', command: 'echo should-not-run' });
|
|
679
|
-
expect(res.status).toBe(400);
|
|
680
|
-
expect((await res.json()).error).toMatch(/branch/i);
|
|
681
|
-
expect(workspacePathCalls).toEqual([]);
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
// Every branch value that must fail closed without resolving a workspace.
|
|
685
|
-
// `''` (like the missing field above) is refused a layer earlier by the input
|
|
686
|
-
// schema's `minLength: 1`; the rest satisfy the schema and reach the handler,
|
|
687
|
-
// so they exercise the guard itself. Both layers must produce the same 400.
|
|
688
|
-
// The malformed refs below are rejected by the shared `assertValidBranchName`
|
|
689
|
-
// shape check; the literal "undefined"/"null" — which that validator ACCEPTS
|
|
690
|
-
// as ordinary git refs — are rejected by an explicit by-name check ahead of it
|
|
691
|
-
// (AC3 requires them to fail closed; see the dedicated test below).
|
|
692
|
-
const invalidBranches: Array<[label: string, branch: string]> = [
|
|
693
|
-
['empty', ''],
|
|
694
|
-
['whitespace-only', ' '],
|
|
695
|
-
// Malformed refs — caught by the canonical `assertValidBranchName` shape
|
|
696
|
-
// check, not by any hand-maintained list of literals.
|
|
697
|
-
['whitespace-padded (never silently trimmed)', ' main '],
|
|
698
|
-
['containing a space', 'alice/my draft'],
|
|
699
|
-
['a ".." ref', 'foo..bar'],
|
|
700
|
-
['containing ".."', 'alice/../../etc'],
|
|
701
|
-
['a leading dash (git would read it as a flag)', '-x'],
|
|
702
|
-
['starting with "--" (flag injection)', '--upload-pack=touch'],
|
|
703
|
-
['containing "//"', 'alice//draft'],
|
|
704
|
-
['ending with "/"', 'alice/'],
|
|
705
|
-
['a segment starting with "."', 'alice/.hidden'],
|
|
706
|
-
['a segment ending with ".lock"', 'alice/draft.lock'],
|
|
707
|
-
['containing "@{"', 'alice/draft@{0}'],
|
|
708
|
-
['containing a shell metacharacter', 'alice/draft;rm -rf /'],
|
|
709
|
-
];
|
|
710
|
-
for (const [label, branch] of invalidBranches) {
|
|
711
|
-
it(`execute_command 400s when branch is ${label}`, async () => {
|
|
712
|
-
const base = await start();
|
|
713
|
-
const res = await post(`${base}/api/agent/tools/execute_command`, { branch, command: 'echo should-not-run' });
|
|
714
|
-
expect(res.status).toBe(400);
|
|
715
|
-
expect((await res.json()).error).toMatch(/branch/i);
|
|
716
|
-
// No workspace is resolved for the bad value — proving no clone is attempted.
|
|
717
|
-
expect(workspacePathCalls).toEqual([]);
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* The production regression this ticket bounced on: the guard's hand-written
|
|
723
|
-
* `"undefined"`/`"null"` rejection was replaced by the canonical
|
|
724
|
-
* `assertValidBranchName` alone. Both literals are *syntactically valid* git
|
|
725
|
-
* branch names, so the validator accepts them — a branch-less call then reached
|
|
726
|
-
* `getOrCreateForBranch("undefined")`, cloned a branch literally named
|
|
727
|
-
* `undefined` into `/app/workspaces/undefined/`, and 500'd in production.
|
|
728
|
-
*
|
|
729
|
-
* AC3 requires these literals to fail closed. Pinning the validator's own
|
|
730
|
-
* behaviour here is the point: it documents WHY the literal check cannot be
|
|
731
|
-
* folded into the shape check, so the next person who "simplifies" the guard
|
|
732
|
-
* down to `assertValidBranchName` gets a red test naming the reason rather than
|
|
733
|
-
* a fresh production 500.
|
|
734
|
-
*/
|
|
735
|
-
it('rejects "undefined"/"null" even though the canonical validator accepts them', async () => {
|
|
736
|
-
for (const literal of ['undefined', 'null']) {
|
|
737
|
-
expect(() => assertValidBranchName(literal), `${literal} is a valid git ref`).not.toThrow();
|
|
738
|
-
}
|
|
595
|
+
// An agent never composes a shell command line: there is no tool that runs
|
|
596
|
+
// one, and a call to the old one by name answers as any unknown tool.
|
|
597
|
+
it('offers no shell tool, and a call to execute_command by name finds nothing', async () => {
|
|
739
598
|
const base = await start();
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
expect((await res.json()).error).toContain(literal);
|
|
745
|
-
}
|
|
746
|
-
expect(workspacePathCalls).toEqual([]);
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
it('answers a branch-less call with the same kind every other KB tool uses', async () => {
|
|
750
|
-
const base = await start();
|
|
751
|
-
// The MESSAGE here is this tool's own — only `execute_command` has a
|
|
752
|
-
// focused-branch fallback to explain. The DISCRIMINATOR is shared, so a
|
|
753
|
-
// client switches on one kind across the whole surface instead of matching
|
|
754
|
-
// prose per tool.
|
|
755
|
-
for (const body of [{ command: 'x' }, { branch: '', command: 'x' }, { branch: 'undefined', command: 'x' }]) {
|
|
756
|
-
const res = await postRaw(`${base}/api/agent/tools/execute_command`, body);
|
|
757
|
-
expect(res.status).toBe(400);
|
|
758
|
-
expect((await res.json()).kind).toBe('branch-required');
|
|
759
|
-
}
|
|
760
|
-
expect(workspacePathCalls).toEqual([]);
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
it('execute_command validates the branch BEFORE the write-policy gate', async () => {
|
|
764
|
-
const base = await start();
|
|
765
|
-
// A routine-restricted session is refused by `assertUnrestricted` with 403.
|
|
766
|
-
// The branch guard must still run first, so a malformed call gets the 400
|
|
767
|
-
// that names the bad context instead of a 403 masking it.
|
|
768
|
-
writePolicy.restrictToExtensions('restricted-run', ['.html']);
|
|
769
|
-
const res = await post(`${base}/api/agent/tools/execute_command`, {
|
|
770
|
-
branch: 'alice/../../etc',
|
|
771
|
-
command: 'echo should-not-run',
|
|
772
|
-
sessionId: 'restricted-run',
|
|
773
|
-
});
|
|
774
|
-
expect(res.status).toBe(400);
|
|
775
|
-
expect((await res.json()).error).toMatch(/branch/i);
|
|
599
|
+
expect((await toolRegistry.listInternal()).map((t) => t.name)).not.toContain('execute_command');
|
|
600
|
+
expect((await toolRegistry.listExternal()).map((t) => t.name)).not.toContain('execute_command');
|
|
601
|
+
const res = await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'echo should-not-run' });
|
|
602
|
+
expect(res.status).toBe(404);
|
|
776
603
|
expect(workspacePathCalls).toEqual([]);
|
|
777
|
-
// …and the restriction really is live for that session (so the 400 above is
|
|
778
|
-
// the guard winning the race, not an inert policy).
|
|
779
|
-
const gated = await post(`${base}/api/agent/tools/execute_command`, {
|
|
780
|
-
branch: 'main',
|
|
781
|
-
command: 'echo should-not-run',
|
|
782
|
-
sessionId: 'restricted-run',
|
|
783
|
-
});
|
|
784
|
-
expect(gated.status).toBe(403);
|
|
785
|
-
});
|
|
786
|
-
|
|
787
|
-
it('execute_command bootstraps the workspace through getOrCreateForBranch', async () => {
|
|
788
|
-
const base = await start();
|
|
789
|
-
// Per-branch bootstrap is the service's job: the shell passes the branch
|
|
790
|
-
// itself rather than hand-encoding a workspace id.
|
|
791
|
-
await post(`${base}/api/agent/tools/execute_command`, { branch: 'alice/draft', command: 'echo hi' });
|
|
792
|
-
expect(workspacePathCalls).toEqual(['alice/draft']);
|
|
793
|
-
});
|
|
794
|
-
|
|
795
|
-
it('execute_command runs plain git against the nested KB clone', async () => {
|
|
796
|
-
const base = await start();
|
|
797
|
-
// Production layout: the repo lives one level BELOW the shell's cwd, at
|
|
798
|
-
// <workspace>/<kbDirName>/.git. A bare `git …` (what the agent prompt
|
|
799
|
-
// teaches) must still target that clone instead of failing with
|
|
800
|
-
// "not a git repository".
|
|
801
|
-
const execFileAsync = promisify(execFile);
|
|
802
|
-
const repoDir = join(tempDir, KB_DIR);
|
|
803
|
-
await mkdir(repoDir, { recursive: true });
|
|
804
|
-
await execFileAsync('git', ['init'], { cwd: repoDir });
|
|
805
|
-
await execFileAsync(
|
|
806
|
-
'git',
|
|
807
|
-
['-c', 'user.email=t@test', '-c', 'user.name=t', 'commit', '--allow-empty', '-m', 'kb-seed'],
|
|
808
|
-
{ cwd: repoDir },
|
|
809
|
-
);
|
|
810
|
-
const res = (await (await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'git log -1 --format=%s' })).json()) as { stdout: string; stderr: string; exitCode: number };
|
|
811
|
-
expect(res.exitCode).toBe(0);
|
|
812
|
-
expect(res.stdout.trim()).toBe('kb-seed');
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
// POSIX-only probes: `$GIT_DIR` expansion and `>/dev/null` are `sh`
|
|
816
|
-
// semantics, and `shell: true` on Windows runs cmd.exe, where the probe
|
|
817
|
-
// itself (not the scoping under test) is meaningless. CI runs Linux.
|
|
818
|
-
it.skipIf(process.platform === 'win32')('execute_command does not leak GIT_DIR into non-git commands', async () => {
|
|
819
|
-
const base = await start();
|
|
820
|
-
// The KB-clone GIT_DIR/GIT_WORK_TREE override is scoped to bare `git …`
|
|
821
|
-
// only; a non-git command (e.g. npm/pip that shells git internally) must
|
|
822
|
-
// NOT inherit it, or the nested git child would target the KB clone.
|
|
823
|
-
// Probe via `node -e` rather than shell expansion — the override lives in
|
|
824
|
-
// the spawn env, and `$GIT_DIR` syntax doesn't expand under cmd.exe.
|
|
825
|
-
const res = (await (await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: `node -e "console.log('GIT_DIR=['+(process.env.GIT_DIR||'')+']')"` })).json()) as { stdout: string; stderr: string; exitCode: number };
|
|
826
|
-
expect(res.exitCode).toBe(0);
|
|
827
|
-
expect(res.stdout.trim()).toBe('GIT_DIR=[]');
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
it.skipIf(process.platform === 'win32')('execute_command does not leak GIT_DIR into a chained step after git', async () => {
|
|
831
|
-
const base = await start();
|
|
832
|
-
// Runs under `shell: true`, so a command that merely STARTS with git but
|
|
833
|
-
// chains another step must not export the KB git env to the whole shell —
|
|
834
|
-
// otherwise `git … && npm ci` would leak the KB repo context into the npm/pip
|
|
835
|
-
// git subprocess. `git --version` needs no repo, so exit stays 0. The
|
|
836
|
-
// version line lands on stdout (`>/dev/null` isn't portable to cmd.exe),
|
|
837
|
-
// so the leak probe is asserted on the LAST line.
|
|
838
|
-
const res = (await (await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: `git --version && node -e "console.log('leak=['+(process.env.GIT_DIR||'')+']')"` })).json()) as { stdout: string; stderr: string; exitCode: number };
|
|
839
|
-
expect(res.exitCode).toBe(0);
|
|
840
|
-
const lines = res.stdout.trim().split(/\r?\n/);
|
|
841
|
-
expect(lines[lines.length - 1].trim()).toBe('leak=[]');
|
|
842
604
|
});
|
|
843
605
|
|
|
844
606
|
it('read scope refuses write tools (403) but allows reads', async () => {
|
|
@@ -1117,14 +879,14 @@ describe('write modes and per-path outcomes', () => {
|
|
|
1117
879
|
describe("the agent guide at the guide's name", () => {
|
|
1118
880
|
const GUIDE = `${KB_DIR}/AGENTS.md`;
|
|
1119
881
|
const read = (base: string, p = GUIDE, extra: Record<string, unknown> = {}) =>
|
|
1120
|
-
post(`${base}/api/agent/tools/read_file`, { path: p, ...extra }).then((r) => r.json() as Promise<{ path: string; content: string }>);
|
|
882
|
+
post(`${base}/api/agent/tools/read_file`, { path: p, ...extra }).then((r) => r.json() as Promise<{ path: string; content: string; canWrite?: boolean }>);
|
|
1121
883
|
const statOf = (base: string, p = GUIDE) =>
|
|
1122
884
|
post(`${base}/api/agent/tools/file_stat`, { path: p }).then((r) => r.json() as Promise<Record<string, unknown>>);
|
|
1123
885
|
|
|
1124
886
|
it('answers with the guide when the knowledge base has no file of that name', async () => {
|
|
1125
887
|
guideText = 'THE PLATFORM GUIDE\n';
|
|
1126
888
|
const base = await start();
|
|
1127
|
-
expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n' });
|
|
889
|
+
expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n', canWrite: false });
|
|
1128
890
|
// By the root-anchored and the prefix-less spellings too, like any path.
|
|
1129
891
|
expect((await read(base, `/${GUIDE}`)).content).toBe('THE PLATFORM GUIDE\n');
|
|
1130
892
|
expect((await read(base, 'AGENTS.md')).content).toBe('THE PLATFORM GUIDE\n');
|
|
@@ -1145,7 +907,9 @@ describe("the agent guide at the guide's name", () => {
|
|
|
1145
907
|
guideText = 'THE PLATFORM GUIDE\n';
|
|
1146
908
|
const base = await start();
|
|
1147
909
|
await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n\nWrite tickets in the present tense.\n');
|
|
1148
|
-
const { content } = await read(base);
|
|
910
|
+
const { content, canWrite } = await read(base);
|
|
911
|
+
// Its own file is a file like any other: the write verdict is the rules' answer for it.
|
|
912
|
+
expect(canWrite).toBe(true);
|
|
1149
913
|
expect(content.startsWith('# Acme\n\nWrite tickets in the present tense.\n\n---\n')).toBe(true);
|
|
1150
914
|
expect(content.endsWith('\n\nTHE PLATFORM GUIDE\n')).toBe(true);
|
|
1151
915
|
expect(content).toContain("The text above is this knowledge base's own conventions file.");
|
|
@@ -1155,7 +919,7 @@ describe("the agent guide at the guide's name", () => {
|
|
|
1155
919
|
guideText = 'THE PLATFORM GUIDE\n';
|
|
1156
920
|
const base = await start();
|
|
1157
921
|
await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Knowledge base\n\n> **This file is managed by the platform.** Stale.\n');
|
|
1158
|
-
expect(
|
|
922
|
+
expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n', canWrite: false });
|
|
1159
923
|
});
|
|
1160
924
|
|
|
1161
925
|
it("never tells a caller who may not read the knowledge base's own file that it exists", async () => {
|
|
@@ -1199,7 +963,7 @@ describe("the agent guide at the guide's name", () => {
|
|
|
1199
963
|
};
|
|
1200
964
|
await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n');
|
|
1201
965
|
vanish = true;
|
|
1202
|
-
expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n' });
|
|
966
|
+
expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n', canWrite: false });
|
|
1203
967
|
await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n');
|
|
1204
968
|
vanish = true;
|
|
1205
969
|
expect(await statOf(base)).toMatchObject({ platformGuide: true });
|
|
@@ -1502,6 +1266,123 @@ describe('grep with a path that names a file', () => {
|
|
|
1502
1266
|
});
|
|
1503
1267
|
});
|
|
1504
1268
|
|
|
1269
|
+
/**
|
|
1270
|
+
* A caller's pattern runs off the event loop under a budget (WorkerPatternMatcher):
|
|
1271
|
+
* one that backtracks for ever is stopped with a worded 400, and every other
|
|
1272
|
+
* request keeps being served meanwhile — here, a `read_file` on the same
|
|
1273
|
+
* server, which shares the one loop the pattern used to hold.
|
|
1274
|
+
*/
|
|
1275
|
+
describe('a pattern too expensive to match', () => {
|
|
1276
|
+
const LONG_LINE = `${'a'.repeat(39)}!`;
|
|
1277
|
+
|
|
1278
|
+
it('answers 400 "too expensive" fast over a 40-character line, while other calls stay under a second', async () => {
|
|
1279
|
+
const base = await start();
|
|
1280
|
+
await fs.writeFile(`${KB_DIR}/redos.md`, `intro\n${LONG_LINE}\n`);
|
|
1281
|
+
const started = Date.now();
|
|
1282
|
+
const searching = post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$', path: `${KB_DIR}/redos.md` }).then(
|
|
1283
|
+
async (res) => ({ status: res.status, body: (await res.json()) as { error: string; code?: string }, took: Date.now() - started }),
|
|
1284
|
+
);
|
|
1285
|
+
// Probe while the search runs: each read must answer within a second.
|
|
1286
|
+
const probes: number[] = [];
|
|
1287
|
+
let done = false;
|
|
1288
|
+
void searching.finally(() => (done = true));
|
|
1289
|
+
while (!done) {
|
|
1290
|
+
const t = Date.now();
|
|
1291
|
+
const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` });
|
|
1292
|
+
expect(res.status).toBe(200);
|
|
1293
|
+
probes.push(Date.now() - t);
|
|
1294
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1295
|
+
}
|
|
1296
|
+
const { status, body, took } = await searching;
|
|
1297
|
+
expect(status).toBe(400);
|
|
1298
|
+
expect(body.error).toMatch(/^Pattern too expensive: /);
|
|
1299
|
+
expect(body.code).toBe('pattern_too_expensive');
|
|
1300
|
+
expect(body.error).toContain("this search's 5 s budget");
|
|
1301
|
+
expect(took).toBeLessThan(5_000 + 2_000);
|
|
1302
|
+
expect(probes.length).toBeGreaterThan(0);
|
|
1303
|
+
expect(Math.max(...probes)).toBeLessThan(1_000);
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
it('stops it in a directory walk too, rather than skipping the file as unreadable', async () => {
|
|
1307
|
+
const base = await start();
|
|
1308
|
+
await fs.writeFile(`${KB_DIR}/redos.md`, `${LONG_LINE}\n`);
|
|
1309
|
+
const res = await post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$' });
|
|
1310
|
+
expect(res.status).toBe(400);
|
|
1311
|
+
expect(((await res.json()) as { error: string }).error).toMatch(/^Pattern too expensive: /);
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
it('answers a directory walk 503 when no worker is free, rather than skipping every file as unreadable', async () => {
|
|
1315
|
+
// One worker and room for one search to wait: two expensive greps fill both.
|
|
1316
|
+
matcherOptions = { budgetMs: 1_000, poolMax: 1, maxWaiting: 1 };
|
|
1317
|
+
const base = await start();
|
|
1318
|
+
await fs.writeFile(`${KB_DIR}/redos.md`, `${LONG_LINE}\n`);
|
|
1319
|
+
await fs.writeFile(`${KB_DIR}/hit.md`, 'needle\n');
|
|
1320
|
+
const expensive = () =>
|
|
1321
|
+
post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$', path: `${KB_DIR}/redos.md` }).then((r) => r.status);
|
|
1322
|
+
const holding = expensive();
|
|
1323
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1324
|
+
const queued = expensive();
|
|
1325
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1326
|
+
const res = await post(`${base}/api/agent/tools/grep`, { pattern: 'needle' });
|
|
1327
|
+
expect(res.status).toBe(503);
|
|
1328
|
+
expect(((await res.json()) as { code?: string }).code).toBe('search_busy');
|
|
1329
|
+
// The two that held the pool answered as they always would, and it serves again after.
|
|
1330
|
+
expect(await Promise.all([holding, queued])).toEqual([400, 400]);
|
|
1331
|
+
const after = await post(`${base}/api/agent/tools/grep`, { pattern: 'needle' });
|
|
1332
|
+
expect(after.status).toBe(200);
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
it('spends one budget across every file a search reads: each alone fits, all of them together do not', async () => {
|
|
1336
|
+
// How long the pattern takes over one line IN A WORKER, which backtracks at
|
|
1337
|
+
// its own pace, not this thread's: once on a fresh worker (`first`, which
|
|
1338
|
+
// can be several times slower) and then again on the same one (`again`).
|
|
1339
|
+
// Timed through a probe matcher of the same kind, on a line long enough
|
|
1340
|
+
// that `again` is at least 50 ms.
|
|
1341
|
+
let line = '';
|
|
1342
|
+
let first = 0;
|
|
1343
|
+
let again = 0;
|
|
1344
|
+
for (let n = 16; again < 50; n++) {
|
|
1345
|
+
line = `${'a'.repeat(n)}!`;
|
|
1346
|
+
const probe = new WorkerPatternMatcher();
|
|
1347
|
+
try {
|
|
1348
|
+
await probe.search('x', '').matchingLines(['x'], 1); // the worker is up before anything is timed
|
|
1349
|
+
let t = performance.now();
|
|
1350
|
+
await probe.search('^(a+)+$', '').matchingLines([line], 1);
|
|
1351
|
+
first = performance.now() - t;
|
|
1352
|
+
t = performance.now();
|
|
1353
|
+
await probe.search('^(a+)+$', '').matchingLines([line], 1);
|
|
1354
|
+
again = performance.now() - t;
|
|
1355
|
+
} finally {
|
|
1356
|
+
await probe.close();
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
// A budget well above what any one file costs, and enough files that
|
|
1360
|
+
// together they cost about twice the budget.
|
|
1361
|
+
const budgetMs = Math.ceil(Math.max(first, again) * 1.5) + 200;
|
|
1362
|
+
const files = Math.ceil((2 * budgetMs) / again);
|
|
1363
|
+
matcherOptions = { budgetMs };
|
|
1364
|
+
const base = await start();
|
|
1365
|
+
for (let i = 1; i <= files; i++) await fs.writeFile(`${KB_DIR}/slow-${String(i).padStart(3, '0')}.md`, `${line}\n`);
|
|
1366
|
+
const all = await post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$' });
|
|
1367
|
+
expect(all.status).toBe(400);
|
|
1368
|
+
const body = (await all.json()) as { error: string; code?: string };
|
|
1369
|
+
expect(body.code).toBe('pattern_too_expensive');
|
|
1370
|
+
expect(body.error).toMatch(/^Pattern too expensive: /);
|
|
1371
|
+
// Any one of the same files, searched alone, fits the same budget.
|
|
1372
|
+
const one = await post(`${base}/api/agent/tools/grep`, { pattern: '^(a+)+$', path: `${KB_DIR}/slow-001.md` });
|
|
1373
|
+
expect(one.status).toBe(200);
|
|
1374
|
+
expect(((await one.json()) as { matches: unknown[] }).matches).toEqual([]);
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
it('a control pattern over the same line still matches', async () => {
|
|
1378
|
+
const base = await start();
|
|
1379
|
+
await fs.writeFile(`${KB_DIR}/redos.md`, `intro\n${LONG_LINE}\n`);
|
|
1380
|
+
const res = await post(`${base}/api/agent/tools/grep`, { pattern: '^a+!$', path: `${KB_DIR}/redos.md` });
|
|
1381
|
+
expect(res.status).toBe(200);
|
|
1382
|
+
expect(((await res.json()) as { matches: unknown[] }).matches).toEqual([{ path: `${KB_DIR}/redos.md`, line: 2, text: LONG_LINE }]);
|
|
1383
|
+
});
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1505
1386
|
/**
|
|
1506
1387
|
* Document reading: read_file/grep consume the doc-extract service for office
|
|
1507
1388
|
* documents and PDFs; the agent text-editing tools refuse them. Fixtures are
|
|
@@ -1926,9 +1807,7 @@ describe('office documents and PDFs', () => {
|
|
|
1926
1807
|
it('every mounted tool opens with the one sentence sending the agent to the guide, and repeats none of the rules', async () => {
|
|
1927
1808
|
await start();
|
|
1928
1809
|
const tools = await toolRegistry.listInternal();
|
|
1929
|
-
|
|
1930
|
-
// and that reminder is one of the rules that moved.
|
|
1931
|
-
const mounted = ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip', 'execute_command'];
|
|
1810
|
+
const mounted = ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip'];
|
|
1932
1811
|
for (const name of mounted) {
|
|
1933
1812
|
const def = tools.find((t) => t.name === name);
|
|
1934
1813
|
expect(def, name).toBeDefined();
|
|
@@ -2211,7 +2090,7 @@ describe('images', () => {
|
|
|
2211
2090
|
const body = (await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/photo.jpg` })).json()) as ImageSentinel;
|
|
2212
2091
|
expect(body.mimeType).toBe('image/jpeg');
|
|
2213
2092
|
expect(body.data).toBe(bytes.toString('base64'));
|
|
2214
|
-
expect(body.note).toBe(`[image: ${KB_DIR}/photo.jpg — image/jpeg, ${bytes.length} bytes]`);
|
|
2093
|
+
expect(body.note).toBe(`[image: ${KB_DIR}/photo.jpg — image/jpeg, ${bytes.length} bytes] (canWrite: true)`);
|
|
2215
2094
|
});
|
|
2216
2095
|
|
|
2217
2096
|
it('read_file refuses an image over 3.5 MiB raw with the downscale message, not a sentinel', async () => {
|
|
@@ -2237,6 +2116,7 @@ describe('images', () => {
|
|
|
2237
2116
|
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/icon.svg` })).json()).toEqual({
|
|
2238
2117
|
path: `${KB_DIR}/icon.svg`,
|
|
2239
2118
|
content: svg,
|
|
2119
|
+
canWrite: true,
|
|
2240
2120
|
});
|
|
2241
2121
|
});
|
|
2242
2122
|
|
|
@@ -2624,6 +2504,16 @@ describe('branch is a required parameter in the tool contract', () => {
|
|
|
2624
2504
|
{ recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
2625
2505
|
new RoutineWritePolicyService(),
|
|
2626
2506
|
{} as never,
|
|
2507
|
+
// Skill check, change gate, uploads, guide, downloads, starter packs, disk: not this harness's business.
|
|
2508
|
+
undefined,
|
|
2509
|
+
undefined,
|
|
2510
|
+
undefined,
|
|
2511
|
+
undefined,
|
|
2512
|
+
undefined,
|
|
2513
|
+
undefined,
|
|
2514
|
+
undefined,
|
|
2515
|
+
// So `grep` is mounted; it spawns no worker, as nothing here searches.
|
|
2516
|
+
new WorkerPatternMatcher(),
|
|
2627
2517
|
);
|
|
2628
2518
|
return registry;
|
|
2629
2519
|
}
|
|
@@ -2634,19 +2524,6 @@ describe('branch is a required parameter in the tool contract', () => {
|
|
|
2634
2524
|
return inputs?.properties?.body?.required ?? [];
|
|
2635
2525
|
}
|
|
2636
2526
|
|
|
2637
|
-
it('execute_command declares branch required on the INTERNAL manual', async () => {
|
|
2638
|
-
const internal = await buildRegistry().listInternal();
|
|
2639
|
-
const exec = internal.find((t) => t.name === 'execute_command');
|
|
2640
|
-
expect(exec).toBeDefined();
|
|
2641
|
-
expect(bodyRequired(exec)).toContain('branch');
|
|
2642
|
-
expect(bodyRequired(exec)).toContain('command');
|
|
2643
|
-
});
|
|
2644
|
-
|
|
2645
|
-
it('execute_command is internal-only — never advertised on the external manual', async () => {
|
|
2646
|
-
const external = await buildRegistry().listExternal();
|
|
2647
|
-
expect(external.find((t) => t.name === 'execute_command')).toBeUndefined();
|
|
2648
|
-
});
|
|
2649
|
-
|
|
2650
2527
|
it('every workspace file tool requires branch on BOTH manuals (one convention everywhere)', async () => {
|
|
2651
2528
|
const registry = buildRegistry();
|
|
2652
2529
|
const [internal, external] = await Promise.all([registry.listInternal(), registry.listExternal()]);
|
|
@@ -2776,6 +2653,7 @@ describe('the tools place an unprefixed path inside the repository', () => {
|
|
|
2776
2653
|
expect(await (await tool(base, 'read_file', { path: 'Notes.md' })).json()).toEqual({
|
|
2777
2654
|
path: `${KB_DIR}/Notes.md`,
|
|
2778
2655
|
content: 'hello\nworld\n',
|
|
2656
|
+
canWrite: true,
|
|
2779
2657
|
});
|
|
2780
2658
|
expect(await (await tool(base, 'file_stat', { path: 'Notes.md' })).json()).toMatchObject({ type: 'file' });
|
|
2781
2659
|
const edited = await tool(base, 'edit_file', { path: 'Notes.md', old_string: 'world', new_string: 'earth' });
|
|
@@ -4713,16 +4591,6 @@ describe('agent read/write hooks', () => {
|
|
|
4713
4591
|
expect(writes.map((w) => w.wsPath)).toEqual([`${KB_DIR}/out/one.md`, `${KB_DIR}/out/two.md`]);
|
|
4714
4592
|
});
|
|
4715
4593
|
|
|
4716
|
-
it('execute_command calls the write hook once, with no path', async () => {
|
|
4717
|
-
const base = await start();
|
|
4718
|
-
record();
|
|
4719
|
-
await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'echo hi', sessionId: 's1' });
|
|
4720
|
-
expect(writes).toHaveLength(1);
|
|
4721
|
-
expect(writes[0].wsPath).toBeUndefined();
|
|
4722
|
-
expect(writes[0]).toMatchObject({ sessionId: 's1', branch: 'main', source: 'internal' });
|
|
4723
|
-
expect(reads).toEqual([]);
|
|
4724
|
-
});
|
|
4725
|
-
|
|
4726
4594
|
it('a write hook that throws refuses the write with its own message and status, and the file is unchanged', async () => {
|
|
4727
4595
|
const base = await start();
|
|
4728
4596
|
record();
|
|
@@ -4918,9 +4786,6 @@ describe('tool descriptions and the deployment note', () => {
|
|
|
4918
4786
|
expect(all.get(name)!.description.endsWith(' One folder per conversation.'), name).toBe(true);
|
|
4919
4787
|
expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(`${SESSION_ID_DESCRIPTION} It also pins that folder.`);
|
|
4920
4788
|
}
|
|
4921
|
-
// `execute_command` is internal-only, so it is checked on that surface.
|
|
4922
|
-
const internal = new Map((await toolRegistry.listInternal()).map((t) => [t.name, t]));
|
|
4923
|
-
expect(internal.get('execute_command')!.description.endsWith(' One folder per conversation.')).toBe(true);
|
|
4924
4789
|
});
|
|
4925
4790
|
|
|
4926
4791
|
it('a tool that is not gated carries no note', async () => {
|