@bevel-software/platform-core-backend 0.15.1 → 0.20.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/THIRD-PARTY-NOTICES.md +4 -4
- package/dist/core/catalog-cache-invalidation.d.ts +16 -0
- package/dist/core/catalog-cache-invalidation.d.ts.map +1 -1
- package/dist/core/catalog-cache-invalidation.js +6 -1
- package/dist/core/catalog-cache-invalidation.js.map +1 -1
- package/dist/core/catalog-revision.d.ts +39 -0
- package/dist/core/catalog-revision.d.ts.map +1 -0
- package/dist/core/catalog-revision.js +94 -0
- package/dist/core/catalog-revision.js.map +1 -0
- package/dist/core/create-core-server.d.ts +7 -0
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +221 -75
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +52 -2
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +222 -87
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +138 -0
- package/dist/core/lifecycle.d.ts.map +1 -0
- package/dist/core/lifecycle.js +218 -0
- package/dist/core/lifecycle.js.map +1 -0
- package/dist/core/public-config.d.ts +82 -0
- package/dist/core/public-config.d.ts.map +1 -0
- package/dist/core/public-config.js +78 -0
- package/dist/core/public-config.js.map +1 -0
- package/dist/core/readiness.d.ts +86 -0
- package/dist/core/readiness.d.ts.map +1 -0
- package/dist/core/readiness.js +56 -0
- package/dist/core/readiness.js.map +1 -0
- package/dist/core-config.d.ts +30 -0
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +64 -2
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +215 -11
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +57 -11
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +616 -155
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +71 -5
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +161 -13
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-view.d.ts +147 -0
- package/dist/modules/access/access-view.d.ts.map +1 -0
- package/dist/modules/access/access-view.js +211 -0
- package/dist/modules/access/access-view.js.map +1 -0
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +255 -79
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts +2 -3
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +9 -8
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/admin-route-helpers.d.ts.map +1 -1
- package/dist/modules/access/admin-route-helpers.js +4 -4
- package/dist/modules/access/admin-route-helpers.js.map +1 -1
- package/dist/modules/access/change-read-gate.d.ts +53 -0
- package/dist/modules/access/change-read-gate.d.ts.map +1 -0
- package/dist/modules/access/change-read-gate.js +134 -0
- package/dist/modules/access/change-read-gate.js.map +1 -0
- package/dist/modules/access/creator-access.d.ts +33 -39
- package/dist/modules/access/creator-access.d.ts.map +1 -1
- package/dist/modules/access/creator-access.js +92 -119
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
- package/dist/modules/access/directory-sync-bot.js +3 -1
- package/dist/modules/access/directory-sync-bot.js.map +1 -1
- package/dist/modules/access/groups-admin.service.d.ts +3 -3
- package/dist/modules/access/groups-admin.service.js +2 -2
- package/dist/modules/access/reference-scan.d.ts +1 -1
- package/dist/modules/access/reference-scan.d.ts.map +1 -1
- package/dist/modules/access/reference-scan.js +5 -4
- package/dist/modules/access/reference-scan.js.map +1 -1
- package/dist/modules/access/roles-admin.service.d.ts +72 -13
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +71 -13
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.d.ts.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +8 -6
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access/synced-groups-writer.d.ts.map +1 -1
- package/dist/modules/access/synced-groups-writer.js +3 -8
- package/dist/modules/access/synced-groups-writer.js.map +1 -1
- package/dist/modules/access/user-access-removal.service.d.ts +209 -0
- package/dist/modules/access/user-access-removal.service.d.ts.map +1 -0
- package/dist/modules/access/user-access-removal.service.js +662 -0
- package/dist/modules/access/user-access-removal.service.js.map +1 -0
- package/dist/modules/access-model/access-errors.d.ts +32 -1
- package/dist/modules/access-model/access-errors.d.ts.map +1 -1
- package/dist/modules/access-model/access-errors.js +38 -11
- package/dist/modules/access-model/access-errors.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +49 -24
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +68 -48
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/access-model/access-splice.d.ts.map +1 -1
- package/dist/modules/access-model/access-splice.js +18 -18
- package/dist/modules/access-model/access-splice.js.map +1 -1
- package/dist/modules/access-model/access-template.d.ts +22 -0
- package/dist/modules/access-model/access-template.d.ts.map +1 -0
- package/dist/modules/access-model/access-template.js +42 -0
- package/dist/modules/access-model/access-template.js.map +1 -0
- package/dist/modules/access-model/change-gate.d.ts +65 -0
- package/dist/modules/access-model/change-gate.d.ts.map +1 -0
- package/dist/modules/access-model/change-gate.js +25 -0
- package/dist/modules/access-model/change-gate.js.map +1 -0
- package/dist/modules/access-model/creator.d.ts +13 -16
- package/dist/modules/access-model/creator.d.ts.map +1 -1
- package/dist/modules/access-model/creator.js.map +1 -1
- package/dist/modules/access-model/frontmatter-lines.d.ts +27 -0
- package/dist/modules/access-model/frontmatter-lines.d.ts.map +1 -0
- package/dist/modules/access-model/frontmatter-lines.js +74 -0
- package/dist/modules/access-model/frontmatter-lines.js.map +1 -0
- package/dist/modules/access-model/plugin-principals.d.ts +5 -3
- package/dist/modules/access-model/plugin-principals.d.ts.map +1 -1
- package/dist/modules/access-model/plugin-principals.js +11 -17
- package/dist/modules/access-model/plugin-principals.js.map +1 -1
- package/dist/modules/access-model/roles-yaml-guard.d.ts +73 -2
- package/dist/modules/access-model/roles-yaml-guard.d.ts.map +1 -1
- package/dist/modules/access-model/roles-yaml-guard.js +153 -3
- package/dist/modules/access-model/roles-yaml-guard.js.map +1 -1
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
- package/dist/modules/admin/admin-access.service.js +5 -2
- package/dist/modules/admin/admin-access.service.js.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +21 -0
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -0
- package/dist/modules/agent-instructions/agent-instructions.routes.js +38 -0
- package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -0
- package/dist/modules/agent-instructions/compose.d.ts +71 -0
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -0
- package/dist/modules/agent-instructions/compose.js +234 -0
- package/dist/modules/agent-instructions/compose.js.map +1 -0
- package/dist/modules/agent-instructions/index.d.ts +4 -0
- package/dist/modules/agent-instructions/index.d.ts.map +1 -0
- package/dist/modules/agent-instructions/index.js +4 -0
- package/dist/modules/agent-instructions/index.js.map +1 -0
- package/dist/modules/agent-instructions/read-preamble.d.ts +37 -0
- package/dist/modules/agent-instructions/read-preamble.d.ts.map +1 -0
- package/dist/modules/agent-instructions/read-preamble.js +81 -0
- package/dist/modules/agent-instructions/read-preamble.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts +54 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -0
- package/dist/modules/audit/agent-audit.service.js +290 -0
- package/dist/modules/audit/agent-audit.service.js.map +1 -0
- package/dist/modules/audit/agent-rest-audit.middleware.d.ts +40 -0
- package/dist/modules/audit/agent-rest-audit.middleware.d.ts.map +1 -0
- package/dist/modules/audit/agent-rest-audit.middleware.js +75 -0
- package/dist/modules/audit/agent-rest-audit.middleware.js.map +1 -0
- package/dist/modules/audit/audit.contract.d.ts +145 -0
- package/dist/modules/audit/audit.contract.d.ts.map +1 -0
- package/dist/modules/audit/audit.contract.js +35 -0
- package/dist/modules/audit/audit.contract.js.map +1 -0
- package/dist/modules/audit/audit.routes.d.ts +21 -0
- package/dist/modules/audit/audit.routes.d.ts.map +1 -0
- package/dist/modules/audit/audit.routes.js +157 -0
- package/dist/modules/audit/audit.routes.js.map +1 -0
- package/dist/modules/audit/event-classifier.d.ts +52 -0
- package/dist/modules/audit/event-classifier.d.ts.map +1 -0
- package/dist/modules/audit/event-classifier.js +123 -0
- package/dist/modules/audit/event-classifier.js.map +1 -0
- package/dist/modules/audit/index.d.ts +7 -0
- package/dist/modules/audit/index.d.ts.map +1 -0
- package/dist/modules/audit/index.js +7 -0
- package/dist/modules/audit/index.js.map +1 -0
- package/dist/modules/audit/request-audit.d.ts +57 -0
- package/dist/modules/audit/request-audit.d.ts.map +1 -0
- package/dist/modules/audit/request-audit.js +117 -0
- package/dist/modules/audit/request-audit.js.map +1 -0
- package/dist/modules/auth/account-erasure.service.d.ts +30 -4
- package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
- package/dist/modules/auth/account-erasure.service.js +96 -10
- package/dist/modules/auth/account-erasure.service.js.map +1 -1
- package/dist/modules/auth/account.routes.d.ts +6 -3
- package/dist/modules/auth/account.routes.d.ts.map +1 -1
- package/dist/modules/auth/account.routes.js +109 -10
- package/dist/modules/auth/account.routes.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts +10 -3
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +9 -4
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +89 -5
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +160 -25
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.d.ts +46 -3
- package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.js +99 -21
- package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +14 -2
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
- package/dist/modules/connection-probe/connection-probe.service.js +56 -7
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
- package/dist/modules/database/advisory-lock.d.ts +103 -0
- package/dist/modules/database/advisory-lock.d.ts.map +1 -0
- package/dist/modules/database/advisory-lock.js +320 -0
- package/dist/modules/database/advisory-lock.js.map +1 -0
- package/dist/modules/database/connection.d.ts.map +1 -1
- package/dist/modules/database/connection.js +9 -1
- package/dist/modules/database/connection.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +859 -99
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +218 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/database/migrate.d.ts.map +1 -1
- package/dist/modules/database/migrate.js +26 -6
- package/dist/modules/database/migrate.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.js +4 -2
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -1
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +2 -4
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/diff/diff.service.d.ts +4 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +84 -81
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/{workspace → kb-fs}/bevel-ignore.d.ts +14 -9
- package/dist/modules/kb-fs/bevel-ignore.d.ts.map +1 -0
- package/dist/modules/{workspace → kb-fs}/bevel-ignore.js +24 -14
- package/dist/modules/kb-fs/bevel-ignore.js.map +1 -0
- package/dist/modules/kb-fs/file-change-notifier.d.ts.map +1 -1
- package/dist/modules/kb-fs/file-change-notifier.js +3 -1
- package/dist/modules/kb-fs/file-change-notifier.js.map +1 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.d.ts +45 -0
- package/dist/modules/kb-fs/git-guarded-filesystem.d.ts.map +1 -0
- package/dist/modules/kb-fs/git-guarded-filesystem.js +117 -0
- package/dist/modules/kb-fs/git-guarded-filesystem.js.map +1 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts +113 -18
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +353 -76
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/node-fs.d.ts +23 -0
- package/dist/modules/kb-fs/node-fs.d.ts.map +1 -0
- package/dist/modules/kb-fs/node-fs.js +158 -0
- package/dist/modules/kb-fs/node-fs.js.map +1 -0
- package/dist/modules/kb-fs/read-only-filesystem.d.ts +10 -9
- package/dist/modules/kb-fs/read-only-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/read-only-filesystem.js +18 -9
- package/dist/modules/kb-fs/read-only-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +100 -3
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
- package/dist/modules/kb-fs/repo-path.js +195 -3
- package/dist/modules/kb-fs/repo-path.js.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.js +4 -2
- package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
- package/dist/modules/kb-sync/kb-sync.service.d.ts.map +1 -1
- package/dist/modules/kb-sync/kb-sync.service.js +7 -5
- package/dist/modules/kb-sync/kb-sync.service.js.map +1 -1
- package/dist/modules/marketplace/git-http.routes.d.ts.map +1 -1
- package/dist/modules/marketplace/git-http.routes.js +6 -4
- package/dist/modules/marketplace/git-http.routes.js.map +1 -1
- package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts +12 -2
- package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts.map +1 -1
- package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js +32 -3
- package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js.map +1 -1
- package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts +27 -0
- package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts.map +1 -1
- package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js +45 -0
- package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js.map +1 -1
- package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts.map +1 -1
- package/dist/modules/marketplace/github-facade/github-facade.routes.js +30 -6
- package/dist/modules/marketplace/github-facade/github-facade.routes.js.map +1 -1
- package/dist/modules/marketplace/github-facade/github-facade.service.d.ts +21 -9
- package/dist/modules/marketplace/github-facade/github-facade.service.d.ts.map +1 -1
- package/dist/modules/marketplace/github-facade/github-facade.service.js +60 -19
- package/dist/modules/marketplace/github-facade/github-facade.service.js.map +1 -1
- package/dist/modules/marketplace/github-facade/index.d.ts +1 -1
- package/dist/modules/marketplace/github-facade/index.d.ts.map +1 -1
- package/dist/modules/marketplace/github-facade/index.js +1 -1
- package/dist/modules/marketplace/github-facade/index.js.map +1 -1
- package/dist/modules/marketplace/marketplace-repo.service.d.ts +14 -1
- package/dist/modules/marketplace/marketplace-repo.service.d.ts.map +1 -1
- package/dist/modules/marketplace/marketplace-repo.service.js +26 -8
- package/dist/modules/marketplace/marketplace-repo.service.js.map +1 -1
- package/dist/modules/mcp/downstream-pool.d.ts +107 -0
- package/dist/modules/mcp/downstream-pool.d.ts.map +1 -0
- package/dist/modules/mcp/downstream-pool.js +221 -0
- package/dist/modules/mcp/downstream-pool.js.map +1 -0
- package/dist/modules/mcp/downstream-token-refresh.d.ts +77 -0
- package/dist/modules/mcp/downstream-token-refresh.d.ts.map +1 -0
- package/dist/modules/mcp/downstream-token-refresh.js +166 -0
- package/dist/modules/mcp/downstream-token-refresh.js.map +1 -0
- package/dist/modules/mcp/first-call-probe.cli-args.d.ts +78 -0
- package/dist/modules/mcp/first-call-probe.cli-args.d.ts.map +1 -0
- package/dist/modules/mcp/first-call-probe.cli-args.js +151 -0
- package/dist/modules/mcp/first-call-probe.cli-args.js.map +1 -0
- package/dist/modules/mcp/first-call-probe.cli.d.ts +2 -0
- package/dist/modules/mcp/first-call-probe.cli.d.ts.map +1 -0
- package/dist/modules/mcp/first-call-probe.cli.js +62 -0
- package/dist/modules/mcp/first-call-probe.cli.js.map +1 -0
- package/dist/modules/mcp/first-call-probe.d.ts +165 -0
- package/dist/modules/mcp/first-call-probe.d.ts.map +1 -0
- package/dist/modules/mcp/first-call-probe.js +298 -0
- package/dist/modules/mcp/first-call-probe.js.map +1 -0
- package/dist/modules/mcp/manual-failure-memo.d.ts +42 -17
- package/dist/modules/mcp/manual-failure-memo.d.ts.map +1 -1
- package/dist/modules/mcp/manual-failure-memo.js +79 -23
- package/dist/modules/mcp/manual-failure-memo.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +10 -5
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +56 -16
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +4 -4
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +91 -180
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +226 -61
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +758 -161
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +59 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +184 -10
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/oauth-consent.routes.js +3 -1
- package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -1
- package/dist/modules/mcp/surface-log-throttle.d.ts +59 -0
- package/dist/modules/mcp/surface-log-throttle.d.ts.map +1 -0
- package/dist/modules/mcp/surface-log-throttle.js +94 -0
- package/dist/modules/mcp/surface-log-throttle.js.map +1 -0
- package/dist/modules/plugins/compile/compile-marketplace.d.ts +13 -6
- package/dist/modules/plugins/compile/compile-marketplace.d.ts.map +1 -1
- package/dist/modules/plugins/compile/compile-marketplace.js +119 -36
- package/dist/modules/plugins/compile/compile-marketplace.js.map +1 -1
- package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts +8 -1
- package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts.map +1 -1
- package/dist/modules/plugins/compile/marketplace-compiler.service.js +9 -6
- package/dist/modules/plugins/compile/marketplace-compiler.service.js.map +1 -1
- package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts.map +1 -1
- package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js +32 -9
- package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js.map +1 -1
- package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts +26 -6
- package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts.map +1 -1
- package/dist/modules/plugins/discovery/bundle-dialect/registry.js +71 -14
- package/dist/modules/plugins/discovery/bundle-dialect/registry.js.map +1 -1
- package/dist/modules/plugins/discovery/kb-plugin-source.d.ts +4 -2
- package/dist/modules/plugins/discovery/kb-plugin-source.d.ts.map +1 -1
- package/dist/modules/plugins/discovery/kb-plugin-source.js +9 -6
- package/dist/modules/plugins/discovery/kb-plugin-source.js.map +1 -1
- package/dist/modules/plugins/discovery/native.source.d.ts +2 -1
- package/dist/modules/plugins/discovery/native.source.d.ts.map +1 -1
- package/dist/modules/plugins/discovery/native.source.js +22 -4
- package/dist/modules/plugins/discovery/native.source.js.map +1 -1
- package/dist/modules/plugins/discovery/plugin-source.d.ts +17 -3
- package/dist/modules/plugins/discovery/plugin-source.d.ts.map +1 -1
- package/dist/modules/plugins/index.d.ts +6 -3
- package/dist/modules/plugins/index.d.ts.map +1 -1
- package/dist/modules/plugins/index.js +6 -3
- package/dist/modules/plugins/index.js.map +1 -1
- package/dist/modules/plugins/join-request-jobs.service.d.ts +250 -0
- package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -0
- package/dist/modules/plugins/join-request-jobs.service.js +602 -0
- package/dist/modules/plugins/join-request-jobs.service.js.map +1 -0
- package/dist/modules/plugins/join-request-records.store.d.ts +161 -0
- package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -0
- package/dist/modules/plugins/join-request-records.store.js +179 -0
- package/dist/modules/plugins/join-request-records.store.js.map +1 -0
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-requests.service.js +4 -2
- package/dist/modules/plugins/join-requests.service.js.map +1 -1
- package/dist/modules/plugins/plugin-links.d.ts +4 -1
- package/dist/modules/plugins/plugin-links.d.ts.map +1 -1
- package/dist/modules/plugins/plugin-links.js +6 -3
- package/dist/modules/plugins/plugin-links.js.map +1 -1
- package/dist/modules/plugins/plugin-links.service.d.ts +2 -5
- package/dist/modules/plugins/plugin-links.service.d.ts.map +1 -1
- package/dist/modules/plugins/plugin-links.service.js +4 -13
- package/dist/modules/plugins/plugin-links.service.js.map +1 -1
- package/dist/modules/plugins/plugin-provision.service.d.ts +91 -19
- package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -1
- package/dist/modules/plugins/plugin-provision.service.js +183 -38
- package/dist/modules/plugins/plugin-provision.service.js.map +1 -1
- package/dist/modules/plugins/plugin-rename.service.d.ts +3 -1
- package/dist/modules/plugins/plugin-rename.service.d.ts.map +1 -1
- package/dist/modules/plugins/plugin-rename.service.js +20 -11
- package/dist/modules/plugins/plugin-rename.service.js.map +1 -1
- package/dist/modules/plugins/plugins.contract.d.ts +52 -4
- package/dist/modules/plugins/plugins.contract.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.routes.d.ts +38 -5
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.routes.js +190 -116
- package/dist/modules/plugins/plugins.routes.js.map +1 -1
- package/dist/modules/plugins/plugins.service.d.ts +38 -6
- package/dist/modules/plugins/plugins.service.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.service.js +86 -17
- package/dist/modules/plugins/plugins.service.js.map +1 -1
- package/dist/modules/plugins/plugins.tools.d.ts +25 -0
- package/dist/modules/plugins/plugins.tools.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.tools.js +82 -0
- package/dist/modules/plugins/plugins.tools.js.map +1 -0
- package/dist/modules/plugins/teams.routes.d.ts +42 -0
- package/dist/modules/plugins/teams.routes.d.ts.map +1 -0
- package/dist/modules/plugins/teams.routes.js +79 -0
- package/dist/modules/plugins/teams.routes.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +40 -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 +202 -37
- 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.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +5 -3
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-variable-loader.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-variable-loader.js +3 -1
- package/dist/modules/secrets-vault/secrets-variable-loader.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +78 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.contract.js +40 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +106 -52
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/connection-check.d.ts +118 -0
- package/dist/modules/settings/connection-check.d.ts.map +1 -0
- package/dist/modules/settings/connection-check.js +287 -0
- package/dist/modules/settings/connection-check.js.map +1 -0
- package/dist/modules/settings/deployment-settings.service.d.ts +162 -8
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +375 -52
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/git-root-folders.d.ts +54 -0
- package/dist/modules/settings/git-root-folders.d.ts.map +1 -0
- package/dist/modules/settings/git-root-folders.js +130 -0
- package/dist/modules/settings/git-root-folders.js.map +1 -0
- package/dist/modules/settings/oidc-check.d.ts +85 -0
- package/dist/modules/settings/oidc-check.d.ts.map +1 -0
- package/dist/modules/settings/oidc-check.js +172 -0
- package/dist/modules/settings/oidc-check.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +31 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +400 -108
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/allowed-tools-check.d.ts +132 -0
- package/dist/modules/skills/allowed-tools-check.d.ts.map +1 -0
- package/dist/modules/skills/allowed-tools-check.js +260 -0
- package/dist/modules/skills/allowed-tools-check.js.map +1 -0
- package/dist/modules/skills/index.d.ts +3 -1
- package/dist/modules/skills/index.d.ts.map +1 -1
- package/dist/modules/skills/index.js +1 -0
- package/dist/modules/skills/index.js.map +1 -1
- package/dist/modules/skills/pending-skills.service.d.ts +6 -11
- package/dist/modules/skills/pending-skills.service.d.ts.map +1 -1
- package/dist/modules/skills/pending-skills.service.js +18 -70
- package/dist/modules/skills/pending-skills.service.js.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.js +9 -6
- package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +44 -10
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.routes.d.ts +8 -1
- package/dist/modules/skills/skills.routes.d.ts.map +1 -1
- package/dist/modules/skills/skills.routes.js +16 -2
- package/dist/modules/skills/skills.routes.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +31 -7
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +191 -81
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +4 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +41 -6
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-auth/connection-key-rejection.d.ts +18 -0
- package/dist/modules/tool-auth/connection-key-rejection.d.ts.map +1 -0
- package/dist/modules/tool-auth/connection-key-rejection.js +20 -0
- package/dist/modules/tool-auth/connection-key-rejection.js.map +1 -0
- package/dist/modules/tool-auth/connection-keys-admin.routes.d.ts +14 -0
- package/dist/modules/tool-auth/connection-keys-admin.routes.d.ts.map +1 -0
- package/dist/modules/tool-auth/connection-keys-admin.routes.js +67 -0
- package/dist/modules/tool-auth/connection-keys-admin.routes.js.map +1 -0
- package/dist/modules/tool-auth/external-api-key.interface.d.ts +72 -8
- 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 +17 -6
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +83 -27
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +15 -2
- package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.js +6 -2
- package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
- package/dist/modules/tool-auth/key-or-session.middleware.d.ts +23 -0
- package/dist/modules/tool-auth/key-or-session.middleware.d.ts.map +1 -0
- package/dist/modules/tool-auth/key-or-session.middleware.js +39 -0
- package/dist/modules/tool-auth/key-or-session.middleware.js.map +1 -0
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +15 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +17 -6
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts +14 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +28 -6
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.js +22 -5
- package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +6 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js +7 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/tool-manuals/index.d.ts +2 -1
- package/dist/modules/tool-manuals/index.d.ts.map +1 -1
- package/dist/modules/tool-manuals/index.js +1 -0
- package/dist/modules/tool-manuals/index.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +8 -6
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +10 -2
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +12 -15
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/pending-tools.service.d.ts +50 -0
- package/dist/modules/tool-manuals/pending-tools.service.d.ts.map +1 -0
- package/dist/modules/tool-manuals/pending-tools.service.js +149 -0
- package/dist/modules/tool-manuals/pending-tools.service.js.map +1 -0
- package/dist/modules/tool-manuals/tool-delete.service.d.ts +135 -0
- package/dist/modules/tool-manuals/tool-delete.service.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-delete.service.js +303 -0
- package/dist/modules/tool-manuals/tool-delete.service.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +161 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js +9 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +19 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +127 -57
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +119 -5
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +404 -74
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +78 -6
- package/dist/modules/tool-manuals/tool-manuals.tools.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 +88 -54
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/event-bus.d.ts +10 -4
- package/dist/modules/workflow/event-bus.d.ts.map +1 -1
- package/dist/modules/workflow/event-bus.js +14 -11
- package/dist/modules/workflow/event-bus.js.map +1 -1
- package/dist/modules/workflow/events.routes.d.ts +17 -2
- package/dist/modules/workflow/events.routes.d.ts.map +1 -1
- package/dist/modules/workflow/events.routes.js +31 -12
- package/dist/modules/workflow/events.routes.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +22 -4
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +27 -4
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/change-request-link.d.ts +20 -0
- package/dist/modules/workflow/git/change-request-link.d.ts.map +1 -0
- package/dist/modules/workflow/git/change-request-link.js +34 -0
- package/dist/modules/workflow/git/change-request-link.js.map +1 -0
- package/dist/modules/workflow/git/git-version.d.ts.map +1 -1
- package/dist/modules/workflow/git/git-version.js +3 -1
- package/dist/modules/workflow/git/git-version.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +263 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +528 -118
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.d.ts +36 -0
- package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -0
- package/dist/modules/workflow/git/node-git-runner.js +273 -0
- package/dist/modules/workflow/git/node-git-runner.js.map +1 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts +51 -2
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +157 -25
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +11 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +24 -4
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +15 -8
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +107 -58
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
- package/dist/modules/workflow/recovery-bot.js +3 -1
- package/dist/modules/workflow/recovery-bot.js.map +1 -1
- package/dist/modules/workflow/review-workflow/approval-lock.d.ts +60 -0
- package/dist/modules/workflow/review-workflow/approval-lock.d.ts.map +1 -0
- package/dist/modules/workflow/review-workflow/approval-lock.js +116 -0
- package/dist/modules/workflow/review-workflow/approval-lock.js.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts +54 -7
- package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +64 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +269 -90
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/sanitize-error.d.ts +5 -2
- package/dist/modules/workflow/sanitize-error.d.ts.map +1 -1
- package/dist/modules/workflow/sanitize-error.js +8 -4
- package/dist/modules/workflow/sanitize-error.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts +2 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +259 -29
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +166 -14
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +906 -106
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/empty-dirs.d.ts +15 -0
- package/dist/modules/workspace/empty-dirs.d.ts.map +1 -0
- package/dist/modules/workspace/empty-dirs.js +90 -0
- package/dist/modules/workspace/empty-dirs.js.map +1 -0
- package/dist/modules/workspace/file-readers/content-mode.d.ts +50 -0
- package/dist/modules/workspace/file-readers/content-mode.d.ts.map +1 -0
- package/dist/modules/workspace/file-readers/content-mode.js +55 -0
- package/dist/modules/workspace/file-readers/content-mode.js.map +1 -0
- package/dist/modules/workspace/file-readers/doc-extract.service.d.ts +4 -1
- package/dist/modules/workspace/file-readers/doc-extract.service.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/doc-extract.service.js +4 -1
- package/dist/modules/workspace/file-readers/doc-extract.service.js.map +1 -1
- package/dist/modules/workspace/file-readers/doc-extract.types.d.ts +5 -2
- package/dist/modules/workspace/file-readers/doc-extract.types.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/doc-extract.types.js +7 -4
- package/dist/modules/workspace/file-readers/doc-extract.types.js.map +1 -1
- package/dist/modules/workspace/file-readers/document-reader.d.ts +2 -0
- package/dist/modules/workspace/file-readers/document-reader.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/document-reader.js +5 -0
- package/dist/modules/workspace/file-readers/document-reader.js.map +1 -1
- package/dist/modules/workspace/file-readers/extract-docx.d.ts +6 -0
- package/dist/modules/workspace/file-readers/extract-docx.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/extract-docx.js +6 -0
- package/dist/modules/workspace/file-readers/extract-docx.js.map +1 -1
- package/dist/modules/workspace/file-readers/file-reader.d.ts +24 -5
- package/dist/modules/workspace/file-readers/file-reader.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/file-reader.js +11 -6
- package/dist/modules/workspace/file-readers/file-reader.js.map +1 -1
- package/dist/modules/workspace/file-readers/file-reader.registry.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/file-reader.registry.js +6 -1
- package/dist/modules/workspace/file-readers/file-reader.registry.js.map +1 -1
- package/dist/modules/workspace/file-readers/image-reader.d.ts +7 -5
- package/dist/modules/workspace/file-readers/image-reader.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/image-reader.js +9 -5
- package/dist/modules/workspace/file-readers/image-reader.js.map +1 -1
- package/dist/modules/workspace/file-readers/ooxml-text.d.ts +3 -0
- package/dist/modules/workspace/file-readers/ooxml-text.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/ooxml-text.js +39 -2
- package/dist/modules/workspace/file-readers/ooxml-text.js.map +1 -1
- package/dist/modules/workspace/file-readers/text-reader.d.ts +29 -1
- package/dist/modules/workspace/file-readers/text-reader.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/text-reader.js +57 -3
- package/dist/modules/workspace/file-readers/text-reader.js.map +1 -1
- package/dist/modules/workspace/git-internals.middleware.d.ts +28 -0
- package/dist/modules/workspace/git-internals.middleware.d.ts.map +1 -0
- package/dist/modules/workspace/git-internals.middleware.js +93 -0
- package/dist/modules/workspace/git-internals.middleware.js.map +1 -0
- package/dist/modules/workspace/not-found.d.ts +80 -0
- package/dist/modules/workspace/not-found.d.ts.map +1 -0
- package/dist/modules/workspace/not-found.js +112 -0
- package/dist/modules/workspace/not-found.js.map +1 -0
- package/dist/modules/workspace/session-ontology.gate.d.ts +1 -1
- package/dist/modules/workspace/session-ontology.gate.d.ts.map +1 -1
- package/dist/modules/workspace/session-ontology.gate.js +1 -1
- package/dist/modules/workspace/session-ontology.gate.js.map +1 -1
- package/dist/modules/workspace/startup/beside-checkout.d.ts +42 -0
- package/dist/modules/workspace/startup/beside-checkout.d.ts.map +1 -0
- package/dist/modules/workspace/startup/beside-checkout.js +110 -0
- package/dist/modules/workspace/startup/beside-checkout.js.map +1 -0
- package/dist/modules/workspace/startup/kb-git.d.ts +7 -12
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-git.js +34 -35
- package/dist/modules/workspace/startup/kb-git.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +95 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +257 -55
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +45 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +417 -385
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -1
- package/dist/modules/workspace/startup/steps/personal-spaces.step.d.ts +48 -0
- package/dist/modules/workspace/startup/steps/personal-spaces.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/personal-spaces.step.js +183 -0
- package/dist/modules/workspace/startup/steps/personal-spaces.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/plugin-display-names.step.d.ts +51 -0
- package/dist/modules/workspace/startup/steps/plugin-display-names.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/plugin-display-names.step.js +158 -0
- package/dist/modules/workspace/startup/steps/plugin-display-names.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/plugin-layout.d.ts +64 -0
- package/dist/modules/workspace/startup/steps/plugin-layout.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/plugin-layout.js +97 -0
- package/dist/modules/workspace/startup/steps/plugin-layout.js.map +1 -0
- package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts +7 -1
- package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/plugin-manifests.step.js +45 -81
- package/dist/modules/workspace/startup/steps/plugin-manifests.step.js.map +1 -1
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +3 -1
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js +4 -12
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts +6 -1
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.js +154 -78
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts +96 -24
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/template-files.step.js +446 -138
- package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.d.ts +94 -0
- package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/template-source.js +160 -0
- package/dist/modules/workspace/startup/steps/template-source.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.d.ts +14 -2
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +649 -238
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +298 -12
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +933 -269
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +42 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +1626 -146
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/modules/workspace/write-denial.d.ts +76 -0
- package/dist/modules/workspace/write-denial.d.ts.map +1 -0
- package/dist/modules/workspace/write-denial.js +138 -0
- package/dist/modules/workspace/write-denial.js.map +1 -0
- package/dist/shared/canonical-file-identity.d.ts +64 -0
- package/dist/shared/canonical-file-identity.d.ts.map +1 -0
- package/dist/shared/canonical-file-identity.js +92 -0
- package/dist/shared/canonical-file-identity.js.map +1 -0
- package/dist/shared/domain-errors.d.ts +108 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +137 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/email-identity.d.ts +32 -0
- package/dist/shared/email-identity.d.ts.map +1 -0
- package/dist/shared/email-identity.js +37 -0
- package/dist/shared/email-identity.js.map +1 -0
- package/dist/shared/fs.contract.d.ts +206 -0
- package/dist/shared/fs.contract.d.ts.map +1 -0
- package/dist/shared/fs.contract.js +79 -0
- package/dist/shared/fs.contract.js.map +1 -0
- package/dist/shared/git-failure.d.ts +80 -0
- package/dist/shared/git-failure.d.ts.map +1 -0
- package/dist/shared/git-failure.js +139 -0
- package/dist/shared/git-failure.js.map +1 -0
- package/dist/shared/git-internals.d.ts +23 -0
- package/dist/shared/git-internals.d.ts.map +1 -0
- package/dist/shared/git-internals.js +181 -0
- package/dist/shared/git-internals.js.map +1 -0
- package/dist/shared/git.contract.d.ts +157 -0
- package/dist/shared/git.contract.d.ts.map +1 -0
- package/dist/shared/git.contract.js +103 -0
- package/dist/shared/git.contract.js.map +1 -0
- package/dist/shared/http-errors.d.ts +33 -0
- package/dist/shared/http-errors.d.ts.map +1 -0
- package/dist/shared/http-errors.js +35 -0
- package/dist/shared/http-errors.js.map +1 -0
- package/dist/shared/logger.contract.d.ts +63 -0
- package/dist/shared/logger.contract.d.ts.map +1 -0
- package/dist/shared/logger.contract.js +126 -0
- package/dist/shared/logger.contract.js.map +1 -0
- package/dist/shared/logging.d.ts +31 -0
- package/dist/shared/logging.d.ts.map +1 -0
- package/dist/shared/logging.js +92 -0
- package/dist/shared/logging.js.map +1 -0
- package/dist/shared/path-containment.d.ts +21 -0
- package/dist/shared/path-containment.d.ts.map +1 -0
- package/dist/shared/path-containment.js +34 -0
- package/dist/shared/path-containment.js.map +1 -0
- package/dist/shared/pending-proposals.d.ts +71 -0
- package/dist/shared/pending-proposals.d.ts.map +1 -0
- package/dist/shared/pending-proposals.js +98 -0
- package/dist/shared/pending-proposals.js.map +1 -0
- package/dist/shared/printable.d.ts +22 -0
- package/dist/shared/printable.d.ts.map +1 -0
- package/dist/shared/printable.js +65 -0
- package/dist/shared/printable.js.map +1 -0
- package/dist/shared/redact-secret.d.ts +30 -0
- package/dist/shared/redact-secret.d.ts.map +1 -0
- package/dist/shared/redact-secret.js +114 -0
- package/dist/shared/redact-secret.js.map +1 -0
- package/dist/shared/rename-no-replace.d.ts +119 -0
- package/dist/shared/rename-no-replace.d.ts.map +1 -0
- package/dist/shared/rename-no-replace.js +323 -0
- package/dist/shared/rename-no-replace.js.map +1 -0
- package/dist/shared/ssrf.d.ts.map +1 -1
- package/dist/shared/ssrf.js +49 -18
- package/dist/shared/ssrf.js.map +1 -1
- package/dist/version.d.ts +10 -6
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +10 -6
- package/dist/version.js.map +1 -1
- package/kb-template/.bevelignore +4 -3
- package/kb-template/AGENTS.md +281 -36
- package/kb-template/KnowledgeBase/How to get started.md +1 -1
- package/kb-template/access.md +5 -3
- package/kb-template/mcp-description.md +27 -0
- package/migrations/0006_api_tokens_revoked_by.sql +1 -0
- package/migrations/0007_github_facade_registered.sql +1 -0
- package/migrations/0008_change_request_apply_failure.sql +4 -0
- package/migrations/0009_change_request_apply_failure_kind.sql +13 -0
- package/migrations/0010_plugin_join_requests.sql +15 -0
- package/migrations/0011_plugin_join_request_claim.sql +11 -0
- package/migrations/0012_agent_audit.sql +41 -0
- package/migrations/0013_agent_identity_and_key_soft_delete.sql +29 -0
- package/migrations/meta/0006_snapshot.json +1723 -0
- package/migrations/meta/0007_snapshot.json +1729 -0
- package/migrations/meta/0008_snapshot.json +1753 -0
- package/migrations/meta/0009_snapshot.json +1759 -0
- package/migrations/meta/0010_snapshot.json +1872 -0
- package/migrations/meta/0011_snapshot.json +1884 -0
- package/migrations/meta/0012_snapshot.json +2247 -0
- package/migrations/meta/0013_snapshot.json +2259 -0
- package/migrations/meta/_journal.json +56 -0
- package/package.json +7 -6
- package/src/__tests__/core-config.domain.test.ts +67 -2
- package/src/__tests__/kb-layout-config.test.ts +186 -1
- package/src/__tests__/open-change-gate.ts +15 -0
- package/src/core/__tests__/catalog-cache-invalidation.test.ts +83 -3
- package/src/core/__tests__/catalog-revision.test.ts +190 -0
- package/src/core/__tests__/lifecycle.test.ts +369 -0
- package/src/core/__tests__/public-config.test.ts +30 -0
- package/src/core/__tests__/readiness.test.ts +109 -0
- package/src/core/catalog-cache-invalidation.ts +20 -1
- package/src/core/catalog-revision.ts +110 -0
- package/src/core/create-core-server.ts +286 -79
- package/src/core/create-core-services.ts +315 -81
- package/src/core/lifecycle.ts +312 -0
- package/src/core/public-config.ts +86 -0
- package/src/core/readiness.ts +123 -0
- package/src/core-config.ts +70 -4
- package/src/index.ts +15 -0
- package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +362 -0
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +2 -1
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +46 -22
- package/src/modules/access/__tests__/access-control.deny-dependents.test.ts +215 -0
- package/src/modules/access/__tests__/access-control.download-read.test.ts +268 -0
- package/src/modules/access/__tests__/access-control.everyone-read.test.ts +145 -0
- package/src/modules/access/__tests__/access-control.group-read.test.ts +163 -0
- package/src/modules/access/__tests__/access-control.invalidate-race.test.ts +2 -0
- package/src/modules/access/__tests__/access-control.platform-restore.test.ts +152 -0
- package/src/modules/access/__tests__/access-control.prospective.test.ts +189 -0
- package/src/modules/access/__tests__/access-control.read-batch.test.ts +2 -1
- package/src/modules/access/__tests__/access-control.service.test.ts +123 -78
- package/src/modules/access/__tests__/access-groups.test.ts +47 -14
- package/src/modules/access/__tests__/access-md-format.test.ts +2 -1
- package/src/modules/access/__tests__/access-mutation.service.test.ts +52 -4
- package/src/modules/access/__tests__/access-own-read-grant.test.ts +2 -0
- package/src/modules/access/__tests__/access-personal-plugin.test.ts +153 -0
- package/src/modules/access/__tests__/access-plugin-principals.test.ts +18 -1
- package/src/modules/access/__tests__/access-restrict-here.test.ts +450 -0
- package/src/modules/access/__tests__/access.routes.batch.test.ts +139 -0
- package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +394 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +6 -2
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +3 -2
- package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +234 -0
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +216 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +7 -2
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +360 -0
- package/src/modules/access/__tests__/change-read-gate.test.ts +272 -0
- package/src/modules/access/__tests__/creator-access.test.ts +81 -83
- package/src/modules/access/__tests__/grant-sources.test.ts +3 -2
- package/src/modules/access/__tests__/groups-admin.service.test.ts +6 -5
- package/src/modules/access/__tests__/roles-admin.security.test.ts +196 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +127 -2
- package/src/modules/access/__tests__/roles-capabilities.test.ts +5 -4
- package/src/modules/access/__tests__/roles.routes.test.ts +5 -2
- package/src/modules/access/__tests__/user-access-removal.service.test.ts +402 -0
- package/src/modules/access/__tests__/users-db-double.ts +63 -0
- package/src/modules/access/access-control.interface.ts +251 -11
- package/src/modules/access/access-control.service.ts +719 -164
- package/src/modules/access/access-mutation.service.ts +182 -13
- package/src/modules/access/access-view.ts +316 -0
- package/src/modules/access/access.routes.ts +293 -95
- package/src/modules/access/admin-locked-commit.ts +12 -14
- package/src/modules/access/admin-route-helpers.ts +4 -4
- package/src/modules/access/change-read-gate.ts +148 -0
- package/src/modules/access/creator-access.ts +89 -124
- package/src/modules/access/directory-sync-bot.ts +4 -1
- package/src/modules/access/groups-admin.service.ts +3 -3
- package/src/modules/access/reference-scan.ts +5 -5
- package/src/modules/access/roles-admin.service.ts +94 -15
- package/src/modules/access/synced-groups-committer.ts +10 -15
- package/src/modules/access/synced-groups-writer.ts +3 -12
- package/src/modules/access/user-access-removal.service.ts +711 -0
- package/src/modules/access-model/__tests__/access-splice.test.ts +63 -1
- package/src/modules/access-model/__tests__/frontmatter-fences.test.ts +71 -0
- package/src/modules/access-model/__tests__/roles-yaml-guard.test.ts +131 -0
- package/src/modules/access-model/access-errors.ts +56 -15
- package/src/modules/access-model/access-grammar.ts +85 -48
- package/src/modules/access-model/access-splice.ts +18 -18
- package/src/modules/access-model/access-template.ts +44 -0
- package/src/modules/access-model/change-gate.ts +80 -0
- package/src/modules/access-model/creator.ts +18 -21
- package/src/modules/access-model/frontmatter-lines.ts +98 -0
- package/src/modules/access-model/plugin-principals.ts +12 -19
- package/src/modules/access-model/roles-yaml-guard.ts +182 -4
- package/src/modules/admin/admin-access.service.ts +6 -2
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +100 -0
- package/src/modules/agent-instructions/__tests__/compose.test.ts +256 -0
- package/src/modules/agent-instructions/__tests__/read-preamble.test.ts +135 -0
- package/src/modules/agent-instructions/agent-instructions.routes.ts +41 -0
- package/src/modules/agent-instructions/compose.ts +271 -0
- package/src/modules/agent-instructions/index.ts +14 -0
- package/src/modules/agent-instructions/read-preamble.ts +88 -0
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +370 -0
- package/src/modules/audit/__tests__/agent-rest-audit.middleware.test.ts +113 -0
- package/src/modules/audit/__tests__/audit.routes.test.ts +201 -0
- package/src/modules/audit/__tests__/event-classifier.test.ts +121 -0
- package/src/modules/audit/agent-audit.service.ts +316 -0
- package/src/modules/audit/agent-rest-audit.middleware.ts +83 -0
- package/src/modules/audit/audit.contract.ts +164 -0
- package/src/modules/audit/audit.routes.ts +168 -0
- package/src/modules/audit/event-classifier.ts +155 -0
- package/src/modules/audit/index.ts +14 -0
- package/src/modules/audit/request-audit.ts +141 -0
- package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +209 -0
- package/src/modules/auth/__tests__/account.routes.test.ts +178 -2
- package/src/modules/auth/__tests__/auth.middleware.test.ts +78 -0
- package/src/modules/auth/__tests__/auth.service.test.ts +338 -5
- package/src/modules/auth/__tests__/oidc-auth-provider.test.ts +352 -12
- package/src/modules/auth/__tests__/onboarding-done.route.test.ts +3 -1
- package/src/modules/auth/account-erasure.service.ts +117 -10
- package/src/modules/auth/account.routes.ts +115 -14
- package/src/modules/auth/auth.routes.ts +20 -7
- package/src/modules/auth/auth.service.ts +187 -28
- package/src/modules/auth/oidc-auth-provider.ts +120 -22
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +109 -1
- package/src/modules/code-mode/code-mode.tool.ts +13 -2
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +78 -0
- package/src/modules/connection-probe/connection-probe.service.ts +59 -7
- package/src/modules/database/__tests__/advisory-lease.test.ts +152 -0
- package/src/modules/database/__tests__/advisory-lock.test.ts +147 -0
- package/src/modules/database/advisory-lock.ts +346 -0
- package/src/modules/database/connection.ts +9 -1
- package/src/modules/database/core-schema.ts +227 -0
- package/src/modules/database/migrate.ts +27 -6
- package/src/modules/declared-variables/declared-variables.routes.ts +6 -3
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +3 -0
- package/src/modules/diff/__tests__/diff.service.test.ts +3 -0
- package/src/modules/diff/diff.routes.ts +2 -4
- package/src/modules/diff/diff.service.ts +80 -79
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +472 -9
- package/src/modules/kb-fs/__tests__/node-fs.test.ts +409 -0
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +210 -1
- package/src/modules/{workspace → kb-fs}/bevel-ignore.ts +22 -15
- package/src/modules/kb-fs/file-change-notifier.ts +4 -1
- package/src/modules/kb-fs/git-guarded-filesystem.ts +136 -0
- package/src/modules/kb-fs/locking-filesystem.ts +399 -109
- package/src/modules/kb-fs/node-fs.ts +165 -0
- package/src/modules/kb-fs/read-only-filesystem.ts +18 -9
- package/src/modules/kb-fs/repo-path.ts +213 -3
- package/src/modules/kb-sync/kb-sync.routes.ts +5 -2
- package/src/modules/kb-sync/kb-sync.service.ts +8 -5
- package/src/modules/marketplace/__tests__/github-facade.test.ts +134 -2
- package/src/modules/marketplace/__tests__/marketplace-git.test.ts +2 -1
- package/src/modules/marketplace/__tests__/marketplace-repo.head.test.ts +47 -0
- package/src/modules/marketplace/git-http.routes.ts +7 -4
- package/src/modules/marketplace/github-facade/github-facade-admin.routes.ts +35 -3
- package/src/modules/marketplace/github-facade/github-facade-credentials.service.ts +55 -0
- package/src/modules/marketplace/github-facade/github-facade.routes.ts +32 -6
- package/src/modules/marketplace/github-facade/github-facade.service.ts +80 -19
- package/src/modules/marketplace/github-facade/index.ts +1 -0
- package/src/modules/marketplace/marketplace-repo.service.ts +23 -8
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +183 -13
- package/src/modules/mcp/__tests__/downstream-pool.test.ts +278 -0
- package/src/modules/mcp/__tests__/downstream-token-refresh.test.ts +178 -0
- package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +156 -0
- package/src/modules/mcp/__tests__/first-call-probe.cli-args.test.ts +178 -0
- package/src/modules/mcp/__tests__/first-call-probe.test.ts +414 -0
- package/src/modules/mcp/__tests__/manual-failure-memo.test.ts +98 -6
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +68 -7
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +1 -3
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +1055 -59
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +24 -4
- package/src/modules/mcp/__tests__/mcp.routes.stateless.test.ts +182 -0
- package/src/modules/mcp/__tests__/mcp.service.test.ts +391 -6
- package/src/modules/mcp/__tests__/surface-log-throttle.test.ts +169 -0
- package/src/modules/mcp/downstream-pool.ts +256 -0
- package/src/modules/mcp/downstream-token-refresh.ts +170 -0
- package/src/modules/mcp/first-call-probe.cli-args.ts +159 -0
- package/src/modules/mcp/first-call-probe.cli.ts +60 -0
- package/src/modules/mcp/first-call-probe.ts +418 -0
- package/src/modules/mcp/manual-failure-memo.ts +81 -23
- package/src/modules/mcp/mcp-auth.middleware.ts +56 -16
- package/src/modules/mcp/mcp.routes.ts +386 -482
- package/src/modules/mcp/mcp.service.ts +905 -177
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +189 -8
- package/src/modules/mcp/oauth/oauth-consent.routes.ts +4 -4
- package/src/modules/mcp/surface-log-throttle.ts +120 -0
- package/src/modules/plugins/__tests__/bundle-dialect.e2e.test.ts +10 -7
- package/src/modules/plugins/__tests__/bundle-dialect.test.ts +165 -0
- package/src/modules/plugins/__tests__/compile-marketplace.test.ts +139 -10
- package/src/modules/plugins/__tests__/fake-join-request-store.ts +189 -0
- package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +608 -0
- package/src/modules/plugins/__tests__/plugin-creation-display-name.test.ts +241 -0
- package/src/modules/plugins/__tests__/plugin-index.service.test.ts +148 -7
- package/src/modules/plugins/__tests__/plugin-links.service.test.ts +10 -13
- package/src/modules/plugins/__tests__/plugin-names.test.ts +77 -0
- package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +129 -19
- package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +91 -29
- package/src/modules/plugins/__tests__/plugins.routes.test.ts +370 -19
- package/src/modules/plugins/__tests__/plugins.tools.test.ts +131 -0
- package/src/modules/plugins/__tests__/teams.routes.test.ts +248 -0
- package/src/modules/plugins/compile/compile-marketplace.ts +143 -46
- package/src/modules/plugins/compile/marketplace-compiler.service.ts +8 -7
- package/src/modules/plugins/discovery/__tests__/kb-plugin-source.test.ts +56 -20
- package/src/modules/plugins/discovery/bundle-dialect/bundle.source.ts +30 -9
- package/src/modules/plugins/discovery/bundle-dialect/registry.ts +93 -18
- package/src/modules/plugins/discovery/kb-plugin-source.ts +10 -7
- package/src/modules/plugins/discovery/native.source.ts +21 -3
- package/src/modules/plugins/discovery/plugin-source.ts +17 -3
- package/src/modules/plugins/index.ts +16 -3
- package/src/modules/plugins/join-request-jobs.service.ts +675 -0
- package/src/modules/plugins/join-request-records.store.ts +353 -0
- package/src/modules/plugins/join-requests.service.ts +7 -4
- package/src/modules/plugins/plugin-links.service.ts +4 -17
- package/src/modules/plugins/plugin-links.ts +6 -3
- package/src/modules/plugins/plugin-provision.service.ts +209 -37
- package/src/modules/plugins/plugin-rename.service.ts +21 -9
- package/src/modules/plugins/plugins.contract.ts +52 -4
- package/src/modules/plugins/plugins.routes.ts +204 -124
- package/src/modules/plugins/plugins.service.ts +92 -18
- package/src/modules/plugins/plugins.tools.ts +88 -0
- package/src/modules/plugins/teams.routes.ts +129 -0
- package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +359 -15
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +248 -0
- package/src/modules/secrets-vault/__tests__/namespace-secrets.test.ts +202 -0
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +217 -35
- package/src/modules/secrets-vault/index.ts +1 -0
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +6 -3
- package/src/modules/secrets-vault/secrets-variable-loader.ts +4 -4
- package/src/modules/secrets-vault/secrets-vault.contract.ts +93 -0
- package/src/modules/secrets-vault/secrets-vault.routes.ts +99 -38
- package/src/modules/settings/__tests__/connection-check.test.ts +233 -0
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +223 -6
- package/src/modules/settings/__tests__/git-root-folders.test.ts +247 -0
- package/src/modules/settings/__tests__/oidc-check.test.ts +185 -0
- package/src/modules/settings/__tests__/setup.routes.boot-failure.test.ts +119 -0
- package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +170 -0
- package/src/modules/settings/__tests__/setup.routes.oidc.test.ts +555 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +667 -16
- package/src/modules/settings/connection-check.ts +351 -0
- package/src/modules/settings/deployment-settings.service.ts +444 -59
- package/src/modules/settings/git-root-folders.ts +171 -0
- package/src/modules/settings/oidc-check.ts +229 -0
- package/src/modules/settings/setup.routes.ts +456 -110
- package/src/modules/skills/__tests__/allowed-tools-check.test.ts +224 -0
- package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +210 -0
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +3 -3
- package/src/modules/skills/__tests__/skills.routes.warnings.test.ts +91 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +223 -2
- package/src/modules/skills/allowed-tools-check.ts +352 -0
- package/src/modules/skills/index.ts +4 -0
- package/src/modules/skills/pending-skills.service.ts +21 -82
- package/src/modules/skills/skill-access-requests.routes.ts +10 -6
- package/src/modules/skills/skills.contract.ts +43 -11
- package/src/modules/skills/skills.routes.ts +16 -1
- package/src/modules/skills/skills.service.ts +214 -84
- package/src/modules/skills/skills.tools.ts +43 -5
- package/src/modules/tool-auth/__tests__/connection-key-rejection.test.ts +192 -0
- package/src/modules/tool-auth/__tests__/connection-keys-admin.routes.test.ts +116 -0
- package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +153 -8
- package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +6 -0
- package/src/modules/tool-auth/connection-key-rejection.ts +25 -0
- package/src/modules/tool-auth/connection-keys-admin.routes.ts +76 -0
- package/src/modules/tool-auth/external-api-key.interface.ts +78 -8
- package/src/modules/tool-auth/external-api-key.service.ts +98 -33
- package/src/modules/tool-auth/internal-token.service.ts +16 -2
- package/src/modules/tool-auth/key-or-session.middleware.ts +45 -0
- package/src/modules/tool-auth/tool-auth.middleware.ts +33 -8
- package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +228 -0
- package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +33 -4
- package/src/modules/tool-helpers/tool-context.ts +46 -7
- package/src/modules/tool-helpers/tool-handler.ts +23 -5
- package/src/modules/tool-helpers/tool.contract.ts +5 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -1
- package/src/modules/tool-manuals/__tests__/pending-tools.service.test.ts +411 -0
- package/src/modules/tool-manuals/__tests__/tool-delete.route.test.ts +133 -0
- package/src/modules/tool-manuals/__tests__/tool-delete.service.test.ts +469 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +21 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +9 -4
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +6 -1
- package/src/modules/tool-manuals/__tests__/tool-manuals.invalid.routes.test.ts +254 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.invalid.test.ts +577 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +6 -1
- package/src/modules/tool-manuals/__tests__/tool-manuals.pending.route.test.ts +102 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.restart.test.ts +285 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +178 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +114 -2
- package/src/modules/tool-manuals/index.ts +3 -0
- package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -10
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +11 -14
- package/src/modules/tool-manuals/pending-tools.service.ts +179 -0
- package/src/modules/tool-manuals/tool-delete.service.ts +401 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +165 -0
- package/src/modules/tool-manuals/tool-manuals.routes.ts +138 -54
- package/src/modules/tool-manuals/tool-manuals.service.ts +432 -87
- package/src/modules/tool-manuals/tool-manuals.tools.ts +81 -6
- package/src/modules/workflow/__tests__/apply-failure.test.ts +1038 -0
- package/src/modules/workflow/__tests__/event-bus.test.ts +61 -9
- package/src/modules/workflow/__tests__/fake-file-lock-db.ts +199 -0
- package/src/modules/workflow/__tests__/file-lock.service.path-identity.test.ts +237 -0
- package/src/modules/workflow/__tests__/format-affected-owners.test.ts +18 -0
- package/src/modules/workflow/__tests__/merge-announces-tree.test.ts +220 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.concurrency.test.ts +187 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +2 -0
- package/src/modules/workflow/__tests__/sanitize-error.test.ts +10 -0
- package/src/modules/workflow/__tests__/workflow.routes.folder-proposals.test.ts +128 -0
- package/src/modules/workflow/__tests__/workflow.routes.fork-point-file.test.ts +149 -0
- package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +305 -0
- package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +2 -0
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +2 -0
- package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +2 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +294 -25
- package/src/modules/workflow/__tests__/workflow.service.folder-proposals.test.ts +564 -0
- package/src/modules/workflow/__tests__/workflow.service.platform-restore.test.ts +135 -0
- package/src/modules/workflow/__tests__/workflow.service.read-gate.test.ts +172 -0
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +14 -0
- package/src/modules/workflow/__tests__/workflow.service.sync.test.ts +5 -0
- package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +281 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +158 -7
- package/src/modules/workflow/agent-tools/workflow.tools.ts +93 -64
- package/src/modules/workflow/event-bus.ts +26 -28
- package/src/modules/workflow/events.routes.ts +56 -21
- package/src/modules/workflow/file-lock.service.ts +27 -4
- package/src/modules/workflow/git/__tests__/change-request-link.test.ts +43 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +1 -0
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +238 -1
- package/src/modules/workflow/git/__tests__/git.service.forkPoint.test.ts +245 -0
- package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +35 -0
- package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +149 -0
- package/src/modules/workflow/git/__tests__/git.service.revertPathsAndPush.test.ts +177 -0
- package/src/modules/workflow/git/__tests__/node-git-runner.test.ts +114 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +69 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +121 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +79 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-update.test.ts +111 -0
- package/src/modules/workflow/git/change-request-link.ts +35 -0
- package/src/modules/workflow/git/git-version.ts +5 -2
- package/src/modules/workflow/git/git.service.ts +684 -144
- package/src/modules/workflow/git/node-git-runner.ts +315 -0
- package/src/modules/workflow/git/pull-request.service.ts +179 -27
- package/src/modules/workflow/pending-commits.service.ts +26 -7
- package/src/modules/workflow/pending-commits.worker.ts +114 -69
- package/src/modules/workflow/recovery-bot.ts +4 -1
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +180 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
- package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +520 -0
- package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +84 -0
- package/src/modules/workflow/review-workflow/__tests__/merge-gate.test.ts +89 -77
- package/src/modules/workflow/review-workflow/approval-lock.ts +122 -0
- package/src/modules/workflow/review-workflow/review-workflow.interface.ts +59 -7
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +309 -105
- package/src/modules/workflow/sanitize-error.ts +9 -4
- package/src/modules/workflow/workflow.routes.ts +273 -29
- package/src/modules/workflow/workflow.service.ts +1087 -171
- package/src/modules/workspace/__tests__/empty-dirs.test.ts +44 -0
- package/src/modules/workspace/__tests__/file-stat-access.test.ts +301 -0
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +883 -0
- package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +464 -0
- package/src/modules/workspace/__tests__/workspace.routes.branch-errors.test.ts +139 -0
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +189 -37
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +51 -11
- package/src/modules/workspace/__tests__/workspace.routes.download-implies-read.test.ts +161 -0
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +109 -10
- package/src/modules/workspace/__tests__/workspace.routes.file-writes.test.ts +624 -0
- package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +409 -0
- package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +327 -0
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +25 -6
- package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +109 -0
- package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -5
- package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +218 -0
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +56 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +811 -42
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +197 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +128 -0
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +212 -0
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +2632 -110
- package/src/modules/workspace/__tests__/write-denial.test.ts +75 -0
- package/src/modules/workspace/empty-dirs.ts +79 -0
- package/src/modules/workspace/file-readers/README.md +32 -0
- package/src/modules/workspace/file-readers/__tests__/doc-extract.test.ts +59 -2
- package/src/modules/workspace/file-readers/__tests__/file-reader.registry.test.ts +133 -4
- package/src/modules/workspace/file-readers/__tests__/fixtures/soft-break.docx +0 -0
- package/src/modules/workspace/file-readers/__tests__/fixtures/tab.docx +0 -0
- package/src/modules/workspace/file-readers/content-mode.ts +87 -0
- package/src/modules/workspace/file-readers/doc-extract.service.ts +4 -1
- package/src/modules/workspace/file-readers/doc-extract.types.ts +8 -4
- package/src/modules/workspace/file-readers/document-reader.ts +6 -0
- package/src/modules/workspace/file-readers/extract-docx.ts +6 -0
- package/src/modules/workspace/file-readers/file-reader.registry.ts +9 -1
- package/src/modules/workspace/file-readers/file-reader.ts +27 -6
- package/src/modules/workspace/file-readers/image-reader.ts +10 -5
- package/src/modules/workspace/file-readers/ooxml-text.ts +38 -2
- package/src/modules/workspace/file-readers/text-reader.ts +61 -4
- package/src/modules/workspace/git-internals.middleware.ts +86 -0
- package/src/modules/workspace/not-found.ts +119 -0
- package/src/modules/workspace/session-ontology.gate.ts +1 -1
- package/src/modules/workspace/startup/__tests__/beside-checkout.test.ts +181 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +361 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +53 -16
- package/src/modules/workspace/startup/beside-checkout.ts +112 -0
- package/src/modules/workspace/startup/kb-git.ts +43 -36
- package/src/modules/workspace/startup/kb-startup-runner.ts +303 -63
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +1215 -73
- package/src/modules/workspace/startup/steps/groups-to-plugins.step.ts +460 -417
- package/src/modules/workspace/startup/steps/personal-spaces.step.ts +191 -0
- package/src/modules/workspace/startup/steps/plugin-display-names.step.ts +169 -0
- package/src/modules/workspace/startup/steps/plugin-layout.ts +110 -0
- package/src/modules/workspace/startup/steps/plugin-manifests.step.ts +46 -85
- package/src/modules/workspace/startup/steps/roles-yaml.step.ts +6 -12
- package/src/modules/workspace/startup/steps/seed-tree.ts +159 -78
- package/src/modules/workspace/startup/steps/template-files.step.ts +492 -141
- package/src/modules/workspace/startup/steps/template-source.ts +168 -0
- package/src/modules/workspace/workspace.routes.ts +687 -257
- package/src/modules/workspace/workspace.service.ts +1000 -303
- package/src/modules/workspace/workspace.tools.ts +1823 -180
- package/src/modules/workspace/write-denial.ts +189 -0
- package/src/shared/__tests__/canonical-file-identity.test.ts +154 -0
- package/src/shared/__tests__/email-identity.test.ts +35 -0
- package/src/shared/__tests__/filename.test.ts +0 -0
- package/src/shared/__tests__/frontmatter-id.test.ts +5 -0
- package/src/shared/__tests__/git-failure.test.ts +198 -0
- package/src/shared/__tests__/git-internals.test.ts +156 -0
- package/src/shared/__tests__/http-errors.test.ts +42 -0
- package/src/shared/__tests__/logging.test.ts +185 -0
- package/src/shared/__tests__/path-containment.test.ts +60 -0
- package/src/shared/__tests__/pending-proposals.readAt.test.ts +102 -0
- package/src/shared/__tests__/printable.test.ts +64 -0
- package/src/shared/__tests__/redact-secret.test.ts +122 -0
- package/src/shared/__tests__/rename-no-replace.test.ts +466 -0
- package/src/shared/__tests__/ssrf.test.ts +20 -0
- package/src/shared/canonical-file-identity.ts +92 -0
- package/src/shared/domain-errors.ts +149 -0
- package/src/shared/email-identity.ts +38 -0
- package/src/shared/fs.contract.ts +225 -0
- package/src/shared/git-failure.ts +196 -0
- package/src/shared/git-internals.ts +179 -0
- package/src/shared/git.contract.ts +170 -0
- package/src/shared/http-errors.ts +36 -0
- package/src/shared/logger.contract.ts +141 -0
- package/src/shared/logging.ts +96 -0
- package/src/shared/path-containment.ts +34 -0
- package/src/shared/pending-proposals.ts +161 -0
- package/src/shared/printable.ts +68 -0
- package/src/shared/redact-secret.ts +118 -0
- package/src/shared/rename-no-replace.ts +386 -0
- package/src/shared/ssrf.ts +44 -16
- package/src/version.ts +10 -6
- package/dist/modules/access/access-errors.d.ts +0 -34
- package/dist/modules/access/access-errors.d.ts.map +0 -1
- package/dist/modules/access/access-errors.js +0 -40
- package/dist/modules/access/access-errors.js.map +0 -1
- package/dist/modules/access/access-splice.d.ts +0 -140
- package/dist/modules/access/access-splice.d.ts.map +0 -1
- package/dist/modules/access/access-splice.js +0 -389
- package/dist/modules/access/access-splice.js.map +0 -1
- package/dist/modules/access/group-files.d.ts +0 -83
- package/dist/modules/access/group-files.d.ts.map +0 -1
- package/dist/modules/access/group-files.js +0 -167
- package/dist/modules/access/group-files.js.map +0 -1
- package/dist/modules/access/kb-read-filter.d.ts +0 -41
- package/dist/modules/access/kb-read-filter.d.ts.map +0 -1
- package/dist/modules/access/kb-read-filter.js +0 -60
- package/dist/modules/access/kb-read-filter.js.map +0 -1
- package/dist/modules/access/render-roles-yaml.d.ts +0 -22
- package/dist/modules/access/render-roles-yaml.d.ts.map +0 -1
- package/dist/modules/access/render-roles-yaml.js +0 -56
- package/dist/modules/access/render-roles-yaml.js.map +0 -1
- package/dist/modules/access/roles-yaml-guard.d.ts +0 -56
- package/dist/modules/access/roles-yaml-guard.d.ts.map +0 -1
- package/dist/modules/access/roles-yaml-guard.js +0 -79
- package/dist/modules/access/roles-yaml-guard.js.map +0 -1
- package/dist/modules/diff/diff-paths.d.ts +0 -2
- package/dist/modules/diff/diff-paths.d.ts.map +0 -1
- package/dist/modules/diff/diff-paths.js +0 -9
- package/dist/modules/diff/diff-paths.js.map +0 -1
- package/dist/modules/groups/group-provision.service.d.ts +0 -130
- package/dist/modules/groups/group-provision.service.d.ts.map +0 -1
- package/dist/modules/groups/group-provision.service.js +0 -288
- package/dist/modules/groups/group-provision.service.js.map +0 -1
- package/dist/modules/groups/groups.contract.d.ts +0 -106
- package/dist/modules/groups/groups.contract.d.ts.map +0 -1
- package/dist/modules/groups/groups.contract.js +0 -36
- package/dist/modules/groups/groups.contract.js.map +0 -1
- package/dist/modules/groups/groups.routes.d.ts +0 -42
- package/dist/modules/groups/groups.routes.d.ts.map +0 -1
- package/dist/modules/groups/groups.routes.js +0 -379
- package/dist/modules/groups/groups.routes.js.map +0 -1
- package/dist/modules/groups/groups.service.d.ts +0 -60
- package/dist/modules/groups/groups.service.d.ts.map +0 -1
- package/dist/modules/groups/groups.service.js +0 -172
- package/dist/modules/groups/groups.service.js.map +0 -1
- package/dist/modules/groups/index.d.ts +0 -7
- package/dist/modules/groups/index.d.ts.map +0 -1
- package/dist/modules/groups/index.js +0 -6
- package/dist/modules/groups/index.js.map +0 -1
- package/dist/modules/groups/join-proposals.d.ts +0 -53
- package/dist/modules/groups/join-proposals.d.ts.map +0 -1
- package/dist/modules/groups/join-proposals.js +0 -67
- package/dist/modules/groups/join-proposals.js.map +0 -1
- package/dist/modules/groups/join-requests.service.d.ts +0 -81
- package/dist/modules/groups/join-requests.service.d.ts.map +0 -1
- package/dist/modules/groups/join-requests.service.js +0 -135
- package/dist/modules/groups/join-requests.service.js.map +0 -1
- package/dist/modules/mcp/mcp-session-store.d.ts +0 -74
- package/dist/modules/mcp/mcp-session-store.d.ts.map +0 -1
- package/dist/modules/mcp/mcp-session-store.js +0 -131
- package/dist/modules/mcp/mcp-session-store.js.map +0 -1
- package/dist/modules/workflow/file-change-notifier.d.ts +0 -38
- package/dist/modules/workflow/file-change-notifier.d.ts.map +0 -1
- package/dist/modules/workflow/file-change-notifier.js +0 -22
- package/dist/modules/workflow/file-change-notifier.js.map +0 -1
- package/dist/modules/workflow/git/branch-name.d.ts +0 -10
- package/dist/modules/workflow/git/branch-name.d.ts.map +0 -1
- package/dist/modules/workflow/git/branch-name.js +0 -76
- package/dist/modules/workflow/git/branch-name.js.map +0 -1
- package/dist/modules/workflow/git/clone-config.d.ts +0 -61
- package/dist/modules/workflow/git/clone-config.d.ts.map +0 -1
- package/dist/modules/workflow/git/clone-config.js +0 -69
- package/dist/modules/workflow/git/clone-config.js.map +0 -1
- package/dist/modules/workflow/git/mutex.d.ts +0 -11
- package/dist/modules/workflow/git/mutex.d.ts.map +0 -1
- package/dist/modules/workflow/git/mutex.js +0 -23
- package/dist/modules/workflow/git/mutex.js.map +0 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +0 -137
- package/dist/modules/workflow/locking-filesystem.d.ts.map +0 -1
- package/dist/modules/workflow/locking-filesystem.js +0 -553
- package/dist/modules/workflow/locking-filesystem.js.map +0 -1
- package/dist/modules/workflow/read-only-filesystem.d.ts +0 -24
- package/dist/modules/workflow/read-only-filesystem.d.ts.map +0 -1
- package/dist/modules/workflow/read-only-filesystem.js +0 -39
- package/dist/modules/workflow/read-only-filesystem.js.map +0 -1
- package/dist/modules/workflow/workflow.errors.d.ts +0 -197
- package/dist/modules/workflow/workflow.errors.d.ts.map +0 -1
- package/dist/modules/workflow/workflow.errors.js +0 -298
- package/dist/modules/workflow/workflow.errors.js.map +0 -1
- package/dist/modules/workspace/bevel-ignore.d.ts.map +0 -1
- package/dist/modules/workspace/bevel-ignore.js.map +0 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +0 -36
- package/dist/modules/workspace/kb-seed.interface.d.ts.map +0 -1
- package/dist/modules/workspace/kb-seed.interface.js +0 -2
- package/dist/modules/workspace/kb-seed.interface.js.map +0 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +0 -125
- package/dist/modules/workspace/kb-seed.service.d.ts.map +0 -1
- package/dist/modules/workspace/kb-seed.service.js +0 -535
- package/dist/modules/workspace/kb-seed.service.js.map +0 -1
- package/dist/modules/workspace/plugins-migration.d.ts +0 -50
- package/dist/modules/workspace/plugins-migration.d.ts.map +0 -1
- package/dist/modules/workspace/plugins-migration.js +0 -379
- package/dist/modules/workspace/plugins-migration.js.map +0 -1
- package/dist/shared/fs-errors.d.ts +0 -8
- package/dist/shared/fs-errors.d.ts.map +0 -1
- package/dist/shared/fs-errors.js +0 -11
- package/dist/shared/fs-errors.js.map +0 -1
- package/dist/shared/fs-walk.d.ts +0 -23
- package/dist/shared/fs-walk.d.ts.map +0 -1
- package/dist/shared/fs-walk.js +0 -42
- package/dist/shared/fs-walk.js.map +0 -1
- package/dist/shared/hash-email.d.ts +0 -11
- package/dist/shared/hash-email.d.ts.map +0 -1
- package/dist/shared/hash-email.js +0 -14
- package/dist/shared/hash-email.js.map +0 -1
- package/dist/shared/kb-walk.d.ts +0 -52
- package/dist/shared/kb-walk.d.ts.map +0 -1
- package/dist/shared/kb-walk.js +0 -76
- package/dist/shared/kb-walk.js.map +0 -1
- package/src/modules/diff/diff-paths.ts +0 -9
- package/src/modules/mcp/__tests__/mcp-session-store.test.ts +0 -151
- package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +0 -226
- package/src/modules/mcp/mcp-session-store.ts +0 -162
- package/src/shared/__tests__/fs-walk.test.ts +0 -63
- package/src/shared/__tests__/kb-walk.test.ts +0 -107
- package/src/shared/fs-errors.ts +0 -10
- package/src/shared/fs-walk.ts +0 -45
- package/src/shared/hash-email.ts +0 -14
- package/src/shared/kb-walk.ts +0 -95
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
import type { Server as HttpServer } from 'node:http';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
|
-
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { link, mkdir, mkdtemp, readdir as nodeReaddir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import AdmZip from 'adm-zip';
|
|
8
8
|
import express from 'express';
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
10
|
import { LocalFilesystem } from '@mastra/core/workspace';
|
|
11
11
|
import { ToolRegistry } from '../../tool-registry/tool-registry.js';
|
|
12
12
|
import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
13
13
|
import type { ToolContext } from '../../tool-helpers/tool.contract.js';
|
|
14
14
|
import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
|
|
15
|
-
import { registerWorkspaceTools } from '../workspace.tools.js';
|
|
15
|
+
import { CONTENT_RULE, registerWorkspaceTools } from '../workspace.tools.js';
|
|
16
16
|
import { RoutineWritePolicyService } from '../routine-write-policy.js';
|
|
17
|
+
import { UuidSessionSink, type ISessionSink } from '../session-sink.js';
|
|
17
18
|
import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
|
|
18
19
|
import { SpillStore } from '../spill-store.js';
|
|
19
20
|
import { DocExtractService } from '../file-readers/doc-extract.service.js';
|
|
21
|
+
import { OCTET_STREAM_FALLBACK_NOTE } from '../file-readers/content-mode.js';
|
|
20
22
|
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
23
|
+
import { isBranchAuthoredBy, isOwnSuggestionsBranch } from '@bevel-software/platform-shared';
|
|
21
24
|
import { assertValidBranchName } from '../../kb-fs/branch-name.js';
|
|
25
|
+
import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
|
|
26
|
+
import { GIT_INTERNALS_MESSAGE, PathNotFoundError } from '../../../shared/domain-errors.js';
|
|
27
|
+
import { AccessDeniedError } from '../../access-model/access-errors.js';
|
|
28
|
+
import { PROPOSAL_ROUTE_NOTE, proposalTitleFor } from '../write-denial.js';
|
|
29
|
+
import { NOT_FOUND_NEXT_STEP } from '../not-found.js';
|
|
22
30
|
|
|
23
31
|
const KB_DIR = 'knowledge-base';
|
|
24
32
|
|
|
@@ -27,6 +35,13 @@ const allowAll = {
|
|
|
27
35
|
canRead: async () => true,
|
|
28
36
|
canReadBatch: async (_w: string, _u: string, paths: string[]) =>
|
|
29
37
|
new Map(paths.map((p) => [p, true])),
|
|
38
|
+
// The verbs `file_stat` and the move/delete preflight ask about. Allowing
|
|
39
|
+
// everything, and answering "no rules at HEAD" for the batch write check,
|
|
40
|
+
// keeps these tests about what they test rather than about access.
|
|
41
|
+
canWrite: async () => true,
|
|
42
|
+
canDownload: async () => true,
|
|
43
|
+
canOwner: async () => true,
|
|
44
|
+
canWriteBatchAtRef: async () => null,
|
|
30
45
|
} as unknown as IAccessControl;
|
|
31
46
|
|
|
32
47
|
/** Access control that denies `canRead` for an explicit set of repo-relative paths. */
|
|
@@ -58,6 +73,10 @@ let fs: LocalFilesystem;
|
|
|
58
73
|
* named "undefined" is attempted.
|
|
59
74
|
*/
|
|
60
75
|
let workspacePathCalls: string[] = [];
|
|
76
|
+
/** Every `(archive, destination)` pair `unzip` handed the service. */
|
|
77
|
+
let unzipCalls: [string, string | undefined][] = [];
|
|
78
|
+
/** Every folder turn a tool took, as `workspaceId:dir`. */
|
|
79
|
+
let folderTurns: string[] = [];
|
|
61
80
|
/** The policy instance the tools were mounted with, so a test can restrict a session. */
|
|
62
81
|
let writePolicy: RoutineWritePolicyService;
|
|
63
82
|
/**
|
|
@@ -68,23 +87,89 @@ let writePolicy: RoutineWritePolicyService;
|
|
|
68
87
|
let focusedBranch: string | undefined;
|
|
69
88
|
/** The registry the tools were mounted into, so a test can inspect their defs. */
|
|
70
89
|
let toolRegistry: ToolRegistry;
|
|
90
|
+
/**
|
|
91
|
+
* Another writer getting to a path in the window the real `LockingFilesystem`
|
|
92
|
+
* closes: the stand-ins below run it where that filesystem would be acquiring
|
|
93
|
+
* the lock — after the tool's preflight, before the under-lock `check`. Fires
|
|
94
|
+
* ONCE (it clears itself), so a test's later writes are ordinary ones.
|
|
95
|
+
*/
|
|
96
|
+
let raceHook: (() => Promise<void>) | null = null;
|
|
71
97
|
|
|
72
98
|
async function start(
|
|
73
99
|
scope: 'read' | 'write' = 'write',
|
|
74
100
|
access: IAccessControl = allowAll,
|
|
101
|
+
/** The signed-in caller's address — only the branch-naming tests vary it. */
|
|
102
|
+
userEmail = 'e@x',
|
|
75
103
|
): Promise<string> {
|
|
104
|
+
raceHook = null;
|
|
76
105
|
tempDir = await mkdtemp(join(tmpdir(), 'ws-tools-'));
|
|
77
106
|
docCacheDir = await mkdtemp(join(tmpdir(), 'ws-doc-cache-'));
|
|
78
107
|
fs = new LocalFilesystem({ basePath: tempDir, contained: true });
|
|
108
|
+
// The harness speaks the paths the TOOLS speak. A fixture written here as
|
|
109
|
+
// `report.docx` must land where a tool asked for `report.docx` will read it:
|
|
110
|
+
// inside the checkout, through the one normaliser. Before the normaliser
|
|
111
|
+
// existed, this file's fixtures sat BESIDE the clone — the very bug the
|
|
112
|
+
// ticket is about — and every test would otherwise have to restate the
|
|
113
|
+
// prefix. Idempotent, so the tools' own already-prefixed paths pass through.
|
|
114
|
+
for (const method of ['readFile', 'writeFile', 'appendFile', 'deleteFile', 'mkdir', 'stat'] as const) {
|
|
115
|
+
const inner = (fs as unknown as Record<string, (...a: unknown[]) => unknown>)[method].bind(fs);
|
|
116
|
+
(fs as unknown as Record<string, unknown>)[method] = (path: string, ...rest: unknown[]) =>
|
|
117
|
+
inner(normalizeWorkspacePath(path, KB_DIR), ...rest);
|
|
118
|
+
}
|
|
119
|
+
// `LockingFilesystem.writeFiles` is what the real `delete_folder` and
|
|
120
|
+
// `write_files` land through — one lock cycle over every path, then one
|
|
121
|
+
// commit. A plain LocalFilesystem has no such method, so stand in for its
|
|
122
|
+
// DISK effect and let the tools be exercised end to end. This stand-in is
|
|
123
|
+
// NOT atomic, and these tests do not claim atomicity: they assert what the
|
|
124
|
+
// TOOL controls — that it hands the whole folder over in ONE batch. The
|
|
125
|
+
// batch's own all-or-none behaviour is asserted where it lives, in
|
|
126
|
+
// `locking-filesystem.test.ts` ("a DELETE batch whose later lock is
|
|
127
|
+
// contended deletes nothing").
|
|
128
|
+
// Both stand-ins honour the caller's under-lock `check` the way the real
|
|
129
|
+
// filesystem does — run it with the paths "locked" (here: just before any
|
|
130
|
+
// byte lands), let a refusal through untouched, and in the batch case write
|
|
131
|
+
// only what the check keeps. That is what makes the outcome the tools report
|
|
132
|
+
// a verdict about the state they actually wrote over.
|
|
133
|
+
const runRaceHook = async (): Promise<void> => {
|
|
134
|
+
const hook = raceHook;
|
|
135
|
+
raceHook = null;
|
|
136
|
+
if (hook) await hook();
|
|
137
|
+
};
|
|
138
|
+
const plainWriteFile = fs.writeFile.bind(fs);
|
|
139
|
+
(fs as unknown as Record<string, unknown>).writeFile = async (
|
|
140
|
+
path: string,
|
|
141
|
+
content: string,
|
|
142
|
+
options?: unknown,
|
|
143
|
+
check?: () => Promise<void>,
|
|
144
|
+
) => {
|
|
145
|
+
await runRaceHook();
|
|
146
|
+
await check?.();
|
|
147
|
+
return plainWriteFile(path, content, options as never);
|
|
148
|
+
};
|
|
149
|
+
(fs as unknown as Record<string, unknown>).writeFiles = async (
|
|
150
|
+
writes: { path: string; content: string }[],
|
|
151
|
+
_summary: string,
|
|
152
|
+
deletes: string[] = [],
|
|
153
|
+
check?: (
|
|
154
|
+
pending: readonly { path: string; content: string }[],
|
|
155
|
+
) => Promise<{ path: string; content: string }[]>,
|
|
156
|
+
) => {
|
|
157
|
+
await runRaceHook();
|
|
158
|
+
const landing = check ? await check(writes) : writes;
|
|
159
|
+
for (const w of landing) await plainWriteFile(w.path, w.content);
|
|
160
|
+
for (const p of deletes) await fs.deleteFile(p);
|
|
161
|
+
};
|
|
79
162
|
await fs.writeFile('a.md', 'hello\nworld\n');
|
|
80
163
|
workspacePathCalls = [];
|
|
164
|
+
unzipCalls = [];
|
|
165
|
+
folderTurns = [];
|
|
81
166
|
writePolicy = new RoutineWritePolicyService();
|
|
82
167
|
focusedBranch = undefined;
|
|
83
168
|
|
|
84
169
|
const registry = new ToolRegistry();
|
|
85
170
|
toolRegistry = registry;
|
|
86
171
|
const resolve = async (auth: ToolAuth, signal: AbortSignal, sessionId?: string): Promise<ToolContext> => ({
|
|
87
|
-
user: { id: 'u', email:
|
|
172
|
+
user: { id: 'u', email: userEmail, name: 'N' },
|
|
88
173
|
scope: auth.scope,
|
|
89
174
|
source: auth.source,
|
|
90
175
|
sessionId,
|
|
@@ -101,6 +186,25 @@ async function start(
|
|
|
101
186
|
workspacePathCalls.push(id);
|
|
102
187
|
return tempDir;
|
|
103
188
|
},
|
|
189
|
+
withFolderTurn: async <T>(id: string, dir: string, op: () => Promise<T>) => {
|
|
190
|
+
folderTurns.push(`${id}:${dir}`);
|
|
191
|
+
return op();
|
|
192
|
+
},
|
|
193
|
+
// Enough of `unzipFile` to exercise the TOOL: the one answer the tool
|
|
194
|
+
// shapes is absence, and the real service raises exactly this error for
|
|
195
|
+
// it (asserted in workspace.service.test.ts). Extraction itself lives
|
|
196
|
+
// there too — none of it is the tool's to decide.
|
|
197
|
+
unzipFile: async (_id: string, zipRel: string, destRel?: string) => {
|
|
198
|
+
// Recorded so a test can assert the PATHS the tool handed over — both
|
|
199
|
+
// ends normalised into the repository.
|
|
200
|
+
unzipCalls.push([zipRel, destRel]);
|
|
201
|
+
try {
|
|
202
|
+
await stat(join(tempDir, zipRel));
|
|
203
|
+
} catch {
|
|
204
|
+
throw new PathNotFoundError(zipRel);
|
|
205
|
+
}
|
|
206
|
+
return { destination: '', extracted: [], skipped: [] };
|
|
207
|
+
},
|
|
104
208
|
} as never,
|
|
105
209
|
workflowService: {} as never,
|
|
106
210
|
events: {} as never,
|
|
@@ -169,38 +273,137 @@ async function isDeadOrZombie(pid: number): Promise<boolean> {
|
|
|
169
273
|
describe('workspace file primitives', () => {
|
|
170
274
|
it('read_file returns the content', async () => {
|
|
171
275
|
const base = await start();
|
|
172
|
-
expect(await (await post(`${base}/api/agent/tools/read_file`, { path:
|
|
276
|
+
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n' });
|
|
173
277
|
});
|
|
174
278
|
|
|
175
279
|
it('write_file then read_file round-trips', async () => {
|
|
176
280
|
const base = await start();
|
|
177
|
-
await post(`${base}/api/agent/tools/write_file`, { path:
|
|
178
|
-
expect(await (await post(`${base}/api/agent/tools/read_file`, { path:
|
|
281
|
+
await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/b.md`, content: 'fresh' });
|
|
282
|
+
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/b.md` })).json()).toMatchObject({ content: 'fresh' });
|
|
179
283
|
});
|
|
180
284
|
|
|
181
285
|
it('edit_file replaces an exact unique string', async () => {
|
|
182
286
|
const base = await start();
|
|
183
|
-
const res = await post(`${base}/api/agent/tools/edit_file`, { path:
|
|
184
|
-
expect(await res.json()).toMatchObject({ path:
|
|
185
|
-
expect(await (await post(`${base}/api/agent/tools/read_file`, { path:
|
|
287
|
+
const res = await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'world', new_string: 'earth' });
|
|
288
|
+
expect(await res.json()).toMatchObject({ path: `${KB_DIR}/a.md`, replaced: 1 });
|
|
289
|
+
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).json()).toMatchObject({ content: 'hello\nearth\n' });
|
|
186
290
|
});
|
|
187
291
|
|
|
188
292
|
it('edit_file 400s when old_string is missing', async () => {
|
|
189
293
|
const base = await start();
|
|
190
|
-
expect((await post(`${base}/api/agent/tools/edit_file`, { path:
|
|
294
|
+
expect((await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'nope', new_string: 'x' })).status).toBe(400);
|
|
191
295
|
});
|
|
192
296
|
|
|
193
297
|
it('list_files + file_stat', async () => {
|
|
194
298
|
const base = await start();
|
|
195
|
-
const
|
|
299
|
+
const root = (await (await post(`${base}/api/agent/tools/list_files`, {})).json()) as { entries: { name: string }[] };
|
|
300
|
+
// The workspace root holds the checkout and nothing else now.
|
|
301
|
+
expect(root.entries.map((e) => e.name)).toEqual([KB_DIR]);
|
|
302
|
+
const list = (await (await post(`${base}/api/agent/tools/list_files`, { path: KB_DIR })).json()) as { entries: { name: string }[] };
|
|
196
303
|
expect(list.entries.map((e) => e.name)).toContain('a.md');
|
|
197
|
-
expect(await (await post(`${base}/api/agent/tools/file_stat`, { path:
|
|
304
|
+
expect(await (await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/a.md` })).json()).toMatchObject({ type: 'file' });
|
|
198
305
|
});
|
|
199
306
|
|
|
200
307
|
it('grep finds a match with line number', async () => {
|
|
201
308
|
const base = await start();
|
|
202
309
|
const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'wor' })).json()) as { matches: { path: string; line: number }[] };
|
|
203
|
-
expect(res.matches).toContainEqual(expect.objectContaining({ path:
|
|
310
|
+
expect(res.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/a.md`, line: 2 }));
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Copy path gives the root-anchored `/<kbDirName>/…`, and people paste
|
|
314
|
+
// that same text into an agent: a leading slash names the same path.
|
|
315
|
+
it('every path input accepts a leading slash as the same workspace path', async () => {
|
|
316
|
+
const base = await start();
|
|
317
|
+
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `/${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n' });
|
|
318
|
+
expect(await (await post(`${base}/api/agent/tools/file_stat`, { path: `/${KB_DIR}/a.md` })).json()).toMatchObject({ type: 'file' });
|
|
319
|
+
await post(`${base}/api/agent/tools/write_file`, { path: `/${KB_DIR}/b.md`, content: 'fresh' });
|
|
320
|
+
await post(`${base}/api/agent/tools/write_file`, { path: `/${KB_DIR}/c.md`, content: 'batch' });
|
|
321
|
+
await post(`${base}/api/agent/tools/edit_file`, { path: `/${KB_DIR}/a.md`, old_string: 'world', new_string: 'earth' });
|
|
322
|
+
await post(`${base}/api/agent/tools/mkdir`, { path: `/${KB_DIR}/dir` });
|
|
323
|
+
await post(`${base}/api/agent/tools/copy_file`, { src: `/${KB_DIR}/b.md`, dest: `/${KB_DIR}/dir/b-copy.md` });
|
|
324
|
+
await post(`${base}/api/agent/tools/move_file`, { src: `/${KB_DIR}/c.md`, dest: `/${KB_DIR}/dir/c.md` });
|
|
325
|
+
expect(await readFile(join(tempDir, `${KB_DIR}/a.md`), 'utf8')).toBe('hello\nearth\n');
|
|
326
|
+
expect(await readFile(join(tempDir, `${KB_DIR}/b.md`), 'utf8')).toBe('fresh');
|
|
327
|
+
expect(await readFile(join(tempDir, KB_DIR, 'dir', 'b-copy.md'), 'utf8')).toBe('fresh');
|
|
328
|
+
expect(await readFile(join(tempDir, KB_DIR, 'dir', 'c.md'), 'utf8')).toBe('batch');
|
|
329
|
+
const list = (await (await post(`${base}/api/agent/tools/list_files`, { path: `/${KB_DIR}/dir` })).json()) as { path: string; entries: { name: string }[] };
|
|
330
|
+
expect(list.path).toBe(`${KB_DIR}/dir`);
|
|
331
|
+
expect(list.entries.map((e) => e.name).sort()).toEqual(['b-copy.md', 'c.md']);
|
|
332
|
+
const grep = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'earth', path: `/${KB_DIR}/a.md` })).json()) as { matches: { path: string }[] };
|
|
333
|
+
expect(grep.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/a.md` }));
|
|
334
|
+
await post(`${base}/api/agent/tools/delete_file`, { path: `/${KB_DIR}/b.md` });
|
|
335
|
+
await expect(readFile(join(tempDir, `${KB_DIR}/b.md`), 'utf8')).rejects.toThrow();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('says so on the path inputs', async () => {
|
|
339
|
+
await start();
|
|
340
|
+
const tools = await toolRegistry.listInternal();
|
|
341
|
+
for (const name of ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'edit_file', 'delete_file', 'mkdir', 'unzip']) {
|
|
342
|
+
const def = tools.find((t) => t.name === name)!;
|
|
343
|
+
const body = (def.inputs as { properties: { body: { properties: Record<string, { description?: string }> } } }).properties.body;
|
|
344
|
+
expect(body.properties.path.description, name).toContain('with or without a leading slash');
|
|
345
|
+
}
|
|
346
|
+
for (const name of ['copy_file', 'move_file']) {
|
|
347
|
+
const def = tools.find((t) => t.name === name)!;
|
|
348
|
+
const body = (def.inputs as { properties: { body: { properties: Record<string, { description?: string }> } } }).properties.body;
|
|
349
|
+
expect(body.properties.src.description, name).toContain('with or without a leading slash');
|
|
350
|
+
expect(body.properties.dest.description, name).toContain('with or without a leading slash');
|
|
351
|
+
}
|
|
352
|
+
const batch = tools.find((t) => t.name === 'write_files')!;
|
|
353
|
+
const batchBody = (batch.inputs as { properties: { body: { properties: { files: { items: { properties: Record<string, { description?: string }> } } } } } }).properties.body;
|
|
354
|
+
expect(batchBody.properties.files.items.properties.path.description).toContain('with or without a leading slash');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// The routes meet this rule inside WorkspaceService; the tools write through
|
|
358
|
+
// the locking filesystem, which never enters it — so the rule has to hold on
|
|
359
|
+
// this surface on its own, or `write_file` could create the one folder no
|
|
360
|
+
// single-prefix path can name.
|
|
361
|
+
it('the checkout folder name is reserved at the repository root on the tool surface too', async () => {
|
|
362
|
+
const base = await start();
|
|
363
|
+
const reserved = `${KB_DIR}/${KB_DIR}`;
|
|
364
|
+
const refused = async (tool: string, body: Record<string, unknown>) => {
|
|
365
|
+
const res = await post(`${base}/api/agent/tools/${tool}`, body);
|
|
366
|
+
expect(res.status, tool).toBe(400);
|
|
367
|
+
expect(JSON.stringify(await res.json()), tool).toContain('is reserved');
|
|
368
|
+
};
|
|
369
|
+
await refused('write_file', { path: `${reserved}/x.md`, content: 'x' });
|
|
370
|
+
await refused('write_files', { files: [{ path: `${KB_DIR}/ok.md`, content: 'ok' }, { path: `${reserved}/y.md`, content: 'y' }] });
|
|
371
|
+
await refused('mkdir', { path: reserved });
|
|
372
|
+
await refused('copy_file', { src: `${KB_DIR}/a.md`, dest: `${reserved}/a.md` });
|
|
373
|
+
await refused('move_file', { src: `${KB_DIR}/a.md`, dest: `${reserved}/a.md` });
|
|
374
|
+
await refused('edit_file', { path: `${reserved}/x.md`, old_string: 'a', new_string: 'b' });
|
|
375
|
+
// `unzip` names its target under `destination`, the one key that is not
|
|
376
|
+
// `path`, `dest` or `files` — refused before any archive is looked at.
|
|
377
|
+
await refused('unzip', { path: `${KB_DIR}/archive.zip`, destination: reserved });
|
|
378
|
+
// Nothing landed — the batch's valid entry included, since the batch was
|
|
379
|
+
// refused as a whole before any write.
|
|
380
|
+
await expect(stat(join(tempDir, reserved))).rejects.toThrow();
|
|
381
|
+
await expect(stat(join(tempDir, KB_DIR, 'ok.md'))).rejects.toThrow();
|
|
382
|
+
|
|
383
|
+
// An existing reserved folder (an older build could have made one) can
|
|
384
|
+
// still be emptied and moved out of: the rule is about creating, not about
|
|
385
|
+
// trapping what is there.
|
|
386
|
+
await mkdir(join(tempDir, reserved), { recursive: true });
|
|
387
|
+
await writeFile(join(tempDir, reserved, 'old.md'), 'old');
|
|
388
|
+
await writeFile(join(tempDir, reserved, 'keep.md'), 'keep');
|
|
389
|
+
expect((await post(`${base}/api/agent/tools/move_file`, { src: `${reserved}/keep.md`, dest: `${KB_DIR}/keep.md` })).status).toBe(200);
|
|
390
|
+
expect((await post(`${base}/api/agent/tools/delete_file`, { path: `${reserved}/old.md` })).status).toBe(200);
|
|
391
|
+
expect(await readFile(join(tempDir, KB_DIR, 'keep.md'), 'utf8')).toBe('keep');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('grep with no path searches the repository, never what sits beside the checkout', async () => {
|
|
395
|
+
const base = await start();
|
|
396
|
+
// A stray an older build left beside the checkout. The read gate has no
|
|
397
|
+
// rules for a path outside the repository and would call it readable, so
|
|
398
|
+
// the only safe root for a walk is the repository itself.
|
|
399
|
+
await writeFile(join(tempDir, 'stray.md'), 'needle in a stray\n');
|
|
400
|
+
await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/n.md`, content: 'needle in the repository\n' });
|
|
401
|
+
const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'needle' })).json()) as { matches: { path: string }[] };
|
|
402
|
+
expect(res.matches.map((m) => m.path)).toEqual([`${KB_DIR}/n.md`]);
|
|
403
|
+
// An explicit empty path is the same absence, not a spelling of the
|
|
404
|
+
// workspace directory.
|
|
405
|
+
const empty = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: '' })).json()) as { matches: { path: string }[] };
|
|
406
|
+
expect(empty.matches.map((m) => m.path)).toEqual([`${KB_DIR}/n.md`]);
|
|
204
407
|
});
|
|
205
408
|
|
|
206
409
|
it('execute_command runs in the workspace dir', async () => {
|
|
@@ -434,8 +637,260 @@ describe('workspace file primitives', () => {
|
|
|
434
637
|
|
|
435
638
|
it('read scope refuses write tools (403) but allows reads', async () => {
|
|
436
639
|
const base = await start('read');
|
|
437
|
-
expect((await post(`${base}/api/agent/tools/write_file`, { path:
|
|
438
|
-
expect((await post(`${base}/api/agent/tools/read_file`, { path:
|
|
640
|
+
expect((await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/c.md`, content: 'x' })).status).toBe(403);
|
|
641
|
+
expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).status).toBe(200);
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* `mode` on the two write tools. The default is `create`, so "write this
|
|
647
|
+
* content here" can no longer replace a page the agent never read: an
|
|
648
|
+
* existing path is refused, by name, with the one argument that would have
|
|
649
|
+
* made it a deliberate replacement. `write_files` answers for EVERY requested
|
|
650
|
+
* path, in the order it was given them, and a path it cannot write does not
|
|
651
|
+
* stop the ones it can.
|
|
652
|
+
*/
|
|
653
|
+
describe('write modes and per-path outcomes', () => {
|
|
654
|
+
const writeFile = (base: string, body: Record<string, unknown>) => post(`${base}/api/agent/tools/write_file`, body);
|
|
655
|
+
const writeFiles = (base: string, body: Record<string, unknown>) => post(`${base}/api/agent/tools/write_files`, body);
|
|
656
|
+
const onDisk = (p: string) => readFile(join(tempDir, p), 'utf8');
|
|
657
|
+
interface BatchAnswer { count: number; files: { path: string; outcome: string; error?: string; message?: string }[] }
|
|
658
|
+
|
|
659
|
+
it('write_file with no mode creates a new file and says `created`', async () => {
|
|
660
|
+
const base = await start();
|
|
661
|
+
const res = await writeFile(base, { path: `${KB_DIR}/fresh.md`, content: 'new page' });
|
|
662
|
+
expect(res.status).toBe(200);
|
|
663
|
+
expect(await res.json()).toMatchObject({ path: `${KB_DIR}/fresh.md`, bytes: 8, outcome: 'created' });
|
|
664
|
+
expect(await onDisk(`${KB_DIR}/fresh.md`)).toBe('new page');
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('write_file with no mode refuses a path that exists, names it, says how to replace it, and leaves it alone', async () => {
|
|
668
|
+
const base = await start();
|
|
669
|
+
const res = await writeFile(base, { path: `${KB_DIR}/a.md`, content: 'clobbered' });
|
|
670
|
+
expect(res.status).toBe(409);
|
|
671
|
+
const body = (await res.json()) as { error: string; code: string; path: string };
|
|
672
|
+
expect(body.code).toBe('exists');
|
|
673
|
+
expect(body.path).toBe(`${KB_DIR}/a.md`);
|
|
674
|
+
expect(body.error).toContain(`${KB_DIR}/a.md`);
|
|
675
|
+
expect(body.error).toContain('pass mode: overwrite to replace it');
|
|
676
|
+
expect(await onDisk(`${KB_DIR}/a.md`)).toBe('hello\nworld\n');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('write_file mode overwrite replaces and says `replaced`, and creates what is not there yet', async () => {
|
|
680
|
+
const base = await start();
|
|
681
|
+
const replaced = await writeFile(base, { path: `${KB_DIR}/a.md`, content: 'replacement' });
|
|
682
|
+
expect(replaced.status).toBe(409); // …without the mode.
|
|
683
|
+
const res = await writeFile(base, { path: `${KB_DIR}/a.md`, content: 'replacement', mode: 'overwrite' });
|
|
684
|
+
expect(res.status).toBe(200);
|
|
685
|
+
expect(await res.json()).toMatchObject({ path: `${KB_DIR}/a.md`, outcome: 'replaced' });
|
|
686
|
+
expect(await onDisk(`${KB_DIR}/a.md`)).toBe('replacement');
|
|
687
|
+
// `overwrite` on a path with nothing at it is still a create, and says so.
|
|
688
|
+
const created = await writeFile(base, { path: `${KB_DIR}/not-there.md`, content: 'x', mode: 'overwrite' });
|
|
689
|
+
expect(await created.json()).toMatchObject({ outcome: 'created' });
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('write_file mode update rewrites an existing file and refuses a missing one with `missing`', async () => {
|
|
693
|
+
const base = await start();
|
|
694
|
+
const updated = await writeFile(base, { path: `${KB_DIR}/a.md`, content: 'second draft', mode: 'update' });
|
|
695
|
+
expect(updated.status).toBe(200);
|
|
696
|
+
expect(await updated.json()).toMatchObject({ path: `${KB_DIR}/a.md`, outcome: 'updated' });
|
|
697
|
+
expect(await onDisk(`${KB_DIR}/a.md`)).toBe('second draft');
|
|
698
|
+
|
|
699
|
+
const missing = await writeFile(base, { path: `${KB_DIR}/nowhere.md`, content: 'x', mode: 'update' });
|
|
700
|
+
expect(missing.status).toBe(404);
|
|
701
|
+
const body = (await missing.json()) as { error: string; code: string; path: string };
|
|
702
|
+
expect(body.code).toBe('missing');
|
|
703
|
+
expect(body.path).toBe(`${KB_DIR}/nowhere.md`);
|
|
704
|
+
expect(body.error).toContain(`${KB_DIR}/nowhere.md`);
|
|
705
|
+
await expect(onDisk(`${KB_DIR}/nowhere.md`)).rejects.toThrow();
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it('write_files answers for every requested path in input order, writes the rest, and counts only what landed', async () => {
|
|
709
|
+
const base = await start();
|
|
710
|
+
const res = await writeFiles(base, {
|
|
711
|
+
files: [
|
|
712
|
+
{ path: `${KB_DIR}/one.md`, content: 'first' },
|
|
713
|
+
{ path: `${KB_DIR}/a.md`, content: 'clobbered' }, // already there
|
|
714
|
+
{ path: `${KB_DIR}/two.md`, content: 'second' },
|
|
715
|
+
],
|
|
716
|
+
});
|
|
717
|
+
expect(res.status).toBe(200);
|
|
718
|
+
const body = (await res.json()) as BatchAnswer;
|
|
719
|
+
expect(body.count).toBe(2);
|
|
720
|
+
expect(body.files.map((f) => f.path)).toEqual([`${KB_DIR}/one.md`, `${KB_DIR}/a.md`, `${KB_DIR}/two.md`]);
|
|
721
|
+
expect(body.files.map((f) => f.outcome)).toEqual(['created', 'refused', 'created']);
|
|
722
|
+
expect(body.files[1].error).toBe('exists');
|
|
723
|
+
expect(body.files[1].message).toContain('pass mode: overwrite to replace it');
|
|
724
|
+
// The two it could write landed; the one it refused is untouched.
|
|
725
|
+
expect(await onDisk(`${KB_DIR}/one.md`)).toBe('first');
|
|
726
|
+
expect(await onDisk(`${KB_DIR}/two.md`)).toBe('second');
|
|
727
|
+
expect(await onDisk(`${KB_DIR}/a.md`)).toBe('hello\nworld\n');
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('write_files takes the same three modes', async () => {
|
|
731
|
+
const base = await start();
|
|
732
|
+
const overwritten = (await (await writeFiles(base, {
|
|
733
|
+
mode: 'overwrite',
|
|
734
|
+
files: [{ path: `${KB_DIR}/a.md`, content: 'replaced text' }, { path: `${KB_DIR}/brand-new.md`, content: 'new' }],
|
|
735
|
+
})).json()) as BatchAnswer;
|
|
736
|
+
expect(overwritten.count).toBe(2);
|
|
737
|
+
expect(overwritten.files.map((f) => f.outcome)).toEqual(['replaced', 'created']);
|
|
738
|
+
expect(await onDisk(`${KB_DIR}/a.md`)).toBe('replaced text');
|
|
739
|
+
|
|
740
|
+
const updated = (await (await writeFiles(base, {
|
|
741
|
+
mode: 'update',
|
|
742
|
+
files: [{ path: `${KB_DIR}/a.md`, content: 'again' }, { path: `${KB_DIR}/never-written.md`, content: 'x' }],
|
|
743
|
+
})).json()) as BatchAnswer;
|
|
744
|
+
expect(updated.count).toBe(1);
|
|
745
|
+
expect(updated.files.map((f) => f.outcome)).toEqual(['updated', 'refused']);
|
|
746
|
+
expect(updated.files[1].error).toBe('missing');
|
|
747
|
+
await expect(onDisk(`${KB_DIR}/never-written.md`)).rejects.toThrow();
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('write_files refuses a second create for a path an earlier entry in the SAME batch already claims', async () => {
|
|
751
|
+
const base = await start();
|
|
752
|
+
const body = (await (await writeFiles(base, {
|
|
753
|
+
files: [{ path: `${KB_DIR}/dup.md`, content: 'first' }, { path: `${KB_DIR}/dup.md`, content: 'second' }],
|
|
754
|
+
})).json()) as BatchAnswer;
|
|
755
|
+
expect(body.count).toBe(1);
|
|
756
|
+
expect(body.files.map((f) => f.outcome)).toEqual(['created', 'refused']);
|
|
757
|
+
expect(body.files[1].error).toBe('exists');
|
|
758
|
+
expect(await onDisk(`${KB_DIR}/dup.md`)).toBe('first');
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('an empty batch is still an answer with both fields', async () => {
|
|
762
|
+
const base = await start();
|
|
763
|
+
expect(await (await writeFiles(base, { files: [] })).json()).toEqual({ count: 0, files: [] });
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('a mode that is not one of the three is refused, not read as the nearest one', async () => {
|
|
767
|
+
const base = await start();
|
|
768
|
+
for (const res of [
|
|
769
|
+
await writeFile(base, { path: `${KB_DIR}/a.md`, content: 'x', mode: 'replace' }),
|
|
770
|
+
await writeFiles(base, { files: [{ path: `${KB_DIR}/a.md`, content: 'x' }], mode: 'replace' }),
|
|
771
|
+
]) {
|
|
772
|
+
expect(res.status).toBe(400);
|
|
773
|
+
const body = (await res.json()) as { error: string; code: string };
|
|
774
|
+
expect(body.code).toBe('bad_mode');
|
|
775
|
+
expect(body.error).toContain('`create`, `overwrite`, `update`');
|
|
776
|
+
}
|
|
777
|
+
expect(await onDisk(`${KB_DIR}/a.md`)).toBe('hello\nworld\n');
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* The mode is a promise about the state the write lands on, so it has to be
|
|
782
|
+
* judged over a state no one else can move — i.e. with the path's lock held,
|
|
783
|
+
* not at a preflight anybody may invalidate before the bytes land. These
|
|
784
|
+
* four drive that window directly: `raceHook` is the other writer, running
|
|
785
|
+
* exactly where the real filesystem acquires the lock.
|
|
786
|
+
*/
|
|
787
|
+
describe('the mode is judged over the state the write actually lands on', () => {
|
|
788
|
+
it('write_file create refuses a path another writer created after the preflight, and keeps their file', async () => {
|
|
789
|
+
const base = await start();
|
|
790
|
+
raceHook = async () => { await fs.writeFile(`${KB_DIR}/contested.md`, 'theirs\n'); };
|
|
791
|
+
const res = await writeFile(base, { path: `${KB_DIR}/contested.md`, content: 'mine' });
|
|
792
|
+
expect(res.status).toBe(409);
|
|
793
|
+
const body = (await res.json()) as { code: string; path: string; error: string };
|
|
794
|
+
expect(body.code).toBe('exists');
|
|
795
|
+
expect(body.path).toBe(`${KB_DIR}/contested.md`);
|
|
796
|
+
expect(body.error).toContain('pass mode: overwrite to replace it');
|
|
797
|
+
// The point of the whole feature: their bytes are still there.
|
|
798
|
+
expect(await onDisk(`${KB_DIR}/contested.md`)).toBe('theirs\n');
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('write_file update refuses a path another writer deleted after the preflight, and does not recreate it', async () => {
|
|
802
|
+
const base = await start();
|
|
803
|
+
raceHook = async () => { await fs.deleteFile(`${KB_DIR}/a.md`); };
|
|
804
|
+
const res = await writeFile(base, { path: `${KB_DIR}/a.md`, content: 'second draft', mode: 'update' });
|
|
805
|
+
expect(res.status).toBe(404);
|
|
806
|
+
expect(await res.json()).toMatchObject({ code: 'missing', path: `${KB_DIR}/a.md` });
|
|
807
|
+
await expect(onDisk(`${KB_DIR}/a.md`)).rejects.toThrow();
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it('write_file overwrite reports `replaced`, not `created`, when the file appeared after the preflight', async () => {
|
|
811
|
+
const base = await start();
|
|
812
|
+
raceHook = async () => { await fs.writeFile(`${KB_DIR}/late.md`, 'theirs\n'); };
|
|
813
|
+
const res = await writeFile(base, { path: `${KB_DIR}/late.md`, content: 'mine', mode: 'overwrite' });
|
|
814
|
+
expect(res.status).toBe(200);
|
|
815
|
+
// The preflight saw nothing there and would have answered `created`.
|
|
816
|
+
expect(await res.json()).toMatchObject({ path: `${KB_DIR}/late.md`, outcome: 'replaced' });
|
|
817
|
+
expect(await onDisk(`${KB_DIR}/late.md`)).toBe('mine');
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('write_files drops only the path another writer took, lands the rest, and counts what landed', async () => {
|
|
821
|
+
const base = await start();
|
|
822
|
+
raceHook = async () => { await fs.writeFile(`${KB_DIR}/two.md`, 'theirs\n'); };
|
|
823
|
+
const body = (await (await writeFiles(base, {
|
|
824
|
+
files: [
|
|
825
|
+
{ path: `${KB_DIR}/one.md`, content: 'first' },
|
|
826
|
+
{ path: `${KB_DIR}/two.md`, content: 'second' },
|
|
827
|
+
{ path: `${KB_DIR}/three.md`, content: 'third' },
|
|
828
|
+
],
|
|
829
|
+
})).json()) as BatchAnswer;
|
|
830
|
+
expect(body.count).toBe(2);
|
|
831
|
+
expect(body.files.map((f) => f.path)).toEqual([`${KB_DIR}/one.md`, `${KB_DIR}/two.md`, `${KB_DIR}/three.md`]);
|
|
832
|
+
expect(body.files.map((f) => f.outcome)).toEqual(['created', 'refused', 'created']);
|
|
833
|
+
expect(body.files[1].error).toBe('exists');
|
|
834
|
+
expect(await onDisk(`${KB_DIR}/one.md`)).toBe('first');
|
|
835
|
+
expect(await onDisk(`${KB_DIR}/three.md`)).toBe('third');
|
|
836
|
+
expect(await onDisk(`${KB_DIR}/two.md`)).toBe('theirs\n');
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
it('both descriptions state the default and all three modes, and `mode` is an input on each', async () => {
|
|
841
|
+
await start();
|
|
842
|
+
const tools = await toolRegistry.listInternal();
|
|
843
|
+
for (const name of ['write_file', 'write_files']) {
|
|
844
|
+
const def = tools.find((t) => t.name === name)!;
|
|
845
|
+
expect(def.description, name).toContain('DEFAULTS TO `create`');
|
|
846
|
+
for (const mode of ['`create`', '`overwrite`', '`update`']) {
|
|
847
|
+
expect(def.description, `${name} ${mode}`).toContain(mode);
|
|
848
|
+
}
|
|
849
|
+
const body = (def.inputs as { properties: { body: { properties: Record<string, { enum?: string[]; description?: string }> } } }).properties.body;
|
|
850
|
+
expect(body.properties.mode, name).toBeDefined();
|
|
851
|
+
expect(body.properties.mode.enum, name).toEqual(['create', 'overwrite', 'update']);
|
|
852
|
+
expect(body.properties.mode.description, name).toContain('default `create`');
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* The audited-caller case, as a KB fixture: a skill whose step rewrites a
|
|
858
|
+
* ticket card it has just read (the shape every delivery skill uses to
|
|
859
|
+
* append to a ticket's log). It keeps working because the rewrite says
|
|
860
|
+
* `mode: overwrite` — and the same step WITHOUT the mode is refused, which
|
|
861
|
+
* is exactly the signal the audit was for.
|
|
862
|
+
*/
|
|
863
|
+
it('a skill that rewrites a ticket card it just read still works, because it passes mode: overwrite', async () => {
|
|
864
|
+
const base = await start();
|
|
865
|
+
const card = `${KB_DIR}/Data/Tickets/Ship-It.md`;
|
|
866
|
+
await fs.mkdir(`${KB_DIR}/Data/Tickets`, { recursive: true });
|
|
867
|
+
await fs.writeFile(card, '# Ship It\n\n# Log\n- filed\n');
|
|
868
|
+
|
|
869
|
+
const read = (await (await post(`${base}/api/agent/tools/read_file`, { path: card })).json()) as { content: string };
|
|
870
|
+
const updatedCard = `${read.content}- coding done\n`;
|
|
871
|
+
|
|
872
|
+
// The step as the skill writes it today, with no mode: refused, card intact.
|
|
873
|
+
const blind = await writeFile(base, { path: card, content: updatedCard });
|
|
874
|
+
expect(blind.status).toBe(409);
|
|
875
|
+
expect(await onDisk(card)).toBe('# Ship It\n\n# Log\n- filed\n');
|
|
876
|
+
|
|
877
|
+
// The audited step, saying what it means: the rewrite lands.
|
|
878
|
+
const audited = await writeFile(base, { path: card, content: updatedCard, mode: 'overwrite' });
|
|
879
|
+
expect(audited.status).toBe(200);
|
|
880
|
+
expect(await audited.json()).toMatchObject({ path: card, outcome: 'replaced' });
|
|
881
|
+
expect(await onDisk(card)).toBe('# Ship It\n\n# Log\n- filed\n- coding done\n');
|
|
882
|
+
|
|
883
|
+
// …and the same step through the batch tool, which the skills use to land
|
|
884
|
+
// a card and its transcript in ONE commit.
|
|
885
|
+
const both = (await (await writeFiles(base, {
|
|
886
|
+
mode: 'overwrite',
|
|
887
|
+
files: [
|
|
888
|
+
{ path: card, content: `${updatedCard}- local testing done\n` },
|
|
889
|
+
{ path: `${KB_DIR}/Data/Tickets/Ship-It/transcripts/02-coding.md`, content: '# transcript\n' },
|
|
890
|
+
],
|
|
891
|
+
})).json()) as BatchAnswer;
|
|
892
|
+
expect(both.count).toBe(2);
|
|
893
|
+
expect(both.files.map((f) => f.outcome)).toEqual(['replaced', 'created']);
|
|
439
894
|
});
|
|
440
895
|
});
|
|
441
896
|
|
|
@@ -474,10 +929,165 @@ describe('read-permission gating', () => {
|
|
|
474
929
|
expect(paths).not.toContain(`${KB_DIR}/Knowledge/Secret.md`);
|
|
475
930
|
});
|
|
476
931
|
|
|
477
|
-
it('
|
|
478
|
-
// `a.md`
|
|
479
|
-
|
|
480
|
-
|
|
932
|
+
it('gates on the repository path, not on the spelling the caller sent', async () => {
|
|
933
|
+
// `a.md` is seeded through the normaliser like every path in this file, so
|
|
934
|
+
// it sits INSIDE the checkout, at `<tempDir>/knowledge-base/a.md`. Nothing
|
|
935
|
+
// is outside the KB dir any more. The gate keys on the repo-relative path,
|
|
936
|
+
// so a rule written on the workspace-relative spelling names no file…
|
|
937
|
+
const base = await start('read', denyReads(new Set([`${KB_DIR}/a.md`])));
|
|
938
|
+
expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).status).toBe(200);
|
|
939
|
+
// …and the same read, with the rule on the key the gate actually uses, is
|
|
940
|
+
// denied: what is checked is the path the read resolves, whichever of its
|
|
941
|
+
// spellings the caller sent.
|
|
942
|
+
const gated = await start('read', denyReads(new Set(['a.md'])));
|
|
943
|
+
for (const path of ['a.md', `${KB_DIR}/a.md`, `/${KB_DIR}/a.md`]) {
|
|
944
|
+
expect((await post(`${gated}/api/agent/tools/read_file`, { path })).status, path).toBe(403);
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* `grep` given a `path` that names a FILE. The walk begins with `readdir`,
|
|
951
|
+
* which fails on a file and on an absent path alike — so such a grep used to
|
|
952
|
+
* answer an empty match list, indistinguishable from "your pattern is not in
|
|
953
|
+
* there". Every case now answers honestly: the file's matches, a note when
|
|
954
|
+
* there was no text to search, or an error when there is nothing at the path.
|
|
955
|
+
*/
|
|
956
|
+
describe('grep with a path that names a file', () => {
|
|
957
|
+
interface GrepResult {
|
|
958
|
+
matches: { path: string; line: number; text: string }[];
|
|
959
|
+
truncated: boolean;
|
|
960
|
+
note?: string;
|
|
961
|
+
}
|
|
962
|
+
const grep = async (base: string, body: Record<string, unknown>): Promise<GrepResult> =>
|
|
963
|
+
(await (await post(`${base}/api/agent/tools/grep`, body)).json()) as GrepResult;
|
|
964
|
+
|
|
965
|
+
it('searches exactly that file — the match carries that path and a 1-based line number', async () => {
|
|
966
|
+
const base = await start();
|
|
967
|
+
await fs.writeFile(`${KB_DIR}/notes/deep.md`, 'alpha\nbeta needle\ngamma\n');
|
|
968
|
+
// A sibling holding the same term: a file grep must not reach it.
|
|
969
|
+
await fs.writeFile(`${KB_DIR}/notes/other.md`, 'needle elsewhere\n');
|
|
970
|
+
const res = await grep(base, { pattern: 'needle', path: `${KB_DIR}/notes/deep.md` });
|
|
971
|
+
expect(res.matches).toEqual([{ path: `${KB_DIR}/notes/deep.md`, line: 2, text: 'beta needle' }]);
|
|
972
|
+
expect(res.truncated).toBe(false);
|
|
973
|
+
expect(res.note).toBeUndefined();
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it('a file the pattern is simply not in is an empty SUCCESS — no note, no error', async () => {
|
|
977
|
+
const base = await start();
|
|
978
|
+
const res = await grep(base, { pattern: 'absent-term', path: `${KB_DIR}/a.md` });
|
|
979
|
+
expect(res.matches).toEqual([]);
|
|
980
|
+
expect(res.truncated).toBe(false);
|
|
981
|
+
expect(res.note).toBeUndefined();
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it('caps a file grep at max_results and reports truncated, exactly as a directory grep does', async () => {
|
|
985
|
+
const base = await start();
|
|
986
|
+
await fs.writeFile(`${KB_DIR}/many.md`, 'needle\n'.repeat(5));
|
|
987
|
+
const res = await grep(base, { pattern: 'needle', path: `${KB_DIR}/many.md`, max_results: 2 });
|
|
988
|
+
expect(res.matches.map((m) => m.line)).toEqual([1, 2]);
|
|
989
|
+
expect(res.truncated).toBe(true);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it('a file with no searchable text returns empty matches plus a note saying so', async () => {
|
|
993
|
+
const base = await start();
|
|
994
|
+
// "needle" followed by a NUL byte: binary content, so nothing to search —
|
|
995
|
+
// the byte pattern is present but the file is not text.
|
|
996
|
+
await fs.writeFile(`${KB_DIR}/data.bin`, Buffer.from('needle\0tail', 'latin1'));
|
|
997
|
+
await fs.writeFile(
|
|
998
|
+
`${KB_DIR}/logo.png`,
|
|
999
|
+
Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'),
|
|
1000
|
+
);
|
|
1001
|
+
for (const path of [`${KB_DIR}/data.bin`, `${KB_DIR}/logo.png`]) {
|
|
1002
|
+
const res = await grep(base, { pattern: 'needle', path });
|
|
1003
|
+
expect(res.matches, path).toEqual([]);
|
|
1004
|
+
expect(res.note, path).toContain('no searchable text');
|
|
1005
|
+
expect(res.note, path).toContain(path);
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
it('a path with nothing at it fails with an error naming the path — never an empty success', async () => {
|
|
1010
|
+
const base = await start();
|
|
1011
|
+
const res = await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: `${KB_DIR}/notes/ghost.md` });
|
|
1012
|
+
expect(res.status).toBe(404);
|
|
1013
|
+
const { error } = (await res.json()) as { error: string };
|
|
1014
|
+
expect(error).toContain(`${KB_DIR}/notes/ghost.md`);
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it('a FILE the caller may not read answers exactly as read_file does — same status, same body', async () => {
|
|
1018
|
+
const secret = `${KB_DIR}/Knowledge/Secret.md`;
|
|
1019
|
+
// Denied AND absent: the two tools must agree here too, or grep's error
|
|
1020
|
+
// would reveal an existence read_file refuses to confirm.
|
|
1021
|
+
const ghost = `${KB_DIR}/Knowledge/Ghost.md`;
|
|
1022
|
+
const base = await start('write', denyReads(new Set(['Knowledge/Secret.md', 'Knowledge/Ghost.md'])));
|
|
1023
|
+
await fs.writeFile(secret, 'secret needle\n');
|
|
1024
|
+
for (const path of [secret, ghost]) {
|
|
1025
|
+
const [readRes, grepRes] = await Promise.all([
|
|
1026
|
+
post(`${base}/api/agent/tools/read_file`, { path }),
|
|
1027
|
+
post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path }),
|
|
1028
|
+
]);
|
|
1029
|
+
expect(grepRes.status, path).toBe(403);
|
|
1030
|
+
expect(grepRes.status, path).toBe(readRes.status);
|
|
1031
|
+
expect(await grepRes.json(), path).toEqual(await readRes.json());
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it('a path UNDER an existing file is nothing-there too — the same 404, not a raw failure', async () => {
|
|
1036
|
+
const base = await start();
|
|
1037
|
+
await fs.writeFile(`${KB_DIR}/notes/deep.md`, 'alpha\n');
|
|
1038
|
+
// `notes/deep.md` is a FILE, so the filesystem answers ENOTDIR rather than
|
|
1039
|
+
// ENOENT. Nothing can live at this path either, so it earns the same
|
|
1040
|
+
// honest 404 as a plainly absent one.
|
|
1041
|
+
const res = await post(`${base}/api/agent/tools/grep`, {
|
|
1042
|
+
pattern: 'needle',
|
|
1043
|
+
path: `${KB_DIR}/notes/deep.md/deeper.md`,
|
|
1044
|
+
});
|
|
1045
|
+
expect(res.status).toBe(404);
|
|
1046
|
+
const { error } = (await res.json()) as { error: string };
|
|
1047
|
+
expect(error).toContain(`${KB_DIR}/notes/deep.md/deeper.md`);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it("a denied path the filesystem cannot even stat still answers with read_file's 403", async () => {
|
|
1051
|
+
const loop = `${KB_DIR}/Knowledge/Loop.md`;
|
|
1052
|
+
const base = await start('write', denyReads(new Set(['Knowledge/Loop.md'])));
|
|
1053
|
+
await mkdir(join(tempDir, KB_DIR, 'Knowledge'), { recursive: true });
|
|
1054
|
+
// A symlink pointing at ITSELF: stat fails with ELOOP — neither absence
|
|
1055
|
+
// nor a readable file. The permission verdict must still come FIRST, or
|
|
1056
|
+
// grep would leak a filesystem complaint where read_file says only 403.
|
|
1057
|
+
await symlink('Loop.md', join(tempDir, loop));
|
|
1058
|
+
const [readRes, grepRes] = await Promise.all([
|
|
1059
|
+
post(`${base}/api/agent/tools/read_file`, { path: loop }),
|
|
1060
|
+
post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: loop }),
|
|
1061
|
+
]);
|
|
1062
|
+
expect(grepRes.status).toBe(403);
|
|
1063
|
+
expect(grepRes.status).toBe(readRes.status);
|
|
1064
|
+
expect(await grepRes.json()).toEqual(await readRes.json());
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it('a READABLE path the filesystem cannot resolve fails exactly as read_file fails', async () => {
|
|
1068
|
+
const loop = `${KB_DIR}/Knowledge/Tangle.md`;
|
|
1069
|
+
const base = await start();
|
|
1070
|
+
await mkdir(join(tempDir, KB_DIR, 'Knowledge'), { recursive: true });
|
|
1071
|
+
await symlink('Tangle.md', join(tempDir, loop));
|
|
1072
|
+
const [readRes, grepRes] = await Promise.all([
|
|
1073
|
+
post(`${base}/api/agent/tools/read_file`, { path: loop }),
|
|
1074
|
+
post(`${base}/api/agent/tools/grep`, { pattern: 'needle', path: loop }),
|
|
1075
|
+
]);
|
|
1076
|
+
// The gate allows it, so the READ is what answers — and it is the same
|
|
1077
|
+
// `fs.readFile` read_file calls. grep must not dress that up as absence
|
|
1078
|
+
// (a false 404), nor invent a failure of its own: one path, one story.
|
|
1079
|
+
expect(grepRes.status).toBe(readRes.status);
|
|
1080
|
+
expect(await grepRes.json()).toEqual(await readRes.json());
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it('a DIRECTORY path still walks the whole subtree (unchanged)', async () => {
|
|
1084
|
+
const base = await start();
|
|
1085
|
+
await fs.writeFile(`${KB_DIR}/notes/one.md`, 'needle here\n');
|
|
1086
|
+
await fs.writeFile(`${KB_DIR}/notes/sub/two.md`, 'and needle there\n');
|
|
1087
|
+
await fs.writeFile(`${KB_DIR}/outside.md`, 'needle outside the subtree\n');
|
|
1088
|
+
const res = await grep(base, { pattern: 'needle', path: 'notes' });
|
|
1089
|
+
expect(res.matches.map((m) => m.path).sort()).toEqual([`${KB_DIR}/notes/one.md`, `${KB_DIR}/notes/sub/two.md`]);
|
|
1090
|
+
expect(res.note).toBeUndefined();
|
|
481
1091
|
});
|
|
482
1092
|
});
|
|
483
1093
|
|
|
@@ -586,45 +1196,77 @@ describe('office documents and PDFs', () => {
|
|
|
586
1196
|
|
|
587
1197
|
it('read_file returns marker + extracted text for a docx', async () => {
|
|
588
1198
|
const base = await start();
|
|
589
|
-
await fs.writeFile(
|
|
590
|
-
const content = await readContent(base,
|
|
1199
|
+
await fs.writeFile(`${KB_DIR}/report.docx`, docx('Hello from Word', 'Second paragraph'));
|
|
1200
|
+
const content = await readContent(base, `${KB_DIR}/report.docx`);
|
|
591
1201
|
const lines = content.split('\n');
|
|
592
|
-
expect(lines[0]).toMatch(
|
|
1202
|
+
expect(lines[0]).toMatch(new RegExp(`^\\[extracted text of ${KB_DIR}/report\\.docx — 2 paragraphs;`));
|
|
593
1203
|
expect(lines[0]).toContain('layout, images and formatting omitted');
|
|
594
1204
|
expect(lines.slice(1)).toEqual(['Hello from Word', 'Second paragraph']);
|
|
595
1205
|
});
|
|
596
1206
|
|
|
597
1207
|
it('read_file slices offset/limit AFTER assembling marker + text (unchanged semantics)', async () => {
|
|
598
1208
|
const base = await start();
|
|
599
|
-
await fs.writeFile(
|
|
600
|
-
const full = await readContent(base,
|
|
601
|
-
const sliced = await readContent(base,
|
|
1209
|
+
await fs.writeFile(`${KB_DIR}/report.docx`, docx('Sliceable content here'));
|
|
1210
|
+
const full = await readContent(base, `${KB_DIR}/report.docx`);
|
|
1211
|
+
const sliced = await readContent(base, `${KB_DIR}/report.docx`, { offset: 5, limit: 12 });
|
|
602
1212
|
expect(sliced).toBe(full.slice(5, 17));
|
|
603
1213
|
});
|
|
604
1214
|
|
|
605
1215
|
it('read_file returns [page N] text for a PDF', async () => {
|
|
606
1216
|
const base = await start();
|
|
607
|
-
await fs.writeFile(
|
|
608
|
-
const content = await readContent(base,
|
|
609
|
-
expect(content).toMatch(
|
|
1217
|
+
await fs.writeFile(`${KB_DIR}/paper.pdf`, pdf('Findings inside a PDF'));
|
|
1218
|
+
const content = await readContent(base, `${KB_DIR}/paper.pdf`);
|
|
1219
|
+
expect(content).toMatch(new RegExp(`^\\[extracted text of ${KB_DIR}/paper\\.pdf — 1 page;`));
|
|
610
1220
|
expect(content).toContain('[page 1]\nFindings inside a PDF');
|
|
611
1221
|
});
|
|
612
1222
|
|
|
613
1223
|
it('grep finds a term inside a pptx, with the [slide N] marker line locating it', async () => {
|
|
614
1224
|
const base = await start();
|
|
615
|
-
await fs.writeFile(
|
|
1225
|
+
await fs.writeFile(`${KB_DIR}/deck.pptx`, pptx([['Intro'], ['Roadmap 2026', 'Ship documents']]));
|
|
616
1226
|
const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'Roadmap' })).json()) as {
|
|
617
1227
|
matches: { path: string; line: number; text: string }[];
|
|
618
1228
|
note?: string;
|
|
619
1229
|
};
|
|
620
|
-
expect(res.matches).toContainEqual(expect.objectContaining({ path:
|
|
1230
|
+
expect(res.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/deck.pptx`, text: 'Roadmap 2026' }));
|
|
621
1231
|
// The extraction reads: marker line 1, [slide 1] line 2, Intro line 3,
|
|
622
1232
|
// [slide 2] line 4, Roadmap line 5 — grep reports the extraction's numbers.
|
|
623
1233
|
expect(res.matches.find((m) => m.text === 'Roadmap 2026')?.line).toBe(5);
|
|
624
1234
|
// The structure markers themselves are searchable.
|
|
625
1235
|
const markers = (await (await post(`${base}/api/agent/tools/grep`, { pattern: '\\[slide 2\\]' })).json()) as { matches: { path: string }[] };
|
|
626
|
-
expect(markers.matches).toContainEqual(expect.objectContaining({ path:
|
|
1236
|
+
expect(markers.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/deck.pptx` }));
|
|
1237
|
+
expect(res.note).toBeUndefined();
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
it('grep on a path naming a DOCUMENT searches its extraction the way the walk does — markers included', async () => {
|
|
1241
|
+
const base = await start();
|
|
1242
|
+
await fs.writeFile(`${KB_DIR}/deck.pptx`, pptx([['Intro'], ['Roadmap 2026']]));
|
|
1243
|
+
// A second deck carrying the same term: a file grep must not reach it.
|
|
1244
|
+
await fs.writeFile(`${KB_DIR}/decoy.pptx`, pptx([['Roadmap 2026 decoy deck']]));
|
|
1245
|
+
const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'Roadmap', path: `${KB_DIR}/deck.pptx` })).json()) as {
|
|
1246
|
+
matches: { path: string; line: number; text: string }[];
|
|
1247
|
+
note?: string;
|
|
1248
|
+
};
|
|
1249
|
+
// Same line arithmetic as the directory grep: marker 1, [slide 1] 2,
|
|
1250
|
+
// Intro 3, [slide 2] 4, Roadmap 5.
|
|
1251
|
+
expect(res.matches).toEqual([{ path: `${KB_DIR}/deck.pptx`, line: 5, text: 'Roadmap 2026' }]);
|
|
627
1252
|
expect(res.note).toBeUndefined();
|
|
1253
|
+
// The structure markers are searchable on the single-file path too.
|
|
1254
|
+
const markers = (await (await post(`${base}/api/agent/tools/grep`, { pattern: '\\[slide 2\\]', path: `${KB_DIR}/deck.pptx` })).json()) as {
|
|
1255
|
+
matches: { path: string; line: number }[];
|
|
1256
|
+
};
|
|
1257
|
+
expect(markers.matches).toEqual([expect.objectContaining({ path: `${KB_DIR}/deck.pptx`, line: 4 })]);
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
it('grep on a path naming a CORRUPT document notes it has no searchable text', async () => {
|
|
1261
|
+
const base = await start();
|
|
1262
|
+
await fs.writeFile(`${KB_DIR}/broken.docx`, Buffer.from('not really a zip'));
|
|
1263
|
+
const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'zip', path: `${KB_DIR}/broken.docx` })).json()) as {
|
|
1264
|
+
matches: unknown[];
|
|
1265
|
+
note?: string;
|
|
1266
|
+
};
|
|
1267
|
+
expect(res.matches).toEqual([]);
|
|
1268
|
+
expect(res.note).toContain('no searchable text');
|
|
1269
|
+
expect(res.note).toContain(`${KB_DIR}/broken.docx`);
|
|
628
1270
|
});
|
|
629
1271
|
|
|
630
1272
|
it('grep extracts at most 20 uncached documents per call and notes the skipped rest; a re-run covers them', async () => {
|
|
@@ -651,41 +1293,41 @@ describe('office documents and PDFs', () => {
|
|
|
651
1293
|
|
|
652
1294
|
it('read_file returns marker + extracted text for the three OpenDocument formats', async () => {
|
|
653
1295
|
const base = await start();
|
|
654
|
-
await fs.writeFile(
|
|
655
|
-
const odtContent = await readContent(base,
|
|
1296
|
+
await fs.writeFile(`${KB_DIR}/memo.odt`, odt('Hello from Writer', 'Second paragraph'));
|
|
1297
|
+
const odtContent = await readContent(base, `${KB_DIR}/memo.odt`);
|
|
656
1298
|
expect(odtContent.split('\n')).toEqual([
|
|
657
|
-
|
|
1299
|
+
`[extracted text of ${KB_DIR}/memo.odt — 2 paragraphs; layout, images and formatting omitted]`,
|
|
658
1300
|
'Hello from Writer',
|
|
659
1301
|
'Second paragraph',
|
|
660
1302
|
]);
|
|
661
1303
|
|
|
662
|
-
await fs.writeFile(
|
|
663
|
-
const odpContent = await readContent(base,
|
|
664
|
-
expect(odpContent).toMatch(
|
|
1304
|
+
await fs.writeFile(`${KB_DIR}/deck.odp`, odp([['Impress intro'], ['Second page']]));
|
|
1305
|
+
const odpContent = await readContent(base, `${KB_DIR}/deck.odp`);
|
|
1306
|
+
expect(odpContent).toMatch(new RegExp(`^\\[extracted text of ${KB_DIR}/deck\\.odp — 2 slides;`));
|
|
665
1307
|
expect(odpContent).toContain('[slide 1]\nImpress intro\n[slide 2]\nSecond page');
|
|
666
1308
|
|
|
667
|
-
await fs.writeFile(
|
|
668
|
-
const odsContent = await readContent(base,
|
|
669
|
-
expect(odsContent).toMatch(
|
|
1309
|
+
await fs.writeFile(`${KB_DIR}/numbers.ods`, ods('Inventory', [['Name', 'Qty'], ['Widget', '3']]));
|
|
1310
|
+
const odsContent = await readContent(base, `${KB_DIR}/numbers.ods`);
|
|
1311
|
+
expect(odsContent).toMatch(new RegExp(`^\\[extracted text of ${KB_DIR}/numbers\\.ods — 1 sheet, rows as tab-separated values;`));
|
|
670
1312
|
expect(odsContent).toContain('[sheet: Inventory]\nName\tQty\nWidget\t3');
|
|
671
1313
|
});
|
|
672
1314
|
|
|
673
1315
|
it('grep finds a term inside an odp, with the [slide N] marker line locating it', async () => {
|
|
674
1316
|
const base = await start();
|
|
675
|
-
await fs.writeFile(
|
|
1317
|
+
await fs.writeFile(`${KB_DIR}/deck.odp`, odp([['Intro'], ['Roadmap 2027', 'Ship OpenDocument']]));
|
|
676
1318
|
const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'Roadmap' })).json()) as {
|
|
677
1319
|
matches: { path: string; line: number; text: string }[];
|
|
678
1320
|
};
|
|
679
1321
|
// Extraction: marker line 1, [slide 1] 2, Intro 3, [slide 2] 4, Roadmap 5.
|
|
680
|
-
expect(res.matches).toContainEqual(expect.objectContaining({ path:
|
|
1322
|
+
expect(res.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/deck.odp`, text: 'Roadmap 2027', line: 5 }));
|
|
681
1323
|
const markers = (await (await post(`${base}/api/agent/tools/grep`, { pattern: '\\[slide 2\\]' })).json()) as { matches: { path: string }[] };
|
|
682
|
-
expect(markers.matches).toContainEqual(expect.objectContaining({ path:
|
|
1324
|
+
expect(markers.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/deck.odp` }));
|
|
683
1325
|
});
|
|
684
1326
|
|
|
685
1327
|
it('read_file answers a corrupt odt zip with an honest could-not-parse message (no 500)', async () => {
|
|
686
1328
|
const base = await start();
|
|
687
|
-
await fs.writeFile(
|
|
688
|
-
const res = await post(`${base}/api/agent/tools/read_file`, { path:
|
|
1329
|
+
await fs.writeFile(`${KB_DIR}/broken.odt`, Buffer.from('not really a zip'));
|
|
1330
|
+
const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/broken.odt` });
|
|
689
1331
|
expect(res.status).toBe(200);
|
|
690
1332
|
const { content } = (await res.json()) as { content: string };
|
|
691
1333
|
expect(content).toContain('could not be parsed as a .odt');
|
|
@@ -708,10 +1350,10 @@ describe('office documents and PDFs', () => {
|
|
|
708
1350
|
|
|
709
1351
|
it('read_file returns marker + [from]/[subject] header block + body for a .eml email', async () => {
|
|
710
1352
|
const base = await start();
|
|
711
|
-
await fs.writeFile(
|
|
712
|
-
const content = await readContent(base,
|
|
1353
|
+
await fs.writeFile(`${KB_DIR}/Inbox/offer.eml`, eml('Quarterly numbers', 'Please see the summary.'));
|
|
1354
|
+
const content = await readContent(base, `${KB_DIR}/Inbox/offer.eml`);
|
|
713
1355
|
expect(content.split('\n')).toEqual([
|
|
714
|
-
|
|
1356
|
+
`[extracted text of ${KB_DIR}/Inbox/offer.eml — email message; formatting and full headers omitted]`,
|
|
715
1357
|
'[from] Ada Lovelace <ada@example.com>',
|
|
716
1358
|
'[to] bob@example.com',
|
|
717
1359
|
'[subject] Quarterly numbers',
|
|
@@ -723,43 +1365,43 @@ describe('office documents and PDFs', () => {
|
|
|
723
1365
|
|
|
724
1366
|
it('grep finds a term inside a .eml, with the [subject] header line itself searchable', async () => {
|
|
725
1367
|
const base = await start();
|
|
726
|
-
await fs.writeFile(
|
|
1368
|
+
await fs.writeFile(`${KB_DIR}/Inbox/offer.eml`, eml('Quarterly numbers', 'The needle-2026 is in the body.'));
|
|
727
1369
|
const res = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'needle-2026' })).json()) as {
|
|
728
1370
|
matches: { path: string; line: number; text: string }[];
|
|
729
1371
|
};
|
|
730
1372
|
// Extraction: marker 1, [from] 2, [to] 3, [subject] 4, [date] 5, blank 6, body 7.
|
|
731
1373
|
expect(res.matches).toContainEqual(
|
|
732
|
-
expect.objectContaining({ path:
|
|
1374
|
+
expect.objectContaining({ path: `${KB_DIR}/Inbox/offer.eml`, text: 'The needle-2026 is in the body.', line: 7 }),
|
|
733
1375
|
);
|
|
734
1376
|
const header = (await (await post(`${base}/api/agent/tools/grep`, { pattern: '\\[subject\\] Quarterly' })).json()) as {
|
|
735
1377
|
matches: { path: string; line: number }[];
|
|
736
1378
|
};
|
|
737
|
-
expect(header.matches).toContainEqual(expect.objectContaining({ path:
|
|
1379
|
+
expect(header.matches).toContainEqual(expect.objectContaining({ path: `${KB_DIR}/Inbox/offer.eml`, line: 4 }));
|
|
738
1380
|
});
|
|
739
1381
|
|
|
740
1382
|
it('write_file / edit_file refuse email files with the snapshot explanation', async () => {
|
|
741
1383
|
const base = await start();
|
|
742
|
-
await fs.writeFile(
|
|
1384
|
+
await fs.writeFile(`${KB_DIR}/Inbox/offer.eml`, eml('original', 'original body'));
|
|
743
1385
|
for (const [tool, body] of [
|
|
744
|
-
['write_file', { path:
|
|
745
|
-
['write_file', { path:
|
|
746
|
-
['edit_file', { path:
|
|
1386
|
+
['write_file', { path: `${KB_DIR}/Inbox/offer.eml`, content: 'rewritten' }],
|
|
1387
|
+
['write_file', { path: `${KB_DIR}/Inbox/new-thread.msg`, content: 'plain text' }],
|
|
1388
|
+
['edit_file', { path: `${KB_DIR}/Inbox/offer.eml`, old_string: 'original', new_string: 'changed' }],
|
|
747
1389
|
] as const) {
|
|
748
1390
|
const res = await post(`${base}/api/agent/tools/${tool}`, body);
|
|
749
|
-
expect(res.status, tool).toBe(
|
|
1391
|
+
expect(res.status, tool).toBe(415);
|
|
750
1392
|
const { error } = (await res.json()) as { error: string };
|
|
751
1393
|
expect(error, tool).toContain('email file');
|
|
752
1394
|
expect(error, tool).toContain('snapshot');
|
|
753
1395
|
expect(error, tool).toContain('uploading a new version');
|
|
754
1396
|
}
|
|
755
1397
|
// The email is untouched: reading it still extracts the original text.
|
|
756
|
-
expect(await readContent(base,
|
|
1398
|
+
expect(await readContent(base, `${KB_DIR}/Inbox/offer.eml`)).toContain('original body');
|
|
757
1399
|
});
|
|
758
1400
|
|
|
759
1401
|
it('read_file answers a corrupt .msg with an honest could-not-parse message (no 500)', async () => {
|
|
760
1402
|
const base = await start();
|
|
761
|
-
await fs.writeFile(
|
|
762
|
-
const res = await post(`${base}/api/agent/tools/read_file`, { path:
|
|
1403
|
+
await fs.writeFile(`${KB_DIR}/Inbox/broken.msg`, Buffer.from('not a CFB container'));
|
|
1404
|
+
const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/Inbox/broken.msg` });
|
|
763
1405
|
expect(res.status).toBe(200);
|
|
764
1406
|
const { content } = (await res.json()) as { content: string };
|
|
765
1407
|
expect(content).toContain('could not be parsed as a .msg');
|
|
@@ -768,8 +1410,8 @@ describe('office documents and PDFs', () => {
|
|
|
768
1410
|
|
|
769
1411
|
it('read_file answers a corrupt docx with an honest could-not-parse message (no 500)', async () => {
|
|
770
1412
|
const base = await start();
|
|
771
|
-
await fs.writeFile(
|
|
772
|
-
const res = await post(`${base}/api/agent/tools/read_file`, { path:
|
|
1413
|
+
await fs.writeFile(`${KB_DIR}/broken.docx`, Buffer.from('not really a zip'));
|
|
1414
|
+
const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/broken.docx` });
|
|
773
1415
|
expect(res.status).toBe(200);
|
|
774
1416
|
const { content } = (await res.json()) as { content: string };
|
|
775
1417
|
expect(content).toContain('could not be parsed as a .docx');
|
|
@@ -778,21 +1420,21 @@ describe('office documents and PDFs', () => {
|
|
|
778
1420
|
|
|
779
1421
|
it('read_file answers a legacy .doc with the convert-to-modern hint', async () => {
|
|
780
1422
|
const base = await start();
|
|
781
|
-
await fs.writeFile(
|
|
782
|
-
const content = await readContent(base,
|
|
1423
|
+
await fs.writeFile(`${KB_DIR}/old.doc`, Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0x00, 0x01, 0x02]));
|
|
1424
|
+
const content = await readContent(base, `${KB_DIR}/old.doc`);
|
|
783
1425
|
expect(content).toContain('legacy office format');
|
|
784
1426
|
expect(content).toContain('.docx');
|
|
785
1427
|
});
|
|
786
1428
|
|
|
787
1429
|
it('edit_file / write_file refuse a legacy .doc with the convert-or-replace message — a binary the reader cannot extract must never be text-overwritten', async () => {
|
|
788
1430
|
const base = await start();
|
|
789
|
-
await fs.writeFile(
|
|
1431
|
+
await fs.writeFile(`${KB_DIR}/old.doc`, Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0x00, 0x01, 0x02]));
|
|
790
1432
|
for (const [tool, body] of [
|
|
791
|
-
['edit_file', { path:
|
|
792
|
-
['write_file', { path:
|
|
1433
|
+
['edit_file', { path: `${KB_DIR}/old.doc`, old_string: 'a', new_string: 'b' }],
|
|
1434
|
+
['write_file', { path: `${KB_DIR}/old.doc`, content: 'plain text' }],
|
|
793
1435
|
] as const) {
|
|
794
1436
|
const res = await post(`${base}/api/agent/tools/${tool}`, body);
|
|
795
|
-
expect(res.status, tool).toBe(
|
|
1437
|
+
expect(res.status, tool).toBe(415);
|
|
796
1438
|
const { error } = (await res.json()) as { error: string };
|
|
797
1439
|
expect(error, tool).toContain('legacy binary office format');
|
|
798
1440
|
expect(error, tool).toContain('.docx');
|
|
@@ -805,72 +1447,246 @@ describe('office documents and PDFs', () => {
|
|
|
805
1447
|
// A write gate that only asked about the EXTENSION let an agent overwrite
|
|
806
1448
|
// exactly those bytes — destroying a file it was never allowed to see.
|
|
807
1449
|
const base = await start();
|
|
808
|
-
await fs.writeFile(
|
|
1450
|
+
await fs.writeFile(`${KB_DIR}/blob.dat`, Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff]));
|
|
809
1451
|
for (const [tool, body] of [
|
|
810
|
-
['write_file', { path:
|
|
811
|
-
['edit_file', { path:
|
|
1452
|
+
['write_file', { path: `${KB_DIR}/blob.dat`, content: 'plain text' }],
|
|
1453
|
+
['edit_file', { path: `${KB_DIR}/blob.dat`, old_string: 'a', new_string: 'b' }],
|
|
812
1454
|
] as const) {
|
|
813
1455
|
const res = await post(`${base}/api/agent/tools/${tool}`, body);
|
|
814
|
-
expect(res.status, tool).toBe(
|
|
1456
|
+
expect(res.status, tool).toBe(415);
|
|
815
1457
|
const { error } = (await res.json()) as { error: string };
|
|
816
1458
|
expect(error, tool).toContain('binary content');
|
|
817
1459
|
expect(error, tool).toContain('uploading a new version');
|
|
818
1460
|
}
|
|
819
1461
|
// …and a TEXT file under the same fallback reader still writes normally.
|
|
820
|
-
const ok = await post(`${base}/api/agent/tools/write_file`, { path:
|
|
1462
|
+
const ok = await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/notes.dat`, content: 'hello' });
|
|
821
1463
|
expect(ok.status).toBe(200);
|
|
822
1464
|
});
|
|
823
1465
|
|
|
824
1466
|
it('read_file answers other binary files with a one-line notice (zip names the unzip tool)', async () => {
|
|
825
1467
|
const base = await start();
|
|
826
1468
|
// .mp3, not an image: images return native MCP image content (see below).
|
|
827
|
-
await fs.writeFile(
|
|
828
|
-
const mp3 = await readContent(base,
|
|
829
|
-
expect(mp3).toBe(
|
|
830
|
-
await fs.writeFile(
|
|
831
|
-
const zip = await readContent(base,
|
|
1469
|
+
await fs.writeFile(`${KB_DIR}/song.mp3`, Buffer.from([0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]));
|
|
1470
|
+
const mp3 = await readContent(base, `${KB_DIR}/song.mp3`);
|
|
1471
|
+
expect(mp3).toBe(`[${KB_DIR}/song.mp3 is a binary file (audio/mpeg, 9 bytes) — not readable as text.]`);
|
|
1472
|
+
await fs.writeFile(`${KB_DIR}/bundle.zip`, Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]));
|
|
1473
|
+
const zip = await readContent(base, `${KB_DIR}/bundle.zip`);
|
|
832
1474
|
expect(zip).toContain('application/zip');
|
|
833
1475
|
expect(zip).toContain('unzip tool');
|
|
834
1476
|
});
|
|
835
1477
|
|
|
836
1478
|
it('write_file / edit_file / write_files refuse document extensions with the round-trip explanation', async () => {
|
|
837
1479
|
const base = await start();
|
|
838
|
-
await fs.writeFile(
|
|
1480
|
+
await fs.writeFile(`${KB_DIR}/deck.pptx`, pptx([['Original']]));
|
|
839
1481
|
for (const [tool, body] of [
|
|
840
|
-
['write_file', { path:
|
|
841
|
-
['write_file', { path:
|
|
842
|
-
['write_file', { path:
|
|
843
|
-
['edit_file', { path:
|
|
844
|
-
['edit_file', { path:
|
|
845
|
-
['write_files', { files: [{ path: 'ok.md', content: 'fine' }, { path: 'sheet.xlsx', content: 'nope' }] }],
|
|
846
|
-
['write_files', { files: [{ path: 'ok.md', content: 'fine' }, { path: 'sheet.ods', content: 'nope' }] }],
|
|
1482
|
+
['write_file', { path: `${KB_DIR}/new.docx`, content: 'plain text' }],
|
|
1483
|
+
['write_file', { path: `${KB_DIR}/new.odt`, content: 'plain text' }],
|
|
1484
|
+
['write_file', { path: `${KB_DIR}/slides.odp`, content: 'plain text' }],
|
|
1485
|
+
['edit_file', { path: `${KB_DIR}/deck.pptx`, old_string: 'Original', new_string: 'Changed' }],
|
|
1486
|
+
['edit_file', { path: `${KB_DIR}/numbers.ods`, old_string: 'a', new_string: 'b' }],
|
|
847
1487
|
] as const) {
|
|
848
1488
|
const res = await post(`${base}/api/agent/tools/${tool}`, body);
|
|
849
|
-
expect(res.status, tool).toBe(
|
|
1489
|
+
expect(res.status, tool).toBe(415);
|
|
850
1490
|
const { error } = (await res.json()) as { error: string };
|
|
851
1491
|
expect(error, tool).toContain('EXTRACTED text');
|
|
852
1492
|
expect(error, tool).toContain('uploading a new version');
|
|
853
1493
|
}
|
|
854
|
-
//
|
|
855
|
-
|
|
1494
|
+
// In a BATCH the same refusal is per path: the document is refused with the
|
|
1495
|
+
// same explanation, and the innocent .md beside it is still written.
|
|
1496
|
+
for (const doc of [`${KB_DIR}/sheet.xlsx`, `${KB_DIR}/sheet.ods`]) {
|
|
1497
|
+
const res = await post(`${base}/api/agent/tools/write_files`, {
|
|
1498
|
+
files: [{ path: `${doc}.ok.md`, content: 'fine' }, { path: doc, content: 'nope' }],
|
|
1499
|
+
});
|
|
1500
|
+
expect(res.status, doc).toBe(200);
|
|
1501
|
+
const body = (await res.json()) as { count: number; files: { path: string; outcome: string; error?: string; message?: string }[] };
|
|
1502
|
+
expect(body.count, doc).toBe(1);
|
|
1503
|
+
expect(body.files.map((f) => f.path), doc).toEqual([`${doc}.ok.md`, doc]);
|
|
1504
|
+
expect(body.files[0], doc).toMatchObject({ outcome: 'created' });
|
|
1505
|
+
expect(body.files[1], doc).toMatchObject({ outcome: 'refused', error: 'binary_not_writable' });
|
|
1506
|
+
expect(body.files[1].message, doc).toContain('EXTRACTED text');
|
|
1507
|
+
expect(body.files[1].message, doc).toContain('uploading a new version');
|
|
1508
|
+
expect(await readContent(base, `${doc}.ok.md`)).toBe('fine');
|
|
1509
|
+
expect((await post(`${base}/api/agent/tools/file_stat`, { path: doc })).status, doc).not.toBe(200);
|
|
1510
|
+
}
|
|
856
1511
|
// And the pptx is untouched: reading it still extracts the original text.
|
|
857
|
-
expect(await readContent(base,
|
|
1512
|
+
expect(await readContent(base, `${KB_DIR}/deck.pptx`)).toContain('Original');
|
|
858
1513
|
});
|
|
859
1514
|
|
|
860
|
-
it('
|
|
1515
|
+
it('every file tool states the SAME content rule — refused families, the byte tools and the upload path — so agents learn before the call', async () => {
|
|
861
1516
|
await start();
|
|
862
1517
|
const tools = await toolRegistry.listInternal();
|
|
863
|
-
|
|
1518
|
+
const fileTools = ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'mkdir', 'move_file', 'copy_file', 'unzip'];
|
|
1519
|
+
for (const name of fileTools) {
|
|
864
1520
|
const def = tools.find((t) => t.name === name);
|
|
865
1521
|
expect(def, name).toBeDefined();
|
|
1522
|
+
// One constant, verbatim — the description is what tools_info returns.
|
|
1523
|
+
expect(def!.description, name).toContain(CONTENT_RULE);
|
|
1524
|
+
expect(def!.description, name).toContain('`binary_not_writable`');
|
|
1525
|
+
expect(def!.description, name).toContain('copy_file, move_file, delete_file and unzip act on bytes of any kind');
|
|
1526
|
+
expect(def!.description, name).toContain('`request_upload_token` + `apply_upload`');
|
|
1527
|
+
expect(def!.description, name).toContain('`contentMode`');
|
|
866
1528
|
// Modern extractable formats…
|
|
867
1529
|
expect(def!.description, name).toContain('.docx/.pptx/.xlsx/.odt/.odp/.ods/.pdf');
|
|
868
1530
|
// …email files (extractions too, so the same refusal applies)…
|
|
869
1531
|
expect(def!.description, name).toContain('.eml/.msg');
|
|
870
1532
|
// …the legacy binary family the refusal also covers…
|
|
871
1533
|
expect(def!.description, name).toContain('.doc/.ppt/.xls');
|
|
872
|
-
|
|
873
|
-
|
|
1534
|
+
}
|
|
1535
|
+
// The shell is not a file tool: it does not carry the rule.
|
|
1536
|
+
expect(tools.find((t) => t.name === 'execute_command')!.description).not.toContain(CONTENT_RULE);
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
describe('binary capability contract: a text file, a document, an image and a zip', () => {
|
|
1540
|
+
const PNG = Buffer.from(
|
|
1541
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
|
1542
|
+
'base64',
|
|
1543
|
+
);
|
|
1544
|
+
const zipBytes = (): Buffer => {
|
|
1545
|
+
const z = new AdmZip();
|
|
1546
|
+
z.addFile(`${KB_DIR}/inner.md`, Buffer.from('# inner\n'));
|
|
1547
|
+
return z.toBuffer();
|
|
1548
|
+
};
|
|
1549
|
+
/** Seed the four kinds; returns each path with its exact bytes and expected answers. */
|
|
1550
|
+
const seed = async () => {
|
|
1551
|
+
const files = [
|
|
1552
|
+
{ path: `${KB_DIR}/notes.md`, bytes: Buffer.from('hello text\n'), mode: 'text', kind: null },
|
|
1553
|
+
{ path: `${KB_DIR}/deck.pptx`, bytes: pptx([['Original']]), mode: 'document', kind: 'document' },
|
|
1554
|
+
{ path: `${KB_DIR}/logo.png`, bytes: PNG, mode: 'binary', kind: 'image' },
|
|
1555
|
+
{ path: `${KB_DIR}/bundle.zip`, bytes: zipBytes(), mode: 'binary', kind: 'archive' },
|
|
1556
|
+
] as const;
|
|
1557
|
+
for (const f of files) await fs.writeFile(f.path, f.bytes);
|
|
1558
|
+
return files;
|
|
1559
|
+
};
|
|
1560
|
+
/** `path` is a repository path, which is workspace-relative already. */
|
|
1561
|
+
const onDisk = async (path: string) => Buffer.from(await readFile(join(tempDir, path)));
|
|
1562
|
+
/** A sibling of `path` under `dir`, inside the repository. */
|
|
1563
|
+
const under = (dir: string, path: string) => `${KB_DIR}/${dir}/${path.slice(KB_DIR.length + 1)}`;
|
|
1564
|
+
interface Refusal { error: string; kind: string; fileKind: string; useInstead: string[] }
|
|
1565
|
+
const expectRefusal = async (res: Response, fileKind: string, label: string) => {
|
|
1566
|
+
expect(res.status, label).toBe(415);
|
|
1567
|
+
const body = (await res.json()) as Refusal;
|
|
1568
|
+
expect(body.kind, label).toBe('binary_not_writable');
|
|
1569
|
+
expect(body.fileKind, label).toBe(fileKind);
|
|
1570
|
+
expect(body.useInstead, label).toEqual(['upload', 'copy_file', 'move_file']);
|
|
1571
|
+
// The prose names the kind and the alternatives too, for a caller that only sees the message.
|
|
1572
|
+
expect(body.error, label).toContain(`this file's kind is ${fileKind};`);
|
|
1573
|
+
expect(body.error, label).toContain('upload');
|
|
1574
|
+
expect(body.error, label).toContain('copy_file / move_file');
|
|
1575
|
+
};
|
|
1576
|
+
/** The same refusal, as `write_files` reports it: per path, inside a 200. */
|
|
1577
|
+
const expectBatchRefusal = async (res: Response, fileKind: string, label: string) => {
|
|
1578
|
+
expect(res.status, label).toBe(200);
|
|
1579
|
+
const body = (await res.json()) as { count: number; files: { outcome: string; error?: string; message?: string }[] };
|
|
1580
|
+
expect(body.count, label).toBe(0);
|
|
1581
|
+
expect(body.files[0].outcome, label).toBe('refused');
|
|
1582
|
+
expect(body.files[0].error, label).toBe('binary_not_writable');
|
|
1583
|
+
expect(body.files[0].message, label).toContain(`this file's kind is ${fileKind};`);
|
|
1584
|
+
};
|
|
1585
|
+
|
|
1586
|
+
it('file_stat reports contentMode text | document | binary', async () => {
|
|
1587
|
+
const base = await start();
|
|
1588
|
+
for (const f of await seed()) {
|
|
1589
|
+
const stat = (await (await post(`${base}/api/agent/tools/file_stat`, { path: f.path })).json()) as Record<string, unknown>;
|
|
1590
|
+
expect(stat.type, f.path).toBe('file');
|
|
1591
|
+
expect(stat.contentMode, f.path).toBe(f.mode);
|
|
1592
|
+
}
|
|
1593
|
+
// Binary content under a text name is binary: write_file would refuse it.
|
|
1594
|
+
await fs.writeFile(`${KB_DIR}/blob.dat`, Buffer.from([0x00, 0xff]));
|
|
1595
|
+
const blob = (await (await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/blob.dat` })).json()) as Record<string, unknown>;
|
|
1596
|
+
expect(blob.contentMode).toBe('binary');
|
|
1597
|
+
// A directory has no content mode.
|
|
1598
|
+
await fs.mkdir(`${KB_DIR}/dir`, { recursive: true });
|
|
1599
|
+
const dir = (await (await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/dir` })).json()) as Record<string, unknown>;
|
|
1600
|
+
expect(dir.contentMode).toBeUndefined();
|
|
1601
|
+
expect(dir.kind).toBeUndefined();
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
it('file_stat classifies a file the way read_file does: kind, mime, mimeSource, textEditable', async () => {
|
|
1605
|
+
const base = await start();
|
|
1606
|
+
const stat = async (path: string) =>
|
|
1607
|
+
(await (await post(`${base}/api/agent/tools/file_stat`, { path })).json()) as Record<string, unknown>;
|
|
1608
|
+
// Extensionless UTF-8: read_file returns its text, so stat says text/plain.
|
|
1609
|
+
await fs.writeFile('Sample file', Buffer.from('plain words, no extension\n'));
|
|
1610
|
+
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: 'Sample file' })).json()).toMatchObject({ content: 'plain words, no extension\n' });
|
|
1611
|
+
expect(await stat('Sample file')).toMatchObject({ type: 'file', kind: 'text', mime: 'text/plain', mimeSource: 'sniff', textEditable: true });
|
|
1612
|
+
expect(await stat('Sample file')).not.toHaveProperty('mimeNote');
|
|
1613
|
+
// The filesystem's own mimeType (a second extension table: octet-stream
|
|
1614
|
+
// here) is not passed through, so nothing in the answer contradicts `mime`.
|
|
1615
|
+
expect((await fs.stat('Sample file')).mimeType).toBeDefined();
|
|
1616
|
+
await fs.writeFile(`${KB_DIR}/notes.md`, Buffer.from('# notes\n'));
|
|
1617
|
+
await fs.mkdir('folder', { recursive: true });
|
|
1618
|
+
for (const p of ['Sample file', `${KB_DIR}/notes.md`, 'folder']) {
|
|
1619
|
+
expect(await stat(p), p).not.toHaveProperty('mimeType');
|
|
1620
|
+
}
|
|
1621
|
+
expect(await stat(`${KB_DIR}/notes.md`)).toMatchObject({ kind: 'text', mime: 'text/plain' });
|
|
1622
|
+
await fs.writeFile(`${KB_DIR}/plata.pdf`, Buffer.from('%PDF-1.4\n'));
|
|
1623
|
+
expect(await stat(`${KB_DIR}/plata.pdf`)).toMatchObject({ kind: 'document', mime: 'application/pdf', textEditable: false });
|
|
1624
|
+
await seed();
|
|
1625
|
+
expect(await stat(`${KB_DIR}/deck.pptx`)).toMatchObject({ kind: 'document', mimeSource: 'extension', textEditable: false });
|
|
1626
|
+
expect(await stat(`${KB_DIR}/logo.png`)).toMatchObject({ kind: 'image', mime: 'image/png', textEditable: false });
|
|
1627
|
+
// Real binary bytes without a known extension: the octet-stream fallback, and a note saying so.
|
|
1628
|
+
await fs.writeFile('Sample blob', Buffer.from([0x00, 0x01, 0xff]));
|
|
1629
|
+
const blob = await stat('Sample blob');
|
|
1630
|
+
expect(blob).toMatchObject({ kind: 'binary', mime: 'application/octet-stream', mimeSource: 'fallback', textEditable: false });
|
|
1631
|
+
expect(blob.mimeNote).toBe(OCTET_STREAM_FALLBACK_NOTE);
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
it('write_file, write_files and edit_file accept the text file and refuse the other three with binary_not_writable, bytes untouched', async () => {
|
|
1635
|
+
const base = await start();
|
|
1636
|
+
// The plain test filesystem has no batch commit; give it one that lands
|
|
1637
|
+
// each write, so the text batch must actually SUCCEED past the gate.
|
|
1638
|
+
(fs as unknown as { writeFiles: (writes: { path: string; content: string }[]) => Promise<void> }).writeFiles = async (writes) => {
|
|
1639
|
+
for (const w of writes) await fs.writeFile(w.path, w.content);
|
|
1640
|
+
};
|
|
1641
|
+
const files = await seed();
|
|
1642
|
+
for (const f of files) {
|
|
1643
|
+
// Every seeded file already exists, so the write says it means to replace it.
|
|
1644
|
+
const write = await post(`${base}/api/agent/tools/write_file`, { path: f.path, content: 'plain text', mode: 'overwrite' });
|
|
1645
|
+
const batch = await post(`${base}/api/agent/tools/write_files`, { files: [{ path: f.path, content: 'plain text' }], mode: 'overwrite' });
|
|
1646
|
+
const edit = await post(`${base}/api/agent/tools/edit_file`, { path: f.path, old_string: 'a', new_string: 'b' });
|
|
1647
|
+
if (f.kind === null) {
|
|
1648
|
+
expect(write.status, f.path).toBe(200);
|
|
1649
|
+
expect(batch.status, f.path).toBe(200);
|
|
1650
|
+
// Content is now 'plain text', so 'a' is found once.
|
|
1651
|
+
expect(edit.status, f.path).toBe(200);
|
|
1652
|
+
expect((await onDisk(f.path)).toString('utf8')).toBe('plbin text');
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
await expectRefusal(write, f.kind, `write_file ${f.path}`);
|
|
1656
|
+
await expectBatchRefusal(batch, f.kind, `write_files ${f.path}`);
|
|
1657
|
+
await expectRefusal(edit, f.kind, `edit_file ${f.path}`);
|
|
1658
|
+
expect((await onDisk(f.path)).equals(f.bytes), f.path).toBe(true);
|
|
1659
|
+
}
|
|
1660
|
+
// Creating a NEW image or zip by text is refused the same way — nothing lands.
|
|
1661
|
+
await expectRefusal(await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/new.png`, content: 'x' }), 'image', `${KB_DIR}/new.png`);
|
|
1662
|
+
await expectRefusal(await post(`${base}/api/agent/tools/write_file`, { path: `${KB_DIR}/new.zip`, content: 'x' }), 'archive', `${KB_DIR}/new.zip`);
|
|
1663
|
+
expect((await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/new.png` })).status).not.toBe(200);
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
it('copy_file and move_file carry every kind byte-for-byte', async () => {
|
|
1667
|
+
const base = await start();
|
|
1668
|
+
for (const f of await seed()) {
|
|
1669
|
+
const copied = await post(`${base}/api/agent/tools/copy_file`, { src: f.path, dest: under('copies', f.path) });
|
|
1670
|
+
expect(copied.status, f.path).toBe(200);
|
|
1671
|
+
expect((await onDisk(under('copies', f.path))).equals(f.bytes), `copy ${f.path}`).toBe(true);
|
|
1672
|
+
const moved = await post(`${base}/api/agent/tools/move_file`, { src: f.path, dest: under('moved', f.path) });
|
|
1673
|
+
expect(moved.status, f.path).toBe(200);
|
|
1674
|
+
expect((await onDisk(under('moved', f.path))).equals(f.bytes), `move ${f.path}`).toBe(true);
|
|
1675
|
+
}
|
|
1676
|
+
// The moved zip is still a real archive: unzip reads it as bytes.
|
|
1677
|
+
const z = new AdmZip(await onDisk(`${KB_DIR}/moved/bundle.zip`));
|
|
1678
|
+
expect(z.getEntry(`${KB_DIR}/inner.md`)?.getData().toString('utf8')).toBe('# inner\n');
|
|
1679
|
+
});
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
it('the page-writing tools say where images go, so an agent writes the link a page will render', async () => {
|
|
1683
|
+
await start();
|
|
1684
|
+
const tools = await toolRegistry.listInternal();
|
|
1685
|
+
for (const name of ['write_file', 'write_files']) {
|
|
1686
|
+
const def = tools.find((t) => t.name === name);
|
|
1687
|
+
expect(def, name).toBeDefined();
|
|
1688
|
+
expect(def!.description, name).toContain('`assets/` folder next to the page');
|
|
1689
|
+
expect(def!.description, name).toContain('');
|
|
874
1690
|
}
|
|
875
1691
|
});
|
|
876
1692
|
});
|
|
@@ -899,14 +1715,14 @@ describe('images', () => {
|
|
|
899
1715
|
|
|
900
1716
|
it('read_file on a png returns the image sentinel with base64, mimeType and a note naming path + dimensions + size', async () => {
|
|
901
1717
|
const base = await start();
|
|
902
|
-
await fs.writeFile(
|
|
903
|
-
const res = await post(`${base}/api/agent/tools/read_file`, { path:
|
|
1718
|
+
await fs.writeFile(`${KB_DIR}/logo.png`, PNG_1X1);
|
|
1719
|
+
const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/logo.png` });
|
|
904
1720
|
expect(res.status).toBe(200);
|
|
905
1721
|
const body = (await res.json()) as ImageSentinel;
|
|
906
1722
|
expect(body.kind).toBe('bevel/mcp-image@v1');
|
|
907
1723
|
expect(body.mimeType).toBe('image/png');
|
|
908
1724
|
expect(body.data).toBe(PNG_1X1.toString('base64'));
|
|
909
|
-
expect(body.note).toContain(
|
|
1725
|
+
expect(body.note).toContain(`${KB_DIR}/logo.png`);
|
|
910
1726
|
expect(body.note).toContain('image/png');
|
|
911
1727
|
expect(body.note).toContain(`${PNG_1X1.length} bytes`);
|
|
912
1728
|
expect(body.note).toContain('1×1 px');
|
|
@@ -914,8 +1730,8 @@ describe('images', () => {
|
|
|
914
1730
|
|
|
915
1731
|
it('read_file passes a gif through whole under the same cap (first frame is the client’s concern)', async () => {
|
|
916
1732
|
const base = await start();
|
|
917
|
-
await fs.writeFile(
|
|
918
|
-
const body = (await (await post(`${base}/api/agent/tools/read_file`, { path:
|
|
1733
|
+
await fs.writeFile(`${KB_DIR}/anim.gif`, GIF_1X1);
|
|
1734
|
+
const body = (await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/anim.gif` })).json()) as ImageSentinel;
|
|
919
1735
|
expect(body.kind).toBe('bevel/mcp-image@v1');
|
|
920
1736
|
expect(body.mimeType).toBe('image/gif');
|
|
921
1737
|
expect(body.data).toBe(GIF_1X1.toString('base64'));
|
|
@@ -925,11 +1741,11 @@ describe('images', () => {
|
|
|
925
1741
|
it('read_file maps .jpg/.jpeg to image/jpeg (dimensions omitted when the header has none to give)', async () => {
|
|
926
1742
|
const base = await start();
|
|
927
1743
|
const bytes = Buffer.from([0xff, 0xd8, 0xff, 0xd9]); // SOI + EOI, no frame header
|
|
928
|
-
await fs.writeFile(
|
|
929
|
-
const body = (await (await post(`${base}/api/agent/tools/read_file`, { path:
|
|
1744
|
+
await fs.writeFile(`${KB_DIR}/photo.jpg`, bytes);
|
|
1745
|
+
const body = (await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/photo.jpg` })).json()) as ImageSentinel;
|
|
930
1746
|
expect(body.mimeType).toBe('image/jpeg');
|
|
931
1747
|
expect(body.data).toBe(bytes.toString('base64'));
|
|
932
|
-
expect(body.note).toBe(`[image: photo.jpg — image/jpeg, ${bytes.length} bytes]`);
|
|
1748
|
+
expect(body.note).toBe(`[image: ${KB_DIR}/photo.jpg — image/jpeg, ${bytes.length} bytes]`);
|
|
933
1749
|
});
|
|
934
1750
|
|
|
935
1751
|
it('read_file refuses an image over 3.5 MiB raw with the downscale message, not a sentinel', async () => {
|
|
@@ -937,11 +1753,11 @@ describe('images', () => {
|
|
|
937
1753
|
// 3,670,016 is the cap; one byte over must refuse. PNG magic + zero fill.
|
|
938
1754
|
const big = Buffer.alloc(3_670_017);
|
|
939
1755
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(big);
|
|
940
|
-
await fs.writeFile(
|
|
941
|
-
const res = await post(`${base}/api/agent/tools/read_file`, { path:
|
|
1756
|
+
await fs.writeFile(`${KB_DIR}/huge.png`, big);
|
|
1757
|
+
const res = await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/huge.png` });
|
|
942
1758
|
expect(res.status).toBe(200);
|
|
943
1759
|
const body = (await res.json()) as { path: string; content: string };
|
|
944
|
-
expect(body.path).toBe(
|
|
1760
|
+
expect(body.path).toBe(`${KB_DIR}/huge.png`);
|
|
945
1761
|
expect(body.content).toContain('too large to return over MCP');
|
|
946
1762
|
expect(body.content).toContain('3670016 bytes');
|
|
947
1763
|
expect(body.content).toContain('Downscale');
|
|
@@ -951,9 +1767,9 @@ describe('images', () => {
|
|
|
951
1767
|
it('read_file keeps .svg on the TEXT path — it is markup, not an image block', async () => {
|
|
952
1768
|
const base = await start();
|
|
953
1769
|
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8"/></svg>';
|
|
954
|
-
await fs.writeFile(
|
|
955
|
-
expect(await (await post(`${base}/api/agent/tools/read_file`, { path:
|
|
956
|
-
path:
|
|
1770
|
+
await fs.writeFile(`${KB_DIR}/icon.svg`, svg);
|
|
1771
|
+
expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/icon.svg` })).json()).toEqual({
|
|
1772
|
+
path: `${KB_DIR}/icon.svg`,
|
|
957
1773
|
content: svg,
|
|
958
1774
|
});
|
|
959
1775
|
});
|
|
@@ -977,7 +1793,14 @@ describe('start_session', () => {
|
|
|
977
1793
|
let server: HttpServer | undefined;
|
|
978
1794
|
let created: Array<{ userId: string; startedAt: Date }> = [];
|
|
979
1795
|
|
|
980
|
-
async function startSessionApp(
|
|
1796
|
+
async function startSessionApp(
|
|
1797
|
+
source: 'external' | 'internal' = 'external',
|
|
1798
|
+
// The default fake returns one fixed id, which is what most of these
|
|
1799
|
+
// assertions want. The load reproduction below substitutes the REAL
|
|
1800
|
+
// `UuidSessionSink`, because "did fifty first calls collide?" is a question
|
|
1801
|
+
// only the real minting can answer.
|
|
1802
|
+
sink?: ISessionSink,
|
|
1803
|
+
): Promise<string> {
|
|
981
1804
|
created = [];
|
|
982
1805
|
const registry = new ToolRegistry();
|
|
983
1806
|
const resolve = async (auth: ToolAuth, signal: AbortSignal): Promise<ToolContext> => ({
|
|
@@ -1011,7 +1834,7 @@ describe('start_session', () => {
|
|
|
1011
1834
|
new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, KB_DIR,
|
|
1012
1835
|
{ service: {} as never, enabled: false, kbDirName: KB_DIR, recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks() },
|
|
1013
1836
|
new RoutineWritePolicyService(),
|
|
1014
|
-
fakeSessionSink,
|
|
1837
|
+
sink ?? fakeSessionSink,
|
|
1015
1838
|
);
|
|
1016
1839
|
app.use('/api', router);
|
|
1017
1840
|
server = await new Promise<HttpServer>((r) => {
|
|
@@ -1048,6 +1871,107 @@ describe('start_session', () => {
|
|
|
1048
1871
|
expect(res.status).toBe(403);
|
|
1049
1872
|
expect(created).toHaveLength(0);
|
|
1050
1873
|
});
|
|
1874
|
+
|
|
1875
|
+
/**
|
|
1876
|
+
* The load reproduction, platform side.
|
|
1877
|
+
*
|
|
1878
|
+
* The report behind this was one first call on a fresh connection failing
|
|
1879
|
+
* with a generic error, an immediate retry working, and the error carrying a
|
|
1880
|
+
* `req_011…` id — the Anthropic API's request-id shape, which nothing here
|
|
1881
|
+
* mints. The hypothesis was that the failure never reached the tool. These
|
|
1882
|
+
* run the real `UuidSessionSink` behind the real route, fifty calls at once,
|
|
1883
|
+
* so the hypothesis stops being the only account of what the route does
|
|
1884
|
+
* under a burst.
|
|
1885
|
+
*
|
|
1886
|
+
* `first-call-probe.ts` (and its suite) does the same over a real MCP
|
|
1887
|
+
* transport; this one strips the transport away so a future failure can be
|
|
1888
|
+
* placed on one side of it or the other.
|
|
1889
|
+
*/
|
|
1890
|
+
describe('fifty first calls at once', () => {
|
|
1891
|
+
it('answers every one of them with a session id of its own', async () => {
|
|
1892
|
+
const base = await startSessionApp('external', new UuidSessionSink());
|
|
1893
|
+
|
|
1894
|
+
const responses = await Promise.all(Array.from({ length: 50 }, () => post(`${base}/api/agent/tools/start_session`)));
|
|
1895
|
+
const bodies = (await Promise.all(responses.map((r) => r.json()))) as Array<{ sessionId?: string }>;
|
|
1896
|
+
|
|
1897
|
+
expect(responses.map((r) => r.status)).toEqual(Array.from({ length: 50 }, () => 200));
|
|
1898
|
+
// Fifty ids, no collision and no blank: a burst is not one id handed out
|
|
1899
|
+
// repeatedly, and it is not a partially-served queue either.
|
|
1900
|
+
expect(new Set(bodies.map((b) => b.sessionId)).size).toBe(50);
|
|
1901
|
+
expect(bodies.every((b) => typeof b.sessionId === 'string' && b.sessionId.length > 0)).toBe(true);
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
it('mints nothing when the call fails, so the retry the description promises is safe', async () => {
|
|
1905
|
+
// A sink that fails the first call and serves the second is the reported
|
|
1906
|
+
// sequence exactly. What the retry must NOT inherit is any state the
|
|
1907
|
+
// failed call left — and there is none to inherit, because a failed mint
|
|
1908
|
+
// never reached the point of producing an id.
|
|
1909
|
+
let calls = 0;
|
|
1910
|
+
const flaky: ISessionSink = {
|
|
1911
|
+
createSession: async () => {
|
|
1912
|
+
calls++;
|
|
1913
|
+
if (calls === 1) throw new Error('sink unavailable');
|
|
1914
|
+
return { sessionId: `session-${calls}` };
|
|
1915
|
+
},
|
|
1916
|
+
};
|
|
1917
|
+
const base = await startSessionApp('external', flaky);
|
|
1918
|
+
|
|
1919
|
+
// The route logs the sink's failure — correct behaviour, and expected
|
|
1920
|
+
// here, so it is kept out of the suite's output rather than left to look
|
|
1921
|
+
// like a real fault. Restored in a `finally`: if the request itself
|
|
1922
|
+
// rejected, an un-restored spy would go on swallowing console.error for
|
|
1923
|
+
// every later test in this file, and they would pass while saying nothing.
|
|
1924
|
+
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
1925
|
+
let failed: Awaited<ReturnType<typeof post>>;
|
|
1926
|
+
try {
|
|
1927
|
+
failed = await post(`${base}/api/agent/tools/start_session`);
|
|
1928
|
+
} finally {
|
|
1929
|
+
errorLog.mockRestore();
|
|
1930
|
+
}
|
|
1931
|
+
expect(failed.status).toBeGreaterThanOrEqual(500);
|
|
1932
|
+
|
|
1933
|
+
const retried = (await (await post(`${base}/api/agent/tools/start_session`)).json()) as { sessionId: string };
|
|
1934
|
+
expect(retried.sessionId).toBe('session-2');
|
|
1935
|
+
expect(calls).toBe(2);
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
it('gives a retry that lands after a success a NEW id, leaving the first one usable', async () => {
|
|
1939
|
+
// The harmless case the description calls out: a client that retries a
|
|
1940
|
+
// call which had in fact succeeded ends up holding two ids. Neither
|
|
1941
|
+
// supersedes the other — the run keeps using the one it already passed
|
|
1942
|
+
// to other tools, and the spare is simply never mentioned again.
|
|
1943
|
+
const base = await startSessionApp('external', new UuidSessionSink());
|
|
1944
|
+
|
|
1945
|
+
const first = (await (await post(`${base}/api/agent/tools/start_session`)).json()) as { sessionId: string };
|
|
1946
|
+
const retry = (await (await post(`${base}/api/agent/tools/start_session`)).json()) as { sessionId: string };
|
|
1947
|
+
|
|
1948
|
+
expect(retry.sessionId).not.toBe(first.sessionId);
|
|
1949
|
+
expect(first.sessionId).toBeTruthy();
|
|
1950
|
+
});
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
it('tells the caller, in the tool description, that a failed call can be retried', async () => {
|
|
1954
|
+
const registry = new ToolRegistry();
|
|
1955
|
+
const router = express.Router();
|
|
1956
|
+
const noopAuth: express.RequestHandler = (_req, _res, next) => next();
|
|
1957
|
+
registerWorkspaceTools(
|
|
1958
|
+
registry, router, noopAuth, (() => () => {}) as never,
|
|
1959
|
+
new SpillStore(join(tmpdir(), 'bevel-test-spills')), new DocExtractService(join(tmpdir(), 'bevel-test-doc-extract')), allowAll, KB_DIR,
|
|
1960
|
+
{ service: {} as never, enabled: false, kbDirName: KB_DIR, recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks() },
|
|
1961
|
+
new RoutineWritePolicyService(),
|
|
1962
|
+
{} as never,
|
|
1963
|
+
);
|
|
1964
|
+
|
|
1965
|
+
const description = (await registry.listExternal()).find((t) => t.name === 'start_session')?.description ?? '';
|
|
1966
|
+
|
|
1967
|
+
// The three things a caller has to be told, and the reason the ticket asked
|
|
1968
|
+
// for them: without the first two a transport hiccup reads as a dead end,
|
|
1969
|
+
// and without the third a client that already retried thinks it has
|
|
1970
|
+
// corrupted its own run.
|
|
1971
|
+
expect(description).toMatch(/retry/i);
|
|
1972
|
+
expect(description).toMatch(/created nothing/i);
|
|
1973
|
+
expect(description).toMatch(/harmless/i);
|
|
1974
|
+
});
|
|
1051
1975
|
});
|
|
1052
1976
|
|
|
1053
1977
|
/**
|
|
@@ -1154,6 +2078,30 @@ describe('path inputs tell the agent about the repository folder', () => {
|
|
|
1154
2078
|
}
|
|
1155
2079
|
});
|
|
1156
2080
|
|
|
2081
|
+
it('say the prefix is OPTIONAL, and what an unprefixed path means', async () => {
|
|
2082
|
+
await start();
|
|
2083
|
+
const tools = await toolRegistry.listInternal();
|
|
2084
|
+
const byName = (name: string) => tools.find((t) => t.name === name);
|
|
2085
|
+
const said = (name: string, ...keys: string[]) => inputDescription(byName(name), ...keys);
|
|
2086
|
+
// Every path input, the ones that used to carry the shorter "may name a
|
|
2087
|
+
// stray" wording included: a path without the prefix is PLACED under it,
|
|
2088
|
+
// and the description says which path that is.
|
|
2089
|
+
for (const name of ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'edit_file', 'delete_file', 'mkdir', 'unzip', 'delete_folder']) {
|
|
2090
|
+
expect(said(name, 'path'), name).toContain(`placed under \`${KB_DIR}/\``);
|
|
2091
|
+
}
|
|
2092
|
+
for (const name of ['move_file', 'copy_file']) {
|
|
2093
|
+
for (const key of ['src', 'dest']) {
|
|
2094
|
+
expect(said(name, key), `${name}.${key}`).toContain(`placed under \`${KB_DIR}/\``);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
expect(said('unzip', 'destination')).toContain(`placed under \`${KB_DIR}/\``);
|
|
2098
|
+
expect(inputDescription(byName('write_files'), 'files', 'path')).toContain(`placed under \`${KB_DIR}/\``);
|
|
2099
|
+
// And no input still claims a missing prefix is refused.
|
|
2100
|
+
for (const name of ['read_file', 'write_file', 'delete_file', 'move_file', 'copy_file', 'unzip']) {
|
|
2101
|
+
expect(said(name, 'path') + said(name, 'src') + said(name, 'dest'), name).not.toContain('is refused');
|
|
2102
|
+
}
|
|
2103
|
+
});
|
|
2104
|
+
|
|
1157
2105
|
it('the root listing names the clone folder, so an agent that lists first learns the prefix', async () => {
|
|
1158
2106
|
await start();
|
|
1159
2107
|
const tools = await toolRegistry.listInternal();
|
|
@@ -1163,3 +2111,1577 @@ describe('path inputs tell the agent about the repository folder', () => {
|
|
|
1163
2111
|
expect(inputDescription(list, 'path')).toContain(`\`${KB_DIR}/\``);
|
|
1164
2112
|
});
|
|
1165
2113
|
});
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* The MCP tools ACCEPT an unprefixed path now, by placing it inside the
|
|
2117
|
+
* checkout, where they used to refuse it with a message naming the corrected
|
|
2118
|
+
* spelling. One normaliser does it for all of them, at the tool boundary, so a
|
|
2119
|
+
* tool cannot drift from a route again — and the answer names the repository
|
|
2120
|
+
* path, so a caller always learns where its bytes went.
|
|
2121
|
+
*/
|
|
2122
|
+
describe('the tools place an unprefixed path inside the repository', () => {
|
|
2123
|
+
const tool = (base: string, name: string, body: Record<string, unknown>) =>
|
|
2124
|
+
post(`${base}/api/agent/tools/${name}`, { branch: 'main', ...body });
|
|
2125
|
+
const onDisk = (repoRel: string) => readFile(join(tempDir, KB_DIR, repoRel), 'utf8');
|
|
2126
|
+
/** Nothing beside the checkout, ever. */
|
|
2127
|
+
const besideCheckout = async () => (await nodeReaddir(tempDir)).filter((n) => n !== KB_DIR).sort();
|
|
2128
|
+
|
|
2129
|
+
it('write_file writes into the repository and reports the repository path', async () => {
|
|
2130
|
+
const base = await start();
|
|
2131
|
+
const res = await tool(base, 'write_file', { path: 'KnowledgeBase/Report.md', content: 'body' });
|
|
2132
|
+
expect(res.status).toBe(200);
|
|
2133
|
+
expect(await res.json()).toMatchObject({ path: `${KB_DIR}/KnowledgeBase/Report.md`, outcome: 'created' });
|
|
2134
|
+
expect(await onDisk('KnowledgeBase/Report.md')).toBe('body');
|
|
2135
|
+
expect(await besideCheckout()).toEqual([]);
|
|
2136
|
+
});
|
|
2137
|
+
|
|
2138
|
+
it('write_files places every entry of a batch', async () => {
|
|
2139
|
+
const base = await start();
|
|
2140
|
+
const res = await tool(base, 'write_files', {
|
|
2141
|
+
files: [{ path: 'one.md', content: '1' }, { path: 'Nested/two.md', content: '2' }],
|
|
2142
|
+
});
|
|
2143
|
+
expect(res.status).toBe(200);
|
|
2144
|
+
const body = (await res.json()) as { files: { path: string }[] };
|
|
2145
|
+
expect(body.files.map((f) => f.path)).toEqual([`${KB_DIR}/one.md`, `${KB_DIR}/Nested/two.md`]);
|
|
2146
|
+
expect(await onDisk('Nested/two.md')).toBe('2');
|
|
2147
|
+
expect(await besideCheckout()).toEqual([]);
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
it('read_file, file_stat and edit_file all read the same repository file', async () => {
|
|
2151
|
+
const base = await start();
|
|
2152
|
+
await fs.writeFile(`${KB_DIR}/Notes.md`, 'hello\nworld\n');
|
|
2153
|
+
|
|
2154
|
+
expect(await (await tool(base, 'read_file', { path: 'Notes.md' })).json()).toEqual({
|
|
2155
|
+
path: `${KB_DIR}/Notes.md`,
|
|
2156
|
+
content: 'hello\nworld\n',
|
|
2157
|
+
});
|
|
2158
|
+
expect(await (await tool(base, 'file_stat', { path: 'Notes.md' })).json()).toMatchObject({ type: 'file' });
|
|
2159
|
+
const edited = await tool(base, 'edit_file', { path: 'Notes.md', old_string: 'world', new_string: 'earth' });
|
|
2160
|
+
expect(await edited.json()).toMatchObject({ path: `${KB_DIR}/Notes.md`, replaced: 1 });
|
|
2161
|
+
expect(await onDisk('Notes.md')).toBe('hello\nearth\n');
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2164
|
+
it('mkdir creates the folder in the repository', async () => {
|
|
2165
|
+
const base = await start();
|
|
2166
|
+
expect((await tool(base, 'mkdir', { path: 'KnowledgeBase/Reports' })).status).toBe(200);
|
|
2167
|
+
expect(await fs.exists(`${KB_DIR}/KnowledgeBase/Reports`)).toBe(true);
|
|
2168
|
+
expect(await besideCheckout()).toEqual([]);
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
it('move_file and copy_file place both ends', async () => {
|
|
2172
|
+
const base = await start();
|
|
2173
|
+
await fs.writeFile(`${KB_DIR}/Src/a.md`, 'a');
|
|
2174
|
+
|
|
2175
|
+
expect((await tool(base, 'copy_file', { src: 'Src/a.md', dest: 'Copies/a.md' })).status).toBe(200);
|
|
2176
|
+
expect(await onDisk('Copies/a.md')).toBe('a');
|
|
2177
|
+
expect((await tool(base, 'move_file', { src: 'Src/a.md', dest: 'Moved/a.md' })).status).toBe(200);
|
|
2178
|
+
expect(await onDisk('Moved/a.md')).toBe('a');
|
|
2179
|
+
expect(await besideCheckout()).toEqual([]);
|
|
2180
|
+
});
|
|
2181
|
+
|
|
2182
|
+
it('delete_file and delete_folder act inside the repository', async () => {
|
|
2183
|
+
const base = await start();
|
|
2184
|
+
await fs.writeFile(`${KB_DIR}/Doomed/x.md`, 'x');
|
|
2185
|
+
expect((await tool(base, 'delete_file', { path: 'Doomed/x.md' })).status).toBe(200);
|
|
2186
|
+
expect(await fs.exists(`${KB_DIR}/Doomed/x.md`)).toBe(false);
|
|
2187
|
+
|
|
2188
|
+
await fs.writeFile(`${KB_DIR}/Gone/y.md`, 'y');
|
|
2189
|
+
expect((await tool(base, 'delete_folder', { path: 'Gone', confirm: true })).status).toBe(200);
|
|
2190
|
+
expect(await fs.exists(`${KB_DIR}/Gone/y.md`)).toBe(false);
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
it('unzip hands the service the repository path for archive and destination alike', async () => {
|
|
2194
|
+
const base = await start();
|
|
2195
|
+
const zip = new AdmZip();
|
|
2196
|
+
zip.addFile('in.md', Buffer.from('z'));
|
|
2197
|
+
await fs.writeFile(`${KB_DIR}/drop.zip`, zip.toBuffer());
|
|
2198
|
+
|
|
2199
|
+
expect((await tool(base, 'unzip', { path: 'drop.zip', destination: 'Out' })).status).toBe(200);
|
|
2200
|
+
expect(unzipCalls).toContainEqual([`${KB_DIR}/drop.zip`, `${KB_DIR}/Out`]);
|
|
2201
|
+
});
|
|
2202
|
+
|
|
2203
|
+
it('still refuses the spellings no prefix can rescue, on every tool', async () => {
|
|
2204
|
+
const base = await start();
|
|
2205
|
+
for (const p of ['../etc/hostname', 'KnowledgeBase\\x', '/tmp/x', 'KnowledgeBase/../../escape.md']) {
|
|
2206
|
+
for (const [name, body] of [
|
|
2207
|
+
['read_file', { path: p }],
|
|
2208
|
+
['file_stat', { path: p }],
|
|
2209
|
+
['write_file', { path: p, content: 'x' }],
|
|
2210
|
+
['edit_file', { path: p, old_string: 'a', new_string: 'b' }],
|
|
2211
|
+
['mkdir', { path: p }],
|
|
2212
|
+
['delete_file', { path: p }],
|
|
2213
|
+
['move_file', { src: p, dest: `${KB_DIR}/x.md` }],
|
|
2214
|
+
['copy_file', { src: `${KB_DIR}/a.md`, dest: p }],
|
|
2215
|
+
['unzip', { path: p }],
|
|
2216
|
+
] as [string, Record<string, unknown>][]) {
|
|
2217
|
+
const res = await tool(base, name, body);
|
|
2218
|
+
expect(res.status, `${name} ${p}`).toBe(400);
|
|
2219
|
+
expect((await res.json()).error, `${name} ${p}`).toContain('outside the knowledge base repository');
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
expect(await besideCheckout()).toEqual([]);
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
it('leaves a spill ref alone — it belongs to no workspace', async () => {
|
|
2226
|
+
const base = await start();
|
|
2227
|
+
const res = await tool(base, 'read_file', { path: '__tool_chain_spill__/nope.json' });
|
|
2228
|
+
// Absent, not refused as a path: the ref reached the spill store as written.
|
|
2229
|
+
expect(res.status).not.toBe(400);
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
it('gives that exception to read_file alone — for every other tool the ref is a path', async () => {
|
|
2233
|
+
// `read_file` is the only tool that consumes a spill ref. Anywhere else,
|
|
2234
|
+
// `__tool_chain_spill__/…` is an ordinary string, and leaving it unnormalised
|
|
2235
|
+
// would be a workspace-relative path that never reached the repository —
|
|
2236
|
+
// beside the checkout, which is the whole bug. So it is placed under the
|
|
2237
|
+
// checkout like any other prefix-less path, and names a file that is not there.
|
|
2238
|
+
const base = await start();
|
|
2239
|
+
const ref = '__tool_chain_spill__/nope.json';
|
|
2240
|
+
for (const [name, body] of [
|
|
2241
|
+
['file_stat', { path: ref }],
|
|
2242
|
+
['delete_file', { path: ref }],
|
|
2243
|
+
['copy_file', { src: ref, dest: `${KB_DIR}/copied.md` }],
|
|
2244
|
+
['list_files', { path: ref }],
|
|
2245
|
+
] as [string, Record<string, unknown>][]) {
|
|
2246
|
+
const res = await tool(base, name, body);
|
|
2247
|
+
const answer = JSON.stringify(await res.json());
|
|
2248
|
+
// The answer names the path the tool actually looked at, and it is the
|
|
2249
|
+
// one under the checkout — the ref was normalised, not exempted.
|
|
2250
|
+
expect(answer, name).toContain(`${KB_DIR}/__tool_chain_spill__`);
|
|
2251
|
+
expect(res.status, `${name}: ${answer}`).not.toBe(200);
|
|
2252
|
+
}
|
|
2253
|
+
// And nothing was written beside the checkout on the way.
|
|
2254
|
+
expect(await besideCheckout()).toEqual([]);
|
|
2255
|
+
});
|
|
2256
|
+
});
|
|
2257
|
+
|
|
2258
|
+
/**
|
|
2259
|
+
* A folder exists until someone deletes it, and its placeholder is never shown
|
|
2260
|
+
* as content — the agent tools' half (the UI routes' half lives in
|
|
2261
|
+
* `workspace.routes.folders.test.ts`). The filesystem here is a plain
|
|
2262
|
+
* LocalFilesystem, so "committed" is proven the way git sees it: a real
|
|
2263
|
+
* repository in the clone folder, committed and freshly cloned.
|
|
2264
|
+
*/
|
|
2265
|
+
describe('folders never vanish', () => {
|
|
2266
|
+
const KB = (p: string) => `${KB_DIR}/${p}`;
|
|
2267
|
+
const tool = async (base: string, name: string, body: Record<string, unknown>) => {
|
|
2268
|
+
const res = await post(`${base}/api/agent/tools/${name}`, body);
|
|
2269
|
+
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
|
|
2270
|
+
};
|
|
2271
|
+
const names = async (base: string, dir: string) =>
|
|
2272
|
+
((await tool(base, 'list_files', { path: dir })).body.entries as { name: string; type: string }[]).map(
|
|
2273
|
+
(e) => `${e.name}:${e.type}`,
|
|
2274
|
+
);
|
|
2275
|
+
const gitIn = (cwd: string, args: string[]) =>
|
|
2276
|
+
promisify(execFile)('git', args, {
|
|
2277
|
+
cwd,
|
|
2278
|
+
env: {
|
|
2279
|
+
...process.env,
|
|
2280
|
+
GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@x', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@x',
|
|
2281
|
+
},
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
it('list_files, file_stat and grep never show the placeholder', async () => {
|
|
2285
|
+
const base = await start();
|
|
2286
|
+
await fs.writeFile(KB('Docs/note.md'), 'marker');
|
|
2287
|
+
await fs.writeFile(KB('Docs/.gitkeep'), 'marker');
|
|
2288
|
+
await fs.writeFile(KB('Docs/Empty/.gitkeep'), '');
|
|
2289
|
+
|
|
2290
|
+
expect((await names(base, KB('Docs'))).sort()).toEqual(['Empty:directory', 'note.md:file']);
|
|
2291
|
+
expect(await names(base, KB('Docs/Empty'))).toEqual([]);
|
|
2292
|
+
|
|
2293
|
+
const missing = await tool(base, 'file_stat', { path: KB('Docs/nothing-here.md') });
|
|
2294
|
+
const placeholder = await tool(base, 'file_stat', { path: KB('Docs/.gitkeep') });
|
|
2295
|
+
expect(missing.status).toBe(404);
|
|
2296
|
+
// The placeholder is nothing, and it says so in the one shape every file
|
|
2297
|
+
// tool uses for a path with nothing at it (see not-found.ts).
|
|
2298
|
+
expect(placeholder).toEqual({
|
|
2299
|
+
status: 404,
|
|
2300
|
+
body: {
|
|
2301
|
+
kind: 'not_found',
|
|
2302
|
+
path: KB('Docs/.gitkeep'),
|
|
2303
|
+
error: `There is no file or directory at "${KB('Docs/.gitkeep')}" in this workspace. ${NOT_FOUND_NEXT_STEP}`,
|
|
2304
|
+
},
|
|
2305
|
+
});
|
|
2306
|
+
expect((await tool(base, 'file_stat', { path: KB('Docs/Empty') })).body).toMatchObject({ type: 'directory' });
|
|
2307
|
+
|
|
2308
|
+
const grep = await tool(base, 'grep', { pattern: 'marker' });
|
|
2309
|
+
expect((grep.body.matches as { path: string }[]).map((m) => m.path)).toEqual([KB('Docs/note.md')]);
|
|
2310
|
+
expect((await tool(base, 'grep', { pattern: 'marker', path: KB('Docs/.gitkeep') })).status).toBe(404);
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
it('delete_folder removes its folder but keeps the one that held it, placeholder not counted as content', async () => {
|
|
2314
|
+
const base = await start();
|
|
2315
|
+
await fs.writeFile(KB('Parent/Child/a.md'), 'a');
|
|
2316
|
+
await fs.writeFile(KB('Parent/Child/.gitkeep'), '');
|
|
2317
|
+
|
|
2318
|
+
// The placeholder goes with the folder, but it is not a file the caller
|
|
2319
|
+
// is told about or asked to confirm.
|
|
2320
|
+
const dry = await tool(base, 'delete_folder', { path: KB('Parent/Child'), dryRun: true });
|
|
2321
|
+
expect(dry.body).toMatchObject({ descendants: 1, files: [KB('Parent/Child/a.md')] });
|
|
2322
|
+
expect((await tool(base, 'file_stat', { path: KB('Parent/Child') })).body).toMatchObject({ descendants: 1 });
|
|
2323
|
+
|
|
2324
|
+
const run = await tool(base, 'delete_folder', { path: KB('Parent/Child'), confirm: true });
|
|
2325
|
+
expect(run.body).toMatchObject({ deleted: true });
|
|
2326
|
+
expect(await fs.exists(KB('Parent/Child'))).toBe(false);
|
|
2327
|
+
// `Parent` was not deleted: now empty, it stays, listed as an empty folder.
|
|
2328
|
+
expect(await names(base, KB(''))).toContain('Parent:directory');
|
|
2329
|
+
expect(await names(base, KB('Parent'))).toEqual([]);
|
|
2330
|
+
expect(await fs.exists(KB('Parent/.gitkeep'))).toBe(true);
|
|
2331
|
+
});
|
|
2332
|
+
|
|
2333
|
+
it('a folder holding only its placeholder is empty: deleted without confirm, reporting no files', async () => {
|
|
2334
|
+
const base = await start();
|
|
2335
|
+
await fs.writeFile(KB('Holder/Empty/.gitkeep'), '');
|
|
2336
|
+
await fs.writeFile(KB('Holder/keep.md'), 'k');
|
|
2337
|
+
|
|
2338
|
+
const run = await tool(base, 'delete_folder', { path: KB('Holder/Empty') });
|
|
2339
|
+
expect(run.body).toMatchObject({ deleted: true, descendants: 0, files: [] });
|
|
2340
|
+
expect(await fs.exists(KB('Holder/Empty'))).toBe(false);
|
|
2341
|
+
// `Holder` still has content of its own, so it needs no placeholder.
|
|
2342
|
+
expect(await fs.exists(KB('Holder/.gitkeep'))).toBe(false);
|
|
2343
|
+
});
|
|
2344
|
+
|
|
2345
|
+
it('move_file of a whole folder out keeps the folder it came from', async () => {
|
|
2346
|
+
const base = await start();
|
|
2347
|
+
await fs.writeFile(KB('Src/Inner/x.md'), 'x');
|
|
2348
|
+
|
|
2349
|
+
expect((await tool(base, 'move_file', { src: KB('Src/Inner'), dest: KB('Dst/Inner') })).status).toBe(200);
|
|
2350
|
+
expect(await fs.exists(KB('Dst/Inner/x.md'))).toBe(true);
|
|
2351
|
+
expect(await names(base, KB('Src'))).toEqual([]);
|
|
2352
|
+
expect(await fs.exists(KB('Src/.gitkeep'))).toBe(true);
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
it('delete_file on the last file keeps the folder, and it survives a fresh clone', async () => {
|
|
2356
|
+
const base = await start();
|
|
2357
|
+
const repo = join(tempDir, KB_DIR);
|
|
2358
|
+
await fs.writeFile(KB('nested/level-two/notes.md'), 'x');
|
|
2359
|
+
await gitIn(repo, ['init', '-q', '-b', 'main']);
|
|
2360
|
+
await gitIn(repo, ['add', '-A']);
|
|
2361
|
+
await gitIn(repo, ['commit', '-qm', 'seed']);
|
|
2362
|
+
|
|
2363
|
+
expect((await tool(base, 'delete_file', { path: KB('nested/level-two/notes.md') })).status).toBe(200);
|
|
2364
|
+
|
|
2365
|
+
expect(await names(base, KB('nested'))).toEqual(['level-two:directory']);
|
|
2366
|
+
expect(await names(base, KB('nested/level-two'))).toEqual([]);
|
|
2367
|
+
// What the lock-aware filesystem commits on release, committed here by hand.
|
|
2368
|
+
await gitIn(repo, ['add', '-A']);
|
|
2369
|
+
await gitIn(repo, ['commit', '-qm', 'delete']);
|
|
2370
|
+
await gitIn(tempDir, ['clone', '-q', repo, 'fresh-clone']);
|
|
2371
|
+
// Beside the checkout on purpose — a clone OF it, not content in it — so it
|
|
2372
|
+
// is read from disk rather than through a workspace path.
|
|
2373
|
+
expect((await stat(join(tempDir, 'fresh-clone', 'nested', 'level-two'))).isDirectory()).toBe(true);
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
it('delete_file with siblings left, or at the clone folder itself, writes no placeholder', async () => {
|
|
2377
|
+
const base = await start();
|
|
2378
|
+
await fs.writeFile(KB('full/a.md'), 'a');
|
|
2379
|
+
await fs.writeFile(KB('full/b.md'), 'b');
|
|
2380
|
+
await fs.writeFile(KB('top.md'), 't');
|
|
2381
|
+
|
|
2382
|
+
expect((await tool(base, 'delete_file', { path: KB('full/a.md') })).status).toBe(200);
|
|
2383
|
+
expect((await tool(base, 'delete_file', { path: KB('top.md') })).status).toBe(200);
|
|
2384
|
+
expect((await tool(base, 'delete_file', { path: 'a.md' })).status).toBe(200);
|
|
2385
|
+
|
|
2386
|
+
expect(await names(base, KB('full'))).toEqual(['b.md:file']);
|
|
2387
|
+
await expect(fs.exists(KB('full/.gitkeep'))).resolves.toBe(false);
|
|
2388
|
+
await expect(fs.exists(KB('.gitkeep'))).resolves.toBe(false);
|
|
2389
|
+
await expect(fs.exists('.gitkeep')).resolves.toBe(false);
|
|
2390
|
+
});
|
|
2391
|
+
|
|
2392
|
+
it('delete_file keeps the folder in its folder turn, on the branch it was given', async () => {
|
|
2393
|
+
const base = await start();
|
|
2394
|
+
await fs.writeFile(KB('turned/only.md'), 'x');
|
|
2395
|
+
|
|
2396
|
+
expect((await tool(base, 'delete_file', { path: KB('turned/only.md'), branch: 'alice/draft' })).status).toBe(200);
|
|
2397
|
+
expect(folderTurns).toEqual([`alice%2Fdraft:${KB('turned')}`]);
|
|
2398
|
+
});
|
|
2399
|
+
|
|
2400
|
+
it('delete_file fails when the emptied folder cannot be kept, and says the file is gone', async () => {
|
|
2401
|
+
const base = await start();
|
|
2402
|
+
await fs.writeFile(KB('unkeepable/only.md'), 'x');
|
|
2403
|
+
const write = fs.writeFile.bind(fs);
|
|
2404
|
+
const spy = vi.spyOn(fs, 'writeFile').mockImplementation(async (p, ...rest) => {
|
|
2405
|
+
if (p.endsWith('.gitkeep')) throw new Error('disk full');
|
|
2406
|
+
return write(p, ...rest);
|
|
2407
|
+
});
|
|
2408
|
+
|
|
2409
|
+
let res: Awaited<ReturnType<typeof tool>>;
|
|
2410
|
+
try {
|
|
2411
|
+
res = await tool(base, 'delete_file', { path: KB('unkeepable/only.md') });
|
|
2412
|
+
} finally {
|
|
2413
|
+
spy.mockRestore();
|
|
2414
|
+
}
|
|
2415
|
+
expect(res.status).toBe(500);
|
|
2416
|
+
expect(res.body.error).toBe(
|
|
2417
|
+
`"${KB('unkeepable/only.md')}" was removed, but its folder "${KB('unkeepable')}" could not be kept: disk full`,
|
|
2418
|
+
);
|
|
2419
|
+
await expect(fs.exists(KB('unkeepable/only.md'))).resolves.toBe(false);
|
|
2420
|
+
});
|
|
2421
|
+
|
|
2422
|
+
it('move_file of the last file keeps the source folder', async () => {
|
|
2423
|
+
const base = await start();
|
|
2424
|
+
await fs.writeFile(KB('from/a.md'), 'a');
|
|
2425
|
+
|
|
2426
|
+
expect((await tool(base, 'move_file', { src: KB('from/a.md'), dest: KB('to/a.md') })).status).toBe(200);
|
|
2427
|
+
|
|
2428
|
+
expect((await names(base, KB(''))).sort()).toEqual(['a.md:file', 'from:directory', 'to:directory']);
|
|
2429
|
+
expect(await names(base, KB('from'))).toEqual([]);
|
|
2430
|
+
await expect(fs.exists(KB('from/.gitkeep'))).resolves.toBe(true);
|
|
2431
|
+
});
|
|
2432
|
+
|
|
2433
|
+
it('mkdir, a nested write, and an emptied folder converge on the same state', async () => {
|
|
2434
|
+
const base = await start();
|
|
2435
|
+
await tool(base, 'mkdir', { path: KB('Made') });
|
|
2436
|
+
await tool(base, 'write_file', { path: KB('Written/n.md'), content: 'n' });
|
|
2437
|
+
|
|
2438
|
+
expect((await names(base, KB(''))).sort()).toEqual(['Made:directory', 'Written:directory', 'a.md:file']);
|
|
2439
|
+
expect(await names(base, KB('Made'))).toEqual([]);
|
|
2440
|
+
|
|
2441
|
+
await tool(base, 'delete_file', { path: KB('Written/n.md') });
|
|
2442
|
+
expect((await names(base, KB(''))).sort()).toEqual(['Made:directory', 'Written:directory', 'a.md:file']);
|
|
2443
|
+
expect(await names(base, KB('Written'))).toEqual(await names(base, KB('Made')));
|
|
2444
|
+
expect((await tool(base, 'file_stat', { path: KB('Written') })).body).toMatchObject({ type: 'directory' });
|
|
2445
|
+
});
|
|
2446
|
+
});
|
|
2447
|
+
|
|
2448
|
+
/**
|
|
2449
|
+
* The safety contract an agent gets before a move or delete: `file_stat` says
|
|
2450
|
+
* what an item is and what the caller may do with it, `move_file` and
|
|
2451
|
+
* `delete_folder` answer a dry run with the impact and wait for `confirm`, and
|
|
2452
|
+
* a permission refusal says whether and how to propose instead. The access
|
|
2453
|
+
* double below stands in for the rules, per top-level KB folder:
|
|
2454
|
+
* Sales/ — everything (the default for anything unlisted)
|
|
2455
|
+
* HR/ — read + write, no download, no owner: a move here changes access
|
|
2456
|
+
* Locked/ — read only: writes are refused, proposing is possible
|
|
2457
|
+
* Secret/ — nothing: writes are refused, proposing is not
|
|
2458
|
+
* …/sealed.md — read only, wherever it sits: a denied file inside a writable folder
|
|
2459
|
+
* Sales/outbox/nested/, Sales/moved/nested/ — read only: a denial at only the old, or only the new, path of a moved folder
|
|
2460
|
+
*/
|
|
2461
|
+
describe('preflight for moves and deletes', () => {
|
|
2462
|
+
const PROTECTED = 'target-company-state';
|
|
2463
|
+
const KB = (p: string) => `${KB_DIR}/${p}`;
|
|
2464
|
+
|
|
2465
|
+
const verbsFor = (rel: string) => {
|
|
2466
|
+
if (rel.startsWith('Sales/outbox/nested/') || rel.startsWith('Sales/moved/nested/')) {
|
|
2467
|
+
return { read: true, write: false, download: false, owner: false };
|
|
2468
|
+
}
|
|
2469
|
+
if (rel === 'sealed.md' || rel.endsWith('/sealed.md')) return { read: true, write: false, download: false, owner: false };
|
|
2470
|
+
if (rel.startsWith('HR/')) return { read: true, write: true, download: false, owner: false };
|
|
2471
|
+
if (rel.startsWith('Locked/')) return { read: true, write: false, download: false, owner: false };
|
|
2472
|
+
if (rel.startsWith('Secret/')) return { read: false, write: false, download: false, owner: false };
|
|
2473
|
+
return { read: true, write: true, download: true, owner: true };
|
|
2474
|
+
};
|
|
2475
|
+
const rules = {
|
|
2476
|
+
canRead: async (_w: string, _u: string, rel: string) => verbsFor(rel).read,
|
|
2477
|
+
canReadBatch: async (_w: string, _u: string, paths: string[]) => new Map(paths.map((p) => [p, verbsFor(p).read])),
|
|
2478
|
+
canWrite: async (_w: string, _u: string, rel: string) => verbsFor(rel).write,
|
|
2479
|
+
canDownload: async (_w: string, _u: string, rel: string) => verbsFor(rel).download,
|
|
2480
|
+
canOwner: async (_w: string, _u: string, rel: string) => verbsFor(rel).owner,
|
|
2481
|
+
canWriteBatchAtRef: async (_w: string, _r: string, _u: string, rels: string[]) =>
|
|
2482
|
+
new Map(rels.map((p) => [p, verbsFor(p).write])),
|
|
2483
|
+
eligibleWritersAtRef: async () => ({ roles: ['Admin'], users: [] }),
|
|
2484
|
+
} as unknown as IAccessControl;
|
|
2485
|
+
|
|
2486
|
+
const call = async (base: string, tool: string, body: Record<string, unknown>) => {
|
|
2487
|
+
const res = await post(`${base}/api/agent/tools/${tool}`, { branch: PROTECTED, ...body });
|
|
2488
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- a tool body is free-form JSON, probed field by field
|
|
2489
|
+
return { status: res.status, body: (await res.json()) as Record<string, any> };
|
|
2490
|
+
};
|
|
2491
|
+
const exists = async (p: string) => fs.exists(p);
|
|
2492
|
+
|
|
2493
|
+
async function seeded(): Promise<string> {
|
|
2494
|
+
const base = await start('write', rules);
|
|
2495
|
+
await fs.writeFile(KB('access.md'), '---\nread: everyone\n---\n');
|
|
2496
|
+
await fs.writeFile(KB('KnowledgeBase/Team/a.md'), 'a');
|
|
2497
|
+
await fs.writeFile(KB('Sales/deal.md'), 'deal');
|
|
2498
|
+
await fs.writeFile(KB('Sales/archive/nested/old.md'), 'old');
|
|
2499
|
+
await fs.writeFile(KB('Sales/archive/nested/older.md'), 'older');
|
|
2500
|
+
await fs.writeFile(KB('Sales/archive/top.md'), 'top');
|
|
2501
|
+
await fs.writeFile(KB('HR/policy.md'), 'policy');
|
|
2502
|
+
await fs.writeFile(KB('Locked/rules.md'), 'rules');
|
|
2503
|
+
return base;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
describe('file_stat', () => {
|
|
2507
|
+
it('a platform file is managed, not movable, not deletable', async () => {
|
|
2508
|
+
const base = await seeded();
|
|
2509
|
+
const { status, body } = await call(base, 'file_stat', { path: KB('access.md') });
|
|
2510
|
+
expect(status).toBe(200);
|
|
2511
|
+
expect(body).toMatchObject({ type: 'file', managed: true, movable: false, deletable: false });
|
|
2512
|
+
expect(body.access).toEqual({ read: true, write: true, download: true, owner: true });
|
|
2513
|
+
expect(body.descendants).toBeUndefined();
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
it('a plain file is movable and deletable, with the caller\'s verdicts', async () => {
|
|
2517
|
+
const base = await seeded();
|
|
2518
|
+
expect((await call(base, 'file_stat', { path: KB('Sales/deal.md') })).body).toMatchObject({
|
|
2519
|
+
type: 'file', managed: false, movable: true, deletable: true,
|
|
2520
|
+
access: { read: true, write: true, download: true, owner: true },
|
|
2521
|
+
});
|
|
2522
|
+
expect((await call(base, 'file_stat', { path: KB('Locked/rules.md') })).body).toMatchObject({
|
|
2523
|
+
managed: false, movable: false, deletable: false,
|
|
2524
|
+
access: { read: true, write: false, download: false, owner: false },
|
|
2525
|
+
});
|
|
2526
|
+
});
|
|
2527
|
+
|
|
2528
|
+
it('a folder counts its files at any depth; a reserved root folder is managed', async () => {
|
|
2529
|
+
const base = await seeded();
|
|
2530
|
+
expect((await call(base, 'file_stat', { path: KB('Sales/archive') })).body).toMatchObject({
|
|
2531
|
+
type: 'directory', managed: false, movable: true, deletable: true, descendants: 3,
|
|
2532
|
+
});
|
|
2533
|
+
expect((await call(base, 'file_stat', { path: KB('KnowledgeBase') })).body).toMatchObject({
|
|
2534
|
+
type: 'directory', managed: true, movable: false, deletable: false, descendants: 1,
|
|
2535
|
+
});
|
|
2536
|
+
});
|
|
2537
|
+
});
|
|
2538
|
+
|
|
2539
|
+
describe('move_file', () => {
|
|
2540
|
+
it('a same-access move: the dry run changes nothing, the real call runs without confirm', async () => {
|
|
2541
|
+
const base = await seeded();
|
|
2542
|
+
const args = { src: KB('Sales/deal.md'), dest: KB('Sales/2026/deal.md') };
|
|
2543
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
2544
|
+
expect(dry.status).toBe(200);
|
|
2545
|
+
expect(dry.body).toMatchObject({
|
|
2546
|
+
...args, kind: 'file', descendants: 1, accessChanges: false, allowed: true, dryRun: true, moved: false,
|
|
2547
|
+
});
|
|
2548
|
+
expect(dry.body.access.before).toEqual(dry.body.access.after);
|
|
2549
|
+
expect(dry.body.reason).toBeUndefined();
|
|
2550
|
+
expect(await exists(args.src)).toBe(true);
|
|
2551
|
+
expect(await exists(args.dest)).toBe(false);
|
|
2552
|
+
|
|
2553
|
+
const run = await call(base, 'move_file', args);
|
|
2554
|
+
expect(run.body).toMatchObject({ moved: true });
|
|
2555
|
+
expect(await exists(args.dest)).toBe(true);
|
|
2556
|
+
expect(await exists(args.src)).toBe(false);
|
|
2557
|
+
});
|
|
2558
|
+
|
|
2559
|
+
it('an access-changing move shows before/after and runs only with confirm: true', async () => {
|
|
2560
|
+
const base = await seeded();
|
|
2561
|
+
const args = { src: KB('Sales/deal.md'), dest: KB('HR/deal.md') };
|
|
2562
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
2563
|
+
expect(dry.body).toMatchObject({
|
|
2564
|
+
accessChanges: true,
|
|
2565
|
+
allowed: true,
|
|
2566
|
+
access: {
|
|
2567
|
+
before: { read: true, write: true, download: true, owner: true },
|
|
2568
|
+
after: { read: true, write: true, download: false, owner: false },
|
|
2569
|
+
},
|
|
2570
|
+
});
|
|
2571
|
+
|
|
2572
|
+
const unconfirmed = await call(base, 'move_file', args);
|
|
2573
|
+
expect(unconfirmed.status).toBe(200);
|
|
2574
|
+
expect(unconfirmed.body).toMatchObject({ accessChanges: true, confirmationRequired: true, moved: false });
|
|
2575
|
+
expect(unconfirmed.body.message).toContain('confirm: true');
|
|
2576
|
+
expect(await exists(args.src)).toBe(true);
|
|
2577
|
+
expect(await exists(args.dest)).toBe(false);
|
|
2578
|
+
|
|
2579
|
+
const confirmed = await call(base, 'move_file', { ...args, confirm: true });
|
|
2580
|
+
expect(confirmed.body).toMatchObject({ moved: true });
|
|
2581
|
+
expect(await exists(args.dest)).toBe(true);
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
it('a folder moves recursively', async () => {
|
|
2585
|
+
const base = await seeded();
|
|
2586
|
+
const args = { src: KB('Sales/archive'), dest: KB('Sales/old-archive') };
|
|
2587
|
+
expect((await call(base, 'move_file', { ...args, dryRun: true })).body).toMatchObject({ kind: 'folder', descendants: 3 });
|
|
2588
|
+
expect((await call(base, 'move_file', args)).body).toMatchObject({ moved: true });
|
|
2589
|
+
expect(await exists(KB('Sales/old-archive/nested/old.md'))).toBe(true);
|
|
2590
|
+
expect(await exists(args.src)).toBe(false);
|
|
2591
|
+
});
|
|
2592
|
+
|
|
2593
|
+
/**
|
|
2594
|
+
* "A file named rules.md already exists in Locked." is a fact about a
|
|
2595
|
+
* folder. Answering it before the write verdict turned these tools into an
|
|
2596
|
+
* existence oracle: a caller who may not write `Locked/` — and on a
|
|
2597
|
+
* protected branch that is most callers — could ask for a name and read
|
|
2598
|
+
* off whether it is taken. The two calls below differ ONLY in whether the
|
|
2599
|
+
* destination exists, and must be indistinguishable.
|
|
2600
|
+
*/
|
|
2601
|
+
it('a destination the caller may not write answers the same whether the name is taken or free', async () => {
|
|
2602
|
+
const base = await seeded();
|
|
2603
|
+
const taken = { src: KB('Sales/deal.md'), dest: KB('Locked/rules.md') };
|
|
2604
|
+
const free = { src: KB('Sales/deal.md'), dest: KB('Locked/free.md') };
|
|
2605
|
+
|
|
2606
|
+
const ontoTaken = await call(base, 'move_file', taken);
|
|
2607
|
+
const ontoFree = await call(base, 'move_file', free);
|
|
2608
|
+
expect(ontoTaken.status).toBe(403);
|
|
2609
|
+
expect(ontoTaken.status).toBe(ontoFree.status);
|
|
2610
|
+
expect(ontoTaken.body.kind).toBe('write-denied');
|
|
2611
|
+
expect(ontoTaken.body.error).not.toContain('already exists');
|
|
2612
|
+
// Same shape, same words — only the path each names differs.
|
|
2613
|
+
expect(ontoTaken.body.error.replace('rules.md', 'free.md')).toBe(ontoFree.body.error);
|
|
2614
|
+
|
|
2615
|
+
// The dry run is the easier oracle to reach, and says the same.
|
|
2616
|
+
const dryTaken = await call(base, 'move_file', { ...taken, dryRun: true });
|
|
2617
|
+
const dryFree = await call(base, 'move_file', { ...free, dryRun: true });
|
|
2618
|
+
expect(dryTaken.body).toMatchObject({ allowed: false });
|
|
2619
|
+
expect(dryTaken.body.reason).not.toContain('already exists');
|
|
2620
|
+
expect(dryTaken.body.reason.replace('rules.md', 'free.md')).toBe(dryFree.body.reason);
|
|
2621
|
+
|
|
2622
|
+
// And nothing was moved onto the name that was taken.
|
|
2623
|
+
expect(await fs.readFile(KB('Locked/rules.md'), { encoding: 'utf-8' })).toBe('rules');
|
|
2624
|
+
expect(await exists(KB('Sales/deal.md'))).toBe(true);
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
it('copy_file keeps the same order: the write refusal, not what is in the folder', async () => {
|
|
2628
|
+
const base = await seeded();
|
|
2629
|
+
|
|
2630
|
+
const ontoTaken = await call(base, 'copy_file', { src: KB('Sales/deal.md'), dest: KB('Locked/rules.md') });
|
|
2631
|
+
const ontoFree = await call(base, 'copy_file', { src: KB('Sales/deal.md'), dest: KB('Locked/free.md') });
|
|
2632
|
+
expect(ontoTaken.status).toBe(403);
|
|
2633
|
+
expect(ontoTaken.status).toBe(ontoFree.status);
|
|
2634
|
+
expect(ontoTaken.body.kind).toBe('write-denied');
|
|
2635
|
+
expect(ontoTaken.body.error).not.toContain('already exists');
|
|
2636
|
+
expect(ontoTaken.body.error.replace('rules.md', 'free.md')).toBe(ontoFree.body.error);
|
|
2637
|
+
|
|
2638
|
+
expect(await fs.readFile(KB('Locked/rules.md'), { encoding: 'utf-8' })).toBe('rules');
|
|
2639
|
+
expect(await exists(KB('Locked/free.md'))).toBe(false);
|
|
2640
|
+
});
|
|
2641
|
+
|
|
2642
|
+
it('a move the caller may not write: the dry run says so, the real call is a write-denied with proposal steps', async () => {
|
|
2643
|
+
const base = await seeded();
|
|
2644
|
+
const args = { src: KB('Sales/deal.md'), dest: KB('Locked/deal.md') };
|
|
2645
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
2646
|
+
expect(dry.body).toMatchObject({ allowed: false });
|
|
2647
|
+
expect(dry.body.reason).toContain(KB('Locked/deal.md'));
|
|
2648
|
+
|
|
2649
|
+
const run = await call(base, 'move_file', args);
|
|
2650
|
+
expect(run.status).toBe(403);
|
|
2651
|
+
expect(run.body).toMatchObject({ kind: 'write-denied', path: KB('Locked/deal.md'), canPropose: true });
|
|
2652
|
+
expect(run.body.reason).toContain('Eligible: Admin');
|
|
2653
|
+
// `reason` is what the refusal said; `error` is the sentence the agent
|
|
2654
|
+
// reads, which carries it plus the invitation to propose.
|
|
2655
|
+
expect(run.body.error).toContain('Eligible: Admin');
|
|
2656
|
+
expect(run.body.error).toContain('You may propose this change instead');
|
|
2657
|
+
expect(run.body.proposal.targetBranch).toBe(PROTECTED);
|
|
2658
|
+
expect(run.body.proposal.steps.map((s: { tool: string }) => s.tool)).toEqual([
|
|
2659
|
+
'create_branch',
|
|
2660
|
+
'move_file',
|
|
2661
|
+
'open_change_request',
|
|
2662
|
+
]);
|
|
2663
|
+
expect(await exists(args.src)).toBe(true);
|
|
2664
|
+
});
|
|
2665
|
+
|
|
2666
|
+
it('a folder move judges every file under it: one denied file blocks the move, in the dry run and for real', async () => {
|
|
2667
|
+
const base = await seeded();
|
|
2668
|
+
await fs.writeFile(KB('Sales/archive/nested/sealed.md'), 'sealed');
|
|
2669
|
+
const args = { src: KB('Sales/archive'), dest: KB('Sales/archive-2026') };
|
|
2670
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
2671
|
+
expect(dry.body).toMatchObject({ kind: 'folder', descendants: 4, accessChanges: false, allowed: false });
|
|
2672
|
+
expect(dry.body.reason).toContain('sealed.md');
|
|
2673
|
+
const run = await call(base, 'move_file', { ...args, confirm: true });
|
|
2674
|
+
expect(run.status).toBe(403);
|
|
2675
|
+
expect(run.body).toMatchObject({ kind: 'write-denied', path: KB('Sales/archive/nested/sealed.md'), canPropose: true });
|
|
2676
|
+
expect(await exists(KB('Sales/archive/nested/sealed.md'))).toBe(true);
|
|
2677
|
+
expect(await exists(args.dest)).toBe(false);
|
|
2678
|
+
});
|
|
2679
|
+
|
|
2680
|
+
it('file_stat on a folder answers exactly what the move and delete dry runs answer, a denied file inside included', async () => {
|
|
2681
|
+
const base = await seeded();
|
|
2682
|
+
const path = KB('Sales/archive');
|
|
2683
|
+
const verdicts = async () => ({
|
|
2684
|
+
stat: (await call(base, 'file_stat', { path })).body,
|
|
2685
|
+
move: (await call(base, 'move_file', { src: path, dest: KB('Sales/archive-2026'), dryRun: true })).body,
|
|
2686
|
+
del: (await call(base, 'delete_folder', { path, dryRun: true })).body,
|
|
2687
|
+
});
|
|
2688
|
+
// All writable: every answer says yes.
|
|
2689
|
+
let v = await verdicts();
|
|
2690
|
+
expect([v.stat.movable, v.move.allowed, v.stat.deletable, v.del.allowed]).toEqual([true, true, true, true]);
|
|
2691
|
+
// One file its own rules deny, deep inside the writable folder: every answer says no.
|
|
2692
|
+
await fs.writeFile(KB('Sales/archive/nested/sealed.md'), 'sealed');
|
|
2693
|
+
v = await verdicts();
|
|
2694
|
+
expect(v.stat).toMatchObject({ access: { write: true }, descendants: 4 });
|
|
2695
|
+
expect(v.stat.movable).toBe(v.move.allowed);
|
|
2696
|
+
expect(v.stat.deletable).toBe(v.del.allowed);
|
|
2697
|
+
expect([v.stat.movable, v.stat.deletable]).toEqual([false, false]);
|
|
2698
|
+
});
|
|
2699
|
+
|
|
2700
|
+
it('a folder move judges each file at its old path and at its new path, separately', async () => {
|
|
2701
|
+
const base = await seeded();
|
|
2702
|
+
// Denied only at the old path: the file under Sales/outbox/nested/ may not be removed.
|
|
2703
|
+
await fs.writeFile(KB('Sales/outbox/nested/letter.md'), 'letter');
|
|
2704
|
+
const oldSide = await call(base, 'move_file', { src: KB('Sales/outbox'), dest: KB('Sales/sent'), dryRun: true });
|
|
2705
|
+
expect(oldSide.body).toMatchObject({ allowed: false });
|
|
2706
|
+
expect(oldSide.body.reason).toContain(KB('Sales/outbox/nested/letter.md'));
|
|
2707
|
+
// Denied only at the new path: Sales/archive is writable, Sales/moved/nested/ is not.
|
|
2708
|
+
const newSide = await call(base, 'move_file', { src: KB('Sales/archive'), dest: KB('Sales/moved'), dryRun: true });
|
|
2709
|
+
expect(newSide.body).toMatchObject({ allowed: false });
|
|
2710
|
+
expect(newSide.body.reason).toContain(KB('Sales/moved/nested/'));
|
|
2711
|
+
const run = await call(base, 'move_file', { src: KB('Sales/archive'), dest: KB('Sales/moved'), confirm: true });
|
|
2712
|
+
expect(run.status).toBe(403);
|
|
2713
|
+
expect(run.body).toMatchObject({ kind: 'write-denied' });
|
|
2714
|
+
expect(await exists(KB('Sales/archive/nested/old.md'))).toBe(true);
|
|
2715
|
+
});
|
|
2716
|
+
|
|
2717
|
+
it('trailing slashes do not change which paths a folder move is judged on', async () => {
|
|
2718
|
+
const base = await seeded();
|
|
2719
|
+
const args = { src: `${KB('Sales/archive')}/`, dest: `${KB('Sales/moved')}/` };
|
|
2720
|
+
const dry = await call(base, 'move_file', { ...args, dryRun: true });
|
|
2721
|
+
expect(dry.body).toMatchObject({ src: KB('Sales/archive'), dest: KB('Sales/moved'), descendants: 3, allowed: false });
|
|
2722
|
+
expect(dry.body.reason).toContain(KB('Sales/moved/nested/'));
|
|
2723
|
+
expect((await call(base, 'move_file', { ...args, confirm: true })).status).toBe(403);
|
|
2724
|
+
expect(await exists(KB('Sales/archive/nested/old.md'))).toBe(true);
|
|
2725
|
+
expect(await exists(KB('Sales/moved'))).toBe(false);
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2728
|
+
it('a denied move into a path the caller cannot read cannot be proposed, and says why', async () => {
|
|
2729
|
+
const base = await seeded();
|
|
2730
|
+
const run = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Secret/deal.md'), confirm: true });
|
|
2731
|
+
expect(run.status).toBe(403);
|
|
2732
|
+
expect(run.body).toMatchObject({ kind: 'write-denied', canPropose: false });
|
|
2733
|
+
expect(run.body.proposal).toBeUndefined();
|
|
2734
|
+
expect(run.body.cannotProposeReason).toContain('you cannot read this path');
|
|
2735
|
+
});
|
|
2736
|
+
|
|
2737
|
+
it('a draft branch is not write-gated, so the same move is allowed there', async () => {
|
|
2738
|
+
const base = await seeded();
|
|
2739
|
+
const dry = await call(base, 'move_file', { branch: 'someone/draft', src: KB('Sales/deal.md'), dest: KB('Locked/deal.md'), dryRun: true });
|
|
2740
|
+
expect(dry.body).toMatchObject({ allowed: true, accessChanges: true });
|
|
2741
|
+
});
|
|
2742
|
+
|
|
2743
|
+
it('a platform file is refused with the platform-file sentence, in the dry run and for real', async () => {
|
|
2744
|
+
const base = await seeded();
|
|
2745
|
+
const args = { src: KB('access.md'), dest: KB('Sales/access.md') };
|
|
2746
|
+
const sentence = 'access.md is a platform file and stays in its folder.';
|
|
2747
|
+
expect((await call(base, 'move_file', { ...args, dryRun: true })).body).toMatchObject({ allowed: false, reason: sentence });
|
|
2748
|
+
const run = await call(base, 'move_file', { ...args, confirm: true });
|
|
2749
|
+
expect(run.status).toBe(400);
|
|
2750
|
+
expect(run.body.error).toBe(sentence);
|
|
2751
|
+
expect(await exists(args.src)).toBe(true);
|
|
2752
|
+
});
|
|
2753
|
+
|
|
2754
|
+
it('every one of the four platform files gets the same sentence, and the agent never gets the admin restore', async () => {
|
|
2755
|
+
// The agent move tool has no exception: the recovery move is a person's,
|
|
2756
|
+
// made as an admin, and an agent is neither.
|
|
2757
|
+
const base = await seeded();
|
|
2758
|
+
await fs.writeFile(KB('roles.yaml'), 'roles: {}\n');
|
|
2759
|
+
await fs.writeFile(KB('AGENTS.md'), 'agents\n');
|
|
2760
|
+
await fs.writeFile(KB('.bevelignore'), '*.tmp\n');
|
|
2761
|
+
await fs.writeFile(KB('Misplaced/access.md'), '---\nread: everyone\n---\n');
|
|
2762
|
+
const cases: [string, string][] = [
|
|
2763
|
+
[KB('access.md'), KB('Sales/access.md')],
|
|
2764
|
+
[KB('roles.yaml'), KB('Sales/roles.yaml')],
|
|
2765
|
+
[KB('.bevelignore'), KB('Sales/.bevelignore')],
|
|
2766
|
+
[KB('AGENTS.md'), KB('Sales/AGENTS.md')],
|
|
2767
|
+
// Including the shape of the admin's recovery move: a misplaced
|
|
2768
|
+
// access.md into a folder that has none. A person holding the Admin
|
|
2769
|
+
// role is allowed exactly this move from the UI; the agent is not.
|
|
2770
|
+
[KB('Misplaced/access.md'), KB('HR/access.md')],
|
|
2771
|
+
];
|
|
2772
|
+
for (const [src, dest] of cases) {
|
|
2773
|
+
const name = src.slice(src.lastIndexOf('/') + 1);
|
|
2774
|
+
const sentence = `${name} is a platform file and stays in its folder.`;
|
|
2775
|
+
expect((await call(base, 'move_file', { src, dest, dryRun: true })).body)
|
|
2776
|
+
.toMatchObject({ allowed: false, reason: sentence });
|
|
2777
|
+
const run = await call(base, 'move_file', { src, dest, confirm: true });
|
|
2778
|
+
expect(run.status).toBe(400);
|
|
2779
|
+
expect(run.body.error).toBe(sentence);
|
|
2780
|
+
expect(await exists(src)).toBe(true);
|
|
2781
|
+
}
|
|
2782
|
+
});
|
|
2783
|
+
|
|
2784
|
+
it('an existing destination is refused, never overwritten', async () => {
|
|
2785
|
+
const base = await seeded();
|
|
2786
|
+
const args = { src: KB('Sales/deal.md'), dest: KB('Sales/archive/top.md') };
|
|
2787
|
+
expect((await call(base, 'move_file', { ...args, dryRun: true })).body).toMatchObject({ allowed: false });
|
|
2788
|
+
expect((await call(base, 'move_file', args)).status).toBe(409);
|
|
2789
|
+
expect(await fs.readFile(args.dest, { encoding: 'utf-8' })).toBe('top');
|
|
2790
|
+
});
|
|
2791
|
+
});
|
|
2792
|
+
|
|
2793
|
+
describe('delete_folder', () => {
|
|
2794
|
+
it('the dry run lists the files and deletes nothing; without confirm nothing is deleted; with confirm the folder is gone', async () => {
|
|
2795
|
+
const base = await seeded();
|
|
2796
|
+
const path = KB('Sales/archive');
|
|
2797
|
+
const dry = await call(base, 'delete_folder', { path, dryRun: true });
|
|
2798
|
+
expect(dry.status).toBe(200);
|
|
2799
|
+
expect(dry.body).toMatchObject({ path, kind: 'folder', descendants: 3, allowed: true, dryRun: true, filesTruncated: false });
|
|
2800
|
+
expect([...dry.body.files].sort()).toEqual([
|
|
2801
|
+
KB('Sales/archive/nested/old.md'), KB('Sales/archive/nested/older.md'), KB('Sales/archive/top.md'),
|
|
2802
|
+
]);
|
|
2803
|
+
expect(await exists(KB('Sales/archive/top.md'))).toBe(true);
|
|
2804
|
+
|
|
2805
|
+
const unconfirmed = await call(base, 'delete_folder', { path });
|
|
2806
|
+
expect(unconfirmed.body).toMatchObject({ confirmationRequired: true, deleted: false });
|
|
2807
|
+
expect(unconfirmed.body.message).toContain('confirm: true');
|
|
2808
|
+
expect(await exists(KB('Sales/archive/nested/old.md'))).toBe(true);
|
|
2809
|
+
|
|
2810
|
+
const confirmed = await call(base, 'delete_folder', { path, confirm: true });
|
|
2811
|
+
expect(confirmed.body).toMatchObject({ deleted: true, descendants: 3 });
|
|
2812
|
+
expect(await exists(path)).toBe(false);
|
|
2813
|
+
const listing = await call(base, 'list_files', { path: KB('Sales') });
|
|
2814
|
+
expect(listing.body.entries.map((e: { name: string }) => e.name)).toEqual(['deal.md']);
|
|
2815
|
+
});
|
|
2816
|
+
|
|
2817
|
+
it('a platform folder is refused with its reason', async () => {
|
|
2818
|
+
const base = await seeded();
|
|
2819
|
+
const dry = await call(base, 'delete_folder', { path: KB('KnowledgeBase'), dryRun: true });
|
|
2820
|
+
expect(dry.body).toMatchObject({ allowed: false, reason: 'KnowledgeBase/ is a platform folder and cannot be moved or deleted.' });
|
|
2821
|
+
const run = await call(base, 'delete_folder', { path: KB('KnowledgeBase'), confirm: true });
|
|
2822
|
+
expect(run.status).toBe(400);
|
|
2823
|
+
expect(await exists(KB('KnowledgeBase/Team/a.md'))).toBe(true);
|
|
2824
|
+
});
|
|
2825
|
+
|
|
2826
|
+
it('a folder the caller cannot write is refused with the structured denial', async () => {
|
|
2827
|
+
const base = await seeded();
|
|
2828
|
+
expect((await call(base, 'delete_folder', { path: KB('Locked'), dryRun: true })).body).toMatchObject({ allowed: false });
|
|
2829
|
+
const run = await call(base, 'delete_folder', { path: KB('Locked'), confirm: true });
|
|
2830
|
+
expect(run.status).toBe(403);
|
|
2831
|
+
expect(run.body).toMatchObject({ kind: 'write-denied', canPropose: true });
|
|
2832
|
+
expect(await exists(KB('Locked/rules.md'))).toBe(true);
|
|
2833
|
+
});
|
|
2834
|
+
|
|
2835
|
+
it('an empty folder is deleted without confirm; a file is pointed to delete_file', async () => {
|
|
2836
|
+
const base = await seeded();
|
|
2837
|
+
await fs.mkdir(KB('Sales/empty'), { recursive: true });
|
|
2838
|
+
expect((await call(base, 'delete_folder', { path: KB('Sales/empty') })).body).toMatchObject({ deleted: true, descendants: 0 });
|
|
2839
|
+
expect(await exists(KB('Sales/empty'))).toBe(false);
|
|
2840
|
+
const file = await call(base, 'delete_folder', { path: KB('Sales/deal.md') });
|
|
2841
|
+
expect(file.status).toBe(400);
|
|
2842
|
+
expect(file.body.error).toContain('delete_file');
|
|
2843
|
+
});
|
|
2844
|
+
});
|
|
2845
|
+
|
|
2846
|
+
describe('delete_file', () => {
|
|
2847
|
+
it('on a folder answers with delete_folder instead of the filesystem error', async () => {
|
|
2848
|
+
const base = await seeded();
|
|
2849
|
+
const run = await call(base, 'delete_file', { path: KB('Sales/archive') });
|
|
2850
|
+
expect(run.status).toBe(400);
|
|
2851
|
+
expect(run.body.error).toContain('use delete_folder');
|
|
2852
|
+
expect(await exists(KB('Sales/archive/top.md'))).toBe(true);
|
|
2853
|
+
});
|
|
2854
|
+
|
|
2855
|
+
it('a denied delete is the structured denial', async () => {
|
|
2856
|
+
const base = await seeded();
|
|
2857
|
+
const run = await call(base, 'delete_file', { path: KB('Locked/rules.md') });
|
|
2858
|
+
expect(run.status).toBe(403);
|
|
2859
|
+
expect(run.body).toMatchObject({ kind: 'write-denied', path: KB('Locked/rules.md'), canPropose: true });
|
|
2860
|
+
expect(run.body.proposal.steps.map((s: { tool: string }) => s.tool)).toContain('delete_file');
|
|
2861
|
+
});
|
|
2862
|
+
it('a refusal from the lock gate itself (rules changed after the preflight) is the structured denial too', async () => {
|
|
2863
|
+
const base = await seeded();
|
|
2864
|
+
fs.deleteFile = async (p: string) => {
|
|
2865
|
+
throw new AccessDeniedError({ path: p, eligibleRoles: ['Admin'], eligibleUsers: [] });
|
|
2866
|
+
};
|
|
2867
|
+
const run = await call(base, 'delete_file', { path: KB('Sales/deal.md') });
|
|
2868
|
+
expect(run.status).toBe(403);
|
|
2869
|
+
expect(run.body).toMatchObject({ kind: 'write-denied', path: KB('Sales/deal.md'), canPropose: true });
|
|
2870
|
+
});
|
|
2871
|
+
});
|
|
2872
|
+
|
|
2873
|
+
describe('what counts as the platform\'s own, and what a move or delete may reach', () => {
|
|
2874
|
+
const caseSensitiveDisk = process.platform === 'linux';
|
|
2875
|
+
|
|
2876
|
+
it('roles.yaml and AGENTS.md are platform files only at the root; access.md and .bevelignore at any depth', async () => {
|
|
2877
|
+
const base = await seeded();
|
|
2878
|
+
await fs.writeFile(KB('roles.yaml'), 'roles: {}\n');
|
|
2879
|
+
await fs.writeFile(KB('Sales/roles.yaml'), 'content');
|
|
2880
|
+
await fs.writeFile(KB('Sales/AGENTS.md'), 'content');
|
|
2881
|
+
await fs.writeFile(KB('Sales/.bevelignore'), '*.tmp\n');
|
|
2882
|
+
const managed = async (p: string) => (await call(base, 'file_stat', { path: KB(p) })).body.managed;
|
|
2883
|
+
expect(await managed('roles.yaml')).toBe(true);
|
|
2884
|
+
expect(await managed('Sales/roles.yaml')).toBe(false);
|
|
2885
|
+
expect(await managed('Sales/AGENTS.md')).toBe(false);
|
|
2886
|
+
expect(await managed('Sales/.bevelignore')).toBe(true);
|
|
2887
|
+
expect((await call(base, 'move_file', { src: KB('Sales/roles.yaml'), dest: KB('Sales/old-roles.yaml') })).body).toMatchObject({ moved: true });
|
|
2888
|
+
});
|
|
2889
|
+
|
|
2890
|
+
it.skipIf(!caseSensitiveDisk)('a differently cased access.md is content on a case-sensitive disk', async () => {
|
|
2891
|
+
const base = await seeded();
|
|
2892
|
+
await fs.writeFile(KB('Sales/Access.md'), 'notes');
|
|
2893
|
+
expect((await call(base, 'file_stat', { path: KB('Sales/Access.md') })).body).toMatchObject({ managed: false, movable: true });
|
|
2894
|
+
});
|
|
2895
|
+
|
|
2896
|
+
// The git folder is not merely "managed": it is not reachable at all, so
|
|
2897
|
+
// every one of these is the one sanitized refusal (`shared/git-internals.ts`)
|
|
2898
|
+
// rather than this preflight's own "git metadata" answer — a dry run
|
|
2899
|
+
// included, since even describing what is in there is not on offer.
|
|
2900
|
+
it('git metadata is refused, and a folder walk never enters it', async () => {
|
|
2901
|
+
const base = await seeded();
|
|
2902
|
+
await fs.writeFile(KB('.git/HEAD'), 'ref: refs/heads/main\n');
|
|
2903
|
+
await fs.writeFile(KB('Sales/sub/.git/HEAD'), 'ref: refs/heads/main\n');
|
|
2904
|
+
for (const call_ of [
|
|
2905
|
+
{ tool: 'delete_folder', args: { path: KB('.git'), dryRun: true } },
|
|
2906
|
+
{ tool: 'delete_folder', args: { path: KB('.git'), confirm: true } },
|
|
2907
|
+
{ tool: 'delete_file', args: { path: KB('.git/HEAD') } },
|
|
2908
|
+
{ tool: 'move_file', args: { src: KB('.git'), dest: KB('Sales/git') } },
|
|
2909
|
+
]) {
|
|
2910
|
+
const res = await call(base, call_.tool, call_.args);
|
|
2911
|
+
expect({ tool: call_.tool, status: res.status, error: res.body.error }).toEqual({
|
|
2912
|
+
tool: call_.tool,
|
|
2913
|
+
status: 403,
|
|
2914
|
+
error: GIT_INTERNALS_MESSAGE,
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2917
|
+
expect(await exists(KB('.git/HEAD'))).toBe(true);
|
|
2918
|
+
expect((await call(base, 'file_stat', { path: KB('Sales/sub') })).body).toMatchObject({ descendants: 0 });
|
|
2919
|
+
});
|
|
2920
|
+
|
|
2921
|
+
it('a folder\'s own access.md goes with it, in the same single change as every other file', async () => {
|
|
2922
|
+
const base = await seeded();
|
|
2923
|
+
await fs.writeFile(KB('Sales/archive/access.md'), '---\nread: everyone\n---\n');
|
|
2924
|
+
// What the TOOL decides is the shape it hands the filesystem: ONE batch
|
|
2925
|
+
// carrying every file, the folder's own access.md among them. That the
|
|
2926
|
+
// batch then lands all-or-nothing is the batch's own property, asserted
|
|
2927
|
+
// in locking-filesystem.test.ts; the harness stand-in here only applies
|
|
2928
|
+
// the deletes.
|
|
2929
|
+
const batches: string[][] = [];
|
|
2930
|
+
const fsAny = fs as unknown as Record<string, unknown>;
|
|
2931
|
+
const writeFiles = fsAny.writeFiles as (w: unknown[], s: string, d: string[]) => Promise<void>;
|
|
2932
|
+
fsAny.writeFiles = async (w: unknown[], s: string, d: string[] = []) => {
|
|
2933
|
+
batches.push([...d]);
|
|
2934
|
+
return writeFiles(w, s, d);
|
|
2935
|
+
};
|
|
2936
|
+
|
|
2937
|
+
const run = await call(base, 'delete_folder', { path: KB('Sales/archive'), confirm: true });
|
|
2938
|
+
|
|
2939
|
+
expect(run.body).toMatchObject({ deleted: true, descendants: 4 });
|
|
2940
|
+
expect(batches).toHaveLength(1);
|
|
2941
|
+
expect([...batches[0]].sort()).toEqual([
|
|
2942
|
+
KB('Sales/archive/access.md'),
|
|
2943
|
+
KB('Sales/archive/nested/old.md'),
|
|
2944
|
+
KB('Sales/archive/nested/older.md'),
|
|
2945
|
+
KB('Sales/archive/top.md'),
|
|
2946
|
+
]);
|
|
2947
|
+
expect(await exists(KB('Sales/archive/access.md'))).toBe(false);
|
|
2948
|
+
expect(await exists(KB('Sales/archive'))).toBe(false);
|
|
2949
|
+
});
|
|
2950
|
+
|
|
2951
|
+
it('a restricted run is judged on the files a folder delete or move would write, not the folder path', async () => {
|
|
2952
|
+
const base = await seeded();
|
|
2953
|
+
writePolicy.restrictToExtensions('restricted-run', ['.html']);
|
|
2954
|
+
await fs.writeFile(KB('Sales/views/a.html'), '<p>a</p>');
|
|
2955
|
+
await fs.writeFile(KB('Sales/views/b.html'), '<p>b</p>');
|
|
2956
|
+
const moved = await call(base, 'move_file', { src: KB('Sales/views'), dest: KB('Sales/dashboards'), sessionId: 'restricted-run' });
|
|
2957
|
+
expect(moved.body).toMatchObject({ moved: true, descendants: 2 });
|
|
2958
|
+
const deleted = await call(base, 'delete_folder', { path: KB('Sales/dashboards'), confirm: true, sessionId: 'restricted-run' });
|
|
2959
|
+
expect(deleted.body).toMatchObject({ deleted: true });
|
|
2960
|
+
const refused = await call(base, 'delete_folder', { path: KB('Sales/archive'), confirm: true, sessionId: 'restricted-run' });
|
|
2961
|
+
expect(refused.status).toBe(403);
|
|
2962
|
+
expect(await exists(KB('Sales/archive/top.md'))).toBe(true);
|
|
2963
|
+
});
|
|
2964
|
+
|
|
2965
|
+
it.skipIf(process.platform === 'win32')('a move or folder delete through a symbolic link that leaves the repository is refused, a dangling one included', async () => {
|
|
2966
|
+
const base = await seeded();
|
|
2967
|
+
await mkdir(join(tempDir, 'beside-the-clone'), { recursive: true });
|
|
2968
|
+
await symlink(join(tempDir, 'beside-the-clone'), join(tempDir, KB('Sales/out')));
|
|
2969
|
+
const escaped = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/out/deal.md'), dryRun: true });
|
|
2970
|
+
expect(escaped.status).toBe(400);
|
|
2971
|
+
expect(escaped.body.error).toContain('symbolic link');
|
|
2972
|
+
expect((await call(base, 'delete_folder', { path: KB('Sales/out'), dryRun: true })).status).toBe(400);
|
|
2973
|
+
await symlink(join(tempDir, 'nowhere'), join(tempDir, KB('Sales/dangling.md')));
|
|
2974
|
+
const collided = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/dangling.md') });
|
|
2975
|
+
expect(collided.status).toBe(400);
|
|
2976
|
+
expect(collided.body.error).toContain('symbolic link');
|
|
2977
|
+
expect(await exists(KB('Sales/deal.md'))).toBe(true);
|
|
2978
|
+
});
|
|
2979
|
+
|
|
2980
|
+
it('a path with "." or ".." segments is refused by every move and delete, before anything changes', async () => {
|
|
2981
|
+
const base = await seeded();
|
|
2982
|
+
const sneaky = KB('Sales/../Locked/rules.md');
|
|
2983
|
+
const del = await call(base, 'delete_file', { path: sneaky });
|
|
2984
|
+
expect(del.status).toBe(400);
|
|
2985
|
+
expect(del.body.error).toContain('".." segments');
|
|
2986
|
+
expect((await call(base, 'move_file', { src: sneaky, dest: KB('Sales/rules.md'), dryRun: true })).status).toBe(400);
|
|
2987
|
+
expect((await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/../Locked/deal.md') })).status).toBe(400);
|
|
2988
|
+
expect((await call(base, 'delete_folder', { path: KB('Sales/../Locked'), confirm: true })).status).toBe(400);
|
|
2989
|
+
// `file_stat` used to answer with a body saying the path was neither
|
|
2990
|
+
// movable nor deletable. The normaliser refuses the spelling before the
|
|
2991
|
+
// tool runs at all now, which is the stronger answer: nothing is judged
|
|
2992
|
+
// under a path that names one folder and opens another.
|
|
2993
|
+
const stat = await call(base, 'file_stat', { path: KB('Sales/./deal.md') });
|
|
2994
|
+
expect(stat.status).toBe(400);
|
|
2995
|
+
expect(stat.body.error).toContain('"." or ".." segments');
|
|
2996
|
+
expect(await exists(KB('Locked/rules.md'))).toBe(true);
|
|
2997
|
+
expect(await exists(KB('Sales/deal.md'))).toBe(true);
|
|
2998
|
+
});
|
|
2999
|
+
|
|
3000
|
+
it.skipIf(process.platform === 'win32')('file_stat agrees with move and delete about links on the way, a dangling one included', async () => {
|
|
3001
|
+
const base = await seeded();
|
|
3002
|
+
await symlink(join(tempDir, KB('Locked')), join(tempDir, KB('Sales/alias')));
|
|
3003
|
+
expect((await call(base, 'file_stat', { path: KB('Sales/alias/rules.md') })).body).toMatchObject({ movable: false, deletable: false });
|
|
3004
|
+
await symlink(join(tempDir, 'nowhere'), join(tempDir, KB('Sales/gone')));
|
|
3005
|
+
const dangling = await call(base, 'file_stat', { path: KB('Sales/gone/x.md') });
|
|
3006
|
+
expect(dangling.status).toBe(400);
|
|
3007
|
+
expect(dangling.body.error).toContain(`the symbolic link "${KB('Sales/gone')}"`);
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
it.skipIf(process.platform === 'win32')('a link past the descendants cap still makes a folder not deletable', async () => {
|
|
3011
|
+
const base = await seeded();
|
|
3012
|
+
const dir = join(tempDir, KB('Sales/bulk'));
|
|
3013
|
+
await mkdir(join(dir, 'a'), { recursive: true });
|
|
3014
|
+
const names = Array.from({ length: 10_001 }, (_, i) => `f${i}.md`);
|
|
3015
|
+
for (let i = 0; i < names.length; i += 500) {
|
|
3016
|
+
await Promise.all(names.slice(i, i + 500).map((n) => writeFile(join(dir, 'a', n), '')));
|
|
3017
|
+
}
|
|
3018
|
+
await mkdir(join(dir, 'z'), { recursive: true });
|
|
3019
|
+
await symlink(join(tempDir, 'nowhere'), join(dir, 'z', 'link'));
|
|
3020
|
+
const stat = await call(base, 'file_stat', { path: KB('Sales/bulk') });
|
|
3021
|
+
expect(stat.body).toMatchObject({ descendants: 10_000, descendantsTruncated: true, deletable: false });
|
|
3022
|
+
expect((await call(base, 'delete_folder', { path: KB('Sales/bulk'), dryRun: true })).body).toMatchObject({ allowed: false });
|
|
3023
|
+
}, 60_000);
|
|
3024
|
+
|
|
3025
|
+
it.skipIf(process.platform === 'win32')('a link inside the repository is not followed either, so a move cannot write into a folder whose rules it never judged', async () => {
|
|
3026
|
+
const base = await seeded();
|
|
3027
|
+
await symlink(join(tempDir, KB('Locked')), join(tempDir, KB('Sales/alias')));
|
|
3028
|
+
const run = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/alias/deal.md'), dryRun: true });
|
|
3029
|
+
expect(run.status).toBe(400);
|
|
3030
|
+
expect(run.body.error).toContain(`the symbolic link "${KB('Sales/alias')}"`);
|
|
3031
|
+
expect((await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/alias/deal.md') })).status).toBe(400);
|
|
3032
|
+
expect((await call(base, 'delete_file', { path: KB('Sales/alias/rules.md') })).status).toBe(400);
|
|
3033
|
+
expect(await exists(KB('Locked/rules.md'))).toBe(true);
|
|
3034
|
+
expect(await exists(KB('Locked/deal.md'))).toBe(false);
|
|
3035
|
+
});
|
|
3036
|
+
|
|
3037
|
+
it.skipIf(process.platform === 'win32')('a folder holding a symbolic link is refused in the dry run and deletes nothing, instead of half-deleting', async () => {
|
|
3038
|
+
const base = await seeded();
|
|
3039
|
+
// The Local Testing shape: a file sorted before the link, the link (to a
|
|
3040
|
+
// folder, so the per-file delete cannot remove it), a file after it.
|
|
3041
|
+
await fs.writeFile(KB('Sales/links/aaa.md'), 'a');
|
|
3042
|
+
await fs.writeFile(KB('Sales/links/real.md'), 'r');
|
|
3043
|
+
await symlink('../archive', join(tempDir, KB('Sales/links/inside')));
|
|
3044
|
+
const dry = await call(base, 'delete_folder', { path: KB('Sales/links'), dryRun: true });
|
|
3045
|
+
expect(dry.status).toBe(200);
|
|
3046
|
+
expect(dry.body).toMatchObject({ allowed: false });
|
|
3047
|
+
expect(dry.body.reason).toContain(`"${KB('Sales/links/inside')}"`);
|
|
3048
|
+
const run = await call(base, 'delete_folder', { path: KB('Sales/links'), confirm: true });
|
|
3049
|
+
expect(run.status).toBe(400);
|
|
3050
|
+
expect(run.body.error).toBe(dry.body.reason);
|
|
3051
|
+
for (const p of ['Sales/links/aaa.md', 'Sales/links/real.md', 'Sales/archive/top.md']) {
|
|
3052
|
+
expect(await exists(KB(p)), p).toBe(true);
|
|
3053
|
+
}
|
|
3054
|
+
expect((await call(base, 'file_stat', { path: KB('Sales/links') })).body).toMatchObject({ deletable: false });
|
|
3055
|
+
});
|
|
3056
|
+
|
|
3057
|
+
it.skipIf(process.platform === 'win32')('a link itself is answered as a link by delete_file, move_file and file_stat, not "not found"', async () => {
|
|
3058
|
+
const base = await seeded();
|
|
3059
|
+
await symlink(join(tempDir, 'nowhere'), join(tempDir, KB('Sales/dangling.md')));
|
|
3060
|
+
await symlink('deal.md', join(tempDir, KB('Sales/alias.md')));
|
|
3061
|
+
for (const path of [KB('Sales/dangling.md'), KB('Sales/alias.md')]) {
|
|
3062
|
+
const del = await call(base, 'delete_file', { path });
|
|
3063
|
+
expect(del.status, path).toBe(400);
|
|
3064
|
+
expect(del.body.error, path).toBe(`"${path}" is a symbolic link; the agent tools never follow or remove links.`);
|
|
3065
|
+
const mv = await call(base, 'move_file', { src: path, dest: KB('Sales/moved.md'), dryRun: true });
|
|
3066
|
+
expect(mv.status, path).toBe(400);
|
|
3067
|
+
expect(mv.body.error, path).toContain('symbolic link');
|
|
3068
|
+
}
|
|
3069
|
+
expect((await call(base, 'file_stat', { path: KB('Sales/alias.md') })).body).toMatchObject({ movable: false, deletable: false });
|
|
3070
|
+
expect(await exists(KB('Sales/deal.md'))).toBe(true);
|
|
3071
|
+
});
|
|
3072
|
+
|
|
3073
|
+
it('a move cannot create a platform file or folder at the destination', async () => {
|
|
3074
|
+
const base = await seeded();
|
|
3075
|
+
const dry = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/archive/access.md'), dryRun: true });
|
|
3076
|
+
expect(dry.body).toMatchObject({ allowed: false, reason: 'access.md is a platform file name; a move cannot create a platform file.' });
|
|
3077
|
+
expect((await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/archive/access.md') })).status).toBe(400);
|
|
3078
|
+
expect((await call(base, 'move_file', { src: KB('Sales/archive'), dest: KB('Skills'), dryRun: true })).body).toMatchObject({ allowed: false });
|
|
3079
|
+
expect(await exists(KB('Sales/deal.md'))).toBe(true);
|
|
3080
|
+
expect(await exists(KB('Sales/archive/access.md'))).toBe(false);
|
|
3081
|
+
});
|
|
3082
|
+
|
|
3083
|
+
it.skipIf(process.platform !== 'linux')('on a case-sensitive disk a case-distinct destination is a collision, not an overwrite', async () => {
|
|
3084
|
+
const base = await seeded();
|
|
3085
|
+
await fs.writeFile(KB('Sales/Deal.md'), 'other deal');
|
|
3086
|
+
const run = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/Deal.md') });
|
|
3087
|
+
expect(run.status).toBe(409);
|
|
3088
|
+
expect(await fs.readFile(KB('Sales/Deal.md'), { encoding: 'utf-8' })).toBe('other deal');
|
|
3089
|
+
expect(await exists(KB('Sales/deal.md'))).toBe(true);
|
|
3090
|
+
});
|
|
3091
|
+
|
|
3092
|
+
/**
|
|
3093
|
+
* The agent hears the sentence the sidebar shows — one refusal, named by
|
|
3094
|
+
* what is in the way, so a user reading a tool result and a user reading
|
|
3095
|
+
* the rename box are told the same thing.
|
|
3096
|
+
*/
|
|
3097
|
+
it('a taken destination is refused with the one sentence, naming what is already there', async () => {
|
|
3098
|
+
const base = await seeded();
|
|
3099
|
+
await fs.writeFile(KB('Sales/notes.md'), '# Notes\n');
|
|
3100
|
+
await fs.writeFile(KB('Sales/report.docx'), 'docx bytes');
|
|
3101
|
+
|
|
3102
|
+
const ontoFile = await call(base, 'move_file', { src: KB('Sales/report.docx'), dest: KB('Sales/notes.md') });
|
|
3103
|
+
expect(ontoFile.status).toBe(409);
|
|
3104
|
+
expect(ontoFile.body.error).toBe('A file named notes.md already exists in Sales.');
|
|
3105
|
+
expect(await fs.readFile(KB('Sales/notes.md'), { encoding: 'utf-8' })).toBe('# Notes\n');
|
|
3106
|
+
expect(await exists(KB('Sales/report.docx'))).toBe(true);
|
|
3107
|
+
|
|
3108
|
+
// Onto a FOLDER of that name: the same sentence, said of a folder.
|
|
3109
|
+
const ontoFolder = await call(base, 'move_file', { src: KB('Sales/notes.md'), dest: KB('Sales/archive') });
|
|
3110
|
+
expect(ontoFolder.status).toBe(409);
|
|
3111
|
+
expect(ontoFolder.body.error).toBe('A folder named archive already exists in Sales.');
|
|
3112
|
+
expect(await exists(KB('Sales/archive/top.md'))).toBe(true);
|
|
3113
|
+
|
|
3114
|
+
// A folder onto a file: named for what is in the way, not for what moves.
|
|
3115
|
+
const folderOntoFile = await call(base, 'move_file', { src: KB('Sales/archive'), dest: KB('Sales/notes.md') });
|
|
3116
|
+
expect(folderOntoFile.status).toBe(409);
|
|
3117
|
+
expect(folderOntoFile.body.error).toBe('A file named notes.md already exists in Sales.');
|
|
3118
|
+
expect(await fs.readFile(KB('Sales/notes.md'), { encoding: 'utf-8' })).toBe('# Notes\n');
|
|
3119
|
+
|
|
3120
|
+
// The dry run says the same thing before anything is attempted.
|
|
3121
|
+
const dry = await call(base, 'move_file', { src: KB('Sales/report.docx'), dest: KB('Sales/notes.md'), dryRun: true });
|
|
3122
|
+
expect(dry.body).toMatchObject({ allowed: false, reason: 'A file named notes.md already exists in Sales.' });
|
|
3123
|
+
});
|
|
3124
|
+
|
|
3125
|
+
it('copy_file refuses a taken destination with the same sentence, and copies nothing', async () => {
|
|
3126
|
+
const base = await seeded();
|
|
3127
|
+
await fs.writeFile(KB('Sales/notes.md'), '# Notes\n');
|
|
3128
|
+
|
|
3129
|
+
const onto = await call(base, 'copy_file', { src: KB('Sales/deal.md'), dest: KB('Sales/notes.md') });
|
|
3130
|
+
expect(onto.status).toBe(409);
|
|
3131
|
+
expect(onto.body.error).toBe('A file named notes.md already exists in Sales.');
|
|
3132
|
+
expect(await fs.readFile(KB('Sales/notes.md'), { encoding: 'utf-8' })).toBe('# Notes\n');
|
|
3133
|
+
|
|
3134
|
+
const ontoFolder = await call(base, 'copy_file', { src: KB('Sales/deal.md'), dest: KB('Sales/archive') });
|
|
3135
|
+
expect(ontoFolder.status).toBe(409);
|
|
3136
|
+
expect(ontoFolder.body.error).toBe('A folder named archive already exists in Sales.');
|
|
3137
|
+
|
|
3138
|
+
// A free name still copies, exactly as before.
|
|
3139
|
+
const free = await call(base, 'copy_file', { src: KB('Sales/deal.md'), dest: KB('Sales/deal-copy.md') });
|
|
3140
|
+
expect(free.status).toBe(200);
|
|
3141
|
+
expect(await fs.readFile(KB('Sales/deal-copy.md'), { encoding: 'utf-8' })).toBe('deal');
|
|
3142
|
+
});
|
|
3143
|
+
|
|
3144
|
+
// Only where the two spellings are distinct entries: on a case-insensitive
|
|
3145
|
+
// disk `link(deal.md, Deal.md)` is EEXIST at setup, and the case-only
|
|
3146
|
+
// rename it stands in for is covered by the service's own suite.
|
|
3147
|
+
it.skipIf(!caseSensitiveDisk)('a hard link under a case-variant name is still a second entry, so still a clash', async () => {
|
|
3148
|
+
// The one destination a move may land on is the source ITSELF, which is
|
|
3149
|
+
// what a case-insensitive disk shows for `deal.md` → `Deal.md`. This
|
|
3150
|
+
// disk is not that: `Deal.md` is a directory entry of its own, hard link
|
|
3151
|
+
// or no hard link, and a move onto it would take that name away. One
|
|
3152
|
+
// inode does not make it the same name — the folder listing does, and
|
|
3153
|
+
// here the folder lists both.
|
|
3154
|
+
const base = await seeded();
|
|
3155
|
+
await link(join(tempDir, KB('Sales/deal.md')), join(tempDir, KB('Sales/Deal.md')));
|
|
3156
|
+
|
|
3157
|
+
const run = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/Deal.md') });
|
|
3158
|
+
expect(run.status).toBe(409);
|
|
3159
|
+
expect(run.body.error).toBe('A file named Deal.md already exists in Sales.');
|
|
3160
|
+
expect(await exists(KB('Sales/Deal.md'))).toBe(true);
|
|
3161
|
+
expect(await exists(KB('Sales/deal.md'))).toBe(true);
|
|
3162
|
+
|
|
3163
|
+
const copied = await call(base, 'copy_file', { src: KB('Sales/Deal.md'), dest: KB('Sales/deal.md') });
|
|
3164
|
+
expect(copied.status).toBe(409);
|
|
3165
|
+
});
|
|
3166
|
+
|
|
3167
|
+
it.skipIf(!caseSensitiveDisk)('a hard link under an unrelated name is a clash, inode or no inode', async () => {
|
|
3168
|
+
// The same reading, without the case-variance to confuse it: two names
|
|
3169
|
+
// a user can see separately, and moving onto the second one would take
|
|
3170
|
+
// it away.
|
|
3171
|
+
const base = await seeded();
|
|
3172
|
+
await link(join(tempDir, KB('Sales/deal.md')), join(tempDir, KB('Sales/twin.md')));
|
|
3173
|
+
|
|
3174
|
+
const run = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Sales/twin.md') });
|
|
3175
|
+
expect(run.status).toBe(409);
|
|
3176
|
+
expect(run.body.error).toBe('A file named twin.md already exists in Sales.');
|
|
3177
|
+
expect(await exists(KB('Sales/twin.md'))).toBe(true);
|
|
3178
|
+
expect(await exists(KB('Sales/deal.md'))).toBe(true);
|
|
3179
|
+
});
|
|
3180
|
+
});
|
|
3181
|
+
|
|
3182
|
+
describe('descriptions match behaviour', () => {
|
|
3183
|
+
type Schema = { properties?: Record<string, Schema> };
|
|
3184
|
+
const def = async (name: string) => {
|
|
3185
|
+
const d = (await toolRegistry.listInternal()).find((t) => t.name === name);
|
|
3186
|
+
expect(d, name).toBeDefined();
|
|
3187
|
+
return d as unknown as { description: string; inputs: Schema; outputs: Schema };
|
|
3188
|
+
};
|
|
3189
|
+
const declaredOutputs = (d: { outputs: Schema }) => Object.keys(d.outputs.properties ?? {});
|
|
3190
|
+
|
|
3191
|
+
it('move_file states folders, recursion, collisions, platform files and the confirm rule, and declares every field it returns', async () => {
|
|
3192
|
+
const base = await seeded();
|
|
3193
|
+
const d = await def('move_file');
|
|
3194
|
+
expect(d.description).toMatch(/FILE or FOLDER/);
|
|
3195
|
+
expect(d.description).toMatch(/folder moves recursively/);
|
|
3196
|
+
expect(d.description).toMatch(/destination must not exist/);
|
|
3197
|
+
expect(d.description).toContain('is a platform file and stays in its folder.');
|
|
3198
|
+
expect(d.description).toContain('`dryRun: true`');
|
|
3199
|
+
expect(d.description).toContain('`confirm: true`');
|
|
3200
|
+
expect(Object.keys(d.inputs.properties.body.properties)).toEqual(expect.arrayContaining(['dryRun', 'confirm']));
|
|
3201
|
+
// What the description promises a dry run returns is what it returns.
|
|
3202
|
+
const dry = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('HR/deal.md'), dryRun: true });
|
|
3203
|
+
for (const key of ['src', 'dest', 'kind', 'descendants', 'access', 'accessChanges', 'allowed']) {
|
|
3204
|
+
expect(d.description, key).toContain(key);
|
|
3205
|
+
expect(dry.body, key).toHaveProperty(key);
|
|
3206
|
+
}
|
|
3207
|
+
const unconfirmed = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('HR/deal.md') });
|
|
3208
|
+
expect(d.description).toContain('confirmationRequired: true');
|
|
3209
|
+
expect(unconfirmed.body.confirmationRequired).toBe(true);
|
|
3210
|
+
for (const body of [dry.body, unconfirmed.body]) {
|
|
3211
|
+
expect(declaredOutputs(d)).toEqual(expect.arrayContaining(Object.keys(body)));
|
|
3212
|
+
}
|
|
3213
|
+
});
|
|
3214
|
+
|
|
3215
|
+
it('delete_folder states the confirm rule and refusals, and declares every field it returns', async () => {
|
|
3216
|
+
const base = await seeded();
|
|
3217
|
+
const d = await def('delete_folder');
|
|
3218
|
+
expect(d.description).toContain('`dryRun: true`');
|
|
3219
|
+
expect(d.description).toContain('A non-empty folder is deleted only with `confirm: true`');
|
|
3220
|
+
expect(d.description).toContain('platform folder');
|
|
3221
|
+
const dry = await call(base, 'delete_folder', { path: KB('Sales/archive'), dryRun: true });
|
|
3222
|
+
const unconfirmed = await call(base, 'delete_folder', { path: KB('Sales/archive') });
|
|
3223
|
+
const confirmed = await call(base, 'delete_folder', { path: KB('Sales/archive'), confirm: true });
|
|
3224
|
+
for (const body of [dry.body, unconfirmed.body, confirmed.body]) {
|
|
3225
|
+
expect(declaredOutputs(d)).toEqual(expect.arrayContaining(Object.keys(body)));
|
|
3226
|
+
}
|
|
3227
|
+
});
|
|
3228
|
+
|
|
3229
|
+
it('delete_file names delete_folder, file_stat names its new fields, and every destructive tool names the proposal route', async () => {
|
|
3230
|
+
const base = await seeded();
|
|
3231
|
+
expect((await def('delete_file')).description).toContain('`delete_folder`');
|
|
3232
|
+
const stat = await def('file_stat');
|
|
3233
|
+
const body = (await call(base, 'file_stat', { path: KB('Sales/archive') })).body;
|
|
3234
|
+
for (const key of ['managed', 'movable', 'deletable', 'access', 'descendants']) {
|
|
3235
|
+
expect(stat.description, key).toContain(`\`${key}`);
|
|
3236
|
+
expect(body, key).toHaveProperty(key);
|
|
3237
|
+
}
|
|
3238
|
+
for (const name of ['move_file', 'delete_file', 'delete_folder']) {
|
|
3239
|
+
expect((await def(name)).description, name).toContain('`write-denied`');
|
|
3240
|
+
}
|
|
3241
|
+
});
|
|
3242
|
+
});
|
|
3243
|
+
});
|
|
3244
|
+
|
|
3245
|
+
describe('a write refused for permissions says whether and how to propose it', () => {
|
|
3246
|
+
const TARGET = 'target-company-state';
|
|
3247
|
+
const DENIED = `${KB_DIR}/Sales/deal.md`;
|
|
3248
|
+
const KEY = 'hx_live_Zm9vYmFyU2VjcmV0S2V5';
|
|
3249
|
+
const SECRET_CONTENT = 'password=hunter2-do-not-echo';
|
|
3250
|
+
|
|
3251
|
+
it('the suggested change-request title fits the limit and never splits an emoji', () => {
|
|
3252
|
+
const lead = 'Propose a change to ';
|
|
3253
|
+
expect(proposalTitleFor('Sales/deal.md')).toBe(`${lead}Sales/deal.md`);
|
|
3254
|
+
// The emoji's high surrogate lands on the 255th unit, the last one kept before the ellipsis.
|
|
3255
|
+
const path = `${'a'.repeat(254 - lead.length)}😀${'b'.repeat(40)}`;
|
|
3256
|
+
const title = proposalTitleFor(path);
|
|
3257
|
+
expect(title).toBe(`${lead}${'a'.repeat(254 - lead.length)}…`);
|
|
3258
|
+
expect(title.length).toBeLessThanOrEqual(256);
|
|
3259
|
+
expect(title).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/);
|
|
3260
|
+
});
|
|
3261
|
+
|
|
3262
|
+
/** Access control whose read verdict is `readable` and which records every call. */
|
|
3263
|
+
const readVerdict = (readable: boolean | 'throws') => {
|
|
3264
|
+
const calls: string[] = [];
|
|
3265
|
+
const ac = {
|
|
3266
|
+
canRead: async (_w: string, _u: string, rel: string) => {
|
|
3267
|
+
calls.push(rel);
|
|
3268
|
+
if (readable === 'throws') throw new Error('git failed');
|
|
3269
|
+
return readable;
|
|
3270
|
+
},
|
|
3271
|
+
canReadBatch: async (_w: string, _u: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
|
|
3272
|
+
// The move/delete preflight asks these before it ever reaches the
|
|
3273
|
+
// filesystem. `canWriteBatchAtRef` answering null is "no rules resolve
|
|
3274
|
+
// at HEAD", so the preflight blocks nothing and the refusal comes from
|
|
3275
|
+
// the lock gate below — which is the denial these tests are about.
|
|
3276
|
+
canWrite: async () => true,
|
|
3277
|
+
canDownload: async () => true,
|
|
3278
|
+
canOwner: async () => true,
|
|
3279
|
+
canWriteBatchAtRef: async () => null,
|
|
3280
|
+
eligibleWritersAtRef: async () => ({ roles: ['Sales Lead'], users: [{ name: 'Owner', email: 'owner@x' }] }),
|
|
3281
|
+
} as unknown as IAccessControl;
|
|
3282
|
+
return { ac, calls };
|
|
3283
|
+
};
|
|
3284
|
+
|
|
3285
|
+
/** Make every mutating filesystem method refuse like the lock gate on a protected branch. */
|
|
3286
|
+
const denyWrites = (message?: string) => {
|
|
3287
|
+
const refuse = async (p?: unknown) => {
|
|
3288
|
+
const err = new AccessDeniedError({
|
|
3289
|
+
path: typeof p === 'string' ? p : DENIED,
|
|
3290
|
+
eligibleRoles: ['Sales Lead'],
|
|
3291
|
+
eligibleUsers: [{ name: 'Owner', email: 'owner@x' }],
|
|
3292
|
+
});
|
|
3293
|
+
if (message) Object.defineProperty(err, 'message', { value: message });
|
|
3294
|
+
throw err;
|
|
3295
|
+
};
|
|
3296
|
+
const target = fs as unknown as Record<string, unknown>;
|
|
3297
|
+
for (const m of ['writeFile', 'deleteFile', 'moveFile', 'mkdir']) target[m] = refuse;
|
|
3298
|
+
target.copyFile = async (_src: string, dest: string) => refuse(dest);
|
|
3299
|
+
// `delete_folder` lands through the same batch with NO writes and the
|
|
3300
|
+
// folder's files as `deletes` — in production the refusal comes from the
|
|
3301
|
+
// lock it takes on one of them, so the denial names that file.
|
|
3302
|
+
target.writeFiles = async (writes: { path: string }[], _summary?: string, deletes?: string[]) =>
|
|
3303
|
+
refuse(writes[0]?.path ?? deletes?.[0]);
|
|
3304
|
+
};
|
|
3305
|
+
|
|
3306
|
+
const call = async (base: string, tool: string, body: Record<string, unknown>) => {
|
|
3307
|
+
const res = await fetch(`${base}/api/agent/tools/${tool}`, {
|
|
3308
|
+
method: 'POST',
|
|
3309
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${KEY}` },
|
|
3310
|
+
body: JSON.stringify(body),
|
|
3311
|
+
});
|
|
3312
|
+
const text = await res.text();
|
|
3313
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- a JSON body read field by field
|
|
3314
|
+
return { status: res.status, text, json: JSON.parse(text) as Record<string, any> };
|
|
3315
|
+
};
|
|
3316
|
+
|
|
3317
|
+
const CALLS: Array<[string, Record<string, unknown>]> = [
|
|
3318
|
+
// The denied path already exists, so the writes say they mean to replace
|
|
3319
|
+
// it: the mode gate is not what these tests are about.
|
|
3320
|
+
['write_file', { branch: TARGET, path: DENIED, content: SECRET_CONTENT, mode: 'overwrite' }],
|
|
3321
|
+
['write_files', { branch: TARGET, files: [{ path: DENIED, content: SECRET_CONTENT }], mode: 'overwrite' }],
|
|
3322
|
+
['edit_file', { branch: TARGET, path: DENIED, old_string: 'old', new_string: SECRET_CONTENT }],
|
|
3323
|
+
['move_file', { branch: TARGET, src: DENIED, dest: `${KB_DIR}/Sales/moved.md` }],
|
|
3324
|
+
['delete_file', { branch: TARGET, path: DENIED }],
|
|
3325
|
+
// The folder HOLDING the denied file: confirmed, so the call goes past the
|
|
3326
|
+
// impact preflight and reaches the gate that refuses.
|
|
3327
|
+
['delete_folder', { branch: TARGET, path: `${KB_DIR}/Sales`, confirm: true }],
|
|
3328
|
+
['copy_file', { branch: TARGET, src: `${KB_DIR}/a.md`, dest: DENIED }],
|
|
3329
|
+
['mkdir', { branch: TARGET, path: DENIED }],
|
|
3330
|
+
];
|
|
3331
|
+
|
|
3332
|
+
/**
|
|
3333
|
+
* A copy onto a name that is TAKEN is refused for the name, before any
|
|
3334
|
+
* permission is read (as a move is) — so the case that is about the
|
|
3335
|
+
* permission gives it a free destination. Every other tool here acts on the
|
|
3336
|
+
* file at `DENIED` and needs it present.
|
|
3337
|
+
*/
|
|
3338
|
+
const freeDestinationFor = async (tool: string) => {
|
|
3339
|
+
if (tool === 'copy_file') await rm(join(tempDir, DENIED), { force: true });
|
|
3340
|
+
// An EMPTY folder needs no batch, so it would never reach the gate: put the
|
|
3341
|
+
// denied file back (past the refusing stub, straight to disk) whatever the
|
|
3342
|
+
// case before it did.
|
|
3343
|
+
if (tool === 'delete_folder') await writeFile(join(tempDir, DENIED), 'old text\n');
|
|
3344
|
+
};
|
|
3345
|
+
|
|
3346
|
+
it.each(CALLS)('%s: a reader gets canPropose and the three steps, and nothing is created', async (tool, body) => {
|
|
3347
|
+
const { ac, calls } = readVerdict(true);
|
|
3348
|
+
const base = await start('write', ac);
|
|
3349
|
+
await fs.mkdir(`${KB_DIR}/Sales`, { recursive: true });
|
|
3350
|
+
await fs.writeFile(DENIED, 'old text\n');
|
|
3351
|
+
await freeDestinationFor(tool);
|
|
3352
|
+
denyWrites();
|
|
3353
|
+
const workflowCalls = workspacePathCalls.length;
|
|
3354
|
+
|
|
3355
|
+
const { status, json } = await call(base, tool, body);
|
|
3356
|
+
|
|
3357
|
+
expect(status).toBe(403);
|
|
3358
|
+
expect(json).toMatchObject({
|
|
3359
|
+
kind: 'write-denied',
|
|
3360
|
+
path: DENIED,
|
|
3361
|
+
reason: 'Eligible: Sales Lead; Owner <owner@x>.',
|
|
3362
|
+
canPropose: true,
|
|
3363
|
+
});
|
|
3364
|
+
expect(json.cannotProposeReason).toBeUndefined();
|
|
3365
|
+
expect(json.error).toContain('You may propose this change instead');
|
|
3366
|
+
const draft = json.proposal.draftBranch as string;
|
|
3367
|
+
expect(json.proposal.targetBranch).toBe(TARGET);
|
|
3368
|
+
expect(json.proposal.steps.map((s: { tool: string }) => s.tool)).toEqual(['create_branch', tool, 'open_change_request']);
|
|
3369
|
+
expect(json.proposal.steps[0].args).toEqual({ name: draft, branch: TARGET });
|
|
3370
|
+
expect(json.proposal.steps[1].args).toEqual({ branch: draft });
|
|
3371
|
+
// Every argument open_change_request requires is present, so the step works as given.
|
|
3372
|
+
expect(json.proposal.steps[2].args).toEqual({ sourceBranch: draft, targetBranch: TARGET, title: 'Propose a change to Sales/deal.md' });
|
|
3373
|
+
// The read verdict was asked of the repo-relative path.
|
|
3374
|
+
expect(calls).toContain('Sales/deal.md');
|
|
3375
|
+
// Nothing was created: no workspace was resolved for the DRAFT the answer
|
|
3376
|
+
// suggests, and the context's workflow service is an empty stub, so a
|
|
3377
|
+
// create_branch or change request would have 500d. (The tools that
|
|
3378
|
+
// preflight do resolve the CALLER's own workspace on the way — to read
|
|
3379
|
+
// the on-disk spelling and refuse links — which is not a draft.)
|
|
3380
|
+
const forDraft = workspacePathCalls
|
|
3381
|
+
.slice(workflowCalls)
|
|
3382
|
+
.filter((b) => b.includes(draft) || b.includes(encodeURIComponent(draft)));
|
|
3383
|
+
expect(forDraft).toEqual([]);
|
|
3384
|
+
});
|
|
3385
|
+
|
|
3386
|
+
it('the suggested draft is a branch the caller owns, for an address the convention rewrites', async () => {
|
|
3387
|
+
// `john.doe@` is the common corporate shape, and the one that catches a
|
|
3388
|
+
// prefix derived by a second spelling of the rule: the platform judges
|
|
3389
|
+
// authorship on `john-doe/`, so a suggested `john.doe/…` would be a draft
|
|
3390
|
+
// the agent creates, proposes from, and is then refused permission to
|
|
3391
|
+
// delete. Asserted through the platform's own predicate, not the regex.
|
|
3392
|
+
const email = 'John.Doe+kb@example.com';
|
|
3393
|
+
const { ac } = readVerdict(true);
|
|
3394
|
+
const base = await start('write', ac, email);
|
|
3395
|
+
denyWrites();
|
|
3396
|
+
|
|
3397
|
+
const { json } = await call(base, 'write_file', CALLS[0][1]);
|
|
3398
|
+
|
|
3399
|
+
const draft = json.proposal.draftBranch as string;
|
|
3400
|
+
expect(isBranchAuthoredBy(draft, email)).toBe(true);
|
|
3401
|
+
expect(draft).toBe('john-doe-kb/propose-deal');
|
|
3402
|
+
// The step the agent actually runs carries that same name.
|
|
3403
|
+
expect(json.proposal.steps[0].args).toEqual({ name: draft, branch: TARGET });
|
|
3404
|
+
});
|
|
3405
|
+
|
|
3406
|
+
it('an address with no usable localpart is given a draft under its own suggestions prefix, still one it owns', async () => {
|
|
3407
|
+
// `branchAuthorLocalpart` answers null for a local part with no letter or
|
|
3408
|
+
// digit, rather than inventing an identity. The draft must still be one
|
|
3409
|
+
// the caller can later delete, so it comes from the second authorship
|
|
3410
|
+
// convention — keyed by user id — and is judged by that convention's own
|
|
3411
|
+
// predicate, as the branch-delete path judges it.
|
|
3412
|
+
const email = '+++@example.com';
|
|
3413
|
+
const { ac } = readVerdict(true);
|
|
3414
|
+
const base = await start('write', ac, email);
|
|
3415
|
+
denyWrites();
|
|
3416
|
+
|
|
3417
|
+
const { json } = await call(base, 'write_file', CALLS[0][1]);
|
|
3418
|
+
|
|
3419
|
+
const draft = json.proposal.draftBranch as string;
|
|
3420
|
+
expect(isBranchAuthoredBy(draft, email)).toBe(false);
|
|
3421
|
+
expect(isOwnSuggestionsBranch(draft, { email, id: 'u' })).toBe(true);
|
|
3422
|
+
expect(draft.startsWith('suggestions/')).toBe(true);
|
|
3423
|
+
expect(draft.endsWith('/propose-deal')).toBe(true);
|
|
3424
|
+
});
|
|
3425
|
+
|
|
3426
|
+
it('without read access the denial says so in one sentence and offers no proposal', async () => {
|
|
3427
|
+
const { ac } = readVerdict(false);
|
|
3428
|
+
const base = await start('write', ac);
|
|
3429
|
+
denyWrites();
|
|
3430
|
+
const { status, json } = await call(base, 'write_file', CALLS[0][1]);
|
|
3431
|
+
expect(status).toBe(403);
|
|
3432
|
+
expect(json).toMatchObject({ kind: 'write-denied', path: DENIED, canPropose: false });
|
|
3433
|
+
expect(json.proposal).toBeUndefined();
|
|
3434
|
+
expect(json.cannotProposeReason).toBe('Proposing is not available: you cannot read this path.');
|
|
3435
|
+
expect(json.error).toContain('you cannot read this path');
|
|
3436
|
+
});
|
|
3437
|
+
|
|
3438
|
+
it('on a branch that takes no change requests the denial says the branch is not proposable', async () => {
|
|
3439
|
+
const { ac } = readVerdict(true);
|
|
3440
|
+
const base = await start('write', ac);
|
|
3441
|
+
denyWrites();
|
|
3442
|
+
const { json } = await call(base, 'write_file', { ...CALLS[0][1], branch: 'someone/draft' });
|
|
3443
|
+
expect(json).toMatchObject({ kind: 'write-denied', canPropose: false });
|
|
3444
|
+
expect(json.cannotProposeReason).toContain('not a branch that accepts change requests');
|
|
3445
|
+
});
|
|
3446
|
+
|
|
3447
|
+
it('fails closed when the read verdict cannot be reached', async () => {
|
|
3448
|
+
const { ac } = readVerdict('throws');
|
|
3449
|
+
const base = await start('write', ac);
|
|
3450
|
+
denyWrites();
|
|
3451
|
+
const { json } = await call(base, 'write_file', CALLS[0][1]);
|
|
3452
|
+
expect(json).toMatchObject({ kind: 'write-denied', canPropose: false });
|
|
3453
|
+
expect(json.proposal).toBeUndefined();
|
|
3454
|
+
});
|
|
3455
|
+
|
|
3456
|
+
it('carries the excluded-principal sentence as the reason when the refusal gives one', async () => {
|
|
3457
|
+
const { ac } = readVerdict(true);
|
|
3458
|
+
const base = await start('write', ac);
|
|
3459
|
+
denyWrites(`You don't have permission to write to "${DENIED}". The Sales role is excluded at this folder.`);
|
|
3460
|
+
const { json } = await call(base, 'write_file', CALLS[0][1]);
|
|
3461
|
+
expect(json.reason).toBe('The Sales role is excluded at this folder.');
|
|
3462
|
+
});
|
|
3463
|
+
|
|
3464
|
+
it('never echoes the key, the content or the session id', async () => {
|
|
3465
|
+
const { ac } = readVerdict(true);
|
|
3466
|
+
const base = await start('write', ac);
|
|
3467
|
+
await fs.mkdir(`${KB_DIR}/Sales`, { recursive: true });
|
|
3468
|
+
await fs.writeFile(DENIED, 'old text\n');
|
|
3469
|
+
denyWrites();
|
|
3470
|
+
for (const [tool, body] of CALLS) {
|
|
3471
|
+
await freeDestinationFor(tool);
|
|
3472
|
+
const { text } = await call(base, tool, { ...body, sessionId: 'sess-secret-123' });
|
|
3473
|
+
expect(text, tool).toContain('write-denied');
|
|
3474
|
+
for (const secret of [KEY, SECRET_CONTENT, 'sess-secret-123', 'Bearer']) expect(text, tool).not.toContain(secret);
|
|
3475
|
+
}
|
|
3476
|
+
});
|
|
3477
|
+
|
|
3478
|
+
it('other failures pass through unchanged', async () => {
|
|
3479
|
+
const base = await start('write', readVerdict(true).ac);
|
|
3480
|
+
const res = await call(base, 'edit_file', { branch: TARGET, path: 'a.md', old_string: 'nope', new_string: 'x' });
|
|
3481
|
+
expect(res.status).toBe(400);
|
|
3482
|
+
expect(res.json).toEqual({ error: 'old_string not found in the file.' });
|
|
3483
|
+
});
|
|
3484
|
+
|
|
3485
|
+
it('each write tool mentions the proposal route in its description; read tools do not', async () => {
|
|
3486
|
+
await start();
|
|
3487
|
+
const tools = await toolRegistry.listInternal();
|
|
3488
|
+
for (const name of ['write_file', 'edit_file', 'write_files', 'move_file', 'delete_file', 'delete_folder', 'copy_file', 'mkdir']) {
|
|
3489
|
+
expect(tools.find((t) => t.name === name)?.description, name).toContain(PROPOSAL_ROUTE_NOTE.trim());
|
|
3490
|
+
}
|
|
3491
|
+
for (const name of ['read_file', 'grep', 'list_files']) {
|
|
3492
|
+
expect(tools.find((t) => t.name === name)?.description, name).not.toContain(PROPOSAL_ROUTE_NOTE.trim());
|
|
3493
|
+
}
|
|
3494
|
+
});
|
|
3495
|
+
});
|
|
3496
|
+
|
|
3497
|
+
/**
|
|
3498
|
+
* A path with nothing at it is an ordinary answer, not a server failure.
|
|
3499
|
+
*
|
|
3500
|
+
* Every file tool used to reach that conclusion its own way — `file_stat`,
|
|
3501
|
+
* `grep` and `move_file` with a 404 of their own wording, the rest by letting
|
|
3502
|
+
* the filesystem's `ENOENT` escape as a 500 — so an agent could not tell "your
|
|
3503
|
+
* path is wrong" from "this deployment is broken". These tests pin ONE answer
|
|
3504
|
+
* for all of them: 404, `kind: 'not_found'`, the requested path echoed back,
|
|
3505
|
+
* and the one next-step sentence.
|
|
3506
|
+
*
|
|
3507
|
+
* The four path kinds each tool is asked about: never existed, cannot be read,
|
|
3508
|
+
* deleted on this branch, and malformed.
|
|
3509
|
+
*/
|
|
3510
|
+
describe('a path with nothing at it answers 404 not_found on every file tool', () => {
|
|
3511
|
+
const FOLDER = `${KB_DIR}/Knowledge`;
|
|
3512
|
+
const MISSING = `${FOLDER}/NoSuchFile.md`;
|
|
3513
|
+
|
|
3514
|
+
/** Every file tool the ticket names, called against `path` / `src`. */
|
|
3515
|
+
const callsFor = (path: string): [string, Record<string, unknown>][] => [
|
|
3516
|
+
['read_file', { path }],
|
|
3517
|
+
['file_stat', { path }],
|
|
3518
|
+
['grep', { pattern: 'anything', path }],
|
|
3519
|
+
['edit_file', { path, old_string: 'a', new_string: 'b' }],
|
|
3520
|
+
['delete_file', { path }],
|
|
3521
|
+
['move_file', { src: path, dest: `${FOLDER}/Moved.md` }],
|
|
3522
|
+
['copy_file', { src: path, dest: `${FOLDER}/Copied.md` }],
|
|
3523
|
+
['unzip', { path: `${path}.zip` }],
|
|
3524
|
+
];
|
|
3525
|
+
|
|
3526
|
+
/** The one answer, asserted the same way for every tool. */
|
|
3527
|
+
async function expectNotFound(base: string, tool: string, body: Record<string, unknown>, named: string): Promise<void> {
|
|
3528
|
+
const res = await post(`${base}/api/agent/tools/${tool}`, { branch: 'main', ...body });
|
|
3529
|
+
const json = (await res.json()) as { error?: string; kind?: string; path?: string };
|
|
3530
|
+
expect(res.status, `${tool} status`).toBe(404);
|
|
3531
|
+
expect(json.kind, `${tool} kind`).toBe('not_found');
|
|
3532
|
+
expect(json.path, `${tool} path`).toBe(named);
|
|
3533
|
+
expect(json.error, `${tool} message`).toContain(`"${named}"`);
|
|
3534
|
+
expect(json.error, `${tool} next step`).toContain(NOT_FOUND_NEXT_STEP);
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
it('a path that never existed', async () => {
|
|
3538
|
+
const base = await start();
|
|
3539
|
+
await fs.mkdir(FOLDER, { recursive: true });
|
|
3540
|
+
for (const [tool, body] of callsFor(MISSING)) {
|
|
3541
|
+
// unzip is asked about `<path>.zip`, so it names that path, not MISSING.
|
|
3542
|
+
await expectNotFound(base, tool, body, (body.path as string) ?? (body.src as string));
|
|
3543
|
+
}
|
|
3544
|
+
});
|
|
3545
|
+
|
|
3546
|
+
it('a file deleted on this branch', async () => {
|
|
3547
|
+
const base = await start();
|
|
3548
|
+
const gone = `${FOLDER}/Gone.md`;
|
|
3549
|
+
await fs.mkdir(FOLDER, { recursive: true });
|
|
3550
|
+
await fs.writeFile(gone, 'here for now\n');
|
|
3551
|
+
await fs.writeFile(`${gone}.zip`, 'not really a zip');
|
|
3552
|
+
expect((await post(`${base}/api/agent/tools/delete_file`, { branch: 'main', path: gone })).status).toBe(200);
|
|
3553
|
+
await fs.deleteFile(`${gone}.zip`);
|
|
3554
|
+
for (const [tool, body] of callsFor(gone)) {
|
|
3555
|
+
await expectNotFound(base, tool, body, (body.path as string) ?? (body.src as string));
|
|
3556
|
+
}
|
|
3557
|
+
});
|
|
3558
|
+
|
|
3559
|
+
// ENOTDIR, not ENOENT: nothing can live under a FILE, so the path is as
|
|
3560
|
+
// absent as a name nobody used — and the raw errno used to escape as a 500
|
|
3561
|
+
// carrying the server's own absolute path in its message.
|
|
3562
|
+
it('a path whose parent segment is a file', async () => {
|
|
3563
|
+
const base = await start();
|
|
3564
|
+
await fs.mkdir(FOLDER, { recursive: true });
|
|
3565
|
+
await fs.writeFile(`${FOLDER}/Note.md`, 'a real file\n');
|
|
3566
|
+
const under = `${FOLDER}/Note.md/nested.md`;
|
|
3567
|
+
for (const [tool, body] of callsFor(under)) {
|
|
3568
|
+
await expectNotFound(base, tool, body, (body.path as string) ?? (body.src as string));
|
|
3569
|
+
}
|
|
3570
|
+
});
|
|
3571
|
+
|
|
3572
|
+
// A copy has TWO ends and the filesystem blames the source for both: it
|
|
3573
|
+
// re-throws every ENOENT as `FileNotFoundError(src)`, and a destination
|
|
3574
|
+
// segment that is a file escapes raw as ENOTDIR from the parent mkdir. With
|
|
3575
|
+
// the source sitting right there, the absence can only be the destination's
|
|
3576
|
+
// — so the 404 names the destination. It must not be left to escape as a
|
|
3577
|
+
// 500 either: that is the answer whose message carries the server's own
|
|
3578
|
+
// absolute path, and a mis-spelled destination is the caller's to fix.
|
|
3579
|
+
it('copy_file names the DESTINATION when the source is there and the destination is not', async () => {
|
|
3580
|
+
const base = await start('write');
|
|
3581
|
+
await fs.mkdir(FOLDER, { recursive: true });
|
|
3582
|
+
await fs.writeFile(`${FOLDER}/Note.md`, 'a real file\n');
|
|
3583
|
+
await fs.writeFile(`${FOLDER}/Source.md`, 'copy me\n');
|
|
3584
|
+
const dest = `${FOLDER}/Note.md/deeper/copy.md`;
|
|
3585
|
+
const res = await post(`${base}/api/agent/tools/copy_file`, { branch: 'main', src: `${FOLDER}/Source.md`, dest });
|
|
3586
|
+
const json = (await res.json()) as { error?: string; kind?: string; path?: string };
|
|
3587
|
+
expect(res.status).toBe(404);
|
|
3588
|
+
expect(json.kind).toBe('not_found');
|
|
3589
|
+
expect(json.path).toBe(dest);
|
|
3590
|
+
expect(json.error).toContain(NOT_FOUND_NEXT_STEP);
|
|
3591
|
+
// The source is not what is wrong, and the answer never says it is.
|
|
3592
|
+
expect(json.error).not.toContain('Source.md');
|
|
3593
|
+
// Nor does the raw errno — and the server's own absolute path with it —
|
|
3594
|
+
// reach the caller, which is what the old 500 handed over.
|
|
3595
|
+
expect(json.error).not.toContain('ENOTDIR');
|
|
3596
|
+
});
|
|
3597
|
+
|
|
3598
|
+
// A backslash is a filename character on THIS disk and a separator on
|
|
3599
|
+
// Windows, so it is a path nobody can read the same way twice. The read tools
|
|
3600
|
+
// used to meet it as plain absence; the one normaliser refuses it up front
|
|
3601
|
+
// now, on every tool alike — the refusal the move and delete tools always
|
|
3602
|
+
// gave, extended to the rest by the fact that they all go through it.
|
|
3603
|
+
it('a malformed path: backslashes are refused up front, on every tool', async () => {
|
|
3604
|
+
const base = await start();
|
|
3605
|
+
const odd = `${KB_DIR}\\Knowledge\\NoSuchFile.md`;
|
|
3606
|
+
const tools: [string, Record<string, unknown>][] = [
|
|
3607
|
+
...callsFor(odd).filter(([name]) => ['read_file', 'file_stat', 'grep', 'edit_file', 'copy_file'].includes(name)),
|
|
3608
|
+
['delete_file', { path: odd }],
|
|
3609
|
+
['move_file', { src: odd, dest: `${FOLDER}/Moved.md` }],
|
|
3610
|
+
];
|
|
3611
|
+
for (const [tool, body] of tools) {
|
|
3612
|
+
const res = await post(`${base}/api/agent/tools/${tool}`, { branch: 'main', ...body });
|
|
3613
|
+
expect(res.status, tool).toBe(400);
|
|
3614
|
+
expect((await res.json()).error, tool).toContain('backslashes');
|
|
3615
|
+
}
|
|
3616
|
+
});
|
|
3617
|
+
|
|
3618
|
+
// THE ordering rule: the read gate answers before absence does. A caller who
|
|
3619
|
+
// may not read a path must not learn from the answer whether anything is
|
|
3620
|
+
// there — so a missing denied path and an existing denied one are the SAME
|
|
3621
|
+
// response, byte for byte.
|
|
3622
|
+
it('a path the caller may not read answers the denial, never 404', async () => {
|
|
3623
|
+
const base = await start('write', denyReads(new Set(['Knowledge/Secret.md', 'Knowledge/Ghost.md'])));
|
|
3624
|
+
await fs.mkdir(FOLDER, { recursive: true });
|
|
3625
|
+
await fs.writeFile(`${FOLDER}/Secret.md`, 'real content\n');
|
|
3626
|
+
for (const tool of ['read_file', 'file_stat', 'grep'] as const) {
|
|
3627
|
+
const ask = async (name: string): Promise<{ status: number; body: string }> => {
|
|
3628
|
+
const body = callsFor(`${FOLDER}/${name}`).find(([t]) => t === tool)![1];
|
|
3629
|
+
const res = await post(`${base}/api/agent/tools/${tool}`, { branch: 'main', ...body });
|
|
3630
|
+
return { status: res.status, body: (await res.text()).replace(name, '<name>') };
|
|
3631
|
+
};
|
|
3632
|
+
const present = await ask('Secret.md');
|
|
3633
|
+
const absent = await ask('Ghost.md');
|
|
3634
|
+
expect(present.status, tool).toBe(403);
|
|
3635
|
+
expect(absent, tool).toEqual(present);
|
|
3636
|
+
expect(absent.body, tool).not.toContain('not_found');
|
|
3637
|
+
}
|
|
3638
|
+
});
|
|
3639
|
+
|
|
3640
|
+
// The same ordering rule on the WRITE side. A refusal to write must not
|
|
3641
|
+
// report what is on disk, or the refusal itself becomes the disclosure: a
|
|
3642
|
+
// caller who may not copy into a folder would learn from the answer whether
|
|
3643
|
+
// the source they named exists. So the write denial comes first and absence
|
|
3644
|
+
// is only asked about once the copy was allowed to be attempted — the 404
|
|
3645
|
+
// this ticket adds must not push in front of the 403 that was already there.
|
|
3646
|
+
it('copy_file refuses a denied destination with the write denial, even when the source is missing', async () => {
|
|
3647
|
+
const base = await start('write');
|
|
3648
|
+
await fs.mkdir(FOLDER, { recursive: true });
|
|
3649
|
+
const denied = async (src: string): Promise<{ status: number; body: string }> => {
|
|
3650
|
+
const copySpy = vi.spyOn(fs, 'copyFile').mockRejectedValue(
|
|
3651
|
+
new AccessDeniedError({ path: `${FOLDER}/Denied.md`, eligibleRoles: ['Owner'], eligibleUsers: [] }),
|
|
3652
|
+
);
|
|
3653
|
+
try {
|
|
3654
|
+
const res = await post(`${base}/api/agent/tools/copy_file`, { branch: 'main', src, dest: `${FOLDER}/Denied.md` });
|
|
3655
|
+
return { status: res.status, body: await res.text() };
|
|
3656
|
+
} finally {
|
|
3657
|
+
copySpy.mockRestore();
|
|
3658
|
+
}
|
|
3659
|
+
};
|
|
3660
|
+
await fs.writeFile(`${FOLDER}/Present.md`, 'here\n');
|
|
3661
|
+
const present = await denied(`${FOLDER}/Present.md`);
|
|
3662
|
+
const absent = await denied(MISSING);
|
|
3663
|
+
expect(present.status).toBe(403);
|
|
3664
|
+
// Byte for byte the same refusal: the source's existence changes nothing.
|
|
3665
|
+
expect(absent.status).toBe(403);
|
|
3666
|
+
expect(absent.body).toBe(present.body);
|
|
3667
|
+
expect(absent.body).toContain('write-denied');
|
|
3668
|
+
expect(absent.body).not.toContain('not_found');
|
|
3669
|
+
});
|
|
3670
|
+
|
|
3671
|
+
// Absence is ENOENT and ENOTDIR and nothing else. A path that cannot be READ
|
|
3672
|
+
// is not a path the caller should be told to go and re-spell.
|
|
3673
|
+
it('a filesystem failure that is not absence stays a 500', async () => {
|
|
3674
|
+
const base = await start();
|
|
3675
|
+
await fs.mkdir(FOLDER, { recursive: true });
|
|
3676
|
+
const boom = Object.assign(new Error('EIO: i/o error, read'), { code: 'EIO' });
|
|
3677
|
+
const readFileSpy = vi.spyOn(fs, 'readFile').mockRejectedValue(boom);
|
|
3678
|
+
try {
|
|
3679
|
+
const res = await post(`${base}/api/agent/tools/read_file`, { branch: 'main', path: `${FOLDER}/Unreadable.md` });
|
|
3680
|
+
expect(res.status).toBe(500);
|
|
3681
|
+
const json = (await res.json()) as { kind?: string };
|
|
3682
|
+
expect(json.kind).toBeUndefined();
|
|
3683
|
+
} finally {
|
|
3684
|
+
readFileSpy.mockRestore();
|
|
3685
|
+
}
|
|
3686
|
+
});
|
|
3687
|
+
});
|