@bevel-software/platform-core-backend 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/core-ports.d.ts +11 -1
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +46 -13
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +12 -2
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +137 -24
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +46 -1
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +99 -13
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core-config.d.ts +16 -12
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +28 -13
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -3
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +42 -12
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +67 -10
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +214 -35
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +17 -16
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
- package/dist/modules/access/directory-sync-bot.js +7 -3
- package/dist/modules/access/directory-sync-bot.js.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
- package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts +38 -6
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +39 -6
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/index.d.ts +2 -1
- package/dist/modules/agent-instructions/index.d.ts.map +1 -1
- package/dist/modules/agent-instructions/index.js +2 -1
- package/dist/modules/agent-instructions/index.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
- package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +4 -2
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/auth/account-admission.d.ts +58 -7
- package/dist/modules/auth/account-admission.d.ts.map +1 -1
- package/dist/modules/auth/account-admission.js +44 -1
- package/dist/modules/auth/account-admission.js.map +1 -1
- package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
- package/dist/modules/auth/account-erasure.service.js +57 -16
- package/dist/modules/auth/account-erasure.service.js.map +1 -1
- package/dist/modules/auth/account.routes.d.ts +1 -1
- package/dist/modules/auth/account.routes.d.ts.map +1 -1
- package/dist/modules/auth/account.routes.js +61 -5
- package/dist/modules/auth/account.routes.js.map +1 -1
- package/dist/modules/auth/auth.middleware.d.ts +1 -1
- package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
- package/dist/modules/auth/auth.middleware.js +18 -7
- package/dist/modules/auth/auth.middleware.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +8 -0
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +70 -2
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +197 -18
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.js +7 -2
- package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +66 -35
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/database/connection.d.ts +16 -0
- package/dist/modules/database/connection.d.ts.map +1 -1
- package/dist/modules/database/connection.js +117 -0
- package/dist/modules/database/connection.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +337 -120
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +116 -58
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/database/migrate.d.ts +8 -0
- package/dist/modules/database/migrate.d.ts.map +1 -1
- package/dist/modules/database/migrate.js +271 -1
- package/dist/modules/database/migrate.js.map +1 -1
- package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
- package/dist/modules/kb-fs/branch-name.js +12 -2
- package/dist/modules/kb-fs/branch-name.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +11 -16
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
- package/dist/modules/kb-fs/repo-path.js +79 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
- package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
- package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
- package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
- package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
- package/dist/modules/kb-sync/sync-auth.js +1 -1
- package/dist/modules/kb-sync/sync-auth.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +8 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +38 -8
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-records.store.js +7 -4
- package/dist/modules/plugins/join-request-records.store.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +8 -3
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/setup.routes.d.ts +52 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +207 -19
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +90 -18
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +4 -1
- package/dist/modules/skills/skills.contract.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +98 -9
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +239 -37
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +7 -0
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +71 -7
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +12 -4
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.js +25 -4
- package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/tool-helpers/validate-token.js +1 -1
- package/dist/modules/tool-helpers/validate-token.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-registry/description-length.d.ts +80 -0
- package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
- package/dist/modules/tool-registry/description-length.js +108 -0
- package/dist/modules/tool-registry/description-length.js.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +24 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +30 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +94 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +193 -3
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +60 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
- package/dist/modules/workflow/recovery-bot.js +7 -3
- package/dist/modules/workflow/recovery-bot.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
- package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
- package/dist/modules/workflow/workflow-hooks.js +16 -1
- package/dist/modules/workflow/workflow-hooks.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +60 -6
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +115 -5
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
- package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
- package/dist/modules/workspace/agent-access.gate.js +123 -0
- package/dist/modules/workspace/agent-access.gate.js.map +1 -0
- package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
- package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
- package/dist/modules/workspace/agent-upload.routes.js +210 -0
- package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
- package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
- package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
- package/dist/modules/workspace/agent-upload.store.js +553 -0
- package/dist/modules/workspace/agent-upload.store.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +5 -6
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/session-sink.d.ts +5 -5
- package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
- package/dist/modules/workspace/set-aside-clone.js +92 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
- package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.js +46 -4
- package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
- package/dist/modules/workspace/upload-limits.d.ts +13 -0
- package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
- package/dist/modules/workspace/upload-limits.js +13 -0
- package/dist/modules/workspace/upload-limits.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +93 -4
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +128 -5
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +316 -58
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +13 -11
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +791 -198
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/modules/workspace/write-denial.d.ts +0 -6
- package/dist/modules/workspace/write-denial.d.ts.map +1 -1
- package/dist/modules/workspace/write-denial.js +0 -6
- package/dist/modules/workspace/write-denial.js.map +1 -1
- package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
- package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
- package/dist/modules/workspace/zip-entry-rules.js +154 -0
- package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
- package/dist/modules/write-access/write-access.d.ts +60 -0
- package/dist/modules/write-access/write-access.d.ts.map +1 -0
- package/dist/modules/write-access/write-access.js +129 -0
- package/dist/modules/write-access/write-access.js.map +1 -0
- package/dist/shared/column-crypto.d.ts +194 -0
- package/dist/shared/column-crypto.d.ts.map +1 -0
- package/dist/shared/column-crypto.js +144 -0
- package/dist/shared/column-crypto.js.map +1 -0
- package/dist/shared/domain-errors.d.ts +25 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +28 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +20 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +26 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -1
- package/dist/shared/token-crypto.js.map +1 -1
- package/dist/tenancy/static-tenant-source.d.ts +0 -1
- package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
- package/dist/tenancy/static-tenant-source.js +1 -2
- package/dist/tenancy/static-tenant-source.js.map +1 -1
- package/dist/tenancy/tenant-secrets.d.ts +5 -1
- package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
- package/dist/tenancy/tenant-secrets.js +4 -0
- package/dist/tenancy/tenant-secrets.js.map +1 -1
- package/kb-template/AGENTS.md +2 -0
- package/migrations/0014_change_request_closed_reason.sql +1 -0
- package/migrations/0015_account_deactivation.sql +3 -0
- package/migrations/0016_pii_encryption.sql +20 -0
- package/migrations/meta/0014_snapshot.json +2265 -0
- package/migrations/meta/0015_snapshot.json +2271 -0
- package/migrations/meta/0016_snapshot.json +2327 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +3 -3
- package/src/__tests__/kb-layout-config.test.ts +0 -2
- package/src/__tests__/retired-settings.test.ts +96 -0
- package/src/core/__tests__/gated-boot.test.ts +132 -0
- package/src/core/__tests__/lifecycle.test.ts +241 -5
- package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
- package/src/core/core-ports.ts +11 -1
- package/src/core/create-core-server.ts +52 -16
- package/src/core/create-core-services.ts +156 -24
- package/src/core/lifecycle.ts +117 -13
- package/src/core-config.ts +31 -14
- package/src/index.ts +30 -1
- package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
- package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
- package/src/modules/access/__tests__/users-db-double.ts +21 -12
- package/src/modules/access/access-control.interface.ts +43 -12
- package/src/modules/access/access-control.service.ts +234 -36
- package/src/modules/access/access.routes.ts +17 -16
- package/src/modules/access/directory-sync-bot.ts +7 -3
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +11 -5
- package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
- package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
- package/src/modules/agent-instructions/compose.ts +50 -7
- package/src/modules/agent-instructions/index.ts +12 -0
- package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
- package/src/modules/audit/agent-audit.service.ts +4 -2
- package/src/modules/auth/__tests__/account-deactivation.test.ts +253 -0
- package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
- package/src/modules/auth/__tests__/account.routes.test.ts +87 -6
- package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
- package/src/modules/auth/__tests__/auth.service.test.ts +4 -2
- package/src/modules/auth/account-admission.ts +78 -9
- package/src/modules/auth/account-erasure.service.ts +69 -18
- package/src/modules/auth/account.routes.ts +63 -7
- package/src/modules/auth/auth.middleware.ts +19 -8
- package/src/modules/auth/auth.routes.ts +8 -0
- package/src/modules/auth/auth.service.ts +206 -23
- package/src/modules/auth/oidc-auth-provider.ts +7 -2
- package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
- package/src/modules/code-mode/code-mode.tool.ts +80 -34
- package/src/modules/database/__tests__/connection.test.ts +12 -0
- package/src/modules/database/__tests__/pii-backfill.pg.test.ts +561 -0
- package/src/modules/database/connection.ts +117 -0
- package/src/modules/database/core-schema.ts +116 -58
- package/src/modules/database/migrate.ts +353 -1
- package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
- package/src/modules/kb-fs/branch-name.ts +14 -1
- package/src/modules/kb-fs/repo-path.ts +85 -0
- package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
- package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
- package/src/modules/kb-sync/sync-auth.ts +2 -2
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
- package/src/modules/mcp/__tests__/mcp.service.test.ts +115 -13
- package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
- package/src/modules/mcp/mcp.service.ts +46 -7
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
- package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
- package/src/modules/plugins/join-request-records.store.ts +7 -4
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
- package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
- package/src/modules/settings/deployment-settings.service.ts +8 -3
- package/src/modules/settings/setup.routes.ts +263 -19
- package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
- package/src/modules/skills/skills.contract.ts +91 -18
- package/src/modules/skills/skills.service.ts +278 -41
- package/src/modules/skills/skills.tools.ts +80 -8
- package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
- package/src/modules/tool-auth/external-api-key.service.ts +12 -4
- package/src/modules/tool-auth/internal-token.service.ts +3 -3
- package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
- package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
- package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
- package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
- package/src/modules/tool-helpers/tool-handler.ts +24 -4
- package/src/modules/tool-helpers/tool.contract.ts +3 -2
- package/src/modules/tool-helpers/validate-token.ts +1 -1
- package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
- package/src/modules/tool-registry/description-length.ts +111 -0
- package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
- package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
- package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
- package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
- package/src/modules/workflow/file-lock.service.ts +31 -0
- package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
- package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
- package/src/modules/workflow/git/git.service.ts +215 -1
- package/src/modules/workflow/pending-commits.service.ts +64 -2
- package/src/modules/workflow/recovery-bot.ts +7 -3
- package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
- package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow-hooks.ts +64 -26
- package/src/modules/workflow/workflow.service.ts +125 -5
- package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
- package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +394 -0
- package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
- package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
- package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +402 -0
- package/src/modules/workspace/__tests__/workspace.service.test.ts +77 -9
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +41 -39
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +766 -56
- package/src/modules/workspace/agent-access.gate.ts +164 -0
- package/src/modules/workspace/agent-upload.routes.ts +214 -0
- package/src/modules/workspace/agent-upload.store.ts +668 -0
- package/src/modules/workspace/routine-write-policy.ts +5 -6
- package/src/modules/workspace/session-sink.ts +5 -5
- package/src/modules/workspace/set-aside-clone.ts +96 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
- package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
- package/src/modules/workspace/startup/steps/template-source.ts +53 -5
- package/src/modules/workspace/upload-limits.ts +12 -0
- package/src/modules/workspace/workspace.routes.ts +96 -5
- package/src/modules/workspace/workspace.service.ts +319 -61
- package/src/modules/workspace/workspace.tools.ts +894 -214
- package/src/modules/workspace/write-denial.ts +0 -8
- package/src/modules/workspace/zip-entry-rules.ts +173 -0
- package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
- package/src/modules/write-access/write-access.ts +153 -0
- package/src/shared/__tests__/column-crypto.test.ts +217 -0
- package/src/shared/column-crypto.ts +218 -0
- package/src/shared/domain-errors.ts +31 -0
- package/src/shared/git.contract.ts +27 -0
- package/src/shared/token-crypto.ts +28 -1
- package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -1
- package/src/tenancy/static-tenant-source.ts +1 -3
- package/src/tenancy/tenant-secrets.ts +5 -1
- package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
- package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
- package/dist/modules/workflow/session-ontology.policy.js +0 -62
- package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
- package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
- package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
- package/dist/modules/workflow/session-ontology.service.js +0 -147
- package/dist/modules/workflow/session-ontology.service.js.map +0 -1
- package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
- package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
- package/dist/modules/workspace/session-ontology.gate.js +0 -161
- package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
- package/dist/shared/kb-layout.d.ts +0 -39
- package/dist/shared/kb-layout.d.ts.map +0 -1
- package/dist/shared/kb-layout.js +0 -103
- package/dist/shared/kb-layout.js.map +0 -1
- package/dist/shared/kb-layout.test.d.ts +0 -2
- package/dist/shared/kb-layout.test.d.ts.map +0 -1
- package/dist/shared/kb-layout.test.js +0 -75
- package/dist/shared/kb-layout.test.js.map +0 -1
- package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
- package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
- package/src/modules/workflow/session-ontology.policy.ts +0 -70
- package/src/modules/workflow/session-ontology.service.ts +0 -183
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
- package/src/modules/workspace/session-ontology.gate.ts +0 -191
- package/src/shared/kb-layout.test.ts +0 -98
- package/src/shared/kb-layout.ts +0 -102
|
@@ -0,0 +1,1604 @@
|
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, symlink, utimes, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
import AdmZip from 'adm-zip';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
9
|
+
import { LocalFilesystem } from '@mastra/core/workspace';
|
|
10
|
+
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
11
|
+
import { ToolRegistry } from '../../tool-registry/tool-registry.js';
|
|
12
|
+
import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
13
|
+
import type { ToolContext } from '../../tool-helpers/tool.contract.js';
|
|
14
|
+
import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
|
|
15
|
+
import { registerWorkspaceTools } from '../workspace.tools.js';
|
|
16
|
+
import { RoutineWritePolicyService } from '../routine-write-policy.js';
|
|
17
|
+
import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
|
|
18
|
+
import { ToolDescriptionNotes } from '../agent-access.gate.js';
|
|
19
|
+
import { SpillStore } from '../spill-store.js';
|
|
20
|
+
import { DocExtractService } from '../file-readers/doc-extract.service.js';
|
|
21
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
22
|
+
import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
|
|
23
|
+
import {
|
|
24
|
+
AgentUploadStore,
|
|
25
|
+
assertUploadsRootOutsideWorkspaces,
|
|
26
|
+
UPLOAD_TOKEN_REFUSAL,
|
|
27
|
+
} from '../agent-upload.store.js';
|
|
28
|
+
import { createAgentUploadRoutes, isAgentUploadRawBodyPath } from '../agent-upload.routes.js';
|
|
29
|
+
import { READ_ONLY_CODE, type IWriteAccess } from '../../write-access/write-access.js';
|
|
30
|
+
import { MAX_UPLOAD_BYTES } from '../upload-limits.js';
|
|
31
|
+
import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
|
|
32
|
+
import { sharedFileRules } from '../../agent-instructions/shared-file-rules.js';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The upload route an agent lands files by, end to end: a token, bytes sent to
|
|
36
|
+
* the address with no session of any kind, and an apply that commits them on a
|
|
37
|
+
* branch.
|
|
38
|
+
*
|
|
39
|
+
* Every Scenario in `Agent uploads files: Specification` has a test here. The
|
|
40
|
+
* filesystem is a REAL `LocalFilesystem` over a temp dir, with a `writeFiles`
|
|
41
|
+
* stand-in for the locking batch (as `workspace.tools.test.ts` uses): the
|
|
42
|
+
* tests assert what the TOOL controls — one batch for the whole upload, a
|
|
43
|
+
* per-path outcome, and the exact bytes on disk. That the batch is atomic is
|
|
44
|
+
* asserted where it lives, in `locking-filesystem.test.ts`.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const KB_DIR = 'knowledge-base';
|
|
48
|
+
/** A draft branch — not one `testKbContext` protects. */
|
|
49
|
+
const DRAFT = 'draft';
|
|
50
|
+
/** A branch the test context protects, where the lock gate's write rules apply. */
|
|
51
|
+
const PROTECTED = 'target-company-state';
|
|
52
|
+
const PUBLIC_BASE = 'https://kb.example.com';
|
|
53
|
+
|
|
54
|
+
const allowAll = {
|
|
55
|
+
canRead: async () => true,
|
|
56
|
+
canReadBatch: async (_w: string, _u: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
|
|
57
|
+
canWrite: async () => true,
|
|
58
|
+
canDownload: async () => true,
|
|
59
|
+
canOwner: async () => true,
|
|
60
|
+
canWriteBatchAtRef: async () => null,
|
|
61
|
+
eligibleWritersAtRef: async () => ({ roles: ['Admin'], users: [] }),
|
|
62
|
+
} as unknown as IAccessControl;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Access control that, at HEAD on a protected branch, denies `write` for an
|
|
66
|
+
* explicit set of repo-relative paths — which is the only verdict the write
|
|
67
|
+
* tools' preflight consults (`writeBlocked`). Everything else is allowed, so a
|
|
68
|
+
* test says exactly which path it is about.
|
|
69
|
+
*/
|
|
70
|
+
function denyWritesAtHead(denied: (rel: string) => boolean): IAccessControl {
|
|
71
|
+
return {
|
|
72
|
+
...allowAll,
|
|
73
|
+
canRead: async () => true,
|
|
74
|
+
canWriteBatchAtRef: async (_w: string, _ref: string, _u: string, rels: string[]) =>
|
|
75
|
+
new Map(rels.map((rel) => [rel, !denied(rel)])),
|
|
76
|
+
eligibleWritersAtRef: async () => ({ roles: ['Editor'], users: [{ name: 'Ada', email: 'ada@x' }] }),
|
|
77
|
+
} as unknown as IAccessControl;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every server a test started — a second one stands for a second caller. */
|
|
81
|
+
let servers: HttpServer[] = [];
|
|
82
|
+
let tempDir = '';
|
|
83
|
+
let uploadsDir = '';
|
|
84
|
+
let docCacheDir = '';
|
|
85
|
+
let fs: LocalFilesystem;
|
|
86
|
+
let uploads: AgentUploadStore;
|
|
87
|
+
let toolRegistry: ToolRegistry;
|
|
88
|
+
/** One entry per `writeFiles` batch the tools landed: the paths it carried. */
|
|
89
|
+
let batches: string[][] = [];
|
|
90
|
+
/** One entry per folder turn a tool took: the folder it named. */
|
|
91
|
+
let folderTurns: string[] = [];
|
|
92
|
+
/** How many folder turns are held right now — a batch reads it to prove it ran inside one. */
|
|
93
|
+
let turnsHeld = 0;
|
|
94
|
+
/** The depth `turnsHeld` stood at while each `writeFiles` batch landed. */
|
|
95
|
+
let batchTurnDepth: number[] = [];
|
|
96
|
+
/**
|
|
97
|
+
* Held by a test that wants an apply to WAIT, standing in for the folder turn
|
|
98
|
+
* a concurrent `delete_folder` would be holding over the same subtree.
|
|
99
|
+
*/
|
|
100
|
+
let folderTurnGate: Promise<void> | undefined;
|
|
101
|
+
/**
|
|
102
|
+
* Resolved the moment a tool ASKS for a folder turn — before any wait on
|
|
103
|
+
* `folderTurnGate`. A test awaits this instead of sleeping: when it resolves,
|
|
104
|
+
* the apply is parked on the gate with certainty, not with probability.
|
|
105
|
+
*/
|
|
106
|
+
let folderTurnAsked: Promise<void>;
|
|
107
|
+
let announceFolderTurnAsked: () => void;
|
|
108
|
+
|
|
109
|
+
interface StartOptions {
|
|
110
|
+
access?: IAccessControl;
|
|
111
|
+
userId?: string;
|
|
112
|
+
userEmail?: string;
|
|
113
|
+
ttlMs?: number;
|
|
114
|
+
maxBytes?: number;
|
|
115
|
+
/** How many tokens one user may hold open at once. */
|
|
116
|
+
maxOpenPerUser?: number;
|
|
117
|
+
/** Start the periodic sweep at this interval BEFORE any token is issued. */
|
|
118
|
+
sweepEveryMs?: number;
|
|
119
|
+
/** The store's listing seam, for a test that needs to hold a sweep open. */
|
|
120
|
+
listRoot?: (root: string) => Promise<string[]>;
|
|
121
|
+
/**
|
|
122
|
+
* Mount against a store that is already running, so a second server can
|
|
123
|
+
* stand for a second caller — a different connection key, the same server's
|
|
124
|
+
* store — without the first one's state being thrown away.
|
|
125
|
+
*/
|
|
126
|
+
store?: AgentUploadStore;
|
|
127
|
+
/**
|
|
128
|
+
* The deployment’s write verdict, as a host fills it. Default (absent) is
|
|
129
|
+
* core’s own: always writable.
|
|
130
|
+
*/
|
|
131
|
+
writeAccess?: IWriteAccess;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function start(options: StartOptions = {}): Promise<string> {
|
|
135
|
+
const { access = allowAll, userId = 'u', userEmail = 'e@x' } = options;
|
|
136
|
+
if (options.store === undefined) {
|
|
137
|
+
tempDir = await mkdtemp(join(tmpdir(), 'ws-upload-'));
|
|
138
|
+
uploadsDir = await mkdtemp(join(tmpdir(), 'agent-uploads-'));
|
|
139
|
+
docCacheDir = await mkdtemp(join(tmpdir(), 'ws-upload-doc-'));
|
|
140
|
+
batches = [];
|
|
141
|
+
folderTurns = [];
|
|
142
|
+
batchTurnDepth = [];
|
|
143
|
+
turnsHeld = 0;
|
|
144
|
+
folderTurnGate = undefined;
|
|
145
|
+
folderTurnAsked = new Promise<void>((resolve) => (announceFolderTurnAsked = resolve));
|
|
146
|
+
}
|
|
147
|
+
fs = new LocalFilesystem({ basePath: tempDir, contained: true });
|
|
148
|
+
// The harness speaks the paths the TOOLS speak: a fixture written here as
|
|
149
|
+
// `Skills/x.md` lands where a tool asking for `Skills/x.md` will read it.
|
|
150
|
+
for (const method of ['readFile', 'writeFile', 'appendFile', 'deleteFile', 'mkdir', 'stat'] as const) {
|
|
151
|
+
const inner = (fs as unknown as Record<string, (...a: unknown[]) => unknown>)[method].bind(fs);
|
|
152
|
+
(fs as unknown as Record<string, unknown>)[method] = (path: string, ...rest: unknown[]) =>
|
|
153
|
+
inner(normalizeWorkspacePath(path, KB_DIR), ...rest);
|
|
154
|
+
}
|
|
155
|
+
const plainWriteFile = fs.writeFile.bind(fs);
|
|
156
|
+
// Stands in for `LockingFilesystem.writeFiles`: honours the caller's
|
|
157
|
+
// under-lock `check` (run with the paths "locked", just before any byte
|
|
158
|
+
// lands) and writes only what the check keeps. Content arrives as a Buffer
|
|
159
|
+
// from an apply, and is written through untouched — which is the property
|
|
160
|
+
// byte-for-byte landing rests on.
|
|
161
|
+
(fs as unknown as Record<string, unknown>).writeFiles = async (
|
|
162
|
+
writes: { path: string; content: string | Buffer }[],
|
|
163
|
+
_summary: string,
|
|
164
|
+
deletes: string[] = [],
|
|
165
|
+
check?: (
|
|
166
|
+
pending: readonly { path: string; content: string | Buffer }[],
|
|
167
|
+
) => Promise<{ path: string; content: string | Buffer }[]>,
|
|
168
|
+
) => {
|
|
169
|
+
const landing = check ? await check(writes) : writes;
|
|
170
|
+
batches.push(landing.map((w) => w.path));
|
|
171
|
+
batchTurnDepth.push(turnsHeld);
|
|
172
|
+
for (const w of landing) await plainWriteFile(w.path, w.content as never);
|
|
173
|
+
for (const p of deletes) await fs.deleteFile(p);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
uploads =
|
|
177
|
+
options.store ??
|
|
178
|
+
new AgentUploadStore({
|
|
179
|
+
root: uploadsDir,
|
|
180
|
+
publicBaseUrl: PUBLIC_BASE,
|
|
181
|
+
tokenPrefix: 'bevel-up_',
|
|
182
|
+
...(options.ttlMs !== undefined ? { ttlMs: options.ttlMs } : {}),
|
|
183
|
+
...(options.maxBytes !== undefined ? { maxBytes: options.maxBytes } : {}),
|
|
184
|
+
...(options.maxOpenPerUser !== undefined ? { maxOpenPerUser: options.maxOpenPerUser } : {}),
|
|
185
|
+
...(options.listRoot !== undefined ? { listRoot: options.listRoot } : {}),
|
|
186
|
+
});
|
|
187
|
+
if (options.sweepEveryMs !== undefined) uploads.startSweeping(options.sweepEveryMs);
|
|
188
|
+
|
|
189
|
+
const registry = new ToolRegistry();
|
|
190
|
+
toolRegistry = registry;
|
|
191
|
+
const resolve = async (auth: ToolAuth, signal: AbortSignal, sessionId?: string): Promise<ToolContext> => ({
|
|
192
|
+
user: { id: userId, email: userEmail, name: 'N' },
|
|
193
|
+
scope: auth.scope,
|
|
194
|
+
source: auth.source,
|
|
195
|
+
sessionId,
|
|
196
|
+
abortSignal: signal,
|
|
197
|
+
workspaceService: {
|
|
198
|
+
getOrCreateForBranch: async (branch: string) => ({
|
|
199
|
+
id: encodeURIComponent(branch),
|
|
200
|
+
name: branch,
|
|
201
|
+
absolutePath: tempDir,
|
|
202
|
+
createdAt: '',
|
|
203
|
+
kbDirName: KB_DIR,
|
|
204
|
+
}),
|
|
205
|
+
getWorkspacePath: async () => tempDir,
|
|
206
|
+
hasBootstrappedWorkspace: async () => false,
|
|
207
|
+
// Stands in for `WorkspaceService.withFolderTurn`: records the folder
|
|
208
|
+
// whose turn was taken, and waits on `folderTurnGate` on the way in so a
|
|
209
|
+
// test can hold the subtree the way a concurrent `delete_folder` holds
|
|
210
|
+
// it. The real one serialises overlapping subtrees; what a tool test can
|
|
211
|
+
// check is that the tool TAKES the turn, over the right folder, and that
|
|
212
|
+
// it lands nothing until the turn is its own.
|
|
213
|
+
withFolderTurn: async <T>(_id: string, dir: string, op: () => Promise<T>): Promise<T> => {
|
|
214
|
+
folderTurns.push(dir);
|
|
215
|
+
announceFolderTurnAsked();
|
|
216
|
+
if (folderTurnGate !== undefined) await folderTurnGate;
|
|
217
|
+
turnsHeld++;
|
|
218
|
+
try {
|
|
219
|
+
return await op();
|
|
220
|
+
} finally {
|
|
221
|
+
turnsHeld--;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
} as never,
|
|
225
|
+
workflowService: {} as never,
|
|
226
|
+
events: {} as never,
|
|
227
|
+
getFilesystem: async () => fs,
|
|
228
|
+
});
|
|
229
|
+
const toolHandler =
|
|
230
|
+
options.writeAccess !== undefined
|
|
231
|
+
? createToolHandlerFactory(resolve, options.writeAccess)
|
|
232
|
+
: createToolHandlerFactory(resolve);
|
|
233
|
+
const fakeAuth = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
|
234
|
+
req.toolAuth = { source: 'external', userId, scope: 'write' };
|
|
235
|
+
next();
|
|
236
|
+
};
|
|
237
|
+
const app = express();
|
|
238
|
+
app.use((req, res, next) => (isAgentUploadRawBodyPath(req.path) ? next() : express.json()(req, res, next)));
|
|
239
|
+
const router = express.Router();
|
|
240
|
+
registerWorkspaceTools(
|
|
241
|
+
registry,
|
|
242
|
+
router,
|
|
243
|
+
fakeAuth,
|
|
244
|
+
toolHandler,
|
|
245
|
+
new SpillStore(join(tmpdir(), 'bevel-test-spills')),
|
|
246
|
+
new DocExtractService(docCacheDir),
|
|
247
|
+
access,
|
|
248
|
+
testKbContext({ kbDirName: KB_DIR }),
|
|
249
|
+
{ recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
250
|
+
new RoutineWritePolicyService(),
|
|
251
|
+
{} as never,
|
|
252
|
+
undefined,
|
|
253
|
+
undefined,
|
|
254
|
+
uploads,
|
|
255
|
+
);
|
|
256
|
+
router.use(createAgentUploadRoutes({ uploads }));
|
|
257
|
+
app.use('/api', router);
|
|
258
|
+
const httpServer = await new Promise<HttpServer>((r) => {
|
|
259
|
+
const s = app.listen(0, () => r(s));
|
|
260
|
+
});
|
|
261
|
+
servers.push(httpServer);
|
|
262
|
+
return `http://127.0.0.1:${(httpServer.address() as { port: number }).port}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
afterEach(async () => {
|
|
266
|
+
uploads?.stopSweeping();
|
|
267
|
+
for (const server of servers) await new Promise<void>((r) => server.close(() => r()));
|
|
268
|
+
servers = [];
|
|
269
|
+
for (const dir of [tempDir, uploadsDir, docCacheDir]) {
|
|
270
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
tempDir = '';
|
|
273
|
+
uploadsDir = '';
|
|
274
|
+
docCacheDir = '';
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const call = (base: string, tool: string, body: unknown = {}) =>
|
|
278
|
+
fetch(`${base}/api/agent/tools/${tool}`, {
|
|
279
|
+
method: 'POST',
|
|
280
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer x' },
|
|
281
|
+
body: JSON.stringify(body),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const json = async <T>(res: Response): Promise<T> => (await res.json()) as T;
|
|
285
|
+
|
|
286
|
+
interface Issued {
|
|
287
|
+
uploadUrl: string;
|
|
288
|
+
token: string;
|
|
289
|
+
expiresAt: string;
|
|
290
|
+
expiresInSeconds: number;
|
|
291
|
+
maxBytes: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const request = async (base: string): Promise<Issued> =>
|
|
295
|
+
json<Issued>(await call(base, 'request_file_upload', {}));
|
|
296
|
+
|
|
297
|
+
/** POST bytes to the token's address, exactly as `curl --data-binary` would. */
|
|
298
|
+
const send = (
|
|
299
|
+
base: string,
|
|
300
|
+
token: string,
|
|
301
|
+
filename: string,
|
|
302
|
+
data: Buffer,
|
|
303
|
+
contentType = 'application/octet-stream',
|
|
304
|
+
) =>
|
|
305
|
+
fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}?filename=${encodeURIComponent(filename)}`, {
|
|
306
|
+
method: 'POST',
|
|
307
|
+
headers: { 'content-type': contentType },
|
|
308
|
+
body: data,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
interface ApplyAnswer {
|
|
312
|
+
destination: string;
|
|
313
|
+
count: number;
|
|
314
|
+
total: number;
|
|
315
|
+
files: { path: string; outcome: string; error?: string; message?: string }[];
|
|
316
|
+
truncated?: boolean;
|
|
317
|
+
error?: string;
|
|
318
|
+
proposal?: { targetBranch: string; draftBranch: string; steps: { tool: string }[] };
|
|
319
|
+
canPropose?: boolean;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const apply = async (base: string, body: Record<string, unknown>): Promise<ApplyAnswer> =>
|
|
323
|
+
json<ApplyAnswer>(await call(base, 'apply_file_upload', body));
|
|
324
|
+
|
|
325
|
+
const sha = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex');
|
|
326
|
+
|
|
327
|
+
function zipOf(entries: Record<string, Buffer | string>): Buffer {
|
|
328
|
+
const zip = new AdmZip();
|
|
329
|
+
for (const [name, content] of Object.entries(entries)) {
|
|
330
|
+
zip.addFile(name, Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8'));
|
|
331
|
+
}
|
|
332
|
+
return zip.toBuffer();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A zip whose members keep the names given here VERBATIM. `AdmZip.addFile`
|
|
337
|
+
* sanitizes what it is handed — `../outside.md` goes in as `outside.md` — so a
|
|
338
|
+
* test about an entry name that climbs out has to set the name afterwards, as
|
|
339
|
+
* the tools that write such archives do.
|
|
340
|
+
*/
|
|
341
|
+
function zipWithRawNames(entries: Record<string, Buffer | string>): Buffer {
|
|
342
|
+
const zip = new AdmZip();
|
|
343
|
+
for (const [name, content] of Object.entries(entries)) {
|
|
344
|
+
const placeholder = `__raw_${zip.getEntries().length}`;
|
|
345
|
+
zip.addFile(placeholder, Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8'));
|
|
346
|
+
const added = zip.getEntries().find((e) => e.entryName === placeholder);
|
|
347
|
+
if (!added) throw new Error(`could not add "${name}" to the test archive`);
|
|
348
|
+
added.entryName = name;
|
|
349
|
+
}
|
|
350
|
+
return zip.toBuffer();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A one-entry zip whose headers CLAIM `declaredSize` uncompressed bytes while
|
|
355
|
+
* holding only `content`.
|
|
356
|
+
*
|
|
357
|
+
* What a zip bomb looks like to a reader before it decompresses anything, and
|
|
358
|
+
* the only way to write one in a test without allocating the bytes it claims:
|
|
359
|
+
* the uncompressed-size field is patched in both the local file header and the
|
|
360
|
+
* central directory, which is where every reader takes an entry's size from.
|
|
361
|
+
* An apply that believed the field only after inflating would have to hold the
|
|
362
|
+
* expansion in memory to find out.
|
|
363
|
+
*/
|
|
364
|
+
function zipWithDeclaredSize(name: string, content: string, declaredSize: number): Buffer {
|
|
365
|
+
const buffer = zipOf({ [name]: content });
|
|
366
|
+
// The uncompressed-size field: 22 bytes into a local file header, 24 into a
|
|
367
|
+
// central-directory one (see APPNOTE 4.3.7 and 4.3.12).
|
|
368
|
+
for (const [signature, offset] of [
|
|
369
|
+
[0x04034b50, 22],
|
|
370
|
+
[0x02014b50, 24],
|
|
371
|
+
] as const) {
|
|
372
|
+
for (let at = 0; at + 4 <= buffer.length; at++) {
|
|
373
|
+
if (buffer.readUInt32LE(at) === signature) buffer.writeUInt32LE(declaredSize, at + offset);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return buffer;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** A real PNG: the 1×1 transparent one, so the bytes are a genuine image. */
|
|
380
|
+
const PNG = Buffer.from(
|
|
381
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
|
382
|
+
'base64',
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const outcomeAt = (answer: ApplyAnswer, path: string) => answer.files.find((f) => f.path === path);
|
|
386
|
+
|
|
387
|
+
describe('request_file_upload', () => {
|
|
388
|
+
it('answers a one-time address, a token, the expiry and the size limit', async () => {
|
|
389
|
+
const base = await start();
|
|
390
|
+
const issued = await request(base);
|
|
391
|
+
expect(issued.uploadUrl).toBe(`${PUBLIC_BASE}/api/agent/uploads/${encodeURIComponent(issued.token)}`);
|
|
392
|
+
expect(issued.token.startsWith('bevel-up_')).toBe(true);
|
|
393
|
+
// 32 random bytes, base64url — nothing derived from the user or the clock.
|
|
394
|
+
expect(issued.token.length).toBeGreaterThan(40);
|
|
395
|
+
expect(Date.parse(issued.expiresAt)).toBeGreaterThan(Date.now());
|
|
396
|
+
expect(issued.expiresInSeconds).toBeGreaterThan(0);
|
|
397
|
+
expect(issued.maxBytes).toBe(MAX_UPLOAD_BYTES);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('mints a different token every time', async () => {
|
|
401
|
+
const base = await start();
|
|
402
|
+
const [a, b] = [await request(base), await request(base)];
|
|
403
|
+
expect(a.token).not.toBe(b.token);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* A token is permission to put the deployment's whole upload limit on its
|
|
408
|
+
* disk for a TTL. Unbounded, one caller asks for a hundred and sends against
|
|
409
|
+
* all of them at once.
|
|
410
|
+
*/
|
|
411
|
+
it('refuses another token once one user holds the most they may, and gives one back when it is applied', async () => {
|
|
412
|
+
const base = await start({ maxOpenPerUser: 2 });
|
|
413
|
+
const first = await request(base);
|
|
414
|
+
await request(base);
|
|
415
|
+
const refused = await call(base, 'request_file_upload', {});
|
|
416
|
+
expect(refused.status).toBe(429);
|
|
417
|
+
expect((await json<{ error: string }>(refused)).error).toContain('already hold 2 upload tokens');
|
|
418
|
+
|
|
419
|
+
// Applying one spends it, and its place is free again.
|
|
420
|
+
await send(base, first.token, 'a.md', Buffer.from('a'));
|
|
421
|
+
const landed = await apply(base, { branch: DRAFT, token: first.token, destination: `${KB_DIR}/Drop` });
|
|
422
|
+
expect(outcomeAt(landed, `${KB_DIR}/Drop/a.md`)).toMatchObject({ outcome: 'created' });
|
|
423
|
+
expect((await call(base, 'request_file_upload', {})).status).toBe(200);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* A token whose expiry has passed is not held any more — unless its upload
|
|
428
|
+
* is still arriving. That one keeps a connection and a file open exactly as
|
|
429
|
+
* a live token does, and letting it fall out of the count at expiry let a
|
|
430
|
+
* caller keep a full set of slow uploads going and ask for another.
|
|
431
|
+
*/
|
|
432
|
+
it('counts an upload still arriving as held, past its token\'s expiry', async () => {
|
|
433
|
+
const ttlMs = 1_000;
|
|
434
|
+
const base = await start({ ttlMs, maxOpenPerUser: 1 });
|
|
435
|
+
const { token } = await request(base);
|
|
436
|
+
const issuedAt = Date.now();
|
|
437
|
+
let finish!: () => void;
|
|
438
|
+
const body = new ReadableStream<Uint8Array>({
|
|
439
|
+
async start(controller) {
|
|
440
|
+
controller.enqueue(new Uint8Array([1, 2, 3]));
|
|
441
|
+
await new Promise<void>((resolve) => {
|
|
442
|
+
finish = resolve;
|
|
443
|
+
});
|
|
444
|
+
controller.close();
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
const arriving = fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}?filename=slow.bin`, {
|
|
448
|
+
method: 'POST',
|
|
449
|
+
body,
|
|
450
|
+
// Node's fetch requires this for a streamed request body.
|
|
451
|
+
duplex: 'half',
|
|
452
|
+
} as RequestInit & { duplex: 'half' });
|
|
453
|
+
// The upload has REACHED the store — its first bytes are staged — while the
|
|
454
|
+
// token was still good. Waited for, not assumed from a delay: a request
|
|
455
|
+
// that arrived only after the expiry would be refused at the door, pinned
|
|
456
|
+
// by nothing, and this would be measuring something else.
|
|
457
|
+
const staged = Date.now() + 5_000;
|
|
458
|
+
while ((await readdir(uploadsDir).catch(() => [])).length === 0) {
|
|
459
|
+
expect(Date.now(), 'the upload never reached the store').toBeLessThan(staged);
|
|
460
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
461
|
+
}
|
|
462
|
+
expect(Date.now() - issuedAt, 'the upload arrived after its token had expired').toBeLessThan(ttlMs);
|
|
463
|
+
// Now past the expiry, with the body still open.
|
|
464
|
+
await new Promise((r) => setTimeout(r, Math.max(0, issuedAt + ttlMs + 150 - Date.now())));
|
|
465
|
+
expect((await call(base, 'request_file_upload', {})).status).toBe(429);
|
|
466
|
+
|
|
467
|
+
// Once it has ended (refused: its token expired meanwhile), the place is free.
|
|
468
|
+
finish();
|
|
469
|
+
expect((await arriving).status).toBe(404);
|
|
470
|
+
expect((await call(base, 'request_file_upload', {})).status).toBe(200);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('is on both tool surfaces, with apply_file_upload', async () => {
|
|
474
|
+
await start();
|
|
475
|
+
const names = (await toolRegistry.listExternal()).map((t) => t.name);
|
|
476
|
+
expect(names).toContain('request_file_upload');
|
|
477
|
+
expect(names).toContain('apply_file_upload');
|
|
478
|
+
const internal = (await toolRegistry.listInternal()).map((t) => t.name);
|
|
479
|
+
expect(internal).toContain('request_file_upload');
|
|
480
|
+
expect(internal).toContain('apply_file_upload');
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe('the upload route', () => {
|
|
485
|
+
it('stores the bytes outside every workspace and names what it received', async () => {
|
|
486
|
+
const base = await start();
|
|
487
|
+
const { token } = await request(base);
|
|
488
|
+
const res = await send(base, token, 'notes.md', Buffer.from('hello'));
|
|
489
|
+
expect(res.status).toBe(200);
|
|
490
|
+
expect(await json(res)).toEqual({ filename: 'notes.md', bytes: 5, kind: 'file' });
|
|
491
|
+
// BESIDE the workspaces root, never inside one: the bytes are content the
|
|
492
|
+
// server has accepted and not yet judged.
|
|
493
|
+
expect(uploadsDir.startsWith(tempDir)).toBe(false);
|
|
494
|
+
const stored = await readdir(uploadsDir);
|
|
495
|
+
expect(stored).toHaveLength(1);
|
|
496
|
+
expect(await readFile(join(uploadsDir, stored[0]), 'utf8')).toBe('hello');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('counts a zip\'s entries in the answer', async () => {
|
|
500
|
+
const base = await start();
|
|
501
|
+
const { token } = await request(base);
|
|
502
|
+
const zip = zipOf({ 'SKILL.md': '# Skill\n', 'docs/a.md': 'a', 'logo.png': PNG });
|
|
503
|
+
const res = await send(base, token, 'skill.zip', zip);
|
|
504
|
+
expect(await json(res)).toEqual({ filename: 'skill.zip', bytes: zip.byteLength, kind: 'zip', entries: 3 });
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('needs no session of any kind — the token is the whole credential', async () => {
|
|
508
|
+
const base = await start();
|
|
509
|
+
const { token } = await request(base);
|
|
510
|
+
// No Authorization header at all, which is what an agent's `curl` sends.
|
|
511
|
+
const res = await fetch(`${base}/api/agent/uploads/${token}?filename=x.md`, {
|
|
512
|
+
method: 'POST',
|
|
513
|
+
body: Buffer.from('x'),
|
|
514
|
+
});
|
|
515
|
+
expect(res.status).toBe(200);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('keeps the exact bytes even when the sender claims a JSON content-type', async () => {
|
|
519
|
+
const base = await start();
|
|
520
|
+
const { token } = await request(base);
|
|
521
|
+
// `curl --data-binary @f -H 'content-type: application/json'` — the global
|
|
522
|
+
// JSON parser must not have drained the stream (isAgentUploadRawBodyPath).
|
|
523
|
+
const body = Buffer.from('{"not":"json at all\\u0000"}\u0000\u0001', 'utf8');
|
|
524
|
+
const res = await send(base, token, 'raw.bin', body, 'application/json');
|
|
525
|
+
expect(await json(res)).toMatchObject({ bytes: body.byteLength });
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('refuses an upload over the limit, naming the limit', async () => {
|
|
529
|
+
const base = await start({ maxBytes: 1024 });
|
|
530
|
+
const { token, maxBytes } = await request(base);
|
|
531
|
+
expect(maxBytes).toBe(1024);
|
|
532
|
+
const res = await send(base, token, 'big.bin', Buffer.alloc(2048, 7));
|
|
533
|
+
expect(res.status).toBe(413);
|
|
534
|
+
expect((await json<{ error: string }>(res)).error).toContain('1024 byte upload limit');
|
|
535
|
+
expect(await readdir(uploadsDir)).toEqual([]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('refuses a second upload against the same token', async () => {
|
|
539
|
+
const base = await start();
|
|
540
|
+
const { token } = await request(base);
|
|
541
|
+
expect((await send(base, token, 'a.md', Buffer.from('a'))).status).toBe(200);
|
|
542
|
+
const second = await send(base, token, 'b.md', Buffer.from('b'));
|
|
543
|
+
expect(second.status).toBe(404);
|
|
544
|
+
expect((await json<{ error: string }>(second)).error).toBe(UPLOAD_TOKEN_REFUSAL);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('refuses an unknown token with one message that says nothing about it', async () => {
|
|
548
|
+
const base = await start();
|
|
549
|
+
const res = await send(base, 'bevel-up_nope', 'a.md', Buffer.from('a'));
|
|
550
|
+
expect(res.status).toBe(404);
|
|
551
|
+
expect((await json<{ error: string }>(res)).error).toBe(UPLOAD_TOKEN_REFUSAL);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('tells an unknown token nothing about its OTHER mistakes either', async () => {
|
|
555
|
+
const base = await start();
|
|
556
|
+
// No file name at all, and a name no filesystem would keep — both
|
|
557
|
+
// ordinarily a 400 that says which. Against a token that does not exist
|
|
558
|
+
// they get the one refusal instead: "that is not a usable file name" is an
|
|
559
|
+
// answer only somebody entitled to send a file should be able to collect.
|
|
560
|
+
for (const url of [
|
|
561
|
+
`${base}/api/agent/uploads/bevel-up_nope`,
|
|
562
|
+
`${base}/api/agent/uploads/bevel-up_nope?filename=${encodeURIComponent('../escape.md')}`,
|
|
563
|
+
]) {
|
|
564
|
+
const res = await fetch(url, { method: 'POST', body: Buffer.from('x') });
|
|
565
|
+
expect(res.status).toBe(404);
|
|
566
|
+
expect((await json<{ error: string }>(res)).error).toBe(UPLOAD_TOKEN_REFUSAL);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('does not read a parameter that merely ENDS in "filename" as the name', async () => {
|
|
571
|
+
const base = await start();
|
|
572
|
+
const { token } = await request(base);
|
|
573
|
+
// `xfilename` is a parameter of its own, and `filename` is its suffix. The
|
|
574
|
+
// name is asked for instead of taken from it.
|
|
575
|
+
const res = await fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}`, {
|
|
576
|
+
method: 'POST',
|
|
577
|
+
headers: { 'content-disposition': 'inline; xfilename=wrong.md' },
|
|
578
|
+
body: Buffer.from('x'),
|
|
579
|
+
});
|
|
580
|
+
expect(res.status).toBe(400);
|
|
581
|
+
expect((await json<{ error: string }>(res)).error).toContain('filename');
|
|
582
|
+
expect(await readdir(uploadsDir)).toEqual([]);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('asks for a file name when none was given', async () => {
|
|
586
|
+
const base = await start();
|
|
587
|
+
const { token } = await request(base);
|
|
588
|
+
const res = await fetch(`${base}/api/agent/uploads/${token}`, { method: 'POST', body: Buffer.from('x') });
|
|
589
|
+
expect(res.status).toBe(400);
|
|
590
|
+
expect((await json<{ error: string }>(res)).error).toContain('filename');
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('refuses a file name that is a path', async () => {
|
|
594
|
+
const base = await start();
|
|
595
|
+
const { token } = await request(base);
|
|
596
|
+
const res = await send(base, token, '../escape.md', Buffer.from('x'));
|
|
597
|
+
expect(res.status).toBe(400);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('refuses the second of two uploads sent against one token at the same time', async () => {
|
|
601
|
+
const base = await start();
|
|
602
|
+
const { token } = await request(base);
|
|
603
|
+
// Both in flight before either has finished: the token is reserved before
|
|
604
|
+
// the first `await` of the attach, so exactly one of them is accepted and
|
|
605
|
+
// the other meets the one refusal. Without the reservation both stored
|
|
606
|
+
// their bytes and the apply landed whichever finished last.
|
|
607
|
+
const [first, second] = await Promise.all([
|
|
608
|
+
send(base, token, 'first.md', Buffer.from('first')),
|
|
609
|
+
send(base, token, 'second.md', Buffer.from('second')),
|
|
610
|
+
]);
|
|
611
|
+
const statuses = [first.status, second.status].sort();
|
|
612
|
+
expect(statuses).toEqual([200, 404]);
|
|
613
|
+
expect(await readdir(uploadsDir)).toHaveLength(1);
|
|
614
|
+
// And the file the accepted answer named is the file the apply lands.
|
|
615
|
+
const accepted = first.status === 200 ? first : second;
|
|
616
|
+
const { filename } = await json<{ filename: string }>(accepted);
|
|
617
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Race` });
|
|
618
|
+
expect(answer.files).toEqual([{ path: `${KB_DIR}/Race/${filename}`, outcome: 'created' }]);
|
|
619
|
+
expect(await readFile(join(tempDir, KB_DIR, 'Race', filename), 'utf8')).toBe(filename.replace('.md', ''));
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('refuses an unknown token before it has read a single byte of the body', async () => {
|
|
623
|
+
const base = await start({ maxBytes: 64 });
|
|
624
|
+
// Over the limit AND against a token that does not exist. The 404 proves
|
|
625
|
+
// the token was judged first: had the body been buffered, the size check
|
|
626
|
+
// inside the read would have answered 413 instead. That ordering is what
|
|
627
|
+
// keeps an invented token from making the process hold the deployment's
|
|
628
|
+
// whole upload limit in memory.
|
|
629
|
+
const res = await fetch(`${base}/api/agent/uploads/bevel-up_invented?filename=big.md`, {
|
|
630
|
+
method: 'POST',
|
|
631
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
632
|
+
body: Buffer.alloc(200, 7),
|
|
633
|
+
});
|
|
634
|
+
expect(res.status).toBe(404);
|
|
635
|
+
expect((await json<{ error: string }>(res)).error).toBe(UPLOAD_TOKEN_REFUSAL);
|
|
636
|
+
expect(await readdir(uploadsDir)).toEqual([]);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('keeps a quoted content-disposition name that holds a semicolon', async () => {
|
|
640
|
+
const base = await start();
|
|
641
|
+
const { token } = await request(base);
|
|
642
|
+
const res = await fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}`, {
|
|
643
|
+
method: 'POST',
|
|
644
|
+
headers: { 'content-disposition': 'attachment; filename="report;final.md"' },
|
|
645
|
+
body: Buffer.from('r'),
|
|
646
|
+
});
|
|
647
|
+
expect(await json<{ filename: string }>(res)).toMatchObject({ filename: 'report;final.md' });
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('reads the extended content-disposition name, percent-decoded', async () => {
|
|
651
|
+
const base = await start();
|
|
652
|
+
const { token } = await request(base);
|
|
653
|
+
const res = await fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}`, {
|
|
654
|
+
method: 'POST',
|
|
655
|
+
headers: { 'content-disposition': "attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.md" },
|
|
656
|
+
body: Buffer.from('r'),
|
|
657
|
+
});
|
|
658
|
+
expect(await json<{ filename: string }>(res)).toMatchObject({ filename: 'résumé.md' });
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('reads a plain content-disposition name as the name itself, percent signs and all', async () => {
|
|
662
|
+
const base = await start();
|
|
663
|
+
const { token } = await request(base);
|
|
664
|
+
const res = await fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}`, {
|
|
665
|
+
method: 'POST',
|
|
666
|
+
headers: { 'content-disposition': 'attachment; filename=50%20off.md' },
|
|
667
|
+
body: Buffer.from('r'),
|
|
668
|
+
});
|
|
669
|
+
expect(await json<{ filename: string }>(res)).toMatchObject({ filename: '50%20off.md' });
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('answers an unexpected failure without quoting the filesystem back', async () => {
|
|
673
|
+
const base = await start();
|
|
674
|
+
const { token } = await request(base);
|
|
675
|
+
// The store's root replaced by a FILE, so the attach's `mkdir` fails with
|
|
676
|
+
// an ENOTDIR whose message carries the absolute path of the staging root —
|
|
677
|
+
// a place the caller is told nothing else about.
|
|
678
|
+
await rm(uploadsDir, { recursive: true, force: true });
|
|
679
|
+
await writeFile(uploadsDir, 'not a directory');
|
|
680
|
+
const res = await send(base, token, 'a.md', Buffer.from('a'));
|
|
681
|
+
expect(res.status).toBe(500);
|
|
682
|
+
expect(await json<{ error: string }>(res)).toEqual({ error: 'Upload failed' });
|
|
683
|
+
// Removed here, because `afterEach` deletes a directory, not a file.
|
|
684
|
+
await rm(uploadsDir, { force: true });
|
|
685
|
+
uploadsDir = '';
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('refuses a .zip whose bytes are not an archive, while the caller still holds the file', async () => {
|
|
689
|
+
const base = await start();
|
|
690
|
+
const { token } = await request(base);
|
|
691
|
+
const res = await send(base, token, 'broken.zip', Buffer.from('not a zip at all'));
|
|
692
|
+
expect(res.status).toBe(422);
|
|
693
|
+
expect((await json<{ error: string }>(res)).error).toContain('not a readable .zip');
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
describe('apply_file_upload', () => {
|
|
698
|
+
it('lands a zip\'s entries in ONE commit, each created, keeping the folder structure', async () => {
|
|
699
|
+
const base = await start();
|
|
700
|
+
const { token } = await request(base);
|
|
701
|
+
await send(base, token, 'skill.zip', zipOf({
|
|
702
|
+
'SKILL.md': '# My skill\n',
|
|
703
|
+
'references/guide.md': 'guide\n',
|
|
704
|
+
'assets/logo.png': PNG,
|
|
705
|
+
}));
|
|
706
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Skills/my-skill` });
|
|
707
|
+
expect(answer.destination).toBe(`${KB_DIR}/Skills/my-skill`);
|
|
708
|
+
expect(answer.total).toBe(3);
|
|
709
|
+
expect(answer.count).toBe(3);
|
|
710
|
+
expect(answer.files.map((f) => f.outcome)).toEqual(['created', 'created', 'created']);
|
|
711
|
+
expect(answer.files.map((f) => f.path).sort()).toEqual([
|
|
712
|
+
`${KB_DIR}/Skills/my-skill/SKILL.md`,
|
|
713
|
+
`${KB_DIR}/Skills/my-skill/assets/logo.png`,
|
|
714
|
+
`${KB_DIR}/Skills/my-skill/references/guide.md`,
|
|
715
|
+
]);
|
|
716
|
+
// One batch, so one commit for the whole upload.
|
|
717
|
+
expect(batches).toHaveLength(1);
|
|
718
|
+
expect(batches[0]).toHaveLength(3);
|
|
719
|
+
expect(await readFile(join(tempDir, KB_DIR, 'Skills/my-skill/references/guide.md'), 'utf8')).toBe('guide\n');
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it('lands a single file under the name it was sent with', async () => {
|
|
723
|
+
const base = await start();
|
|
724
|
+
const { token } = await request(base);
|
|
725
|
+
await send(base, token, 'page.html', Buffer.from('<p>hi</p>'));
|
|
726
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Pages` });
|
|
727
|
+
expect(answer.files).toEqual([{ path: `${KB_DIR}/Pages/page.html`, outcome: 'created' }]);
|
|
728
|
+
expect(await readFile(join(tempDir, KB_DIR, 'Pages/page.html'), 'utf8')).toBe('<p>hi</p>');
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// Scenario: the zip holds SKILL.md and SKILL.md already exists, `mode` left out.
|
|
732
|
+
it('refuses an existing path with `exists` under the default mode, and lands the others', async () => {
|
|
733
|
+
const base = await start();
|
|
734
|
+
await mkdir(join(tempDir, KB_DIR, 'Skills/my-skill'), { recursive: true });
|
|
735
|
+
await writeFile(join(tempDir, KB_DIR, 'Skills/my-skill/SKILL.md'), 'mine\n');
|
|
736
|
+
const { token } = await request(base);
|
|
737
|
+
await send(base, token, 'skill.zip', zipOf({ 'SKILL.md': '# new\n', 'extra.md': 'extra\n' }));
|
|
738
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Skills/my-skill` });
|
|
739
|
+
expect(answer.count).toBe(1);
|
|
740
|
+
expect(answer.total).toBe(2);
|
|
741
|
+
expect(outcomeAt(answer, `${KB_DIR}/Skills/my-skill/SKILL.md`)).toMatchObject({
|
|
742
|
+
outcome: 'refused',
|
|
743
|
+
error: 'exists',
|
|
744
|
+
});
|
|
745
|
+
expect(outcomeAt(answer, `${KB_DIR}/Skills/my-skill/extra.md`)).toMatchObject({ outcome: 'created' });
|
|
746
|
+
// The refused path is left exactly as it was.
|
|
747
|
+
expect(await readFile(join(tempDir, KB_DIR, 'Skills/my-skill/SKILL.md'), 'utf8')).toBe('mine\n');
|
|
748
|
+
expect(await readFile(join(tempDir, KB_DIR, 'Skills/my-skill/extra.md'), 'utf8')).toBe('extra\n');
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it('replaces an existing path with `mode: overwrite`', async () => {
|
|
752
|
+
const base = await start();
|
|
753
|
+
await mkdir(join(tempDir, KB_DIR, 'Skills/my-skill'), { recursive: true });
|
|
754
|
+
await writeFile(join(tempDir, KB_DIR, 'Skills/my-skill/SKILL.md'), 'mine\n');
|
|
755
|
+
const { token } = await request(base);
|
|
756
|
+
await send(base, token, 'skill.zip', zipOf({ 'SKILL.md': '# new\n' }));
|
|
757
|
+
const answer = await apply(base, {
|
|
758
|
+
branch: DRAFT,
|
|
759
|
+
token,
|
|
760
|
+
destination: `${KB_DIR}/Skills/my-skill`,
|
|
761
|
+
mode: 'overwrite',
|
|
762
|
+
});
|
|
763
|
+
expect(answer.files).toEqual([{ path: `${KB_DIR}/Skills/my-skill/SKILL.md`, outcome: 'replaced' }]);
|
|
764
|
+
expect(await readFile(join(tempDir, KB_DIR, 'Skills/my-skill/SKILL.md'), 'utf8')).toBe('# new\n');
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it('`mode: update` refuses a path that does not exist and updates one that does', async () => {
|
|
768
|
+
const base = await start();
|
|
769
|
+
await mkdir(join(tempDir, KB_DIR, 'Pages'), { recursive: true });
|
|
770
|
+
await writeFile(join(tempDir, KB_DIR, 'Pages/there.md'), 'old\n');
|
|
771
|
+
const { token } = await request(base);
|
|
772
|
+
await send(base, token, 'pair.zip', zipOf({ 'there.md': 'new\n', 'missing.md': 'nope\n' }));
|
|
773
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Pages`, mode: 'update' });
|
|
774
|
+
expect(outcomeAt(answer, `${KB_DIR}/Pages/there.md`)).toMatchObject({ outcome: 'updated' });
|
|
775
|
+
expect(outcomeAt(answer, `${KB_DIR}/Pages/missing.md`)).toMatchObject({
|
|
776
|
+
outcome: 'refused',
|
|
777
|
+
error: 'missing',
|
|
778
|
+
});
|
|
779
|
+
expect(await readFile(join(tempDir, KB_DIR, 'Pages/there.md'), 'utf8')).toBe('new\n');
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it('refuses a mode that is not one of the three', async () => {
|
|
783
|
+
const base = await start();
|
|
784
|
+
const { token } = await request(base);
|
|
785
|
+
await send(base, token, 'a.md', Buffer.from('a'));
|
|
786
|
+
const res = await call(base, 'apply_file_upload', {
|
|
787
|
+
branch: DRAFT,
|
|
788
|
+
token,
|
|
789
|
+
destination: `${KB_DIR}/Pages`,
|
|
790
|
+
mode: 'replace',
|
|
791
|
+
});
|
|
792
|
+
expect(res.status).toBe(400);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// Scenario: a 40 KB page of unicode escapes, backslashes and quotes — the
|
|
796
|
+
// content a JSON tool parameter cannot carry — lands with its own checksum.
|
|
797
|
+
it('lands an escape-heavy text file byte for byte', async () => {
|
|
798
|
+
const base = await start();
|
|
799
|
+
const line = 'const re = /\\u00e9\\\\[a-z]+"quoted"\\n\\t/g; // "\\u2028" \\\\ \'é中文
\n';
|
|
800
|
+
const page = Buffer.from(line.repeat(Math.ceil(40 * 1024 / line.length)), 'utf8');
|
|
801
|
+
expect(page.byteLength).toBeGreaterThan(40 * 1024);
|
|
802
|
+
const before = sha(page);
|
|
803
|
+
const { token } = await request(base);
|
|
804
|
+
await send(base, token, 'page.html', page);
|
|
805
|
+
await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Pages` });
|
|
806
|
+
const landed = await readFile(join(tempDir, KB_DIR, 'Pages/page.html'));
|
|
807
|
+
expect(sha(landed)).toBe(before);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it('lands a PNG byte for byte', async () => {
|
|
811
|
+
const base = await start();
|
|
812
|
+
const before = sha(PNG);
|
|
813
|
+
const { token } = await request(base);
|
|
814
|
+
await send(base, token, 'logo.png', PNG);
|
|
815
|
+
await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Pages/assets` });
|
|
816
|
+
const landed = await readFile(join(tempDir, KB_DIR, 'Pages/assets/logo.png'));
|
|
817
|
+
expect(sha(landed)).toBe(before);
|
|
818
|
+
expect(landed.equals(PNG)).toBe(true);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it('lands a PNG and a text file out of one zip, both with their own checksums', async () => {
|
|
822
|
+
const base = await start();
|
|
823
|
+
const markdown = Buffer.from('Backslashes \\\\ and "quotes" and \\u00e9 as text.\n', 'utf8');
|
|
824
|
+
const { token } = await request(base);
|
|
825
|
+
await send(base, token, 'mixed.zip', zipOf({ 'logo.png': PNG, 'notes.md': markdown }));
|
|
826
|
+
await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Mixed` });
|
|
827
|
+
expect(sha(await readFile(join(tempDir, KB_DIR, 'Mixed/logo.png')))).toBe(sha(PNG));
|
|
828
|
+
expect(sha(await readFile(join(tempDir, KB_DIR, 'Mixed/notes.md')))).toBe(sha(markdown));
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// Scenario: the destination is on a protected branch the caller may not
|
|
832
|
+
// write directly.
|
|
833
|
+
it('lands nothing on a protected branch it may not write, and names the change-request route', async () => {
|
|
834
|
+
const base = await start({ access: denyWritesAtHead(() => true) });
|
|
835
|
+
const { token } = await request(base);
|
|
836
|
+
await send(base, token, 'skill.zip', zipOf({ 'a.md': 'a', 'b.md': 'b' }));
|
|
837
|
+
const res = await call(base, 'apply_file_upload', {
|
|
838
|
+
branch: PROTECTED,
|
|
839
|
+
token,
|
|
840
|
+
destination: `${KB_DIR}/Skills/my-skill`,
|
|
841
|
+
});
|
|
842
|
+
expect(res.status).toBe(403);
|
|
843
|
+
const body = await json<ApplyAnswer & { kind?: string }>(res);
|
|
844
|
+
expect(body.kind).toBe('write-denied');
|
|
845
|
+
expect(body.canPropose).toBe(true);
|
|
846
|
+
expect(body.proposal?.steps.map((s) => s.tool)).toEqual([
|
|
847
|
+
'create_branch',
|
|
848
|
+
'apply_file_upload',
|
|
849
|
+
'open_change_request',
|
|
850
|
+
]);
|
|
851
|
+
expect(batches).toEqual([]);
|
|
852
|
+
// The token survives a refusal that landed nothing, so the bytes need not
|
|
853
|
+
// be sent again to try a draft branch instead.
|
|
854
|
+
const retry = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Skills/my-skill` });
|
|
855
|
+
expect(retry.count).toBe(2);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
// Scenario: one entry targets a folder the caller may not write.
|
|
859
|
+
it('refuses the one entry whose own path is denied, and lands the others', async () => {
|
|
860
|
+
const base = await start({ access: denyWritesAtHead((rel) => rel.startsWith('Locked/')) });
|
|
861
|
+
const { token } = await request(base);
|
|
862
|
+
await send(base, token, 'drop.zip', zipOf({ 'open.md': 'open', 'Locked/secret.md': 'secret' }));
|
|
863
|
+
const answer = await apply(base, { branch: PROTECTED, token, destination: KB_DIR });
|
|
864
|
+
expect(outcomeAt(answer, `${KB_DIR}/Locked/secret.md`)).toMatchObject({
|
|
865
|
+
outcome: 'refused',
|
|
866
|
+
error: 'write-denied',
|
|
867
|
+
});
|
|
868
|
+
expect(outcomeAt(answer, `${KB_DIR}/Locked/secret.md`)?.message).toContain('Eligible:');
|
|
869
|
+
expect(outcomeAt(answer, `${KB_DIR}/open.md`)).toMatchObject({ outcome: 'created' });
|
|
870
|
+
expect(answer.count).toBe(1);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
// Scenario: an entry named `access.md`, or one that would create a platform
|
|
874
|
+
// folder.
|
|
875
|
+
it('refuses a platform file with the platform-file sentence, and lands the others', async () => {
|
|
876
|
+
const base = await start();
|
|
877
|
+
const { token } = await request(base);
|
|
878
|
+
await send(base, token, 'drop.zip', zipOf({
|
|
879
|
+
'access.md': '---\nread: everyone\n---\n',
|
|
880
|
+
'roles.yaml': 'Admin:\n - e@x\n',
|
|
881
|
+
'ok.md': 'ok',
|
|
882
|
+
}));
|
|
883
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: KB_DIR });
|
|
884
|
+
expect(outcomeAt(answer, `${KB_DIR}/access.md`)).toMatchObject({
|
|
885
|
+
outcome: 'refused',
|
|
886
|
+
error: 'platform_file',
|
|
887
|
+
message: 'access.md is a platform file and is never landed by an upload — change it with edit_file or write_file, where the change is checked.',
|
|
888
|
+
});
|
|
889
|
+
expect(outcomeAt(answer, `${KB_DIR}/roles.yaml`)).toMatchObject({ outcome: 'refused', error: 'platform_file' });
|
|
890
|
+
expect(outcomeAt(answer, `${KB_DIR}/ok.md`)).toMatchObject({ outcome: 'created' });
|
|
891
|
+
expect(await readdir(join(tempDir, KB_DIR))).toEqual(['ok.md']);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it('refuses an entry that would create the checkout folder\'s own name at the root', async () => {
|
|
895
|
+
const base = await start();
|
|
896
|
+
const { token } = await request(base);
|
|
897
|
+
await send(base, token, 'drop.zip', zipOf({ [`${KB_DIR}/inner.md`]: 'inner', 'ok.md': 'ok' }));
|
|
898
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: KB_DIR });
|
|
899
|
+
expect(outcomeAt(answer, `${KB_DIR}/${KB_DIR}/inner.md`)).toMatchObject({
|
|
900
|
+
outcome: 'refused',
|
|
901
|
+
error: 'reserved-root-name',
|
|
902
|
+
});
|
|
903
|
+
expect(outcomeAt(answer, `${KB_DIR}/ok.md`)).toMatchObject({ outcome: 'created' });
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// Scenario: a zip entry is `../outside.md` or a symbolic link.
|
|
907
|
+
it('refuses an entry that would land outside the destination, and lands the others', async () => {
|
|
908
|
+
const base = await start();
|
|
909
|
+
const { token } = await request(base);
|
|
910
|
+
await send(base, token, 'drop.zip', zipWithRawNames({
|
|
911
|
+
'../outside.md': 'escaped',
|
|
912
|
+
'/rooted.md': 'rooted',
|
|
913
|
+
'inside.md': 'inside',
|
|
914
|
+
}));
|
|
915
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
|
|
916
|
+
expect(answer.total).toBe(3);
|
|
917
|
+
expect(answer.count).toBe(1);
|
|
918
|
+
expect(outcomeAt(answer, '../outside.md')).toMatchObject({ outcome: 'refused', error: 'invalid_entry' });
|
|
919
|
+
expect(outcomeAt(answer, '/rooted.md')).toMatchObject({ outcome: 'refused', error: 'invalid_entry' });
|
|
920
|
+
expect(outcomeAt(answer, `${KB_DIR}/Drop/inside.md`)).toMatchObject({ outcome: 'created' });
|
|
921
|
+
// Nothing landed beside the destination.
|
|
922
|
+
expect(await readdir(join(tempDir, KB_DIR, 'Drop'))).toEqual(['inside.md']);
|
|
923
|
+
expect(await readdir(join(tempDir, KB_DIR))).toEqual(['Drop']);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('refuses a zip entry that is a symbolic link, and lands the others', async () => {
|
|
927
|
+
const base = await start();
|
|
928
|
+
const { token } = await request(base);
|
|
929
|
+
const zip = new AdmZip();
|
|
930
|
+
zip.addFile('link.md', Buffer.from('../../etc/passwd'));
|
|
931
|
+
// The unix mode a zip records for a symbolic link (`S_IFLNK | 0777`), in
|
|
932
|
+
// the high half of the external attributes.
|
|
933
|
+
const entry = zip.getEntry('link.md');
|
|
934
|
+
if (entry) entry.header.attr = ((0o120000 | 0o777) << 16) >>> 0;
|
|
935
|
+
zip.addFile('real.md', Buffer.from('real'));
|
|
936
|
+
await send(base, token, 'links.zip', zip.toBuffer());
|
|
937
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
|
|
938
|
+
expect(outcomeAt(answer, 'link.md')).toMatchObject({ outcome: 'refused', error: 'link' });
|
|
939
|
+
expect(outcomeAt(answer, `${KB_DIR}/Drop/real.md`)).toMatchObject({ outcome: 'created' });
|
|
940
|
+
expect(await readdir(join(tempDir, KB_DIR, 'Drop'))).toEqual(['real.md']);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it('refuses an entry whose path goes through a symbolic link already on disk', async () => {
|
|
944
|
+
const base = await start();
|
|
945
|
+
await mkdir(join(tempDir, KB_DIR, 'Real'), { recursive: true });
|
|
946
|
+
await mkdir(join(tempDir, 'outside'), { recursive: true });
|
|
947
|
+
await symlink(join(tempDir, 'outside'), join(tempDir, KB_DIR, 'Away'), 'dir');
|
|
948
|
+
const { token } = await request(base);
|
|
949
|
+
await send(base, token, 'drop.zip', zipOf({ 'Away/x.md': 'away', 'Real/y.md': 'real' }));
|
|
950
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: KB_DIR });
|
|
951
|
+
expect(outcomeAt(answer, `${KB_DIR}/Away/x.md`)).toMatchObject({ outcome: 'refused', error: 'symlink' });
|
|
952
|
+
expect(outcomeAt(answer, `${KB_DIR}/Real/y.md`)).toMatchObject({ outcome: 'created' });
|
|
953
|
+
expect(await readdir(join(tempDir, 'outside'))).toEqual([]);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it('refuses an entry naming the git folder, and lands the others', async () => {
|
|
957
|
+
const base = await start();
|
|
958
|
+
const { token } = await request(base);
|
|
959
|
+
await send(base, token, 'drop.zip', zipOf({ '.git/config': 'evil', 'fine.md': 'fine' }));
|
|
960
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
|
|
961
|
+
expect(outcomeAt(answer, '.git/config')).toMatchObject({ outcome: 'refused', error: 'invalid_entry' });
|
|
962
|
+
expect(outcomeAt(answer, `${KB_DIR}/Drop/fine.md`)).toMatchObject({ outcome: 'created' });
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it('refuses a SINGLE file named after the git folder as that path\'s own outcome', async () => {
|
|
966
|
+
const base = await start();
|
|
967
|
+
const { token } = await request(base);
|
|
968
|
+
// `.git` is a name `validateFilename` accepts, so the upload route lets it
|
|
969
|
+
// through; it reaches the apply as a name of the upload's, not of the
|
|
970
|
+
// call's, so the preflight that reads the caller's arguments never sees it.
|
|
971
|
+
// Judged per path, like every other refusal, rather than failing the whole
|
|
972
|
+
// apply from inside the batch write.
|
|
973
|
+
await send(base, token, '.git', Buffer.from('evil'));
|
|
974
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
|
|
975
|
+
expect(answer.count).toBe(0);
|
|
976
|
+
expect(outcomeAt(answer, `${KB_DIR}/Drop/.git`)).toMatchObject({
|
|
977
|
+
outcome: 'refused',
|
|
978
|
+
error: 'git-internals',
|
|
979
|
+
});
|
|
980
|
+
expect(batches).toEqual([]);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it('refuses an entry that CLAIMS more than one commit lands, without inflating it', async () => {
|
|
984
|
+
const base = await start();
|
|
985
|
+
const { token } = await request(base);
|
|
986
|
+
// 200 MB declared against a 128 MB apply budget. Judged on the header,
|
|
987
|
+
// before `getData`, because a deflate stream expands by three orders of
|
|
988
|
+
// magnitude and an entry inflated to be measured is an entry already held
|
|
989
|
+
// in memory. The bytes behind this header are a handful.
|
|
990
|
+
const bomb = zipWithDeclaredSize('bomb.md', 'tiny', 200 * 1024 * 1024);
|
|
991
|
+
await send(base, token, 'bomb.zip', bomb);
|
|
992
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Bomb` });
|
|
993
|
+
expect(outcomeAt(answer, 'bomb.md')).toMatchObject({ outcome: 'refused', error: 'too_large' });
|
|
994
|
+
expect(outcomeAt(answer, 'bomb.md')?.message).toContain('one commit');
|
|
995
|
+
expect(batches).toEqual([]);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* The other way to lie about a size. The archive reader caps an inflation at
|
|
1000
|
+
* the size the header declares — but only when that size is above zero, so an
|
|
1001
|
+
* entry declaring ZERO passed the check above and was then inflated with no
|
|
1002
|
+
* cap at all: four megabytes out of four kilobytes here, tens of gigabytes
|
|
1003
|
+
* out of an upload at the limit.
|
|
1004
|
+
*/
|
|
1005
|
+
it('refuses an entry whose header says it is empty and whose stream is not', async () => {
|
|
1006
|
+
const base = await start();
|
|
1007
|
+
const { token } = await request(base);
|
|
1008
|
+
const bomb = zipWithDeclaredSize('bomb.md', 'a'.repeat(4 * 1024 * 1024), 0);
|
|
1009
|
+
expect(bomb.byteLength).toBeLessThan(16 * 1024);
|
|
1010
|
+
await send(base, token, 'bomb.zip', bomb);
|
|
1011
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Bomb` });
|
|
1012
|
+
expect(outcomeAt(answer, 'bomb.md')).toMatchObject({ outcome: 'refused', error: 'unreadable_entry' });
|
|
1013
|
+
expect(outcomeAt(answer, 'bomb.md')?.message).toContain('declares an empty file');
|
|
1014
|
+
expect(answer.count).toBe(0);
|
|
1015
|
+
expect(batches).toEqual([]);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it('lands a file that really is empty', async () => {
|
|
1019
|
+
const base = await start();
|
|
1020
|
+
const { token } = await request(base);
|
|
1021
|
+
await send(base, token, 'drop.zip', zipOf({ 'empty.md': '', 'full.md': 'x' }));
|
|
1022
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
|
|
1023
|
+
expect(outcomeAt(answer, `${KB_DIR}/Drop/empty.md`)).toMatchObject({ outcome: 'created' });
|
|
1024
|
+
expect(outcomeAt(answer, `${KB_DIR}/Drop/full.md`)).toMatchObject({ outcome: 'created' });
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it('refuses the one entry that fails its checksum, and lands the others', async () => {
|
|
1028
|
+
const base = await start();
|
|
1029
|
+
const { token } = await request(base);
|
|
1030
|
+
const archive = zipOf({ 'bad.md': 'this entry will not match its checksum', 'fine.md': 'fine' });
|
|
1031
|
+
// The CRC field of the FIRST entry, in both of its headers: 14 bytes into
|
|
1032
|
+
// the local one, 16 into the central-directory one. A reader checks one or
|
|
1033
|
+
// the other depending on the entry's flags.
|
|
1034
|
+
const firstLocal = archive.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04]));
|
|
1035
|
+
const firstCentral = archive.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
|
|
1036
|
+
archive.writeUInt32LE((archive.readUInt32LE(firstLocal + 14) ^ 0xffffffff) >>> 0, firstLocal + 14);
|
|
1037
|
+
archive.writeUInt32LE((archive.readUInt32LE(firstCentral + 16) ^ 0xffffffff) >>> 0, firstCentral + 16);
|
|
1038
|
+
await send(base, token, 'drop.zip', archive);
|
|
1039
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
|
|
1040
|
+
expect(outcomeAt(answer, 'bad.md')).toMatchObject({ outcome: 'refused', error: 'unreadable_entry' });
|
|
1041
|
+
expect(outcomeAt(answer, `${KB_DIR}/Drop/fine.md`)).toMatchObject({ outcome: 'created' });
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// Scenario: a zip holds 60 entries.
|
|
1045
|
+
it('lists 25 paths with the total, and all of them when asked', async () => {
|
|
1046
|
+
const base = await start();
|
|
1047
|
+
const { token } = await request(base);
|
|
1048
|
+
const entries: Record<string, string> = {};
|
|
1049
|
+
for (let i = 0; i < 60; i++) entries[`f${String(i).padStart(2, '0')}.md`] = `file ${i}\n`;
|
|
1050
|
+
await send(base, token, 'many.zip', zipOf(entries));
|
|
1051
|
+
const cut = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Many` });
|
|
1052
|
+
expect(cut.total).toBe(60);
|
|
1053
|
+
expect(cut.count).toBe(60);
|
|
1054
|
+
expect(cut.files).toHaveLength(25);
|
|
1055
|
+
expect(cut.truncated).toBe(true);
|
|
1056
|
+
// Every one of the 60 landed, even though only 25 are named.
|
|
1057
|
+
expect(await readdir(join(tempDir, KB_DIR, 'Many'))).toHaveLength(60);
|
|
1058
|
+
|
|
1059
|
+
const { token: second } = await request(base);
|
|
1060
|
+
await send(base, second, 'many.zip', zipOf(entries));
|
|
1061
|
+
const all = await apply(base, {
|
|
1062
|
+
branch: DRAFT,
|
|
1063
|
+
token: second,
|
|
1064
|
+
destination: `${KB_DIR}/Many`,
|
|
1065
|
+
mode: 'overwrite',
|
|
1066
|
+
all: true,
|
|
1067
|
+
});
|
|
1068
|
+
expect(all.total).toBe(60);
|
|
1069
|
+
expect(all.files).toHaveLength(60);
|
|
1070
|
+
expect(all.truncated).toBeUndefined();
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it('drops a macOS archive\'s sidecar entries without reporting them', async () => {
|
|
1074
|
+
const base = await start();
|
|
1075
|
+
const { token } = await request(base);
|
|
1076
|
+
await send(base, token, 'mac.zip', zipOf({
|
|
1077
|
+
'__MACOSX/._SKILL.md': 'junk',
|
|
1078
|
+
'.DS_Store': 'junk',
|
|
1079
|
+
'SKILL.md': '# real\n',
|
|
1080
|
+
}));
|
|
1081
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Drop` });
|
|
1082
|
+
expect(answer.total).toBe(1);
|
|
1083
|
+
expect(answer.files).toEqual([{ path: `${KB_DIR}/Drop/SKILL.md`, outcome: 'created' }]);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it('refuses a destination that is a file, not a folder', async () => {
|
|
1087
|
+
const base = await start();
|
|
1088
|
+
await mkdir(join(tempDir, KB_DIR), { recursive: true });
|
|
1089
|
+
await writeFile(join(tempDir, KB_DIR, 'page.md'), 'page');
|
|
1090
|
+
const { token } = await request(base);
|
|
1091
|
+
await send(base, token, 'a.md', Buffer.from('a'));
|
|
1092
|
+
const res = await call(base, 'apply_file_upload', {
|
|
1093
|
+
branch: DRAFT,
|
|
1094
|
+
token,
|
|
1095
|
+
destination: `${KB_DIR}/page.md`,
|
|
1096
|
+
});
|
|
1097
|
+
expect(res.status).toBe(409);
|
|
1098
|
+
expect((await json<{ error: string }>(res)).error).toContain('is a file, not a folder');
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it('refuses a destination outside the repository', async () => {
|
|
1102
|
+
const base = await start();
|
|
1103
|
+
const { token } = await request(base);
|
|
1104
|
+
await send(base, token, 'a.md', Buffer.from('a'));
|
|
1105
|
+
// The normaliser PLACES an unprefixed path under the checkout, so the
|
|
1106
|
+
// refusal is about one that cannot be rescued by any spelling.
|
|
1107
|
+
const res = await call(base, 'apply_file_upload', {
|
|
1108
|
+
branch: DRAFT,
|
|
1109
|
+
token,
|
|
1110
|
+
destination: `${KB_DIR}/../beside`,
|
|
1111
|
+
});
|
|
1112
|
+
expect(res.status).toBe(400);
|
|
1113
|
+
expect(batches).toEqual([]);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it('refuses an apply with no token at all', async () => {
|
|
1117
|
+
const base = await start();
|
|
1118
|
+
const res = await call(base, 'apply_file_upload', { branch: DRAFT, destination: KB_DIR });
|
|
1119
|
+
expect(res.status).toBe(400);
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it('refuses a token whose bytes were never sent', async () => {
|
|
1123
|
+
const base = await start();
|
|
1124
|
+
const { token } = await request(base);
|
|
1125
|
+
const res = await call(base, 'apply_file_upload', { branch: DRAFT, token, destination: KB_DIR });
|
|
1126
|
+
expect(res.status).toBe(404);
|
|
1127
|
+
expect((await json<{ error: string }>(res)).error).toBe(UPLOAD_TOKEN_REFUSAL);
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
describe('a token is good once, for one user, for a limited time', () => {
|
|
1132
|
+
it('is refused the second time it is applied', async () => {
|
|
1133
|
+
const base = await start();
|
|
1134
|
+
const { token } = await request(base);
|
|
1135
|
+
await send(base, token, 'a.md', Buffer.from('a'));
|
|
1136
|
+
expect((await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/One` })).count).toBe(1);
|
|
1137
|
+
const second = await call(base, 'apply_file_upload', {
|
|
1138
|
+
branch: DRAFT,
|
|
1139
|
+
token,
|
|
1140
|
+
destination: `${KB_DIR}/Two`,
|
|
1141
|
+
mode: 'overwrite',
|
|
1142
|
+
});
|
|
1143
|
+
expect(second.status).toBe(404);
|
|
1144
|
+
expect((await json<{ error: string }>(second)).error).toBe(UPLOAD_TOKEN_REFUSAL);
|
|
1145
|
+
expect(await readdir(join(tempDir, KB_DIR))).toEqual(['One']);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it('deletes the stored bytes once it has been applied', async () => {
|
|
1149
|
+
const base = await start();
|
|
1150
|
+
const { token } = await request(base);
|
|
1151
|
+
await send(base, token, 'a.md', Buffer.from('a'));
|
|
1152
|
+
expect(await readdir(uploadsDir)).toHaveLength(1);
|
|
1153
|
+
await apply(base, { branch: DRAFT, token, destination: KB_DIR });
|
|
1154
|
+
expect(await readdir(uploadsDir)).toEqual([]);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
it('is refused when another user presents it, saying nothing about whether it exists', async () => {
|
|
1158
|
+
const owner = await start({ userId: 'owner', userEmail: 'owner@x' });
|
|
1159
|
+
const { token } = await request(owner);
|
|
1160
|
+
await send(owner, token, 'a.md', Buffer.from('a'));
|
|
1161
|
+
// A second caller on the SAME server's store — a different connection key.
|
|
1162
|
+
const stranger = await start({ userId: 'stranger', userEmail: 'stranger@x', store: uploads });
|
|
1163
|
+
const refused = await call(stranger, 'apply_file_upload', {
|
|
1164
|
+
branch: DRAFT,
|
|
1165
|
+
token,
|
|
1166
|
+
destination: `${KB_DIR}/Theirs`,
|
|
1167
|
+
});
|
|
1168
|
+
expect(refused.status).toBe(404);
|
|
1169
|
+
const { error } = await json<{ error: string }>(refused);
|
|
1170
|
+
expect(error).toBe(UPLOAD_TOKEN_REFUSAL);
|
|
1171
|
+
// Says nothing about whether the token exists — the same sentence an
|
|
1172
|
+
// invented token gets, so a stranger learns nothing from the difference.
|
|
1173
|
+
const invented = await call(stranger, 'apply_file_upload', {
|
|
1174
|
+
branch: DRAFT,
|
|
1175
|
+
token: 'bevel-up_invented',
|
|
1176
|
+
destination: `${KB_DIR}/Theirs`,
|
|
1177
|
+
});
|
|
1178
|
+
expect((await json<{ error: string }>(invented)).error).toBe(error);
|
|
1179
|
+
expect(batches).toEqual([]);
|
|
1180
|
+
// And the stranger's attempt did not spend it: the owner still can.
|
|
1181
|
+
expect((await apply(owner, { branch: DRAFT, token, destination: `${KB_DIR}/Mine` })).count).toBe(1);
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
it('is refused once it has expired, and the stored upload is gone', async () => {
|
|
1185
|
+
// A TTL comfortably longer than the two HTTP round-trips below: at 30ms a
|
|
1186
|
+
// loaded machine could expire the token mid-`send`, and the test would
|
|
1187
|
+
// fail on an upload that was never stored rather than on the expiry it is
|
|
1188
|
+
// about.
|
|
1189
|
+
const base = await start({ ttlMs: 1_000 });
|
|
1190
|
+
const { token } = await request(base);
|
|
1191
|
+
await send(base, token, 'a.md', Buffer.from('a'));
|
|
1192
|
+
expect(await readdir(uploadsDir)).toHaveLength(1);
|
|
1193
|
+
await new Promise((r) => setTimeout(r, 1_500));
|
|
1194
|
+
const res = await call(base, 'apply_file_upload', { branch: DRAFT, token, destination: KB_DIR });
|
|
1195
|
+
expect(res.status).toBe(404);
|
|
1196
|
+
expect((await json<{ error: string }>(res)).error).toBe(UPLOAD_TOKEN_REFUSAL);
|
|
1197
|
+
await uploads.sweepNow();
|
|
1198
|
+
expect(await readdir(uploadsDir)).toEqual([]);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
it('deletes an upload nobody applied, on its own, when the token expires', async () => {
|
|
1202
|
+
// The periodic sweep, started before the first token is issued so the
|
|
1203
|
+
// store's own default interval does not win the race.
|
|
1204
|
+
// The TTL has the same margin over the round-trips as above; the sweep
|
|
1205
|
+
// interval stays short, and the deadline loop below is what makes the
|
|
1206
|
+
// sweep-side timing robust once the send has landed.
|
|
1207
|
+
const base = await start({ ttlMs: 1_000, sweepEveryMs: 10 });
|
|
1208
|
+
const { token } = await request(base);
|
|
1209
|
+
await send(base, token, 'forgotten.zip', zipOf({ 'a.md': 'a' }));
|
|
1210
|
+
expect(await readdir(uploadsDir)).toHaveLength(1);
|
|
1211
|
+
const deadline = Date.now() + 6_500;
|
|
1212
|
+
while (Date.now() < deadline && (await readdir(uploadsDir)).length > 0) {
|
|
1213
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1214
|
+
}
|
|
1215
|
+
expect(await readdir(uploadsDir)).toEqual([]);
|
|
1216
|
+
expect((await call(base, 'apply_file_upload', { branch: DRAFT, token, destination: KB_DIR })).status).toBe(404);
|
|
1217
|
+
});
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
describe('the sweep', () => {
|
|
1221
|
+
it('leaves alone the bytes of a token issued while it was listing the directory', async () => {
|
|
1222
|
+
// THE WINDOW, HELD OPEN. Whether a sweep running alongside a brand-new
|
|
1223
|
+
// upload deletes its bytes turns on the order of two steps — the listing
|
|
1224
|
+
// of the staging root, and the set of ids the live records hold — and in
|
|
1225
|
+
// production the gap between them is a filesystem round-trip no test can
|
|
1226
|
+
// time. So the listing is the seam: this one is paused on its way in, a
|
|
1227
|
+
// whole token-issue-and-upload happens inside the pause, and only then is
|
|
1228
|
+
// the directory read and the live set taken.
|
|
1229
|
+
//
|
|
1230
|
+
// That ordering is what makes this a test and not a hope. A sweep that
|
|
1231
|
+
// took its live set BEFORE listing would have taken it before the second
|
|
1232
|
+
// record existed, then listed a directory holding the second file, and
|
|
1233
|
+
// deleted bytes whose sender had just been answered "received".
|
|
1234
|
+
let reached: () => void = () => undefined;
|
|
1235
|
+
const listing = new Promise<void>((resolve) => {
|
|
1236
|
+
reached = resolve;
|
|
1237
|
+
});
|
|
1238
|
+
let release: () => void = () => undefined;
|
|
1239
|
+
let barrier: Promise<void> | null = null;
|
|
1240
|
+
const base = await start({
|
|
1241
|
+
listRoot: async (root) => {
|
|
1242
|
+
if (barrier !== null) {
|
|
1243
|
+
const held = barrier;
|
|
1244
|
+
barrier = null; // one-shot: only the sweep this test arms is held
|
|
1245
|
+
reached();
|
|
1246
|
+
await held;
|
|
1247
|
+
}
|
|
1248
|
+
return readdir(root);
|
|
1249
|
+
},
|
|
1250
|
+
});
|
|
1251
|
+
const first = await request(base);
|
|
1252
|
+
await send(base, first.token, 'first.md', Buffer.from('first'));
|
|
1253
|
+
|
|
1254
|
+
barrier = new Promise<void>((resolve) => {
|
|
1255
|
+
release = resolve;
|
|
1256
|
+
});
|
|
1257
|
+
const sweeping = uploads.sweepNow();
|
|
1258
|
+
await listing;
|
|
1259
|
+
const second = await request(base);
|
|
1260
|
+
const landed = await send(base, second.token, 'second.md', Buffer.from('second'));
|
|
1261
|
+
release();
|
|
1262
|
+
await sweeping;
|
|
1263
|
+
|
|
1264
|
+
expect(landed.status).toBe(200);
|
|
1265
|
+
expect(await readdir(uploadsDir)).toHaveLength(2);
|
|
1266
|
+
// And both are still applicable — the sweep took neither record with it.
|
|
1267
|
+
expect((await apply(base, { branch: DRAFT, token: first.token, destination: `${KB_DIR}/A` })).count).toBe(1);
|
|
1268
|
+
expect((await apply(base, { branch: DRAFT, token: second.token, destination: `${KB_DIR}/B` })).count).toBe(1);
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
it('refuses an upload whose token expired while the body was still arriving', async () => {
|
|
1272
|
+
// The TTL passes mid-request, and the periodic sweep fires inside it. The
|
|
1273
|
+
// answer is the one refusal rather than a success the sender could do
|
|
1274
|
+
// nothing with — and nothing is left staged.
|
|
1275
|
+
const base = await start({ ttlMs: 250, sweepEveryMs: 20 });
|
|
1276
|
+
const { token } = await request(base);
|
|
1277
|
+
const body = new ReadableStream<Uint8Array>({
|
|
1278
|
+
async start(controller) {
|
|
1279
|
+
controller.enqueue(new Uint8Array([1, 2, 3]));
|
|
1280
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
1281
|
+
controller.enqueue(new Uint8Array([4, 5, 6]));
|
|
1282
|
+
controller.close();
|
|
1283
|
+
},
|
|
1284
|
+
});
|
|
1285
|
+
const res = await fetch(`${base}/api/agent/uploads/${encodeURIComponent(token)}?filename=slow.bin`, {
|
|
1286
|
+
method: 'POST',
|
|
1287
|
+
body,
|
|
1288
|
+
// Node's fetch requires this for a streamed request body.
|
|
1289
|
+
duplex: 'half',
|
|
1290
|
+
} as RequestInit & { duplex: 'half' });
|
|
1291
|
+
expect(res.status).toBe(404);
|
|
1292
|
+
expect((await json<{ error: string }>(res)).error).toBe(UPLOAD_TOKEN_REFUSAL);
|
|
1293
|
+
await uploads.drainSweep();
|
|
1294
|
+
expect(await readdir(uploadsDir)).toEqual([]);
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it('leaves the bytes an apply is holding alone, even past the expiry', async () => {
|
|
1298
|
+
const base = await start({ ttlMs: 1_000 });
|
|
1299
|
+
const { token } = await request(base);
|
|
1300
|
+
await send(base, token, 'slow.md', Buffer.from('slow'));
|
|
1301
|
+
// Exactly what an apply holds while it resolves the branch, reads the
|
|
1302
|
+
// bytes, judges every path and commits — work that can outlast a token
|
|
1303
|
+
// whose TTL was nearly up when it started. A sweep that deleted the source
|
|
1304
|
+
// here would make the commit land short with nothing saying why.
|
|
1305
|
+
const claimed = uploads.claim(token, 'u');
|
|
1306
|
+
await new Promise((r) => setTimeout(r, 1_200));
|
|
1307
|
+
await uploads.sweepNow();
|
|
1308
|
+
expect(await readFile(claimed.absolutePath, 'utf8')).toBe('slow');
|
|
1309
|
+
// And a second apply arriving meanwhile still finds the token unusable.
|
|
1310
|
+
expect(() => uploads.claim(token, 'u')).toThrow(UPLOAD_TOKEN_REFUSAL);
|
|
1311
|
+
// Released without landing anything, the claim is gone and so are the bytes.
|
|
1312
|
+
uploads.release(token);
|
|
1313
|
+
await uploads.sweepNow();
|
|
1314
|
+
expect(await readdir(uploadsDir)).toEqual([]);
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
it('runs once the moment it starts, so bytes a dead process left behind long ago go now', async () => {
|
|
1318
|
+
const base = await start();
|
|
1319
|
+
// A file no record of THIS store names, older than any token could still
|
|
1320
|
+
// be good for: what a restart finds, since the records live in memory and
|
|
1321
|
+
// went with the process that issued them.
|
|
1322
|
+
const orphan = join(uploadsDir, 'upload-from-a-dead-process');
|
|
1323
|
+
await writeFile(orphan, 'orphan');
|
|
1324
|
+
const longAgo = new Date(Date.now() - 60 * 60 * 1000);
|
|
1325
|
+
await utimes(orphan, longAgo, longAgo);
|
|
1326
|
+
const { token } = await request(base);
|
|
1327
|
+
await uploads.drainSweep();
|
|
1328
|
+
expect(await readdir(uploadsDir)).toEqual([]);
|
|
1329
|
+
// The token issued alongside it is untouched — the sweep reclaims by what
|
|
1330
|
+
// the live records do NOT name.
|
|
1331
|
+
expect((await send(base, token, 'a.md', Buffer.from('a'))).status).toBe(200);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* The records are one process's memory, and two processes share the staging
|
|
1336
|
+
* directory whenever a deployment starts the new one before the old one has
|
|
1337
|
+
* stopped. To each, the other's uploads are files no record names — and a
|
|
1338
|
+
* sweep that deleted those at once took away bytes somebody had just been
|
|
1339
|
+
* told were received, from under the apply about to land them.
|
|
1340
|
+
*/
|
|
1341
|
+
it('leaves alone a fresh file it has no record of: it may be another process\'s upload', async () => {
|
|
1342
|
+
const base = await start();
|
|
1343
|
+
await writeFile(join(uploadsDir, 'upload-another-process-just-received'), 'theirs');
|
|
1344
|
+
await request(base);
|
|
1345
|
+
await uploads.drainSweep();
|
|
1346
|
+
await uploads.sweepNow();
|
|
1347
|
+
expect(await readdir(uploadsDir)).toEqual(['upload-another-process-just-received']);
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
it('stops deleting once it is stopped, so an evicted graph cannot sweep its replacement', async () => {
|
|
1351
|
+
await start();
|
|
1352
|
+
uploads.stopSweeping();
|
|
1353
|
+
await writeFile(join(uploadsDir, 'upload-the-next-store-issued'), 'theirs');
|
|
1354
|
+
await uploads.sweepNow();
|
|
1355
|
+
expect(await readdir(uploadsDir)).toEqual(['upload-the-next-store-issued']);
|
|
1356
|
+
});
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
describe('the staging root', () => {
|
|
1360
|
+
it('refuses a boot that would stage uploads inside a workspace', async () => {
|
|
1361
|
+
const workspaces = await mkdtemp(join(tmpdir(), 'ws-root-'));
|
|
1362
|
+
try {
|
|
1363
|
+
for (const inside of [workspaces, join(workspaces, 'agent-uploads'), join(workspaces, 'a', 'b')]) {
|
|
1364
|
+
await expect(assertUploadsRootOutsideWorkspaces(inside, workspaces)).rejects.toThrow(
|
|
1365
|
+
/AGENT_UPLOADS_ROOT/,
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
// Through a LINK, too: where the path resolves to is what decides it.
|
|
1369
|
+
const linked = join(workspaces, '..', `link-${basename(workspaces)}`);
|
|
1370
|
+
await symlink(workspaces, linked);
|
|
1371
|
+
try {
|
|
1372
|
+
await expect(assertUploadsRootOutsideWorkspaces(join(linked, 'uploads'), workspaces)).rejects.toThrow(
|
|
1373
|
+
/AGENT_UPLOADS_ROOT/,
|
|
1374
|
+
);
|
|
1375
|
+
} finally {
|
|
1376
|
+
await rm(linked, { force: true });
|
|
1377
|
+
}
|
|
1378
|
+
// A sibling — the default — is what the invariant asks for.
|
|
1379
|
+
await assertUploadsRootOutsideWorkspaces(join(workspaces, '..', 'agent-uploads'), workspaces);
|
|
1380
|
+
} finally {
|
|
1381
|
+
await rm(workspaces, { recursive: true, force: true });
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
describe('an upload is never readable through a file tool', () => {
|
|
1387
|
+
it('sits outside every workspace, and no path reaches it', async () => {
|
|
1388
|
+
const base = await start();
|
|
1389
|
+
const { token } = await request(base);
|
|
1390
|
+
await send(base, token, 'secret.md', Buffer.from('held outside'));
|
|
1391
|
+
const [stored] = await readdir(uploadsDir);
|
|
1392
|
+
// Not in the workspace tree at all.
|
|
1393
|
+
const names = await json<{ entries: { name: string }[] }>(
|
|
1394
|
+
await call(base, 'list_files', { branch: DRAFT, path: KB_DIR }),
|
|
1395
|
+
);
|
|
1396
|
+
expect((names.entries ?? []).map((e) => e.name)).not.toContain(stored);
|
|
1397
|
+
// And no spelling of a workspace path leaves the repository to find it.
|
|
1398
|
+
for (const path of [`${KB_DIR}/../../agent-uploads/${stored}`, `../${stored}`, `/${stored}`]) {
|
|
1399
|
+
const res = await call(base, 'read_file', { branch: DRAFT, path });
|
|
1400
|
+
expect(res.status).not.toBe(200);
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
describe('the write tools name the upload route', () => {
|
|
1406
|
+
it('says so on write_file, write_files and edit_file', async () => {
|
|
1407
|
+
await start();
|
|
1408
|
+
const tools = await toolRegistry.listExternal();
|
|
1409
|
+
for (const name of ['write_file', 'write_files', 'edit_file']) {
|
|
1410
|
+
const def = tools.find((t) => t.name === name);
|
|
1411
|
+
expect(def, name).toBeDefined();
|
|
1412
|
+
const description = def!.description;
|
|
1413
|
+
expect(description, name).toContain('request_file_upload');
|
|
1414
|
+
expect(description, name).toContain('apply_file_upload');
|
|
1415
|
+
// And what goes that way, in the one sentence a description has room for.
|
|
1416
|
+
expect(description, name).toMatch(/large, escape-heavy or binary/i);
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
it('says why, and how, once — in the rules every file tool shares', () => {
|
|
1421
|
+
// The three failures the route exists for are named where the full
|
|
1422
|
+
// explanation lives: said on each of three descriptions, it took every one
|
|
1423
|
+
// of them past the length a client cuts at.
|
|
1424
|
+
const rule = sharedFileRules(DEFAULT_KB_LAYOUT).find((r) => r.id === 'upload-route');
|
|
1425
|
+
expect(rule).toBeDefined();
|
|
1426
|
+
expect(rule!.body).toMatch(/cut off mid-answer/);
|
|
1427
|
+
expect(rule!.body).toMatch(/escapes fails to parse/);
|
|
1428
|
+
expect(rule!.body).toMatch(/an image, a PDF or a zip/);
|
|
1429
|
+
expect(rule!.body).toContain('`request_file_upload`');
|
|
1430
|
+
expect(rule!.body).toContain('`apply_file_upload`');
|
|
1431
|
+
});
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
describe('the token travels in the path or in a header', () => {
|
|
1435
|
+
/** POST bytes to the BARE address, the token in `x-upload-token`. */
|
|
1436
|
+
const sendWithHeader = (
|
|
1437
|
+
base: string,
|
|
1438
|
+
token: string,
|
|
1439
|
+
filename: string,
|
|
1440
|
+
data: Buffer,
|
|
1441
|
+
contentType = 'application/octet-stream',
|
|
1442
|
+
) =>
|
|
1443
|
+
fetch(`${base}/api/agent/uploads?filename=${encodeURIComponent(filename)}`, {
|
|
1444
|
+
method: 'POST',
|
|
1445
|
+
headers: { 'content-type': contentType, 'x-upload-token': token },
|
|
1446
|
+
body: data,
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
it('takes the token as a header on the bare address, and the apply lands those bytes', async () => {
|
|
1450
|
+
const base = await start();
|
|
1451
|
+
const { token } = await request(base);
|
|
1452
|
+
const res = await sendWithHeader(base, token, 'notes.md', Buffer.from('hello'));
|
|
1453
|
+
expect(res.status).toBe(200);
|
|
1454
|
+
expect(await json(res)).toEqual({ filename: 'notes.md', bytes: 5, kind: 'file' });
|
|
1455
|
+
const answer = await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Pages` });
|
|
1456
|
+
expect(answer.files).toEqual([{ path: `${KB_DIR}/Pages/notes.md`, outcome: 'created' }]);
|
|
1457
|
+
expect(await readFile(join(tempDir, KB_DIR, 'Pages/notes.md'), 'utf8')).toBe('hello');
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
it('keeps the bytes a stream on the bare address too, whatever content-type is claimed', async () => {
|
|
1461
|
+
const base = await start();
|
|
1462
|
+
const { token } = await request(base);
|
|
1463
|
+
// The global `express.json()` would drain this body — the header form has
|
|
1464
|
+
// to be exempt by the same rule the path form is, or a zip sent as
|
|
1465
|
+
// `application/json` reaches the handler with nothing left in it.
|
|
1466
|
+
const zip = zipOf({ 'a.md': 'a' });
|
|
1467
|
+
const res = await sendWithHeader(base, token, 'archive.zip', zip, 'application/json');
|
|
1468
|
+
expect(await json(res)).toEqual({ filename: 'archive.zip', bytes: zip.byteLength, kind: 'zip', entries: 1 });
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
it('exempts both spellings of the address from the JSON parser', () => {
|
|
1472
|
+
expect(isAgentUploadRawBodyPath('/api/agent/uploads')).toBe(true);
|
|
1473
|
+
expect(isAgentUploadRawBodyPath('/api/agent/uploads/')).toBe(true);
|
|
1474
|
+
expect(isAgentUploadRawBodyPath('/API/Agent/Uploads')).toBe(true);
|
|
1475
|
+
expect(isAgentUploadRawBodyPath('/api/agent/uploads/bevel-up_abc')).toBe(true);
|
|
1476
|
+
expect(isAgentUploadRawBodyPath('/api/agent/uploadsomething')).toBe(false);
|
|
1477
|
+
expect(isAgentUploadRawBodyPath('/api/agent/tools/write_file')).toBe(false);
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
it('gives the bare address with NO token the same single refusal an unknown token gets', async () => {
|
|
1481
|
+
const base = await start();
|
|
1482
|
+
const bare = await fetch(`${base}/api/agent/uploads?filename=notes.md`, {
|
|
1483
|
+
method: 'POST',
|
|
1484
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
1485
|
+
body: Buffer.from('hello'),
|
|
1486
|
+
});
|
|
1487
|
+
const unknown = await send(base, 'bevel-up_nosuchtoken', 'notes.md', Buffer.from('hello'));
|
|
1488
|
+
expect(bare.status).toBe(404);
|
|
1489
|
+
expect(unknown.status).toBe(404);
|
|
1490
|
+
// The same words: a caller holding no token learns nothing from the
|
|
1491
|
+
// difference, not even that a header would have been read.
|
|
1492
|
+
expect(await json(bare)).toEqual(await json(unknown));
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
it('lets the path win when a stale header names a different token', async () => {
|
|
1496
|
+
const base = await start();
|
|
1497
|
+
const first = await request(base);
|
|
1498
|
+
const second = await request(base);
|
|
1499
|
+
const res = await fetch(`${base}/api/agent/uploads/${encodeURIComponent(first.token)}?filename=notes.md`, {
|
|
1500
|
+
method: 'POST',
|
|
1501
|
+
headers: { 'content-type': 'application/octet-stream', 'x-upload-token': second.token },
|
|
1502
|
+
body: Buffer.from('hello'),
|
|
1503
|
+
});
|
|
1504
|
+
expect(res.status).toBe(200);
|
|
1505
|
+
// The bytes belong to the token in the address that was POSTed to; the
|
|
1506
|
+
// other token is still open and still holds nothing.
|
|
1507
|
+
const landed = await apply(base, { branch: DRAFT, token: first.token, destination: `${KB_DIR}/Pages` });
|
|
1508
|
+
expect(landed.files).toEqual([{ path: `${KB_DIR}/Pages/notes.md`, outcome: 'created' }]);
|
|
1509
|
+
const empty = await apply(base, { branch: DRAFT, token: second.token, destination: `${KB_DIR}/Pages` });
|
|
1510
|
+
expect(empty.error).toBeDefined();
|
|
1511
|
+
});
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
describe('an apply takes the destination folder\'s turn', () => {
|
|
1515
|
+
it('lands its batch INSIDE the turn, over the destination it was given', async () => {
|
|
1516
|
+
const base = await start();
|
|
1517
|
+
const { token } = await request(base);
|
|
1518
|
+
await send(base, token, 'skill.zip', zipOf({ 'SKILL.md': '# s\n', 'docs/a.md': 'a' }));
|
|
1519
|
+
await apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Skills/my-skill` });
|
|
1520
|
+
// One turn, over the destination — the same folder `delete_folder` takes
|
|
1521
|
+
// its turn over, which is what makes the two wait for each other.
|
|
1522
|
+
expect(folderTurns).toEqual([`${KB_DIR}/Skills/my-skill`]);
|
|
1523
|
+
// And the batch landed while that turn was held, not before or after it:
|
|
1524
|
+
// a turn taken around nothing would serialise nothing.
|
|
1525
|
+
expect(batchTurnDepth).toEqual([1]);
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
it('lands nothing while another caller holds a turn over that subtree', async () => {
|
|
1529
|
+
const base = await start();
|
|
1530
|
+
const { token } = await request(base);
|
|
1531
|
+
await send(base, token, 'page.md', Buffer.from('mine\n'));
|
|
1532
|
+
// The turn a concurrent `delete_folder` would be holding over the
|
|
1533
|
+
// destination, held open until this test lets go.
|
|
1534
|
+
let release!: () => void;
|
|
1535
|
+
folderTurnGate = new Promise<void>((resolve) => (release = resolve));
|
|
1536
|
+
const pending = apply(base, { branch: DRAFT, token, destination: `${KB_DIR}/Pages` });
|
|
1537
|
+
// Until the apply ASKS for the turn — no sleep, so a loaded runner cannot
|
|
1538
|
+
// turn this into a flake, and nothing here depends on how long the judging
|
|
1539
|
+
// took. Racing the apply itself means a regression that never asks for a
|
|
1540
|
+
// turn fails on the assertions below instead of hanging to the timeout.
|
|
1541
|
+
await Promise.race([folderTurnAsked, pending]);
|
|
1542
|
+
// Asked for, and not yet granted: the apply is parked on the gate, which
|
|
1543
|
+
// is what makes every assertion that follows a statement about waiting.
|
|
1544
|
+
expect(folderTurns).toEqual([`${KB_DIR}/Pages`]);
|
|
1545
|
+
expect(turnsHeld).toBe(0);
|
|
1546
|
+
expect(batches).toEqual([]);
|
|
1547
|
+
await expect(readFile(join(tempDir, KB_DIR, 'Pages/page.md'), 'utf8')).rejects.toThrow();
|
|
1548
|
+
// Now the folder is free, and the same apply finishes on it.
|
|
1549
|
+
release();
|
|
1550
|
+
const answer = await pending;
|
|
1551
|
+
expect(answer.files).toEqual([{ path: `${KB_DIR}/Pages/page.md`, outcome: 'created' }]);
|
|
1552
|
+
expect(batches).toHaveLength(1);
|
|
1553
|
+
expect(await readFile(join(tempDir, KB_DIR, 'Pages/page.md'), 'utf8')).toBe('mine\n');
|
|
1554
|
+
});
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
describe('a read-only deployment lands no upload', () => {
|
|
1558
|
+
/** A host's verdict: the deployment may be read, not changed. */
|
|
1559
|
+
const readOnly: IWriteAccess = {
|
|
1560
|
+
canWrite: async () => ({ ok: false, message: 'This workspace is read-only until an admin adds seats.' }),
|
|
1561
|
+
};
|
|
1562
|
+
|
|
1563
|
+
/**
|
|
1564
|
+
* Both upload tools must be WRITE tools, because the read-only gate's HTTP
|
|
1565
|
+
* half cannot refuse them: `/api/agent/` is on its always-writable list, on
|
|
1566
|
+
* the stated grounds that "every tool call is judged by the tool layer
|
|
1567
|
+
* itself, which knows a write tool from a read". That makes `write: true` on
|
|
1568
|
+
* these two mounts the only thing standing between a read-only deployment
|
|
1569
|
+
* and a commit — worth a test that fails if either loses the flag, rather
|
|
1570
|
+
* than a comment hoping nobody does.
|
|
1571
|
+
*/
|
|
1572
|
+
it('refuses request_file_upload and apply_file_upload at the tool layer', async () => {
|
|
1573
|
+
const base = await start({ writeAccess: readOnly });
|
|
1574
|
+
for (const tool of ['request_file_upload', 'apply_file_upload']) {
|
|
1575
|
+
const res = await call(base, tool, { branch: DRAFT, token: 'bevel-up_x', destination: KB_DIR });
|
|
1576
|
+
expect(res.status, tool).toBe(403);
|
|
1577
|
+
expect(await json<{ code?: string }>(res), tool).toMatchObject({ code: READ_ONLY_CODE });
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
/**
|
|
1582
|
+
* The raw upload route is NOT a tool and never reaches the tool layer, so
|
|
1583
|
+
* the always-writable prefix does let its bytes through. That is harmless
|
|
1584
|
+
* and deliberate: it stages bytes beside the workspaces root and commits
|
|
1585
|
+
* nothing, no new token can be issued while the deployment is read-only
|
|
1586
|
+
* (`request_file_upload` is refused above), and bytes nobody can apply are
|
|
1587
|
+
* deleted when their token expires. Asserted so the reasoning is on record
|
|
1588
|
+
* where the behaviour is.
|
|
1589
|
+
*/
|
|
1590
|
+
it('still takes the bytes of a token issued before the deployment went read-only, and lands none of them', async () => {
|
|
1591
|
+
const writable = await start();
|
|
1592
|
+
const { token } = await request(writable);
|
|
1593
|
+
const readOnlyServer = await start({ store: uploads, writeAccess: readOnly });
|
|
1594
|
+
expect((await send(readOnlyServer, token, 'notes.md', Buffer.from('hello'))).status).toBe(200);
|
|
1595
|
+
const refused = await call(readOnlyServer, 'apply_file_upload', {
|
|
1596
|
+
branch: DRAFT,
|
|
1597
|
+
token,
|
|
1598
|
+
destination: `${KB_DIR}/Pages`,
|
|
1599
|
+
});
|
|
1600
|
+
expect(refused.status).toBe(403);
|
|
1601
|
+
expect(batches).toEqual([]);
|
|
1602
|
+
await expect(readFile(join(tempDir, KB_DIR, 'Pages/notes.md'), 'utf8')).rejects.toThrow();
|
|
1603
|
+
});
|
|
1604
|
+
});
|