@bevel-software/platform-core-backend 0.29.0 → 0.31.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 +49 -4
- package/agent-guide/html-views.md +76 -2
- 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.js +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +3 -2
- 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 +35 -5
- 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 +135 -17
- 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/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +66 -6
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +52 -8
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js +37 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/audit/audit.routes.d.ts +1 -1
- package/dist/modules/audit/audit.routes.d.ts.map +1 -1
- package/dist/modules/audit/audit.routes.js +15 -13
- package/dist/modules/audit/audit.routes.js.map +1 -1
- package/dist/modules/audit/request-audit.d.ts +21 -10
- package/dist/modules/audit/request-audit.d.ts.map +1 -1
- package/dist/modules/audit/request-audit.js +26 -10
- package/dist/modules/audit/request-audit.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +22 -3
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +15 -6
- package/dist/modules/database/core-schema.js.map +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 +3 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +3 -1
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +3 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +14 -0
- package/dist/modules/kb-fs/mutex.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/mcp/mcp-auth.middleware.d.ts +14 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +5 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +67 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +107 -27
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +257 -50
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/request-context.d.ts +25 -0
- package/dist/modules/mcp/request-context.d.ts.map +1 -0
- package/dist/modules/mcp/request-context.js +11 -0
- package/dist/modules/mcp/request-context.js.map +1 -0
- package/dist/modules/mcp/shared-definitions.d.ts +56 -0
- package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
- package/dist/modules/mcp/shared-definitions.js +117 -0
- package/dist/modules/mcp/shared-definitions.js.map +1 -0
- package/dist/modules/mcp/surface-cache.d.ts +70 -0
- package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
- package/dist/modules/mcp/surface-cache.js +104 -0
- package/dist/modules/mcp/surface-cache.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-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +33 -12
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts +10 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +142 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +645 -128
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/read-on-branch.d.ts +35 -0
- package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
- package/dist/modules/workflow/read-on-branch.js +58 -0
- package/dist/modules/workflow/read-on-branch.js.map +1 -0
- 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 +22 -41
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +48 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +163 -16
- 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 +9 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +10 -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/shared/git.contract.d.ts +7 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +9 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/no-link-below.d.ts +10 -0
- package/dist/shared/no-link-below.d.ts.map +1 -0
- package/dist/shared/no-link-below.js +30 -0
- package/dist/shared/no-link-below.js.map +1 -0
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/migrations/0018_agent_events_page.sql +11 -0
- package/migrations/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/_journal.json +8 -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/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +3 -2
- package/src/core/create-core-services.ts +39 -4
- 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 +138 -15
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
- package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
- package/src/modules/audit/agent-audit.service.ts +66 -6
- package/src/modules/audit/audit.contract.ts +62 -10
- package/src/modules/audit/audit.routes.ts +14 -12
- package/src/modules/audit/request-audit.ts +28 -13
- package/src/modules/database/core-schema.ts +15 -6
- 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__/mutex.canonical.test.ts +57 -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 +3 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
- package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
- package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
- package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
- package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
- package/src/modules/mcp/mcp.routes.ts +68 -0
- package/src/modules/mcp/mcp.service.ts +315 -51
- package/src/modules/mcp/request-context.ts +34 -0
- package/src/modules/mcp/shared-definitions.ts +121 -0
- package/src/modules/mcp/surface-cache.ts +154 -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-context.ts +37 -18
- package/src/modules/tool-helpers/tool-handler.ts +2 -2
- package/src/modules/tool-helpers/tool.contract.ts +10 -2
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
- package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +719 -126
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +203 -16
- 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 +23 -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/shared/__tests__/no-link-below.test.ts +45 -0
- package/src/shared/git.contract.ts +10 -0
- package/src/shared/no-link-below.ts +29 -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';
|
|
@@ -78,7 +78,7 @@ function denyReads(denied: Set<string>): IAccessControl {
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* File primitives over a REAL LocalFilesystem on a temp dir — proves read_file /
|
|
81
|
-
* write_file / edit_file / list_files / grep / file_stat
|
|
81
|
+
* write_file / edit_file / list_files / grep / file_stat
|
|
82
82
|
* actually work (the handlers re-expose the same filesystem methods Mastra
|
|
83
83
|
* uses). No locking pipeline (plain LocalFilesystem), so writes don't commit.
|
|
84
84
|
*/
|
|
@@ -89,11 +89,9 @@ let tempDir = '';
|
|
|
89
89
|
let docCacheDir = '';
|
|
90
90
|
let fs: LocalFilesystem;
|
|
91
91
|
/**
|
|
92
|
-
* Every branch
|
|
93
|
-
*
|
|
94
|
-
* production
|
|
95
|
-
* missing-branch guard short-circuits before any clone of a branch literally
|
|
96
|
-
* 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.
|
|
97
95
|
*/
|
|
98
96
|
let workspacePathCalls: string[] = [];
|
|
99
97
|
/** Every `(archive, destination)` pair `unzip` handed the service. */
|
|
@@ -114,8 +112,7 @@ let writePolicy: RoutineWritePolicyService;
|
|
|
114
112
|
let guideText = 'THE PLATFORM GUIDE\n';
|
|
115
113
|
/**
|
|
116
114
|
* The focused branch the resolved `ToolContext` carries — mirrors the branch an
|
|
117
|
-
* internal token bakes for the in-process agent.
|
|
118
|
-
* branch-less `execute_command` falls back to the session's own workspace.
|
|
115
|
+
* internal token bakes for the in-process agent.
|
|
119
116
|
*/
|
|
120
117
|
let focusedBranch: string | undefined;
|
|
121
118
|
/** The registry the tools were mounted into, so a test can inspect their defs. */
|
|
@@ -142,6 +139,12 @@ let callerSource: 'internal' | 'external' | 'session' = 'internal';
|
|
|
142
139
|
* ONCE (it clears itself), so a test's later writes are ordinary ones.
|
|
143
140
|
*/
|
|
144
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 = {};
|
|
145
148
|
|
|
146
149
|
async function start(
|
|
147
150
|
scope: 'read' | 'write' = 'write',
|
|
@@ -150,6 +153,8 @@ async function start(
|
|
|
150
153
|
userEmail = 'e@x',
|
|
151
154
|
): Promise<string> {
|
|
152
155
|
raceHook = null;
|
|
156
|
+
await patternMatcher?.close();
|
|
157
|
+
patternMatcher = new WorkerPatternMatcher(matcherOptions);
|
|
153
158
|
hooks = new WorkflowHooks();
|
|
154
159
|
notes = new ToolDescriptionNotes();
|
|
155
160
|
callerSource = 'internal';
|
|
@@ -311,7 +316,7 @@ async function start(
|
|
|
311
316
|
recoveryBotEmail: RECOVERY_BOT,
|
|
312
317
|
hooks,
|
|
313
318
|
notes,
|
|
314
|
-
}, 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);
|
|
315
320
|
app.use('/api', router);
|
|
316
321
|
httpServer = await new Promise<HttpServer>((r) => {
|
|
317
322
|
const s = app.listen(0, () => r(s));
|
|
@@ -342,10 +347,13 @@ const post = (url: string, body: unknown = {}) =>
|
|
|
342
347
|
beforeEach(() => {
|
|
343
348
|
/* fresh per test via start() */
|
|
344
349
|
guideText = 'THE PLATFORM GUIDE\n';
|
|
350
|
+
matcherOptions = {};
|
|
345
351
|
});
|
|
346
352
|
afterEach(async () => {
|
|
347
353
|
if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
|
|
348
354
|
httpServer = undefined;
|
|
355
|
+
await patternMatcher?.close();
|
|
356
|
+
patternMatcher = undefined;
|
|
349
357
|
if (tempDir) {
|
|
350
358
|
await rm(tempDir, { recursive: true, force: true });
|
|
351
359
|
tempDir = '';
|
|
@@ -356,25 +364,6 @@ afterEach(async () => {
|
|
|
356
364
|
}
|
|
357
365
|
});
|
|
358
366
|
|
|
359
|
-
/**
|
|
360
|
-
* True once `pid` has been killed: either it is gone, or it is a zombie
|
|
361
|
-
* waiting on a reaper (Linux: `State:\tZ` in /proc). `kill(pid, 0)` alone
|
|
362
|
-
* cannot tell a zombie from a live process.
|
|
363
|
-
*/
|
|
364
|
-
async function isDeadOrZombie(pid: number): Promise<boolean> {
|
|
365
|
-
try {
|
|
366
|
-
process.kill(pid, 0);
|
|
367
|
-
} catch {
|
|
368
|
-
return true;
|
|
369
|
-
}
|
|
370
|
-
try {
|
|
371
|
-
const status = await readFile(`/proc/${pid}/status`, 'utf8');
|
|
372
|
-
return /^State:\s*Z/m.test(status);
|
|
373
|
-
} catch {
|
|
374
|
-
return false; // no /proc (macOS): alive as far as the signal says
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
367
|
describe('workspace file primitives', () => {
|
|
379
368
|
it('read_file returns the content', async () => {
|
|
380
369
|
const base = await start();
|
|
@@ -603,248 +592,15 @@ describe('workspace file primitives', () => {
|
|
|
603
592
|
expect(empty.matches.map((m) => m.path)).toEqual([`${KB_DIR}/n.md`]);
|
|
604
593
|
});
|
|
605
594
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
expect(res.stdout).toContain('hello-exec');
|
|
610
|
-
expect(res.exitCode).toBe(0);
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
// The command runs under `sh -c`; a timeout used to SIGKILL that shell and
|
|
614
|
-
// leave what it had started running on as an orphan. The whole process
|
|
615
|
-
// group goes now. POSIX only: Windows has no process groups to kill.
|
|
616
|
-
it.skipIf(process.platform === 'win32')('execute_command kills what the command started, not just its shell, on timeout', async () => {
|
|
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 () => {
|
|
617
598
|
const base = await start();
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
await post(`${base}/api/agent/tools/execute_command`, {
|
|
623
|
-
branch: 'main',
|
|
624
|
-
command: 'sleep 30 & echo $!; wait',
|
|
625
|
-
timeout_ms: 1000,
|
|
626
|
-
})
|
|
627
|
-
).json()) as { stdout: string; exitCode: number };
|
|
628
|
-
expect(res.exitCode).toBe(-1);
|
|
629
|
-
const grandchild = Number(res.stdout.trim());
|
|
630
|
-
expect(grandchild).toBeGreaterThan(0);
|
|
631
|
-
// Dead means killed, not reaped: a SIGKILLed process keeps its pid — and
|
|
632
|
-
// answers `kill(pid, 0)` as alive — until whoever inherited it reaps it,
|
|
633
|
-
// and on a host with no reaper (the very thing this guards) that is
|
|
634
|
-
// never. So where /proc exists, a zombie (`State: Z`) counts as dead; a
|
|
635
|
-
// vanished pid counts everywhere. Poll to a deadline rather than trust
|
|
636
|
-
// one fixed pause. Without the fix the grandchild is still sleeping at
|
|
637
|
-
// the deadline, so what fails is the deadline — never an early check.
|
|
638
|
-
const deadline = Date.now() + 3_000;
|
|
639
|
-
let dead = false;
|
|
640
|
-
while (!dead && Date.now() < deadline) {
|
|
641
|
-
dead = await isDeadOrZombie(grandchild);
|
|
642
|
-
if (!dead) await new Promise((r) => setTimeout(r, 50));
|
|
643
|
-
}
|
|
644
|
-
expect(dead).toBe(true);
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
it('execute_command 400s when no branch is given AND no focused branch (external caller)', async () => {
|
|
648
|
-
const base = await start();
|
|
649
|
-
// No `branch` arg and no `ctx.focusedBranch` (an external caller carries no
|
|
650
|
-
// focused branch): the branch context is genuinely absent, so it must NOT
|
|
651
|
-
// resolve the workspace id to "undefined" and try to clone a branch literally
|
|
652
|
-
// named "undefined" — it fails closed with a clear 4xx naming the missing
|
|
653
|
-
// branch context, BEFORE any workspace resolve.
|
|
654
|
-
expect(focusedBranch).toBeUndefined();
|
|
655
|
-
// `postRaw`: the absent field IS the test here, so the body goes as written.
|
|
656
|
-
const res = await postRaw(`${base}/api/agent/tools/execute_command`, { command: 'echo should-not-run' });
|
|
657
|
-
expect(res.status).toBe(400);
|
|
658
|
-
expect((await res.json()).error).toMatch(/branch/i);
|
|
659
|
-
// The guard runs before `getOrCreateForBranch`, so no workspace (least of all
|
|
660
|
-
// the "undefined" one) is ever resolved — proving no clone is attempted.
|
|
661
|
-
expect(workspacePathCalls).toEqual([]);
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
it('execute_command falls back to the session focused branch when branch is omitted', async () => {
|
|
665
|
-
const base = await start();
|
|
666
|
-
// The in-app chat agent, focused on `main`, may leave `branch` off a call.
|
|
667
|
-
// For an internal session that carries a focused branch, the shell runs
|
|
668
|
-
// against that branch's workspace and returns output end to end (AC2) —
|
|
669
|
-
// rather than failing closed the way a context-less external call does.
|
|
670
|
-
focusedBranch = 'main';
|
|
671
|
-
const res = (await (await postRaw(`${base}/api/agent/tools/execute_command`, { command: 'echo hello-exec' })).json()) as { stdout: string; exitCode: number };
|
|
672
|
-
expect(res.exitCode).toBe(0);
|
|
673
|
-
expect(res.stdout).toContain('hello-exec');
|
|
674
|
-
// Resolved against the focused branch — never an "undefined" workspace.
|
|
675
|
-
expect(workspacePathCalls).toEqual(['main']);
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
it('execute_command does NOT fall back to the focused branch for an invalid value', async () => {
|
|
679
|
-
const base = await start();
|
|
680
|
-
// A present-but-broken branch ("undefined") must fail closed even when a
|
|
681
|
-
// focused branch is available — a broken value is never silently reinterpreted
|
|
682
|
-
// as the session's branch.
|
|
683
|
-
focusedBranch = 'main';
|
|
684
|
-
const res = await post(`${base}/api/agent/tools/execute_command`, { branch: 'undefined', command: 'echo should-not-run' });
|
|
685
|
-
expect(res.status).toBe(400);
|
|
686
|
-
expect((await res.json()).error).toMatch(/branch/i);
|
|
687
|
-
expect(workspacePathCalls).toEqual([]);
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
// Every branch value that must fail closed without resolving a workspace.
|
|
691
|
-
// `''` (like the missing field above) is refused a layer earlier by the input
|
|
692
|
-
// schema's `minLength: 1`; the rest satisfy the schema and reach the handler,
|
|
693
|
-
// so they exercise the guard itself. Both layers must produce the same 400.
|
|
694
|
-
// The malformed refs below are rejected by the shared `assertValidBranchName`
|
|
695
|
-
// shape check; the literal "undefined"/"null" — which that validator ACCEPTS
|
|
696
|
-
// as ordinary git refs — are rejected by an explicit by-name check ahead of it
|
|
697
|
-
// (AC3 requires them to fail closed; see the dedicated test below).
|
|
698
|
-
const invalidBranches: Array<[label: string, branch: string]> = [
|
|
699
|
-
['empty', ''],
|
|
700
|
-
['whitespace-only', ' '],
|
|
701
|
-
// Malformed refs — caught by the canonical `assertValidBranchName` shape
|
|
702
|
-
// check, not by any hand-maintained list of literals.
|
|
703
|
-
['whitespace-padded (never silently trimmed)', ' main '],
|
|
704
|
-
['containing a space', 'alice/my draft'],
|
|
705
|
-
['a ".." ref', 'foo..bar'],
|
|
706
|
-
['containing ".."', 'alice/../../etc'],
|
|
707
|
-
['a leading dash (git would read it as a flag)', '-x'],
|
|
708
|
-
['starting with "--" (flag injection)', '--upload-pack=touch'],
|
|
709
|
-
['containing "//"', 'alice//draft'],
|
|
710
|
-
['ending with "/"', 'alice/'],
|
|
711
|
-
['a segment starting with "."', 'alice/.hidden'],
|
|
712
|
-
['a segment ending with ".lock"', 'alice/draft.lock'],
|
|
713
|
-
['containing "@{"', 'alice/draft@{0}'],
|
|
714
|
-
['containing a shell metacharacter', 'alice/draft;rm -rf /'],
|
|
715
|
-
];
|
|
716
|
-
for (const [label, branch] of invalidBranches) {
|
|
717
|
-
it(`execute_command 400s when branch is ${label}`, async () => {
|
|
718
|
-
const base = await start();
|
|
719
|
-
const res = await post(`${base}/api/agent/tools/execute_command`, { branch, command: 'echo should-not-run' });
|
|
720
|
-
expect(res.status).toBe(400);
|
|
721
|
-
expect((await res.json()).error).toMatch(/branch/i);
|
|
722
|
-
// No workspace is resolved for the bad value — proving no clone is attempted.
|
|
723
|
-
expect(workspacePathCalls).toEqual([]);
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* The production regression this ticket bounced on: the guard's hand-written
|
|
729
|
-
* `"undefined"`/`"null"` rejection was replaced by the canonical
|
|
730
|
-
* `assertValidBranchName` alone. Both literals are *syntactically valid* git
|
|
731
|
-
* branch names, so the validator accepts them — a branch-less call then reached
|
|
732
|
-
* `getOrCreateForBranch("undefined")`, cloned a branch literally named
|
|
733
|
-
* `undefined` into `/app/workspaces/undefined/`, and 500'd in production.
|
|
734
|
-
*
|
|
735
|
-
* AC3 requires these literals to fail closed. Pinning the validator's own
|
|
736
|
-
* behaviour here is the point: it documents WHY the literal check cannot be
|
|
737
|
-
* folded into the shape check, so the next person who "simplifies" the guard
|
|
738
|
-
* down to `assertValidBranchName` gets a red test naming the reason rather than
|
|
739
|
-
* a fresh production 500.
|
|
740
|
-
*/
|
|
741
|
-
it('rejects "undefined"/"null" even though the canonical validator accepts them', async () => {
|
|
742
|
-
for (const literal of ['undefined', 'null']) {
|
|
743
|
-
expect(() => assertValidBranchName(literal), `${literal} is a valid git ref`).not.toThrow();
|
|
744
|
-
}
|
|
745
|
-
const base = await start();
|
|
746
|
-
for (const literal of ['undefined', 'null']) {
|
|
747
|
-
const res = await post(`${base}/api/agent/tools/execute_command`, { branch: literal, command: 'echo should-not-run' });
|
|
748
|
-
expect(res.status, `branch "${literal}" must 400`).toBe(400);
|
|
749
|
-
// The message names the literal, so the agent can see what it actually sent.
|
|
750
|
-
expect((await res.json()).error).toContain(literal);
|
|
751
|
-
}
|
|
752
|
-
expect(workspacePathCalls).toEqual([]);
|
|
753
|
-
});
|
|
754
|
-
|
|
755
|
-
it('answers a branch-less call with the same kind every other KB tool uses', async () => {
|
|
756
|
-
const base = await start();
|
|
757
|
-
// The MESSAGE here is this tool's own — only `execute_command` has a
|
|
758
|
-
// focused-branch fallback to explain. The DISCRIMINATOR is shared, so a
|
|
759
|
-
// client switches on one kind across the whole surface instead of matching
|
|
760
|
-
// prose per tool.
|
|
761
|
-
for (const body of [{ command: 'x' }, { branch: '', command: 'x' }, { branch: 'undefined', command: 'x' }]) {
|
|
762
|
-
const res = await postRaw(`${base}/api/agent/tools/execute_command`, body);
|
|
763
|
-
expect(res.status).toBe(400);
|
|
764
|
-
expect((await res.json()).kind).toBe('branch-required');
|
|
765
|
-
}
|
|
766
|
-
expect(workspacePathCalls).toEqual([]);
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
it('execute_command validates the branch BEFORE the write-policy gate', async () => {
|
|
770
|
-
const base = await start();
|
|
771
|
-
// A routine-restricted session is refused by `assertUnrestricted` with 403.
|
|
772
|
-
// The branch guard must still run first, so a malformed call gets the 400
|
|
773
|
-
// that names the bad context instead of a 403 masking it.
|
|
774
|
-
writePolicy.restrictToExtensions('restricted-run', ['.html']);
|
|
775
|
-
const res = await post(`${base}/api/agent/tools/execute_command`, {
|
|
776
|
-
branch: 'alice/../../etc',
|
|
777
|
-
command: 'echo should-not-run',
|
|
778
|
-
sessionId: 'restricted-run',
|
|
779
|
-
});
|
|
780
|
-
expect(res.status).toBe(400);
|
|
781
|
-
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);
|
|
782
603
|
expect(workspacePathCalls).toEqual([]);
|
|
783
|
-
// …and the restriction really is live for that session (so the 400 above is
|
|
784
|
-
// the guard winning the race, not an inert policy).
|
|
785
|
-
const gated = await post(`${base}/api/agent/tools/execute_command`, {
|
|
786
|
-
branch: 'main',
|
|
787
|
-
command: 'echo should-not-run',
|
|
788
|
-
sessionId: 'restricted-run',
|
|
789
|
-
});
|
|
790
|
-
expect(gated.status).toBe(403);
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
it('execute_command bootstraps the workspace through getOrCreateForBranch', async () => {
|
|
794
|
-
const base = await start();
|
|
795
|
-
// Per-branch bootstrap is the service's job: the shell passes the branch
|
|
796
|
-
// itself rather than hand-encoding a workspace id.
|
|
797
|
-
await post(`${base}/api/agent/tools/execute_command`, { branch: 'alice/draft', command: 'echo hi' });
|
|
798
|
-
expect(workspacePathCalls).toEqual(['alice/draft']);
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
it('execute_command runs plain git against the nested KB clone', async () => {
|
|
802
|
-
const base = await start();
|
|
803
|
-
// Production layout: the repo lives one level BELOW the shell's cwd, at
|
|
804
|
-
// <workspace>/<kbDirName>/.git. A bare `git …` (what the agent prompt
|
|
805
|
-
// teaches) must still target that clone instead of failing with
|
|
806
|
-
// "not a git repository".
|
|
807
|
-
const execFileAsync = promisify(execFile);
|
|
808
|
-
const repoDir = join(tempDir, KB_DIR);
|
|
809
|
-
await mkdir(repoDir, { recursive: true });
|
|
810
|
-
await execFileAsync('git', ['init'], { cwd: repoDir });
|
|
811
|
-
await execFileAsync(
|
|
812
|
-
'git',
|
|
813
|
-
['-c', 'user.email=t@test', '-c', 'user.name=t', 'commit', '--allow-empty', '-m', 'kb-seed'],
|
|
814
|
-
{ cwd: repoDir },
|
|
815
|
-
);
|
|
816
|
-
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 };
|
|
817
|
-
expect(res.exitCode).toBe(0);
|
|
818
|
-
expect(res.stdout.trim()).toBe('kb-seed');
|
|
819
|
-
});
|
|
820
|
-
|
|
821
|
-
// POSIX-only probes: `$GIT_DIR` expansion and `>/dev/null` are `sh`
|
|
822
|
-
// semantics, and `shell: true` on Windows runs cmd.exe, where the probe
|
|
823
|
-
// itself (not the scoping under test) is meaningless. CI runs Linux.
|
|
824
|
-
it.skipIf(process.platform === 'win32')('execute_command does not leak GIT_DIR into non-git commands', async () => {
|
|
825
|
-
const base = await start();
|
|
826
|
-
// The KB-clone GIT_DIR/GIT_WORK_TREE override is scoped to bare `git …`
|
|
827
|
-
// only; a non-git command (e.g. npm/pip that shells git internally) must
|
|
828
|
-
// NOT inherit it, or the nested git child would target the KB clone.
|
|
829
|
-
// Probe via `node -e` rather than shell expansion — the override lives in
|
|
830
|
-
// the spawn env, and `$GIT_DIR` syntax doesn't expand under cmd.exe.
|
|
831
|
-
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 };
|
|
832
|
-
expect(res.exitCode).toBe(0);
|
|
833
|
-
expect(res.stdout.trim()).toBe('GIT_DIR=[]');
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
it.skipIf(process.platform === 'win32')('execute_command does not leak GIT_DIR into a chained step after git', async () => {
|
|
837
|
-
const base = await start();
|
|
838
|
-
// Runs under `shell: true`, so a command that merely STARTS with git but
|
|
839
|
-
// chains another step must not export the KB git env to the whole shell —
|
|
840
|
-
// otherwise `git … && npm ci` would leak the KB repo context into the npm/pip
|
|
841
|
-
// git subprocess. `git --version` needs no repo, so exit stays 0. The
|
|
842
|
-
// version line lands on stdout (`>/dev/null` isn't portable to cmd.exe),
|
|
843
|
-
// so the leak probe is asserted on the LAST line.
|
|
844
|
-
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 };
|
|
845
|
-
expect(res.exitCode).toBe(0);
|
|
846
|
-
const lines = res.stdout.trim().split(/\r?\n/);
|
|
847
|
-
expect(lines[lines.length - 1].trim()).toBe('leak=[]');
|
|
848
604
|
});
|
|
849
605
|
|
|
850
606
|
it('read scope refuses write tools (403) but allows reads', async () => {
|
|
@@ -1510,6 +1266,123 @@ describe('grep with a path that names a file', () => {
|
|
|
1510
1266
|
});
|
|
1511
1267
|
});
|
|
1512
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
|
+
|
|
1513
1386
|
/**
|
|
1514
1387
|
* Document reading: read_file/grep consume the doc-extract service for office
|
|
1515
1388
|
* documents and PDFs; the agent text-editing tools refuse them. Fixtures are
|
|
@@ -1934,9 +1807,7 @@ describe('office documents and PDFs', () => {
|
|
|
1934
1807
|
it('every mounted tool opens with the one sentence sending the agent to the guide, and repeats none of the rules', async () => {
|
|
1935
1808
|
await start();
|
|
1936
1809
|
const tools = await toolRegistry.listInternal();
|
|
1937
|
-
|
|
1938
|
-
// and that reminder is one of the rules that moved.
|
|
1939
|
-
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'];
|
|
1940
1811
|
for (const name of mounted) {
|
|
1941
1812
|
const def = tools.find((t) => t.name === name);
|
|
1942
1813
|
expect(def, name).toBeDefined();
|
|
@@ -2633,6 +2504,16 @@ describe('branch is a required parameter in the tool contract', () => {
|
|
|
2633
2504
|
{ recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
2634
2505
|
new RoutineWritePolicyService(),
|
|
2635
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(),
|
|
2636
2517
|
);
|
|
2637
2518
|
return registry;
|
|
2638
2519
|
}
|
|
@@ -2643,19 +2524,6 @@ describe('branch is a required parameter in the tool contract', () => {
|
|
|
2643
2524
|
return inputs?.properties?.body?.required ?? [];
|
|
2644
2525
|
}
|
|
2645
2526
|
|
|
2646
|
-
it('execute_command declares branch required on the INTERNAL manual', async () => {
|
|
2647
|
-
const internal = await buildRegistry().listInternal();
|
|
2648
|
-
const exec = internal.find((t) => t.name === 'execute_command');
|
|
2649
|
-
expect(exec).toBeDefined();
|
|
2650
|
-
expect(bodyRequired(exec)).toContain('branch');
|
|
2651
|
-
expect(bodyRequired(exec)).toContain('command');
|
|
2652
|
-
});
|
|
2653
|
-
|
|
2654
|
-
it('execute_command is internal-only — never advertised on the external manual', async () => {
|
|
2655
|
-
const external = await buildRegistry().listExternal();
|
|
2656
|
-
expect(external.find((t) => t.name === 'execute_command')).toBeUndefined();
|
|
2657
|
-
});
|
|
2658
|
-
|
|
2659
2527
|
it('every workspace file tool requires branch on BOTH manuals (one convention everywhere)', async () => {
|
|
2660
2528
|
const registry = buildRegistry();
|
|
2661
2529
|
const [internal, external] = await Promise.all([registry.listInternal(), registry.listExternal()]);
|
|
@@ -4723,16 +4591,6 @@ describe('agent read/write hooks', () => {
|
|
|
4723
4591
|
expect(writes.map((w) => w.wsPath)).toEqual([`${KB_DIR}/out/one.md`, `${KB_DIR}/out/two.md`]);
|
|
4724
4592
|
});
|
|
4725
4593
|
|
|
4726
|
-
it('execute_command calls the write hook once, with no path', async () => {
|
|
4727
|
-
const base = await start();
|
|
4728
|
-
record();
|
|
4729
|
-
await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'echo hi', sessionId: 's1' });
|
|
4730
|
-
expect(writes).toHaveLength(1);
|
|
4731
|
-
expect(writes[0].wsPath).toBeUndefined();
|
|
4732
|
-
expect(writes[0]).toMatchObject({ sessionId: 's1', branch: 'main', source: 'internal' });
|
|
4733
|
-
expect(reads).toEqual([]);
|
|
4734
|
-
});
|
|
4735
|
-
|
|
4736
4594
|
it('a write hook that throws refuses the write with its own message and status, and the file is unchanged', async () => {
|
|
4737
4595
|
const base = await start();
|
|
4738
4596
|
record();
|
|
@@ -4928,9 +4786,6 @@ describe('tool descriptions and the deployment note', () => {
|
|
|
4928
4786
|
expect(all.get(name)!.description.endsWith(' One folder per conversation.'), name).toBe(true);
|
|
4929
4787
|
expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(`${SESSION_ID_DESCRIPTION} It also pins that folder.`);
|
|
4930
4788
|
}
|
|
4931
|
-
// `execute_command` is internal-only, so it is checked on that surface.
|
|
4932
|
-
const internal = new Map((await toolRegistry.listInternal()).map((t) => [t.name, t]));
|
|
4933
|
-
expect(internal.get('execute_command')!.description.endsWith(' One folder per conversation.')).toBe(true);
|
|
4934
4789
|
});
|
|
4935
4790
|
|
|
4936
4791
|
it('a tool that is not gated carries no note', async () => {
|
|
@@ -149,8 +149,8 @@ export async function notifyAgentRead(
|
|
|
149
149
|
* that throws refuses it with its own message and status; with none registered
|
|
150
150
|
* this is a no-op.
|
|
151
151
|
*
|
|
152
|
-
* `wsPath` is omitted by
|
|
153
|
-
*
|
|
152
|
+
* `wsPath` is omitted by a write-capable operation with no resolvable
|
|
153
|
+
* target — the hook then decides at the coarser session level.
|
|
154
154
|
*/
|
|
155
155
|
export async function assertAgentWriteAllowed(
|
|
156
156
|
gate: AgentAccessGate,
|