@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
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import nodeFs from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import AdmZip from 'adm-zip';
|
|
4
5
|
import type { Router, RequestHandler } from 'express';
|
|
5
6
|
import type { LocalFilesystem } from '@mastra/core/workspace';
|
|
6
7
|
import type { IToolRegistry, JsonSchema } from '../tool-registry/tool.contract.js';
|
|
7
8
|
import { ToolError, type ToolContext, type ToolHandler } from '../tool-helpers/tool.contract.js';
|
|
8
9
|
import { BRANCH_INPUT, toolDef } from '../tool-helpers/tool-def.js';
|
|
9
10
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
assertShellAllowedWithinOntology,
|
|
13
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
11
|
+
notifyAgentRead,
|
|
12
|
+
assertAgentWriteAllowed,
|
|
14
13
|
SESSION_ID_INPUT,
|
|
15
|
-
type
|
|
16
|
-
} from './
|
|
14
|
+
type AgentAccessGate,
|
|
15
|
+
} from './agent-access.gate.js';
|
|
17
16
|
import type { IRoutineWritePolicy } from './routine-write-policy.js';
|
|
18
17
|
import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
|
|
19
18
|
import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
|
|
20
19
|
import { workspaceIdForBranch } from '../../shared/workspace-id.js';
|
|
21
|
-
import { assertBranchProvided } from '../../shared/domain-errors.js';
|
|
20
|
+
import { assertBranchProvided, GitInternalsError, WorkflowValidationError } from '../../shared/domain-errors.js';
|
|
22
21
|
// Leaf-level shared primitive (same exception `workspace.service.ts` already
|
|
23
22
|
// relies on) — not a workflow service, so this stays inside the module boundary.
|
|
24
23
|
import { assertValidBranchName } from '../kb-fs/branch-name.js';
|
|
25
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
assertInsideRepo,
|
|
26
|
+
assertRepoRootNameFree,
|
|
27
|
+
assertRepoRootNameFreeArgs,
|
|
28
|
+
isInsideRepo,
|
|
29
|
+
normalizePathArgs,
|
|
30
|
+
} from '../kb-fs/repo-path.js';
|
|
26
31
|
import { GitGuardedFilesystem } from '../kb-fs/git-guarded-filesystem.js';
|
|
27
32
|
import { assertNoGitInternalsSegment, assertNotGitInternals, hasGitInternalsSegment } from '../../shared/git-internals.js';
|
|
28
33
|
import { isRolesYamlPath } from '../access-model/roles-yaml-guard.js';
|
|
@@ -40,28 +45,36 @@ import { createFileReaderRegistry } from './file-readers/file-reader.registry.js
|
|
|
40
45
|
import { DocumentReader } from './file-readers/document-reader.js';
|
|
41
46
|
import { mcpImageResult } from '@bevel-software/platform-mcp-core';
|
|
42
47
|
import {
|
|
43
|
-
LEGACY_AGENTS_FILE,
|
|
44
48
|
folderPlaceholderPath,
|
|
45
49
|
isFolderPlaceholder,
|
|
46
50
|
isPlatformFile,
|
|
47
51
|
isPlatformFolder,
|
|
48
52
|
platformFileCreationRefusal,
|
|
49
|
-
platformFileNames,
|
|
50
53
|
platformFileRefusal,
|
|
54
|
+
platformFileUploadRefusal,
|
|
51
55
|
platformFolderRefusal,
|
|
52
56
|
entryExistsMessage,
|
|
53
57
|
type ExistingEntryKind,
|
|
54
|
-
type KbLayout,
|
|
55
58
|
} from '@bevel-software/platform-shared';
|
|
56
59
|
import type { KbContext } from '../../shared/kb-context.js';
|
|
57
60
|
import { AccessDeniedError } from '../access-model/access-errors.js';
|
|
58
61
|
import { removeEmptyDirs } from './empty-dirs.js';
|
|
59
|
-
import {
|
|
62
|
+
import { rethrowAsWriteDenial } from './write-denial.js';
|
|
63
|
+
import { sharedRulesPointer } from '../agent-instructions/shared-file-rules.js';
|
|
60
64
|
import type { IChangeReadGate } from '../access-model/change-gate.js';
|
|
61
65
|
import { notFound, orDeclaredNotFound, orNotFound } from './not-found.js';
|
|
62
66
|
import { logger } from '../../shared/logging.js';
|
|
63
67
|
import { printable } from '../../shared/printable.js';
|
|
64
68
|
import { DestinationTakenError, inspectDestination } from '../../shared/rename-no-replace.js';
|
|
69
|
+
import { AgentUploadStore, type ClaimedUpload } from './agent-upload.store.js';
|
|
70
|
+
import {
|
|
71
|
+
isSymlinkZipEntry,
|
|
72
|
+
isZipNoiseEntry,
|
|
73
|
+
readZipEntry,
|
|
74
|
+
zipEntryName,
|
|
75
|
+
zipEntryNameRefusal,
|
|
76
|
+
zipEntrySegments,
|
|
77
|
+
} from './zip-entry-rules.js';
|
|
65
78
|
|
|
66
79
|
const log = logger('workspace-tools');
|
|
67
80
|
|
|
@@ -195,60 +208,26 @@ async function keepFolderOf(
|
|
|
195
208
|
}
|
|
196
209
|
}
|
|
197
210
|
|
|
198
|
-
/**
|
|
199
|
-
* Appended (centrally, in `mount`) to EVERY workspace tool description. The
|
|
200
|
-
* platform's managed agent guide sits at the workspace root and documents the
|
|
201
|
-
* conventions of that knowledge base; agents (ours and external) should consult
|
|
202
|
-
* it before touching files. It rides on every entrypoint — reads (grep/
|
|
203
|
-
* list_files/file_stat) included — because any of them can be a session's first
|
|
204
|
-
* touch.
|
|
205
|
-
*
|
|
206
|
-
* `CLAUDE.md` is named as a fallback because knowledge bases seeded before the
|
|
207
|
-
* rename still carry one, and the seeder never deletes a file it did not
|
|
208
|
-
* expect. Naming both means an agent finds the conventions either way, instead
|
|
209
|
-
* of reading none because it looked for the newer name and stopped.
|
|
210
|
-
*
|
|
211
|
-
* WHEN THE GUIDE HAS BEEN RENAMED the sentence names two files, ours first. The
|
|
212
|
-
* second is the organisation's OWN `AGENTS.md`, which on such a deployment is
|
|
213
|
-
* ordinary content the platform never touches — and which no harness reads for a
|
|
214
|
-
* remote agent, because a remote agent has no checkout. Telling it to read both
|
|
215
|
-
* is the only way the conventions the customer actually wrote reach the agent
|
|
216
|
-
* working in their knowledge base. Under the default name the wording collapses
|
|
217
|
-
* to the one file it has always named.
|
|
218
|
-
*
|
|
219
|
-
* A FUNCTION of the layout, called when a description is built: the name is a
|
|
220
|
-
* deployment setting, and a module-scope string would snapshot the default.
|
|
221
|
-
*/
|
|
222
|
-
function kbConventionsNote(layout: KbLayout): string {
|
|
223
|
-
const agentsFile = layout.agentsFile ?? LEGACY_AGENTS_FILE;
|
|
224
|
-
if (agentsFile === LEGACY_AGENTS_FILE) {
|
|
225
|
-
return ' Before your first read or change in a workspace, read `AGENTS.md` at the KB root — or `CLAUDE.md` on a knowledge base seeded before it was renamed — if either exists: it holds the author\'s conventions for this knowledge base, and you should follow them.';
|
|
226
|
-
}
|
|
227
|
-
return (
|
|
228
|
-
` Before your first read or change in a workspace, read \`${agentsFile}\` at the KB root, then ` +
|
|
229
|
-
'`AGENTS.md` if it also exists (the organisation\'s own conventions) — or `CLAUDE.md` on a knowledge base seeded before it was renamed: together they hold the conventions for this knowledge base, and you should follow them.'
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** The platform files as a tool description lists them — the guide under its own name. */
|
|
234
|
-
function platformFileList(layout: KbLayout): string {
|
|
235
|
-
return platformFileNames(layout)
|
|
236
|
-
.map((name) => `\`${name}\``)
|
|
237
|
-
.join(', ');
|
|
238
|
-
}
|
|
239
|
-
|
|
240
211
|
const int = (description: string): JsonSchema => ({ type: 'integer', description });
|
|
241
212
|
|
|
242
213
|
const str = (description: string): JsonSchema => ({ type: 'string', description });
|
|
243
214
|
|
|
244
215
|
/**
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
216
|
+
* The upload route, named on every tool that takes content as a JSON string.
|
|
217
|
+
*
|
|
218
|
+
* ONE sentence, and the tools' own: it is what stops the three failures the
|
|
219
|
+
* route was built for. An agent landing 27 files read each one and typed it
|
|
220
|
+
* out again as a tool argument: a 37 KB write was truncated mid-answer, a page
|
|
221
|
+
* of regex backslashes failed to parse as a JSON parameter, and a PNG could not
|
|
222
|
+
* be sent at all. None of that is discoverable from a refusal — a truncated
|
|
223
|
+
* write reports success — so the tools that invite it name where the bytes
|
|
224
|
+
* should go instead, in the description itself, for a client that reads
|
|
225
|
+
* nothing else. WHY, and how the route is used, is one of the shared rules
|
|
226
|
+
* (`agent-instructions/shared-file-rules.ts`): said in full on three
|
|
227
|
+
* descriptions it took each of them past the length a client cuts at.
|
|
249
228
|
*/
|
|
250
|
-
const
|
|
251
|
-
'
|
|
229
|
+
const UPLOAD_ROUTE_NOTE =
|
|
230
|
+
' Large, escape-heavy or binary content does not go through here: use `request_file_upload` + `apply_file_upload`.';
|
|
252
231
|
|
|
253
232
|
/**
|
|
254
233
|
* A path input that names the clone folder, and says what happens when it does
|
|
@@ -287,6 +266,9 @@ const RESERVED_ROOT_NAME_TARGETS: Readonly<Record<string, readonly string[]>> =
|
|
|
287
266
|
copy_file: ['dest'],
|
|
288
267
|
move_file: ['dest'],
|
|
289
268
|
unzip: ['destination'],
|
|
269
|
+
// The folder the upload lands in. Each of its own paths is checked again
|
|
270
|
+
// inside the handler — an archive chooses its entry names, not the caller.
|
|
271
|
+
apply_file_upload: ['destination'],
|
|
290
272
|
};
|
|
291
273
|
|
|
292
274
|
function asText(content: string | Buffer): string {
|
|
@@ -315,16 +297,6 @@ interface DocGrepState {
|
|
|
315
297
|
skippedUncached: number;
|
|
316
298
|
}
|
|
317
299
|
|
|
318
|
-
/**
|
|
319
|
-
* THE binary capability contract, stated once and appended (in `mount`) to
|
|
320
|
-
* every file tool's description — which is also what `tools_info` returns.
|
|
321
|
-
* The split it states is enforced by the reader registry: the text tools
|
|
322
|
-
* refuse what their reader marks not `textEditable` (and binary content under
|
|
323
|
-
* any name) with a `binary_not_writable` refusal; the byte tools never look.
|
|
324
|
-
*/
|
|
325
|
-
export const CONTENT_RULE =
|
|
326
|
-
' Content rule (the same on every file tool): read_file returns text for text files and extracted text for documents (.docx/.pptx/.xlsx/.odt/.odp/.ods/.pdf, .eml/.msg); write_file, write_files and edit_file accept TEXT only — they refuse documents, images, archives and other binary files (legacy .doc/.ppt/.xls included) with kind `binary_not_writable`, naming the file\'s kind and the tool to use instead; copy_file, move_file, delete_file and unzip act on bytes of any kind; new binary content arrives through upload (`request_upload_token` + `apply_upload` where offered, otherwise Upload in the app). file_stat reports `contentMode` (`text` | `document` | `binary`) so you can decide before acting.';
|
|
327
|
-
|
|
328
300
|
/** What a `binary_not_writable` refusal points to, in the order to try them. */
|
|
329
301
|
const BINARY_USE_INSTEAD = ['upload', 'copy_file', 'move_file'] as const;
|
|
330
302
|
|
|
@@ -337,7 +309,7 @@ const BINARY_USE_INSTEAD = ['upload', 'copy_file', 'move_file'] as const;
|
|
|
337
309
|
function binaryNotWritable(fileKind: FileKind, explanation: string): ToolError {
|
|
338
310
|
return new ToolError(
|
|
339
311
|
`${explanation} [binary_not_writable: this file's kind is ${fileKind}; write_file, write_files and edit_file accept text only. ` +
|
|
340
|
-
'Use upload for new bytes (`
|
|
312
|
+
'Use upload for new bytes (`request_file_upload` + `apply_file_upload`, or Upload in the app), ' +
|
|
341
313
|
'or copy_file / move_file to place bytes that are already in the workspace.]',
|
|
342
314
|
415,
|
|
343
315
|
{ kind: 'binary_not_writable', fileKind, useInstead: [...BINARY_USE_INSTEAD] },
|
|
@@ -429,13 +401,6 @@ const WRITE_MODE_INPUT: JsonSchema = {
|
|
|
429
401
|
'EXISTING file and refuses (`missing`) a path that holds nothing.',
|
|
430
402
|
};
|
|
431
403
|
|
|
432
|
-
/** The same three modes, said once, for both tool descriptions. */
|
|
433
|
-
const WRITE_MODE_NOTE =
|
|
434
|
-
' `mode` decides what may happen at a path and DEFAULTS TO `create`: `create` writes a new file and refuses a path that ' +
|
|
435
|
-
'already exists (`exists`, with the path — pass `mode: overwrite` to replace it), `overwrite` replaces what is there ' +
|
|
436
|
-
'(creating it if there is nothing), `update` replaces an existing file and refuses a path that does not exist (`missing`). ' +
|
|
437
|
-
'A refused path is left exactly as it was.';
|
|
438
|
-
|
|
439
404
|
/** The refusal `create` gives on a path that already holds something. */
|
|
440
405
|
function pathExists(path: string): ToolError {
|
|
441
406
|
return new ToolError(
|
|
@@ -467,6 +432,169 @@ function decideWrite(mode: WriteMode, path: string, exists: boolean): WriteOutco
|
|
|
467
432
|
return exists ? 'replaced' : 'created';
|
|
468
433
|
}
|
|
469
434
|
|
|
435
|
+
/**
|
|
436
|
+
* How many of an upload's paths the answer NAMES before it stops and says how
|
|
437
|
+
* many there were. A 300-file zip's full outcome list is pages of text an agent
|
|
438
|
+
* pays for on every call; 25 is enough to see the shape of what happened, and
|
|
439
|
+
* `total` plus `truncated` say that there is more. `all: true` asks for the
|
|
440
|
+
* rest, for a caller that really does have to read each one.
|
|
441
|
+
*/
|
|
442
|
+
const APPLY_ANSWER_CAP = 25;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* How many entries of one uploaded archive are landed, and how many bytes of
|
|
446
|
+
* uncompressed content in total.
|
|
447
|
+
*
|
|
448
|
+
* Tighter than `unzip`'s own caps on purpose. An apply lands its whole set as
|
|
449
|
+
* ONE commit, which means every entry's bytes are held in memory at once —
|
|
450
|
+
* the property that makes the commit atomic is the one that makes a zip bomb
|
|
451
|
+
* expensive. The upload itself is already bounded by the deployment's upload
|
|
452
|
+
* limit; these bound what that upload is allowed to expand into.
|
|
453
|
+
*/
|
|
454
|
+
const APPLY_MAX_ENTRIES = 5_000;
|
|
455
|
+
const APPLY_MAX_TOTAL_BYTES = 128 * 1024 * 1024; // 128 MB uncompressed
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* One path of an upload, as `apply_file_upload` plans it: the bytes to write,
|
|
459
|
+
* or the reason this path is refused before any gate is asked. A refused path
|
|
460
|
+
* carries the name the archive held rather than a workspace path, because for
|
|
461
|
+
* those the whole problem is that no workspace path can be built from it.
|
|
462
|
+
*/
|
|
463
|
+
interface PlannedUploadPath {
|
|
464
|
+
path: string;
|
|
465
|
+
content?: Buffer;
|
|
466
|
+
error?: string;
|
|
467
|
+
message?: string;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Turn a stored upload into one planned path per file.
|
|
472
|
+
*
|
|
473
|
+
* A single file is one path: the destination plus the name it was sent with.
|
|
474
|
+
* A zip is one path per member, with the member's folder structure kept under
|
|
475
|
+
* the destination — judged by the same entry rules `unzip` applies
|
|
476
|
+
* (`zip-entry-rules.ts`), plus one `unzip` does not have: an entry that is a
|
|
477
|
+
* symbolic LINK is refused outright. A zip stores a link as a member whose
|
|
478
|
+
* bytes are its target text, so a reader that ignored the mode bits would
|
|
479
|
+
* write that text out as a file — content nobody sent, under a name that was
|
|
480
|
+
* meant to point elsewhere.
|
|
481
|
+
*/
|
|
482
|
+
/** The refusal an entry gets when the archive would expand past what one commit lands. */
|
|
483
|
+
function tooLargeToApply(): string {
|
|
484
|
+
return `This archive expands past the ${APPLY_MAX_TOTAL_BYTES} byte total the apply lands in one commit; this entry was not applied.`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function planUpload(
|
|
488
|
+
upload: ClaimedUpload,
|
|
489
|
+
destination: string,
|
|
490
|
+
kbDirName: string,
|
|
491
|
+
): Promise<PlannedUploadPath[]> {
|
|
492
|
+
const bytes = await nodeFs.readFile(upload.absolutePath);
|
|
493
|
+
if (upload.kind !== 'zip') {
|
|
494
|
+
return [{ path: `${destination}/${upload.filename}`, content: bytes }];
|
|
495
|
+
}
|
|
496
|
+
let zip: AdmZip;
|
|
497
|
+
try {
|
|
498
|
+
zip = new AdmZip(bytes);
|
|
499
|
+
} catch (err) {
|
|
500
|
+
throw new ToolError(
|
|
501
|
+
`"${upload.filename}" could not be opened as a .zip archive: ${err instanceof Error ? err.message : String(err)}`,
|
|
502
|
+
422,
|
|
503
|
+
{ code: 'unreadable_archive' },
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const planned: PlannedUploadPath[] = [];
|
|
507
|
+
let seen = 0;
|
|
508
|
+
let totalBytes = 0;
|
|
509
|
+
for (const entry of zip.getEntries()) {
|
|
510
|
+
const rawName = zipEntryName(entry.entryName);
|
|
511
|
+
if (isZipNoiseEntry(rawName)) continue;
|
|
512
|
+
if (seen >= APPLY_MAX_ENTRIES) {
|
|
513
|
+
planned.push({
|
|
514
|
+
path: rawName || '(empty)',
|
|
515
|
+
error: 'too_many_entries',
|
|
516
|
+
message: `This archive holds more than ${APPLY_MAX_ENTRIES} entries; the rest were not applied.`,
|
|
517
|
+
});
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
seen++;
|
|
521
|
+
const nameRefusal = zipEntryNameRefusal(rawName);
|
|
522
|
+
if (nameRefusal !== null) {
|
|
523
|
+
planned.push({ path: rawName || '(empty)', error: 'invalid_entry', message: nameRefusal });
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (isSymlinkZipEntry(entry)) {
|
|
527
|
+
planned.push({
|
|
528
|
+
path: rawName,
|
|
529
|
+
error: 'link',
|
|
530
|
+
message: `"${rawName}" is a symbolic link, not a file; an upload lands files, never links.`,
|
|
531
|
+
});
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
// A folder comes into being with the files under it (the write path mkdirs
|
|
535
|
+
// each parent), so a directory member has nothing of its own to land.
|
|
536
|
+
if (entry.isDirectory) continue;
|
|
537
|
+
const segments = zipEntrySegments(rawName);
|
|
538
|
+
const target = [destination, ...segments].join('/');
|
|
539
|
+
// Belt and braces: `zipEntryNameRefusal` already refuses a `..` segment
|
|
540
|
+
// and a root-anchored name, so nothing should reach here that climbs out.
|
|
541
|
+
// The check stays because the cost of being wrong about that is bytes
|
|
542
|
+
// landing outside the folder the caller named.
|
|
543
|
+
if (!target.startsWith(`${destination}/`) || !isInsideRepo(target, kbDirName)) {
|
|
544
|
+
planned.push({ path: rawName, error: 'invalid_entry', message: 'Path escapes destination' });
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
// Through the one bounded reader `unzip` uses too, capped at what is left
|
|
548
|
+
// of the budget: a deflate stream can expand a thousandfold, and the
|
|
549
|
+
// header's declared size is the archive's claim, not a fact — an entry
|
|
550
|
+
// declaring ZERO would otherwise be inflated with no cap at all (see
|
|
551
|
+
// `readZipEntry`). A read that fails is this entry's outcome and no more.
|
|
552
|
+
const read = readZipEntry(entry, APPLY_MAX_TOTAL_BYTES - totalBytes);
|
|
553
|
+
if (!read.ok) {
|
|
554
|
+
planned.push(
|
|
555
|
+
read.reason === 'too_large'
|
|
556
|
+
? { path: rawName, error: 'too_large', message: tooLargeToApply() }
|
|
557
|
+
: { path: rawName, error: 'unreadable_entry', message: `"${rawName}" could not be read: ${read.detail}.` },
|
|
558
|
+
);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
totalBytes += read.data.byteLength;
|
|
562
|
+
planned.push({ path: target, content: read.data });
|
|
563
|
+
}
|
|
564
|
+
return planned;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Record on `entry` that this path was refused, saying what the gate that
|
|
569
|
+
* refused it said. Four kinds of refusal count as one path's outcome: a
|
|
570
|
+
* typed tool refusal (`exists`, `platform_file`, the mode gate), a permission
|
|
571
|
+
* refusal (the caller may not write this path, where the DESTINATION was
|
|
572
|
+
* writable), the git folder in any spelling, and a path-shape refusal from the
|
|
573
|
+
* repository rules. Anything else
|
|
574
|
+
* is not a verdict about this path — it is a gate failing — so it travels on
|
|
575
|
+
* and the whole apply fails loudly, exactly as it does in `write_files`.
|
|
576
|
+
*/
|
|
577
|
+
function refuseEntry(entry: Record<string, unknown>, err: unknown): void {
|
|
578
|
+
entry.outcome = 'refused';
|
|
579
|
+
if (err instanceof ToolError) {
|
|
580
|
+
const details = (err.details ?? {}) as { code?: string; kind?: string };
|
|
581
|
+
entry.error = details.code ?? details.kind ?? 'refused';
|
|
582
|
+
entry.message = err.message;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (err instanceof AccessDeniedError) {
|
|
586
|
+
entry.error = 'write-denied';
|
|
587
|
+
entry.message = err.message;
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (err instanceof GitInternalsError || err instanceof WorkflowValidationError) {
|
|
591
|
+
entry.error = (err.payload as { kind?: string } | undefined)?.kind ?? 'refused';
|
|
592
|
+
entry.message = err.message;
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
throw err;
|
|
596
|
+
}
|
|
597
|
+
|
|
470
598
|
/** The three modes, as a set the handler can check a raw argument against. */
|
|
471
599
|
const WRITE_MODES: readonly WriteMode[] = ['create', 'overwrite', 'update'];
|
|
472
600
|
|
|
@@ -591,7 +719,7 @@ async function grepWalk(
|
|
|
591
719
|
max: number,
|
|
592
720
|
depth: number,
|
|
593
721
|
gate: ReadGate,
|
|
594
|
-
|
|
722
|
+
notifyRead: (path: string) => Promise<void>,
|
|
595
723
|
docs: DocGrepState,
|
|
596
724
|
): Promise<void> {
|
|
597
725
|
if (out.length >= max || depth > 12) return;
|
|
@@ -610,12 +738,12 @@ async function grepWalk(
|
|
|
610
738
|
if (e.type !== 'directory' && isFolderPlaceholder(e.name)) continue;
|
|
611
739
|
const p = dir ? `${dir}/${e.name}` : e.name;
|
|
612
740
|
if (e.type === 'directory') {
|
|
613
|
-
await grepWalk(fs, p, re, out, max, depth + 1, gate,
|
|
741
|
+
await grepWalk(fs, p, re, out, max, depth + 1, gate, notifyRead, docs);
|
|
614
742
|
} else {
|
|
615
|
-
// Opening a file
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
await
|
|
743
|
+
// Opening a file is a read of it, even when the walk started at a root
|
|
744
|
+
// the read hook was already told about — so every file the walk opens
|
|
745
|
+
// reaches the hook by name (closes the read-leak).
|
|
746
|
+
await notifyRead(p);
|
|
619
747
|
// A file the walk cannot read is silently skipped: one unreadable entry
|
|
620
748
|
// must not fail a search over the whole tree.
|
|
621
749
|
try {
|
|
@@ -663,6 +791,20 @@ const BATCH_SAVE_WARNINGS_OUTPUT: JsonSchema = {
|
|
|
663
791
|
items: { type: 'object' },
|
|
664
792
|
};
|
|
665
793
|
|
|
794
|
+
/**
|
|
795
|
+
* The `sessionId` property inside a BUILT tool def's input schema, or
|
|
796
|
+
* `undefined` for a tool that declares none. `toolDef` wraps the flat inputs
|
|
797
|
+
* under `body` and copies the schema it is given, so a note registered after
|
|
798
|
+
* the tools were built has to be written here rather than onto the shared
|
|
799
|
+
* `SESSION_ID_INPUT` constant.
|
|
800
|
+
*/
|
|
801
|
+
function sessionIdInputOf(def: { inputs?: unknown }): { description?: string } | undefined {
|
|
802
|
+
const inputs = def.inputs as
|
|
803
|
+
| { properties?: { body?: { properties?: Record<string, { description?: string }> } } }
|
|
804
|
+
| undefined;
|
|
805
|
+
return inputs?.properties?.body?.properties?.sessionId;
|
|
806
|
+
}
|
|
807
|
+
|
|
666
808
|
/**
|
|
667
809
|
* Workspace domain tools: the file primitives (replacing Mastra's auto-injected
|
|
668
810
|
* Workspace tools) + unzip. Most just re-expose the SAME `LocalFilesystem`
|
|
@@ -681,7 +823,7 @@ export function registerWorkspaceTools(
|
|
|
681
823
|
docExtract: DocExtractService,
|
|
682
824
|
accessControl: IAccessControl,
|
|
683
825
|
kb: KbContext,
|
|
684
|
-
|
|
826
|
+
agentAccessGate: AgentAccessGate,
|
|
685
827
|
writePolicy: IRoutineWritePolicy,
|
|
686
828
|
sessionSink: ISessionSink,
|
|
687
829
|
/**
|
|
@@ -697,6 +839,15 @@ export function registerWorkspaceTools(
|
|
|
697
839
|
* verdict alone then decides, which differs only at a root.
|
|
698
840
|
*/
|
|
699
841
|
changeGate?: IChangeReadGate,
|
|
842
|
+
/**
|
|
843
|
+
* The upload-token store behind `request_file_upload` / `apply_file_upload`
|
|
844
|
+
* — the route an agent lands bytes by, without their content passing
|
|
845
|
+
* through the model. Optional for the same reason the two above are: a tool
|
|
846
|
+
* harness that is about the file primitives need not stand one up. Every
|
|
847
|
+
* real composition wires it (`create-core-server.ts`), and without it the
|
|
848
|
+
* two tools are not mounted at all rather than mounted and broken.
|
|
849
|
+
*/
|
|
850
|
+
uploads?: AgentUploadStore,
|
|
700
851
|
): void {
|
|
701
852
|
const { kbDirName } = kb;
|
|
702
853
|
/**
|
|
@@ -780,7 +931,7 @@ export function registerWorkspaceTools(
|
|
|
780
931
|
* Every spelling first, then the resolved form against the branch's
|
|
781
932
|
* workspace, so a link into the folder is refused the same way. The resolved
|
|
782
933
|
* check only runs on a branch that is already cloned: bootstrapping a clone
|
|
783
|
-
* here would happen before the handler's access and
|
|
934
|
+
* here would happen before the handler's access and agent-access gates. A branch
|
|
784
935
|
* not cloned yet (or that does not resolve) is left to the handler; the
|
|
785
936
|
* filesystem refuses again underneath regardless.
|
|
786
937
|
*/
|
|
@@ -802,7 +953,7 @@ export function registerWorkspaceTools(
|
|
|
802
953
|
* The branch's workspace root, to judge a spelling against what is on disk
|
|
803
954
|
* — or null when there is nothing to judge it against yet. Only a branch
|
|
804
955
|
* ALREADY cloned is used: bootstrapping one here would clone before the
|
|
805
|
-
* handler's access and
|
|
956
|
+
* handler's access and agent-access gates have had their say.
|
|
806
957
|
*/
|
|
807
958
|
const gitCheckRootFor = async (args: Record<string, unknown>, ctx: ToolContext): Promise<string | null> => {
|
|
808
959
|
if (typeof args.branch !== 'string' || args.branch === '') return null;
|
|
@@ -865,6 +1016,128 @@ export function registerWorkspaceTools(
|
|
|
865
1016
|
return { read, write, download, owner };
|
|
866
1017
|
};
|
|
867
1018
|
|
|
1019
|
+
/** Whether any one of the caller's four verdicts differs between the two sides of a preview. */
|
|
1020
|
+
const verbsDiffer = (before: AccessVerbs, after: AccessVerbs): boolean =>
|
|
1021
|
+
(Object.keys(before) as (keyof AccessVerbs)[]).some((v) => before[v] !== after[v]);
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Why `copy_file` will not take a folder. One sentence, said by the dry
|
|
1025
|
+
* run and by the call itself, so the preflight and the execution never
|
|
1026
|
+
* disagree — the rule this whole section is built on.
|
|
1027
|
+
*/
|
|
1028
|
+
const folderCopyRefusal = (src: string): string =>
|
|
1029
|
+
`"${src}" is a folder; copy_file copies one file. Copy its files one by one, or move the folder with move_file.`;
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* The caller's verdicts at `dest` as they will be once `src` has been
|
|
1033
|
+
* moved (or, with `sourceRemains`, copied) there — the `after` half of a
|
|
1034
|
+
* move's or copy's preview.
|
|
1035
|
+
*
|
|
1036
|
+
* `accessAt(dest)` is the wrong answer to that question for a folder: the
|
|
1037
|
+
* destination on disk has neither the folder nor the `access.md` files it
|
|
1038
|
+
* carries, so it describes the destination's PARENT. A rename of a folder
|
|
1039
|
+
* that names the caller owner in its own `access.md` therefore warned
|
|
1040
|
+
* about losing owner access the move was about to hand straight back, and
|
|
1041
|
+
* a warning that is wrong is a warning people learn to click through.
|
|
1042
|
+
*
|
|
1043
|
+
* Preview only, like everything else in this section: it answers what the
|
|
1044
|
+
* caller WILL have, never whether they may do it. The write verdicts that
|
|
1045
|
+
* gate the move are `writeBlocked` and the lock gate, both of which read
|
|
1046
|
+
* the tree as it is.
|
|
1047
|
+
*/
|
|
1048
|
+
const accessAfter = async (
|
|
1049
|
+
branch: string,
|
|
1050
|
+
ctx: ToolContext,
|
|
1051
|
+
src: string,
|
|
1052
|
+
dest: string,
|
|
1053
|
+
opts?: { sourceRemains?: boolean },
|
|
1054
|
+
): Promise<AccessVerbs> => {
|
|
1055
|
+
const from = toKbRelative(src, kbDirName);
|
|
1056
|
+
const to = toKbRelative(dest, kbDirName);
|
|
1057
|
+
// Outside the repository there are no rules to carry, and none to land
|
|
1058
|
+
// among — the same answer `accessAt` gives for such a path.
|
|
1059
|
+
if (from === null || to === null) return accessAt(branch, ctx, dest);
|
|
1060
|
+
return accessControl.previewAccessAfterRelocation(
|
|
1061
|
+
workspaceIdForBranch(branch),
|
|
1062
|
+
ctx.user.email,
|
|
1063
|
+
from,
|
|
1064
|
+
to,
|
|
1065
|
+
opts,
|
|
1066
|
+
);
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* What `copy_file`'s dry run answers: the same impact shape `move_file`
|
|
1071
|
+
* previews, over a copy's own rules.
|
|
1072
|
+
*
|
|
1073
|
+
* A copy LEAVES the source where it is, so the rules it carries are
|
|
1074
|
+
* duplicated rather than relocated (`sourceRemains`) — otherwise the two
|
|
1075
|
+
* previews ask the same question. The order of the refusals is `copy_file`'s
|
|
1076
|
+
* own and is load-bearing: the write verdict on the destination outranks
|
|
1077
|
+
* "that name is taken", because a caller who may not write a folder must
|
|
1078
|
+
* not learn what is in it from a refusal.
|
|
1079
|
+
*
|
|
1080
|
+
* A folder source is reported as the refusal it is. `copy_file` copies one
|
|
1081
|
+
* file; the preview says so rather than promising a copy that would fail,
|
|
1082
|
+
* and still answers `access.after` for the folder it was asked about.
|
|
1083
|
+
*
|
|
1084
|
+
* NOTHING is probed on disk until the write verdict on the destination has
|
|
1085
|
+
* been taken — not the destination, and not the source either, which is the
|
|
1086
|
+
* order the call itself keeps at length: a caller who may not write there
|
|
1087
|
+
* gets the same refusal whether the source is a file, a folder, or missing
|
|
1088
|
+
* altogether. Probing the source first put a 404 in front of that 403 and
|
|
1089
|
+
* handed a denied caller the source's kind and its file count. So a refused
|
|
1090
|
+
* preview answers `allowed: false` with the sentence and no `kind` or
|
|
1091
|
+
* `descendants`: those are the half of the impact the caller has to have
|
|
1092
|
+
* earned. The two `access` sides are the caller's own four verbs and tell
|
|
1093
|
+
* them nothing they could not ask `file_stat` for.
|
|
1094
|
+
*/
|
|
1095
|
+
const copyImpact = async (branch: string, ctx: ToolContext, src: string, dest: string) => {
|
|
1096
|
+
const [before, after, blocked] = await Promise.all([
|
|
1097
|
+
accessAt(branch, ctx, src),
|
|
1098
|
+
accessAfter(branch, ctx, src, dest, { sourceRemains: true }),
|
|
1099
|
+
writeBlocked(branch, ctx, [dest]),
|
|
1100
|
+
]);
|
|
1101
|
+
const access = { before, after };
|
|
1102
|
+
const accessChanges = verbsDiffer(before, after);
|
|
1103
|
+
if (blocked.length > 0) {
|
|
1104
|
+
return {
|
|
1105
|
+
src,
|
|
1106
|
+
dest,
|
|
1107
|
+
access,
|
|
1108
|
+
accessChanges,
|
|
1109
|
+
allowed: false,
|
|
1110
|
+
reason: `You may not write "${dest}", so the copy cannot run.`,
|
|
1111
|
+
dryRun: true,
|
|
1112
|
+
copied: false,
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
const fs = await ctx.getFilesystem(branch);
|
|
1116
|
+
const kind = await kindOf(fs, src);
|
|
1117
|
+
if (kind === null) throw notFound(src, 'Nothing to copy');
|
|
1118
|
+
const srcFiles = kind === 'folder' ? (await filesUnder(fs, src)).files : [src];
|
|
1119
|
+
const occupiedBy = await existingAt(await workspaceRoot(branch, ctx), dest);
|
|
1120
|
+
const reason = occupiedBy !== null
|
|
1121
|
+
? entryExistsMessage(occupiedBy, dest)
|
|
1122
|
+
: kind === 'folder'
|
|
1123
|
+
? folderCopyRefusal(src)
|
|
1124
|
+
: undefined;
|
|
1125
|
+
return {
|
|
1126
|
+
src,
|
|
1127
|
+
dest,
|
|
1128
|
+
kind,
|
|
1129
|
+
// The placeholder travels with its folder, but it is never content —
|
|
1130
|
+
// counted as `move_file` counts it.
|
|
1131
|
+
descendants: srcFiles.filter((f) => !isFolderPlaceholder(f)).length,
|
|
1132
|
+
access,
|
|
1133
|
+
accessChanges,
|
|
1134
|
+
allowed: reason === undefined,
|
|
1135
|
+
...(reason !== undefined ? { reason } : {}),
|
|
1136
|
+
dryRun: true,
|
|
1137
|
+
copied: false,
|
|
1138
|
+
};
|
|
1139
|
+
};
|
|
1140
|
+
|
|
868
1141
|
/**
|
|
869
1142
|
* The paths among `paths` the caller may NOT write, judged exactly as the
|
|
870
1143
|
* lock gate judges them (`WorkflowService.acquireLock`): on a protected
|
|
@@ -1172,6 +1445,14 @@ export function registerWorkspaceTools(
|
|
|
1172
1445
|
proposable?: boolean;
|
|
1173
1446
|
/** False for a tool that is not a file tool (the shell), which the content rule does not describe. */
|
|
1174
1447
|
fileTool?: boolean;
|
|
1448
|
+
/**
|
|
1449
|
+
* This tool runs through the agent-access gate, so a call to it reaches
|
|
1450
|
+
* the deployment's read or write hook. Such a tool carries the note the
|
|
1451
|
+
* deployment registered ({@link ToolDescriptionNotes}) at the end of its
|
|
1452
|
+
* description — core registers none, so on a core-only deployment the
|
|
1453
|
+
* flag adds nothing to what the agent reads.
|
|
1454
|
+
*/
|
|
1455
|
+
gated?: boolean;
|
|
1175
1456
|
/**
|
|
1176
1457
|
* This tool resolves `branch` ITSELF and must not be pre-checked here.
|
|
1177
1458
|
* Only `execute_command` sets it: for an internal session that leaves the
|
|
@@ -1184,10 +1465,14 @@ export function registerWorkspaceTools(
|
|
|
1184
1465
|
handler: ToolHandler;
|
|
1185
1466
|
}): void => {
|
|
1186
1467
|
const path = `/api/agent/tools/${spec.name}`;
|
|
1187
|
-
// Every
|
|
1188
|
-
//
|
|
1189
|
-
// proposal route
|
|
1190
|
-
//
|
|
1468
|
+
// Every description ends with ONE sentence pointing at the rules these
|
|
1469
|
+
// tools share — the content rule, the agent guide, the write modes, the
|
|
1470
|
+
// dry-run protocol, the proposal route. They used to be appended here in
|
|
1471
|
+
// FULL, which made a description several thousand characters of text the
|
|
1472
|
+
// agent had already read on the tool above, and clients cut a long
|
|
1473
|
+
// description from the END, where what is specific to the tool sits. The
|
|
1474
|
+
// rules themselves are in the handshake instructions and in the managed
|
|
1475
|
+
// guide (see `shared-file-rules.ts`), stated once and from one text.
|
|
1191
1476
|
// Whether a call to this tool MUST name a branch, read off the tool's own
|
|
1192
1477
|
// declaration rather than assumed of the family. Every tool mounted here
|
|
1193
1478
|
// requires `branch` today; keying on the schema means a tool that declares
|
|
@@ -1196,9 +1481,8 @@ export function registerWorkspaceTools(
|
|
|
1196
1481
|
const requiresBranch = ((spec.inputs as { required?: string[] }).required ?? []).includes('branch');
|
|
1197
1482
|
const describe = (): string =>
|
|
1198
1483
|
(typeof spec.description === 'function' ? spec.description() : spec.description) +
|
|
1199
|
-
(spec.
|
|
1200
|
-
(
|
|
1201
|
-
kbConventionsNote(kb.layout);
|
|
1484
|
+
(spec.gated ? agentAccessGate.notes.gatedToolNote() : '') +
|
|
1485
|
+
sharedRulesPointer(kb.layout);
|
|
1202
1486
|
const def = toolDef({
|
|
1203
1487
|
name: spec.name,
|
|
1204
1488
|
description: describe(),
|
|
@@ -1209,16 +1493,32 @@ export function registerWorkspaceTools(
|
|
|
1209
1493
|
});
|
|
1210
1494
|
registry.registerInternalTool(def);
|
|
1211
1495
|
if (!spec.internalOnly) registry.registerExternalTool(def);
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1496
|
+
/**
|
|
1497
|
+
* What the agent reads about this tool, rebuilt from whatever is in effect
|
|
1498
|
+
* NOW: the layout's names and the notes the deployment registered.
|
|
1499
|
+
*
|
|
1500
|
+
* The catalog FOLLOWS the layout. The conventions reminder above names the
|
|
1501
|
+
* guide, and several descriptions name it again as a platform file, so the
|
|
1502
|
+
* save that completes first-run setup — which applies the names the admin
|
|
1503
|
+
* just chose, in that same request, without a restart — must be able to
|
|
1504
|
+
* move the text with them. Rewritten in place: the registry holds this
|
|
1505
|
+
* object, both surfaces hold the same one, and re-registering would be a
|
|
1506
|
+
* duplicate name. The `sessionId` input is rewritten on the DEF rather
|
|
1507
|
+
* than on `SESSION_ID_INPUT`, because `toolDef` copies the schema it is
|
|
1508
|
+
* given.
|
|
1509
|
+
*/
|
|
1510
|
+
const redescribe = (): void => {
|
|
1220
1511
|
def.description = describe();
|
|
1221
|
-
|
|
1512
|
+
const sessionId = sessionIdInputOf(def);
|
|
1513
|
+
if (sessionId) sessionId.description = agentAccessGate.notes.sessionIdDescription();
|
|
1514
|
+
};
|
|
1515
|
+
// Once for a note registered BEFORE the tools were mounted (the `sessionId`
|
|
1516
|
+
// input is copied by `toolDef`, so it carries the bare default until this
|
|
1517
|
+
// runs), and then on every later change: a note may be registered AFTER
|
|
1518
|
+
// the mount, from the tool-surface hook an overlay registers on.
|
|
1519
|
+
redescribe();
|
|
1520
|
+
kb.onLayoutApplied(redescribe);
|
|
1521
|
+
agentAccessGate.notes.onChange(redescribe);
|
|
1222
1522
|
// Internal-only tools (e.g. `execute_command`) keep their route mounted —
|
|
1223
1523
|
// our agent calls it over the same loopback — but gate it to internal-source
|
|
1224
1524
|
// callers so an external connection key can't invoke it by name.
|
|
@@ -1288,22 +1588,20 @@ export function registerWorkspaceTools(
|
|
|
1288
1588
|
};
|
|
1289
1589
|
|
|
1290
1590
|
// ── session bootstrap (external agents) ─────────────────────────────────
|
|
1291
|
-
// Every read/write tool below
|
|
1292
|
-
//
|
|
1293
|
-
// agent
|
|
1294
|
-
//
|
|
1295
|
-
// it onto every later
|
|
1591
|
+
// Every read/write tool below takes a `sessionId`: the conversation the
|
|
1592
|
+
// call belongs to, which is what a deployment's hooks scope their rule to.
|
|
1593
|
+
// The in-process agent carries its thread id, but an external agent has no
|
|
1594
|
+
// ambient run id, so this mints one up front (called ONCE); the MCP proxy
|
|
1595
|
+
// then threads it onto every later call via its sessionId-output continuity
|
|
1296
1596
|
// convention. EXTERNAL-ONLY (not registered internal): the in-process agent
|
|
1297
1597
|
// already supplies its session id and ignores any body value.
|
|
1298
1598
|
//
|
|
1299
1599
|
// WHAT the minted id is backed by is the `ISessionSink` port's business
|
|
1300
1600
|
// (session-sink.ts). In the enterprise app it is a REAL chat-thread id, so
|
|
1301
|
-
// the SAME id works end to end:
|
|
1302
|
-
//
|
|
1303
|
-
//
|
|
1304
|
-
//
|
|
1305
|
-
// deployment (no chat/ask) the default sink mints a bare id, which is all
|
|
1306
|
-
// the ontology gate needs.
|
|
1601
|
+
// the SAME id works end to end: the file tools take it AND `ask` accepts it
|
|
1602
|
+
// (its sessionId IS a chat thread, resolved via getThread), so a run's reads
|
|
1603
|
+
// and its questions are one conversation rather than two. In a core-only
|
|
1604
|
+
// deployment (no chat/ask) the default sink mints a bare id.
|
|
1307
1605
|
//
|
|
1308
1606
|
// The description tells the caller that retrying is safe, and that is a
|
|
1309
1607
|
// property of the sink rather than a promise this route makes on its own:
|
|
@@ -1316,7 +1614,7 @@ export function registerWorkspaceTools(
|
|
|
1316
1614
|
const startSessionDef = toolDef({
|
|
1317
1615
|
name: 'start_session',
|
|
1318
1616
|
description:
|
|
1319
|
-
'Mint the
|
|
1617
|
+
'Mint the id of this conversation, which the KnowledgeBase tools take as `sessionId`. Call this ONCE, at the start of your work and only once per run — minting a new id mid-run starts a second conversation as far as the server is concerned. The id is also a chat session in the app, so you can hand the SAME id to the `ask` tool: your reads and your questions are then one conversation. Pass the returned id explicitly as `sessionId` on every subsequent KnowledgeBase tool call (direct MCP calls and inside `call_tool_chain` alike). RETRYING IS SAFE: a call that fails created nothing, so retry it — there is no half-made session to clean up. If a retry lands after a success you simply hold two independent ids, which is harmless: keep passing the one id you have already used for the rest of the run and ignore the other. Returns `{ sessionId }`.',
|
|
1320
1618
|
path: '/api/agent/tools/start_session',
|
|
1321
1619
|
inputs: { type: 'object', properties: {}, additionalProperties: false },
|
|
1322
1620
|
outputs: {
|
|
@@ -1328,12 +1626,12 @@ export function registerWorkspaceTools(
|
|
|
1328
1626
|
});
|
|
1329
1627
|
registry.registerExternalTool(startSessionDef);
|
|
1330
1628
|
// Mint the session id via the sink and return it (see comment above: one id
|
|
1331
|
-
// spans start_session -> reads -> ask
|
|
1629
|
+
// spans start_session -> reads -> ask).
|
|
1332
1630
|
router.post(
|
|
1333
1631
|
'/agent/tools/start_session',
|
|
1334
1632
|
toolAuth,
|
|
1335
1633
|
// External-only: an internal token already carries its run's sessionId, so
|
|
1336
|
-
// minting a new thread mid-run would
|
|
1634
|
+
// minting a new thread mid-run would split one run in two. Note
|
|
1337
1635
|
// "external" includes the MCP proxy's `externalProxy` loopback tokens
|
|
1338
1636
|
// (OAuth/JWT MCP sessions) — the verifier resolves those to
|
|
1339
1637
|
// `source: 'external'`, and one such session may legitimately mint several
|
|
@@ -1348,9 +1646,15 @@ export function registerWorkspaceTools(
|
|
|
1348
1646
|
// ── reads ──────────────────────────────────────────────────────────────
|
|
1349
1647
|
mount({
|
|
1350
1648
|
name: 'read_file',
|
|
1649
|
+
gated: true,
|
|
1351
1650
|
description:
|
|
1352
|
-
'Read a workspace file as text. Returns `{ path, content }`.
|
|
1353
|
-
|
|
1651
|
+
'Read a workspace file as text. Returns `{ path, content }`. What comes back for a document, an email file, an image ' +
|
|
1652
|
+
'or any other binary file is the content rule\'s business (see the shared rules): text files as text, documents and ' +
|
|
1653
|
+
'email files as extracted text, an image as the picture itself, anything else as a one-line description. ' +
|
|
1654
|
+
'Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored ' +
|
|
1655
|
+
'for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. ' +
|
|
1656
|
+
'It also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: such a ref belongs to no ' +
|
|
1657
|
+
'workspace, so `branch` is ignored for it.',
|
|
1354
1658
|
inputs: {
|
|
1355
1659
|
type: 'object',
|
|
1356
1660
|
properties: {
|
|
@@ -1376,12 +1680,12 @@ export function registerWorkspaceTools(
|
|
|
1376
1680
|
if (spillStore.isSpillRef(p)) {
|
|
1377
1681
|
return { path: p, content: await spillStore.read(p, offset, limit) };
|
|
1378
1682
|
}
|
|
1379
|
-
await
|
|
1683
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch as string, p);
|
|
1380
1684
|
await assertCanRead(readGateFor(a.branch as string, ctx), p);
|
|
1381
1685
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
1382
1686
|
// Reading (extraction, image and binary handling included) happens AFTER
|
|
1383
|
-
// the access gate and the
|
|
1384
|
-
//
|
|
1687
|
+
// the access gate and the read hook above — a document read is still a
|
|
1688
|
+
// KB read. ONE registry dispatch picks the reader by
|
|
1385
1689
|
// extension; everything below just maps its ReadResult onto the tool's
|
|
1386
1690
|
// result shape.
|
|
1387
1691
|
const bytes = await orNotFound(p, async () => asBytes(await fs.readFile(p)));
|
|
@@ -1414,9 +1718,9 @@ export function registerWorkspaceTools(
|
|
|
1414
1718
|
|
|
1415
1719
|
mount({
|
|
1416
1720
|
name: 'list_files',
|
|
1721
|
+
gated: true,
|
|
1417
1722
|
description:
|
|
1418
|
-
`List a directory. Returns \`{ path, entries: [{ name, type, size? }] }\`. Omit \`path\` for the workspace root, which holds the repository as the \`${kbDirName}/\` folder: every content path is under it (e.g. \`${kbDirName}/KnowledgeBase\`), and a path given without that prefix is placed under it
|
|
1419
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
1723
|
+
`List a directory. Returns \`{ path, entries: [{ name, type, size? }] }\`. Omit \`path\` for the workspace root, which holds the repository as the \`${kbDirName}/\` folder: every content path is under it (e.g. \`${kbDirName}/KnowledgeBase\`), and a path given without that prefix is placed under it.`,
|
|
1420
1724
|
inputs: {
|
|
1421
1725
|
type: 'object',
|
|
1422
1726
|
properties: {
|
|
@@ -1446,7 +1750,7 @@ export function registerWorkspaceTools(
|
|
|
1446
1750
|
write: false,
|
|
1447
1751
|
handler: async (a, ctx: ToolContext) => {
|
|
1448
1752
|
const dir = (a.path as string) || '';
|
|
1449
|
-
await
|
|
1753
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch as string, dir);
|
|
1450
1754
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
1451
1755
|
const entries = withoutPlaceholder((await fs.readdir(dir || '.')) as DirEntry[]);
|
|
1452
1756
|
const filtered = await filterReadableEntries(readGateFor(a.branch as string, ctx), dir, entries);
|
|
@@ -1456,14 +1760,19 @@ export function registerWorkspaceTools(
|
|
|
1456
1760
|
|
|
1457
1761
|
mount({
|
|
1458
1762
|
name: 'file_stat',
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
'
|
|
1462
|
-
|
|
1463
|
-
'
|
|
1464
|
-
'
|
|
1465
|
-
'
|
|
1466
|
-
|
|
1763
|
+
gated: true,
|
|
1764
|
+
description:
|
|
1765
|
+
'Get a file/directory\'s metadata (name, type, size, …) without returning content, and what you may DO with it. ' +
|
|
1766
|
+
'A file also reports `contentMode`, `kind`, `mime`, `mimeSource` and `textEditable` — decided by the same readers ' +
|
|
1767
|
+
'read_file, grep and the write tools use, so an extensionless text file is `text/plain`. ' +
|
|
1768
|
+
'`access: { read, write, download, owner }` is your own verdict under the access rules; pass `explainAccess: true` to ' +
|
|
1769
|
+
'learn why, and who else holds each verb. ' +
|
|
1770
|
+
'Call this before a move or delete: `managed`, `movable` and `deletable` answer the shared rules on what these tools ' +
|
|
1771
|
+
'never move or delete, judged like the dry runs (on a draft branch writes are not gated); `movable` judges the SOURCE ' +
|
|
1772
|
+
'side only, so the destination still wants a `move_file` dry run. ' +
|
|
1773
|
+
'For a folder, `descendants` counts the files under it at any depth; counting stops at 10000 and ' +
|
|
1774
|
+
'`descendantsTruncated` says so, past which `movable` and `deletable` are false — a folder that large was not judged ' +
|
|
1775
|
+
'in full, so run the `move_file` or `delete_folder` dry run for the real verdict.',
|
|
1467
1776
|
inputs: {
|
|
1468
1777
|
type: 'object',
|
|
1469
1778
|
properties: {
|
|
@@ -1539,7 +1848,7 @@ export function registerWorkspaceTools(
|
|
|
1539
1848
|
handler: async (a, ctx: ToolContext) => {
|
|
1540
1849
|
const p = a.path as string;
|
|
1541
1850
|
const branch = a.branch as string;
|
|
1542
|
-
await
|
|
1851
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, p);
|
|
1543
1852
|
await assertCanRead(readGateFor(branch, ctx), p);
|
|
1544
1853
|
// Nothing there is a 404, and the placeholder — never content — gets
|
|
1545
1854
|
// exactly that answer: the one every file tool gives (see not-found.ts).
|
|
@@ -1640,9 +1949,9 @@ export function registerWorkspaceTools(
|
|
|
1640
1949
|
|
|
1641
1950
|
mount({
|
|
1642
1951
|
name: 'grep',
|
|
1952
|
+
gated: true,
|
|
1643
1953
|
description:
|
|
1644
|
-
'Regex content search across the workspace. Returns `{ matches: [{ path, line, text }] }` (capped). Use to find where something is defined/referenced. `path` may name a DIRECTORY (searches the subtree) or a single FILE (searches just that file); a path with nothing at it is an error, never an empty result. Searches INSIDE Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods), PDFs and email files (.eml/.msg) via their extracted text — matches there carry the extraction\'s line numbers, and the `[slide N]`/`[sheet: Name]`/`[page N]`/`[from]`/`[subject]` marker lines locate them; a bounded number of not-yet-extracted documents is extracted per call, and the result notes how many were skipped (re-run to cover them).'
|
|
1645
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
1954
|
+
'Regex content search across the workspace. Returns `{ matches: [{ path, line, text }] }` (capped). Use to find where something is defined/referenced. `path` may name a DIRECTORY (searches the subtree) or a single FILE (searches just that file); a path with nothing at it is an error, never an empty result. Searches INSIDE Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods), PDFs and email files (.eml/.msg) via their extracted text — matches there carry the extraction\'s line numbers, and the `[slide N]`/`[sheet: Name]`/`[page N]`/`[from]`/`[subject]` marker lines locate them; a bounded number of not-yet-extracted documents is extracted per call, and the result notes how many were skipped (re-run to cover them).',
|
|
1646
1955
|
inputs: {
|
|
1647
1956
|
type: 'object',
|
|
1648
1957
|
properties: {
|
|
@@ -1692,11 +2001,10 @@ export function registerWorkspaceTools(
|
|
|
1692
2001
|
// (an empty path is the handler's to explain), and here it would
|
|
1693
2002
|
// otherwise name the workspace directory by another spelling.
|
|
1694
2003
|
const searchRoot = typeof a.path === 'string' && a.path.length > 0 ? a.path : kbDirName;
|
|
1695
|
-
// The search root itself
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
1698
|
-
|
|
1699
|
-
await recordOntologyRead(sessionOntologyGate, ctx, searchRoot);
|
|
2004
|
+
// The search root itself goes to the read hook here; each file the walk
|
|
2005
|
+
// actually opens goes to it per-file below, so a hook sees every path a
|
|
2006
|
+
// grep reached rather than only the root it started from.
|
|
2007
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch as string, searchRoot);
|
|
1700
2008
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
1701
2009
|
const gate = readGateFor(a.branch as string, ctx);
|
|
1702
2010
|
const out: { path: string; line: number; text: string }[] = [];
|
|
@@ -1723,7 +2031,7 @@ export function registerWorkspaceTools(
|
|
|
1723
2031
|
max,
|
|
1724
2032
|
0,
|
|
1725
2033
|
gate,
|
|
1726
|
-
(p) =>
|
|
2034
|
+
(p) => notifyAgentRead(agentAccessGate, ctx, a.branch as string, p),
|
|
1727
2035
|
docs,
|
|
1728
2036
|
);
|
|
1729
2037
|
} else {
|
|
@@ -1768,12 +2076,11 @@ export function registerWorkspaceTools(
|
|
|
1768
2076
|
// ── writes (through the lock/commit pipeline) ───────────────────────────
|
|
1769
2077
|
mount({
|
|
1770
2078
|
name: 'write_file',
|
|
2079
|
+
gated: true,
|
|
1771
2080
|
description:
|
|
1772
2081
|
'Write a workspace TEXT file. The change is committed + pushed as you. Returns `{ path, bytes, outcome }`, where `outcome` is ' +
|
|
1773
2082
|
'`created`, `replaced` or `updated`.' +
|
|
1774
|
-
|
|
1775
|
-
IMAGE_CONVENTION_NOTE +
|
|
1776
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
2083
|
+
UPLOAD_ROUTE_NOTE,
|
|
1777
2084
|
inputs: {
|
|
1778
2085
|
type: 'object',
|
|
1779
2086
|
properties: {
|
|
@@ -1809,7 +2116,7 @@ export function registerWorkspaceTools(
|
|
|
1809
2116
|
// (today only `watchlist_check`, to `.html`). Unrestricted sessions pass straight
|
|
1810
2117
|
// through (see `assertPathWritable`), so it does not limit other agents.
|
|
1811
2118
|
writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
|
|
1812
|
-
await
|
|
2119
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, a.path as string);
|
|
1813
2120
|
const mode = modeOf(a);
|
|
1814
2121
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
1815
2122
|
await assertNotBinaryOverwrite(readers,a.path as string, fs);
|
|
@@ -1851,18 +2158,17 @@ export function registerWorkspaceTools(
|
|
|
1851
2158
|
|
|
1852
2159
|
mount({
|
|
1853
2160
|
name: 'write_files',
|
|
2161
|
+
gated: true,
|
|
1854
2162
|
description:
|
|
1855
2163
|
'Batch-write many files in ONE commit — far faster than calling write_file once per file when ' +
|
|
1856
2164
|
'creating many files at once (e.g. seeding a knowledge base). Each entry is `{ path, content }`, and the files it ' +
|
|
1857
2165
|
'writes are committed + pushed together as you. Prefer this over many write_file ' +
|
|
1858
|
-
'calls.
|
|
2166
|
+
'calls. Text files only. ' +
|
|
1859
2167
|
'Returns `{ count, files }`: one entry per REQUESTED path, in the order you gave them, each `{ path, outcome }` — ' +
|
|
1860
2168
|
'`created` / `replaced` / `updated` for a path it wrote, or `refused` with `error` (the code) and `message` (why) for a ' +
|
|
1861
2169
|
'path it could not. `count` is how many were written. A path it refuses — the mode said no, or the file is not text — ' +
|
|
1862
2170
|
'does not stop the others; read `files` to see what landed.' +
|
|
1863
|
-
|
|
1864
|
-
IMAGE_CONVENTION_NOTE +
|
|
1865
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
2171
|
+
UPLOAD_ROUTE_NOTE,
|
|
1866
2172
|
inputs: {
|
|
1867
2173
|
type: 'object',
|
|
1868
2174
|
properties: {
|
|
@@ -1915,13 +2221,13 @@ export function registerWorkspaceTools(
|
|
|
1915
2221
|
const files = (a.files as Array<{ path: string; content: string }>) ?? [];
|
|
1916
2222
|
if (files.length === 0) return { count: 0, files: [] };
|
|
1917
2223
|
const mode = modeOf(a);
|
|
1918
|
-
// The POLICY
|
|
1919
|
-
//
|
|
1920
|
-
//
|
|
1921
|
-
//
|
|
1922
|
-
// path already holds (the mode) is
|
|
2224
|
+
// The POLICY gate still judges the whole batch: a restricted run is a
|
|
2225
|
+
// call that should not have been made at all, not a per-path outcome.
|
|
2226
|
+
// The write hook is asked PER PATH, below, so a path it refuses is that
|
|
2227
|
+
// path's outcome and the rest of the batch still lands. What a single
|
|
2228
|
+
// FILE is (not text) or what its path already holds (the mode) is
|
|
2229
|
+
// decided per path too.
|
|
1923
2230
|
for (const f of files) writePolicy.assertPathWritable(ctx.sessionId, f.path);
|
|
1924
|
-
for (const f of files) await assertOntologyWriteAllowed(sessionOntologyGate, ctx, f.path);
|
|
1925
2231
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
1926
2232
|
// `write: true` guarantees a LockingFilesystem here; `writeFiles` lands the
|
|
1927
2233
|
// batch as one commit. Structural cast avoids a workflow-internal import.
|
|
@@ -1950,6 +2256,18 @@ export function registerWorkspaceTools(
|
|
|
1950
2256
|
for (const f of files) {
|
|
1951
2257
|
const entry: Record<string, unknown> = { path: f.path };
|
|
1952
2258
|
outcomes.push(entry);
|
|
2259
|
+
try {
|
|
2260
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, f.path);
|
|
2261
|
+
} catch (err) {
|
|
2262
|
+
// A DELIBERATE refusal by the deployment's write hook is this path's
|
|
2263
|
+
// outcome and no more: its message is what the caller is meant to
|
|
2264
|
+
// read, and one refused path must not take the others down. Anything
|
|
2265
|
+
// else the hook throws is not a verdict — it is the gate itself
|
|
2266
|
+
// failing — so `refuse` rethrows it and the whole batch fails loudly,
|
|
2267
|
+
// exactly as it does in `write_file`.
|
|
2268
|
+
refuse(entry, err);
|
|
2269
|
+
continue;
|
|
2270
|
+
}
|
|
1953
2271
|
try {
|
|
1954
2272
|
assertNotDocumentEdit(readers, f.path);
|
|
1955
2273
|
await assertNotBinaryOverwrite(readers, f.path, fs);
|
|
@@ -2017,9 +2335,10 @@ export function registerWorkspaceTools(
|
|
|
2017
2335
|
|
|
2018
2336
|
mount({
|
|
2019
2337
|
name: 'edit_file',
|
|
2338
|
+
gated: true,
|
|
2020
2339
|
description:
|
|
2021
2340
|
'Replace an exact string in a workspace TEXT file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you.' +
|
|
2022
|
-
|
|
2341
|
+
UPLOAD_ROUTE_NOTE,
|
|
2023
2342
|
inputs: {
|
|
2024
2343
|
type: 'object',
|
|
2025
2344
|
properties: {
|
|
@@ -2043,7 +2362,7 @@ export function registerWorkspaceTools(
|
|
|
2043
2362
|
handler: async (a, ctx: ToolContext) => {
|
|
2044
2363
|
assertNotDocumentEdit(readers, a.path as string);
|
|
2045
2364
|
writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
|
|
2046
|
-
await
|
|
2365
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, a.path as string);
|
|
2047
2366
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
2048
2367
|
const path = a.path as string;
|
|
2049
2368
|
const oldStr = a.old_string as string;
|
|
@@ -2067,10 +2386,9 @@ export function registerWorkspaceTools(
|
|
|
2067
2386
|
|
|
2068
2387
|
mount({
|
|
2069
2388
|
name: 'delete_file',
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
2389
|
+
gated: true,
|
|
2390
|
+
description:
|
|
2391
|
+
'Delete ONE workspace file. Committed + pushed as you. Its folder stays, even when this was its last file. Files only: a folder is refused with a pointer to `delete_folder`.',
|
|
2074
2392
|
inputs: {
|
|
2075
2393
|
type: 'object',
|
|
2076
2394
|
properties: {
|
|
@@ -2089,14 +2407,14 @@ export function registerWorkspaceTools(
|
|
|
2089
2407
|
write: true,
|
|
2090
2408
|
proposable: true,
|
|
2091
2409
|
handler: async (a, ctx: ToolContext) => {
|
|
2092
|
-
// A delete
|
|
2093
|
-
//
|
|
2094
|
-
//
|
|
2095
|
-
//
|
|
2410
|
+
// A delete carries no bytes from anywhere else — it removes a node — so
|
|
2411
|
+
// it goes to the READ hook, like a read, not the write hook. The
|
|
2412
|
+
// extension policy DOES apply though: a dashboard-only run must not
|
|
2413
|
+
// delete graph `.md` nodes.
|
|
2096
2414
|
const path = a.path as string;
|
|
2097
2415
|
const branch = a.branch as string;
|
|
2098
2416
|
writePolicy.assertPathWritable(ctx.sessionId, path);
|
|
2099
|
-
await
|
|
2417
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, path);
|
|
2100
2418
|
const fs = await ctx.getFilesystem(branch);
|
|
2101
2419
|
assertPlainPath(path);
|
|
2102
2420
|
const root = await workspaceRoot(branch, ctx);
|
|
@@ -2120,13 +2438,15 @@ export function registerWorkspaceTools(
|
|
|
2120
2438
|
|
|
2121
2439
|
mount({
|
|
2122
2440
|
name: 'delete_folder',
|
|
2441
|
+
gated: true,
|
|
2123
2442
|
description:
|
|
2124
2443
|
'Delete a workspace FOLDER and every file under it, at any depth; the whole folder lands as ONE committed + pushed change as you — all of it or none of it — then the empty folder is removed. This is the one way a folder goes away: the folder that held it stays, even if this was all it had, and a folder holding nothing but its empty-folder placeholder counts as empty. ' +
|
|
2125
|
-
'
|
|
2126
|
-
'
|
|
2127
|
-
'
|
|
2128
|
-
'
|
|
2129
|
-
|
|
2444
|
+
'The dry run answers `{ path, kind: "folder", descendants, files, filesTruncated, allowed, reason? }` — `descendants` is ' +
|
|
2445
|
+
'the file count, `files` names up to 100 of them — and a non-empty folder wants `confirm: true`. ' +
|
|
2446
|
+
'Beyond what the shared rules refuse, a folder HOLDING a symbolic link, or any file you may not write, is refused ' +
|
|
2447
|
+
'(the link itself is never removed), and a path that is a FILE ' +
|
|
2448
|
+
'is refused with a pointer to `delete_file`. You must be able to write the folder\'s own platform files too: they go ' +
|
|
2449
|
+
'with it in that same one change, so its files are never left ungoverned part-way.',
|
|
2130
2450
|
inputs: {
|
|
2131
2451
|
type: 'object',
|
|
2132
2452
|
properties: {
|
|
@@ -2164,7 +2484,7 @@ export function registerWorkspaceTools(
|
|
|
2164
2484
|
// The normaliser has already placed the path inside the repository; this
|
|
2165
2485
|
// is the check that it really is in there before a folder is walked.
|
|
2166
2486
|
assertInsideRepo(path, kbDirName);
|
|
2167
|
-
await
|
|
2487
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, path);
|
|
2168
2488
|
const fs = await ctx.getFilesystem(branch);
|
|
2169
2489
|
const kind = await kindOf(fs, path);
|
|
2170
2490
|
if (kind === null) throw new ToolError(`"${path}" does not exist.`, 404);
|
|
@@ -2259,7 +2579,8 @@ export function registerWorkspaceTools(
|
|
|
2259
2579
|
|
|
2260
2580
|
mount({
|
|
2261
2581
|
name: 'mkdir',
|
|
2262
|
-
|
|
2582
|
+
gated: true,
|
|
2583
|
+
description: 'Create a directory (recursive). It lists as an empty folder and persists in git until it is deleted explicitly.',
|
|
2263
2584
|
inputs: {
|
|
2264
2585
|
type: 'object',
|
|
2265
2586
|
properties: {
|
|
@@ -2279,7 +2600,7 @@ export function registerWorkspaceTools(
|
|
|
2279
2600
|
proposable: true,
|
|
2280
2601
|
handler: async (a, ctx: ToolContext) => {
|
|
2281
2602
|
writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
|
|
2282
|
-
await
|
|
2603
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, a.path as string);
|
|
2283
2604
|
await (await ctx.getFilesystem(a.branch as string)).mkdir(a.path as string, { recursive: true });
|
|
2284
2605
|
return { path: a.path, created: true };
|
|
2285
2606
|
},
|
|
@@ -2287,12 +2608,16 @@ export function registerWorkspaceTools(
|
|
|
2287
2608
|
|
|
2288
2609
|
mount({
|
|
2289
2610
|
name: 'move_file',
|
|
2290
|
-
|
|
2611
|
+
gated: true,
|
|
2612
|
+
// A plain string again: what refuses a move names the guide, and that is in
|
|
2613
|
+
// the shared rules now, which are rebuilt from the layout where they live.
|
|
2614
|
+
description:
|
|
2291
2615
|
'Move or rename a workspace FILE or FOLDER; a folder moves recursively, with everything under it. `dest` is the full new path, not the folder to move into. Lands as a delete + create, committed + pushed as you. ' +
|
|
2292
|
-
|
|
2293
|
-
'
|
|
2294
|
-
'
|
|
2295
|
-
|
|
2616
|
+
'The destination must not exist — a move never overwrites a file or merges into a folder. Access follows the ' +
|
|
2617
|
+
'DESTINATION folder, so a move can change what you (and others) may do with the file: the dry run answers ' +
|
|
2618
|
+
'`{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }`, where `access` is your ' +
|
|
2619
|
+
'own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the move has landed, ' +
|
|
2620
|
+
'with every `access.md` inside a moved folder counted at its new place, and a move whose `accessChanges` is true wants `confirm: true`.',
|
|
2296
2621
|
inputs: {
|
|
2297
2622
|
type: 'object',
|
|
2298
2623
|
properties: {
|
|
@@ -2313,8 +2638,8 @@ export function registerWorkspaceTools(
|
|
|
2313
2638
|
dest: str('Destination path (echoes the input).'),
|
|
2314
2639
|
kind: str('`file` or `folder`.'),
|
|
2315
2640
|
descendants: int('Files that move: 1 for a file, the file count under a folder.'),
|
|
2316
|
-
access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and destination (`after`).' },
|
|
2317
|
-
accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between
|
|
2641
|
+
access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and at the destination once the move has landed (`after`).' },
|
|
2642
|
+
accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after`.' },
|
|
2318
2643
|
allowed: { type: 'boolean', description: 'Whether the move may run.' },
|
|
2319
2644
|
reason: str('Why it may not, when `allowed` is false.'),
|
|
2320
2645
|
dryRun: { type: 'boolean', description: 'True on a dry run.' },
|
|
@@ -2332,12 +2657,12 @@ export function registerWorkspaceTools(
|
|
|
2332
2657
|
const src = (a.src as string).replace(/\/+$/, '');
|
|
2333
2658
|
const dest = (a.dest as string).replace(/\/+$/, '');
|
|
2334
2659
|
const branch = a.branch as string;
|
|
2335
|
-
// A move CARRIES the source content into the destination
|
|
2336
|
-
//
|
|
2337
|
-
//
|
|
2338
|
-
//
|
|
2339
|
-
await
|
|
2340
|
-
await
|
|
2660
|
+
// A move CARRIES the source content into the destination, so BOTH ends
|
|
2661
|
+
// go to the write hook (unlike a plain delete, which moves no content).
|
|
2662
|
+
// Ask about both BEFORE touching disk, so a refused end can't leave the
|
|
2663
|
+
// source already deleted.
|
|
2664
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, src);
|
|
2665
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, dest);
|
|
2341
2666
|
const fs = await ctx.getFilesystem(branch);
|
|
2342
2667
|
const root = await workspaceRoot(branch, ctx);
|
|
2343
2668
|
// Before the kind check, which stats THROUGH a link: a dangling link at
|
|
@@ -2354,8 +2679,13 @@ export function registerWorkspaceTools(
|
|
|
2354
2679
|
writePolicy.assertPathWritable(ctx.sessionId, dest + f.slice(src.length));
|
|
2355
2680
|
}
|
|
2356
2681
|
|
|
2357
|
-
|
|
2358
|
-
|
|
2682
|
+
// `after` is the destination as it WILL be — with the `access.md` files
|
|
2683
|
+
// under `src` counted where they land. See `accessAfter`.
|
|
2684
|
+
const [before, after] = await Promise.all([
|
|
2685
|
+
accessAt(branch, ctx, src),
|
|
2686
|
+
accessAfter(branch, ctx, src, dest),
|
|
2687
|
+
]);
|
|
2688
|
+
const accessChanges = verbsDiffer(before, after);
|
|
2359
2689
|
// The placeholder moves with its folder, but it is never content.
|
|
2360
2690
|
const descendants = srcFiles.filter((f) => !isFolderPlaceholder(f)).length;
|
|
2361
2691
|
// Neither end may be the platform's own: a move neither takes a platform
|
|
@@ -2448,15 +2778,18 @@ export function registerWorkspaceTools(
|
|
|
2448
2778
|
|
|
2449
2779
|
mount({
|
|
2450
2780
|
name: 'copy_file',
|
|
2781
|
+
gated: true,
|
|
2451
2782
|
description:
|
|
2452
|
-
'Copy a workspace
|
|
2453
|
-
|
|
2783
|
+
'Copy a workspace FILE to a new path. The destination must not exist — like a move, a copy never overwrites a file or a folder; to change what is in a file that already exists, write it. Committed + pushed as you. ' +
|
|
2784
|
+
'Preflight first: `dryRun: true` changes nothing and answers `{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }` — `access` is your own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the copy has landed, with every `access.md` inside a copied folder counted at its new place. ' +
|
|
2785
|
+
'One exception to that shape: when the destination is one you may not write, the answer is the refusal alone — `allowed: false` with `reason`, and no `kind` and no `descendants`, because nothing about the source is read before that verdict.',
|
|
2454
2786
|
inputs: {
|
|
2455
2787
|
type: 'object',
|
|
2456
2788
|
properties: {
|
|
2457
2789
|
branch: BRANCH_INPUT,
|
|
2458
2790
|
src: wsPath(kbDirName, 'Source path'),
|
|
2459
2791
|
dest: wsPath(kbDirName, 'Destination path — must not exist yet'),
|
|
2792
|
+
dryRun: { type: 'boolean', description: 'Answer with the impact and change nothing.' },
|
|
2460
2793
|
sessionId: SESSION_ID_INPUT,
|
|
2461
2794
|
},
|
|
2462
2795
|
required: ['branch', 'src', 'dest'],
|
|
@@ -2464,20 +2797,30 @@ export function registerWorkspaceTools(
|
|
|
2464
2797
|
},
|
|
2465
2798
|
outputs: {
|
|
2466
2799
|
type: 'object',
|
|
2467
|
-
properties: {
|
|
2800
|
+
properties: {
|
|
2801
|
+
src: str('Source path (echoes the input).'),
|
|
2802
|
+
dest: str('Destination path (echoes the input).'),
|
|
2803
|
+
kind: str('`file` or `folder` (dry run only; absent when `allowed` is false because you may not write the destination).'),
|
|
2804
|
+
descendants: int('Files the copy would carry: 1 for a file, the file count under a folder (dry run only; absent when `allowed` is false because you may not write the destination).'),
|
|
2805
|
+
access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and at the destination once the copy has landed (`after`) — dry run only.' },
|
|
2806
|
+
accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after` (dry run only).' },
|
|
2807
|
+
allowed: { type: 'boolean', description: 'Whether the copy may run (dry run only).' },
|
|
2808
|
+
reason: str('Why it may not, when `allowed` is false.'),
|
|
2809
|
+
dryRun: { type: 'boolean', description: 'True on a dry run.' },
|
|
2810
|
+
copied: { type: 'boolean', description: 'True once the copy landed; false on a dry run.' },
|
|
2811
|
+
},
|
|
2468
2812
|
required: ['src', 'dest', 'copied'],
|
|
2469
2813
|
},
|
|
2470
2814
|
write: true,
|
|
2471
2815
|
proposable: true,
|
|
2472
2816
|
handler: async (a, ctx: ToolContext) => {
|
|
2473
|
-
// A copy CARRIES the source content into the destination
|
|
2474
|
-
//
|
|
2475
|
-
// Check both before touching disk.
|
|
2817
|
+
// A copy CARRIES the source content into the destination, so BOTH ends
|
|
2818
|
+
// go to the write hook. Ask about both before touching disk.
|
|
2476
2819
|
writePolicy.assertPathWritable(ctx.sessionId, a.src as string);
|
|
2477
2820
|
writePolicy.assertPathWritable(ctx.sessionId, a.dest as string);
|
|
2478
|
-
await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.src as string);
|
|
2479
|
-
await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.dest as string);
|
|
2480
2821
|
const branch = a.branch as string;
|
|
2822
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, a.src as string);
|
|
2823
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, a.dest as string);
|
|
2481
2824
|
const src = a.src as string;
|
|
2482
2825
|
const dest = a.dest as string;
|
|
2483
2826
|
// A copy lands bytes at a name of its own, so it is refused by the same
|
|
@@ -2489,6 +2832,7 @@ export function registerWorkspaceTools(
|
|
|
2489
2832
|
// own containment check: a path with a `..` segment must not reach
|
|
2490
2833
|
// `lstat` outside the workspace, even to be told a name is taken.
|
|
2491
2834
|
assertPlainPath(dest);
|
|
2835
|
+
if (a.dryRun === true) return copyImpact(branch, ctx, src, dest);
|
|
2492
2836
|
// The write verdict comes FIRST, for the reason `move_file` gives at
|
|
2493
2837
|
// length: "already exists" is a fact about the destination folder, and a
|
|
2494
2838
|
// caller who may not write there must not be told it. The lock gate
|
|
@@ -2525,6 +2869,12 @@ export function registerWorkspaceTools(
|
|
|
2525
2869
|
try {
|
|
2526
2870
|
await asEntryExists(() => fs.copyFile(src, dest));
|
|
2527
2871
|
} catch (err) {
|
|
2872
|
+
// The filesystem's own "that is a directory" becomes the sentence the
|
|
2873
|
+
// dry run predicts, instead of escaping as a 500 carrying the
|
|
2874
|
+
// server's absolute path.
|
|
2875
|
+
if ((err as { name?: string } | null)?.name === 'IsDirectoryError') {
|
|
2876
|
+
throw new ToolError(folderCopyRefusal(src), 400);
|
|
2877
|
+
}
|
|
2528
2878
|
const missing = isAbsence(err) || (err as { name?: string }).name === 'FileNotFoundError';
|
|
2529
2879
|
if (missing) {
|
|
2530
2880
|
throw (await kindOf(fs, src)) === null
|
|
@@ -2539,9 +2889,9 @@ export function registerWorkspaceTools(
|
|
|
2539
2889
|
|
|
2540
2890
|
mount({
|
|
2541
2891
|
name: 'unzip',
|
|
2892
|
+
gated: true,
|
|
2542
2893
|
description:
|
|
2543
|
-
'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.'
|
|
2544
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
2894
|
+
'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.',
|
|
2545
2895
|
inputs: {
|
|
2546
2896
|
type: 'object',
|
|
2547
2897
|
properties: {
|
|
@@ -2573,9 +2923,9 @@ export function registerWorkspaceTools(
|
|
|
2573
2923
|
write: true,
|
|
2574
2924
|
handler: async (a, ctx: ToolContext) => {
|
|
2575
2925
|
const zipPath = a.path as string;
|
|
2576
|
-
//
|
|
2577
|
-
//
|
|
2578
|
-
await
|
|
2926
|
+
// Opening the archive is a read of the archive, so the read hook hears
|
|
2927
|
+
// about it before a single entry is extracted out of it.
|
|
2928
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch as string, zipPath);
|
|
2579
2929
|
// A .zip that is not there is a missing PATH, not an unreadable archive:
|
|
2580
2930
|
// the service now says so (PathNotFoundError) and the helper turns it
|
|
2581
2931
|
// into the same 404 every other file tool answers. Only that declared
|
|
@@ -2587,10 +2937,10 @@ export function registerWorkspaceTools(
|
|
|
2587
2937
|
workspaceIdForBranch(a.branch as string),
|
|
2588
2938
|
zipPath,
|
|
2589
2939
|
typeof a.destination === 'string' ? a.destination : undefined,
|
|
2590
|
-
// Each extracted file is a write:
|
|
2591
|
-
// is skipped (not extracted), so an archive can't
|
|
2592
|
-
// extension policy applies per entry too, so
|
|
2593
|
-
// `.md` into the graph.
|
|
2940
|
+
// Each extracted file is a write of its own: an entry the write
|
|
2941
|
+
// hook refuses is skipped (not extracted), so an archive can't be
|
|
2942
|
+
// a way around it — the extension policy applies per entry too, so
|
|
2943
|
+
// a restricted run can't unzip a `.md` into the graph.
|
|
2594
2944
|
(wsRelPath) => {
|
|
2595
2945
|
// An entry that would land beside the repository is skipped with the
|
|
2596
2946
|
// corrected-path reason, like any other refused entry.
|
|
@@ -2604,7 +2954,7 @@ export function registerWorkspaceTools(
|
|
|
2604
2954
|
);
|
|
2605
2955
|
}
|
|
2606
2956
|
writePolicy.assertPathWritable(ctx.sessionId, wsRelPath);
|
|
2607
|
-
return
|
|
2957
|
+
return assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, wsRelPath);
|
|
2608
2958
|
},
|
|
2609
2959
|
),
|
|
2610
2960
|
'Nothing to extract',
|
|
@@ -2612,12 +2962,341 @@ export function registerWorkspaceTools(
|
|
|
2612
2962
|
},
|
|
2613
2963
|
});
|
|
2614
2964
|
|
|
2965
|
+
// ── uploads (bytes that never pass through the model) ───────────────────
|
|
2966
|
+
//
|
|
2967
|
+
// The pair exists because MCP tool arguments are JSON. Every byte an agent
|
|
2968
|
+
// sends through `write_file` is first typed out by the model, which
|
|
2969
|
+
// truncates long files, mangles backslash and `\u` escapes, and cannot carry
|
|
2970
|
+
// a PNG at all. `request_file_upload` answers an address; the agent POSTs
|
|
2971
|
+
// the file (or one zip holding many) there with any HTTP client;
|
|
2972
|
+
// `apply_file_upload` lands it on a branch in one commit. The bytes go from
|
|
2973
|
+
// the agent's disk to the server's and never enter a prompt.
|
|
2974
|
+
//
|
|
2975
|
+
/**
|
|
2976
|
+
* Why one of an upload's paths may not be landed, judged on the path ALONE —
|
|
2977
|
+
* or undefined when nothing about the name itself refuses it.
|
|
2978
|
+
*
|
|
2979
|
+
* The platform files are the whole of it. `access.md` governs who may read
|
|
2980
|
+
* and write the folder it sits in, `roles.yaml` says which roles exist, and
|
|
2981
|
+
* the agent guide is read as instructions: each is configuration the platform
|
|
2982
|
+
* obeys, and each has a write path that CHECKS the change (the roles gate
|
|
2983
|
+
* refuses an edit that would lock every admin out; a folder's access rules
|
|
2984
|
+
* are judged against who is asking). Bytes arriving by upload meet none of
|
|
2985
|
+
* those gates — they are a buffer the sender chose — so an upload never
|
|
2986
|
+
* lands one, whatever else the caller may write. `unzip` has refused
|
|
2987
|
+
* `roles.yaml` from an archive for the same reason; this is that rule, over
|
|
2988
|
+
* all four names.
|
|
2989
|
+
*/
|
|
2990
|
+
const platformFileReason = (wsPath: string): string | undefined => {
|
|
2991
|
+
const rel = toKbRelative(wsPath, kbDirName);
|
|
2992
|
+
return rel !== null && isPlatformFile(rel, kb.layout) ? platformFileUploadRefusal(rel) : undefined;
|
|
2993
|
+
};
|
|
2994
|
+
|
|
2995
|
+
/**
|
|
2996
|
+
* `apply_file_upload`'s handler: resolve the stored bytes into one path per
|
|
2997
|
+
* file, judge each path the way `write_files` judges its own, and land the
|
|
2998
|
+
* survivors as ONE commit.
|
|
2999
|
+
*
|
|
3000
|
+
* The judging is deliberately the same shape as `write_files`, down to the
|
|
3001
|
+
* second verdict under the lock, because the promise the ticket makes is
|
|
3002
|
+
* that an upload is judged "exactly as `write_file` would judge it". Three
|
|
3003
|
+
* gates run per path and a path that fails one is that path's outcome and no
|
|
3004
|
+
* more: the deployment's write hook, the platform-file rule above, and the
|
|
3005
|
+
* `mode`. What is judged ONCE for the whole call is the destination — a
|
|
3006
|
+
* caller who may not write the folder at all gets one refusal naming the
|
|
3007
|
+
* change-request route, rather than the same refusal repeated per entry.
|
|
3008
|
+
*/
|
|
3009
|
+
const applyFileUpload = async (
|
|
3010
|
+
a: Record<string, unknown>,
|
|
3011
|
+
ctx: ToolContext,
|
|
3012
|
+
uploads: AgentUploadStore,
|
|
3013
|
+
): Promise<unknown> => {
|
|
3014
|
+
const branch = a.branch as string;
|
|
3015
|
+
const token = a.token;
|
|
3016
|
+
if (typeof token !== 'string' || token === '') {
|
|
3017
|
+
throw new ToolError(
|
|
3018
|
+
'Name the `token` `request_file_upload` answered with, after POSTing the file to its `uploadUrl`.',
|
|
3019
|
+
400,
|
|
3020
|
+
{ code: 'token-required' },
|
|
3021
|
+
);
|
|
3022
|
+
}
|
|
3023
|
+
const mode = modeOf(a);
|
|
3024
|
+
const destination = (a.destination as string).replace(/\/+$/, '');
|
|
3025
|
+
assertInsideRepo(destination, kbDirName);
|
|
3026
|
+
// CLAIMED, not consumed: an apply refused whole (a protected destination,
|
|
3027
|
+
// an archive that will not open) leaves the token alive so the caller can
|
|
3028
|
+
// retry somewhere else rather than send the bytes again. The claim is what
|
|
3029
|
+
// keeps it single-use meanwhile — a second apply finds the token in use.
|
|
3030
|
+
const upload = uploads.claim(token, ctx.user.id);
|
|
3031
|
+
let spent = false;
|
|
3032
|
+
try {
|
|
3033
|
+
const fs = await ctx.getFilesystem(branch);
|
|
3034
|
+
const root = await workspaceRoot(branch, ctx);
|
|
3035
|
+
// The destination, once, for the whole call. On a protected branch a
|
|
3036
|
+
// caller who may not write the folder gets the lock gate's own refusal —
|
|
3037
|
+
// which `rethrowAsWriteDenial` turns into `write-denied` with the
|
|
3038
|
+
// change-request steps — and nothing lands.
|
|
3039
|
+
const blockedDest = await writeBlocked(branch, ctx, [destination]);
|
|
3040
|
+
if (blockedDest.length > 0) throw await writeRefusal(branch, blockedDest[0], 'dir');
|
|
3041
|
+
if ((await kindOf(fs, destination)) === 'file') {
|
|
3042
|
+
throw new ToolError(
|
|
3043
|
+
`"${displayPath(destination)}" is a file, not a folder — \`destination\` names the folder the upload lands in.`,
|
|
3044
|
+
409,
|
|
3045
|
+
{ code: 'not_a_folder' },
|
|
3046
|
+
);
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
const planned = await planUpload(upload, destination, kbDirName);
|
|
3050
|
+
const paths = planned.filter((p) => p.content !== undefined).map((p) => p.path as string);
|
|
3051
|
+
// One batched access read for every path, like `write_files` — empty on
|
|
3052
|
+
// a draft branch, where changes reach a protected branch only through a
|
|
3053
|
+
// change request.
|
|
3054
|
+
const blocked = new Set(await writeBlocked(branch, ctx, paths));
|
|
3055
|
+
|
|
3056
|
+
const writes: { path: string; content: Buffer }[] = [];
|
|
3057
|
+
const outcomes: Record<string, unknown>[] = [];
|
|
3058
|
+
/** The `files` entry for `writes[i]`, so the under-lock verdict can revise it. */
|
|
3059
|
+
const entryOf: Record<string, unknown>[] = [];
|
|
3060
|
+
for (const item of planned) {
|
|
3061
|
+
const entry: Record<string, unknown> = { path: item.path };
|
|
3062
|
+
outcomes.push(entry);
|
|
3063
|
+
if (item.content === undefined) {
|
|
3064
|
+
entry.outcome = 'refused';
|
|
3065
|
+
entry.error = item.error;
|
|
3066
|
+
entry.message = item.message;
|
|
3067
|
+
continue;
|
|
3068
|
+
}
|
|
3069
|
+
const wsPathOf = item.path;
|
|
3070
|
+
try {
|
|
3071
|
+
if (blocked.has(wsPathOf)) throw await writeRefusal(branch, wsPathOf);
|
|
3072
|
+
const platform = platformFileReason(wsPathOf);
|
|
3073
|
+
if (platform !== undefined) throw new ToolError(platform, 422, { code: 'platform_file' });
|
|
3074
|
+
// The git folder is never a workspace path, in any spelling. A ZIP
|
|
3075
|
+
// entry's name has already met this rule in `zipEntryNameRefusal`; a
|
|
3076
|
+
// SINGLE uploaded file's has not — `.git` is a name the upload
|
|
3077
|
+
// route's `validateFilename` accepts — and the preflight that reads
|
|
3078
|
+
// the caller's own arguments never sees it either, because the name
|
|
3079
|
+
// came from the upload, not from the call. Asked here so that path
|
|
3080
|
+
// is REFUSED like any other, with the rest of the upload landing,
|
|
3081
|
+
// rather than failing the whole apply from inside `writeFiles`.
|
|
3082
|
+
assertNoGitInternalsSegment(wsPathOf);
|
|
3083
|
+
assertRepoRootNameFree(wsPathOf, kbDirName);
|
|
3084
|
+
// A link already on disk under the destination must not redirect
|
|
3085
|
+
// these bytes — the rule `unzip` applies per entry, applied here on
|
|
3086
|
+
// the path the write will take.
|
|
3087
|
+
const link = await symlinkOnPath(root, wsPathOf);
|
|
3088
|
+
if (link !== undefined) {
|
|
3089
|
+
throw new ToolError(
|
|
3090
|
+
`"${wsPathOf}" goes through the symbolic link "${link}"; an upload never follows links.`,
|
|
3091
|
+
400,
|
|
3092
|
+
{ code: 'symlink' },
|
|
3093
|
+
);
|
|
3094
|
+
}
|
|
3095
|
+
writePolicy.assertPathWritable(ctx.sessionId, wsPathOf);
|
|
3096
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, wsPathOf);
|
|
3097
|
+
// An earlier entry of this same upload counts as existing, as it
|
|
3098
|
+
// does in `write_files`: two `create` entries for one path are a
|
|
3099
|
+
// mistake the commit would otherwise hide.
|
|
3100
|
+
const exists =
|
|
3101
|
+
writes.some((w) => w.path === wsPathOf) || (await kindOf(fs, wsPathOf)) !== null;
|
|
3102
|
+
entry.outcome = decideWrite(mode, wsPathOf, exists);
|
|
3103
|
+
writes.push({ path: wsPathOf, content: item.content });
|
|
3104
|
+
entryOf.push(entry);
|
|
3105
|
+
} catch (err) {
|
|
3106
|
+
refuseEntry(entry, err);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
// The mode gate again, with every path's lock held — the verdict the
|
|
3111
|
+
// answer carries, for the reason `write_file` states at length. A path
|
|
3112
|
+
// whose verdict changed under the lock is dropped from the batch and
|
|
3113
|
+
// reported refused, leaving the rest to land.
|
|
3114
|
+
const recheck = async (
|
|
3115
|
+
pending: readonly { path: string; content: Buffer }[],
|
|
3116
|
+
): Promise<{ path: string; content: Buffer }[]> => {
|
|
3117
|
+
const kept: { path: string; content: Buffer }[] = [];
|
|
3118
|
+
for (let i = 0; i < pending.length; i++) {
|
|
3119
|
+
const entry = entryOf[i];
|
|
3120
|
+
try {
|
|
3121
|
+
const exists =
|
|
3122
|
+
kept.some((k) => k.path === pending[i].path) || (await kindOf(fs, pending[i].path)) !== null;
|
|
3123
|
+
entry.outcome = decideWrite(mode, pending[i].path, exists);
|
|
3124
|
+
kept.push(pending[i]);
|
|
3125
|
+
} catch (err) {
|
|
3126
|
+
refuseEntry(entry, err);
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
return kept;
|
|
3130
|
+
};
|
|
3131
|
+
if (writes.length > 0) {
|
|
3132
|
+
// `write: true` guarantees a LockingFilesystem here; `writeFiles` lands
|
|
3133
|
+
// the whole set as ONE commit and takes a Buffer as content, so bytes
|
|
3134
|
+
// reach disk exactly as they were sent — no text decode anywhere on
|
|
3135
|
+
// the way, which is what makes a PNG and a backslash-heavy page land
|
|
3136
|
+
// with the checksum they were uploaded with.
|
|
3137
|
+
const batching = fs as unknown as {
|
|
3138
|
+
writeFiles(
|
|
3139
|
+
writes: { path: string; content: Buffer }[],
|
|
3140
|
+
summary: string,
|
|
3141
|
+
deletes: string[],
|
|
3142
|
+
check: (
|
|
3143
|
+
pending: readonly { path: string; content: Buffer }[],
|
|
3144
|
+
) => Promise<{ path: string; content: Buffer }[]>,
|
|
3145
|
+
): Promise<void>;
|
|
3146
|
+
};
|
|
3147
|
+
// In the DESTINATION folder's TURN, which `delete_folder` takes over the
|
|
3148
|
+
// same subtree (and `keepFolderOf` with it). `writeFiles` creates the
|
|
3149
|
+
// destination, and any folder above a zip entry on the way to it, as
|
|
3150
|
+
// part of landing the batch — and a folder delete running between that
|
|
3151
|
+
// creation and the commit enumerates the folder's files BEFORE these
|
|
3152
|
+
// exist and then removes the folder they are landing in, which is an
|
|
3153
|
+
// answer saying `created` for bytes that are already gone. The turn is
|
|
3154
|
+
// taken OUTSIDE `writeFiles`, so it is held across the under-lock
|
|
3155
|
+
// recheck and the commit both, and in the same order the delete takes
|
|
3156
|
+
// its own (the folder's turn first, then each path's lock), which is
|
|
3157
|
+
// what keeps two callers from waiting on each other's half.
|
|
3158
|
+
await ctx.workspaceService.withFolderTurn(workspaceIdForBranch(branch), destination, async () => {
|
|
3159
|
+
await batching.writeFiles(writes, `Apply upload of ${writes.length} file(s)`, [], recheck);
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
// The token is spent once an ANSWER exists, even an answer in which
|
|
3163
|
+
// every path was refused: the apply ran and said what happened at each
|
|
3164
|
+
// path, and re-running it would say the same. Only a refusal that landed
|
|
3165
|
+
// nothing AND answered nothing (thrown above) gives the token back.
|
|
3166
|
+
spent = true;
|
|
3167
|
+
await uploads.consume(token);
|
|
3168
|
+
const listed = a.all === true ? outcomes : outcomes.slice(0, APPLY_ANSWER_CAP);
|
|
3169
|
+
return {
|
|
3170
|
+
destination,
|
|
3171
|
+
count: outcomes.filter((o) => o.outcome !== 'refused').length,
|
|
3172
|
+
total: outcomes.length,
|
|
3173
|
+
files: listed,
|
|
3174
|
+
...(listed.length < outcomes.length ? { truncated: true } : {}),
|
|
3175
|
+
};
|
|
3176
|
+
} finally {
|
|
3177
|
+
if (!spent) uploads.release(token);
|
|
3178
|
+
}
|
|
3179
|
+
};
|
|
3180
|
+
|
|
3181
|
+
// Mounted only when the composition supplied a store — see the `uploads`
|
|
3182
|
+
// parameter. Core always does.
|
|
3183
|
+
if (uploads) {
|
|
3184
|
+
mount({
|
|
3185
|
+
name: 'request_file_upload',
|
|
3186
|
+
fileTool: false,
|
|
3187
|
+
description:
|
|
3188
|
+
// Within the description cap (`tool-registry/description-length.ts`):
|
|
3189
|
+
// why a file goes this way is one of the shared rules, and the header
|
|
3190
|
+
// spelling of the token is on the `uploadUrl` output, where the
|
|
3191
|
+
// address it changes is.
|
|
3192
|
+
'Ask for a one-time address to send FILE BYTES to, so their content never passes through this conversation. ' +
|
|
3193
|
+
'Use it for anything `write_file` cannot carry faithfully: a large file, a file full of backslashes or `\\u` ' +
|
|
3194
|
+
'escapes, a binary file (a PNG, a PDF, a zip), or many files at once (zip them). ' +
|
|
3195
|
+
'Returns `{ uploadUrl, token, expiresAt, expiresInSeconds, maxBytes }`. THEN: ' +
|
|
3196
|
+
'(1) POST the file as the raw request body to `uploadUrl` with `?filename=<name>` — ' +
|
|
3197
|
+
'`curl -X POST --data-binary @skill.zip "<uploadUrl>?filename=skill.zip"` — which answers what it received; ' +
|
|
3198
|
+
'(2) call `apply_file_upload` with the same `token`, a `branch` and a destination folder. ' +
|
|
3199
|
+
'One token carries one file or one zip, is bound to you and expires at `expiresAt`: an upload nobody applies ' +
|
|
3200
|
+
'by then is deleted, and one over `maxBytes` is refused when you send it, naming the limit.',
|
|
3201
|
+
inputs: { type: 'object', properties: {}, additionalProperties: false },
|
|
3202
|
+
outputs: {
|
|
3203
|
+
type: 'object',
|
|
3204
|
+
properties: {
|
|
3205
|
+
uploadUrl: str(
|
|
3206
|
+
'The absolute URL to POST the bytes to. Carries the token; add `?filename=<name>`. To keep the token out ' +
|
|
3207
|
+
'of a URL — when the command line you send from is logged or shared — POST to this address without its ' +
|
|
3208
|
+
'last (token) segment and send the token in an `x-upload-token` header instead.',
|
|
3209
|
+
),
|
|
3210
|
+
token: str(
|
|
3211
|
+
'The token itself — what `apply_file_upload` takes, and what an `x-upload-token` header carries when you ' +
|
|
3212
|
+
'would rather it not sit in a URL. Treat it as a credential.',
|
|
3213
|
+
),
|
|
3214
|
+
expiresAt: str('ISO-8601 instant after which the token, and any bytes sent with it, are gone.'),
|
|
3215
|
+
expiresInSeconds: int('Seconds from now until `expiresAt`.'),
|
|
3216
|
+
maxBytes: int('The largest upload this deployment accepts, in bytes.'),
|
|
3217
|
+
},
|
|
3218
|
+
required: ['uploadUrl', 'token', 'expiresAt', 'expiresInSeconds', 'maxBytes'],
|
|
3219
|
+
},
|
|
3220
|
+
// A read-scoped caller has nothing to do with an upload token: the only
|
|
3221
|
+
// thing it unlocks is a write. Refused at the handler factory, by scope,
|
|
3222
|
+
// before the token is minted.
|
|
3223
|
+
write: true,
|
|
3224
|
+
handler: async (_a, ctx: ToolContext) => uploads.issue(ctx.user),
|
|
3225
|
+
});
|
|
3226
|
+
|
|
3227
|
+
mount({
|
|
3228
|
+
name: 'apply_file_upload',
|
|
3229
|
+
gated: true,
|
|
3230
|
+
description:
|
|
3231
|
+
'Land a file you have already uploaded (see `request_file_upload`) in a folder on a branch, in ONE commit, as you. ' +
|
|
3232
|
+
'A single file lands under the name it was sent with; a zip lands as its entries, keeping their folder structure. ' +
|
|
3233
|
+
'Returns `{ destination, count, total, files }`: one entry per path, each `{ path, outcome }` — `created` / ' +
|
|
3234
|
+
'`replaced` / `updated`, or `refused` with `error` (the code) and `message` (why). `count` is how many landed and ' +
|
|
3235
|
+
'`total` how many paths there were; `files` is cut to the first 25 unless you pass `all: true`. ' +
|
|
3236
|
+
'Every path is judged one by one — by your write access, the platform-file rules and what is already there — ' +
|
|
3237
|
+
'exactly as `write_file` judges it, and a refused path does not stop the others. ' +
|
|
3238
|
+
'The token is single-use: it is spent by the apply that lands it, and refused if you use it twice, let it ' +
|
|
3239
|
+
'expire, or present one issued to somebody else. `mode` means what it means on `write_file`.',
|
|
3240
|
+
inputs: {
|
|
3241
|
+
type: 'object',
|
|
3242
|
+
properties: {
|
|
3243
|
+
branch: BRANCH_INPUT,
|
|
3244
|
+
token: str('The `token` from `request_file_upload`, after you have POSTed the file to its `uploadUrl`.'),
|
|
3245
|
+
destination: wsPath(kbDirName, 'Folder the upload lands in (created if it is not there yet)'),
|
|
3246
|
+
mode: WRITE_MODE_INPUT,
|
|
3247
|
+
all: {
|
|
3248
|
+
type: 'boolean',
|
|
3249
|
+
description:
|
|
3250
|
+
'List EVERY path in `files` instead of the first 25. `total` always says how many there were, so ask for ' +
|
|
3251
|
+
'all only when you need to read each outcome.',
|
|
3252
|
+
},
|
|
3253
|
+
sessionId: SESSION_ID_INPUT,
|
|
3254
|
+
},
|
|
3255
|
+
required: ['branch', 'token', 'destination'],
|
|
3256
|
+
additionalProperties: false,
|
|
3257
|
+
},
|
|
3258
|
+
outputs: {
|
|
3259
|
+
type: 'object',
|
|
3260
|
+
properties: {
|
|
3261
|
+
destination: str('The folder the upload was applied to (echoes the input).'),
|
|
3262
|
+
count: int('How many paths landed — the entries in `files` whose `outcome` is not `refused`.'),
|
|
3263
|
+
total: int('How many paths the upload held, whether or not `files` lists them all.'),
|
|
3264
|
+
files: {
|
|
3265
|
+
type: 'array',
|
|
3266
|
+
description: 'One entry per path, in the order the upload held them. Cut to 25 unless `all` was true.',
|
|
3267
|
+
items: {
|
|
3268
|
+
type: 'object',
|
|
3269
|
+
properties: {
|
|
3270
|
+
path: str('The workspace path this entry was judged at.'),
|
|
3271
|
+
outcome: {
|
|
3272
|
+
type: 'string',
|
|
3273
|
+
enum: ['created', 'replaced', 'updated', 'refused'],
|
|
3274
|
+
description: 'What happened at this path. `refused` means nothing was written there.',
|
|
3275
|
+
},
|
|
3276
|
+
error: str('Present when `outcome` is `refused`: the refusal code — e.g. `exists`, `missing`, `invalid_entry`, `platform_file`, `write-denied`.'),
|
|
3277
|
+
message: str('Present when `outcome` is `refused`: the full refusal, the same one `write_file` would have given.'),
|
|
3278
|
+
},
|
|
3279
|
+
required: ['path', 'outcome'],
|
|
3280
|
+
},
|
|
3281
|
+
},
|
|
3282
|
+
truncated: { type: 'boolean', description: 'True when `files` was cut: `total` is larger than what it lists. Pass `all: true` for the rest.' },
|
|
3283
|
+
},
|
|
3284
|
+
required: ['destination', 'count', 'total', 'files'],
|
|
3285
|
+
},
|
|
3286
|
+
write: true,
|
|
3287
|
+
// So a protected-branch refusal arrives as `write-denied`, with the
|
|
3288
|
+
// change-request steps, exactly as it does from write_file.
|
|
3289
|
+
proposable: true,
|
|
3290
|
+
handler: async (a, ctx: ToolContext) => applyFileUpload(a, ctx, uploads),
|
|
3291
|
+
});
|
|
3292
|
+
}
|
|
3293
|
+
|
|
2615
3294
|
// ── shell (internal-only) ───────────────────────────────────────────────
|
|
2616
3295
|
mount({
|
|
2617
3296
|
name: 'execute_command',
|
|
3297
|
+
gated: true,
|
|
2618
3298
|
description:
|
|
2619
|
-
'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.'
|
|
2620
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
3299
|
+
'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.',
|
|
2621
3300
|
internalOnly: true,
|
|
2622
3301
|
fileTool: false,
|
|
2623
3302
|
// The one tool the mount's branch check skips: the handler below resolves an
|
|
@@ -2714,11 +3393,12 @@ export function registerWorkspaceTools(
|
|
|
2714
3393
|
400,
|
|
2715
3394
|
);
|
|
2716
3395
|
}
|
|
2717
|
-
// Shell is a write path with no single target path to check, so
|
|
2718
|
-
//
|
|
2719
|
-
//
|
|
3396
|
+
// Shell is a write path with no single target path to check, so the
|
|
3397
|
+
// write hook is asked once for the call itself, with no path — and the
|
|
3398
|
+
// run must not be restricted to a file type either, since shell could
|
|
3399
|
+
// write anything.
|
|
2720
3400
|
writePolicy.assertUnrestricted(ctx.sessionId);
|
|
2721
|
-
await
|
|
3401
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch);
|
|
2722
3402
|
// Canonical per-branch bootstrap entry point — it owns the workspace-id
|
|
2723
3403
|
// encoding and the single-flight clone, so the shell never derives a
|
|
2724
3404
|
// workspace path by hand.
|