@bevel-software/platform-core-backend 0.22.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 +30 -11
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +9 -2
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +112 -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.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/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 +70 -2
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +176 -7
- 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/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/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/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 +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-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 +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 +69 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +153 -2
- 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 +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.service.d.ts +60 -6
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +111 -4
- 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 +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/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 +255 -25
- 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 +309 -115
- 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 +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/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 +32 -14
- package/src/core/create-core-services.ts +129 -23
- 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.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.routes.ts +13 -15
- 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 +1 -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 +179 -6
- package/src/modules/auth/oidc-auth-provider.ts +7 -2
- package/src/modules/database/core-schema.ts +35 -27
- 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.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__/plugins.tools.test.ts +2 -1
- 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 +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-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__/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/git.service.ts +168 -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.service.ts +121 -4
- 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.tools.agents-file.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +685 -14
- 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 +86 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
- package/src/modules/workspace/workspace.routes.ts +95 -3
- package/src/modules/workspace/workspace.service.ts +256 -24
- package/src/modules/workspace/workspace.tools.ts +338 -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 +31 -0
- package/src/shared/git.contract.ts +27 -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) => {
|
|
@@ -1816,7 +1868,7 @@ describe('images', () => {
|
|
|
1816
1868
|
/**
|
|
1817
1869
|
* start_session must mint a REAL chat thread and return its id (not a bare
|
|
1818
1870
|
* 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
|
|
1871
|
+
* sessionId IS a chat thread). This is what unifies the conversation
|
|
1820
1872
|
* across reads + ask — see workspace.tools.ts start_session comment.
|
|
1821
1873
|
*/
|
|
1822
1874
|
describe('start_session', () => {
|
|
@@ -1863,7 +1915,7 @@ describe('start_session', () => {
|
|
|
1863
1915
|
registerWorkspaceTools(
|
|
1864
1916
|
registry, router, auth, toolHandler,
|
|
1865
1917
|
new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, testKbContext({ kbDirName: KB_DIR }),
|
|
1866
|
-
{
|
|
1918
|
+
{ recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
1867
1919
|
new RoutineWritePolicyService(),
|
|
1868
1920
|
sink ?? fakeSessionSink,
|
|
1869
1921
|
);
|
|
@@ -1988,7 +2040,7 @@ describe('start_session', () => {
|
|
|
1988
2040
|
registerWorkspaceTools(
|
|
1989
2041
|
registry, router, noopAuth, (() => () => {}) as never,
|
|
1990
2042
|
new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, testKbContext({ kbDirName: KB_DIR }),
|
|
1991
|
-
{
|
|
2043
|
+
{ recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
1992
2044
|
new RoutineWritePolicyService(),
|
|
1993
2045
|
{} as never,
|
|
1994
2046
|
);
|
|
@@ -2030,7 +2082,7 @@ describe('branch is a required parameter in the tool contract', () => {
|
|
|
2030
2082
|
new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')),
|
|
2031
2083
|
allowAll,
|
|
2032
2084
|
testKbContext({ kbDirName: KB_DIR }),
|
|
2033
|
-
{
|
|
2085
|
+
{ recoveryBotEmail: RECOVERY_BOT, hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
2034
2086
|
new RoutineWritePolicyService(),
|
|
2035
2087
|
{} as never,
|
|
2036
2088
|
);
|
|
@@ -2514,6 +2566,11 @@ describe('preflight for moves and deletes', () => {
|
|
|
2514
2566
|
canWriteBatchAtRef: async (_w: string, _r: string, _u: string, rels: string[]) =>
|
|
2515
2567
|
new Map(rels.map((p) => [p, verbsFor(p).write])),
|
|
2516
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),
|
|
2517
2574
|
} as unknown as IAccessControl;
|
|
2518
2575
|
|
|
2519
2576
|
const call = async (base: string, tool: string, body: Record<string, unknown>) => {
|
|
@@ -2657,6 +2714,35 @@ describe('preflight for moves and deletes', () => {
|
|
|
2657
2714
|
expect(await exists(KB('Sales/deal.md'))).toBe(true);
|
|
2658
2715
|
});
|
|
2659
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
|
+
|
|
2660
2746
|
it('copy_file keeps the same order: the write refusal, not what is in the folder', async () => {
|
|
2661
2747
|
const base = await seeded();
|
|
2662
2748
|
|
|
@@ -3310,6 +3396,7 @@ describe('a write refused for permissions says whether and how to propose it', (
|
|
|
3310
3396
|
canDownload: async () => true,
|
|
3311
3397
|
canOwner: async () => true,
|
|
3312
3398
|
canWriteBatchAtRef: async () => null,
|
|
3399
|
+
previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
|
|
3313
3400
|
eligibleWritersAtRef: async () => ({ roles: ['Sales Lead'], users: [{ name: 'Owner', email: 'owner@x' }] }),
|
|
3314
3401
|
} as unknown as IAccessControl;
|
|
3315
3402
|
return { ac, calls };
|
|
@@ -3718,3 +3805,587 @@ describe('a path with nothing at it answers 404 not_found on every file tool', (
|
|
|
3718
3805
|
}
|
|
3719
3806
|
});
|
|
3720
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
|
+
});
|