@bevel-software/platform-core-backend 0.22.0 → 0.24.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 +46 -13
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +12 -2
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +137 -24
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +46 -1
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +99 -13
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core-config.d.ts +16 -12
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +28 -13
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -3
- 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.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +17 -16
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
- package/dist/modules/access/directory-sync-bot.js +7 -3
- package/dist/modules/access/directory-sync-bot.js.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
- package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts +38 -6
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +39 -6
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/index.d.ts +2 -1
- package/dist/modules/agent-instructions/index.d.ts.map +1 -1
- package/dist/modules/agent-instructions/index.js +2 -1
- package/dist/modules/agent-instructions/index.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
- package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +4 -2
- package/dist/modules/audit/agent-audit.service.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-erasure.service.d.ts.map +1 -1
- package/dist/modules/auth/account-erasure.service.js +57 -16
- package/dist/modules/auth/account-erasure.service.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 +70 -2
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +197 -18
- 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/code-mode/code-mode.tool.d.ts +20 -2
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +66 -35
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/database/connection.d.ts +16 -0
- package/dist/modules/database/connection.d.ts.map +1 -1
- package/dist/modules/database/connection.js +117 -0
- package/dist/modules/database/connection.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +337 -120
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +116 -58
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/database/migrate.d.ts +8 -0
- package/dist/modules/database/migrate.d.ts.map +1 -1
- package/dist/modules/database/migrate.js +271 -1
- package/dist/modules/database/migrate.js.map +1 -1
- 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/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/mcp.service.d.ts +8 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +38 -8
- package/dist/modules/mcp/mcp.service.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-request-records.store.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-records.store.js +7 -4
- package/dist/modules/plugins/join-request-records.store.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +8 -3
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/setup.routes.d.ts +52 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +207 -19
- package/dist/modules/settings/setup.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 +12 -4
- 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-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/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-registry/description-length.d.ts +80 -0
- package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
- package/dist/modules/tool-registry/description-length.js +108 -0
- package/dist/modules/tool-registry/description-length.js.map +1 -0
- 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 +75 -35
- 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 +94 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +193 -3
- package/dist/modules/workflow/git/git.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 +60 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
- package/dist/modules/workflow/recovery-bot.js +7 -3
- package/dist/modules/workflow/recovery-bot.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.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.service.d.ts +60 -6
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +115 -5
- 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/agent-upload.routes.d.ts +77 -0
- package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
- package/dist/modules/workspace/agent-upload.routes.js +210 -0
- package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
- package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
- package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
- package/dist/modules/workspace/agent-upload.store.js +553 -0
- package/dist/modules/workspace/agent-upload.store.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 +59 -9
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
- package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.js +46 -4
- package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
- package/dist/modules/workspace/upload-limits.d.ts +13 -0
- package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
- package/dist/modules/workspace/upload-limits.js +13 -0
- package/dist/modules/workspace/upload-limits.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +93 -4
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +128 -5
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +316 -58
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +13 -11
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +791 -198
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/modules/workspace/write-denial.d.ts +0 -6
- package/dist/modules/workspace/write-denial.d.ts.map +1 -1
- package/dist/modules/workspace/write-denial.js +0 -6
- package/dist/modules/workspace/write-denial.js.map +1 -1
- package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
- package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
- package/dist/modules/workspace/zip-entry-rules.js +154 -0
- package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
- 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/column-crypto.d.ts +194 -0
- package/dist/shared/column-crypto.d.ts.map +1 -0
- package/dist/shared/column-crypto.js +144 -0
- package/dist/shared/column-crypto.js.map +1 -0
- package/dist/shared/domain-errors.d.ts +25 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +28 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +20 -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/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -1
- package/dist/shared/token-crypto.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 +1 -2
- package/dist/tenancy/static-tenant-source.js.map +1 -1
- package/dist/tenancy/tenant-secrets.d.ts +5 -1
- package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
- package/dist/tenancy/tenant-secrets.js +4 -0
- package/dist/tenancy/tenant-secrets.js.map +1 -1
- package/kb-template/AGENTS.md +2 -0
- package/migrations/0014_change_request_closed_reason.sql +1 -0
- package/migrations/0015_account_deactivation.sql +3 -0
- package/migrations/0016_pii_encryption.sql +20 -0
- package/migrations/meta/0014_snapshot.json +2265 -0
- package/migrations/meta/0015_snapshot.json +2271 -0
- package/migrations/meta/0016_snapshot.json +2327 -0
- package/migrations/meta/_journal.json +21 -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 +241 -5
- 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 +52 -16
- package/src/core/create-core-services.ts +156 -24
- package/src/core/lifecycle.ts +117 -13
- package/src/core-config.ts +31 -14
- package/src/index.ts +30 -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.routes.prospective.test.ts +6 -7
- package/src/modules/access/__tests__/users-db-double.ts +21 -12
- 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.routes.ts +17 -16
- package/src/modules/access/directory-sync-bot.ts +7 -3
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +11 -5
- package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
- package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
- package/src/modules/agent-instructions/compose.ts +50 -7
- package/src/modules/agent-instructions/index.ts +12 -0
- package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
- package/src/modules/audit/agent-audit.service.ts +4 -2
- package/src/modules/auth/__tests__/account-deactivation.test.ts +253 -0
- package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
- package/src/modules/auth/__tests__/account.routes.test.ts +87 -6
- package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
- package/src/modules/auth/__tests__/auth.service.test.ts +4 -2
- package/src/modules/auth/account-admission.ts +78 -9
- package/src/modules/auth/account-erasure.service.ts +69 -18
- 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 +206 -23
- package/src/modules/auth/oidc-auth-provider.ts +7 -2
- package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
- package/src/modules/code-mode/code-mode.tool.ts +80 -34
- package/src/modules/database/__tests__/connection.test.ts +12 -0
- package/src/modules/database/__tests__/pii-backfill.pg.test.ts +561 -0
- package/src/modules/database/connection.ts +117 -0
- package/src/modules/database/core-schema.ts +116 -58
- package/src/modules/database/migrate.ts +353 -1
- package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -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/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.e2e.test.ts +4 -3
- package/src/modules/mcp/__tests__/mcp.service.test.ts +115 -13
- package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
- package/src/modules/mcp/mcp.service.ts +46 -7
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
- package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
- package/src/modules/plugins/join-request-records.store.ts +7 -4
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
- 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 +8 -3
- package/src/modules/settings/setup.routes.ts +263 -19
- 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__/skills.service.test.ts +229 -5
- 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 +12 -4
- 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-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/tool-manuals/tool-manuals.tools.ts +44 -31
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
- package/src/modules/tool-registry/description-length.ts +111 -0
- 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.repository-replaced.test.ts +249 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
- package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
- package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
- 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.prFetchFailure.test.ts +170 -0
- package/src/modules/workflow/git/git.service.ts +215 -1
- package/src/modules/workflow/pending-commits.service.ts +64 -2
- package/src/modules/workflow/recovery-bot.ts +7 -3
- package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
- package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow-hooks.ts +64 -26
- package/src/modules/workflow/workflow.service.ts +125 -5
- package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
- package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +394 -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.any-workspace-credentials.test.ts +156 -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 +402 -0
- package/src/modules/workspace/__tests__/workspace.service.test.ts +77 -9
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +41 -39
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +766 -56
- package/src/modules/workspace/agent-access.gate.ts +164 -0
- package/src/modules/workspace/agent-upload.routes.ts +214 -0
- package/src/modules/workspace/agent-upload.store.ts +668 -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 +86 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
- package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
- package/src/modules/workspace/startup/steps/template-source.ts +53 -5
- package/src/modules/workspace/upload-limits.ts +12 -0
- package/src/modules/workspace/workspace.routes.ts +96 -5
- package/src/modules/workspace/workspace.service.ts +319 -61
- package/src/modules/workspace/workspace.tools.ts +894 -214
- package/src/modules/workspace/write-denial.ts +0 -8
- package/src/modules/workspace/zip-entry-rules.ts +173 -0
- 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/__tests__/column-crypto.test.ts +217 -0
- package/src/shared/column-crypto.ts +218 -0
- package/src/shared/domain-errors.ts +31 -0
- package/src/shared/git.contract.ts +27 -0
- package/src/shared/token-crypto.ts +28 -1
- package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -1
- package/src/tenancy/static-tenant-source.ts +1 -3
- package/src/tenancy/tenant-secrets.ts +5 -1
- 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,22 +11,27 @@ 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
|
-
import {
|
|
16
|
+
import { registerWorkspaceTools } from '../workspace.tools.js';
|
|
17
|
+
import { sharedFileRules, sharedFileRulesSection, sharedRulesPointer } from '../../agent-instructions/shared-file-rules.js';
|
|
17
18
|
import { RoutineWritePolicyService } from '../routine-write-policy.js';
|
|
18
19
|
import { UuidSessionSink, type ISessionSink } from '../session-sink.js';
|
|
19
|
-
import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
|
|
20
|
+
import { WorkflowHooks, type AgentOperationContext } from '../../workflow/workflow-hooks.js';
|
|
21
|
+
import { SESSION_ID_DESCRIPTION, ToolDescriptionNotes } from '../agent-access.gate.js';
|
|
20
22
|
import { SpillStore } from '../spill-store.js';
|
|
21
23
|
import { DocExtractService } from '../file-readers/doc-extract.service.js';
|
|
22
24
|
import { OCTET_STREAM_FALLBACK_NOTE } from '../file-readers/content-mode.js';
|
|
23
25
|
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
26
|
+
import { AccessControlService } from '../../access/access-control.service.js';
|
|
27
|
+
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
28
|
+
import type { WorkspaceService } from '../workspace.service.js';
|
|
24
29
|
import { isBranchAuthoredBy, isOwnSuggestionsBranch } from '@bevel-software/platform-shared';
|
|
25
30
|
import { assertValidBranchName } from '../../kb-fs/branch-name.js';
|
|
26
31
|
import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
|
|
27
32
|
import { GIT_INTERNALS_MESSAGE, PathNotFoundError } from '../../../shared/domain-errors.js';
|
|
28
33
|
import { AccessDeniedError } from '../../access-model/access-errors.js';
|
|
29
|
-
import {
|
|
34
|
+
import { proposalTitleFor } from '../write-denial.js';
|
|
30
35
|
import { NOT_FOUND_NEXT_STEP } from '../not-found.js';
|
|
31
36
|
|
|
32
37
|
const KB_DIR = 'knowledge-base';
|
|
@@ -43,6 +48,11 @@ const allowAll = {
|
|
|
43
48
|
canDownload: async () => true,
|
|
44
49
|
canOwner: async () => true,
|
|
45
50
|
canWriteBatchAtRef: async () => null,
|
|
51
|
+
// The `after` half of a move's or copy's preview. Allowing everything here
|
|
52
|
+
// keeps it the same answer as the four gates above; what the relocation of
|
|
53
|
+
// a folder's `access.md` files actually does to it is exercised against
|
|
54
|
+
// REAL rules in "a move preview judges the destination as it will be".
|
|
55
|
+
previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
|
|
46
56
|
} as unknown as IAccessControl;
|
|
47
57
|
|
|
48
58
|
/** Access control that denies `canRead` for an explicit set of repo-relative paths. */
|
|
@@ -76,6 +86,14 @@ let fs: LocalFilesystem;
|
|
|
76
86
|
let workspacePathCalls: string[] = [];
|
|
77
87
|
/** Every `(archive, destination)` pair `unzip` handed the service. */
|
|
78
88
|
let unzipCalls: [string, string | undefined][] = [];
|
|
89
|
+
/**
|
|
90
|
+
* Workspace-relative targets the `unzipFile` stand-in pretends the archive
|
|
91
|
+
* holds. Empty by default (the stub extracts nothing); a test that is about
|
|
92
|
+
* the per-entry write guard sets it, and the stand-in then runs the guard once
|
|
93
|
+
* per entry the way the real service does — which is asserted where it lives,
|
|
94
|
+
* in `workspace.service.test.ts`.
|
|
95
|
+
*/
|
|
96
|
+
let unzipEntries: string[] = [];
|
|
79
97
|
/** Every folder turn a tool took, as `workspaceId:dir`. */
|
|
80
98
|
let folderTurns: string[] = [];
|
|
81
99
|
/** The policy instance the tools were mounted with, so a test can restrict a session. */
|
|
@@ -88,6 +106,21 @@ let writePolicy: RoutineWritePolicyService;
|
|
|
88
106
|
let focusedBranch: string | undefined;
|
|
89
107
|
/** The registry the tools were mounted into, so a test can inspect their defs. */
|
|
90
108
|
let toolRegistry: ToolRegistry;
|
|
109
|
+
/** The hook registry the tools were mounted against, so a test can register one. */
|
|
110
|
+
let hooks: WorkflowHooks;
|
|
111
|
+
/** The note registry the tools were mounted against, so a test can register a note. */
|
|
112
|
+
let notes: ToolDescriptionNotes;
|
|
113
|
+
/**
|
|
114
|
+
* The recovery/merge bot's address, as the mounted gate knows it — the one
|
|
115
|
+
* identity that never reaches a hook.
|
|
116
|
+
*/
|
|
117
|
+
const RECOVERY_BOT = 'recovery-bot@bevel.local';
|
|
118
|
+
/**
|
|
119
|
+
* What the auth layer resolves this caller's source to. `internal` (the
|
|
120
|
+
* in-process agent) unless a test is about a person in the app (`session`) or
|
|
121
|
+
* an external agent.
|
|
122
|
+
*/
|
|
123
|
+
let callerSource: 'internal' | 'external' | 'session' = 'internal';
|
|
91
124
|
/**
|
|
92
125
|
* Another writer getting to a path in the window the real `LockingFilesystem`
|
|
93
126
|
* closes: the stand-ins below run it where that filesystem would be acquiring
|
|
@@ -103,6 +136,9 @@ async function start(
|
|
|
103
136
|
userEmail = 'e@x',
|
|
104
137
|
): Promise<string> {
|
|
105
138
|
raceHook = null;
|
|
139
|
+
hooks = new WorkflowHooks();
|
|
140
|
+
notes = new ToolDescriptionNotes();
|
|
141
|
+
callerSource = 'internal';
|
|
106
142
|
tempDir = await mkdtemp(join(tmpdir(), 'ws-tools-'));
|
|
107
143
|
docCacheDir = await mkdtemp(join(tmpdir(), 'ws-doc-cache-'));
|
|
108
144
|
fs = new LocalFilesystem({ basePath: tempDir, contained: true });
|
|
@@ -163,6 +199,7 @@ async function start(
|
|
|
163
199
|
await fs.writeFile('a.md', 'hello\nworld\n');
|
|
164
200
|
workspacePathCalls = [];
|
|
165
201
|
unzipCalls = [];
|
|
202
|
+
unzipEntries = [];
|
|
166
203
|
folderTurns = [];
|
|
167
204
|
writePolicy = new RoutineWritePolicyService();
|
|
168
205
|
focusedBranch = undefined;
|
|
@@ -195,7 +232,12 @@ async function start(
|
|
|
195
232
|
// shapes is absence, and the real service raises exactly this error for
|
|
196
233
|
// it (asserted in workspace.service.test.ts). Extraction itself lives
|
|
197
234
|
// there too — none of it is the tool's to decide.
|
|
198
|
-
unzipFile: async (
|
|
235
|
+
unzipFile: async (
|
|
236
|
+
_id: string,
|
|
237
|
+
zipRel: string,
|
|
238
|
+
destRel?: string,
|
|
239
|
+
guardWrite?: (wsRelativePath: string) => Promise<void>,
|
|
240
|
+
) => {
|
|
199
241
|
// Recorded so a test can assert the PATHS the tool handed over — both
|
|
200
242
|
// ends normalised into the repository.
|
|
201
243
|
unzipCalls.push([zipRel, destRel]);
|
|
@@ -204,7 +246,17 @@ async function start(
|
|
|
204
246
|
} catch {
|
|
205
247
|
throw new PathNotFoundError(zipRel);
|
|
206
248
|
}
|
|
207
|
-
|
|
249
|
+
const extracted: string[] = [];
|
|
250
|
+
const skipped: { path: string; reason: string }[] = [];
|
|
251
|
+
for (const entry of unzipEntries) {
|
|
252
|
+
try {
|
|
253
|
+
await guardWrite?.(entry);
|
|
254
|
+
extracted.push(entry);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
skipped.push({ path: entry, reason: err instanceof Error ? err.message : 'refused' });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { destination: '', extracted, skipped };
|
|
208
260
|
},
|
|
209
261
|
} as never,
|
|
210
262
|
workflowService: {} as never,
|
|
@@ -213,18 +265,19 @@ async function start(
|
|
|
213
265
|
});
|
|
214
266
|
const toolHandler = createToolHandlerFactory(resolve);
|
|
215
267
|
const fakeAuth = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
|
216
|
-
req.toolAuth = { source:
|
|
268
|
+
req.toolAuth = { source: callerSource, userId: 'u', scope };
|
|
217
269
|
next();
|
|
218
270
|
};
|
|
219
271
|
const app = express();
|
|
220
272
|
app.use(express.json());
|
|
221
273
|
const router = express.Router();
|
|
222
274
|
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
|
|
275
|
+
// The hooks and the notes a test registers against; with neither
|
|
276
|
+
// registered (the default, and every Hexis-only deployment) the gate
|
|
277
|
+
// refuses nothing and the descriptions say nothing extra.
|
|
278
|
+
recoveryBotEmail: RECOVERY_BOT,
|
|
279
|
+
hooks,
|
|
280
|
+
notes,
|
|
228
281
|
}, writePolicy, {} as never /* sessionSink — start_session not exercised here */);
|
|
229
282
|
app.use('/api', router);
|
|
230
283
|
httpServer = await new Promise<HttpServer>((r) => {
|
|
@@ -868,19 +921,26 @@ describe('write modes and per-path outcomes', () => {
|
|
|
868
921
|
});
|
|
869
922
|
});
|
|
870
923
|
|
|
871
|
-
it('
|
|
924
|
+
it('states the three modes on the `mode` input of each write tool, and once in the shared rules', async () => {
|
|
872
925
|
await start();
|
|
873
926
|
const tools = await toolRegistry.listInternal();
|
|
927
|
+
// The paragraph that said this in BOTH descriptions is one shared rule now.
|
|
928
|
+
// What stays on the tool is the input the agent fills, which is where the
|
|
929
|
+
// decision is actually taken.
|
|
930
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'write-mode')!;
|
|
931
|
+
expect(rule.body).toContain('DEFAULTS TO `create`');
|
|
932
|
+
expect(rule.body).toContain('On write_file and write_files');
|
|
933
|
+
expect(sharedFileRulesSection(testKbContext().layout).split(rule.body)).toHaveLength(2);
|
|
874
934
|
for (const name of ['write_file', 'write_files']) {
|
|
875
935
|
const def = tools.find((t) => t.name === name)!;
|
|
876
|
-
expect(def.description, name).toContain('DEFAULTS TO `create`');
|
|
877
|
-
for (const mode of ['`create`', '`overwrite`', '`update`']) {
|
|
878
|
-
expect(def.description, `${name} ${mode}`).toContain(mode);
|
|
879
|
-
}
|
|
936
|
+
expect(def.description, name).not.toContain('DEFAULTS TO `create`');
|
|
880
937
|
const body = (def.inputs as { properties: { body: { properties: Record<string, { enum?: string[]; description?: string }> } } }).properties.body;
|
|
881
938
|
expect(body.properties.mode, name).toBeDefined();
|
|
882
939
|
expect(body.properties.mode.enum, name).toEqual(['create', 'overwrite', 'update']);
|
|
883
940
|
expect(body.properties.mode.description, name).toContain('default `create`');
|
|
941
|
+
for (const mode of ['`create`', '`overwrite`', '`update`']) {
|
|
942
|
+
expect(body.properties.mode.description, `${name} ${mode}`).toContain(mode);
|
|
943
|
+
}
|
|
884
944
|
}
|
|
885
945
|
});
|
|
886
946
|
|
|
@@ -1543,28 +1603,36 @@ describe('office documents and PDFs', () => {
|
|
|
1543
1603
|
expect(await readContent(base, `${KB_DIR}/deck.pptx`)).toContain('Original');
|
|
1544
1604
|
});
|
|
1545
1605
|
|
|
1546
|
-
it('every
|
|
1606
|
+
it('every mounted tool ends with the one sentence pointing at the shared rules, and repeats none of them', async () => {
|
|
1547
1607
|
await start();
|
|
1548
1608
|
const tools = await toolRegistry.listInternal();
|
|
1549
|
-
const
|
|
1550
|
-
|
|
1609
|
+
const pointer = sharedRulesPointer(testKbContext().layout);
|
|
1610
|
+
// The shell is in the list too: it carried the agent-guide reminder before,
|
|
1611
|
+
// and that reminder is one of the rules that moved.
|
|
1612
|
+
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'];
|
|
1613
|
+
for (const name of mounted) {
|
|
1551
1614
|
const def = tools.find((t) => t.name === name);
|
|
1552
1615
|
expect(def, name).toBeDefined();
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
expect(def!.description, name).
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
//
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1616
|
+
expect(def!.description!.endsWith(pointer), name).toBe(true);
|
|
1617
|
+
// Once, at the end — not once per paragraph that used to be appended.
|
|
1618
|
+
expect(def!.description!.split(pointer), name).toHaveLength(2);
|
|
1619
|
+
// EVERY shared rule, in full, is in the two shared places now (see
|
|
1620
|
+
// agent-instructions/__tests__/shared-file-rules.test.ts) and in no
|
|
1621
|
+
// description. Checked on the whole body rather than on a phrase: the
|
|
1622
|
+
// drift this PR exists to prevent is a paragraph pasted back onto a
|
|
1623
|
+
// tool, and naming only two marker phrases would catch two of eight.
|
|
1624
|
+
for (const rule of sharedFileRules(testKbContext().layout)) {
|
|
1625
|
+
expect(def!.description, `${name} / ${rule.id}`).not.toContain(rule.body);
|
|
1626
|
+
}
|
|
1627
|
+
// The two lead-in labels the paragraphs used to arrive under are gone
|
|
1628
|
+
// with them — a description carrying one is carrying the old text.
|
|
1629
|
+
expect(def!.description, name).not.toContain('Content rule (the same on every file tool)');
|
|
1630
|
+
expect(def!.description, name).not.toContain('Before your first read or change in a workspace');
|
|
1565
1631
|
}
|
|
1566
|
-
//
|
|
1567
|
-
|
|
1632
|
+
// start_session carried none of the shared paragraphs and gains no pointer:
|
|
1633
|
+
// it touches no file. External-only, so it is looked up on that surface.
|
|
1634
|
+
const external = await toolRegistry.listExternal();
|
|
1635
|
+
expect(external.find((t) => t.name === 'start_session')!.description).not.toContain(pointer);
|
|
1568
1636
|
});
|
|
1569
1637
|
|
|
1570
1638
|
describe('binary capability contract: a text file, a document, an image and a zip', () => {
|
|
@@ -1710,14 +1778,15 @@ describe('office documents and PDFs', () => {
|
|
|
1710
1778
|
});
|
|
1711
1779
|
});
|
|
1712
1780
|
|
|
1713
|
-
it('the
|
|
1781
|
+
it('says where the images a page uses go — once, in the shared rules', async () => {
|
|
1714
1782
|
await start();
|
|
1715
1783
|
const tools = await toolRegistry.listInternal();
|
|
1784
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'images-in-pages')!;
|
|
1785
|
+
expect(rule.body).toContain('`assets/` folder next to the page');
|
|
1786
|
+
expect(rule.body).toContain('');
|
|
1787
|
+
expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
|
|
1716
1788
|
for (const name of ['write_file', 'write_files']) {
|
|
1717
|
-
|
|
1718
|
-
expect(def, name).toBeDefined();
|
|
1719
|
-
expect(def!.description, name).toContain('`assets/` folder next to the page');
|
|
1720
|
-
expect(def!.description, name).toContain('');
|
|
1789
|
+
expect(tools.find((t) => t.name === name)!.description, name).not.toContain('`assets/` folder next to the page');
|
|
1721
1790
|
}
|
|
1722
1791
|
});
|
|
1723
1792
|
});
|
|
@@ -1816,7 +1885,7 @@ describe('images', () => {
|
|
|
1816
1885
|
/**
|
|
1817
1886
|
* start_session must mint a REAL chat thread and return its id (not a bare
|
|
1818
1887
|
* random UUID), so the same id works for KB reads AND for `ask` (whose
|
|
1819
|
-
* sessionId IS a chat thread). This is what unifies the
|
|
1888
|
+
* sessionId IS a chat thread). This is what unifies the conversation
|
|
1820
1889
|
* across reads + ask — see workspace.tools.ts start_session comment.
|
|
1821
1890
|
*/
|
|
1822
1891
|
describe('start_session', () => {
|
|
@@ -1863,7 +1932,7 @@ describe('start_session', () => {
|
|
|
1863
1932
|
registerWorkspaceTools(
|
|
1864
1933
|
registry, router, auth, toolHandler,
|
|
1865
1934
|
new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, testKbContext({ kbDirName: KB_DIR }),
|
|
1866
|
-
{
|
|
1935
|
+
{ recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
1867
1936
|
new RoutineWritePolicyService(),
|
|
1868
1937
|
sink ?? fakeSessionSink,
|
|
1869
1938
|
);
|
|
@@ -1988,7 +2057,7 @@ describe('start_session', () => {
|
|
|
1988
2057
|
registerWorkspaceTools(
|
|
1989
2058
|
registry, router, noopAuth, (() => () => {}) as never,
|
|
1990
2059
|
new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, testKbContext({ kbDirName: KB_DIR }),
|
|
1991
|
-
{
|
|
2060
|
+
{ recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
1992
2061
|
new RoutineWritePolicyService(),
|
|
1993
2062
|
{} as never,
|
|
1994
2063
|
);
|
|
@@ -2030,7 +2099,7 @@ describe('branch is a required parameter in the tool contract', () => {
|
|
|
2030
2099
|
new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')),
|
|
2031
2100
|
allowAll,
|
|
2032
2101
|
testKbContext({ kbDirName: KB_DIR }),
|
|
2033
|
-
{
|
|
2102
|
+
{ recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
2034
2103
|
new RoutineWritePolicyService(),
|
|
2035
2104
|
{} as never,
|
|
2036
2105
|
);
|
|
@@ -2514,6 +2583,11 @@ describe('preflight for moves and deletes', () => {
|
|
|
2514
2583
|
canWriteBatchAtRef: async (_w: string, _r: string, _u: string, rels: string[]) =>
|
|
2515
2584
|
new Map(rels.map((p) => [p, verbsFor(p).write])),
|
|
2516
2585
|
eligibleWritersAtRef: async () => ({ roles: ['Admin'], users: [] }),
|
|
2586
|
+
// This double has no access files to carry, so the destination as it will
|
|
2587
|
+
// be IS the destination as it is — the answer `accessAt` gave before the
|
|
2588
|
+
// preview learned to relocate them, which keeps every ordering and
|
|
2589
|
+
// oracle test below about what it is about.
|
|
2590
|
+
previewAccessAfterRelocation: async (_w: string, _u: string, _from: string, to: string) => verbsFor(to),
|
|
2517
2591
|
} as unknown as IAccessControl;
|
|
2518
2592
|
|
|
2519
2593
|
const call = async (base: string, tool: string, body: Record<string, unknown>) => {
|
|
@@ -2657,6 +2731,35 @@ describe('preflight for moves and deletes', () => {
|
|
|
2657
2731
|
expect(await exists(KB('Sales/deal.md'))).toBe(true);
|
|
2658
2732
|
});
|
|
2659
2733
|
|
|
2734
|
+
/**
|
|
2735
|
+
* The dry run keeps that order too, and it is the easier oracle to reach:
|
|
2736
|
+
* it answers 200 rather than throwing, so a denied caller could read the
|
|
2737
|
+
* source's kind and its file count off it — or a 404 saying whether a
|
|
2738
|
+
* source they may not copy from exists at all. Nothing on disk is probed
|
|
2739
|
+
* until the write verdict on the destination has been taken.
|
|
2740
|
+
*/
|
|
2741
|
+
it('copy_file\'s dry run refuses a denied destination the same way whatever the source is', async () => {
|
|
2742
|
+
const base = await seeded();
|
|
2743
|
+
const dry = async (src: string) =>
|
|
2744
|
+
(await call(base, 'copy_file', { src, dest: KB('Locked/new.md'), dryRun: true })).body;
|
|
2745
|
+
|
|
2746
|
+
const file = await dry(KB('Sales/deal.md'));
|
|
2747
|
+
const folder = await dry(KB('Sales/archive'));
|
|
2748
|
+
const missing = await dry(KB('Sales/nothing-here.md'));
|
|
2749
|
+
|
|
2750
|
+
// One sentence, three sources: the refusal says nothing about any of them.
|
|
2751
|
+
for (const answer of [file, folder, missing]) {
|
|
2752
|
+
expect(answer).toMatchObject({ allowed: false, dryRun: true, copied: false });
|
|
2753
|
+
expect(answer.reason).toContain(KB('Locked/new.md'));
|
|
2754
|
+
expect(answer.kind).toBeUndefined();
|
|
2755
|
+
expect(answer.descendants).toBeUndefined();
|
|
2756
|
+
}
|
|
2757
|
+
expect(folder.reason).toBe(file.reason);
|
|
2758
|
+
expect(missing.reason).toBe(file.reason);
|
|
2759
|
+
// And the caller's own verbs are still answered — those are theirs.
|
|
2760
|
+
expect(Object.keys(file.access).sort()).toEqual(['after', 'before']);
|
|
2761
|
+
});
|
|
2762
|
+
|
|
2660
2763
|
it('copy_file keeps the same order: the write refusal, not what is in the folder', async () => {
|
|
2661
2764
|
const base = await seeded();
|
|
2662
2765
|
|
|
@@ -3227,8 +3330,12 @@ describe('preflight for moves and deletes', () => {
|
|
|
3227
3330
|
expect(d.description).toMatch(/FILE or FOLDER/);
|
|
3228
3331
|
expect(d.description).toMatch(/folder moves recursively/);
|
|
3229
3332
|
expect(d.description).toMatch(/destination must not exist/);
|
|
3230
|
-
|
|
3231
|
-
|
|
3333
|
+
// What refuses a move, and the dry-run/confirm protocol it shares with the
|
|
3334
|
+
// deletes, are shared rules — stated once, in the two shared places.
|
|
3335
|
+
const rules = sharedFileRulesSection(testKbContext().layout);
|
|
3336
|
+
expect(rules).toContain('is a platform file and stays in its folder.');
|
|
3337
|
+
expect(rules).toContain('`dryRun: true`');
|
|
3338
|
+
expect(rules).toContain('`confirm: true`');
|
|
3232
3339
|
expect(d.description).toContain('`confirm: true`');
|
|
3233
3340
|
expect(Object.keys(d.inputs.properties.body.properties)).toEqual(expect.arrayContaining(['dryRun', 'confirm']));
|
|
3234
3341
|
// What the description promises a dry run returns is what it returns.
|
|
@@ -3238,7 +3345,10 @@ describe('preflight for moves and deletes', () => {
|
|
|
3238
3345
|
expect(dry.body, key).toHaveProperty(key);
|
|
3239
3346
|
}
|
|
3240
3347
|
const unconfirmed = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('HR/deal.md') });
|
|
3241
|
-
|
|
3348
|
+
// What an unconfirmed call answers is the shared protocol's promise; the
|
|
3349
|
+
// field is still declared in this tool's own `outputs`.
|
|
3350
|
+
expect(rules).toContain('confirmationRequired: true');
|
|
3351
|
+
expect(declaredOutputs(d)).toContain('confirmationRequired');
|
|
3242
3352
|
expect(unconfirmed.body.confirmationRequired).toBe(true);
|
|
3243
3353
|
for (const body of [dry.body, unconfirmed.body]) {
|
|
3244
3354
|
expect(declaredOutputs(d)).toEqual(expect.arrayContaining(Object.keys(body)));
|
|
@@ -3248,9 +3358,12 @@ describe('preflight for moves and deletes', () => {
|
|
|
3248
3358
|
it('delete_folder states the confirm rule and refusals, and declares every field it returns', async () => {
|
|
3249
3359
|
const base = await seeded();
|
|
3250
3360
|
const d = await def('delete_folder');
|
|
3251
|
-
expect(d.description).toContain('`
|
|
3252
|
-
|
|
3253
|
-
|
|
3361
|
+
expect(d.description).toContain('a non-empty folder wants `confirm: true`');
|
|
3362
|
+
// The protocol itself, and what a platform folder does to it, are shared.
|
|
3363
|
+
const rules = sharedFileRulesSection(testKbContext().layout);
|
|
3364
|
+
expect(rules).toContain('`dryRun: true`');
|
|
3365
|
+
expect(rules).toContain('A non-empty folder is deleted, and a move that changes your access runs, only with `confirm: true`');
|
|
3366
|
+
expect(rules).toContain('platform folder');
|
|
3254
3367
|
const dry = await call(base, 'delete_folder', { path: KB('Sales/archive'), dryRun: true });
|
|
3255
3368
|
const unconfirmed = await call(base, 'delete_folder', { path: KB('Sales/archive') });
|
|
3256
3369
|
const confirmed = await call(base, 'delete_folder', { path: KB('Sales/archive'), confirm: true });
|
|
@@ -3268,8 +3381,12 @@ describe('preflight for moves and deletes', () => {
|
|
|
3268
3381
|
expect(stat.description, key).toContain(`\`${key}`);
|
|
3269
3382
|
expect(body, key).toHaveProperty(key);
|
|
3270
3383
|
}
|
|
3384
|
+
expect(stat.description).toContain('shared rules on what these tools never move or delete');
|
|
3385
|
+
// The proposal route applies to every tool a permission can refuse, so it
|
|
3386
|
+
// is stated once in the shared rules rather than on each of them.
|
|
3387
|
+
expect(sharedFileRulesSection(testKbContext().layout)).toContain('`write-denied`');
|
|
3271
3388
|
for (const name of ['move_file', 'delete_file', 'delete_folder']) {
|
|
3272
|
-
expect((await def(name)).description, name).toContain(
|
|
3389
|
+
expect((await def(name)).description, name).toContain(sharedRulesPointer(testKbContext().layout));
|
|
3273
3390
|
}
|
|
3274
3391
|
});
|
|
3275
3392
|
});
|
|
@@ -3310,6 +3427,7 @@ describe('a write refused for permissions says whether and how to propose it', (
|
|
|
3310
3427
|
canDownload: async () => true,
|
|
3311
3428
|
canOwner: async () => true,
|
|
3312
3429
|
canWriteBatchAtRef: async () => null,
|
|
3430
|
+
previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
|
|
3313
3431
|
eligibleWritersAtRef: async () => ({ roles: ['Sales Lead'], users: [{ name: 'Owner', email: 'owner@x' }] }),
|
|
3314
3432
|
} as unknown as IAccessControl;
|
|
3315
3433
|
return { ac, calls };
|
|
@@ -3515,14 +3633,18 @@ describe('a write refused for permissions says whether and how to propose it', (
|
|
|
3515
3633
|
expect(res.json).toEqual({ error: 'old_string not found in the file.' });
|
|
3516
3634
|
});
|
|
3517
3635
|
|
|
3518
|
-
it('
|
|
3636
|
+
it('states the proposal route in the shared rules, not in each write tool description', async () => {
|
|
3519
3637
|
await start();
|
|
3520
3638
|
const tools = await toolRegistry.listInternal();
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3639
|
+
// It applies to every tool a permission can refuse, so it is a shared rule:
|
|
3640
|
+
// stated in the handshake instructions and in the managed guide, and in no
|
|
3641
|
+
// description. The refusal ITSELF still spells the steps out — that is what
|
|
3642
|
+
// the tests above this one assert.
|
|
3643
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'refused-for-permissions')!;
|
|
3644
|
+
expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
|
|
3645
|
+
for (const def of tools) {
|
|
3646
|
+
expect(def.description ?? '', def.name).not.toContain('If this is refused for permissions');
|
|
3647
|
+
expect(def.description ?? '', def.name).not.toContain(rule.body);
|
|
3526
3648
|
}
|
|
3527
3649
|
});
|
|
3528
3650
|
});
|
|
@@ -3718,3 +3840,591 @@ describe('a path with nothing at it answers 404 not_found on every file tool', (
|
|
|
3718
3840
|
}
|
|
3719
3841
|
});
|
|
3720
3842
|
});
|
|
3843
|
+
|
|
3844
|
+
/**
|
|
3845
|
+
* The neutral session hooks, through the tool routes.
|
|
3846
|
+
*
|
|
3847
|
+
* Hexis decides nothing about which paths a conversation may touch: it calls a
|
|
3848
|
+
* registered hook before every agent read and every agent write of a
|
|
3849
|
+
* knowledge-base path, hands it what it knows about the call, and lets a hook
|
|
3850
|
+
* that throws refuse the operation with its own message and status. With no
|
|
3851
|
+
* hook registered — every Hexis-only deployment — nothing is refused, nothing
|
|
3852
|
+
* is recorded, and a call without a `sessionId` is an ordinary call.
|
|
3853
|
+
*/
|
|
3854
|
+
describe('agent read/write hooks', () => {
|
|
3855
|
+
/** Every call the read hook saw, in order. */
|
|
3856
|
+
let reads: AgentOperationContext[];
|
|
3857
|
+
/** Every call the write hook saw, in order. */
|
|
3858
|
+
let writes: AgentOperationContext[];
|
|
3859
|
+
|
|
3860
|
+
/** Register recording hooks on the mounted registry (after `start()`). */
|
|
3861
|
+
const record = (): void => {
|
|
3862
|
+
reads = [];
|
|
3863
|
+
writes = [];
|
|
3864
|
+
hooks.onAgentRead(async (op) => {
|
|
3865
|
+
reads.push(op);
|
|
3866
|
+
});
|
|
3867
|
+
hooks.onPreWrite(async (op) => {
|
|
3868
|
+
writes.push(op);
|
|
3869
|
+
});
|
|
3870
|
+
};
|
|
3871
|
+
|
|
3872
|
+
/** Register a write hook that refuses `path` (or every path) with a 403. */
|
|
3873
|
+
const refuseWrites = (message: string, path?: string): void => {
|
|
3874
|
+
hooks.onPreWrite(async (op) => {
|
|
3875
|
+
if (path === undefined || op.wsPath === path) throw new ToolError(message, 403);
|
|
3876
|
+
});
|
|
3877
|
+
};
|
|
3878
|
+
|
|
3879
|
+
beforeEach(() => {
|
|
3880
|
+
reads = [];
|
|
3881
|
+
writes = [];
|
|
3882
|
+
});
|
|
3883
|
+
|
|
3884
|
+
it('read_file calls the read hook once with the session, path, branch, user and source', async () => {
|
|
3885
|
+
const base = await start();
|
|
3886
|
+
record();
|
|
3887
|
+
await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
|
|
3888
|
+
await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/Roadmap.md`, 'plans');
|
|
3889
|
+
const res = await post(`${base}/api/agent/tools/read_file`, {
|
|
3890
|
+
branch: 'draft-1',
|
|
3891
|
+
path: `${KB_DIR}/KnowledgeBase/Product/Knowledge/Roadmap.md`,
|
|
3892
|
+
sessionId: 's1',
|
|
3893
|
+
});
|
|
3894
|
+
expect(res.status).toBe(200);
|
|
3895
|
+
expect(reads).toEqual([
|
|
3896
|
+
{
|
|
3897
|
+
sessionId: 's1',
|
|
3898
|
+
wsPath: `${KB_DIR}/KnowledgeBase/Product/Knowledge/Roadmap.md`,
|
|
3899
|
+
branch: 'draft-1',
|
|
3900
|
+
user: { id: 'u', email: 'e@x', name: 'N' },
|
|
3901
|
+
source: 'internal',
|
|
3902
|
+
},
|
|
3903
|
+
]);
|
|
3904
|
+
expect(writes).toEqual([]);
|
|
3905
|
+
});
|
|
3906
|
+
|
|
3907
|
+
it('the read hook covers list_files, file_stat, grep, delete_file and delete_folder', async () => {
|
|
3908
|
+
const base = await start();
|
|
3909
|
+
record();
|
|
3910
|
+
await fs.mkdir(`${KB_DIR}/Folder`, { recursive: true });
|
|
3911
|
+
await fs.writeFile(`${KB_DIR}/Folder/one.md`, 'needle');
|
|
3912
|
+
await fs.writeFile(`${KB_DIR}/gone.md`, 'x');
|
|
3913
|
+
await post(`${base}/api/agent/tools/list_files`, { path: `${KB_DIR}/Folder`, sessionId: 's1' });
|
|
3914
|
+
await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/Folder/one.md`, sessionId: 's1' });
|
|
3915
|
+
await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: `${KB_DIR}/Folder`, sessionId: 's1' });
|
|
3916
|
+
await post(`${base}/api/agent/tools/delete_file`, { path: `${KB_DIR}/gone.md`, sessionId: 's1' });
|
|
3917
|
+
await post(`${base}/api/agent/tools/delete_folder`, { path: `${KB_DIR}/Folder`, confirm: true, sessionId: 's1' });
|
|
3918
|
+
const paths = reads.map((r) => r.wsPath);
|
|
3919
|
+
expect(paths).toContain(`${KB_DIR}/Folder`);
|
|
3920
|
+
expect(paths).toContain(`${KB_DIR}/Folder/one.md`);
|
|
3921
|
+
expect(paths).toContain(`${KB_DIR}/gone.md`);
|
|
3922
|
+
// A delete carries no bytes from elsewhere, so it is a read, never a write.
|
|
3923
|
+
expect(writes).toEqual([]);
|
|
3924
|
+
});
|
|
3925
|
+
|
|
3926
|
+
it('grep tells the read hook about every file the walk opens, not only its root', async () => {
|
|
3927
|
+
const base = await start();
|
|
3928
|
+
record();
|
|
3929
|
+
await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
|
|
3930
|
+
await fs.mkdir(`${KB_DIR}/KnowledgeBase/Legal/Knowledge`, { recursive: true });
|
|
3931
|
+
await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`, 'needle');
|
|
3932
|
+
await fs.writeFile(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`, 'needle');
|
|
3933
|
+
await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: KB_DIR, sessionId: 's1' });
|
|
3934
|
+
const paths = reads.map((r) => r.wsPath);
|
|
3935
|
+
expect(paths).toContain(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`);
|
|
3936
|
+
expect(paths).toContain(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`);
|
|
3937
|
+
});
|
|
3938
|
+
|
|
3939
|
+
it('the write hook covers write_file, edit_file and mkdir, once per path', async () => {
|
|
3940
|
+
const base = await start();
|
|
3941
|
+
record();
|
|
3942
|
+
await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/new.md`, content: 'hi', sessionId: 's1' });
|
|
3943
|
+
await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'world', new_string: 'earth', sessionId: 's1' });
|
|
3944
|
+
await post(`${base}/api/agent/tools/mkdir`, { path: `${KB_DIR}/dir`, sessionId: 's1' });
|
|
3945
|
+
expect(writes.map((w) => w.wsPath)).toEqual([`${KB_DIR}/new.md`, `${KB_DIR}/a.md`, `${KB_DIR}/dir`]);
|
|
3946
|
+
expect(writes.every((w) => w.sessionId === 's1' && w.branch === 'main')).toBe(true);
|
|
3947
|
+
});
|
|
3948
|
+
|
|
3949
|
+
it('move_file and copy_file call the write hook for the source path AND the destination', async () => {
|
|
3950
|
+
const base = await start();
|
|
3951
|
+
record();
|
|
3952
|
+
await fs.writeFile(`${KB_DIR}/src.md`, 'body');
|
|
3953
|
+
await fs.writeFile(`${KB_DIR}/copy-me.md`, 'body');
|
|
3954
|
+
await post(`${base}/api/agent/tools/move_file`, { src: `${KB_DIR}/src.md`, dest: `${KB_DIR}/moved.md`, confirm: true, sessionId: 's1' });
|
|
3955
|
+
await post(`${base}/api/agent/tools/copy_file`, { src: `${KB_DIR}/copy-me.md`, dest: `${KB_DIR}/copied.md`, sessionId: 's1' });
|
|
3956
|
+
expect(writes.map((w) => w.wsPath)).toEqual([
|
|
3957
|
+
`${KB_DIR}/src.md`,
|
|
3958
|
+
`${KB_DIR}/moved.md`,
|
|
3959
|
+
`${KB_DIR}/copy-me.md`,
|
|
3960
|
+
`${KB_DIR}/copied.md`,
|
|
3961
|
+
]);
|
|
3962
|
+
});
|
|
3963
|
+
|
|
3964
|
+
it('unzip reads the archive and calls the write hook once per extracted entry', async () => {
|
|
3965
|
+
const base = await start();
|
|
3966
|
+
record();
|
|
3967
|
+
await fs.writeFile(`${KB_DIR}/a.zip`, 'not-really-a-zip');
|
|
3968
|
+
unzipEntries = [`${KB_DIR}/out/one.md`, `${KB_DIR}/out/two.md`];
|
|
3969
|
+
const res = await post(`${base}/api/agent/tools/unzip`, { path: `${KB_DIR}/a.zip`, sessionId: 's1' });
|
|
3970
|
+
expect(res.status).toBe(200);
|
|
3971
|
+
expect(reads.map((r) => r.wsPath)).toEqual([`${KB_DIR}/a.zip`]);
|
|
3972
|
+
expect(writes.map((w) => w.wsPath)).toEqual([`${KB_DIR}/out/one.md`, `${KB_DIR}/out/two.md`]);
|
|
3973
|
+
});
|
|
3974
|
+
|
|
3975
|
+
it('execute_command calls the write hook once, with no path', async () => {
|
|
3976
|
+
const base = await start();
|
|
3977
|
+
record();
|
|
3978
|
+
await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'echo hi', sessionId: 's1' });
|
|
3979
|
+
expect(writes).toHaveLength(1);
|
|
3980
|
+
expect(writes[0].wsPath).toBeUndefined();
|
|
3981
|
+
expect(writes[0]).toMatchObject({ sessionId: 's1', branch: 'main', source: 'internal' });
|
|
3982
|
+
expect(reads).toEqual([]);
|
|
3983
|
+
});
|
|
3984
|
+
|
|
3985
|
+
it('a write hook that throws refuses the write with its own message and status, and the file is unchanged', async () => {
|
|
3986
|
+
const base = await start();
|
|
3987
|
+
record();
|
|
3988
|
+
refuseWrites('Not in this conversation.');
|
|
3989
|
+
const res = await post(`${base}/api/agent/tools/write_file`, {
|
|
3990
|
+
path: `${KB_DIR}/a.md`,
|
|
3991
|
+
// `overwrite`, so the unchanged file below is the hook's doing: with no
|
|
3992
|
+
// hook this call WOULD replace `a.md`.
|
|
3993
|
+
mode: 'overwrite',
|
|
3994
|
+
content: 'clobbered',
|
|
3995
|
+
sessionId: 's1',
|
|
3996
|
+
});
|
|
3997
|
+
expect(res.status).toBe(403);
|
|
3998
|
+
expect(((await res.json()) as { error: string }).error).toBe('Not in this conversation.');
|
|
3999
|
+
expect(await readFile(join(tempDir, `${KB_DIR}/a.md`), 'utf8')).toBe('hello\nworld\n');
|
|
4000
|
+
});
|
|
4001
|
+
|
|
4002
|
+
it('a read hook that throws refuses the read with its own message and status', async () => {
|
|
4003
|
+
const base = await start();
|
|
4004
|
+
hooks.onAgentRead(async () => {
|
|
4005
|
+
throw new ToolError('Not in this conversation.', 403);
|
|
4006
|
+
});
|
|
4007
|
+
const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md`, sessionId: 's1' });
|
|
4008
|
+
expect(res.status).toBe(403);
|
|
4009
|
+
expect(((await res.json()) as { error: string }).error).toBe('Not in this conversation.');
|
|
4010
|
+
});
|
|
4011
|
+
|
|
4012
|
+
it('write_files reports the refused path only; the other two land', async () => {
|
|
4013
|
+
const base = await start();
|
|
4014
|
+
record();
|
|
4015
|
+
refuseWrites('Not in this conversation.', `${KB_DIR}/two.md`);
|
|
4016
|
+
const res = await post(`${base}/api/agent/tools/write_files`, {
|
|
4017
|
+
files: [
|
|
4018
|
+
{ path: `${KB_DIR}/one.md`, content: '1' },
|
|
4019
|
+
{ path: `${KB_DIR}/two.md`, content: '2' },
|
|
4020
|
+
{ path: `${KB_DIR}/three.md`, content: '3' },
|
|
4021
|
+
],
|
|
4022
|
+
sessionId: 's1',
|
|
4023
|
+
});
|
|
4024
|
+
expect(res.status).toBe(200);
|
|
4025
|
+
const body = (await res.json()) as { count: number; files: { path: string; outcome: string; message?: string }[] };
|
|
4026
|
+
expect(body.files.map((f) => f.outcome)).toEqual(['created', 'refused', 'created']);
|
|
4027
|
+
expect(body.files[1].message).toBe('Not in this conversation.');
|
|
4028
|
+
expect(body.count).toBe(2);
|
|
4029
|
+
expect(await readFile(join(tempDir, `${KB_DIR}/one.md`), 'utf8')).toBe('1');
|
|
4030
|
+
expect(await readFile(join(tempDir, `${KB_DIR}/three.md`), 'utf8')).toBe('3');
|
|
4031
|
+
});
|
|
4032
|
+
|
|
4033
|
+
it('a write hook that fails unexpectedly fails the whole write_files batch, rather than reading as a refusal', async () => {
|
|
4034
|
+
const base = await start();
|
|
4035
|
+
record();
|
|
4036
|
+
// Not a ToolError: the hook did not JUDGE this path, the gate itself
|
|
4037
|
+
// broke. Reporting that as `refused` would let the other paths commit
|
|
4038
|
+
// past a gate that never ran.
|
|
4039
|
+
hooks.onPreWrite(async (op) => {
|
|
4040
|
+
if (op.wsPath === `${KB_DIR}/two.md`) throw new Error('the hook store is down');
|
|
4041
|
+
});
|
|
4042
|
+
const res = await post(`${base}/api/agent/tools/write_files`, {
|
|
4043
|
+
files: [
|
|
4044
|
+
{ path: `${KB_DIR}/one.md`, content: '1' },
|
|
4045
|
+
{ path: `${KB_DIR}/two.md`, content: '2' },
|
|
4046
|
+
{ path: `${KB_DIR}/three.md`, content: '3' },
|
|
4047
|
+
],
|
|
4048
|
+
sessionId: 's1',
|
|
4049
|
+
});
|
|
4050
|
+
expect(res.status).toBe(500);
|
|
4051
|
+
for (const name of ['one.md', 'two.md', 'three.md']) {
|
|
4052
|
+
await expect(readFile(join(tempDir, `${KB_DIR}/${name}`), 'utf8')).rejects.toThrow();
|
|
4053
|
+
}
|
|
4054
|
+
});
|
|
4055
|
+
|
|
4056
|
+
it('unzip skips the refused entry with the hook\'s message and extracts the others', async () => {
|
|
4057
|
+
const base = await start();
|
|
4058
|
+
record();
|
|
4059
|
+
await fs.writeFile(`${KB_DIR}/a.zip`, 'not-really-a-zip');
|
|
4060
|
+
unzipEntries = [`${KB_DIR}/out/keep.md`, `${KB_DIR}/out/blocked.md`, `${KB_DIR}/out/also-keep.md`];
|
|
4061
|
+
refuseWrites('Not in this conversation.', `${KB_DIR}/out/blocked.md`);
|
|
4062
|
+
const res = await post(`${base}/api/agent/tools/unzip`, { path: `${KB_DIR}/a.zip`, sessionId: 's1' });
|
|
4063
|
+
const body = (await res.json()) as { extracted: string[]; skipped: { path: string; reason: string }[] };
|
|
4064
|
+
expect(body.extracted).toEqual([`${KB_DIR}/out/keep.md`, `${KB_DIR}/out/also-keep.md`]);
|
|
4065
|
+
expect(body.skipped).toEqual([{ path: `${KB_DIR}/out/blocked.md`, reason: 'Not in this conversation.' }]);
|
|
4066
|
+
});
|
|
4067
|
+
|
|
4068
|
+
it('a hook is called with no session id when the call carried none — what happens next is its decision', async () => {
|
|
4069
|
+
const base = await start();
|
|
4070
|
+
record();
|
|
4071
|
+
await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` });
|
|
4072
|
+
await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/fresh.md`, content: 'x' });
|
|
4073
|
+
expect(reads).toHaveLength(1);
|
|
4074
|
+
expect(reads[0].sessionId).toBeUndefined();
|
|
4075
|
+
expect(writes).toHaveLength(1);
|
|
4076
|
+
expect(writes[0].sessionId).toBeUndefined();
|
|
4077
|
+
});
|
|
4078
|
+
|
|
4079
|
+
it('a person saving in the app never reaches the hooks', async () => {
|
|
4080
|
+
const base = await start();
|
|
4081
|
+
record();
|
|
4082
|
+
callerSource = 'session';
|
|
4083
|
+
await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md`, sessionId: 's1' });
|
|
4084
|
+
await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/fresh.md`, content: 'x', sessionId: 's1' });
|
|
4085
|
+
expect(reads).toEqual([]);
|
|
4086
|
+
expect(writes).toEqual([]);
|
|
4087
|
+
});
|
|
4088
|
+
|
|
4089
|
+
it('the recovery bot never reaches the hooks', async () => {
|
|
4090
|
+
const base = await start('write', allowAll, RECOVERY_BOT);
|
|
4091
|
+
record();
|
|
4092
|
+
refuseWrites('Not in this conversation.');
|
|
4093
|
+
const res = await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/fresh.md`, content: 'x', sessionId: 's1' });
|
|
4094
|
+
expect(res.status).toBe(200);
|
|
4095
|
+
expect(reads).toEqual([]);
|
|
4096
|
+
expect(writes).toEqual([]);
|
|
4097
|
+
});
|
|
4098
|
+
|
|
4099
|
+
/**
|
|
4100
|
+
* The Hexis-only deployment: nothing registered at all. No call is refused,
|
|
4101
|
+
* and a file tool call without a `sessionId` succeeds — including one that
|
|
4102
|
+
* read under two different folders first.
|
|
4103
|
+
*/
|
|
4104
|
+
it('with no hook registered, reads and writes without a sessionId all succeed', async () => {
|
|
4105
|
+
const base = await start();
|
|
4106
|
+
await fs.mkdir(`${KB_DIR}/KnowledgeBase/Product/Knowledge`, { recursive: true });
|
|
4107
|
+
await fs.mkdir(`${KB_DIR}/KnowledgeBase/Legal/Knowledge`, { recursive: true });
|
|
4108
|
+
await fs.writeFile(`${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md`, 'p');
|
|
4109
|
+
await fs.writeFile(`${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md`, 'l');
|
|
4110
|
+
expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/KnowledgeBase/Product/Knowledge/P.md` })).status).toBe(200);
|
|
4111
|
+
expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/KnowledgeBase/Legal/Knowledge/L.md` })).status).toBe(200);
|
|
4112
|
+
const wrote = await post(`${base}/api/agent/tools/write_file`, {
|
|
4113
|
+
path: `${KB_DIR}/KnowledgeBase/Product/Knowledge/New.md`,
|
|
4114
|
+
content: 'fresh',
|
|
4115
|
+
});
|
|
4116
|
+
expect(wrote.status).toBe(200);
|
|
4117
|
+
expect(await readFile(join(tempDir, `${KB_DIR}/KnowledgeBase/Product/Knowledge/New.md`), 'utf8')).toBe('fresh');
|
|
4118
|
+
});
|
|
4119
|
+
});
|
|
4120
|
+
|
|
4121
|
+
/**
|
|
4122
|
+
* What an agent READS about these tools. A Hexis-only deployment names no
|
|
4123
|
+
* boundary and no ontology; a deployment that has one registers the wording
|
|
4124
|
+
* for it, and it lands on the gated tools and on the `sessionId` input.
|
|
4125
|
+
*/
|
|
4126
|
+
describe('tool descriptions and the deployment note', () => {
|
|
4127
|
+
/** Every tool def on the external surface, by name. */
|
|
4128
|
+
const defs = async (): Promise<Map<string, { description: string; inputs: unknown }>> =>
|
|
4129
|
+
new Map((await toolRegistry.listExternal()).map((t) => [t.name, t as unknown as { description: string; inputs: unknown }]));
|
|
4130
|
+
|
|
4131
|
+
/** The `sessionId` input's description on a built def, or undefined. */
|
|
4132
|
+
const sessionIdDescriptionOf = (def: { inputs: unknown }): string | undefined =>
|
|
4133
|
+
(def.inputs as { properties?: { body?: { properties?: Record<string, { description?: string }> } } })
|
|
4134
|
+
.properties?.body?.properties?.sessionId?.description;
|
|
4135
|
+
|
|
4136
|
+
it('no tool description and no input description contains the word "ontology"', async () => {
|
|
4137
|
+
await start();
|
|
4138
|
+
for (const tool of await toolRegistry.listExternal()) {
|
|
4139
|
+
expect(tool.description.toLowerCase(), `${tool.name} description`).not.toContain('ontolog');
|
|
4140
|
+
expect(JSON.stringify(tool.inputs).toLowerCase(), `${tool.name} inputs`).not.toContain('ontolog');
|
|
4141
|
+
}
|
|
4142
|
+
});
|
|
4143
|
+
|
|
4144
|
+
it('start_session says what the id is — the conversation\'s, shared with `ask` — and names no ontology', async () => {
|
|
4145
|
+
await start();
|
|
4146
|
+
const description = (await defs()).get('start_session')?.description ?? '';
|
|
4147
|
+
expect(description).toMatch(/conversation/i);
|
|
4148
|
+
expect(description).toContain('`ask`');
|
|
4149
|
+
expect(description.toLowerCase()).not.toContain('ontolog');
|
|
4150
|
+
});
|
|
4151
|
+
|
|
4152
|
+
it('the file tools still accept a sessionId, described as this conversation\'s id', async () => {
|
|
4153
|
+
await start();
|
|
4154
|
+
const all = await defs();
|
|
4155
|
+
for (const name of ['read_file', 'write_file', 'edit_file', 'grep', 'unzip']) {
|
|
4156
|
+
expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(SESSION_ID_DESCRIPTION);
|
|
4157
|
+
}
|
|
4158
|
+
});
|
|
4159
|
+
|
|
4160
|
+
it('a Hexis-only deployment registers no note, so nothing is appended', async () => {
|
|
4161
|
+
await start();
|
|
4162
|
+
const all = await defs();
|
|
4163
|
+
expect(notes.gatedToolNote()).toBe('');
|
|
4164
|
+
expect(all.get('read_file')!.description).not.toContain('Stay within one');
|
|
4165
|
+
});
|
|
4166
|
+
|
|
4167
|
+
it("a registered note lands after every gated tool's own text, ahead of the shared-rules pointer, and on the sessionId input", async () => {
|
|
4168
|
+
await start();
|
|
4169
|
+
notes.registerGatedToolNote(' One folder per conversation.');
|
|
4170
|
+
notes.registerSessionIdNote(' It also pins that folder.');
|
|
4171
|
+
const all = await defs();
|
|
4172
|
+
// The POINTER is last, always: that is the one sentence an agent needs to
|
|
4173
|
+
// find the shared rules, and a description cut short must not lose it. The
|
|
4174
|
+
// deployment's note sits directly before it, after the tool's own text.
|
|
4175
|
+
const pointer = sharedRulesPointer(testKbContext().layout);
|
|
4176
|
+
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']) {
|
|
4177
|
+
expect(all.get(name)!.description.endsWith(` One folder per conversation.${pointer}`), name).toBe(true);
|
|
4178
|
+
expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(`${SESSION_ID_DESCRIPTION} It also pins that folder.`);
|
|
4179
|
+
}
|
|
4180
|
+
// `execute_command` is internal-only, so it is checked on that surface.
|
|
4181
|
+
const internal = new Map((await toolRegistry.listInternal()).map((t) => [t.name, t]));
|
|
4182
|
+
expect(internal.get('execute_command')!.description.endsWith(` One folder per conversation.${pointer}`)).toBe(true);
|
|
4183
|
+
});
|
|
4184
|
+
|
|
4185
|
+
it('a tool that is not gated carries no note', async () => {
|
|
4186
|
+
await start();
|
|
4187
|
+
notes.registerGatedToolNote(' One folder per conversation.');
|
|
4188
|
+
const description = (await defs()).get('start_session')?.description ?? '';
|
|
4189
|
+
expect(description).not.toContain('One folder per conversation.');
|
|
4190
|
+
});
|
|
4191
|
+
});
|
|
4192
|
+
|
|
4193
|
+
/**
|
|
4194
|
+
* The bug Juan reported: the dry run of renaming a top-level folder warned he
|
|
4195
|
+
* would lose owner access, and after the move he was still owner. The folder
|
|
4196
|
+
* carried its own `access.md`, which moved with it, and the preview judged
|
|
4197
|
+
* the destination as it stood — where neither the folder nor its rules were
|
|
4198
|
+
* yet.
|
|
4199
|
+
*
|
|
4200
|
+
* These run against the REAL resolver over a real tree, because the thing
|
|
4201
|
+
* under test is what the rules say about a path nothing is at. Every preview
|
|
4202
|
+
* is then held against the operation it predicted: the caller's access at the
|
|
4203
|
+
* destination afterwards must be what the preview answered, or the preview is
|
|
4204
|
+
* wrong again and nobody will find out until the next report.
|
|
4205
|
+
*/
|
|
4206
|
+
describe('a move preview judges the destination as it will be', () => {
|
|
4207
|
+
const KB = (p: string) => `${KB_DIR}/${p}`;
|
|
4208
|
+
const MOVER = 'mover@x.io';
|
|
4209
|
+
const rules = (body: string) => `---\n${body}---\n`;
|
|
4210
|
+
|
|
4211
|
+
/**
|
|
4212
|
+
* The real `AccessControlService`, re-read from disk on every question.
|
|
4213
|
+
* In production the file-change notifier invalidates the model after a
|
|
4214
|
+
* write; a test leaning on the five-second cache would be asserting the
|
|
4215
|
+
* cache rather than what the tree now says.
|
|
4216
|
+
*/
|
|
4217
|
+
const liveRules = (): IAccessControl => {
|
|
4218
|
+
const fresh = () =>
|
|
4219
|
+
new AccessControlService(
|
|
4220
|
+
{
|
|
4221
|
+
getWorkspacePath: async () => tempDir,
|
|
4222
|
+
ensureRemotesFetched: async () => undefined,
|
|
4223
|
+
} as unknown as WorkspaceService,
|
|
4224
|
+
KB_DIR,
|
|
4225
|
+
new NodeFs(),
|
|
4226
|
+
);
|
|
4227
|
+
return {
|
|
4228
|
+
canRead: (w: string, u: string, r: string) => fresh().canRead(w, u, r),
|
|
4229
|
+
canReadBatch: (w: string, u: string, r: string[]) => fresh().canReadBatch(w, u, r),
|
|
4230
|
+
canWrite: (w: string, u: string, r: string) => fresh().canWrite(w, u, r),
|
|
4231
|
+
canDownload: (w: string, u: string, r: string) => fresh().canDownload(w, u, r),
|
|
4232
|
+
canOwner: (w: string, u: string, r: string) => fresh().canOwner(w, u, r),
|
|
4233
|
+
canWriteBatchAtRef: async () => null,
|
|
4234
|
+
previewAccessAfterRelocation: (
|
|
4235
|
+
w: string, u: string, from: string, to: string, opts?: { sourceRemains?: boolean },
|
|
4236
|
+
) => fresh().previewAccessAfterRelocation(w, u, from, to, opts),
|
|
4237
|
+
} as unknown as IAccessControl;
|
|
4238
|
+
};
|
|
4239
|
+
|
|
4240
|
+
const call = async (base: string, tool: string, body: Record<string, unknown>) => {
|
|
4241
|
+
const res = await post(`${base}/api/agent/tools/${tool}`, body);
|
|
4242
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- a tool body is free-form JSON, probed field by field
|
|
4243
|
+
return { status: res.status, body: (await res.json()) as Record<string, any> };
|
|
4244
|
+
};
|
|
4245
|
+
|
|
4246
|
+
/**
|
|
4247
|
+
* `Sales/` grants the caller write and owner in its OWN access.md. `Work/`
|
|
4248
|
+
* grants both too, so `Work/Team` — which grants only write of its own —
|
|
4249
|
+
* holds owner by inheritance and loses it on the way out. `Legal/` grants
|
|
4250
|
+
* read to everyone and nothing else. The root grants read and nothing else,
|
|
4251
|
+
* so a folder that arrives there arrives with only what it brought.
|
|
4252
|
+
*/
|
|
4253
|
+
async function seeded(): Promise<string> {
|
|
4254
|
+
const base = await start('write', liveRules(), MOVER);
|
|
4255
|
+
await fs.writeFile(KB('roles.yaml'), 'roles:\n Admin:\n - admin@x.io\n');
|
|
4256
|
+
await fs.writeFile(KB('access.md'), rules('read:\n - everyone\n'));
|
|
4257
|
+
await fs.writeFile(KB('Sales/access.md'), rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`));
|
|
4258
|
+
await fs.writeFile(KB('Sales/deal.md'), 'deal');
|
|
4259
|
+
await fs.writeFile(KB('Work/access.md'), rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`));
|
|
4260
|
+
await fs.writeFile(KB('Work/Notes/note.md'), 'note');
|
|
4261
|
+
await fs.writeFile(KB('Work/Team/access.md'), rules(`write:\n - Mover <${MOVER}>\n`));
|
|
4262
|
+
await fs.writeFile(KB('Work/Team/plan.md'), 'plan');
|
|
4263
|
+
await fs.writeFile(KB('Legal/access.md'), rules('read:\n - everyone\n'));
|
|
4264
|
+
return base;
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
/** The caller's verbs where the thing really is now, through `file_stat`. */
|
|
4268
|
+
const verbsAt = async (base: string, path: string) =>
|
|
4269
|
+
(await call(base, 'file_stat', { path })).body.access;
|
|
4270
|
+
|
|
4271
|
+
it('renaming a folder that names the caller owner previews no loss at all', async () => {
|
|
4272
|
+
const base = await seeded();
|
|
4273
|
+
const args = { src: KB('Sales'), dest: KB('Revenue') };
|
|
4274
|
+
|
|
4275
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
4276
|
+
expect(dry.status).toBe(200);
|
|
4277
|
+
expect(dry.body).toMatchObject({ kind: 'folder', accessChanges: false, allowed: true, moved: false });
|
|
4278
|
+
expect(dry.body.access.after).toEqual(dry.body.access.before);
|
|
4279
|
+
expect(dry.body.access.after.owner).toBe(true);
|
|
4280
|
+
// Nothing moved and nothing was written to answer the question.
|
|
4281
|
+
expect(await fs.exists(args.dest)).toBe(false);
|
|
4282
|
+
expect(await fs.exists(KB('Sales/access.md'))).toBe(true);
|
|
4283
|
+
|
|
4284
|
+
// `accessChanges: false`, so the move itself needs no confirmation.
|
|
4285
|
+
expect((await call(base, 'move_file', args)).body).toMatchObject({ moved: true });
|
|
4286
|
+
expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
|
|
4287
|
+
});
|
|
4288
|
+
|
|
4289
|
+
it('moving a folder with no access.md of its own still previews the loss', async () => {
|
|
4290
|
+
const base = await seeded();
|
|
4291
|
+
const args = { src: KB('Work/Notes'), dest: KB('Legal/Notes') };
|
|
4292
|
+
|
|
4293
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
4294
|
+
expect(dry.body).toMatchObject({ accessChanges: true, allowed: true });
|
|
4295
|
+
expect(dry.body.access.before.write).toBe(true);
|
|
4296
|
+
expect(dry.body.access.after.write).toBe(false);
|
|
4297
|
+
|
|
4298
|
+
// A changed answer still asks before it moves.
|
|
4299
|
+
expect((await call(base, 'move_file', args)).body).toMatchObject({ confirmationRequired: true, moved: false });
|
|
4300
|
+
expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
|
|
4301
|
+
expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
|
|
4302
|
+
});
|
|
4303
|
+
|
|
4304
|
+
it('what the folder inherited is left behind; what its own access.md gives comes along', async () => {
|
|
4305
|
+
const base = await seeded();
|
|
4306
|
+
const args = { src: KB('Work/Team'), dest: KB('Legal/Team') };
|
|
4307
|
+
|
|
4308
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
4309
|
+
// Write is the folder's own and travels; owner came from `Work/`.
|
|
4310
|
+
expect(dry.body.access.before).toMatchObject({ write: true, owner: true });
|
|
4311
|
+
expect(dry.body.access.after).toMatchObject({ write: true, owner: false });
|
|
4312
|
+
|
|
4313
|
+
expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
|
|
4314
|
+
expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
|
|
4315
|
+
});
|
|
4316
|
+
|
|
4317
|
+
it('a nested access.md governs where it lands, not the folder above it', async () => {
|
|
4318
|
+
const base = await seeded();
|
|
4319
|
+
const args = { src: KB('Work/Team'), dest: KB('Legal/Team') };
|
|
4320
|
+
const withoutNested = (await call(base, 'move_file', { ...args, dryRun: true })).body.access.after;
|
|
4321
|
+
|
|
4322
|
+
// `Sub/` shuts the caller out and moves too, but it governs `Legal/Team/Sub`.
|
|
4323
|
+
await fs.writeFile(KB('Work/Team/Sub/access.md'), rules(`read:\n - deny Mover <${MOVER}>\n`));
|
|
4324
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
4325
|
+
expect(dry.body.access.after).toEqual(withoutNested);
|
|
4326
|
+
|
|
4327
|
+
expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
|
|
4328
|
+
expect(await verbsAt(base, args.dest)).toEqual(withoutNested);
|
|
4329
|
+
// The nested file did land and does govern where it landed: the caller
|
|
4330
|
+
// it shuts out cannot even stat the folder now.
|
|
4331
|
+
expect((await call(base, 'file_stat', { path: KB('Legal/Team/Sub') })).status).toBe(403);
|
|
4332
|
+
});
|
|
4333
|
+
|
|
4334
|
+
it('moving a single file previews as it always did', async () => {
|
|
4335
|
+
const base = await seeded();
|
|
4336
|
+
const args = { src: KB('Sales/deal.md'), dest: KB('Legal/deal.md') };
|
|
4337
|
+
|
|
4338
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
4339
|
+
expect(dry.body).toMatchObject({ kind: 'file', descendants: 1 });
|
|
4340
|
+
// Nothing travels with a file but its own bytes, so the answer is the
|
|
4341
|
+
// destination's own rules — what `file_stat` says there already.
|
|
4342
|
+
expect(dry.body.access.after).toEqual(await verbsAt(base, KB('Legal')));
|
|
4343
|
+
|
|
4344
|
+
expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
|
|
4345
|
+
expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
|
|
4346
|
+
});
|
|
4347
|
+
|
|
4348
|
+
it('the preview answers the caller\'s own verbs and nothing more', async () => {
|
|
4349
|
+
const base = await seeded();
|
|
4350
|
+
const dry = await call(base, 'move_file', { src: KB('Sales'), dest: KB('Revenue'), dryRun: true });
|
|
4351
|
+
|
|
4352
|
+
expect(Object.keys(dry.body.access).sort()).toEqual(['after', 'before']);
|
|
4353
|
+
for (const side of ['before', 'after'] as const) {
|
|
4354
|
+
expect(Object.keys(dry.body.access[side]).sort()).toEqual(['download', 'owner', 'read', 'write']);
|
|
4355
|
+
}
|
|
4356
|
+
});
|
|
4357
|
+
|
|
4358
|
+
describe('copy_file previews the same way', () => {
|
|
4359
|
+
it('a folder copy counts the copied access.md at the destination', async () => {
|
|
4360
|
+
const base = await seeded();
|
|
4361
|
+
const args = { src: KB('Sales'), dest: KB('Sales-Copy') };
|
|
4362
|
+
|
|
4363
|
+
const dry = await call(base, 'copy_file', { ...args, dryRun: true });
|
|
4364
|
+
expect(dry.status).toBe(200);
|
|
4365
|
+
expect(dry.body).toMatchObject({ kind: 'folder', accessChanges: false, copied: false, dryRun: true });
|
|
4366
|
+
expect(dry.body.access.after.owner).toBe(true);
|
|
4367
|
+
expect(dry.body.access.after).toEqual(dry.body.access.before);
|
|
4368
|
+
|
|
4369
|
+
// `copy_file` copies ONE FILE, which the preview says rather than
|
|
4370
|
+
// promising a copy that cannot land — and the call itself refuses with
|
|
4371
|
+
// the same sentence, so preflight and execution never disagree.
|
|
4372
|
+
expect(dry.body.allowed).toBe(false);
|
|
4373
|
+
const run = await call(base, 'copy_file', args);
|
|
4374
|
+
expect(run.status).toBe(400);
|
|
4375
|
+
expect(run.body.error).toBe(dry.body.reason);
|
|
4376
|
+
expect(await fs.exists(args.dest)).toBe(false);
|
|
4377
|
+
});
|
|
4378
|
+
|
|
4379
|
+
it('a file copy previews what the caller will have at the destination', async () => {
|
|
4380
|
+
const base = await seeded();
|
|
4381
|
+
const args = { src: KB('Sales/deal.md'), dest: KB('Legal/deal.md') };
|
|
4382
|
+
|
|
4383
|
+
const dry = await call(base, 'copy_file', { ...args, dryRun: true });
|
|
4384
|
+
expect(dry.body).toMatchObject({ kind: 'file', descendants: 1, allowed: true, copied: false });
|
|
4385
|
+
expect(dry.body.access.before.write).toBe(true);
|
|
4386
|
+
expect(dry.body.access.after.write).toBe(false);
|
|
4387
|
+
expect(dry.body.accessChanges).toBe(true);
|
|
4388
|
+
expect(await fs.exists(args.dest)).toBe(false);
|
|
4389
|
+
|
|
4390
|
+
expect((await call(base, 'copy_file', args)).body).toMatchObject({ copied: true });
|
|
4391
|
+
expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
|
|
4392
|
+
// A copy leaves the source exactly as it was.
|
|
4393
|
+
expect(await verbsAt(base, args.src)).toEqual(dry.body.access.before);
|
|
4394
|
+
});
|
|
4395
|
+
|
|
4396
|
+
/**
|
|
4397
|
+
* `copy_file` will copy a lone `access.md`, and the moment it lands it
|
|
4398
|
+
* governs the folder it landed in. The preview counts it: the rules a
|
|
4399
|
+
* copy carries are the point of this ticket whether they travel inside a
|
|
4400
|
+
* folder or on their own.
|
|
4401
|
+
*/
|
|
4402
|
+
it('a lone access.md previews the access it will give at the destination', async () => {
|
|
4403
|
+
const base = await seeded();
|
|
4404
|
+
// A folder with no rules of its own, so the destination's answer today
|
|
4405
|
+
// is the root's: read and nothing else. (`Legal/access.md` is taken, and
|
|
4406
|
+
// a copy onto a name that exists is refused long before access is asked.)
|
|
4407
|
+
await fs.writeFile(KB('Open/note.md'), 'note');
|
|
4408
|
+
const args = { src: KB('Sales/access.md'), dest: KB('Open/access.md') };
|
|
4409
|
+
|
|
4410
|
+
const dry = await call(base, 'copy_file', { ...args, dryRun: true });
|
|
4411
|
+
expect(dry.body).toMatchObject({ kind: 'file', descendants: 1, allowed: true });
|
|
4412
|
+
expect(await verbsAt(base, KB('Open'))).toMatchObject({ write: false, owner: false });
|
|
4413
|
+
expect(dry.body.access.after).toMatchObject({ write: true, owner: true });
|
|
4414
|
+
expect(dry.body.accessChanges).toBe(false);
|
|
4415
|
+
|
|
4416
|
+
expect((await call(base, 'copy_file', args)).body).toMatchObject({ copied: true });
|
|
4417
|
+
expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
|
|
4418
|
+
});
|
|
4419
|
+
|
|
4420
|
+
it('a destination that is taken is named, and the dry run changes nothing', async () => {
|
|
4421
|
+
const base = await seeded();
|
|
4422
|
+
const args = { src: KB('Sales/deal.md'), dest: KB('Work/Notes/note.md') };
|
|
4423
|
+
|
|
4424
|
+
const dry = await call(base, 'copy_file', { ...args, dryRun: true });
|
|
4425
|
+
expect(dry.body.allowed).toBe(false);
|
|
4426
|
+
expect(dry.body.reason).toContain('already exists');
|
|
4427
|
+
expect(String(await fs.readFile(KB('Work/Notes/note.md'), { encoding: 'utf-8' }))).toContain('note');
|
|
4428
|
+
});
|
|
4429
|
+
});
|
|
4430
|
+
});
|