@bevel-software/platform-core-backend 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/core-ports.d.ts +11 -1
- 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 +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +62 -13
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +22 -3
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +158 -23
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +34 -1
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +89 -13
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core-config.d.ts +0 -12
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +11 -13
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +42 -12
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +67 -10
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +214 -35
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-requests.contract.d.ts +75 -0
- package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
- package/dist/modules/access/access-requests.contract.js +20 -0
- package/dist/modules/access/access-requests.contract.js.map +1 -0
- package/dist/modules/access/access-requests.routes.d.ts +35 -0
- package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
- package/dist/modules/access/access-requests.routes.js +237 -0
- package/dist/modules/access/access-requests.routes.js.map +1 -0
- package/dist/modules/access/access-requests.service.d.ts +123 -0
- package/dist/modules/access/access-requests.service.d.ts.map +1 -0
- package/dist/modules/access/access-requests.service.js +337 -0
- package/dist/modules/access/access-requests.service.js.map +1 -0
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +13 -15
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +12 -0
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +24 -0
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/auth/account-admission.d.ts +58 -7
- package/dist/modules/auth/account-admission.d.ts.map +1 -1
- package/dist/modules/auth/account-admission.js +44 -1
- package/dist/modules/auth/account-admission.js.map +1 -1
- package/dist/modules/auth/account.routes.d.ts +1 -1
- package/dist/modules/auth/account.routes.d.ts.map +1 -1
- package/dist/modules/auth/account.routes.js +61 -5
- package/dist/modules/auth/account.routes.js.map +1 -1
- package/dist/modules/auth/auth.middleware.d.ts +1 -1
- package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
- package/dist/modules/auth/auth.middleware.js +18 -7
- package/dist/modules/auth/auth.middleware.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +8 -0
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +96 -3
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +187 -9
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.js +7 -2
- package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +41 -24
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +35 -27
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/github-app/github-app.client.d.ts +101 -0
- package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.client.js +222 -0
- package/dist/modules/github-app/github-app.client.js.map +1 -0
- package/dist/modules/github-app/github-app.connection.d.ts +102 -0
- package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.connection.js +185 -0
- package/dist/modules/github-app/github-app.connection.js.map +1 -0
- package/dist/modules/github-app/github-app.routes.d.ts +59 -0
- package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.routes.js +323 -0
- package/dist/modules/github-app/github-app.routes.js.map +1 -0
- package/dist/modules/github-app/index.d.ts +4 -0
- package/dist/modules/github-app/index.d.ts.map +1 -0
- package/dist/modules/github-app/index.js +4 -0
- package/dist/modules/github-app/index.js.map +1 -0
- package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
- package/dist/modules/kb-fs/branch-name.js +12 -2
- package/dist/modules/kb-fs/branch-name.js.map +1 -1
- package/dist/modules/kb-fs/remote-url.d.ts +22 -0
- package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
- package/dist/modules/kb-fs/remote-url.js +35 -0
- package/dist/modules/kb-fs/remote-url.js.map +1 -0
- package/dist/modules/kb-fs/repo-path.d.ts +11 -16
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
- package/dist/modules/kb-fs/repo-path.js +79 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
- package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
- package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
- package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
- package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
- package/dist/modules/kb-sync/sync-auth.js +1 -1
- package/dist/modules/kb-sync/sync-auth.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/plugins/join-proposals.d.ts +17 -5
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
- package/dist/modules/plugins/join-proposals.js +76 -22
- package/dist/modules/plugins/join-proposals.js.map +1 -1
- package/dist/modules/plugins/join-requests.service.d.ts +111 -18
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-requests.service.js +173 -29
- package/dist/modules/plugins/join-requests.service.js.map +1 -1
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.routes.js +3 -2
- package/dist/modules/plugins/plugins.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +101 -7
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/managed-repository.d.ts +43 -0
- package/dist/modules/settings/managed-repository.d.ts.map +1 -0
- package/dist/modules/settings/managed-repository.js +60 -0
- package/dist/modules/settings/managed-repository.js.map +1 -0
- package/dist/modules/settings/repository-source.d.ts +128 -0
- package/dist/modules/settings/repository-source.d.ts.map +1 -0
- package/dist/modules/settings/repository-source.js +150 -0
- package/dist/modules/settings/repository-source.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +78 -4
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +379 -17
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
- package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.js +24 -63
- package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +90 -18
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +4 -1
- package/dist/modules/skills/skills.contract.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +98 -9
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +239 -37
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +7 -0
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +71 -7
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +4 -2
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
- 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 +15 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.js +25 -4
- package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +3 -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/tool-helpers/validate-token.js +1 -1
- package/dist/modules/tool-helpers/validate-token.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +113 -37
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +24 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +30 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +72 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +170 -6
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.js +5 -0
- package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +99 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +55 -0
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
- package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
- package/dist/modules/workflow/workflow-hooks.js +16 -1
- package/dist/modules/workflow/workflow-hooks.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +3 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +94 -13
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +258 -51
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
- package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
- package/dist/modules/workspace/agent-access.gate.js +123 -0
- package/dist/modules/workspace/agent-access.gate.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +5 -6
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/session-sink.d.ts +5 -5
- package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
- package/dist/modules/workspace/set-aside-clone.js +92 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +117 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +190 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +92 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +115 -5
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +272 -26
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +2 -2
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +337 -117
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/modules/write-access/write-access.d.ts +60 -0
- package/dist/modules/write-access/write-access.d.ts.map +1 -0
- package/dist/modules/write-access/write-access.js +129 -0
- package/dist/modules/write-access/write-access.js.map +1 -0
- package/dist/shared/domain-errors.d.ts +64 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +83 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +30 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +26 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/tenancy/static-tenant-source.d.ts +0 -1
- package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
- package/dist/tenancy/static-tenant-source.js +0 -2
- package/dist/tenancy/static-tenant-source.js.map +1 -1
- package/migrations/0014_change_request_closed_reason.sql +1 -0
- package/migrations/0015_account_deactivation.sql +3 -0
- package/migrations/meta/0014_snapshot.json +2265 -0
- package/migrations/meta/0015_snapshot.json +2271 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +3 -3
- package/src/__tests__/kb-layout-config.test.ts +0 -2
- package/src/__tests__/retired-settings.test.ts +96 -0
- package/src/core/__tests__/gated-boot.test.ts +132 -0
- package/src/core/__tests__/lifecycle.test.ts +170 -1
- package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
- package/src/core/core-ports.ts +11 -1
- package/src/core/create-core-server.ts +69 -15
- package/src/core/create-core-services.ts +190 -27
- package/src/core/lifecycle.ts +98 -13
- package/src/core-config.ts +13 -14
- package/src/index.ts +12 -1
- package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
- package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
- package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
- package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
- package/src/modules/access/access-control.interface.ts +43 -12
- package/src/modules/access/access-control.service.ts +234 -36
- package/src/modules/access/access-requests.contract.ts +93 -0
- package/src/modules/access/access-requests.routes.ts +286 -0
- package/src/modules/access/access-requests.service.ts +420 -0
- package/src/modules/access/access.routes.ts +13 -15
- package/src/modules/access-model/access-grammar.ts +22 -0
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
- package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
- package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
- package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
- package/src/modules/auth/__tests__/auth.service.test.ts +39 -1
- package/src/modules/auth/account-admission.ts +78 -9
- package/src/modules/auth/account.routes.ts +63 -7
- package/src/modules/auth/auth.middleware.ts +19 -8
- package/src/modules/auth/auth.routes.ts +8 -0
- package/src/modules/auth/auth.service.ts +207 -7
- package/src/modules/auth/oidc-auth-provider.ts +7 -2
- package/src/modules/database/core-schema.ts +35 -27
- package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
- package/src/modules/github-app/github-app.client.ts +268 -0
- package/src/modules/github-app/github-app.connection.ts +204 -0
- package/src/modules/github-app/github-app.routes.ts +359 -0
- package/src/modules/github-app/index.ts +19 -0
- package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
- package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
- package/src/modules/kb-fs/branch-name.ts +14 -1
- package/src/modules/kb-fs/remote-url.ts +35 -0
- package/src/modules/kb-fs/repo-path.ts +85 -0
- package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
- package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
- package/src/modules/kb-sync/sync-auth.ts +2 -2
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
- package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
- package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
- package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
- package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
- package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
- package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
- package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
- package/src/modules/plugins/join-proposals.ts +87 -19
- package/src/modules/plugins/join-requests.service.ts +199 -39
- package/src/modules/plugins/plugins.routes.ts +3 -2
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
- package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +386 -0
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +299 -0
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +236 -0
- package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
- package/src/modules/settings/deployment-settings.service.ts +117 -6
- package/src/modules/settings/managed-repository.ts +68 -0
- package/src/modules/settings/repository-source.ts +197 -0
- package/src/modules/settings/setup.routes.ts +463 -15
- package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
- package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
- package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
- package/src/modules/skills/skill-access-requests.routes.ts +25 -75
- package/src/modules/skills/skills.contract.ts +91 -18
- package/src/modules/skills/skills.service.ts +278 -41
- package/src/modules/skills/skills.tools.ts +80 -8
- package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
- package/src/modules/tool-auth/external-api-key.service.ts +4 -2
- package/src/modules/tool-auth/internal-token.service.ts +3 -3
- package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
- package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
- package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
- package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
- package/src/modules/tool-helpers/tool-context.ts +15 -0
- package/src/modules/tool-helpers/tool-handler.ts +24 -4
- package/src/modules/tool-helpers/tool.contract.ts +3 -2
- package/src/modules/tool-helpers/validate-token.ts +1 -1
- package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
- package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
- package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
- package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +405 -7
- package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
- package/src/modules/workflow/agent-tools/workflow.tools.ts +135 -38
- package/src/modules/workflow/file-lock.service.ts +31 -0
- package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
- package/src/modules/workflow/git/git.service.ts +193 -4
- package/src/modules/workflow/git/node-git-runner.ts +6 -0
- package/src/modules/workflow/git/pull-request.service.ts +119 -0
- package/src/modules/workflow/pending-commits.service.ts +59 -1
- package/src/modules/workflow/workflow-hooks.ts +64 -26
- package/src/modules/workflow/workflow.routes.ts +3 -1
- package/src/modules/workflow/workflow.service.ts +290 -55
- package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
- package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
- package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
- package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
- package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +235 -10
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +720 -17
- package/src/modules/workspace/agent-access.gate.ts +164 -0
- package/src/modules/workspace/routine-write-policy.ts +5 -6
- package/src/modules/workspace/session-sink.ts +5 -5
- package/src/modules/workspace/set-aside-clone.ts +96 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +297 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +242 -2
- package/src/modules/workspace/workspace.routes.ts +95 -3
- package/src/modules/workspace/workspace.service.ts +273 -25
- package/src/modules/workspace/workspace.tools.ts +373 -119
- package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
- package/src/modules/write-access/write-access.ts +153 -0
- package/src/shared/domain-errors.ts +89 -0
- package/src/shared/git.contract.ts +37 -0
- package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
- package/src/tenancy/static-tenant-source.ts +0 -3
- package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
- package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
- package/dist/modules/workflow/session-ontology.policy.js +0 -62
- package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
- package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
- package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
- package/dist/modules/workflow/session-ontology.service.js +0 -147
- package/dist/modules/workflow/session-ontology.service.js.map +0 -1
- package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
- package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
- package/dist/modules/workspace/session-ontology.gate.js +0 -161
- package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
- package/dist/shared/kb-layout.d.ts +0 -39
- package/dist/shared/kb-layout.d.ts.map +0 -1
- package/dist/shared/kb-layout.js +0 -103
- package/dist/shared/kb-layout.js.map +0 -1
- package/dist/shared/kb-layout.test.d.ts +0 -2
- package/dist/shared/kb-layout.test.d.ts.map +0 -1
- package/dist/shared/kb-layout.test.js +0 -75
- package/dist/shared/kb-layout.test.js.map +0 -1
- package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
- package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
- package/src/modules/workflow/session-ontology.policy.ts +0 -70
- package/src/modules/workflow/session-ontology.service.ts +0 -183
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
- package/src/modules/workspace/session-ontology.gate.ts +0 -191
- package/src/shared/kb-layout.test.ts +0 -98
- package/src/shared/kb-layout.ts +0 -102
|
@@ -11,16 +11,20 @@ import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
|
11
11
|
import { LocalFilesystem } from '@mastra/core/workspace';
|
|
12
12
|
import { ToolRegistry } from '../../tool-registry/tool-registry.js';
|
|
13
13
|
import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
14
|
-
import type
|
|
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 { CONTENT_RULE, registerWorkspaceTools } from '../workspace.tools.js';
|
|
17
17
|
import { RoutineWritePolicyService } from '../routine-write-policy.js';
|
|
18
18
|
import { UuidSessionSink, type ISessionSink } from '../session-sink.js';
|
|
19
|
-
import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
|
|
19
|
+
import { WorkflowHooks, type AgentOperationContext } from '../../workflow/workflow-hooks.js';
|
|
20
|
+
import { SESSION_ID_DESCRIPTION, ToolDescriptionNotes } from '../agent-access.gate.js';
|
|
20
21
|
import { SpillStore } from '../spill-store.js';
|
|
21
22
|
import { DocExtractService } from '../file-readers/doc-extract.service.js';
|
|
22
23
|
import { OCTET_STREAM_FALLBACK_NOTE } from '../file-readers/content-mode.js';
|
|
23
24
|
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
25
|
+
import { AccessControlService } from '../../access/access-control.service.js';
|
|
26
|
+
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
27
|
+
import type { WorkspaceService } from '../workspace.service.js';
|
|
24
28
|
import { isBranchAuthoredBy, isOwnSuggestionsBranch } from '@bevel-software/platform-shared';
|
|
25
29
|
import { assertValidBranchName } from '../../kb-fs/branch-name.js';
|
|
26
30
|
import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
|
|
@@ -43,6 +47,11 @@ const allowAll = {
|
|
|
43
47
|
canDownload: async () => true,
|
|
44
48
|
canOwner: async () => true,
|
|
45
49
|
canWriteBatchAtRef: async () => null,
|
|
50
|
+
// The `after` half of a move's or copy's preview. Allowing everything here
|
|
51
|
+
// keeps it the same answer as the four gates above; what the relocation of
|
|
52
|
+
// a folder's `access.md` files actually does to it is exercised against
|
|
53
|
+
// REAL rules in "a move preview judges the destination as it will be".
|
|
54
|
+
previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
|
|
46
55
|
} as unknown as IAccessControl;
|
|
47
56
|
|
|
48
57
|
/** Access control that denies `canRead` for an explicit set of repo-relative paths. */
|
|
@@ -76,6 +85,14 @@ let fs: LocalFilesystem;
|
|
|
76
85
|
let workspacePathCalls: string[] = [];
|
|
77
86
|
/** Every `(archive, destination)` pair `unzip` handed the service. */
|
|
78
87
|
let unzipCalls: [string, string | undefined][] = [];
|
|
88
|
+
/**
|
|
89
|
+
* Workspace-relative targets the `unzipFile` stand-in pretends the archive
|
|
90
|
+
* holds. Empty by default (the stub extracts nothing); a test that is about
|
|
91
|
+
* the per-entry write guard sets it, and the stand-in then runs the guard once
|
|
92
|
+
* per entry the way the real service does — which is asserted where it lives,
|
|
93
|
+
* in `workspace.service.test.ts`.
|
|
94
|
+
*/
|
|
95
|
+
let unzipEntries: string[] = [];
|
|
79
96
|
/** Every folder turn a tool took, as `workspaceId:dir`. */
|
|
80
97
|
let folderTurns: string[] = [];
|
|
81
98
|
/** The policy instance the tools were mounted with, so a test can restrict a session. */
|
|
@@ -88,6 +105,21 @@ let writePolicy: RoutineWritePolicyService;
|
|
|
88
105
|
let focusedBranch: string | undefined;
|
|
89
106
|
/** The registry the tools were mounted into, so a test can inspect their defs. */
|
|
90
107
|
let toolRegistry: ToolRegistry;
|
|
108
|
+
/** The hook registry the tools were mounted against, so a test can register one. */
|
|
109
|
+
let hooks: WorkflowHooks;
|
|
110
|
+
/** The note registry the tools were mounted against, so a test can register a note. */
|
|
111
|
+
let notes: ToolDescriptionNotes;
|
|
112
|
+
/**
|
|
113
|
+
* The recovery/merge bot's address, as the mounted gate knows it — the one
|
|
114
|
+
* identity that never reaches a hook.
|
|
115
|
+
*/
|
|
116
|
+
const RECOVERY_BOT = 'recovery-bot@bevel.local';
|
|
117
|
+
/**
|
|
118
|
+
* What the auth layer resolves this caller's source to. `internal` (the
|
|
119
|
+
* in-process agent) unless a test is about a person in the app (`session`) or
|
|
120
|
+
* an external agent.
|
|
121
|
+
*/
|
|
122
|
+
let callerSource: 'internal' | 'external' | 'session' = 'internal';
|
|
91
123
|
/**
|
|
92
124
|
* Another writer getting to a path in the window the real `LockingFilesystem`
|
|
93
125
|
* closes: the stand-ins below run it where that filesystem would be acquiring
|
|
@@ -103,6 +135,9 @@ async function start(
|
|
|
103
135
|
userEmail = 'e@x',
|
|
104
136
|
): Promise<string> {
|
|
105
137
|
raceHook = null;
|
|
138
|
+
hooks = new WorkflowHooks();
|
|
139
|
+
notes = new ToolDescriptionNotes();
|
|
140
|
+
callerSource = 'internal';
|
|
106
141
|
tempDir = await mkdtemp(join(tmpdir(), 'ws-tools-'));
|
|
107
142
|
docCacheDir = await mkdtemp(join(tmpdir(), 'ws-doc-cache-'));
|
|
108
143
|
fs = new LocalFilesystem({ basePath: tempDir, contained: true });
|
|
@@ -163,6 +198,7 @@ async function start(
|
|
|
163
198
|
await fs.writeFile('a.md', 'hello\nworld\n');
|
|
164
199
|
workspacePathCalls = [];
|
|
165
200
|
unzipCalls = [];
|
|
201
|
+
unzipEntries = [];
|
|
166
202
|
folderTurns = [];
|
|
167
203
|
writePolicy = new RoutineWritePolicyService();
|
|
168
204
|
focusedBranch = undefined;
|
|
@@ -195,7 +231,12 @@ async function start(
|
|
|
195
231
|
// shapes is absence, and the real service raises exactly this error for
|
|
196
232
|
// it (asserted in workspace.service.test.ts). Extraction itself lives
|
|
197
233
|
// there too — none of it is the tool's to decide.
|
|
198
|
-
unzipFile: async (
|
|
234
|
+
unzipFile: async (
|
|
235
|
+
_id: string,
|
|
236
|
+
zipRel: string,
|
|
237
|
+
destRel?: string,
|
|
238
|
+
guardWrite?: (wsRelativePath: string) => Promise<void>,
|
|
239
|
+
) => {
|
|
199
240
|
// Recorded so a test can assert the PATHS the tool handed over — both
|
|
200
241
|
// ends normalised into the repository.
|
|
201
242
|
unzipCalls.push([zipRel, destRel]);
|
|
@@ -204,7 +245,17 @@ async function start(
|
|
|
204
245
|
} catch {
|
|
205
246
|
throw new PathNotFoundError(zipRel);
|
|
206
247
|
}
|
|
207
|
-
|
|
248
|
+
const extracted: string[] = [];
|
|
249
|
+
const skipped: { path: string; reason: string }[] = [];
|
|
250
|
+
for (const entry of unzipEntries) {
|
|
251
|
+
try {
|
|
252
|
+
await guardWrite?.(entry);
|
|
253
|
+
extracted.push(entry);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
skipped.push({ path: entry, reason: err instanceof Error ? err.message : 'refused' });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { destination: '', extracted, skipped };
|
|
208
259
|
},
|
|
209
260
|
} as never,
|
|
210
261
|
workflowService: {} as never,
|
|
@@ -213,18 +264,19 @@ async function start(
|
|
|
213
264
|
});
|
|
214
265
|
const toolHandler = createToolHandlerFactory(resolve);
|
|
215
266
|
const fakeAuth = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
|
216
|
-
req.toolAuth = { source:
|
|
267
|
+
req.toolAuth = { source: callerSource, userId: 'u', scope };
|
|
217
268
|
next();
|
|
218
269
|
};
|
|
219
270
|
const app = express();
|
|
220
271
|
app.use(express.json());
|
|
221
272
|
const router = express.Router();
|
|
222
273
|
registerWorkspaceTools(registry, router, fakeAuth, toolHandler, new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(docCacheDir), access, testKbContext({ kbDirName: KB_DIR }), {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
recoveryBotEmail:
|
|
227
|
-
hooks
|
|
274
|
+
// The hooks and the notes a test registers against; with neither
|
|
275
|
+
// registered (the default, and every Hexis-only deployment) the gate
|
|
276
|
+
// refuses nothing and the descriptions say nothing extra.
|
|
277
|
+
recoveryBotEmail: RECOVERY_BOT,
|
|
278
|
+
hooks,
|
|
279
|
+
notes,
|
|
228
280
|
}, writePolicy, {} as never /* sessionSink — start_session not exercised here */);
|
|
229
281
|
app.use('/api', router);
|
|
230
282
|
httpServer = await new Promise<HttpServer>((r) => {
|
|
@@ -233,9 +285,24 @@ async function start(
|
|
|
233
285
|
return `http://127.0.0.1:${(httpServer.address() as { port: number }).port}`;
|
|
234
286
|
}
|
|
235
287
|
|
|
236
|
-
|
|
288
|
+
/** POST a body EXACTLY as given — for the tests about a malformed call. */
|
|
289
|
+
const postRaw = (url: string, body: unknown = {}) =>
|
|
237
290
|
fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer x' }, body: JSON.stringify(body) });
|
|
238
291
|
|
|
292
|
+
/**
|
|
293
|
+
* POST a well-formed call. Every KB tool requires `branch` — a call that names
|
|
294
|
+
* none is refused at the mount with 400 `branch-required` — so this names one
|
|
295
|
+
* unless the test already did. A test ABOUT the missing input uses `postRaw`,
|
|
296
|
+
* so the thing under test is never papered over by the helper.
|
|
297
|
+
*/
|
|
298
|
+
const post = (url: string, body: unknown = {}) =>
|
|
299
|
+
postRaw(
|
|
300
|
+
url,
|
|
301
|
+
body !== null && typeof body === 'object' && !Array.isArray(body) && !('branch' in body)
|
|
302
|
+
? { branch: 'main', ...body }
|
|
303
|
+
: body,
|
|
304
|
+
);
|
|
305
|
+
|
|
239
306
|
beforeEach(() => {
|
|
240
307
|
/* fresh per test via start() */
|
|
241
308
|
});
|
|
@@ -456,7 +523,8 @@ describe('workspace file primitives', () => {
|
|
|
456
523
|
// named "undefined" — it fails closed with a clear 4xx naming the missing
|
|
457
524
|
// branch context, BEFORE any workspace resolve.
|
|
458
525
|
expect(focusedBranch).toBeUndefined();
|
|
459
|
-
|
|
526
|
+
// `postRaw`: the absent field IS the test here, so the body goes as written.
|
|
527
|
+
const res = await postRaw(`${base}/api/agent/tools/execute_command`, { command: 'echo should-not-run' });
|
|
460
528
|
expect(res.status).toBe(400);
|
|
461
529
|
expect((await res.json()).error).toMatch(/branch/i);
|
|
462
530
|
// The guard runs before `getOrCreateForBranch`, so no workspace (least of all
|
|
@@ -471,7 +539,7 @@ describe('workspace file primitives', () => {
|
|
|
471
539
|
// against that branch's workspace and returns output end to end (AC2) —
|
|
472
540
|
// rather than failing closed the way a context-less external call does.
|
|
473
541
|
focusedBranch = 'main';
|
|
474
|
-
const res = (await (await
|
|
542
|
+
const res = (await (await postRaw(`${base}/api/agent/tools/execute_command`, { command: 'echo hello-exec' })).json()) as { stdout: string; exitCode: number };
|
|
475
543
|
expect(res.exitCode).toBe(0);
|
|
476
544
|
expect(res.stdout).toContain('hello-exec');
|
|
477
545
|
// Resolved against the focused branch — never an "undefined" workspace.
|
|
@@ -555,6 +623,20 @@ describe('workspace file primitives', () => {
|
|
|
555
623
|
expect(workspacePathCalls).toEqual([]);
|
|
556
624
|
});
|
|
557
625
|
|
|
626
|
+
it('answers a branch-less call with the same kind every other KB tool uses', async () => {
|
|
627
|
+
const base = await start();
|
|
628
|
+
// The MESSAGE here is this tool's own — only `execute_command` has a
|
|
629
|
+
// focused-branch fallback to explain. The DISCRIMINATOR is shared, so a
|
|
630
|
+
// client switches on one kind across the whole surface instead of matching
|
|
631
|
+
// prose per tool.
|
|
632
|
+
for (const body of [{ command: 'x' }, { branch: '', command: 'x' }, { branch: 'undefined', command: 'x' }]) {
|
|
633
|
+
const res = await postRaw(`${base}/api/agent/tools/execute_command`, body);
|
|
634
|
+
expect(res.status).toBe(400);
|
|
635
|
+
expect((await res.json()).kind).toBe('branch-required');
|
|
636
|
+
}
|
|
637
|
+
expect(workspacePathCalls).toEqual([]);
|
|
638
|
+
});
|
|
639
|
+
|
|
558
640
|
it('execute_command validates the branch BEFORE the write-policy gate', async () => {
|
|
559
641
|
const base = await start();
|
|
560
642
|
// A routine-restricted session is refused by `assertUnrestricted` with 403.
|
|
@@ -1786,7 +1868,7 @@ describe('images', () => {
|
|
|
1786
1868
|
/**
|
|
1787
1869
|
* start_session must mint a REAL chat thread and return its id (not a bare
|
|
1788
1870
|
* random UUID), so the same id works for KB reads AND for `ask` (whose
|
|
1789
|
-
* sessionId IS a chat thread). This is what unifies the
|
|
1871
|
+
* sessionId IS a chat thread). This is what unifies the conversation
|
|
1790
1872
|
* across reads + ask — see workspace.tools.ts start_session comment.
|
|
1791
1873
|
*/
|
|
1792
1874
|
describe('start_session', () => {
|
|
@@ -1833,7 +1915,7 @@ describe('start_session', () => {
|
|
|
1833
1915
|
registerWorkspaceTools(
|
|
1834
1916
|
registry, router, auth, toolHandler,
|
|
1835
1917
|
new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, testKbContext({ kbDirName: KB_DIR }),
|
|
1836
|
-
{
|
|
1918
|
+
{ recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
1837
1919
|
new RoutineWritePolicyService(),
|
|
1838
1920
|
sink ?? fakeSessionSink,
|
|
1839
1921
|
);
|
|
@@ -1958,7 +2040,7 @@ describe('start_session', () => {
|
|
|
1958
2040
|
registerWorkspaceTools(
|
|
1959
2041
|
registry, router, noopAuth, (() => () => {}) as never,
|
|
1960
2042
|
new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, testKbContext({ kbDirName: KB_DIR }),
|
|
1961
|
-
{
|
|
2043
|
+
{ recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
1962
2044
|
new RoutineWritePolicyService(),
|
|
1963
2045
|
{} as never,
|
|
1964
2046
|
);
|
|
@@ -2000,7 +2082,7 @@ describe('branch is a required parameter in the tool contract', () => {
|
|
|
2000
2082
|
new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')),
|
|
2001
2083
|
allowAll,
|
|
2002
2084
|
testKbContext({ kbDirName: KB_DIR }),
|
|
2003
|
-
{
|
|
2085
|
+
{ recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
2004
2086
|
new RoutineWritePolicyService(),
|
|
2005
2087
|
{} as never,
|
|
2006
2088
|
);
|
|
@@ -2266,6 +2348,8 @@ describe('the tools place an unprefixed path inside the repository', () => {
|
|
|
2266
2348
|
describe('folders never vanish', () => {
|
|
2267
2349
|
const KB = (p: string) => `${KB_DIR}/${p}`;
|
|
2268
2350
|
const tool = async (base: string, name: string, body: Record<string, unknown>) => {
|
|
2351
|
+
// `post` names the branch every KB tool requires; these tests are about
|
|
2352
|
+
// folders, not about the branch input.
|
|
2269
2353
|
const res = await post(`${base}/api/agent/tools/${name}`, body);
|
|
2270
2354
|
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
|
|
2271
2355
|
};
|
|
@@ -2482,6 +2566,11 @@ describe('preflight for moves and deletes', () => {
|
|
|
2482
2566
|
canWriteBatchAtRef: async (_w: string, _r: string, _u: string, rels: string[]) =>
|
|
2483
2567
|
new Map(rels.map((p) => [p, verbsFor(p).write])),
|
|
2484
2568
|
eligibleWritersAtRef: async () => ({ roles: ['Admin'], users: [] }),
|
|
2569
|
+
// This double has no access files to carry, so the destination as it will
|
|
2570
|
+
// be IS the destination as it is — the answer `accessAt` gave before the
|
|
2571
|
+
// preview learned to relocate them, which keeps every ordering and
|
|
2572
|
+
// oracle test below about what it is about.
|
|
2573
|
+
previewAccessAfterRelocation: async (_w: string, _u: string, _from: string, to: string) => verbsFor(to),
|
|
2485
2574
|
} as unknown as IAccessControl;
|
|
2486
2575
|
|
|
2487
2576
|
const call = async (base: string, tool: string, body: Record<string, unknown>) => {
|
|
@@ -2625,6 +2714,35 @@ describe('preflight for moves and deletes', () => {
|
|
|
2625
2714
|
expect(await exists(KB('Sales/deal.md'))).toBe(true);
|
|
2626
2715
|
});
|
|
2627
2716
|
|
|
2717
|
+
/**
|
|
2718
|
+
* The dry run keeps that order too, and it is the easier oracle to reach:
|
|
2719
|
+
* it answers 200 rather than throwing, so a denied caller could read the
|
|
2720
|
+
* source's kind and its file count off it — or a 404 saying whether a
|
|
2721
|
+
* source they may not copy from exists at all. Nothing on disk is probed
|
|
2722
|
+
* until the write verdict on the destination has been taken.
|
|
2723
|
+
*/
|
|
2724
|
+
it('copy_file\'s dry run refuses a denied destination the same way whatever the source is', async () => {
|
|
2725
|
+
const base = await seeded();
|
|
2726
|
+
const dry = async (src: string) =>
|
|
2727
|
+
(await call(base, 'copy_file', { src, dest: KB('Locked/new.md'), dryRun: true })).body;
|
|
2728
|
+
|
|
2729
|
+
const file = await dry(KB('Sales/deal.md'));
|
|
2730
|
+
const folder = await dry(KB('Sales/archive'));
|
|
2731
|
+
const missing = await dry(KB('Sales/nothing-here.md'));
|
|
2732
|
+
|
|
2733
|
+
// One sentence, three sources: the refusal says nothing about any of them.
|
|
2734
|
+
for (const answer of [file, folder, missing]) {
|
|
2735
|
+
expect(answer).toMatchObject({ allowed: false, dryRun: true, copied: false });
|
|
2736
|
+
expect(answer.reason).toContain(KB('Locked/new.md'));
|
|
2737
|
+
expect(answer.kind).toBeUndefined();
|
|
2738
|
+
expect(answer.descendants).toBeUndefined();
|
|
2739
|
+
}
|
|
2740
|
+
expect(folder.reason).toBe(file.reason);
|
|
2741
|
+
expect(missing.reason).toBe(file.reason);
|
|
2742
|
+
// And the caller's own verbs are still answered — those are theirs.
|
|
2743
|
+
expect(Object.keys(file.access).sort()).toEqual(['after', 'before']);
|
|
2744
|
+
});
|
|
2745
|
+
|
|
2628
2746
|
it('copy_file keeps the same order: the write refusal, not what is in the folder', async () => {
|
|
2629
2747
|
const base = await seeded();
|
|
2630
2748
|
|
|
@@ -3278,6 +3396,7 @@ describe('a write refused for permissions says whether and how to propose it', (
|
|
|
3278
3396
|
canDownload: async () => true,
|
|
3279
3397
|
canOwner: async () => true,
|
|
3280
3398
|
canWriteBatchAtRef: async () => null,
|
|
3399
|
+
previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
|
|
3281
3400
|
eligibleWritersAtRef: async () => ({ roles: ['Sales Lead'], users: [{ name: 'Owner', email: 'owner@x' }] }),
|
|
3282
3401
|
} as unknown as IAccessControl;
|
|
3283
3402
|
return { ac, calls };
|
|
@@ -3686,3 +3805,587 @@ describe('a path with nothing at it answers 404 not_found on every file tool', (
|
|
|
3686
3805
|
}
|
|
3687
3806
|
});
|
|
3688
3807
|
});
|
|
3808
|
+
|
|
3809
|
+
/**
|
|
3810
|
+
* The neutral session hooks, through the tool routes.
|
|
3811
|
+
*
|
|
3812
|
+
* Hexis decides nothing about which paths a conversation may touch: it calls a
|
|
3813
|
+
* registered hook before every agent read and every agent write of a
|
|
3814
|
+
* knowledge-base path, hands it what it knows about the call, and lets a hook
|
|
3815
|
+
* that throws refuse the operation with its own message and status. With no
|
|
3816
|
+
* hook registered — every Hexis-only deployment — nothing is refused, nothing
|
|
3817
|
+
* is recorded, and a call without a `sessionId` is an ordinary call.
|
|
3818
|
+
*/
|
|
3819
|
+
describe('agent read/write hooks', () => {
|
|
3820
|
+
/** Every call the read hook saw, in order. */
|
|
3821
|
+
let reads: AgentOperationContext[];
|
|
3822
|
+
/** Every call the write hook saw, in order. */
|
|
3823
|
+
let writes: AgentOperationContext[];
|
|
3824
|
+
|
|
3825
|
+
/** Register recording hooks on the mounted registry (after `start()`). */
|
|
3826
|
+
const record = (): void => {
|
|
3827
|
+
reads = [];
|
|
3828
|
+
writes = [];
|
|
3829
|
+
hooks.onAgentRead(async (op) => {
|
|
3830
|
+
reads.push(op);
|
|
3831
|
+
});
|
|
3832
|
+
hooks.onPreWrite(async (op) => {
|
|
3833
|
+
writes.push(op);
|
|
3834
|
+
});
|
|
3835
|
+
};
|
|
3836
|
+
|
|
3837
|
+
/** Register a write hook that refuses `path` (or every path) with a 403. */
|
|
3838
|
+
const refuseWrites = (message: string, path?: string): void => {
|
|
3839
|
+
hooks.onPreWrite(async (op) => {
|
|
3840
|
+
if (path === undefined || op.wsPath === path) throw new ToolError(message, 403);
|
|
3841
|
+
});
|
|
3842
|
+
};
|
|
3843
|
+
|
|
3844
|
+
beforeEach(() => {
|
|
3845
|
+
reads = [];
|
|
3846
|
+
writes = [];
|
|
3847
|
+
});
|
|
3848
|
+
|
|
3849
|
+
it('read_file calls the read hook once with the session, path, branch, user and source', async () => {
|
|
3850
|
+
const base = await start();
|
|
3851
|
+
record();
|
|
3852
|
+
await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
|
|
3853
|
+
await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/Roadmap.md`, 'plans');
|
|
3854
|
+
const res = await post(`${base}/api/agent/tools/read_file`, {
|
|
3855
|
+
branch: 'draft-1',
|
|
3856
|
+
path: `${KB_DIR}/KnowledgeBase/Product/Knowledge/Roadmap.md`,
|
|
3857
|
+
sessionId: 's1',
|
|
3858
|
+
});
|
|
3859
|
+
expect(res.status).toBe(200);
|
|
3860
|
+
expect(reads).toEqual([
|
|
3861
|
+
{
|
|
3862
|
+
sessionId: 's1',
|
|
3863
|
+
wsPath: `${KB_DIR}/KnowledgeBase/Product/Knowledge/Roadmap.md`,
|
|
3864
|
+
branch: 'draft-1',
|
|
3865
|
+
user: { id: 'u', email: 'e@x', name: 'N' },
|
|
3866
|
+
source: 'internal',
|
|
3867
|
+
},
|
|
3868
|
+
]);
|
|
3869
|
+
expect(writes).toEqual([]);
|
|
3870
|
+
});
|
|
3871
|
+
|
|
3872
|
+
it('the read hook covers list_files, file_stat, grep, delete_file and delete_folder', async () => {
|
|
3873
|
+
const base = await start();
|
|
3874
|
+
record();
|
|
3875
|
+
await fs.mkdir(`${KB_DIR}/Folder`, { recursive: true });
|
|
3876
|
+
await fs.writeFile(`${KB_DIR}/Folder/one.md`, 'needle');
|
|
3877
|
+
await fs.writeFile(`${KB_DIR}/gone.md`, 'x');
|
|
3878
|
+
await post(`${base}/api/agent/tools/list_files`, { path: `${KB_DIR}/Folder`, sessionId: 's1' });
|
|
3879
|
+
await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/Folder/one.md`, sessionId: 's1' });
|
|
3880
|
+
await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: `${KB_DIR}/Folder`, sessionId: 's1' });
|
|
3881
|
+
await post(`${base}/api/agent/tools/delete_file`, { path: `${KB_DIR}/gone.md`, sessionId: 's1' });
|
|
3882
|
+
await post(`${base}/api/agent/tools/delete_folder`, { path: `${KB_DIR}/Folder`, confirm: true, sessionId: 's1' });
|
|
3883
|
+
const paths = reads.map((r) => r.wsPath);
|
|
3884
|
+
expect(paths).toContain(`${KB_DIR}/Folder`);
|
|
3885
|
+
expect(paths).toContain(`${KB_DIR}/Folder/one.md`);
|
|
3886
|
+
expect(paths).toContain(`${KB_DIR}/gone.md`);
|
|
3887
|
+
// A delete carries no bytes from elsewhere, so it is a read, never a write.
|
|
3888
|
+
expect(writes).toEqual([]);
|
|
3889
|
+
});
|
|
3890
|
+
|
|
3891
|
+
it('grep tells the read hook about every file the walk opens, not only its root', async () => {
|
|
3892
|
+
const base = await start();
|
|
3893
|
+
record();
|
|
3894
|
+
await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
|
|
3895
|
+
await fs.mkdir(`${KB_DIR}/KnowledgeBase/Legal/Knowledge`, { recursive: true });
|
|
3896
|
+
await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`, 'needle');
|
|
3897
|
+
await fs.writeFile(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`, 'needle');
|
|
3898
|
+
await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: KB_DIR, sessionId: 's1' });
|
|
3899
|
+
const paths = reads.map((r) => r.wsPath);
|
|
3900
|
+
expect(paths).toContain(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`);
|
|
3901
|
+
expect(paths).toContain(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`);
|
|
3902
|
+
});
|
|
3903
|
+
|
|
3904
|
+
it('the write hook covers write_file, edit_file and mkdir, once per path', async () => {
|
|
3905
|
+
const base = await start();
|
|
3906
|
+
record();
|
|
3907
|
+
await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/new.md`, content: 'hi', sessionId: 's1' });
|
|
3908
|
+
await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'world', new_string: 'earth', sessionId: 's1' });
|
|
3909
|
+
await post(`${base}/api/agent/tools/mkdir`, { path: `${KB_DIR}/dir`, sessionId: 's1' });
|
|
3910
|
+
expect(writes.map((w) => w.wsPath)).toEqual([`${KB_DIR}/new.md`, `${KB_DIR}/a.md`, `${KB_DIR}/dir`]);
|
|
3911
|
+
expect(writes.every((w) => w.sessionId === 's1' && w.branch === 'main')).toBe(true);
|
|
3912
|
+
});
|
|
3913
|
+
|
|
3914
|
+
it('move_file and copy_file call the write hook for the source path AND the destination', async () => {
|
|
3915
|
+
const base = await start();
|
|
3916
|
+
record();
|
|
3917
|
+
await fs.writeFile(`${KB_DIR}/src.md`, 'body');
|
|
3918
|
+
await fs.writeFile(`${KB_DIR}/copy-me.md`, 'body');
|
|
3919
|
+
await post(`${base}/api/agent/tools/move_file`, { src: `${KB_DIR}/src.md`, dest: `${KB_DIR}/moved.md`, confirm: true, sessionId: 's1' });
|
|
3920
|
+
await post(`${base}/api/agent/tools/copy_file`, { src: `${KB_DIR}/copy-me.md`, dest: `${KB_DIR}/copied.md`, sessionId: 's1' });
|
|
3921
|
+
expect(writes.map((w) => w.wsPath)).toEqual([
|
|
3922
|
+
`${KB_DIR}/src.md`,
|
|
3923
|
+
`${KB_DIR}/moved.md`,
|
|
3924
|
+
`${KB_DIR}/copy-me.md`,
|
|
3925
|
+
`${KB_DIR}/copied.md`,
|
|
3926
|
+
]);
|
|
3927
|
+
});
|
|
3928
|
+
|
|
3929
|
+
it('unzip reads the archive and calls the write hook once per extracted entry', async () => {
|
|
3930
|
+
const base = await start();
|
|
3931
|
+
record();
|
|
3932
|
+
await fs.writeFile(`${KB_DIR}/a.zip`, 'not-really-a-zip');
|
|
3933
|
+
unzipEntries = [`${KB_DIR}/out/one.md`, `${KB_DIR}/out/two.md`];
|
|
3934
|
+
const res = await post(`${base}/api/agent/tools/unzip`, { path: `${KB_DIR}/a.zip`, sessionId: 's1' });
|
|
3935
|
+
expect(res.status).toBe(200);
|
|
3936
|
+
expect(reads.map((r) => r.wsPath)).toEqual([`${KB_DIR}/a.zip`]);
|
|
3937
|
+
expect(writes.map((w) => w.wsPath)).toEqual([`${KB_DIR}/out/one.md`, `${KB_DIR}/out/two.md`]);
|
|
3938
|
+
});
|
|
3939
|
+
|
|
3940
|
+
it('execute_command calls the write hook once, with no path', async () => {
|
|
3941
|
+
const base = await start();
|
|
3942
|
+
record();
|
|
3943
|
+
await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'echo hi', sessionId: 's1' });
|
|
3944
|
+
expect(writes).toHaveLength(1);
|
|
3945
|
+
expect(writes[0].wsPath).toBeUndefined();
|
|
3946
|
+
expect(writes[0]).toMatchObject({ sessionId: 's1', branch: 'main', source: 'internal' });
|
|
3947
|
+
expect(reads).toEqual([]);
|
|
3948
|
+
});
|
|
3949
|
+
|
|
3950
|
+
it('a write hook that throws refuses the write with its own message and status, and the file is unchanged', async () => {
|
|
3951
|
+
const base = await start();
|
|
3952
|
+
record();
|
|
3953
|
+
refuseWrites('Not in this conversation.');
|
|
3954
|
+
const res = await post(`${base}/api/agent/tools/write_file`, {
|
|
3955
|
+
path: `${KB_DIR}/a.md`,
|
|
3956
|
+
// `overwrite`, so the unchanged file below is the hook's doing: with no
|
|
3957
|
+
// hook this call WOULD replace `a.md`.
|
|
3958
|
+
mode: 'overwrite',
|
|
3959
|
+
content: 'clobbered',
|
|
3960
|
+
sessionId: 's1',
|
|
3961
|
+
});
|
|
3962
|
+
expect(res.status).toBe(403);
|
|
3963
|
+
expect(((await res.json()) as { error: string }).error).toBe('Not in this conversation.');
|
|
3964
|
+
expect(await readFile(join(tempDir, `${KB_DIR}/a.md`), 'utf8')).toBe('hello\nworld\n');
|
|
3965
|
+
});
|
|
3966
|
+
|
|
3967
|
+
it('a read hook that throws refuses the read with its own message and status', async () => {
|
|
3968
|
+
const base = await start();
|
|
3969
|
+
hooks.onAgentRead(async () => {
|
|
3970
|
+
throw new ToolError('Not in this conversation.', 403);
|
|
3971
|
+
});
|
|
3972
|
+
const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md`, sessionId: 's1' });
|
|
3973
|
+
expect(res.status).toBe(403);
|
|
3974
|
+
expect(((await res.json()) as { error: string }).error).toBe('Not in this conversation.');
|
|
3975
|
+
});
|
|
3976
|
+
|
|
3977
|
+
it('write_files reports the refused path only; the other two land', async () => {
|
|
3978
|
+
const base = await start();
|
|
3979
|
+
record();
|
|
3980
|
+
refuseWrites('Not in this conversation.', `${KB_DIR}/two.md`);
|
|
3981
|
+
const res = await post(`${base}/api/agent/tools/write_files`, {
|
|
3982
|
+
files: [
|
|
3983
|
+
{ path: `${KB_DIR}/one.md`, content: '1' },
|
|
3984
|
+
{ path: `${KB_DIR}/two.md`, content: '2' },
|
|
3985
|
+
{ path: `${KB_DIR}/three.md`, content: '3' },
|
|
3986
|
+
],
|
|
3987
|
+
sessionId: 's1',
|
|
3988
|
+
});
|
|
3989
|
+
expect(res.status).toBe(200);
|
|
3990
|
+
const body = (await res.json()) as { count: number; files: { path: string; outcome: string; message?: string }[] };
|
|
3991
|
+
expect(body.files.map((f) => f.outcome)).toEqual(['created', 'refused', 'created']);
|
|
3992
|
+
expect(body.files[1].message).toBe('Not in this conversation.');
|
|
3993
|
+
expect(body.count).toBe(2);
|
|
3994
|
+
expect(await readFile(join(tempDir, `${KB_DIR}/one.md`), 'utf8')).toBe('1');
|
|
3995
|
+
expect(await readFile(join(tempDir, `${KB_DIR}/three.md`), 'utf8')).toBe('3');
|
|
3996
|
+
});
|
|
3997
|
+
|
|
3998
|
+
it('a write hook that fails unexpectedly fails the whole write_files batch, rather than reading as a refusal', async () => {
|
|
3999
|
+
const base = await start();
|
|
4000
|
+
record();
|
|
4001
|
+
// Not a ToolError: the hook did not JUDGE this path, the gate itself
|
|
4002
|
+
// broke. Reporting that as `refused` would let the other paths commit
|
|
4003
|
+
// past a gate that never ran.
|
|
4004
|
+
hooks.onPreWrite(async (op) => {
|
|
4005
|
+
if (op.wsPath === `${KB_DIR}/two.md`) throw new Error('the hook store is down');
|
|
4006
|
+
});
|
|
4007
|
+
const res = await post(`${base}/api/agent/tools/write_files`, {
|
|
4008
|
+
files: [
|
|
4009
|
+
{ path: `${KB_DIR}/one.md`, content: '1' },
|
|
4010
|
+
{ path: `${KB_DIR}/two.md`, content: '2' },
|
|
4011
|
+
{ path: `${KB_DIR}/three.md`, content: '3' },
|
|
4012
|
+
],
|
|
4013
|
+
sessionId: 's1',
|
|
4014
|
+
});
|
|
4015
|
+
expect(res.status).toBe(500);
|
|
4016
|
+
for (const name of ['one.md', 'two.md', 'three.md']) {
|
|
4017
|
+
await expect(readFile(join(tempDir, `${KB_DIR}/${name}`), 'utf8')).rejects.toThrow();
|
|
4018
|
+
}
|
|
4019
|
+
});
|
|
4020
|
+
|
|
4021
|
+
it('unzip skips the refused entry with the hook\'s message and extracts the others', async () => {
|
|
4022
|
+
const base = await start();
|
|
4023
|
+
record();
|
|
4024
|
+
await fs.writeFile(`${KB_DIR}/a.zip`, 'not-really-a-zip');
|
|
4025
|
+
unzipEntries = [`${KB_DIR}/out/keep.md`, `${KB_DIR}/out/blocked.md`, `${KB_DIR}/out/also-keep.md`];
|
|
4026
|
+
refuseWrites('Not in this conversation.', `${KB_DIR}/out/blocked.md`);
|
|
4027
|
+
const res = await post(`${base}/api/agent/tools/unzip`, { path: `${KB_DIR}/a.zip`, sessionId: 's1' });
|
|
4028
|
+
const body = (await res.json()) as { extracted: string[]; skipped: { path: string; reason: string }[] };
|
|
4029
|
+
expect(body.extracted).toEqual([`${KB_DIR}/out/keep.md`, `${KB_DIR}/out/also-keep.md`]);
|
|
4030
|
+
expect(body.skipped).toEqual([{ path: `${KB_DIR}/out/blocked.md`, reason: 'Not in this conversation.' }]);
|
|
4031
|
+
});
|
|
4032
|
+
|
|
4033
|
+
it('a hook is called with no session id when the call carried none — what happens next is its decision', async () => {
|
|
4034
|
+
const base = await start();
|
|
4035
|
+
record();
|
|
4036
|
+
await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` });
|
|
4037
|
+
await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/fresh.md`, content: 'x' });
|
|
4038
|
+
expect(reads).toHaveLength(1);
|
|
4039
|
+
expect(reads[0].sessionId).toBeUndefined();
|
|
4040
|
+
expect(writes).toHaveLength(1);
|
|
4041
|
+
expect(writes[0].sessionId).toBeUndefined();
|
|
4042
|
+
});
|
|
4043
|
+
|
|
4044
|
+
it('a person saving in the app never reaches the hooks', async () => {
|
|
4045
|
+
const base = await start();
|
|
4046
|
+
record();
|
|
4047
|
+
callerSource = 'session';
|
|
4048
|
+
await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md`, sessionId: 's1' });
|
|
4049
|
+
await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/fresh.md`, content: 'x', sessionId: 's1' });
|
|
4050
|
+
expect(reads).toEqual([]);
|
|
4051
|
+
expect(writes).toEqual([]);
|
|
4052
|
+
});
|
|
4053
|
+
|
|
4054
|
+
it('the recovery bot never reaches the hooks', async () => {
|
|
4055
|
+
const base = await start('write', allowAll, RECOVERY_BOT);
|
|
4056
|
+
record();
|
|
4057
|
+
refuseWrites('Not in this conversation.');
|
|
4058
|
+
const res = await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/fresh.md`, content: 'x', sessionId: 's1' });
|
|
4059
|
+
expect(res.status).toBe(200);
|
|
4060
|
+
expect(reads).toEqual([]);
|
|
4061
|
+
expect(writes).toEqual([]);
|
|
4062
|
+
});
|
|
4063
|
+
|
|
4064
|
+
/**
|
|
4065
|
+
* The Hexis-only deployment: nothing registered at all. No call is refused,
|
|
4066
|
+
* and a file tool call without a `sessionId` succeeds — including one that
|
|
4067
|
+
* read under two different folders first.
|
|
4068
|
+
*/
|
|
4069
|
+
it('with no hook registered, reads and writes without a sessionId all succeed', async () => {
|
|
4070
|
+
const base = await start();
|
|
4071
|
+
await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
|
|
4072
|
+
await fs.mkdir(`${KB_DIR}/KnowledgeBase/Legal/Knowledge`, { recursive: true });
|
|
4073
|
+
await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`, 'p');
|
|
4074
|
+
await fs.writeFile(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`, 'l');
|
|
4075
|
+
expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md` })).status).toBe(200);
|
|
4076
|
+
expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md` })).status).toBe(200);
|
|
4077
|
+
const wrote = await post(`${base}/api/agent/tools/write_file`, {
|
|
4078
|
+
path: `${KB_DIR}/KnowledgeBase/Product/Knowledge/New.md`,
|
|
4079
|
+
content: 'fresh',
|
|
4080
|
+
});
|
|
4081
|
+
expect(wrote.status).toBe(200);
|
|
4082
|
+
expect(await readFile(join(tempDir, `${KB_DIR}/KnowledgeBase/Product/Knowledge/New.md`), 'utf8')).toBe('fresh');
|
|
4083
|
+
});
|
|
4084
|
+
});
|
|
4085
|
+
|
|
4086
|
+
/**
|
|
4087
|
+
* What an agent READS about these tools. A Hexis-only deployment names no
|
|
4088
|
+
* boundary and no ontology; a deployment that has one registers the wording
|
|
4089
|
+
* for it, and it lands on the gated tools and on the `sessionId` input.
|
|
4090
|
+
*/
|
|
4091
|
+
describe('tool descriptions and the deployment note', () => {
|
|
4092
|
+
/** Every tool def on the external surface, by name. */
|
|
4093
|
+
const defs = async (): Promise<Map<string, { description: string; inputs: unknown }>> =>
|
|
4094
|
+
new Map((await toolRegistry.listExternal()).map((t) => [t.name, t as unknown as { description: string; inputs: unknown }]));
|
|
4095
|
+
|
|
4096
|
+
/** The `sessionId` input's description on a built def, or undefined. */
|
|
4097
|
+
const sessionIdDescriptionOf = (def: { inputs: unknown }): string | undefined =>
|
|
4098
|
+
(def.inputs as { properties?: { body?: { properties?: Record<string, { description?: string }> } } })
|
|
4099
|
+
.properties?.body?.properties?.sessionId?.description;
|
|
4100
|
+
|
|
4101
|
+
it('no tool description and no input description contains the word "ontology"', async () => {
|
|
4102
|
+
await start();
|
|
4103
|
+
for (const tool of await toolRegistry.listExternal()) {
|
|
4104
|
+
expect(tool.description.toLowerCase(), `${tool.name} description`).not.toContain('ontolog');
|
|
4105
|
+
expect(JSON.stringify(tool.inputs).toLowerCase(), `${tool.name} inputs`).not.toContain('ontolog');
|
|
4106
|
+
}
|
|
4107
|
+
});
|
|
4108
|
+
|
|
4109
|
+
it('start_session says what the id is — the conversation\'s, shared with `ask` — and names no ontology', async () => {
|
|
4110
|
+
await start();
|
|
4111
|
+
const description = (await defs()).get('start_session')?.description ?? '';
|
|
4112
|
+
expect(description).toMatch(/conversation/i);
|
|
4113
|
+
expect(description).toContain('`ask`');
|
|
4114
|
+
expect(description.toLowerCase()).not.toContain('ontolog');
|
|
4115
|
+
});
|
|
4116
|
+
|
|
4117
|
+
it('the file tools still accept a sessionId, described as this conversation\'s id', async () => {
|
|
4118
|
+
await start();
|
|
4119
|
+
const all = await defs();
|
|
4120
|
+
for (const name of ['read_file', 'write_file', 'edit_file', 'grep', 'unzip']) {
|
|
4121
|
+
expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(SESSION_ID_DESCRIPTION);
|
|
4122
|
+
}
|
|
4123
|
+
});
|
|
4124
|
+
|
|
4125
|
+
it('a Hexis-only deployment registers no note, so nothing is appended', async () => {
|
|
4126
|
+
await start();
|
|
4127
|
+
const all = await defs();
|
|
4128
|
+
expect(notes.gatedToolNote()).toBe('');
|
|
4129
|
+
expect(all.get('read_file')!.description).not.toContain('Stay within one');
|
|
4130
|
+
});
|
|
4131
|
+
|
|
4132
|
+
it('a registered note lands at the END of every gated tool\'s description and on the sessionId input', async () => {
|
|
4133
|
+
await start();
|
|
4134
|
+
notes.registerGatedToolNote(' One folder per conversation.');
|
|
4135
|
+
notes.registerSessionIdNote(' It also pins that folder.');
|
|
4136
|
+
const all = await defs();
|
|
4137
|
+
for (const name of ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip']) {
|
|
4138
|
+
expect(all.get(name)!.description.endsWith(' One folder per conversation.'), name).toBe(true);
|
|
4139
|
+
expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(`${SESSION_ID_DESCRIPTION} It also pins that folder.`);
|
|
4140
|
+
}
|
|
4141
|
+
// `execute_command` is internal-only, so it is checked on that surface.
|
|
4142
|
+
const internal = new Map((await toolRegistry.listInternal()).map((t) => [t.name, t]));
|
|
4143
|
+
expect(internal.get('execute_command')!.description.endsWith(' One folder per conversation.')).toBe(true);
|
|
4144
|
+
});
|
|
4145
|
+
|
|
4146
|
+
it('a tool that is not gated carries no note', async () => {
|
|
4147
|
+
await start();
|
|
4148
|
+
notes.registerGatedToolNote(' One folder per conversation.');
|
|
4149
|
+
const description = (await defs()).get('start_session')?.description ?? '';
|
|
4150
|
+
expect(description).not.toContain('One folder per conversation.');
|
|
4151
|
+
});
|
|
4152
|
+
});
|
|
4153
|
+
|
|
4154
|
+
/**
|
|
4155
|
+
* The bug Juan reported: the dry run of renaming a top-level folder warned he
|
|
4156
|
+
* would lose owner access, and after the move he was still owner. The folder
|
|
4157
|
+
* carried its own `access.md`, which moved with it, and the preview judged
|
|
4158
|
+
* the destination as it stood — where neither the folder nor its rules were
|
|
4159
|
+
* yet.
|
|
4160
|
+
*
|
|
4161
|
+
* These run against the REAL resolver over a real tree, because the thing
|
|
4162
|
+
* under test is what the rules say about a path nothing is at. Every preview
|
|
4163
|
+
* is then held against the operation it predicted: the caller's access at the
|
|
4164
|
+
* destination afterwards must be what the preview answered, or the preview is
|
|
4165
|
+
* wrong again and nobody will find out until the next report.
|
|
4166
|
+
*/
|
|
4167
|
+
describe('a move preview judges the destination as it will be', () => {
|
|
4168
|
+
const KB = (p: string) => `${KB_DIR}/${p}`;
|
|
4169
|
+
const MOVER = 'mover@x.io';
|
|
4170
|
+
const rules = (body: string) => `---\n${body}---\n`;
|
|
4171
|
+
|
|
4172
|
+
/**
|
|
4173
|
+
* The real `AccessControlService`, re-read from disk on every question.
|
|
4174
|
+
* In production the file-change notifier invalidates the model after a
|
|
4175
|
+
* write; a test leaning on the five-second cache would be asserting the
|
|
4176
|
+
* cache rather than what the tree now says.
|
|
4177
|
+
*/
|
|
4178
|
+
const liveRules = (): IAccessControl => {
|
|
4179
|
+
const fresh = () =>
|
|
4180
|
+
new AccessControlService(
|
|
4181
|
+
{
|
|
4182
|
+
getWorkspacePath: async () => tempDir,
|
|
4183
|
+
ensureRemotesFetched: async () => undefined,
|
|
4184
|
+
} as unknown as WorkspaceService,
|
|
4185
|
+
KB_DIR,
|
|
4186
|
+
new NodeFs(),
|
|
4187
|
+
);
|
|
4188
|
+
return {
|
|
4189
|
+
canRead: (w: string, u: string, r: string) => fresh().canRead(w, u, r),
|
|
4190
|
+
canReadBatch: (w: string, u: string, r: string[]) => fresh().canReadBatch(w, u, r),
|
|
4191
|
+
canWrite: (w: string, u: string, r: string) => fresh().canWrite(w, u, r),
|
|
4192
|
+
canDownload: (w: string, u: string, r: string) => fresh().canDownload(w, u, r),
|
|
4193
|
+
canOwner: (w: string, u: string, r: string) => fresh().canOwner(w, u, r),
|
|
4194
|
+
canWriteBatchAtRef: async () => null,
|
|
4195
|
+
previewAccessAfterRelocation: (
|
|
4196
|
+
w: string, u: string, from: string, to: string, opts?: { sourceRemains?: boolean },
|
|
4197
|
+
) => fresh().previewAccessAfterRelocation(w, u, from, to, opts),
|
|
4198
|
+
} as unknown as IAccessControl;
|
|
4199
|
+
};
|
|
4200
|
+
|
|
4201
|
+
const call = async (base: string, tool: string, body: Record<string, unknown>) => {
|
|
4202
|
+
const res = await post(`${base}/api/agent/tools/${tool}`, body);
|
|
4203
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- a tool body is free-form JSON, probed field by field
|
|
4204
|
+
return { status: res.status, body: (await res.json()) as Record<string, any> };
|
|
4205
|
+
};
|
|
4206
|
+
|
|
4207
|
+
/**
|
|
4208
|
+
* `Sales/` grants the caller write and owner in its OWN access.md. `Work/`
|
|
4209
|
+
* grants both too, so `Work/Team` — which grants only write of its own —
|
|
4210
|
+
* holds owner by inheritance and loses it on the way out. `Legal/` grants
|
|
4211
|
+
* read to everyone and nothing else. The root grants read and nothing else,
|
|
4212
|
+
* so a folder that arrives there arrives with only what it brought.
|
|
4213
|
+
*/
|
|
4214
|
+
async function seeded(): Promise<string> {
|
|
4215
|
+
const base = await start('write', liveRules(), MOVER);
|
|
4216
|
+
await fs.writeFile(KB('roles.yaml'), 'roles:\n Admin:\n - admin@x.io\n');
|
|
4217
|
+
await fs.writeFile(KB('access.md'), rules('read:\n - everyone\n'));
|
|
4218
|
+
await fs.writeFile(KB('Sales/access.md'), rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`));
|
|
4219
|
+
await fs.writeFile(KB('Sales/deal.md'), 'deal');
|
|
4220
|
+
await fs.writeFile(KB('Work/access.md'), rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`));
|
|
4221
|
+
await fs.writeFile(KB('Work/Notes/note.md'), 'note');
|
|
4222
|
+
await fs.writeFile(KB('Work/Team/access.md'), rules(`write:\n - Mover <${MOVER}>\n`));
|
|
4223
|
+
await fs.writeFile(KB('Work/Team/plan.md'), 'plan');
|
|
4224
|
+
await fs.writeFile(KB('Legal/access.md'), rules('read:\n - everyone\n'));
|
|
4225
|
+
return base;
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
/** The caller's verbs where the thing really is now, through `file_stat`. */
|
|
4229
|
+
const verbsAt = async (base: string, path: string) =>
|
|
4230
|
+
(await call(base, 'file_stat', { path })).body.access;
|
|
4231
|
+
|
|
4232
|
+
it('renaming a folder that names the caller owner previews no loss at all', async () => {
|
|
4233
|
+
const base = await seeded();
|
|
4234
|
+
const args = { src: KB('Sales'), dest: KB('Revenue') };
|
|
4235
|
+
|
|
4236
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
4237
|
+
expect(dry.status).toBe(200);
|
|
4238
|
+
expect(dry.body).toMatchObject({ kind: 'folder', accessChanges: false, allowed: true, moved: false });
|
|
4239
|
+
expect(dry.body.access.after).toEqual(dry.body.access.before);
|
|
4240
|
+
expect(dry.body.access.after.owner).toBe(true);
|
|
4241
|
+
// Nothing moved and nothing was written to answer the question.
|
|
4242
|
+
expect(await fs.exists(args.dest)).toBe(false);
|
|
4243
|
+
expect(await fs.exists(KB('Sales/access.md'))).toBe(true);
|
|
4244
|
+
|
|
4245
|
+
// `accessChanges: false`, so the move itself needs no confirmation.
|
|
4246
|
+
expect((await call(base, 'move_file', args)).body).toMatchObject({ moved: true });
|
|
4247
|
+
expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
|
|
4248
|
+
});
|
|
4249
|
+
|
|
4250
|
+
it('moving a folder with no access.md of its own still previews the loss', async () => {
|
|
4251
|
+
const base = await seeded();
|
|
4252
|
+
const args = { src: KB('Work/Notes'), dest: KB('Legal/Notes') };
|
|
4253
|
+
|
|
4254
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
4255
|
+
expect(dry.body).toMatchObject({ accessChanges: true, allowed: true });
|
|
4256
|
+
expect(dry.body.access.before.write).toBe(true);
|
|
4257
|
+
expect(dry.body.access.after.write).toBe(false);
|
|
4258
|
+
|
|
4259
|
+
// A changed answer still asks before it moves.
|
|
4260
|
+
expect((await call(base, 'move_file', args)).body).toMatchObject({ confirmationRequired: true, moved: false });
|
|
4261
|
+
expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
|
|
4262
|
+
expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
|
|
4263
|
+
});
|
|
4264
|
+
|
|
4265
|
+
it('what the folder inherited is left behind; what its own access.md gives comes along', async () => {
|
|
4266
|
+
const base = await seeded();
|
|
4267
|
+
const args = { src: KB('Work/Team'), dest: KB('Legal/Team') };
|
|
4268
|
+
|
|
4269
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
4270
|
+
// Write is the folder's own and travels; owner came from `Work/`.
|
|
4271
|
+
expect(dry.body.access.before).toMatchObject({ write: true, owner: true });
|
|
4272
|
+
expect(dry.body.access.after).toMatchObject({ write: true, owner: false });
|
|
4273
|
+
|
|
4274
|
+
expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
|
|
4275
|
+
expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
|
|
4276
|
+
});
|
|
4277
|
+
|
|
4278
|
+
it('a nested access.md governs where it lands, not the folder above it', async () => {
|
|
4279
|
+
const base = await seeded();
|
|
4280
|
+
const args = { src: KB('Work/Team'), dest: KB('Legal/Team') };
|
|
4281
|
+
const withoutNested = (await call(base, 'move_file', { ...args, dryRun: true })).body.access.after;
|
|
4282
|
+
|
|
4283
|
+
// `Sub/` shuts the caller out and moves too, but it governs `Legal/Team/Sub`.
|
|
4284
|
+
await fs.writeFile(KB('Work/Team/Sub/access.md'), rules(`read:\n - deny Mover <${MOVER}>\n`));
|
|
4285
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
4286
|
+
expect(dry.body.access.after).toEqual(withoutNested);
|
|
4287
|
+
|
|
4288
|
+
expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
|
|
4289
|
+
expect(await verbsAt(base, args.dest)).toEqual(withoutNested);
|
|
4290
|
+
// The nested file did land and does govern where it landed: the caller
|
|
4291
|
+
// it shuts out cannot even stat the folder now.
|
|
4292
|
+
expect((await call(base, 'file_stat', { path: KB('Legal/Team/Sub') })).status).toBe(403);
|
|
4293
|
+
});
|
|
4294
|
+
|
|
4295
|
+
it('moving a single file previews as it always did', async () => {
|
|
4296
|
+
const base = await seeded();
|
|
4297
|
+
const args = { src: KB('Sales/deal.md'), dest: KB('Legal/deal.md') };
|
|
4298
|
+
|
|
4299
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
4300
|
+
expect(dry.body).toMatchObject({ kind: 'file', descendants: 1 });
|
|
4301
|
+
// Nothing travels with a file but its own bytes, so the answer is the
|
|
4302
|
+
// destination's own rules — what `file_stat` says there already.
|
|
4303
|
+
expect(dry.body.access.after).toEqual(await verbsAt(base, KB('Legal')));
|
|
4304
|
+
|
|
4305
|
+
expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
|
|
4306
|
+
expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
|
|
4307
|
+
});
|
|
4308
|
+
|
|
4309
|
+
it('the preview answers the caller\'s own verbs and nothing more', async () => {
|
|
4310
|
+
const base = await seeded();
|
|
4311
|
+
const dry = await call(base, 'move_file', { src: KB('Sales'), dest: KB('Revenue'), dryRun: true });
|
|
4312
|
+
|
|
4313
|
+
expect(Object.keys(dry.body.access).sort()).toEqual(['after', 'before']);
|
|
4314
|
+
for (const side of ['before', 'after'] as const) {
|
|
4315
|
+
expect(Object.keys(dry.body.access[side]).sort()).toEqual(['download', 'owner', 'read', 'write']);
|
|
4316
|
+
}
|
|
4317
|
+
});
|
|
4318
|
+
|
|
4319
|
+
describe('copy_file previews the same way', () => {
|
|
4320
|
+
it('a folder copy counts the copied access.md at the destination', async () => {
|
|
4321
|
+
const base = await seeded();
|
|
4322
|
+
const args = { src: KB('Sales'), dest: KB('Sales-Copy') };
|
|
4323
|
+
|
|
4324
|
+
const dry = await call(base, 'copy_file', { ...args, dryRun: true });
|
|
4325
|
+
expect(dry.status).toBe(200);
|
|
4326
|
+
expect(dry.body).toMatchObject({ kind: 'folder', accessChanges: false, copied: false, dryRun: true });
|
|
4327
|
+
expect(dry.body.access.after.owner).toBe(true);
|
|
4328
|
+
expect(dry.body.access.after).toEqual(dry.body.access.before);
|
|
4329
|
+
|
|
4330
|
+
// `copy_file` copies ONE FILE, which the preview says rather than
|
|
4331
|
+
// promising a copy that cannot land — and the call itself refuses with
|
|
4332
|
+
// the same sentence, so preflight and execution never disagree.
|
|
4333
|
+
expect(dry.body.allowed).toBe(false);
|
|
4334
|
+
const run = await call(base, 'copy_file', args);
|
|
4335
|
+
expect(run.status).toBe(400);
|
|
4336
|
+
expect(run.body.error).toBe(dry.body.reason);
|
|
4337
|
+
expect(await fs.exists(args.dest)).toBe(false);
|
|
4338
|
+
});
|
|
4339
|
+
|
|
4340
|
+
it('a file copy previews what the caller will have at the destination', async () => {
|
|
4341
|
+
const base = await seeded();
|
|
4342
|
+
const args = { src: KB('Sales/deal.md'), dest: KB('Legal/deal.md') };
|
|
4343
|
+
|
|
4344
|
+
const dry = await call(base, 'copy_file', { ...args, dryRun: true });
|
|
4345
|
+
expect(dry.body).toMatchObject({ kind: 'file', descendants: 1, allowed: true, copied: false });
|
|
4346
|
+
expect(dry.body.access.before.write).toBe(true);
|
|
4347
|
+
expect(dry.body.access.after.write).toBe(false);
|
|
4348
|
+
expect(dry.body.accessChanges).toBe(true);
|
|
4349
|
+
expect(await fs.exists(args.dest)).toBe(false);
|
|
4350
|
+
|
|
4351
|
+
expect((await call(base, 'copy_file', args)).body).toMatchObject({ copied: true });
|
|
4352
|
+
expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
|
|
4353
|
+
// A copy leaves the source exactly as it was.
|
|
4354
|
+
expect(await verbsAt(base, args.src)).toEqual(dry.body.access.before);
|
|
4355
|
+
});
|
|
4356
|
+
|
|
4357
|
+
/**
|
|
4358
|
+
* `copy_file` will copy a lone `access.md`, and the moment it lands it
|
|
4359
|
+
* governs the folder it landed in. The preview counts it: the rules a
|
|
4360
|
+
* copy carries are the point of this ticket whether they travel inside a
|
|
4361
|
+
* folder or on their own.
|
|
4362
|
+
*/
|
|
4363
|
+
it('a lone access.md previews the access it will give at the destination', async () => {
|
|
4364
|
+
const base = await seeded();
|
|
4365
|
+
// A folder with no rules of its own, so the destination's answer today
|
|
4366
|
+
// is the root's: read and nothing else. (`Legal/access.md` is taken, and
|
|
4367
|
+
// a copy onto a name that exists is refused long before access is asked.)
|
|
4368
|
+
await fs.writeFile(KB('Open/note.md'), 'note');
|
|
4369
|
+
const args = { src: KB('Sales/access.md'), dest: KB('Open/access.md') };
|
|
4370
|
+
|
|
4371
|
+
const dry = await call(base, 'copy_file', { ...args, dryRun: true });
|
|
4372
|
+
expect(dry.body).toMatchObject({ kind: 'file', descendants: 1, allowed: true });
|
|
4373
|
+
expect(await verbsAt(base, KB('Open'))).toMatchObject({ write: false, owner: false });
|
|
4374
|
+
expect(dry.body.access.after).toMatchObject({ write: true, owner: true });
|
|
4375
|
+
expect(dry.body.accessChanges).toBe(false);
|
|
4376
|
+
|
|
4377
|
+
expect((await call(base, 'copy_file', args)).body).toMatchObject({ copied: true });
|
|
4378
|
+
expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
|
|
4379
|
+
});
|
|
4380
|
+
|
|
4381
|
+
it('a destination that is taken is named, and the dry run changes nothing', async () => {
|
|
4382
|
+
const base = await seeded();
|
|
4383
|
+
const args = { src: KB('Sales/deal.md'), dest: KB('Work/Notes/note.md') };
|
|
4384
|
+
|
|
4385
|
+
const dry = await call(base, 'copy_file', { ...args, dryRun: true });
|
|
4386
|
+
expect(dry.body.allowed).toBe(false);
|
|
4387
|
+
expect(dry.body.reason).toContain('already exists');
|
|
4388
|
+
expect(String(await fs.readFile(KB('Work/Notes/note.md'), { encoding: 'utf-8' }))).toContain('note');
|
|
4389
|
+
});
|
|
4390
|
+
});
|
|
4391
|
+
});
|