@bevel-software/platform-core-backend 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-guide/access-control.md +146 -289
- package/agent-guide/conventions.md +23 -25
- package/agent-guide/directory-structure.md +72 -120
- package/agent-guide/drafts-and-change-requests.md +92 -0
- package/agent-guide/for-git-users.md +47 -0
- package/agent-guide/glossary.md +36 -0
- package/agent-guide/how-agents-find-things.md +49 -0
- package/agent-guide/html-views.md +239 -8
- package/agent-guide/new-knowledge-base.md +9 -8
- package/agent-guide/skills.md +67 -42
- package/agent-guide/start-here.md +65 -0
- package/agent-guide/tool-manuals.md +133 -155
- package/agent-guide/tools.md +67 -0
- package/agent-guide/where-a-new-file-goes.md +45 -28
- package/dist/core/catalog-revision.d.ts +12 -1
- package/dist/core/catalog-revision.d.ts.map +1 -1
- package/dist/core/catalog-revision.js +23 -3
- package/dist/core/catalog-revision.js.map +1 -1
- package/dist/core/core-ports.d.ts +41 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +15 -8
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +22 -5
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +44 -16
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +16 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +12 -2
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +39 -9
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +15 -3
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +239 -75
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +22 -24
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +48 -51
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-view.d.ts.map +1 -1
- package/dist/modules/access/access-view.js +12 -11
- package/dist/modules/access/access-view.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts +3 -0
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +58 -33
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/groups-admin.routes.d.ts.map +1 -1
- package/dist/modules/access/groups-admin.routes.js +4 -2
- package/dist/modules/access/groups-admin.routes.js.map +1 -1
- package/dist/modules/access/groups-admin.service.d.ts +44 -13
- package/dist/modules/access/groups-admin.service.d.ts.map +1 -1
- package/dist/modules/access/groups-admin.service.js +80 -38
- package/dist/modules/access/groups-admin.service.js.map +1 -1
- package/dist/modules/access/groups-edit.d.ts +1 -1
- package/dist/modules/access/groups-edit.d.ts.map +1 -1
- package/dist/modules/access/groups-edit.js +1 -1
- package/dist/modules/access/groups-edit.js.map +1 -1
- package/dist/modules/access/index.d.ts +1 -1
- package/dist/modules/access/index.d.ts.map +1 -1
- package/dist/modules/access/reference-scan.d.ts +116 -24
- package/dist/modules/access/reference-scan.d.ts.map +1 -1
- package/dist/modules/access/reference-scan.js +305 -44
- package/dist/modules/access/reference-scan.js.map +1 -1
- package/dist/modules/access/roles-admin.service.d.ts +17 -8
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +32 -23
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +35 -10
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +60 -20
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/access-model/access-splice.d.ts +22 -18
- package/dist/modules/access-model/access-splice.d.ts.map +1 -1
- package/dist/modules/access-model/access-splice.js +20 -14
- package/dist/modules/access-model/access-splice.js.map +1 -1
- package/dist/modules/access-model/group-files.d.ts +1 -0
- package/dist/modules/access-model/group-files.d.ts.map +1 -1
- package/dist/modules/access-model/group-files.js +5 -1
- package/dist/modules/access-model/group-files.js.map +1 -1
- package/dist/modules/agent-guide/agent-guide.d.ts +47 -7
- package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.js +103 -13
- package/dist/modules/agent-guide/agent-guide.js.map +1 -1
- package/dist/modules/agent-guide/agent-guide.tools.d.ts +6 -2
- package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.tools.js +46 -22
- package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -1
- package/dist/modules/agent-guide/guide-budget.d.ts +48 -0
- package/dist/modules/agent-guide/guide-budget.d.ts.map +1 -0
- package/dist/modules/agent-guide/guide-budget.js +67 -0
- package/dist/modules/agent-guide/guide-budget.js.map +1 -0
- package/dist/modules/agent-guide/index.d.ts +2 -1
- package/dist/modules/agent-guide/index.d.ts.map +1 -1
- package/dist/modules/agent-guide/index.js +2 -1
- package/dist/modules/agent-guide/index.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts +3 -1
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +7 -1
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +5 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +26 -9
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
- package/dist/modules/agent-scope/agent-scope.contract.d.ts +67 -0
- package/dist/modules/agent-scope/agent-scope.contract.d.ts.map +1 -0
- package/dist/modules/agent-scope/agent-scope.contract.js +27 -0
- package/dist/modules/agent-scope/agent-scope.contract.js.map +1 -0
- package/dist/modules/agent-scope/connection-scope.d.ts +111 -0
- package/dist/modules/agent-scope/connection-scope.d.ts.map +1 -0
- package/dist/modules/agent-scope/connection-scope.js +165 -0
- package/dist/modules/agent-scope/connection-scope.js.map +1 -0
- package/dist/modules/agent-scope/index.d.ts +3 -0
- package/dist/modules/agent-scope/index.d.ts.map +1 -0
- package/dist/modules/agent-scope/index.js +3 -0
- package/dist/modules/agent-scope/index.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +2 -0
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +6 -0
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/auth/account-admission.d.ts +12 -0
- package/dist/modules/auth/account-admission.d.ts.map +1 -1
- package/dist/modules/auth/account-admission.js +15 -0
- package/dist/modules/auth/account-admission.js.map +1 -1
- package/dist/modules/auth/auth.middleware.d.ts +27 -1
- package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
- package/dist/modules/auth/auth.middleware.js +42 -4
- package/dist/modules/auth/auth.middleware.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts +13 -9
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +35 -29
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +55 -6
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +128 -34
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/index.d.ts +1 -1
- package/dist/modules/auth/index.d.ts.map +1 -1
- package/dist/modules/auth/index.js +1 -1
- package/dist/modules/auth/index.js.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.js +2 -9
- package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +2 -2
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.service.d.ts +0 -12
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
- package/dist/modules/connection-probe/connection-probe.service.js +1 -38
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +68 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +33 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts +8 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.js +13 -2
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -1
- package/dist/modules/embed/embed.tools.d.ts.map +1 -1
- package/dist/modules/embed/embed.tools.js +5 -0
- package/dist/modules/embed/embed.tools.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +2 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +1 -0
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +7 -1
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +26 -6
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
- package/dist/modules/kb-fs/repo-path.js +22 -7
- package/dist/modules/kb-fs/repo-path.js.map +1 -1
- package/dist/modules/kb-fs/tmp-ref.d.ts +36 -0
- package/dist/modules/kb-fs/tmp-ref.d.ts.map +1 -0
- package/dist/modules/kb-fs/tmp-ref.js +76 -0
- package/dist/modules/kb-fs/tmp-ref.js.map +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +7 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +14 -1
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +7 -1
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +39 -4
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +11 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +74 -48
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +24 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +45 -10
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +9 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/oauth-consent.routes.js +34 -1
- package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -1
- package/dist/modules/plugins/index.d.ts +1 -1
- package/dist/modules/plugins/index.d.ts.map +1 -1
- package/dist/modules/plugins/index.js.map +1 -1
- package/dist/modules/plugins/plugin-links.d.ts +18 -2
- package/dist/modules/plugins/plugin-links.d.ts.map +1 -1
- package/dist/modules/plugins/plugin-links.js.map +1 -1
- package/dist/modules/plugins/plugins.routes.d.ts +5 -1
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.routes.js +32 -5
- package/dist/modules/plugins/plugins.routes.js.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +11 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +159 -24
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/index.d.ts +1 -1
- package/dist/modules/secrets-vault/index.d.ts.map +1 -1
- package/dist/modules/secrets-vault/index.js +1 -1
- package/dist/modules/secrets-vault/index.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +53 -11
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.contract.js +27 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +9 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +100 -42
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/oidc-check.d.ts.map +1 -1
- package/dist/modules/settings/oidc-check.js +5 -17
- package/dist/modules/settings/oidc-check.js.map +1 -1
- package/dist/modules/skills/index.d.ts +3 -3
- package/dist/modules/skills/index.d.ts.map +1 -1
- package/dist/modules/skills/index.js +0 -2
- package/dist/modules/skills/index.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +16 -0
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +6 -1
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +25 -13
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.interface.d.ts +11 -0
- package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts +1 -0
- 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 -1
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/index.d.ts +1 -1
- package/dist/modules/tool-auth/index.d.ts.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +9 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.js +1 -0
- package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +27 -3
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +32 -6
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts +7 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +3 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool-def.js +1 -1
- package/dist/modules/tool-helpers/tool-def.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +15 -0
- package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.js +19 -0
- package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +10 -0
- 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.d.ts +3 -1
- package/dist/modules/tool-helpers/validate-token.d.ts.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/index.d.ts +3 -3
- package/dist/modules/tool-manuals/index.d.ts.map +1 -1
- package/dist/modules/tool-manuals/index.js +0 -2
- package/dist/modules/tool-manuals/index.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +7 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +69 -38
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +46 -6
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +7 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +41 -4
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +24 -13
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +138 -59
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +8 -2
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +56 -20
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-registry/manual.routes.d.ts +8 -1
- package/dist/modules/tool-registry/manual.routes.d.ts.map +1 -1
- package/dist/modules/tool-registry/manual.routes.js +27 -7
- package/dist/modules/tool-registry/manual.routes.js.map +1 -1
- package/dist/modules/tool-registry/tool.contract.d.ts +7 -0
- package/dist/modules/tool-registry/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-registry/tool.contract.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +36 -27
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +2 -2
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +20 -35
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +2 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +3 -0
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -1
- package/dist/modules/workspace/agent-access.gate.js +1 -1
- package/dist/modules/workspace/agent-access.gate.js.map +1 -1
- package/dist/modules/workspace/file-readers/doc-extract.service.d.ts +28 -4
- package/dist/modules/workspace/file-readers/doc-extract.service.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/doc-extract.service.js +61 -40
- package/dist/modules/workspace/file-readers/doc-extract.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +8 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +8 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/modules/workspace/nul-scan.d.ts +19 -0
- package/dist/modules/workspace/nul-scan.d.ts.map +1 -0
- package/dist/modules/workspace/nul-scan.js +55 -0
- package/dist/modules/workspace/nul-scan.js.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts +110 -27
- package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -1
- package/dist/modules/workspace/pattern-matcher.contract.js +30 -13
- package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -1
- package/dist/modules/workspace/ripgrep-search.d.ts +54 -0
- package/dist/modules/workspace/ripgrep-search.d.ts.map +1 -0
- package/dist/modules/workspace/ripgrep-search.js +657 -0
- package/dist/modules/workspace/ripgrep-search.js.map +1 -0
- package/dist/modules/workspace/ripgrep-version.d.ts +15 -0
- package/dist/modules/workspace/ripgrep-version.d.ts.map +1 -0
- package/dist/modules/workspace/ripgrep-version.js +31 -0
- package/dist/modules/workspace/ripgrep-version.js.map +1 -0
- package/dist/modules/workspace/startup/beside-checkout.d.ts +8 -5
- package/dist/modules/workspace/startup/beside-checkout.d.ts.map +1 -1
- package/dist/modules/workspace/startup/beside-checkout.js +14 -5
- package/dist/modules/workspace/startup/beside-checkout.js.map +1 -1
- package/dist/modules/workspace/worker-pool.d.ts +3 -4
- package/dist/modules/workspace/worker-pool.d.ts.map +1 -1
- package/dist/modules/workspace/worker-pool.js.map +1 -1
- package/dist/modules/workspace/workspace-tmp.d.ts +146 -0
- package/dist/modules/workspace/workspace-tmp.d.ts.map +1 -0
- package/dist/modules/workspace/workspace-tmp.js +435 -0
- package/dist/modules/workspace/workspace-tmp.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +5 -26
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +47 -6
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +86 -6
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +3 -2
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +531 -137
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/oauth-token-error.d.ts +23 -0
- package/dist/shared/oauth-token-error.d.ts.map +1 -0
- package/dist/shared/oauth-token-error.js +47 -0
- package/dist/shared/oauth-token-error.js.map +1 -0
- package/dist/shared/path-order.d.ts +28 -8
- package/dist/shared/path-order.d.ts.map +1 -1
- package/dist/shared/path-order.js +37 -8
- package/dist/shared/path-order.js.map +1 -1
- package/dist/shared/read-capped.d.ts +19 -0
- package/dist/shared/read-capped.d.ts.map +1 -0
- package/dist/shared/read-capped.js +45 -0
- package/dist/shared/read-capped.js.map +1 -0
- package/migrations/0022_session_version.sql +6 -0
- package/migrations/0023_agent_path.sql +3 -0
- package/migrations/meta/0022_snapshot.json +2360 -0
- package/migrations/meta/0023_snapshot.json +2378 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +4 -3
- package/src/__tests__/hexis-catalog.ts +144 -0
- package/src/__tests__/pg-scratch.test.ts +69 -0
- package/src/__tests__/pg-scratch.ts +31 -0
- package/src/__tests__/real-disk.ts +21 -0
- package/src/__tests__/slow-ripgrep.ts +65 -0
- package/src/__tests__/tokens.ts +19 -0
- package/src/__tests__/tool-manuals.ts +32 -0
- package/src/__tests__/tool-wiring.ts +25 -0
- package/src/core/__tests__/boot-without-ripgrep.test.ts +34 -0
- package/src/core/__tests__/catalog-revision.test.ts +89 -0
- package/src/core/__tests__/damaged-document.tools.test.ts +2 -2
- package/src/core/__tests__/gated-boot.test.ts +8 -1
- package/src/core/__tests__/lifecycle.test.ts +46 -13
- package/src/core/__tests__/oauth-sign-in-real-file.test.ts +179 -0
- package/src/core/catalog-revision.ts +24 -2
- package/src/core/core-ports.ts +42 -0
- package/src/core/create-core-server.ts +14 -1
- package/src/core/create-core-services.ts +62 -15
- package/src/core/lifecycle.ts +26 -2
- package/src/index.ts +1 -1
- package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +12 -0
- package/src/modules/access/__tests__/access-control.colliding-name-cases.test.ts +113 -0
- package/src/modules/access/__tests__/access-control.frontmatter-head.test.ts +117 -0
- package/src/modules/access/__tests__/access-control.group-named-admin.test.ts +122 -0
- package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +50 -0
- package/src/modules/access/__tests__/access-declarations.test.ts +19 -0
- package/src/modules/access/__tests__/access-groups.test.ts +55 -22
- package/src/modules/access/__tests__/access-mutation.service.test.ts +85 -59
- package/src/modules/access/__tests__/access-restrict-here.test.ts +7 -5
- package/src/modules/access/__tests__/access.routes.admins.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.batch.test.ts +2 -0
- package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +2 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +50 -14
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +2 -1
- package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +52 -10
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +18 -13
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +20 -15
- package/src/modules/access/__tests__/groups-admin.routes.test.ts +61 -0
- package/src/modules/access/__tests__/groups-admin.service.test.ts +211 -18
- package/src/modules/access/__tests__/holder-changes.test.ts +127 -0
- package/src/modules/access/__tests__/reference-scan.test.ts +366 -14
- package/src/modules/access/__tests__/roles-admin.security.test.ts +1 -1
- package/src/modules/access/__tests__/roles-admin.service.test.ts +43 -22
- package/src/modules/access/__tests__/roles-capabilities.test.ts +6 -2
- package/src/modules/access/__tests__/roles.routes.test.ts +1 -1
- package/src/modules/access/access-control.interface.ts +43 -10
- package/src/modules/access/access-control.service.ts +250 -72
- package/src/modules/access/access-mutation.service.ts +48 -53
- package/src/modules/access/access-view.ts +12 -10
- package/src/modules/access/access.routes.ts +66 -37
- package/src/modules/access/groups-admin.routes.ts +4 -2
- package/src/modules/access/groups-admin.service.ts +114 -44
- package/src/modules/access/groups-edit.ts +1 -1
- package/src/modules/access/index.ts +1 -1
- package/src/modules/access/reference-scan.ts +319 -46
- package/src/modules/access/roles-admin.service.ts +43 -26
- package/src/modules/access/synced-groups-writer.ts +1 -1
- package/src/modules/access-model/__tests__/access-grammar.test.ts +38 -1
- package/src/modules/access-model/__tests__/access-splice.test.ts +50 -8
- package/src/modules/access-model/access-grammar.ts +65 -20
- package/src/modules/access-model/access-splice.ts +31 -23
- package/src/modules/access-model/group-files.ts +5 -0
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +110 -25
- package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +38 -11
- package/src/modules/agent-guide/__tests__/guide-budget.test.ts +95 -0
- package/src/modules/agent-guide/__tests__/guide-eval.test.ts +182 -0
- package/src/modules/agent-guide/__tests__/guide-matches-code.test.ts +263 -0
- package/src/modules/agent-guide/__tests__/tool-surface.json +514 -0
- package/src/modules/agent-guide/__tests__/tool-surface.test.ts +75 -0
- package/src/modules/agent-guide/agent-guide.tools.ts +51 -22
- package/src/modules/agent-guide/agent-guide.ts +120 -13
- package/src/modules/agent-guide/guide-budget.ts +73 -0
- package/src/modules/agent-guide/index.ts +12 -0
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +5 -4
- package/src/modules/agent-instructions/compose.ts +7 -1
- package/src/modules/agent-instructions/shared-file-rules.ts +27 -9
- package/src/modules/agent-scope/__tests__/agent-surfaces.connect-as.test.ts +421 -0
- package/src/modules/agent-scope/__tests__/connection-scope.test.ts +46 -0
- package/src/modules/agent-scope/__tests__/in-memory-kb.ts +148 -0
- package/src/modules/agent-scope/agent-scope.contract.ts +83 -0
- package/src/modules/agent-scope/connection-scope.ts +199 -0
- package/src/modules/agent-scope/index.ts +15 -0
- package/src/modules/audit/agent-audit.service.ts +2 -0
- package/src/modules/audit/audit.contract.ts +6 -0
- package/src/modules/auth/__tests__/auth.middleware.test.ts +30 -2
- package/src/modules/auth/__tests__/auth.routes.test.ts +71 -5
- package/src/modules/auth/__tests__/auth.service.test.ts +90 -9
- package/src/modules/auth/__tests__/password-change-sessions.pg.test.ts +269 -0
- package/src/modules/auth/account-admission.ts +19 -0
- package/src/modules/auth/auth.middleware.ts +45 -4
- package/src/modules/auth/auth.routes.ts +46 -29
- package/src/modules/auth/auth.service.ts +135 -34
- package/src/modules/auth/index.ts +2 -0
- package/src/modules/auth/oidc-auth-provider.ts +2 -9
- package/src/modules/code-mode/code-mode.tool.ts +2 -2
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +5 -24
- package/src/modules/connection-probe/connection-probe.service.ts +1 -34
- package/src/modules/database/__tests__/session-version-migration.pg.test.ts +133 -0
- package/src/modules/database/core-schema.ts +33 -0
- package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +44 -1
- package/src/modules/declared-variables/declared-variables.routes.ts +12 -1
- package/src/modules/embed/__tests__/embed.tools.test.ts +12 -0
- package/src/modules/embed/embed.tools.ts +5 -0
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +25 -0
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +53 -8
- package/src/modules/kb-fs/index.ts +2 -0
- package/src/modules/kb-fs/locking-filesystem.ts +7 -1
- package/src/modules/kb-fs/repo-path.ts +33 -7
- package/src/modules/kb-fs/tmp-ref.ts +81 -0
- package/src/modules/mcp/__tests__/connect-as.pg.test.ts +137 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +29 -5
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +8 -0
- package/src/modules/mcp/__tests__/mcp.connect-as.test.ts +195 -0
- package/src/modules/mcp/__tests__/mcp.routes.connect-as.test.ts +192 -0
- package/src/modules/mcp/__tests__/oauth-consent.connect-as.test.ts +146 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +19 -2
- package/src/modules/mcp/mcp.routes.ts +39 -3
- package/src/modules/mcp/mcp.service.ts +112 -52
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +46 -8
- package/src/modules/mcp/oauth/oauth-consent.routes.ts +41 -1
- package/src/modules/plugins/__tests__/plugins.tools.test.ts +38 -2
- package/src/modules/plugins/index.ts +1 -1
- package/src/modules/plugins/plugin-links.ts +19 -2
- package/src/modules/plugins/plugins.routes.ts +35 -4
- package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +113 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +430 -3
- package/src/modules/secrets-vault/__tests__/oauth-live-declaration.route.test.ts +218 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +39 -6
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +170 -27
- package/src/modules/secrets-vault/index.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.contract.ts +61 -11
- package/src/modules/secrets-vault/secrets-vault.routes.ts +114 -42
- package/src/modules/settings/oidc-check.ts +5 -18
- package/src/modules/skills/__tests__/skills.service.test.ts +34 -0
- package/src/modules/skills/index.ts +7 -2
- package/src/modules/skills/skills.contract.ts +17 -0
- package/src/modules/skills/skills.service.ts +5 -1
- package/src/modules/skills/skills.tools.ts +45 -14
- package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +4 -0
- package/src/modules/tool-auth/external-api-key.interface.ts +11 -1
- package/src/modules/tool-auth/external-api-key.service.ts +5 -2
- package/src/modules/tool-auth/index.ts +1 -1
- package/src/modules/tool-auth/internal-token.service.ts +10 -0
- package/src/modules/tool-auth/tool-auth.middleware.ts +45 -3
- package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +19 -11
- package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +3 -1
- package/src/modules/tool-helpers/__tests__/route-tool-schemas.catalog.test.ts +38 -0
- package/src/modules/tool-helpers/tool-context.ts +10 -0
- package/src/modules/tool-helpers/tool-def.ts +1 -1
- package/src/modules/tool-helpers/tool-handler.ts +34 -0
- package/src/modules/tool-helpers/tool.contract.ts +10 -0
- package/src/modules/tool-helpers/validate-token.ts +4 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +80 -4
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +197 -11
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +42 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +99 -8
- package/src/modules/tool-manuals/index.ts +6 -2
- package/src/modules/tool-manuals/mcp-json-discovery.ts +73 -34
- package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -6
- package/src/modules/tool-manuals/tool-manuals.routes.ts +40 -3
- package/src/modules/tool-manuals/tool-manuals.service.ts +169 -61
- package/src/modules/tool-manuals/tool-manuals.tools.ts +81 -25
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -113
- package/src/modules/tool-registry/manual.routes.ts +26 -5
- package/src/modules/tool-registry/tool.contract.ts +7 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.sync.test.ts +1 -1
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +54 -3
- package/src/modules/workflow/agent-tools/workflow.tools.ts +36 -27
- package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +130 -3
- package/src/modules/workflow/git/git.service.ts +29 -48
- package/src/modules/workflow/workflow.service.ts +5 -0
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -2
- package/src/modules/workspace/__tests__/holds-nul.test.ts +66 -0
- package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +3 -3
- package/src/modules/workspace/__tests__/ripgrep-search.test.ts +570 -0
- package/src/modules/workspace/__tests__/ripgrep-version.test.ts +29 -0
- package/src/modules/workspace/__tests__/workspace-tmp.tools.test.ts +928 -0
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +17 -37
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +20 -8
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +10 -12
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +22 -10
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +549 -162
- package/src/modules/workspace/agent-access.gate.ts +1 -1
- package/src/modules/workspace/file-readers/__tests__/doc-extract.service.test.ts +10 -0
- package/src/modules/workspace/file-readers/doc-extract.service.ts +71 -38
- package/src/modules/workspace/index.ts +20 -0
- package/src/modules/workspace/nul-scan.ts +50 -0
- package/src/modules/workspace/pattern-matcher.contract.ts +123 -32
- package/src/modules/workspace/ripgrep-search.ts +715 -0
- package/src/modules/workspace/ripgrep-version.ts +35 -0
- package/src/modules/workspace/startup/__tests__/beside-checkout.test.ts +11 -0
- package/src/modules/workspace/startup/beside-checkout.ts +13 -5
- package/src/modules/workspace/worker-pool.ts +3 -4
- package/src/modules/workspace/workspace-tmp.ts +457 -0
- package/src/modules/workspace/workspace.routes.ts +6 -27
- package/src/modules/workspace/workspace.service.ts +92 -15
- package/src/modules/workspace/workspace.tools.ts +574 -176
- package/src/shared/__tests__/read-capped.test.ts +68 -0
- package/src/shared/oauth-token-error.ts +46 -0
- package/src/shared/path-order.ts +37 -8
- package/src/shared/read-capped.ts +40 -0
- package/starter-packs/engineering/Plugins/engineering-starter/access.md +1 -1
- package/starter-packs/general/Plugins/general-starter/access.md +1 -1
- package/starter-packs/operations/Plugins/operations-starter/access.md +1 -1
- package/starter-packs/sales/Plugins/sales-starter/access.md +1 -1
- package/starter-packs/support/Plugins/support-starter/access.md +1 -1
- package/agent-guide/finding-things.md +0 -7
- package/agent-guide/introduction.md +0 -27
- package/dist/modules/workspace/bounded-matcher.d.ts +0 -73
- package/dist/modules/workspace/bounded-matcher.d.ts.map +0 -1
- package/dist/modules/workspace/bounded-matcher.js +0 -200
- package/dist/modules/workspace/bounded-matcher.js.map +0 -1
- package/src/modules/workspace/__tests__/bounded-matcher.test.ts +0 -213
- package/src/modules/workspace/bounded-matcher.ts +0 -271
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
import fsp, { mkdir, mkdtemp, readFile, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import AdmZip from 'adm-zip';
|
|
6
|
+
import express from 'express';
|
|
7
|
+
import * as XLSX from 'xlsx';
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { LocalFilesystem } from '@mastra/core/workspace';
|
|
10
|
+
import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
|
|
11
|
+
import { testKbContext, TEST_BRANCH_MODEL } from '../../../__tests__/kb-context.js';
|
|
12
|
+
import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
|
|
13
|
+
import { ToolRegistry } from '../../tool-registry/tool-registry.js';
|
|
14
|
+
import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
15
|
+
import type { ToolContext } from '../../tool-helpers/tool.contract.js';
|
|
16
|
+
import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
|
|
17
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
18
|
+
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
19
|
+
import { sharedFileRules, sharedFileRulesSection } from '../../agent-instructions/shared-file-rules.js';
|
|
20
|
+
import { agentGuideSections, WORKING_WITH_FILES_SECTION_ID } from '../../agent-guide/agent-guide.js';
|
|
21
|
+
import { PathNotFoundError } from '../../../shared/domain-errors.js';
|
|
22
|
+
import { registerWorkspaceTools } from '../workspace.tools.js';
|
|
23
|
+
import { RipgrepPatternMatcher } from '../ripgrep-search.js';
|
|
24
|
+
import { RoutineWritePolicyService } from '../routine-write-policy.js';
|
|
25
|
+
import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
|
|
26
|
+
import { ToolDescriptionNotes } from '../agent-access.gate.js';
|
|
27
|
+
import { SpillStore } from '../spill-store.js';
|
|
28
|
+
import { DocExtractService } from '../file-readers/doc-extract.service.js';
|
|
29
|
+
import { WorkerDocumentExtractor } from '../file-readers/extract-in-worker.js';
|
|
30
|
+
import type { WorkspaceService } from '../workspace.service.js';
|
|
31
|
+
import { TMP_MAX_AGE_MS, TmpSweeper } from '../workspace-tmp.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The reserved `__tmp__/` ref, end to end: the REAL tool routes, the REAL
|
|
35
|
+
* readers and extraction worker, and the REAL `WorkspaceService` resolver on
|
|
36
|
+
* a real workspaces root — a workspace directory holding its checkout and,
|
|
37
|
+
* beside it, the `tmp/` folder the ref reaches. Nothing about the folder is
|
|
38
|
+
* stubbed; only the git side of the service (cloning, locking, committing) is
|
|
39
|
+
* left out, as the other tool suites leave it out.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const KB_DIR = 'knowledge-base';
|
|
43
|
+
/** A draft: writes are not gated. */
|
|
44
|
+
const DRAFT = 'main';
|
|
45
|
+
/** A protected branch of the test branch model: writes are judged. */
|
|
46
|
+
const PROTECTED = TEST_BRANCH_MODEL.defaultBranch;
|
|
47
|
+
/** The calling user, whose staged files the ref reaches. */
|
|
48
|
+
const ME = 'u';
|
|
49
|
+
/** Somebody else, whose staged files it does not. */
|
|
50
|
+
const OTHER = 'v';
|
|
51
|
+
|
|
52
|
+
// ── fixtures: real bytes, built by the real libraries ────────────────────
|
|
53
|
+
|
|
54
|
+
function xlsxBytes(sheets: Record<string, unknown[][]>): Buffer {
|
|
55
|
+
const wb = XLSX.utils.book_new();
|
|
56
|
+
for (const [name, rows] of Object.entries(sheets)) XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), name);
|
|
57
|
+
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function docxBytes(...paragraphs: string[]): Buffer {
|
|
61
|
+
const zip = new AdmZip();
|
|
62
|
+
zip.addFile('[Content_Types].xml', Buffer.from('<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>'));
|
|
63
|
+
zip.addFile(
|
|
64
|
+
'word/document.xml',
|
|
65
|
+
Buffer.from(
|
|
66
|
+
'<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>' +
|
|
67
|
+
paragraphs.map((p) => `<w:p><w:r><w:t>${p}</w:t></w:r></w:p>`).join('') +
|
|
68
|
+
'</w:body></w:document>',
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
return zip.toBuffer();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pptxBytes(slides: string[][]): Buffer {
|
|
75
|
+
const zip = new AdmZip();
|
|
76
|
+
zip.addFile('[Content_Types].xml', Buffer.from('<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>'));
|
|
77
|
+
slides.forEach((paragraphs, i) => {
|
|
78
|
+
zip.addFile(
|
|
79
|
+
`ppt/slides/slide${i + 1}.xml`,
|
|
80
|
+
Buffer.from(
|
|
81
|
+
'<?xml version="1.0"?><p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:txBody>' +
|
|
82
|
+
paragraphs.map((p) => `<a:p><a:r><a:t>${p}</a:t></a:r></a:p>`).join('') +
|
|
83
|
+
'</p:txBody></p:sld>',
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
return zip.toBuffer();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Minimal valid one-page PDF with `text` as its text layer. */
|
|
91
|
+
function pdfBytes(text: string): Buffer {
|
|
92
|
+
const stream = `BT /F1 12 Tf 72 720 Td (${text}) Tj ET`;
|
|
93
|
+
const objects = [
|
|
94
|
+
'<< /Type /Catalog /Pages 2 0 R >>',
|
|
95
|
+
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
|
96
|
+
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
|
|
97
|
+
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
|
|
98
|
+
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
|
99
|
+
];
|
|
100
|
+
let out = '%PDF-1.4\n';
|
|
101
|
+
const offsets: number[] = [];
|
|
102
|
+
for (let i = 0; i < objects.length; i++) {
|
|
103
|
+
offsets.push(out.length);
|
|
104
|
+
out += `${i + 1} 0 obj\n${objects[i]}\nendobj\n`;
|
|
105
|
+
}
|
|
106
|
+
const xrefStart = out.length;
|
|
107
|
+
out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
|
108
|
+
for (const off of offsets) out += `${String(off).padStart(10, '0')} 00000 n \n`;
|
|
109
|
+
out += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`;
|
|
110
|
+
return Buffer.from(out, 'latin1');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const EML = Buffer.from(
|
|
114
|
+
[
|
|
115
|
+
'From: Ana <ana@example.com>',
|
|
116
|
+
'To: Bo <bo@example.com>',
|
|
117
|
+
'Subject: Quarterly plan',
|
|
118
|
+
'Date: Mon, 26 Oct 2026 09:00:00 +0000',
|
|
119
|
+
'Content-Type: text/plain; charset=utf-8',
|
|
120
|
+
'',
|
|
121
|
+
'The plan is attached in the drive.',
|
|
122
|
+
'',
|
|
123
|
+
].join('\r\n'),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
/** REAL `.msg` bytes: a CFB container carrying a subject and a plain body, as Outlook writes them. */
|
|
127
|
+
function msgBytes(subject: string, body: string): Buffer {
|
|
128
|
+
const cfb = XLSX.CFB.utils.cfb_new();
|
|
129
|
+
XLSX.CFB.utils.cfb_add(cfb, '/__substg1.0_0037001F', Buffer.from(subject, 'utf16le'));
|
|
130
|
+
XLSX.CFB.utils.cfb_add(cfb, '/__substg1.0_1000001F', Buffer.from(body, 'utf16le'));
|
|
131
|
+
return XLSX.CFB.write(cfb, { type: 'buffer' }) as Buffer;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A real, complete 1×1 transparent PNG. */
|
|
135
|
+
const PNG_1X1 = Buffer.from(
|
|
136
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
|
137
|
+
'base64',
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const SPREADSHEET = xlsxBytes({ Plan: [['Quarter', 'Revenue'], ['Q1', 100]], Notes: [['risk', 'supply chain']] });
|
|
141
|
+
|
|
142
|
+
// ── harness ───────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/** Allow-all access, except that writes under `Locked/` are refused on a protected branch. */
|
|
145
|
+
const access = {
|
|
146
|
+
canRead: async () => true,
|
|
147
|
+
canReadBatch: async (_w: string, _u: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
|
|
148
|
+
canWrite: async () => true,
|
|
149
|
+
canWriteBatch: async (_w: string, _u: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
|
|
150
|
+
canDownload: async () => true,
|
|
151
|
+
canOwner: async () => true,
|
|
152
|
+
canWriteBatchAtRef: async (_w: string, _r: string, _u: string, paths: string[]) =>
|
|
153
|
+
new Map(paths.map((p) => [p, !p.startsWith('Locked/')])),
|
|
154
|
+
eligibleWritersAtRef: async () => ({ roles: [], users: [] }),
|
|
155
|
+
// Records what each preview was asked, so a dry run can be checked for the
|
|
156
|
+
// staged text it hands over; the real reading is the access service's own
|
|
157
|
+
// test (`access-control.preview-relocation.test.ts`).
|
|
158
|
+
previewAccessAfterRelocation: async (...asked: unknown[]) => {
|
|
159
|
+
previews.push(asked);
|
|
160
|
+
return { read: true, write: true, download: true, owner: true };
|
|
161
|
+
},
|
|
162
|
+
} as unknown as IAccessControl;
|
|
163
|
+
const previews: unknown[][] = [];
|
|
164
|
+
|
|
165
|
+
let httpServer: HttpServer | undefined;
|
|
166
|
+
let root = '';
|
|
167
|
+
let docCacheDir = '';
|
|
168
|
+
let matcher: RipgrepPatternMatcher | undefined;
|
|
169
|
+
let extractor: WorkerDocumentExtractor | undefined;
|
|
170
|
+
let service: WorkspaceService;
|
|
171
|
+
|
|
172
|
+
/** The directory of `branch`'s workspace: its checkout and its `tmp/` folder side by side. */
|
|
173
|
+
const workspaceDir = (branch: string): string => join(root, encodeURIComponent(branch));
|
|
174
|
+
/** `owner`'s folder inside `branch`'s tmp: where the platform stages what that person attaches. */
|
|
175
|
+
const ownerDir = (branch: string, owner = ME): string => join(workspaceDir(branch), 'tmp', owner);
|
|
176
|
+
/** Stage `bytes` at `rel` in `owner`'s folder of `branch`'s tmp, straight on disk, as `stageTmpFile` lays it out. */
|
|
177
|
+
async function stage(rel: string, bytes: Buffer | string, branch = DRAFT, owner = ME): Promise<string> {
|
|
178
|
+
const abs = join(ownerDir(branch, owner), rel);
|
|
179
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
180
|
+
await writeFile(abs, bytes);
|
|
181
|
+
return abs;
|
|
182
|
+
}
|
|
183
|
+
/** Put `bytes` at a repository path of `branch`, straight on disk. */
|
|
184
|
+
async function commitFixture(rel: string, bytes: Buffer | string, branch = DRAFT): Promise<void> {
|
|
185
|
+
const abs = join(workspaceDir(branch), KB_DIR, rel);
|
|
186
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
187
|
+
await writeFile(abs, bytes);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function start(): Promise<string> {
|
|
191
|
+
root = await mkdtemp(join(tmpdir(), 'ws-tmp-ref-'));
|
|
192
|
+
docCacheDir = await mkdtemp(join(tmpdir(), 'ws-tmp-ref-cache-'));
|
|
193
|
+
for (const branch of [DRAFT, PROTECTED]) await mkdir(join(workspaceDir(branch), KB_DIR), { recursive: true });
|
|
194
|
+
service = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext(), new NodeFs());
|
|
195
|
+
matcher = new RipgrepPatternMatcher();
|
|
196
|
+
extractor = new WorkerDocumentExtractor();
|
|
197
|
+
const filesystems = new Map<string, LocalFilesystem>();
|
|
198
|
+
const resolve = async (auth: ToolAuth, signal: AbortSignal, sessionId?: string): Promise<ToolContext> => ({
|
|
199
|
+
user: { id: ME, email: 'e@x', name: 'N' },
|
|
200
|
+
scope: auth.scope,
|
|
201
|
+
source: auth.source,
|
|
202
|
+
sessionId,
|
|
203
|
+
abortSignal: signal,
|
|
204
|
+
// The tmp side is the real service's; the clone side is the workspace
|
|
205
|
+
// directory as it already stands on disk.
|
|
206
|
+
workspaceService: {
|
|
207
|
+
readFileBinary: (id: string, p: string) => service.readFileBinary(id, p),
|
|
208
|
+
readTmpFile: (id: string, owner: string, ref: string) => service.readTmpFile(id, owner, ref),
|
|
209
|
+
statTmpFile: (id: string, owner: string, ref: string) => service.statTmpFile(id, owner, ref),
|
|
210
|
+
getWorkspacePath: async (id: string) => join(root, id),
|
|
211
|
+
hasBootstrappedWorkspace: async () => false,
|
|
212
|
+
} as never,
|
|
213
|
+
workflowService: {} as never,
|
|
214
|
+
events: {} as never,
|
|
215
|
+
getFilesystem: async (branch: string) => {
|
|
216
|
+
let fs = filesystems.get(branch);
|
|
217
|
+
if (!fs) {
|
|
218
|
+
fs = new LocalFilesystem({ basePath: workspaceDir(branch), contained: true });
|
|
219
|
+
filesystems.set(branch, fs);
|
|
220
|
+
}
|
|
221
|
+
return fs;
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
const app = express();
|
|
225
|
+
app.use(express.json());
|
|
226
|
+
const router = express.Router();
|
|
227
|
+
const auth = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
|
228
|
+
req.toolAuth = { source: 'external', userId: ME, scope: 'write' };
|
|
229
|
+
next();
|
|
230
|
+
};
|
|
231
|
+
registerWorkspaceTools(
|
|
232
|
+
new ToolRegistry(),
|
|
233
|
+
router,
|
|
234
|
+
auth,
|
|
235
|
+
createToolHandlerFactory(resolve),
|
|
236
|
+
new SpillStore(join(tmpdir(), 'bevel-test-spills')),
|
|
237
|
+
new DocExtractService(docCacheDir, extractor),
|
|
238
|
+
access,
|
|
239
|
+
testKbContext({ kbDirName: KB_DIR }),
|
|
240
|
+
{ recoveryBotEmail: 'bot@x', hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
241
|
+
new RoutineWritePolicyService(),
|
|
242
|
+
{} as never,
|
|
243
|
+
undefined,
|
|
244
|
+
undefined,
|
|
245
|
+
// The upload store: enough of it that the upload tools are mounted, so
|
|
246
|
+
// their refusal of a ref is asserted rather than assumed.
|
|
247
|
+
{ maxBytes: 1, issue: async () => { throw new Error('not reached'); } } as never,
|
|
248
|
+
undefined,
|
|
249
|
+
// The download store, for the same reason: `request_file_download` is
|
|
250
|
+
// mounted, and its `paths` list is refused a ref like any other input.
|
|
251
|
+
{ assertCanIssue: () => { throw new Error('not reached'); } } as never,
|
|
252
|
+
undefined,
|
|
253
|
+
undefined,
|
|
254
|
+
matcher,
|
|
255
|
+
);
|
|
256
|
+
app.use('/api', router);
|
|
257
|
+
httpServer = await new Promise<HttpServer>((r) => {
|
|
258
|
+
const s = app.listen(0, () => r(s));
|
|
259
|
+
});
|
|
260
|
+
return `http://127.0.0.1:${(httpServer.address() as { port: number }).port}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- a JSON body read field by field
|
|
264
|
+
type Body = Record<string, any>;
|
|
265
|
+
async function call(base: string, tool: string, body: Record<string, unknown>): Promise<{ status: number; body: Body }> {
|
|
266
|
+
const res = await fetch(`${base}/api/agent/tools/${tool}`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer x' },
|
|
269
|
+
body: JSON.stringify({ branch: DRAFT, ...body }),
|
|
270
|
+
});
|
|
271
|
+
return { status: res.status, body: (await res.json()) as Body };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
afterEach(async () => {
|
|
275
|
+
if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
|
|
276
|
+
httpServer = undefined;
|
|
277
|
+
await matcher?.close();
|
|
278
|
+
await extractor?.close();
|
|
279
|
+
matcher = undefined;
|
|
280
|
+
extractor = undefined;
|
|
281
|
+
for (const dir of [root, docCacheDir]) if (dir) await rm(dir, { recursive: true, force: true });
|
|
282
|
+
root = '';
|
|
283
|
+
docCacheDir = '';
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── read_file ─────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
describe('read_file on a tmp ref answers exactly as the same file under a repository path', () => {
|
|
289
|
+
const CASES: Array<[string, Buffer, RegExp]> = [
|
|
290
|
+
['plan-a1b2c3d4e5f6.xlsx', SPREADSHEET, /\[sheet: Plan\][\s\S]*Q1[\s\S]*\[sheet: Notes\][\s\S]*supply chain/],
|
|
291
|
+
['brief.pdf', pdfBytes('Hello from the brief'), /\[page 1\][\s\S]*Hello from the brief/],
|
|
292
|
+
['deck.pptx', pptxBytes([['First slide'], ['Second slide']]), /\[slide 1\][\s\S]*First slide[\s\S]*\[slide 2\][\s\S]*Second slide/],
|
|
293
|
+
['memo.docx', docxBytes('A memo paragraph'), /A memo paragraph/],
|
|
294
|
+
['mail.eml', EML, /\[subject\] Quarterly plan[\s\S]*The plan is attached/],
|
|
295
|
+
['mail.msg', msgBytes('Outlook subject', 'Outlook body text'), /\[subject\] Outlook subject[\s\S]*Outlook body text/],
|
|
296
|
+
['notes.txt', Buffer.from('plain notes\n'), /^plain notes\n$/],
|
|
297
|
+
['page.md', Buffer.from('# Page\n\nbody\n'), /^# Page\n\nbody\n$/],
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
it.each(CASES)('%s', async (name, bytes, expected) => {
|
|
301
|
+
const base = await start();
|
|
302
|
+
await stage(`chat-uploads/${name}`, bytes);
|
|
303
|
+
await commitFixture(`KnowledgeBase/${name}`, bytes);
|
|
304
|
+
const ref = `__tmp__/chat-uploads/${name}`;
|
|
305
|
+
const repo = `${KB_DIR}/KnowledgeBase/${name}`;
|
|
306
|
+
|
|
307
|
+
const fromTmp = await call(base, 'read_file', { path: ref });
|
|
308
|
+
const fromRepo = await call(base, 'read_file', { path: repo });
|
|
309
|
+
|
|
310
|
+
expect(fromTmp.status).toBe(200);
|
|
311
|
+
expect(fromTmp.body.path).toBe(ref);
|
|
312
|
+
expect(fromTmp.body.content).toMatch(expected);
|
|
313
|
+
// The same reader and the same extraction: only the name it is shown under differs.
|
|
314
|
+
expect(fromTmp.body.content).toBe(fromRepo.body.content.replaceAll(repo, ref));
|
|
315
|
+
expect(fromTmp.body.canWrite).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('a .png answers as the image itself, as a repository image does', async () => {
|
|
319
|
+
const base = await start();
|
|
320
|
+
await stage('chat-uploads/logo.png', PNG_1X1);
|
|
321
|
+
await commitFixture('KnowledgeBase/logo.png', PNG_1X1);
|
|
322
|
+
|
|
323
|
+
const fromTmp = await call(base, 'read_file', { path: '__tmp__/chat-uploads/logo.png' });
|
|
324
|
+
const fromRepo = await call(base, 'read_file', { path: `${KB_DIR}/KnowledgeBase/logo.png` });
|
|
325
|
+
|
|
326
|
+
expect(fromTmp.status).toBe(200);
|
|
327
|
+
expect(fromTmp.body).toMatchObject({ kind: 'bevel/mcp-image@v1', mimeType: 'image/png', data: PNG_1X1.toString('base64') });
|
|
328
|
+
expect(fromTmp.body.note).toContain('__tmp__/chat-uploads/logo.png');
|
|
329
|
+
expect(fromTmp.body.note).toContain('canWrite: false');
|
|
330
|
+
expect(fromTmp.body.data).toBe(fromRepo.body.data);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('offset/limit slice a tmp file as they slice any other', async () => {
|
|
334
|
+
const base = await start();
|
|
335
|
+
await stage('notes.txt', 'abcdefghij');
|
|
336
|
+
expect((await call(base, 'read_file', { path: '__tmp__/notes.txt', offset: 2, limit: 3 })).body.content).toBe('cde');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('reads the workspace of the branch the call names, and no other', async () => {
|
|
340
|
+
const base = await start();
|
|
341
|
+
await stage('only-here.txt', 'protected copy', PROTECTED);
|
|
342
|
+
expect((await call(base, 'read_file', { branch: PROTECTED, path: '__tmp__/only-here.txt' })).body.content).toBe('protected copy');
|
|
343
|
+
expect((await call(base, 'read_file', { branch: DRAFT, path: '__tmp__/only-here.txt' })).status).toBe(404);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ── the ref names one file ───────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
describe('a tmp ref names one file inside the workspace\'s tmp folder', () => {
|
|
350
|
+
it('refuses the bare prefix, a folder, `..`, an absolute path and a control character, naming the rule', async () => {
|
|
351
|
+
const base = await start();
|
|
352
|
+
await stage('chat-uploads/plan.xlsx', SPREADSHEET);
|
|
353
|
+
await commitFixture('x', 'repository file');
|
|
354
|
+
const refused: Array<[string, RegExp]> = [
|
|
355
|
+
['__tmp__/', /names the tmp folder itself/],
|
|
356
|
+
['__tmp__', /names the tmp folder itself/],
|
|
357
|
+
['__tmp__/chat-uploads', /names a folder/],
|
|
358
|
+
['__tmp__/chat-uploads/', /names a folder/],
|
|
359
|
+
[`__tmp__/../${KB_DIR}/x`, /`\.\.` segment/],
|
|
360
|
+
['__tmp__/chat-uploads/../chat-uploads/plan.xlsx', /`\.\.` segment/],
|
|
361
|
+
['__tmp__//etc/passwd', /absolute/],
|
|
362
|
+
['__tmp__/C:/Windows/win.ini', /absolute/],
|
|
363
|
+
['__tmp__/a\\..\\b', /backslash/],
|
|
364
|
+
['__tmp__/chat-uploads/plan.xlsx\nzzz', /control character/],
|
|
365
|
+
];
|
|
366
|
+
for (const [ref, rule] of refused) {
|
|
367
|
+
const { status, body } = await call(base, 'read_file', { path: ref });
|
|
368
|
+
expect(status, ref).toBe(400);
|
|
369
|
+
expect(body.kind, ref).toBe('tmp-ref-invalid');
|
|
370
|
+
expect(body.error, ref).toMatch(/is not a valid tmp ref/);
|
|
371
|
+
expect(body.error, ref).toMatch(rule);
|
|
372
|
+
// Nothing of the repository file — or of anything else — came back.
|
|
373
|
+
expect(JSON.stringify(body), ref).not.toContain('repository file');
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('never follows a link out of the folder, another owner\'s folder included', async () => {
|
|
378
|
+
const base = await start();
|
|
379
|
+
await commitFixture('secret.md', 'repository secret');
|
|
380
|
+
await stage('chat-uploads/theirs.md', 'their secret', DRAFT, OTHER);
|
|
381
|
+
await mkdir(ownerDir(DRAFT), { recursive: true });
|
|
382
|
+
await symlink(join(workspaceDir(DRAFT), KB_DIR), join(ownerDir(DRAFT), 'kb'));
|
|
383
|
+
await symlink(join(workspaceDir(DRAFT), KB_DIR, 'secret.md'), join(ownerDir(DRAFT), 'secret.md'));
|
|
384
|
+
await symlink(ownerDir(DRAFT, OTHER), join(ownerDir(DRAFT), 'theirs'));
|
|
385
|
+
for (const ref of ['__tmp__/kb/secret.md', '__tmp__/secret.md', '__tmp__/theirs/chat-uploads/theirs.md']) {
|
|
386
|
+
const { status, body } = await call(base, 'read_file', { path: ref });
|
|
387
|
+
expect(status, ref).toBe(400);
|
|
388
|
+
expect(body.error, ref).toMatch(/link that leads out of your tmp folder/);
|
|
389
|
+
expect(JSON.stringify(body), ref).not.toContain('repository secret');
|
|
390
|
+
expect(JSON.stringify(body), ref).not.toContain('their secret');
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('a file that does not exist is the ordinary not-found, naming the ref and never a disk path', async () => {
|
|
395
|
+
const base = await start();
|
|
396
|
+
await stage('other.txt', 'x');
|
|
397
|
+
for (const ref of ['__tmp__/chat-uploads/gone.xlsx', '__tmp__/other.txt/below']) {
|
|
398
|
+
const { status, body } = await call(base, 'read_file', { path: ref });
|
|
399
|
+
expect(status, ref).toBe(404);
|
|
400
|
+
expect(body.kind, ref).toBe('not_found');
|
|
401
|
+
expect(body.path, ref).toBe(ref);
|
|
402
|
+
expect(body.error, ref).toContain(`"${ref}"`);
|
|
403
|
+
expect(JSON.stringify(body), ref).not.toContain(root);
|
|
404
|
+
}
|
|
405
|
+
// A workspace with no tmp folder at all answers the same.
|
|
406
|
+
const none = await call(base, 'read_file', { branch: PROTECTED, path: '__tmp__/x.txt' });
|
|
407
|
+
expect(none.status).toBe(404);
|
|
408
|
+
expect(none.body.path).toBe('__tmp__/x.txt');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('a loop of links is refused by the link rule, naming the ref and never a disk path', async () => {
|
|
412
|
+
const base = await start();
|
|
413
|
+
await mkdir(ownerDir(DRAFT), { recursive: true });
|
|
414
|
+
await symlink(join(ownerDir(DRAFT), 'loop'), join(ownerDir(DRAFT), 'loop'));
|
|
415
|
+
const { status, body } = await call(base, 'read_file', { path: '__tmp__/loop' });
|
|
416
|
+
expect(status).toBe(400);
|
|
417
|
+
expect(body.kind).toBe('tmp-ref-invalid');
|
|
418
|
+
expect(body.error).toMatch(/link that cannot be followed/);
|
|
419
|
+
expect(JSON.stringify(body)).not.toContain(root);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('a file the disk will not let it read answers a 500 naming the ref alone, in all four tools', async () => {
|
|
423
|
+
const base = await start();
|
|
424
|
+
await stage('chat-uploads/locked.txt', 'x');
|
|
425
|
+
const realpath = fsp.realpath.bind(fsp);
|
|
426
|
+
// A permission failure carries the path on disk in its message, as the
|
|
427
|
+
// filesystem raises it.
|
|
428
|
+
const spy = vi.spyOn(fsp, 'realpath').mockImplementation((async (p: string, ...rest: unknown[]) => {
|
|
429
|
+
if (String(p).endsWith('locked.txt')) {
|
|
430
|
+
throw Object.assign(new Error(`EACCES: permission denied, realpath '${String(p)}'`), { code: 'EACCES' });
|
|
431
|
+
}
|
|
432
|
+
return (realpath as (...a: unknown[]) => Promise<string>)(p, ...rest);
|
|
433
|
+
}) as typeof fsp.realpath);
|
|
434
|
+
try {
|
|
435
|
+
const ref = '__tmp__/chat-uploads/locked.txt';
|
|
436
|
+
const calls: Array<[string, Record<string, unknown>]> = [
|
|
437
|
+
['read_file', { path: ref }],
|
|
438
|
+
['file_stat', { path: ref }],
|
|
439
|
+
['grep', { path: ref, pattern: 'x' }],
|
|
440
|
+
['copy_file', { src: ref, dest: 'locked.txt' }],
|
|
441
|
+
];
|
|
442
|
+
for (const [tool, args] of calls) {
|
|
443
|
+
const { status, body } = await call(base, tool, args);
|
|
444
|
+
expect(status, tool).toBe(500);
|
|
445
|
+
expect(body.kind, tool).toBe('tmp-ref-unreadable');
|
|
446
|
+
expect(body.path, tool).toBe(ref);
|
|
447
|
+
expect(body.error, tool).toContain(`"${ref}" could not be read`);
|
|
448
|
+
expect(JSON.stringify(body), tool).not.toContain(root);
|
|
449
|
+
expect(JSON.stringify(body), tool).not.toContain('EACCES');
|
|
450
|
+
}
|
|
451
|
+
} finally {
|
|
452
|
+
spy.mockRestore();
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('a repository folder spelled like the ref stays a repository path', async () => {
|
|
457
|
+
const base = await start();
|
|
458
|
+
await commitFixture('__tmp__/kept.md', 'in the repository');
|
|
459
|
+
await stage('kept.md', 'in tmp');
|
|
460
|
+
expect((await call(base, 'read_file', { path: `${KB_DIR}/__tmp__/kept.md` })).body.content).toBe('in the repository');
|
|
461
|
+
expect((await call(base, 'read_file', { path: '__tmp__/kept.md' })).body.content).toBe('in tmp');
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ── file_stat, grep ──────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
describe('file_stat and grep on a tmp ref', () => {
|
|
468
|
+
it('file_stat answers a repository file\'s shape, with canWrite false', async () => {
|
|
469
|
+
const base = await start();
|
|
470
|
+
await stage('chat-uploads/plan.xlsx', SPREADSHEET);
|
|
471
|
+
await commitFixture('KnowledgeBase/plan.xlsx', SPREADSHEET);
|
|
472
|
+
const tmp = (await call(base, 'file_stat', { path: '__tmp__/chat-uploads/plan.xlsx' })).body;
|
|
473
|
+
const repo = (await call(base, 'file_stat', { path: `${KB_DIR}/KnowledgeBase/plan.xlsx` })).body;
|
|
474
|
+
for (const key of ['name', 'type', 'size', 'contentMode', 'kind', 'mime', 'mimeSource']) {
|
|
475
|
+
expect(tmp[key], key).toEqual(repo[key]);
|
|
476
|
+
}
|
|
477
|
+
expect(tmp).toMatchObject({ size: SPREADSHEET.length, contentMode: 'document', kind: 'document', canWrite: false, textEditable: false, movable: false, deletable: false });
|
|
478
|
+
expect(tmp.access).toEqual({ read: true, write: false, download: false, owner: false });
|
|
479
|
+
expect(typeof tmp.modifiedAt).toBe('string');
|
|
480
|
+
expect(JSON.stringify(tmp)).not.toContain(root);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('file_stat on a text, an image and a missing ref', async () => {
|
|
484
|
+
const base = await start();
|
|
485
|
+
await stage('notes.md', '# notes\n');
|
|
486
|
+
await stage('logo.png', PNG_1X1);
|
|
487
|
+
expect((await call(base, 'file_stat', { path: '__tmp__/notes.md' })).body).toMatchObject({ contentMode: 'text', kind: 'text', textEditable: false });
|
|
488
|
+
expect((await call(base, 'file_stat', { path: '__tmp__/logo.png' })).body).toMatchObject({ kind: 'image', mime: 'image/png' });
|
|
489
|
+
const missing = await call(base, 'file_stat', { path: '__tmp__/nope.md' });
|
|
490
|
+
expect(missing.status).toBe(404);
|
|
491
|
+
expect(missing.body.path).toBe('__tmp__/nope.md');
|
|
492
|
+
expect((await call(base, 'file_stat', { path: '__tmp__/' })).status).toBe(400);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('grep with path set to a ref searches that one file, through its extraction', async () => {
|
|
496
|
+
const base = await start();
|
|
497
|
+
await stage('chat-uploads/plan.xlsx', SPREADSHEET);
|
|
498
|
+
await stage('chat-uploads/other.txt', 'supply chain in another file\n');
|
|
499
|
+
await commitFixture('KnowledgeBase/also.md', 'supply chain in the repository\n');
|
|
500
|
+
const { status, body } = await call(base, 'grep', { path: '__tmp__/chat-uploads/plan.xlsx', pattern: 'supply chain' });
|
|
501
|
+
expect(status).toBe(200);
|
|
502
|
+
expect(body.matches).toHaveLength(1);
|
|
503
|
+
expect(body.matches[0]).toMatchObject({ path: '__tmp__/chat-uploads/plan.xlsx' });
|
|
504
|
+
expect(body.matches[0].text).toContain('supply chain');
|
|
505
|
+
// The line number is the one read_file's extraction gives the line.
|
|
506
|
+
const read = (await call(base, 'read_file', { path: '__tmp__/chat-uploads/plan.xlsx' })).body.content as string;
|
|
507
|
+
expect(read.split('\n')[body.matches[0].line - 1]).toBe(body.matches[0].text);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('grep on a folder ref or a missing ref is refused, never an empty result', async () => {
|
|
511
|
+
const base = await start();
|
|
512
|
+
await stage('chat-uploads/a.txt', 'x');
|
|
513
|
+
expect((await call(base, 'grep', { path: '__tmp__/chat-uploads', pattern: 'x' })).status).toBe(400);
|
|
514
|
+
const missing = await call(base, 'grep', { path: '__tmp__/chat-uploads/b.txt', pattern: 'x' });
|
|
515
|
+
expect(missing.status).toBe(404);
|
|
516
|
+
expect(missing.body.path).toBe('__tmp__/chat-uploads/b.txt');
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// ── copy_file out of tmp ─────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
describe('copy_file from a tmp ref to a repository path', () => {
|
|
523
|
+
it('places the bytes in the repository, a normal repository file from then on', async () => {
|
|
524
|
+
const base = await start();
|
|
525
|
+
await stage('chat-uploads/plan-a1b2c3d4e5f6.xlsx', SPREADSHEET);
|
|
526
|
+
const ref = '__tmp__/chat-uploads/plan-a1b2c3d4e5f6.xlsx';
|
|
527
|
+
const dest = 'KnowledgeBase/Sales/Plan.xlsx';
|
|
528
|
+
|
|
529
|
+
const dry = await call(base, 'copy_file', { src: ref, dest, dryRun: true });
|
|
530
|
+
expect(dry.body).toMatchObject({ kind: 'file', descendants: 1, allowed: true, dryRun: true, copied: false });
|
|
531
|
+
|
|
532
|
+
const { status, body } = await call(base, 'copy_file', { src: ref, dest });
|
|
533
|
+
expect(status).toBe(200);
|
|
534
|
+
expect(body).toMatchObject({ src: ref, dest: `${KB_DIR}/${dest}`, copied: true });
|
|
535
|
+
expect(await readFile(join(workspaceDir(DRAFT), KB_DIR, dest))).toEqual(SPREADSHEET);
|
|
536
|
+
|
|
537
|
+
// Read back at the repository path: the same extraction, now writable,
|
|
538
|
+
// and the staged file is where it was.
|
|
539
|
+
const repoRead = (await call(base, 'read_file', { path: dest })).body;
|
|
540
|
+
const tmpRead = (await call(base, 'read_file', { path: ref })).body;
|
|
541
|
+
expect(repoRead.content).toBe((tmpRead.content as string).replaceAll(ref, `${KB_DIR}/${dest}`));
|
|
542
|
+
expect(repoRead.canWrite).toBe(true);
|
|
543
|
+
expect((await call(base, 'file_stat', { path: dest })).body).toMatchObject({ managed: false, movable: true, deletable: true, textEditable: false });
|
|
544
|
+
expect(await readFile(join(ownerDir(DRAFT), 'chat-uploads/plan-a1b2c3d4e5f6.xlsx'))).toEqual(SPREADSHEET);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('the dry run previews the access the staged file carries in, as a repository copy does', async () => {
|
|
548
|
+
const base = await start();
|
|
549
|
+
const staged = '---\nread:\n - deny everyone\n---\n# Staged\n';
|
|
550
|
+
await stage('a.md', staged);
|
|
551
|
+
previews.length = 0;
|
|
552
|
+
|
|
553
|
+
const dry = await call(base, 'copy_file', { src: '__tmp__/a.md', dest: 'KnowledgeBase/a.md', dryRun: true });
|
|
554
|
+
|
|
555
|
+
expect(dry.status).toBe(200);
|
|
556
|
+
// The ref as the source, its text as what travels: the same relocation
|
|
557
|
+
// reading a repository source gets, never "nothing travels".
|
|
558
|
+
expect(previews).toEqual([
|
|
559
|
+
[expect.any(String), 'e@x', '__tmp__/a.md', 'KnowledgeBase/a.md', { sourceRemains: true, sourceContent: staged }],
|
|
560
|
+
]);
|
|
561
|
+
expect(dry.body.access.after).toEqual({ read: true, write: true, download: true, owner: true });
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('never replaces what is at the destination', async () => {
|
|
565
|
+
const base = await start();
|
|
566
|
+
await stage('a.md', 'staged');
|
|
567
|
+
await commitFixture('KnowledgeBase/a.md', 'existing');
|
|
568
|
+
const { status, body } = await call(base, 'copy_file', { src: '__tmp__/a.md', dest: 'KnowledgeBase/a.md' });
|
|
569
|
+
expect(status).toBe(409);
|
|
570
|
+
expect(body.error).toContain('already exists');
|
|
571
|
+
expect(await readFile(join(workspaceDir(DRAFT), KB_DIR, 'KnowledgeBase/a.md'), 'utf8')).toBe('existing');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('to a path the caller may not write: the refusal any repository write gives, and nothing lands', async () => {
|
|
575
|
+
const base = await start();
|
|
576
|
+
await stage('a.md', 'staged', PROTECTED);
|
|
577
|
+
await commitFixture('KnowledgeBase/a.md', 'repository', PROTECTED);
|
|
578
|
+
const fromTmp = await call(base, 'copy_file', { branch: PROTECTED, src: '__tmp__/a.md', dest: 'Locked/a.md' });
|
|
579
|
+
const fromRepo = await call(base, 'copy_file', { branch: PROTECTED, src: 'KnowledgeBase/a.md', dest: 'Locked/a.md' });
|
|
580
|
+
expect(fromTmp.status).toBe(403);
|
|
581
|
+
expect(fromTmp.status).toBe(fromRepo.status);
|
|
582
|
+
expect(fromTmp.body.kind).toBe('write-denied');
|
|
583
|
+
expect(fromTmp.body.error).toBe(fromRepo.body.error);
|
|
584
|
+
// Asked before the source is: a missing staged file is refused the same way.
|
|
585
|
+
const missing = await call(base, 'copy_file', { branch: PROTECTED, src: '__tmp__/gone.md', dest: 'Locked/a.md' });
|
|
586
|
+
expect(missing.body.error).toBe(fromRepo.body.error);
|
|
587
|
+
await expect(stat(join(workspaceDir(PROTECTED), KB_DIR, 'Locked/a.md'))).rejects.toThrow();
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('a dry run to a path the caller may not write is refused before the staged bytes are read', async () => {
|
|
591
|
+
const base = await start();
|
|
592
|
+
await stage('a.md', '---\nread:\n - deny everyone\n---\n# Staged\n', PROTECTED);
|
|
593
|
+
previews.length = 0;
|
|
594
|
+
const read = vi.spyOn(service, 'readTmpFile');
|
|
595
|
+
try {
|
|
596
|
+
const dry = await call(base, 'copy_file', { branch: PROTECTED, src: '__tmp__/a.md', dest: 'Locked/a.md', dryRun: true });
|
|
597
|
+
expect(dry.status).toBe(200);
|
|
598
|
+
expect(dry.body).toMatchObject({ allowed: false, dryRun: true, copied: false });
|
|
599
|
+
expect(dry.body.kind).toBeUndefined();
|
|
600
|
+
expect(read).not.toHaveBeenCalled();
|
|
601
|
+
// Nothing of the source travels into a denied preview.
|
|
602
|
+
expect(previews).toEqual([
|
|
603
|
+
[expect.any(String), 'e@x', '__tmp__/a.md', 'Locked/a.md', { sourceRemains: true, sourceContent: '' }],
|
|
604
|
+
]);
|
|
605
|
+
} finally {
|
|
606
|
+
read.mockRestore();
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('a missing staged file is not-found naming the ref', async () => {
|
|
611
|
+
const base = await start();
|
|
612
|
+
const { status, body } = await call(base, 'copy_file', { src: '__tmp__/gone.xlsx', dest: 'KnowledgeBase/x.xlsx' });
|
|
613
|
+
expect(status).toBe(404);
|
|
614
|
+
expect(body.path).toBe('__tmp__/gone.xlsx');
|
|
615
|
+
expect(JSON.stringify(body)).not.toContain(root);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// ── read-only through the ref ────────────────────────────────────────────
|
|
620
|
+
|
|
621
|
+
describe('every writing tool refuses a tmp ref', () => {
|
|
622
|
+
const REF = '__tmp__/chat-uploads/plan.xlsx';
|
|
623
|
+
const CALLS: Array<[string, Record<string, unknown>]> = [
|
|
624
|
+
['write_file', { path: REF, content: 'x' }],
|
|
625
|
+
['write_files', { files: [{ path: REF, content: 'x' }] }],
|
|
626
|
+
['edit_file', { path: REF, old_string: 'a', new_string: 'b' }],
|
|
627
|
+
['move_file', { src: REF, dest: 'KnowledgeBase/moved.xlsx' }],
|
|
628
|
+
['move_file', { src: 'KnowledgeBase/a.md', dest: REF }],
|
|
629
|
+
['copy_file', { src: 'KnowledgeBase/a.md', dest: REF }],
|
|
630
|
+
['delete_file', { path: REF }],
|
|
631
|
+
['delete_folder', { path: '__tmp__/chat-uploads', confirm: true }],
|
|
632
|
+
['mkdir', { path: '__tmp__/new' }],
|
|
633
|
+
['unzip', { path: '__tmp__/archive.zip' }],
|
|
634
|
+
['apply_file_upload', { token: 'x', destination: '__tmp__/chat-uploads' }],
|
|
635
|
+
['list_files', { path: '__tmp__/chat-uploads' }],
|
|
636
|
+
['request_file_download', { paths: ['KnowledgeBase/a.md', REF] }],
|
|
637
|
+
];
|
|
638
|
+
|
|
639
|
+
it.each(CALLS)('%s %j', async (tool, args) => {
|
|
640
|
+
const base = await start();
|
|
641
|
+
const staged = await stage('chat-uploads/plan.xlsx', SPREADSHEET);
|
|
642
|
+
await commitFixture('KnowledgeBase/a.md', 'a');
|
|
643
|
+
const { status, body } = await call(base, tool, args);
|
|
644
|
+
expect(status).toBe(400);
|
|
645
|
+
expect(body.kind).toBe('tmp-ref-read-only');
|
|
646
|
+
expect(body.error).toContain('read-only through the ref');
|
|
647
|
+
expect(body.error).toContain('copy_file');
|
|
648
|
+
expect(body.error).toContain(tool);
|
|
649
|
+
// Untouched, and nothing appeared in the repository under the ref's name.
|
|
650
|
+
expect(await readFile(staged)).toEqual(SPREADSHEET);
|
|
651
|
+
await expect(stat(join(workspaceDir(DRAFT), KB_DIR, '__tmp__'))).rejects.toThrow();
|
|
652
|
+
expect(await readFile(join(workspaceDir(DRAFT), KB_DIR, 'KnowledgeBase/a.md'), 'utf8')).toBe('a');
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('request_file_upload takes no path, so it has no ref to refuse', async () => {
|
|
656
|
+
const base = await start();
|
|
657
|
+
expect((await call(base, 'request_file_upload', { path: REF })).status).toBe(400);
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// ── the ref reaches the caller's own files ───────────────────────────────
|
|
662
|
+
|
|
663
|
+
describe("a tmp ref reaches the caller's own staged files and nobody else's", () => {
|
|
664
|
+
it('what somebody else staged is not there: the plain not-found, naming the ref, from every tool that takes one', async () => {
|
|
665
|
+
const base = await start();
|
|
666
|
+
await stage('chat-uploads/notes.txt', 'theirs\n', DRAFT, OTHER);
|
|
667
|
+
await stage('chat-uploads/notes.txt', 'theirs\n', PROTECTED, OTHER);
|
|
668
|
+
const ref = '__tmp__/chat-uploads/notes.txt';
|
|
669
|
+
const never = '__tmp__/chat-uploads/never.txt';
|
|
670
|
+
for (const [tool, args] of [
|
|
671
|
+
['read_file', { path: ref }],
|
|
672
|
+
['file_stat', { path: ref }],
|
|
673
|
+
['grep', { path: ref, pattern: 'theirs' }],
|
|
674
|
+
['copy_file', { src: ref, dest: 'KnowledgeBase/notes.txt' }],
|
|
675
|
+
] as Array<[string, Record<string, unknown>]>) {
|
|
676
|
+
const { status, body } = await call(base, tool, args);
|
|
677
|
+
expect(status, tool).toBe(404);
|
|
678
|
+
expect(body.kind, tool).toBe('not_found');
|
|
679
|
+
expect(body.path, tool).toBe(ref);
|
|
680
|
+
// The same answer, word for word, as for a file nobody ever staged.
|
|
681
|
+
const nobody = await call(base, tool, { ...args, ...(tool === 'copy_file' ? { src: never } : { path: never }) });
|
|
682
|
+
expect(nobody.status, tool).toBe(404);
|
|
683
|
+
expect(body.error.replaceAll(ref, ''), tool).toBe(nobody.body.error.replaceAll(never, ''));
|
|
684
|
+
expect(JSON.stringify(body), tool).not.toContain('theirs');
|
|
685
|
+
}
|
|
686
|
+
// Nothing of theirs was copied, and the same ref IS the caller's own file once they stage one.
|
|
687
|
+
await expect(stat(join(workspaceDir(DRAFT), KB_DIR, 'KnowledgeBase/notes.txt'))).rejects.toThrow();
|
|
688
|
+
await stage('chat-uploads/notes.txt', 'mine\n');
|
|
689
|
+
expect((await call(base, 'read_file', { path: ref })).body.content).toBe('mine\n');
|
|
690
|
+
expect((await call(base, 'grep', { path: ref, pattern: 'mine' })).body.matches).toHaveLength(1);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it('the caller cannot reach another owner by spelling their folder into the ref', async () => {
|
|
694
|
+
const base = await start();
|
|
695
|
+
await stage('chat-uploads/plan.xlsx', SPREADSHEET, DRAFT, OTHER);
|
|
696
|
+
for (const ref of [`__tmp__/../${OTHER}/chat-uploads/plan.xlsx`, `__tmp__/${OTHER}/chat-uploads/plan.xlsx`]) {
|
|
697
|
+
const { status, body } = await call(base, 'read_file', { path: ref });
|
|
698
|
+
expect([400, 404], ref).toContain(status);
|
|
699
|
+
expect(JSON.stringify(body), ref).not.toContain('supply chain');
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('WorkspaceService.stageTmpFile lays a file out where the owner, and only the owner, reads it back', async () => {
|
|
704
|
+
await start();
|
|
705
|
+
const id = encodeURIComponent(DRAFT);
|
|
706
|
+
const ref = await service.stageTmpFile(id, ME, 'chat-uploads/plan.xlsx', SPREADSHEET);
|
|
707
|
+
expect(ref).toBe('__tmp__/chat-uploads/plan.xlsx');
|
|
708
|
+
expect(await readFile(join(ownerDir(DRAFT), 'chat-uploads/plan.xlsx'))).toEqual(SPREADSHEET);
|
|
709
|
+
expect(await service.readTmpFile(id, ME, ref)).toEqual(SPREADSHEET);
|
|
710
|
+
expect((await service.statTmpFile(id, ME, ref)).size).toBe(SPREADSHEET.length);
|
|
711
|
+
await expect(service.readTmpFile(id, OTHER, ref)).rejects.toBeInstanceOf(PathNotFoundError);
|
|
712
|
+
await expect(service.readTmpFile(id, ME, '__tmp__/chat-uploads')).rejects.toThrow(/names a folder/);
|
|
713
|
+
await expect(service.readTmpFile(id, ME, `__tmp__/../${KB_DIR}/x`)).rejects.toThrow(/`\.\.` segment/);
|
|
714
|
+
await expect(service.readTmpFile(id, ME, '__tmp__/gone.xlsx')).rejects.toMatchObject({ path: '__tmp__/gone.xlsx' });
|
|
715
|
+
// Staging again replaces: a staged file is the stager's own scratch.
|
|
716
|
+
await service.stageTmpFile(id, ME, 'chat-uploads/plan.xlsx', Buffer.from('v2'));
|
|
717
|
+
expect((await service.readTmpFile(id, ME, ref)).toString()).toBe('v2');
|
|
718
|
+
// A branch never cloned has no workspace directory yet: a staged file
|
|
719
|
+
// belongs beside where its checkout would be, so staging makes the way.
|
|
720
|
+
const uncloned = encodeURIComponent('nobody/not-cloned');
|
|
721
|
+
await expect(stat(workspaceDir('nobody/not-cloned'))).rejects.toThrow();
|
|
722
|
+
const staged = await service.stageTmpFile(uncloned, ME, 'chat-uploads/first.txt', Buffer.from('first'));
|
|
723
|
+
expect((await service.readTmpFile(uncloned, ME, staged)).toString()).toBe('first');
|
|
724
|
+
// The path obeys the ref's rules, and the refusal names the one it breaks.
|
|
725
|
+
await expect(service.stageTmpFile(id, ME, '../escape.txt', Buffer.from('x'))).rejects.toThrow(/`\.\.` segment/);
|
|
726
|
+
await expect(service.stageTmpFile(id, ME, '/abs.txt', Buffer.from('x'))).rejects.toThrow(/absolute/);
|
|
727
|
+
await expect(service.stageTmpFile(id, ME, 'a\\b.txt', Buffer.from('x'))).rejects.toThrow(/backslash/);
|
|
728
|
+
await expect(service.stageTmpFile(id, ME, 'folder/', Buffer.from('x'))).rejects.toThrow(/names a folder/);
|
|
729
|
+
await expect(stat(join(workspaceDir(DRAFT), 'tmp', 'escape.txt'))).rejects.toThrow();
|
|
730
|
+
// A workspace id that names no single workspace directory reaches nothing.
|
|
731
|
+
for (const bad of ['..', '.', '']) {
|
|
732
|
+
await expect(service.readTmpFile(bad, ME, '__tmp__/chat-uploads/plan.xlsx')).rejects.toThrow();
|
|
733
|
+
}
|
|
734
|
+
// An owner id that is not one path segment is a programming error, not a folder to make.
|
|
735
|
+
for (const bad of ['', '.', '..', 'a/b', 'a\\b']) {
|
|
736
|
+
await expect(service.stageTmpFile(id, bad, 'x.txt', Buffer.from('x')), bad).rejects.toThrow(/owner id/);
|
|
737
|
+
await expect(service.readTmpFile(id, bad, '__tmp__/x.txt'), bad).rejects.toThrow(/owner id/);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("never stages through a link: not on the way, not at the file's own name, not where the owner's folder or tmp/ should be", async () => {
|
|
742
|
+
await start();
|
|
743
|
+
const id = encodeURIComponent(DRAFT);
|
|
744
|
+
await commitFixture('secret.md', 'repository secret');
|
|
745
|
+
await mkdir(ownerDir(DRAFT), { recursive: true });
|
|
746
|
+
// A link on the way.
|
|
747
|
+
await symlink(join(workspaceDir(DRAFT), KB_DIR), join(ownerDir(DRAFT), 'kb'));
|
|
748
|
+
await expect(service.stageTmpFile(id, ME, 'kb/planted.md', Buffer.from('x'))).rejects.toThrow(/link that leads out/);
|
|
749
|
+
await expect(stat(join(workspaceDir(DRAFT), KB_DIR, 'planted.md'))).rejects.toThrow();
|
|
750
|
+
// Nor are folders made through it: the link is refused before anything
|
|
751
|
+
// beyond it is created, so nothing lands where the link leads.
|
|
752
|
+
await expect(service.stageTmpFile(id, ME, 'kb/deep/er/planted.md', Buffer.from('x'))).rejects.toThrow(/link that leads out/);
|
|
753
|
+
await expect(stat(join(workspaceDir(DRAFT), KB_DIR, 'deep'))).rejects.toThrow();
|
|
754
|
+
// A link at the file's own name: a write would follow it into the repository.
|
|
755
|
+
await symlink(join(workspaceDir(DRAFT), KB_DIR, 'secret.md'), join(ownerDir(DRAFT), 'secret.md'));
|
|
756
|
+
await expect(service.stageTmpFile(id, ME, 'secret.md', Buffer.from('overwritten'))).rejects.toThrow(/not a file/);
|
|
757
|
+
expect(await readFile(join(workspaceDir(DRAFT), KB_DIR, 'secret.md'), 'utf8')).toBe('repository secret');
|
|
758
|
+
// A link standing where the owner's folder should be: both real paths
|
|
759
|
+
// would agree about the link's target, so it is refused before any of that.
|
|
760
|
+
await symlink(join(workspaceDir(DRAFT), KB_DIR), ownerDir(DRAFT, OTHER));
|
|
761
|
+
await expect(service.stageTmpFile(id, OTHER, 'planted.md', Buffer.from('x'))).rejects.toThrow(/not a folder of this workspace/);
|
|
762
|
+
await expect(service.readTmpFile(id, OTHER, '__tmp__/secret.md')).rejects.toThrow(/not a folder of this workspace/);
|
|
763
|
+
await expect(stat(join(workspaceDir(DRAFT), KB_DIR, 'planted.md'))).rejects.toThrow();
|
|
764
|
+
// A link standing where `tmp/` itself should be, in a workspace with none yet.
|
|
765
|
+
await symlink(join(workspaceDir(PROTECTED), KB_DIR), join(workspaceDir(PROTECTED), 'tmp'));
|
|
766
|
+
const other = encodeURIComponent(PROTECTED);
|
|
767
|
+
await expect(service.stageTmpFile(other, ME, 'planted.md', Buffer.from('x'))).rejects.toThrow(/not a folder of this workspace/);
|
|
768
|
+
await expect(service.readTmpFile(other, ME, '__tmp__/planted.md')).rejects.toThrow(/not a folder of this workspace/);
|
|
769
|
+
await expect(stat(join(workspaceDir(PROTECTED), KB_DIR, 'planted.md'))).rejects.toThrow();
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// ── the seven-day sweep ──────────────────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
describe('the periodic sweep of every workspace\'s tmp folder', () => {
|
|
776
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
777
|
+
const age = async (abs: string, days: number) => {
|
|
778
|
+
const when = new Date(Date.now() - days * DAY);
|
|
779
|
+
await utimes(abs, when, when);
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
it('removes an entry last modified eight days ago and keeps one modified yesterday, in every workspace and for every owner', async () => {
|
|
783
|
+
await start();
|
|
784
|
+
const old = await stage('chat-uploads/old.xlsx', SPREADSHEET);
|
|
785
|
+
const fresh = await stage('chat-uploads/fresh.xlsx', SPREADSHEET);
|
|
786
|
+
const theirs = await stage('chat-uploads/theirs.xlsx', SPREADSHEET, DRAFT, OTHER);
|
|
787
|
+
const oldFolder = await stage('sharepoint/abc/doc.docx', docxBytes('x'), PROTECTED);
|
|
788
|
+
await age(old, 8);
|
|
789
|
+
await age(fresh, 1);
|
|
790
|
+
await age(theirs, 8);
|
|
791
|
+
await age(oldFolder, 8);
|
|
792
|
+
await age(dirname(oldFolder), 8);
|
|
793
|
+
await age(join(ownerDir(PROTECTED), 'sharepoint'), 8);
|
|
794
|
+
// `chat-uploads/` itself is young (an upload landed in it yesterday): the
|
|
795
|
+
// old file in it goes all the same, judged on its own.
|
|
796
|
+
const { removed } = await service.sweepTmp();
|
|
797
|
+
await expect(stat(old)).rejects.toThrow();
|
|
798
|
+
await expect(stat(theirs)).rejects.toThrow();
|
|
799
|
+
expect(await readFile(fresh)).toEqual(SPREADSHEET);
|
|
800
|
+
await expect(stat(join(ownerDir(PROTECTED), 'sharepoint'))).rejects.toThrow();
|
|
801
|
+
// The old file, then the folders it emptied that were old themselves.
|
|
802
|
+
expect(removed[encodeURIComponent(PROTECTED)]).toBe(3);
|
|
803
|
+
expect((await stat(join(ownerDir(DRAFT), 'chat-uploads'))).isDirectory()).toBe(true);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it('keeps the tmp folder itself, however old, and an old folder that still holds a young file', async () => {
|
|
807
|
+
await start();
|
|
808
|
+
const file = await stage('a.txt', 'x');
|
|
809
|
+
const young = await stage('old-folder/young.txt', 'y');
|
|
810
|
+
await age(file, 8);
|
|
811
|
+
await age(young, 1);
|
|
812
|
+
await age(dirname(young), 8);
|
|
813
|
+
await age(join(workspaceDir(DRAFT), 'tmp'), 8);
|
|
814
|
+
await service.sweepTmp();
|
|
815
|
+
await expect(stat(file)).rejects.toThrow();
|
|
816
|
+
expect(await readFile(young, 'utf8')).toBe('y');
|
|
817
|
+
expect((await stat(join(workspaceDir(DRAFT), 'tmp'))).isDirectory()).toBe(true);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('removes a stale link as the link, never what it points at', async () => {
|
|
821
|
+
await start();
|
|
822
|
+
await commitFixture('keep.md', 'kept');
|
|
823
|
+
await mkdir(join(workspaceDir(DRAFT), 'tmp'), { recursive: true });
|
|
824
|
+
const link = join(workspaceDir(DRAFT), 'tmp', 'kb');
|
|
825
|
+
await symlink(join(workspaceDir(DRAFT), KB_DIR), link);
|
|
826
|
+
const when = new Date(Date.now() - 8 * DAY);
|
|
827
|
+
const { lutimes } = await import('node:fs/promises');
|
|
828
|
+
await lutimes(link, when, when);
|
|
829
|
+
await service.sweepTmp();
|
|
830
|
+
await expect(stat(link)).rejects.toThrow();
|
|
831
|
+
expect(await readFile(join(workspaceDir(DRAFT), KB_DIR, 'keep.md'), 'utf8')).toBe('kept');
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it('is what the platform runs on its timer: at start, then every interval, until stopped', async () => {
|
|
835
|
+
let sweeps = 0;
|
|
836
|
+
const sweeper = new TmpSweeper(async () => {
|
|
837
|
+
sweeps += 1;
|
|
838
|
+
});
|
|
839
|
+
sweeper.startSweeping(20);
|
|
840
|
+
sweeper.startSweeping(20); // idempotent
|
|
841
|
+
await sweeper.drainSweep();
|
|
842
|
+
expect(sweeps).toBe(1);
|
|
843
|
+
await new Promise((r) => setTimeout(r, 70));
|
|
844
|
+
sweeper.stopSweeping();
|
|
845
|
+
await sweeper.drainSweep();
|
|
846
|
+
const stopped = sweeps;
|
|
847
|
+
expect(stopped).toBeGreaterThanOrEqual(2);
|
|
848
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
849
|
+
expect(sweeps).toBe(stopped);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it('stopping cancels the sweep in flight: it removes nothing more, and the drain waits for it to end', async () => {
|
|
853
|
+
await start();
|
|
854
|
+
const files: string[] = [];
|
|
855
|
+
for (const ws of [DRAFT, PROTECTED]) {
|
|
856
|
+
for (let i = 0; i < 3; i += 1) {
|
|
857
|
+
const abs = await stage(`old-${i}.txt`, 'x', ws);
|
|
858
|
+
await age(abs, 8);
|
|
859
|
+
files.push(abs);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const signals: AbortSignal[] = [];
|
|
863
|
+
let release!: () => void;
|
|
864
|
+
const held = new Promise<void>((r) => (release = r));
|
|
865
|
+
const sweeper = new TmpSweeper(async (signal) => {
|
|
866
|
+
signals.push(signal);
|
|
867
|
+
// Stopped before the sweep gets to look at anything.
|
|
868
|
+
await held;
|
|
869
|
+
return service.sweepTmp(undefined, undefined, signal);
|
|
870
|
+
});
|
|
871
|
+
sweeper.startSweeping(60_000);
|
|
872
|
+
sweeper.stopSweeping();
|
|
873
|
+
expect(signals[0]!.aborted).toBe(true);
|
|
874
|
+
release();
|
|
875
|
+
await sweeper.drainSweep();
|
|
876
|
+
for (const abs of files) expect(await readFile(abs, 'utf8')).toBe('x');
|
|
877
|
+
// Not cancelled, the same sweep removes them all.
|
|
878
|
+
await service.sweepTmp();
|
|
879
|
+
for (const abs of files) await expect(stat(abs)).rejects.toThrow();
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('a stop that comes while an entry is being looked at removes nothing more, that entry included', async () => {
|
|
883
|
+
await start();
|
|
884
|
+
const abs = await stage('old.txt', 'x');
|
|
885
|
+
await age(abs, 8);
|
|
886
|
+
const stop = new AbortController();
|
|
887
|
+
const lstat = fsp.lstat.bind(fsp);
|
|
888
|
+
// The stop lands DURING the look at the old file, after the abort check
|
|
889
|
+
// that came before it.
|
|
890
|
+
const spy = vi.spyOn(fsp, 'lstat').mockImplementation((async (p: string, ...rest: unknown[]) => {
|
|
891
|
+
const stat = await (lstat as (...a: unknown[]) => Promise<unknown>)(p, ...rest);
|
|
892
|
+
if (String(p) === abs) stop.abort();
|
|
893
|
+
return stat;
|
|
894
|
+
}) as typeof fsp.lstat);
|
|
895
|
+
try {
|
|
896
|
+
const { removed } = await service.sweepTmp(undefined, undefined, stop.signal);
|
|
897
|
+
expect(stop.signal.aborted).toBe(true);
|
|
898
|
+
expect(removed).toEqual({});
|
|
899
|
+
} finally {
|
|
900
|
+
spy.mockRestore();
|
|
901
|
+
}
|
|
902
|
+
expect(await readFile(abs, 'utf8')).toBe('x');
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('sweeps at seven days', () => {
|
|
906
|
+
expect(TMP_MAX_AGE_MS).toBe(7 * DAY);
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
// ── the agent guide ──────────────────────────────────────────────────────
|
|
911
|
+
|
|
912
|
+
describe('the agent guide\'s file-tools section describes the ref', () => {
|
|
913
|
+
it("states the ref, that it reaches the caller's own files, that it is read-only, copy_file to keep, and up to seven days", async () => {
|
|
914
|
+
const rule = sharedFileRules(DEFAULT_KB_LAYOUT).find((r) => r.id === 'workspace-tmp')!.body;
|
|
915
|
+
expect(rule).toContain('`__tmp__/<path>`');
|
|
916
|
+
expect(rule).toContain('`tmp/` folder');
|
|
917
|
+
expect(rule).toContain('YOU staged');
|
|
918
|
+
expect(rule).toContain("nobody else's staged files");
|
|
919
|
+
expect(rule).toContain('read-only');
|
|
920
|
+
expect(rule).toContain('copy_file it to a repository path');
|
|
921
|
+
expect(rule).toContain('up to seven days');
|
|
922
|
+
for (const tool of ['read_file', 'file_stat', 'grep', 'copy_file']) expect(rule).toContain(tool);
|
|
923
|
+
expect(sharedFileRulesSection(DEFAULT_KB_LAYOUT)).toContain(rule);
|
|
924
|
+
const sections = await agentGuideSections(DEFAULT_KB_LAYOUT);
|
|
925
|
+
const files = sections.find((s) => s.id === WORKING_WITH_FILES_SECTION_ID)!;
|
|
926
|
+
expect(files.body).toContain(rule);
|
|
927
|
+
});
|
|
928
|
+
});
|