@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,16 +1,17 @@
|
|
|
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 { ToolError } from '../tool-helpers/tool.contract.js';
|
|
5
6
|
import { BRANCH_INPUT, toolDef } from '../tool-helpers/tool-def.js';
|
|
6
|
-
import {
|
|
7
|
+
import { notifyAgentRead, assertAgentWriteAllowed, SESSION_ID_INPUT, } from './agent-access.gate.js';
|
|
7
8
|
import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
|
|
8
9
|
import { workspaceIdForBranch } from '../../shared/workspace-id.js';
|
|
9
|
-
import { assertBranchProvided } from '../../shared/domain-errors.js';
|
|
10
|
+
import { assertBranchProvided, GitInternalsError, WorkflowValidationError } from '../../shared/domain-errors.js';
|
|
10
11
|
// Leaf-level shared primitive (same exception `workspace.service.ts` already
|
|
11
12
|
// relies on) — not a workflow service, so this stays inside the module boundary.
|
|
12
13
|
import { assertValidBranchName } from '../kb-fs/branch-name.js';
|
|
13
|
-
import { assertInsideRepo, assertRepoRootNameFreeArgs, normalizePathArgs } from '../kb-fs/repo-path.js';
|
|
14
|
+
import { assertInsideRepo, assertRepoRootNameFree, assertRepoRootNameFreeArgs, isInsideRepo, normalizePathArgs, } from '../kb-fs/repo-path.js';
|
|
14
15
|
import { GitGuardedFilesystem } from '../kb-fs/git-guarded-filesystem.js';
|
|
15
16
|
import { assertNoGitInternalsSegment, assertNotGitInternals, hasGitInternalsSegment } from '../../shared/git-internals.js';
|
|
16
17
|
import { isRolesYamlPath } from '../access-model/roles-yaml-guard.js';
|
|
@@ -23,14 +24,16 @@ import { fileTypeOf, needsContent } from './file-readers/content-mode.js';
|
|
|
23
24
|
import { createFileReaderRegistry } from './file-readers/file-reader.registry.js';
|
|
24
25
|
import { DocumentReader } from './file-readers/document-reader.js';
|
|
25
26
|
import { mcpImageResult } from '@bevel-software/platform-mcp-core';
|
|
26
|
-
import {
|
|
27
|
+
import { folderPlaceholderPath, isFolderPlaceholder, isPlatformFile, isPlatformFolder, platformFileCreationRefusal, platformFileRefusal, platformFileUploadRefusal, platformFolderRefusal, entryExistsMessage, } from '@bevel-software/platform-shared';
|
|
27
28
|
import { AccessDeniedError } from '../access-model/access-errors.js';
|
|
28
29
|
import { removeEmptyDirs } from './empty-dirs.js';
|
|
29
|
-
import {
|
|
30
|
+
import { rethrowAsWriteDenial } from './write-denial.js';
|
|
31
|
+
import { sharedRulesPointer } from '../agent-instructions/shared-file-rules.js';
|
|
30
32
|
import { notFound, orDeclaredNotFound, orNotFound } from './not-found.js';
|
|
31
33
|
import { logger } from '../../shared/logging.js';
|
|
32
34
|
import { printable } from '../../shared/printable.js';
|
|
33
35
|
import { DestinationTakenError, inspectDestination } from '../../shared/rename-no-replace.js';
|
|
36
|
+
import { isSymlinkZipEntry, isZipNoiseEntry, readZipEntry, zipEntryName, zipEntryNameRefusal, zipEntrySegments, } from './zip-entry-rules.js';
|
|
34
37
|
const log = logger('workspace-tools');
|
|
35
38
|
/** How many files `file_stat` counts under a folder before it stops and says so. */
|
|
36
39
|
const DESCENDANTS_CAP = 10_000;
|
|
@@ -122,53 +125,23 @@ async function keepFolderOf(fs, ctx, branch, removedPath, kbDirName) {
|
|
|
122
125
|
throw err;
|
|
123
126
|
}
|
|
124
127
|
}
|
|
125
|
-
/**
|
|
126
|
-
* Appended (centrally, in `mount`) to EVERY workspace tool description. The
|
|
127
|
-
* platform's managed agent guide sits at the workspace root and documents the
|
|
128
|
-
* conventions of that knowledge base; agents (ours and external) should consult
|
|
129
|
-
* it before touching files. It rides on every entrypoint — reads (grep/
|
|
130
|
-
* list_files/file_stat) included — because any of them can be a session's first
|
|
131
|
-
* touch.
|
|
132
|
-
*
|
|
133
|
-
* `CLAUDE.md` is named as a fallback because knowledge bases seeded before the
|
|
134
|
-
* rename still carry one, and the seeder never deletes a file it did not
|
|
135
|
-
* expect. Naming both means an agent finds the conventions either way, instead
|
|
136
|
-
* of reading none because it looked for the newer name and stopped.
|
|
137
|
-
*
|
|
138
|
-
* WHEN THE GUIDE HAS BEEN RENAMED the sentence names two files, ours first. The
|
|
139
|
-
* second is the organisation's OWN `AGENTS.md`, which on such a deployment is
|
|
140
|
-
* ordinary content the platform never touches — and which no harness reads for a
|
|
141
|
-
* remote agent, because a remote agent has no checkout. Telling it to read both
|
|
142
|
-
* is the only way the conventions the customer actually wrote reach the agent
|
|
143
|
-
* working in their knowledge base. Under the default name the wording collapses
|
|
144
|
-
* to the one file it has always named.
|
|
145
|
-
*
|
|
146
|
-
* A FUNCTION of the layout, called when a description is built: the name is a
|
|
147
|
-
* deployment setting, and a module-scope string would snapshot the default.
|
|
148
|
-
*/
|
|
149
|
-
function kbConventionsNote(layout) {
|
|
150
|
-
const agentsFile = layout.agentsFile ?? LEGACY_AGENTS_FILE;
|
|
151
|
-
if (agentsFile === LEGACY_AGENTS_FILE) {
|
|
152
|
-
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.';
|
|
153
|
-
}
|
|
154
|
-
return (` Before your first read or change in a workspace, read \`${agentsFile}\` at the KB root, then ` +
|
|
155
|
-
'`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.');
|
|
156
|
-
}
|
|
157
|
-
/** The platform files as a tool description lists them — the guide under its own name. */
|
|
158
|
-
function platformFileList(layout) {
|
|
159
|
-
return platformFileNames(layout)
|
|
160
|
-
.map((name) => `\`${name}\``)
|
|
161
|
-
.join(', ');
|
|
162
|
-
}
|
|
163
128
|
const int = (description) => ({ type: 'integer', description });
|
|
164
129
|
const str = (description) => ({ type: 'string', description });
|
|
165
130
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
131
|
+
* The upload route, named on every tool that takes content as a JSON string.
|
|
132
|
+
*
|
|
133
|
+
* ONE sentence, and the tools' own: it is what stops the three failures the
|
|
134
|
+
* route was built for. An agent landing 27 files read each one and typed it
|
|
135
|
+
* out again as a tool argument: a 37 KB write was truncated mid-answer, a page
|
|
136
|
+
* of regex backslashes failed to parse as a JSON parameter, and a PNG could not
|
|
137
|
+
* be sent at all. None of that is discoverable from a refusal — a truncated
|
|
138
|
+
* write reports success — so the tools that invite it name where the bytes
|
|
139
|
+
* should go instead, in the description itself, for a client that reads
|
|
140
|
+
* nothing else. WHY, and how the route is used, is one of the shared rules
|
|
141
|
+
* (`agent-instructions/shared-file-rules.ts`): said in full on three
|
|
142
|
+
* descriptions it took each of them past the length a client cuts at.
|
|
170
143
|
*/
|
|
171
|
-
const
|
|
144
|
+
const UPLOAD_ROUTE_NOTE = ' Large, escape-heavy or binary content does not go through here: use `request_file_upload` + `apply_file_upload`.';
|
|
172
145
|
/**
|
|
173
146
|
* A path input that names the clone folder, and says what happens when it does
|
|
174
147
|
* not. The tools are rooted at the WORKSPACE dir, one level above the git clone,
|
|
@@ -202,6 +175,9 @@ const RESERVED_ROOT_NAME_TARGETS = {
|
|
|
202
175
|
copy_file: ['dest'],
|
|
203
176
|
move_file: ['dest'],
|
|
204
177
|
unzip: ['destination'],
|
|
178
|
+
// The folder the upload lands in. Each of its own paths is checked again
|
|
179
|
+
// inside the handler — an archive chooses its entry names, not the caller.
|
|
180
|
+
apply_file_upload: ['destination'],
|
|
205
181
|
};
|
|
206
182
|
function asText(content) {
|
|
207
183
|
return typeof content === 'string' ? content : content.toString('utf8');
|
|
@@ -219,14 +195,6 @@ function asBytes(content) {
|
|
|
219
195
|
* `read_file`, which extracts unbudgeted) will cover the rest.
|
|
220
196
|
*/
|
|
221
197
|
const UNCACHED_DOCS_PER_GREP = 20;
|
|
222
|
-
/**
|
|
223
|
-
* THE binary capability contract, stated once and appended (in `mount`) to
|
|
224
|
-
* every file tool's description — which is also what `tools_info` returns.
|
|
225
|
-
* The split it states is enforced by the reader registry: the text tools
|
|
226
|
-
* refuse what their reader marks not `textEditable` (and binary content under
|
|
227
|
-
* any name) with a `binary_not_writable` refusal; the byte tools never look.
|
|
228
|
-
*/
|
|
229
|
-
export const CONTENT_RULE = ' 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.';
|
|
230
198
|
/** What a `binary_not_writable` refusal points to, in the order to try them. */
|
|
231
199
|
const BINARY_USE_INSTEAD = ['upload', 'copy_file', 'move_file'];
|
|
232
200
|
/**
|
|
@@ -237,7 +205,7 @@ const BINARY_USE_INSTEAD = ['upload', 'copy_file', 'move_file'];
|
|
|
237
205
|
*/
|
|
238
206
|
function binaryNotWritable(fileKind, explanation) {
|
|
239
207
|
return new ToolError(`${explanation} [binary_not_writable: this file's kind is ${fileKind}; write_file, write_files and edit_file accept text only. ` +
|
|
240
|
-
'Use upload for new bytes (`
|
|
208
|
+
'Use upload for new bytes (`request_file_upload` + `apply_file_upload`, or Upload in the app), ' +
|
|
241
209
|
'or copy_file / move_file to place bytes that are already in the workspace.]', 415, { kind: 'binary_not_writable', fileKind, useInstead: [...BINARY_USE_INSTEAD] });
|
|
242
210
|
}
|
|
243
211
|
/** The generic why, for a reader without format-specific refusal copy. */
|
|
@@ -314,11 +282,6 @@ const WRITE_MODE_INPUT = {
|
|
|
314
282
|
'holds something; `overwrite` replaces what is there, and creates the file when there is nothing; `update` replaces an ' +
|
|
315
283
|
'EXISTING file and refuses (`missing`) a path that holds nothing.',
|
|
316
284
|
};
|
|
317
|
-
/** The same three modes, said once, for both tool descriptions. */
|
|
318
|
-
const WRITE_MODE_NOTE = ' `mode` decides what may happen at a path and DEFAULTS TO `create`: `create` writes a new file and refuses a path that ' +
|
|
319
|
-
'already exists (`exists`, with the path — pass `mode: overwrite` to replace it), `overwrite` replaces what is there ' +
|
|
320
|
-
'(creating it if there is nothing), `update` replaces an existing file and refuses a path that does not exist (`missing`). ' +
|
|
321
|
-
'A refused path is left exactly as it was.';
|
|
322
285
|
/** The refusal `create` gives on a path that already holds something. */
|
|
323
286
|
function pathExists(path) {
|
|
324
287
|
return new ToolError(`"${displayPath(path)}" already exists — pass mode: overwrite to replace it, or write to a different path.`, 409, { code: 'exists', path });
|
|
@@ -342,6 +305,144 @@ function decideWrite(mode, path, exists) {
|
|
|
342
305
|
return 'updated';
|
|
343
306
|
return exists ? 'replaced' : 'created';
|
|
344
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* How many of an upload's paths the answer NAMES before it stops and says how
|
|
310
|
+
* many there were. A 300-file zip's full outcome list is pages of text an agent
|
|
311
|
+
* pays for on every call; 25 is enough to see the shape of what happened, and
|
|
312
|
+
* `total` plus `truncated` say that there is more. `all: true` asks for the
|
|
313
|
+
* rest, for a caller that really does have to read each one.
|
|
314
|
+
*/
|
|
315
|
+
const APPLY_ANSWER_CAP = 25;
|
|
316
|
+
/**
|
|
317
|
+
* How many entries of one uploaded archive are landed, and how many bytes of
|
|
318
|
+
* uncompressed content in total.
|
|
319
|
+
*
|
|
320
|
+
* Tighter than `unzip`'s own caps on purpose. An apply lands its whole set as
|
|
321
|
+
* ONE commit, which means every entry's bytes are held in memory at once —
|
|
322
|
+
* the property that makes the commit atomic is the one that makes a zip bomb
|
|
323
|
+
* expensive. The upload itself is already bounded by the deployment's upload
|
|
324
|
+
* limit; these bound what that upload is allowed to expand into.
|
|
325
|
+
*/
|
|
326
|
+
const APPLY_MAX_ENTRIES = 5_000;
|
|
327
|
+
const APPLY_MAX_TOTAL_BYTES = 128 * 1024 * 1024; // 128 MB uncompressed
|
|
328
|
+
/**
|
|
329
|
+
* Turn a stored upload into one planned path per file.
|
|
330
|
+
*
|
|
331
|
+
* A single file is one path: the destination plus the name it was sent with.
|
|
332
|
+
* A zip is one path per member, with the member's folder structure kept under
|
|
333
|
+
* the destination — judged by the same entry rules `unzip` applies
|
|
334
|
+
* (`zip-entry-rules.ts`), plus one `unzip` does not have: an entry that is a
|
|
335
|
+
* symbolic LINK is refused outright. A zip stores a link as a member whose
|
|
336
|
+
* bytes are its target text, so a reader that ignored the mode bits would
|
|
337
|
+
* write that text out as a file — content nobody sent, under a name that was
|
|
338
|
+
* meant to point elsewhere.
|
|
339
|
+
*/
|
|
340
|
+
/** The refusal an entry gets when the archive would expand past what one commit lands. */
|
|
341
|
+
function tooLargeToApply() {
|
|
342
|
+
return `This archive expands past the ${APPLY_MAX_TOTAL_BYTES} byte total the apply lands in one commit; this entry was not applied.`;
|
|
343
|
+
}
|
|
344
|
+
async function planUpload(upload, destination, kbDirName) {
|
|
345
|
+
const bytes = await nodeFs.readFile(upload.absolutePath);
|
|
346
|
+
if (upload.kind !== 'zip') {
|
|
347
|
+
return [{ path: `${destination}/${upload.filename}`, content: bytes }];
|
|
348
|
+
}
|
|
349
|
+
let zip;
|
|
350
|
+
try {
|
|
351
|
+
zip = new AdmZip(bytes);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
throw new ToolError(`"${upload.filename}" could not be opened as a .zip archive: ${err instanceof Error ? err.message : String(err)}`, 422, { code: 'unreadable_archive' });
|
|
355
|
+
}
|
|
356
|
+
const planned = [];
|
|
357
|
+
let seen = 0;
|
|
358
|
+
let totalBytes = 0;
|
|
359
|
+
for (const entry of zip.getEntries()) {
|
|
360
|
+
const rawName = zipEntryName(entry.entryName);
|
|
361
|
+
if (isZipNoiseEntry(rawName))
|
|
362
|
+
continue;
|
|
363
|
+
if (seen >= APPLY_MAX_ENTRIES) {
|
|
364
|
+
planned.push({
|
|
365
|
+
path: rawName || '(empty)',
|
|
366
|
+
error: 'too_many_entries',
|
|
367
|
+
message: `This archive holds more than ${APPLY_MAX_ENTRIES} entries; the rest were not applied.`,
|
|
368
|
+
});
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
seen++;
|
|
372
|
+
const nameRefusal = zipEntryNameRefusal(rawName);
|
|
373
|
+
if (nameRefusal !== null) {
|
|
374
|
+
planned.push({ path: rawName || '(empty)', error: 'invalid_entry', message: nameRefusal });
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (isSymlinkZipEntry(entry)) {
|
|
378
|
+
planned.push({
|
|
379
|
+
path: rawName,
|
|
380
|
+
error: 'link',
|
|
381
|
+
message: `"${rawName}" is a symbolic link, not a file; an upload lands files, never links.`,
|
|
382
|
+
});
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
// A folder comes into being with the files under it (the write path mkdirs
|
|
386
|
+
// each parent), so a directory member has nothing of its own to land.
|
|
387
|
+
if (entry.isDirectory)
|
|
388
|
+
continue;
|
|
389
|
+
const segments = zipEntrySegments(rawName);
|
|
390
|
+
const target = [destination, ...segments].join('/');
|
|
391
|
+
// Belt and braces: `zipEntryNameRefusal` already refuses a `..` segment
|
|
392
|
+
// and a root-anchored name, so nothing should reach here that climbs out.
|
|
393
|
+
// The check stays because the cost of being wrong about that is bytes
|
|
394
|
+
// landing outside the folder the caller named.
|
|
395
|
+
if (!target.startsWith(`${destination}/`) || !isInsideRepo(target, kbDirName)) {
|
|
396
|
+
planned.push({ path: rawName, error: 'invalid_entry', message: 'Path escapes destination' });
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
// Through the one bounded reader `unzip` uses too, capped at what is left
|
|
400
|
+
// of the budget: a deflate stream can expand a thousandfold, and the
|
|
401
|
+
// header's declared size is the archive's claim, not a fact — an entry
|
|
402
|
+
// declaring ZERO would otherwise be inflated with no cap at all (see
|
|
403
|
+
// `readZipEntry`). A read that fails is this entry's outcome and no more.
|
|
404
|
+
const read = readZipEntry(entry, APPLY_MAX_TOTAL_BYTES - totalBytes);
|
|
405
|
+
if (!read.ok) {
|
|
406
|
+
planned.push(read.reason === 'too_large'
|
|
407
|
+
? { path: rawName, error: 'too_large', message: tooLargeToApply() }
|
|
408
|
+
: { path: rawName, error: 'unreadable_entry', message: `"${rawName}" could not be read: ${read.detail}.` });
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
totalBytes += read.data.byteLength;
|
|
412
|
+
planned.push({ path: target, content: read.data });
|
|
413
|
+
}
|
|
414
|
+
return planned;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Record on `entry` that this path was refused, saying what the gate that
|
|
418
|
+
* refused it said. Four kinds of refusal count as one path's outcome: a
|
|
419
|
+
* typed tool refusal (`exists`, `platform_file`, the mode gate), a permission
|
|
420
|
+
* refusal (the caller may not write this path, where the DESTINATION was
|
|
421
|
+
* writable), the git folder in any spelling, and a path-shape refusal from the
|
|
422
|
+
* repository rules. Anything else
|
|
423
|
+
* is not a verdict about this path — it is a gate failing — so it travels on
|
|
424
|
+
* and the whole apply fails loudly, exactly as it does in `write_files`.
|
|
425
|
+
*/
|
|
426
|
+
function refuseEntry(entry, err) {
|
|
427
|
+
entry.outcome = 'refused';
|
|
428
|
+
if (err instanceof ToolError) {
|
|
429
|
+
const details = (err.details ?? {});
|
|
430
|
+
entry.error = details.code ?? details.kind ?? 'refused';
|
|
431
|
+
entry.message = err.message;
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (err instanceof AccessDeniedError) {
|
|
435
|
+
entry.error = 'write-denied';
|
|
436
|
+
entry.message = err.message;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (err instanceof GitInternalsError || err instanceof WorkflowValidationError) {
|
|
440
|
+
entry.error = err.payload?.kind ?? 'refused';
|
|
441
|
+
entry.message = err.message;
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
throw err;
|
|
445
|
+
}
|
|
345
446
|
/** The three modes, as a set the handler can check a raw argument against. */
|
|
346
447
|
const WRITE_MODES = ['create', 'overwrite', 'update'];
|
|
347
448
|
/**
|
|
@@ -431,7 +532,7 @@ async function searchRootKind(fs, path) {
|
|
|
431
532
|
}
|
|
432
533
|
}
|
|
433
534
|
/** JS grep over the workspace tree (read methods only) — bounded by match + depth caps. */
|
|
434
|
-
async function grepWalk(fs, dir, re, out, max, depth, gate,
|
|
535
|
+
async function grepWalk(fs, dir, re, out, max, depth, gate, notifyRead, docs) {
|
|
435
536
|
if (out.length >= max || depth > 12)
|
|
436
537
|
return;
|
|
437
538
|
let entries;
|
|
@@ -453,13 +554,13 @@ async function grepWalk(fs, dir, re, out, max, depth, gate, recordOntologyRead,
|
|
|
453
554
|
continue;
|
|
454
555
|
const p = dir ? `${dir}/${e.name}` : e.name;
|
|
455
556
|
if (e.type === 'directory') {
|
|
456
|
-
await grepWalk(fs, p, re, out, max, depth + 1, gate,
|
|
557
|
+
await grepWalk(fs, p, re, out, max, depth + 1, gate, notifyRead, docs);
|
|
457
558
|
}
|
|
458
559
|
else {
|
|
459
|
-
// Opening a file
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
await
|
|
560
|
+
// Opening a file is a read of it, even when the walk started at a root
|
|
561
|
+
// the read hook was already told about — so every file the walk opens
|
|
562
|
+
// reaches the hook by name (closes the read-leak).
|
|
563
|
+
await notifyRead(p);
|
|
463
564
|
// A file the walk cannot read is silently skipped: one unreadable entry
|
|
464
565
|
// must not fail a search over the whole tree.
|
|
465
566
|
try {
|
|
@@ -489,6 +590,17 @@ const BATCH_SAVE_WARNINGS_OUTPUT = {
|
|
|
489
590
|
'The writes still happened.',
|
|
490
591
|
items: { type: 'object' },
|
|
491
592
|
};
|
|
593
|
+
/**
|
|
594
|
+
* The `sessionId` property inside a BUILT tool def's input schema, or
|
|
595
|
+
* `undefined` for a tool that declares none. `toolDef` wraps the flat inputs
|
|
596
|
+
* under `body` and copies the schema it is given, so a note registered after
|
|
597
|
+
* the tools were built has to be written here rather than onto the shared
|
|
598
|
+
* `SESSION_ID_INPUT` constant.
|
|
599
|
+
*/
|
|
600
|
+
function sessionIdInputOf(def) {
|
|
601
|
+
const inputs = def.inputs;
|
|
602
|
+
return inputs?.properties?.body?.properties?.sessionId;
|
|
603
|
+
}
|
|
492
604
|
/**
|
|
493
605
|
* Workspace domain tools: the file primitives (replacing Mastra's auto-injected
|
|
494
606
|
* Workspace tools) + unzip. Most just re-expose the SAME `LocalFilesystem`
|
|
@@ -498,7 +610,7 @@ const BATCH_SAVE_WARNINGS_OUTPUT = {
|
|
|
498
610
|
* method), so they're implemented here. File ops are `both`; `execute_command`
|
|
499
611
|
* is INTERNAL-only (arbitrary shell as the caller is too dangerous to expose).
|
|
500
612
|
*/
|
|
501
|
-
export function registerWorkspaceTools(registry, router, toolAuth, toolHandler, spillStore, docExtract, accessControl, kb,
|
|
613
|
+
export function registerWorkspaceTools(registry, router, toolAuth, toolHandler, spillStore, docExtract, accessControl, kb, agentAccessGate, writePolicy, sessionSink,
|
|
502
614
|
/**
|
|
503
615
|
* Save-time skill check (see `AllowedToolsChecker`): a write to a SKILL.md
|
|
504
616
|
* returns `warnings` for `allowed-tools` entries naming no visible tool.
|
|
@@ -511,7 +623,16 @@ skillSaveCheck,
|
|
|
511
623
|
* would be made on. Optional so tool harnesses need not wire it; the read
|
|
512
624
|
* verdict alone then decides, which differs only at a root.
|
|
513
625
|
*/
|
|
514
|
-
changeGate
|
|
626
|
+
changeGate,
|
|
627
|
+
/**
|
|
628
|
+
* The upload-token store behind `request_file_upload` / `apply_file_upload`
|
|
629
|
+
* — the route an agent lands bytes by, without their content passing
|
|
630
|
+
* through the model. Optional for the same reason the two above are: a tool
|
|
631
|
+
* harness that is about the file primitives need not stand one up. Every
|
|
632
|
+
* real composition wires it (`create-core-server.ts`), and without it the
|
|
633
|
+
* two tools are not mounted at all rather than mounted and broken.
|
|
634
|
+
*/
|
|
635
|
+
uploads) {
|
|
515
636
|
const { kbDirName } = kb;
|
|
516
637
|
/**
|
|
517
638
|
* The one extension→reader registry every read-shaped decision routes
|
|
@@ -582,7 +703,7 @@ changeGate) {
|
|
|
582
703
|
* Every spelling first, then the resolved form against the branch's
|
|
583
704
|
* workspace, so a link into the folder is refused the same way. The resolved
|
|
584
705
|
* check only runs on a branch that is already cloned: bootstrapping a clone
|
|
585
|
-
* here would happen before the handler's access and
|
|
706
|
+
* here would happen before the handler's access and agent-access gates. A branch
|
|
586
707
|
* not cloned yet (or that does not resolve) is left to the handler; the
|
|
587
708
|
* filesystem refuses again underneath regardless.
|
|
588
709
|
*/
|
|
@@ -605,7 +726,7 @@ changeGate) {
|
|
|
605
726
|
* The branch's workspace root, to judge a spelling against what is on disk
|
|
606
727
|
* — or null when there is nothing to judge it against yet. Only a branch
|
|
607
728
|
* ALREADY cloned is used: bootstrapping one here would clone before the
|
|
608
|
-
* handler's access and
|
|
729
|
+
* handler's access and agent-access gates have had their say.
|
|
609
730
|
*/
|
|
610
731
|
const gitCheckRootFor = async (args, ctx) => {
|
|
611
732
|
if (typeof args.branch !== 'string' || args.branch === '')
|
|
@@ -672,6 +793,112 @@ changeGate) {
|
|
|
672
793
|
]);
|
|
673
794
|
return { read, write, download, owner };
|
|
674
795
|
};
|
|
796
|
+
/** Whether any one of the caller's four verdicts differs between the two sides of a preview. */
|
|
797
|
+
const verbsDiffer = (before, after) => Object.keys(before).some((v) => before[v] !== after[v]);
|
|
798
|
+
/**
|
|
799
|
+
* Why `copy_file` will not take a folder. One sentence, said by the dry
|
|
800
|
+
* run and by the call itself, so the preflight and the execution never
|
|
801
|
+
* disagree — the rule this whole section is built on.
|
|
802
|
+
*/
|
|
803
|
+
const folderCopyRefusal = (src) => `"${src}" is a folder; copy_file copies one file. Copy its files one by one, or move the folder with move_file.`;
|
|
804
|
+
/**
|
|
805
|
+
* The caller's verdicts at `dest` as they will be once `src` has been
|
|
806
|
+
* moved (or, with `sourceRemains`, copied) there — the `after` half of a
|
|
807
|
+
* move's or copy's preview.
|
|
808
|
+
*
|
|
809
|
+
* `accessAt(dest)` is the wrong answer to that question for a folder: the
|
|
810
|
+
* destination on disk has neither the folder nor the `access.md` files it
|
|
811
|
+
* carries, so it describes the destination's PARENT. A rename of a folder
|
|
812
|
+
* that names the caller owner in its own `access.md` therefore warned
|
|
813
|
+
* about losing owner access the move was about to hand straight back, and
|
|
814
|
+
* a warning that is wrong is a warning people learn to click through.
|
|
815
|
+
*
|
|
816
|
+
* Preview only, like everything else in this section: it answers what the
|
|
817
|
+
* caller WILL have, never whether they may do it. The write verdicts that
|
|
818
|
+
* gate the move are `writeBlocked` and the lock gate, both of which read
|
|
819
|
+
* the tree as it is.
|
|
820
|
+
*/
|
|
821
|
+
const accessAfter = async (branch, ctx, src, dest, opts) => {
|
|
822
|
+
const from = toKbRelative(src, kbDirName);
|
|
823
|
+
const to = toKbRelative(dest, kbDirName);
|
|
824
|
+
// Outside the repository there are no rules to carry, and none to land
|
|
825
|
+
// among — the same answer `accessAt` gives for such a path.
|
|
826
|
+
if (from === null || to === null)
|
|
827
|
+
return accessAt(branch, ctx, dest);
|
|
828
|
+
return accessControl.previewAccessAfterRelocation(workspaceIdForBranch(branch), ctx.user.email, from, to, opts);
|
|
829
|
+
};
|
|
830
|
+
/**
|
|
831
|
+
* What `copy_file`'s dry run answers: the same impact shape `move_file`
|
|
832
|
+
* previews, over a copy's own rules.
|
|
833
|
+
*
|
|
834
|
+
* A copy LEAVES the source where it is, so the rules it carries are
|
|
835
|
+
* duplicated rather than relocated (`sourceRemains`) — otherwise the two
|
|
836
|
+
* previews ask the same question. The order of the refusals is `copy_file`'s
|
|
837
|
+
* own and is load-bearing: the write verdict on the destination outranks
|
|
838
|
+
* "that name is taken", because a caller who may not write a folder must
|
|
839
|
+
* not learn what is in it from a refusal.
|
|
840
|
+
*
|
|
841
|
+
* A folder source is reported as the refusal it is. `copy_file` copies one
|
|
842
|
+
* file; the preview says so rather than promising a copy that would fail,
|
|
843
|
+
* and still answers `access.after` for the folder it was asked about.
|
|
844
|
+
*
|
|
845
|
+
* NOTHING is probed on disk until the write verdict on the destination has
|
|
846
|
+
* been taken — not the destination, and not the source either, which is the
|
|
847
|
+
* order the call itself keeps at length: a caller who may not write there
|
|
848
|
+
* gets the same refusal whether the source is a file, a folder, or missing
|
|
849
|
+
* altogether. Probing the source first put a 404 in front of that 403 and
|
|
850
|
+
* handed a denied caller the source's kind and its file count. So a refused
|
|
851
|
+
* preview answers `allowed: false` with the sentence and no `kind` or
|
|
852
|
+
* `descendants`: those are the half of the impact the caller has to have
|
|
853
|
+
* earned. The two `access` sides are the caller's own four verbs and tell
|
|
854
|
+
* them nothing they could not ask `file_stat` for.
|
|
855
|
+
*/
|
|
856
|
+
const copyImpact = async (branch, ctx, src, dest) => {
|
|
857
|
+
const [before, after, blocked] = await Promise.all([
|
|
858
|
+
accessAt(branch, ctx, src),
|
|
859
|
+
accessAfter(branch, ctx, src, dest, { sourceRemains: true }),
|
|
860
|
+
writeBlocked(branch, ctx, [dest]),
|
|
861
|
+
]);
|
|
862
|
+
const access = { before, after };
|
|
863
|
+
const accessChanges = verbsDiffer(before, after);
|
|
864
|
+
if (blocked.length > 0) {
|
|
865
|
+
return {
|
|
866
|
+
src,
|
|
867
|
+
dest,
|
|
868
|
+
access,
|
|
869
|
+
accessChanges,
|
|
870
|
+
allowed: false,
|
|
871
|
+
reason: `You may not write "${dest}", so the copy cannot run.`,
|
|
872
|
+
dryRun: true,
|
|
873
|
+
copied: false,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
const fs = await ctx.getFilesystem(branch);
|
|
877
|
+
const kind = await kindOf(fs, src);
|
|
878
|
+
if (kind === null)
|
|
879
|
+
throw notFound(src, 'Nothing to copy');
|
|
880
|
+
const srcFiles = kind === 'folder' ? (await filesUnder(fs, src)).files : [src];
|
|
881
|
+
const occupiedBy = await existingAt(await workspaceRoot(branch, ctx), dest);
|
|
882
|
+
const reason = occupiedBy !== null
|
|
883
|
+
? entryExistsMessage(occupiedBy, dest)
|
|
884
|
+
: kind === 'folder'
|
|
885
|
+
? folderCopyRefusal(src)
|
|
886
|
+
: undefined;
|
|
887
|
+
return {
|
|
888
|
+
src,
|
|
889
|
+
dest,
|
|
890
|
+
kind,
|
|
891
|
+
// The placeholder travels with its folder, but it is never content —
|
|
892
|
+
// counted as `move_file` counts it.
|
|
893
|
+
descendants: srcFiles.filter((f) => !isFolderPlaceholder(f)).length,
|
|
894
|
+
access,
|
|
895
|
+
accessChanges,
|
|
896
|
+
allowed: reason === undefined,
|
|
897
|
+
...(reason !== undefined ? { reason } : {}),
|
|
898
|
+
dryRun: true,
|
|
899
|
+
copied: false,
|
|
900
|
+
};
|
|
901
|
+
};
|
|
675
902
|
/**
|
|
676
903
|
* The paths among `paths` the caller may NOT write, judged exactly as the
|
|
677
904
|
* lock gate judges them (`WorkflowService.acquireLock`): on a protected
|
|
@@ -947,10 +1174,14 @@ changeGate) {
|
|
|
947
1174
|
const linkRefusal = (path) => `"${path}" is a symbolic link; the agent tools never follow or remove links.`;
|
|
948
1175
|
const mount = (spec) => {
|
|
949
1176
|
const path = `/api/agent/tools/${spec.name}`;
|
|
950
|
-
// Every
|
|
951
|
-
//
|
|
952
|
-
// proposal route
|
|
953
|
-
//
|
|
1177
|
+
// Every description ends with ONE sentence pointing at the rules these
|
|
1178
|
+
// tools share — the content rule, the agent guide, the write modes, the
|
|
1179
|
+
// dry-run protocol, the proposal route. They used to be appended here in
|
|
1180
|
+
// FULL, which made a description several thousand characters of text the
|
|
1181
|
+
// agent had already read on the tool above, and clients cut a long
|
|
1182
|
+
// description from the END, where what is specific to the tool sits. The
|
|
1183
|
+
// rules themselves are in the handshake instructions and in the managed
|
|
1184
|
+
// guide (see `shared-file-rules.ts`), stated once and from one text.
|
|
954
1185
|
// Whether a call to this tool MUST name a branch, read off the tool's own
|
|
955
1186
|
// declaration rather than assumed of the family. Every tool mounted here
|
|
956
1187
|
// requires `branch` today; keying on the schema means a tool that declares
|
|
@@ -958,9 +1189,8 @@ changeGate) {
|
|
|
958
1189
|
// own route) is not handed a refusal it never asked for.
|
|
959
1190
|
const requiresBranch = (spec.inputs.required ?? []).includes('branch');
|
|
960
1191
|
const describe = () => (typeof spec.description === 'function' ? spec.description() : spec.description) +
|
|
961
|
-
(spec.
|
|
962
|
-
(
|
|
963
|
-
kbConventionsNote(kb.layout);
|
|
1192
|
+
(spec.gated ? agentAccessGate.notes.gatedToolNote() : '') +
|
|
1193
|
+
sharedRulesPointer(kb.layout);
|
|
964
1194
|
const def = toolDef({
|
|
965
1195
|
name: spec.name,
|
|
966
1196
|
description: describe(),
|
|
@@ -972,16 +1202,33 @@ changeGate) {
|
|
|
972
1202
|
registry.registerInternalTool(def);
|
|
973
1203
|
if (!spec.internalOnly)
|
|
974
1204
|
registry.registerExternalTool(def);
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1205
|
+
/**
|
|
1206
|
+
* What the agent reads about this tool, rebuilt from whatever is in effect
|
|
1207
|
+
* NOW: the layout's names and the notes the deployment registered.
|
|
1208
|
+
*
|
|
1209
|
+
* The catalog FOLLOWS the layout. The conventions reminder above names the
|
|
1210
|
+
* guide, and several descriptions name it again as a platform file, so the
|
|
1211
|
+
* save that completes first-run setup — which applies the names the admin
|
|
1212
|
+
* just chose, in that same request, without a restart — must be able to
|
|
1213
|
+
* move the text with them. Rewritten in place: the registry holds this
|
|
1214
|
+
* object, both surfaces hold the same one, and re-registering would be a
|
|
1215
|
+
* duplicate name. The `sessionId` input is rewritten on the DEF rather
|
|
1216
|
+
* than on `SESSION_ID_INPUT`, because `toolDef` copies the schema it is
|
|
1217
|
+
* given.
|
|
1218
|
+
*/
|
|
1219
|
+
const redescribe = () => {
|
|
983
1220
|
def.description = describe();
|
|
984
|
-
|
|
1221
|
+
const sessionId = sessionIdInputOf(def);
|
|
1222
|
+
if (sessionId)
|
|
1223
|
+
sessionId.description = agentAccessGate.notes.sessionIdDescription();
|
|
1224
|
+
};
|
|
1225
|
+
// Once for a note registered BEFORE the tools were mounted (the `sessionId`
|
|
1226
|
+
// input is copied by `toolDef`, so it carries the bare default until this
|
|
1227
|
+
// runs), and then on every later change: a note may be registered AFTER
|
|
1228
|
+
// the mount, from the tool-surface hook an overlay registers on.
|
|
1229
|
+
redescribe();
|
|
1230
|
+
kb.onLayoutApplied(redescribe);
|
|
1231
|
+
agentAccessGate.notes.onChange(redescribe);
|
|
985
1232
|
// Internal-only tools (e.g. `execute_command`) keep their route mounted —
|
|
986
1233
|
// our agent calls it over the same loopback — but gate it to internal-source
|
|
987
1234
|
// callers so an external connection key can't invoke it by name.
|
|
@@ -1039,22 +1286,20 @@ changeGate) {
|
|
|
1039
1286
|
}, { write: spec.write }));
|
|
1040
1287
|
};
|
|
1041
1288
|
// ── session bootstrap (external agents) ─────────────────────────────────
|
|
1042
|
-
// Every read/write tool below
|
|
1043
|
-
//
|
|
1044
|
-
// agent
|
|
1045
|
-
//
|
|
1046
|
-
// it onto every later
|
|
1289
|
+
// Every read/write tool below takes a `sessionId`: the conversation the
|
|
1290
|
+
// call belongs to, which is what a deployment's hooks scope their rule to.
|
|
1291
|
+
// The in-process agent carries its thread id, but an external agent has no
|
|
1292
|
+
// ambient run id, so this mints one up front (called ONCE); the MCP proxy
|
|
1293
|
+
// then threads it onto every later call via its sessionId-output continuity
|
|
1047
1294
|
// convention. EXTERNAL-ONLY (not registered internal): the in-process agent
|
|
1048
1295
|
// already supplies its session id and ignores any body value.
|
|
1049
1296
|
//
|
|
1050
1297
|
// WHAT the minted id is backed by is the `ISessionSink` port's business
|
|
1051
1298
|
// (session-sink.ts). In the enterprise app it is a REAL chat-thread id, so
|
|
1052
|
-
// the SAME id works end to end:
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
// deployment (no chat/ask) the default sink mints a bare id, which is all
|
|
1057
|
-
// the ontology gate needs.
|
|
1299
|
+
// the SAME id works end to end: the file tools take it AND `ask` accepts it
|
|
1300
|
+
// (its sessionId IS a chat thread, resolved via getThread), so a run's reads
|
|
1301
|
+
// and its questions are one conversation rather than two. In a core-only
|
|
1302
|
+
// deployment (no chat/ask) the default sink mints a bare id.
|
|
1058
1303
|
//
|
|
1059
1304
|
// The description tells the caller that retrying is safe, and that is a
|
|
1060
1305
|
// property of the sink rather than a promise this route makes on its own:
|
|
@@ -1066,7 +1311,7 @@ changeGate) {
|
|
|
1066
1311
|
// transport hiccup on its first call as an unrecoverable start.
|
|
1067
1312
|
const startSessionDef = toolDef({
|
|
1068
1313
|
name: 'start_session',
|
|
1069
|
-
description: 'Mint the
|
|
1314
|
+
description: '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 }`.',
|
|
1070
1315
|
path: '/api/agent/tools/start_session',
|
|
1071
1316
|
inputs: { type: 'object', properties: {}, additionalProperties: false },
|
|
1072
1317
|
outputs: {
|
|
@@ -1078,10 +1323,10 @@ changeGate) {
|
|
|
1078
1323
|
});
|
|
1079
1324
|
registry.registerExternalTool(startSessionDef);
|
|
1080
1325
|
// Mint the session id via the sink and return it (see comment above: one id
|
|
1081
|
-
// spans start_session -> reads -> ask
|
|
1326
|
+
// spans start_session -> reads -> ask).
|
|
1082
1327
|
router.post('/agent/tools/start_session', toolAuth,
|
|
1083
1328
|
// External-only: an internal token already carries its run's sessionId, so
|
|
1084
|
-
// minting a new thread mid-run would
|
|
1329
|
+
// minting a new thread mid-run would split one run in two. Note
|
|
1085
1330
|
// "external" includes the MCP proxy's `externalProxy` loopback tokens
|
|
1086
1331
|
// (OAuth/JWT MCP sessions) — the verifier resolves those to
|
|
1087
1332
|
// `source: 'external'`, and one such session may legitimately mint several
|
|
@@ -1093,8 +1338,14 @@ changeGate) {
|
|
|
1093
1338
|
// ── reads ──────────────────────────────────────────────────────────────
|
|
1094
1339
|
mount({
|
|
1095
1340
|
name: 'read_file',
|
|
1096
|
-
|
|
1097
|
-
|
|
1341
|
+
gated: true,
|
|
1342
|
+
description: 'Read a workspace file as text. Returns `{ path, content }`. What comes back for a document, an email file, an image ' +
|
|
1343
|
+
'or any other binary file is the content rule\'s business (see the shared rules): text files as text, documents and ' +
|
|
1344
|
+
'email files as extracted text, an image as the picture itself, anything else as a one-line description. ' +
|
|
1345
|
+
'Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored ' +
|
|
1346
|
+
'for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. ' +
|
|
1347
|
+
'It also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: such a ref belongs to no ' +
|
|
1348
|
+
'workspace, so `branch` is ignored for it.',
|
|
1098
1349
|
inputs: {
|
|
1099
1350
|
type: 'object',
|
|
1100
1351
|
properties: {
|
|
@@ -1120,12 +1371,12 @@ changeGate) {
|
|
|
1120
1371
|
if (spillStore.isSpillRef(p)) {
|
|
1121
1372
|
return { path: p, content: await spillStore.read(p, offset, limit) };
|
|
1122
1373
|
}
|
|
1123
|
-
await
|
|
1374
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch, p);
|
|
1124
1375
|
await assertCanRead(readGateFor(a.branch, ctx), p);
|
|
1125
1376
|
const fs = await ctx.getFilesystem(a.branch);
|
|
1126
1377
|
// Reading (extraction, image and binary handling included) happens AFTER
|
|
1127
|
-
// the access gate and the
|
|
1128
|
-
//
|
|
1378
|
+
// the access gate and the read hook above — a document read is still a
|
|
1379
|
+
// KB read. ONE registry dispatch picks the reader by
|
|
1129
1380
|
// extension; everything below just maps its ReadResult onto the tool's
|
|
1130
1381
|
// result shape.
|
|
1131
1382
|
const bytes = await orNotFound(p, async () => asBytes(await fs.readFile(p)));
|
|
@@ -1157,8 +1408,8 @@ changeGate) {
|
|
|
1157
1408
|
});
|
|
1158
1409
|
mount({
|
|
1159
1410
|
name: 'list_files',
|
|
1160
|
-
|
|
1161
|
-
|
|
1411
|
+
gated: true,
|
|
1412
|
+
description: `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.`,
|
|
1162
1413
|
inputs: {
|
|
1163
1414
|
type: 'object',
|
|
1164
1415
|
properties: {
|
|
@@ -1188,7 +1439,7 @@ changeGate) {
|
|
|
1188
1439
|
write: false,
|
|
1189
1440
|
handler: async (a, ctx) => {
|
|
1190
1441
|
const dir = a.path || '';
|
|
1191
|
-
await
|
|
1442
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch, dir);
|
|
1192
1443
|
const fs = await ctx.getFilesystem(a.branch);
|
|
1193
1444
|
const entries = withoutPlaceholder((await fs.readdir(dir || '.')));
|
|
1194
1445
|
const filtered = await filterReadableEntries(readGateFor(a.branch, ctx), dir, entries);
|
|
@@ -1197,13 +1448,18 @@ changeGate) {
|
|
|
1197
1448
|
});
|
|
1198
1449
|
mount({
|
|
1199
1450
|
name: 'file_stat',
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
'
|
|
1204
|
-
'
|
|
1205
|
-
'
|
|
1206
|
-
|
|
1451
|
+
gated: true,
|
|
1452
|
+
description: 'Get a file/directory\'s metadata (name, type, size, …) without returning content, and what you may DO with it. ' +
|
|
1453
|
+
'A file also reports `contentMode`, `kind`, `mime`, `mimeSource` and `textEditable` — decided by the same readers ' +
|
|
1454
|
+
'read_file, grep and the write tools use, so an extensionless text file is `text/plain`. ' +
|
|
1455
|
+
'`access: { read, write, download, owner }` is your own verdict under the access rules; pass `explainAccess: true` to ' +
|
|
1456
|
+
'learn why, and who else holds each verb. ' +
|
|
1457
|
+
'Call this before a move or delete: `managed`, `movable` and `deletable` answer the shared rules on what these tools ' +
|
|
1458
|
+
'never move or delete, judged like the dry runs (on a draft branch writes are not gated); `movable` judges the SOURCE ' +
|
|
1459
|
+
'side only, so the destination still wants a `move_file` dry run. ' +
|
|
1460
|
+
'For a folder, `descendants` counts the files under it at any depth; counting stops at 10000 and ' +
|
|
1461
|
+
'`descendantsTruncated` says so, past which `movable` and `deletable` are false — a folder that large was not judged ' +
|
|
1462
|
+
'in full, so run the `move_file` or `delete_folder` dry run for the real verdict.',
|
|
1207
1463
|
inputs: {
|
|
1208
1464
|
type: 'object',
|
|
1209
1465
|
properties: {
|
|
@@ -1276,7 +1532,7 @@ changeGate) {
|
|
|
1276
1532
|
handler: async (a, ctx) => {
|
|
1277
1533
|
const p = a.path;
|
|
1278
1534
|
const branch = a.branch;
|
|
1279
|
-
await
|
|
1535
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, p);
|
|
1280
1536
|
await assertCanRead(readGateFor(branch, ctx), p);
|
|
1281
1537
|
// Nothing there is a 404, and the placeholder — never content — gets
|
|
1282
1538
|
// exactly that answer: the one every file tool gives (see not-found.ts).
|
|
@@ -1378,8 +1634,8 @@ changeGate) {
|
|
|
1378
1634
|
});
|
|
1379
1635
|
mount({
|
|
1380
1636
|
name: 'grep',
|
|
1381
|
-
|
|
1382
|
-
|
|
1637
|
+
gated: true,
|
|
1638
|
+
description: '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).',
|
|
1383
1639
|
inputs: {
|
|
1384
1640
|
type: 'object',
|
|
1385
1641
|
properties: {
|
|
@@ -1430,11 +1686,10 @@ changeGate) {
|
|
|
1430
1686
|
// (an empty path is the handler's to explain), and here it would
|
|
1431
1687
|
// otherwise name the workspace directory by another spelling.
|
|
1432
1688
|
const searchRoot = typeof a.path === 'string' && a.path.length > 0 ? a.path : kbDirName;
|
|
1433
|
-
// The search root itself
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
|
|
1437
|
-
await recordOntologyRead(sessionOntologyGate, ctx, searchRoot);
|
|
1689
|
+
// The search root itself goes to the read hook here; each file the walk
|
|
1690
|
+
// actually opens goes to it per-file below, so a hook sees every path a
|
|
1691
|
+
// grep reached rather than only the root it started from.
|
|
1692
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch, searchRoot);
|
|
1438
1693
|
const fs = await ctx.getFilesystem(a.branch);
|
|
1439
1694
|
const gate = readGateFor(a.branch, ctx);
|
|
1440
1695
|
const out = [];
|
|
@@ -1452,7 +1707,7 @@ changeGate) {
|
|
|
1452
1707
|
/** Why a single-file search found nothing, when "no matches" would be a lie. */
|
|
1453
1708
|
let fileNote;
|
|
1454
1709
|
if (kind === 'directory') {
|
|
1455
|
-
await grepWalk(fs, searchRoot, re, out, max, 0, gate, (p) =>
|
|
1710
|
+
await grepWalk(fs, searchRoot, re, out, max, 0, gate, (p) => notifyAgentRead(agentAccessGate, ctx, a.branch, p), docs);
|
|
1456
1711
|
}
|
|
1457
1712
|
else {
|
|
1458
1713
|
// Not a directory: the permission verdict comes BEFORE every other
|
|
@@ -1491,11 +1746,10 @@ changeGate) {
|
|
|
1491
1746
|
// ── writes (through the lock/commit pipeline) ───────────────────────────
|
|
1492
1747
|
mount({
|
|
1493
1748
|
name: 'write_file',
|
|
1749
|
+
gated: true,
|
|
1494
1750
|
description: 'Write a workspace TEXT file. The change is committed + pushed as you. Returns `{ path, bytes, outcome }`, where `outcome` is ' +
|
|
1495
1751
|
'`created`, `replaced` or `updated`.' +
|
|
1496
|
-
|
|
1497
|
-
IMAGE_CONVENTION_NOTE +
|
|
1498
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
1752
|
+
UPLOAD_ROUTE_NOTE,
|
|
1499
1753
|
inputs: {
|
|
1500
1754
|
type: 'object',
|
|
1501
1755
|
properties: {
|
|
@@ -1531,7 +1785,7 @@ changeGate) {
|
|
|
1531
1785
|
// (today only `watchlist_check`, to `.html`). Unrestricted sessions pass straight
|
|
1532
1786
|
// through (see `assertPathWritable`), so it does not limit other agents.
|
|
1533
1787
|
writePolicy.assertPathWritable(ctx.sessionId, a.path);
|
|
1534
|
-
await
|
|
1788
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, a.path);
|
|
1535
1789
|
const mode = modeOf(a);
|
|
1536
1790
|
const fs = await ctx.getFilesystem(a.branch);
|
|
1537
1791
|
await assertNotBinaryOverwrite(readers, a.path, fs);
|
|
@@ -1564,17 +1818,16 @@ changeGate) {
|
|
|
1564
1818
|
});
|
|
1565
1819
|
mount({
|
|
1566
1820
|
name: 'write_files',
|
|
1821
|
+
gated: true,
|
|
1567
1822
|
description: 'Batch-write many files in ONE commit — far faster than calling write_file once per file when ' +
|
|
1568
1823
|
'creating many files at once (e.g. seeding a knowledge base). Each entry is `{ path, content }`, and the files it ' +
|
|
1569
1824
|
'writes are committed + pushed together as you. Prefer this over many write_file ' +
|
|
1570
|
-
'calls.
|
|
1825
|
+
'calls. Text files only. ' +
|
|
1571
1826
|
'Returns `{ count, files }`: one entry per REQUESTED path, in the order you gave them, each `{ path, outcome }` — ' +
|
|
1572
1827
|
'`created` / `replaced` / `updated` for a path it wrote, or `refused` with `error` (the code) and `message` (why) for a ' +
|
|
1573
1828
|
'path it could not. `count` is how many were written. A path it refuses — the mode said no, or the file is not text — ' +
|
|
1574
1829
|
'does not stop the others; read `files` to see what landed.' +
|
|
1575
|
-
|
|
1576
|
-
IMAGE_CONVENTION_NOTE +
|
|
1577
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
1830
|
+
UPLOAD_ROUTE_NOTE,
|
|
1578
1831
|
inputs: {
|
|
1579
1832
|
type: 'object',
|
|
1580
1833
|
properties: {
|
|
@@ -1628,15 +1881,14 @@ changeGate) {
|
|
|
1628
1881
|
if (files.length === 0)
|
|
1629
1882
|
return { count: 0, files: [] };
|
|
1630
1883
|
const mode = modeOf(a);
|
|
1631
|
-
// The POLICY
|
|
1632
|
-
//
|
|
1633
|
-
//
|
|
1634
|
-
//
|
|
1635
|
-
// path already holds (the mode) is
|
|
1884
|
+
// The POLICY gate still judges the whole batch: a restricted run is a
|
|
1885
|
+
// call that should not have been made at all, not a per-path outcome.
|
|
1886
|
+
// The write hook is asked PER PATH, below, so a path it refuses is that
|
|
1887
|
+
// path's outcome and the rest of the batch still lands. What a single
|
|
1888
|
+
// FILE is (not text) or what its path already holds (the mode) is
|
|
1889
|
+
// decided per path too.
|
|
1636
1890
|
for (const f of files)
|
|
1637
1891
|
writePolicy.assertPathWritable(ctx.sessionId, f.path);
|
|
1638
|
-
for (const f of files)
|
|
1639
|
-
await assertOntologyWriteAllowed(sessionOntologyGate, ctx, f.path);
|
|
1640
1892
|
const fs = await ctx.getFilesystem(a.branch);
|
|
1641
1893
|
// `write: true` guarantees a LockingFilesystem here; `writeFiles` lands the
|
|
1642
1894
|
// batch as one commit. Structural cast avoids a workflow-internal import.
|
|
@@ -1657,6 +1909,19 @@ changeGate) {
|
|
|
1657
1909
|
for (const f of files) {
|
|
1658
1910
|
const entry = { path: f.path };
|
|
1659
1911
|
outcomes.push(entry);
|
|
1912
|
+
try {
|
|
1913
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, f.path);
|
|
1914
|
+
}
|
|
1915
|
+
catch (err) {
|
|
1916
|
+
// A DELIBERATE refusal by the deployment's write hook is this path's
|
|
1917
|
+
// outcome and no more: its message is what the caller is meant to
|
|
1918
|
+
// read, and one refused path must not take the others down. Anything
|
|
1919
|
+
// else the hook throws is not a verdict — it is the gate itself
|
|
1920
|
+
// failing — so `refuse` rethrows it and the whole batch fails loudly,
|
|
1921
|
+
// exactly as it does in `write_file`.
|
|
1922
|
+
refuse(entry, err);
|
|
1923
|
+
continue;
|
|
1924
|
+
}
|
|
1660
1925
|
try {
|
|
1661
1926
|
assertNotDocumentEdit(readers, f.path);
|
|
1662
1927
|
await assertNotBinaryOverwrite(readers, f.path, fs);
|
|
@@ -1724,8 +1989,9 @@ changeGate) {
|
|
|
1724
1989
|
});
|
|
1725
1990
|
mount({
|
|
1726
1991
|
name: 'edit_file',
|
|
1992
|
+
gated: true,
|
|
1727
1993
|
description: 'Replace an exact string in a workspace TEXT file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you.' +
|
|
1728
|
-
|
|
1994
|
+
UPLOAD_ROUTE_NOTE,
|
|
1729
1995
|
inputs: {
|
|
1730
1996
|
type: 'object',
|
|
1731
1997
|
properties: {
|
|
@@ -1749,7 +2015,7 @@ changeGate) {
|
|
|
1749
2015
|
handler: async (a, ctx) => {
|
|
1750
2016
|
assertNotDocumentEdit(readers, a.path);
|
|
1751
2017
|
writePolicy.assertPathWritable(ctx.sessionId, a.path);
|
|
1752
|
-
await
|
|
2018
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, a.path);
|
|
1753
2019
|
const fs = await ctx.getFilesystem(a.branch);
|
|
1754
2020
|
const path = a.path;
|
|
1755
2021
|
const oldStr = a.old_string;
|
|
@@ -1773,9 +2039,8 @@ changeGate) {
|
|
|
1773
2039
|
});
|
|
1774
2040
|
mount({
|
|
1775
2041
|
name: 'delete_file',
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
2042
|
+
gated: true,
|
|
2043
|
+
description: '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`.',
|
|
1779
2044
|
inputs: {
|
|
1780
2045
|
type: 'object',
|
|
1781
2046
|
properties: {
|
|
@@ -1794,14 +2059,14 @@ changeGate) {
|
|
|
1794
2059
|
write: true,
|
|
1795
2060
|
proposable: true,
|
|
1796
2061
|
handler: async (a, ctx) => {
|
|
1797
|
-
// A delete
|
|
1798
|
-
//
|
|
1799
|
-
//
|
|
1800
|
-
//
|
|
2062
|
+
// A delete carries no bytes from anywhere else — it removes a node — so
|
|
2063
|
+
// it goes to the READ hook, like a read, not the write hook. The
|
|
2064
|
+
// extension policy DOES apply though: a dashboard-only run must not
|
|
2065
|
+
// delete graph `.md` nodes.
|
|
1801
2066
|
const path = a.path;
|
|
1802
2067
|
const branch = a.branch;
|
|
1803
2068
|
writePolicy.assertPathWritable(ctx.sessionId, path);
|
|
1804
|
-
await
|
|
2069
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, path);
|
|
1805
2070
|
const fs = await ctx.getFilesystem(branch);
|
|
1806
2071
|
assertPlainPath(path);
|
|
1807
2072
|
const root = await workspaceRoot(branch, ctx);
|
|
@@ -1827,12 +2092,14 @@ changeGate) {
|
|
|
1827
2092
|
});
|
|
1828
2093
|
mount({
|
|
1829
2094
|
name: 'delete_folder',
|
|
2095
|
+
gated: true,
|
|
1830
2096
|
description: '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. ' +
|
|
1831
|
-
'
|
|
1832
|
-
'
|
|
1833
|
-
'
|
|
1834
|
-
'
|
|
1835
|
-
|
|
2097
|
+
'The dry run answers `{ path, kind: "folder", descendants, files, filesTruncated, allowed, reason? }` — `descendants` is ' +
|
|
2098
|
+
'the file count, `files` names up to 100 of them — and a non-empty folder wants `confirm: true`. ' +
|
|
2099
|
+
'Beyond what the shared rules refuse, a folder HOLDING a symbolic link, or any file you may not write, is refused ' +
|
|
2100
|
+
'(the link itself is never removed), and a path that is a FILE ' +
|
|
2101
|
+
'is refused with a pointer to `delete_file`. You must be able to write the folder\'s own platform files too: they go ' +
|
|
2102
|
+
'with it in that same one change, so its files are never left ungoverned part-way.',
|
|
1836
2103
|
inputs: {
|
|
1837
2104
|
type: 'object',
|
|
1838
2105
|
properties: {
|
|
@@ -1870,7 +2137,7 @@ changeGate) {
|
|
|
1870
2137
|
// The normaliser has already placed the path inside the repository; this
|
|
1871
2138
|
// is the check that it really is in there before a folder is walked.
|
|
1872
2139
|
assertInsideRepo(path, kbDirName);
|
|
1873
|
-
await
|
|
2140
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, path);
|
|
1874
2141
|
const fs = await ctx.getFilesystem(branch);
|
|
1875
2142
|
const kind = await kindOf(fs, path);
|
|
1876
2143
|
if (kind === null)
|
|
@@ -1964,7 +2231,8 @@ changeGate) {
|
|
|
1964
2231
|
});
|
|
1965
2232
|
mount({
|
|
1966
2233
|
name: 'mkdir',
|
|
1967
|
-
|
|
2234
|
+
gated: true,
|
|
2235
|
+
description: 'Create a directory (recursive). It lists as an empty folder and persists in git until it is deleted explicitly.',
|
|
1968
2236
|
inputs: {
|
|
1969
2237
|
type: 'object',
|
|
1970
2238
|
properties: {
|
|
@@ -1984,18 +2252,22 @@ changeGate) {
|
|
|
1984
2252
|
proposable: true,
|
|
1985
2253
|
handler: async (a, ctx) => {
|
|
1986
2254
|
writePolicy.assertPathWritable(ctx.sessionId, a.path);
|
|
1987
|
-
await
|
|
2255
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, a.path);
|
|
1988
2256
|
await (await ctx.getFilesystem(a.branch)).mkdir(a.path, { recursive: true });
|
|
1989
2257
|
return { path: a.path, created: true };
|
|
1990
2258
|
},
|
|
1991
2259
|
});
|
|
1992
2260
|
mount({
|
|
1993
2261
|
name: 'move_file',
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
2262
|
+
gated: true,
|
|
2263
|
+
// A plain string again: what refuses a move names the guide, and that is in
|
|
2264
|
+
// the shared rules now, which are rebuilt from the layout where they live.
|
|
2265
|
+
description: '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. ' +
|
|
2266
|
+
'The destination must not exist — a move never overwrites a file or merges into a folder. Access follows the ' +
|
|
2267
|
+
'DESTINATION folder, so a move can change what you (and others) may do with the file: the dry run answers ' +
|
|
2268
|
+
'`{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }`, where `access` is your ' +
|
|
2269
|
+
'own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the move has landed, ' +
|
|
2270
|
+
'with every `access.md` inside a moved folder counted at its new place, and a move whose `accessChanges` is true wants `confirm: true`.',
|
|
1999
2271
|
inputs: {
|
|
2000
2272
|
type: 'object',
|
|
2001
2273
|
properties: {
|
|
@@ -2016,8 +2288,8 @@ changeGate) {
|
|
|
2016
2288
|
dest: str('Destination path (echoes the input).'),
|
|
2017
2289
|
kind: str('`file` or `folder`.'),
|
|
2018
2290
|
descendants: int('Files that move: 1 for a file, the file count under a folder.'),
|
|
2019
|
-
access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and destination (`after`).' },
|
|
2020
|
-
accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between
|
|
2291
|
+
access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and at the destination once the move has landed (`after`).' },
|
|
2292
|
+
accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after`.' },
|
|
2021
2293
|
allowed: { type: 'boolean', description: 'Whether the move may run.' },
|
|
2022
2294
|
reason: str('Why it may not, when `allowed` is false.'),
|
|
2023
2295
|
dryRun: { type: 'boolean', description: 'True on a dry run.' },
|
|
@@ -2035,12 +2307,12 @@ changeGate) {
|
|
|
2035
2307
|
const src = a.src.replace(/\/+$/, '');
|
|
2036
2308
|
const dest = a.dest.replace(/\/+$/, '');
|
|
2037
2309
|
const branch = a.branch;
|
|
2038
|
-
// A move CARRIES the source content into the destination
|
|
2039
|
-
//
|
|
2040
|
-
//
|
|
2041
|
-
//
|
|
2042
|
-
await
|
|
2043
|
-
await
|
|
2310
|
+
// A move CARRIES the source content into the destination, so BOTH ends
|
|
2311
|
+
// go to the write hook (unlike a plain delete, which moves no content).
|
|
2312
|
+
// Ask about both BEFORE touching disk, so a refused end can't leave the
|
|
2313
|
+
// source already deleted.
|
|
2314
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, src);
|
|
2315
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, dest);
|
|
2044
2316
|
const fs = await ctx.getFilesystem(branch);
|
|
2045
2317
|
const root = await workspaceRoot(branch, ctx);
|
|
2046
2318
|
// Before the kind check, which stats THROUGH a link: a dangling link at
|
|
@@ -2057,8 +2329,13 @@ changeGate) {
|
|
|
2057
2329
|
writePolicy.assertPathWritable(ctx.sessionId, f);
|
|
2058
2330
|
writePolicy.assertPathWritable(ctx.sessionId, dest + f.slice(src.length));
|
|
2059
2331
|
}
|
|
2060
|
-
|
|
2061
|
-
|
|
2332
|
+
// `after` is the destination as it WILL be — with the `access.md` files
|
|
2333
|
+
// under `src` counted where they land. See `accessAfter`.
|
|
2334
|
+
const [before, after] = await Promise.all([
|
|
2335
|
+
accessAt(branch, ctx, src),
|
|
2336
|
+
accessAfter(branch, ctx, src, dest),
|
|
2337
|
+
]);
|
|
2338
|
+
const accessChanges = verbsDiffer(before, after);
|
|
2062
2339
|
// The placeholder moves with its folder, but it is never content.
|
|
2063
2340
|
const descendants = srcFiles.filter((f) => !isFolderPlaceholder(f)).length;
|
|
2064
2341
|
// Neither end may be the platform's own: a move neither takes a platform
|
|
@@ -2153,14 +2430,17 @@ changeGate) {
|
|
|
2153
2430
|
});
|
|
2154
2431
|
mount({
|
|
2155
2432
|
name: 'copy_file',
|
|
2156
|
-
|
|
2157
|
-
|
|
2433
|
+
gated: true,
|
|
2434
|
+
description: '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. ' +
|
|
2435
|
+
'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. ' +
|
|
2436
|
+
'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.',
|
|
2158
2437
|
inputs: {
|
|
2159
2438
|
type: 'object',
|
|
2160
2439
|
properties: {
|
|
2161
2440
|
branch: BRANCH_INPUT,
|
|
2162
2441
|
src: wsPath(kbDirName, 'Source path'),
|
|
2163
2442
|
dest: wsPath(kbDirName, 'Destination path — must not exist yet'),
|
|
2443
|
+
dryRun: { type: 'boolean', description: 'Answer with the impact and change nothing.' },
|
|
2164
2444
|
sessionId: SESSION_ID_INPUT,
|
|
2165
2445
|
},
|
|
2166
2446
|
required: ['branch', 'src', 'dest'],
|
|
@@ -2168,20 +2448,30 @@ changeGate) {
|
|
|
2168
2448
|
},
|
|
2169
2449
|
outputs: {
|
|
2170
2450
|
type: 'object',
|
|
2171
|
-
properties: {
|
|
2451
|
+
properties: {
|
|
2452
|
+
src: str('Source path (echoes the input).'),
|
|
2453
|
+
dest: str('Destination path (echoes the input).'),
|
|
2454
|
+
kind: str('`file` or `folder` (dry run only; absent when `allowed` is false because you may not write the destination).'),
|
|
2455
|
+
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).'),
|
|
2456
|
+
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.' },
|
|
2457
|
+
accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after` (dry run only).' },
|
|
2458
|
+
allowed: { type: 'boolean', description: 'Whether the copy may run (dry run only).' },
|
|
2459
|
+
reason: str('Why it may not, when `allowed` is false.'),
|
|
2460
|
+
dryRun: { type: 'boolean', description: 'True on a dry run.' },
|
|
2461
|
+
copied: { type: 'boolean', description: 'True once the copy landed; false on a dry run.' },
|
|
2462
|
+
},
|
|
2172
2463
|
required: ['src', 'dest', 'copied'],
|
|
2173
2464
|
},
|
|
2174
2465
|
write: true,
|
|
2175
2466
|
proposable: true,
|
|
2176
2467
|
handler: async (a, ctx) => {
|
|
2177
|
-
// A copy CARRIES the source content into the destination
|
|
2178
|
-
//
|
|
2179
|
-
// Check both before touching disk.
|
|
2468
|
+
// A copy CARRIES the source content into the destination, so BOTH ends
|
|
2469
|
+
// go to the write hook. Ask about both before touching disk.
|
|
2180
2470
|
writePolicy.assertPathWritable(ctx.sessionId, a.src);
|
|
2181
2471
|
writePolicy.assertPathWritable(ctx.sessionId, a.dest);
|
|
2182
|
-
await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.src);
|
|
2183
|
-
await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.dest);
|
|
2184
2472
|
const branch = a.branch;
|
|
2473
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, a.src);
|
|
2474
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, a.dest);
|
|
2185
2475
|
const src = a.src;
|
|
2186
2476
|
const dest = a.dest;
|
|
2187
2477
|
// A copy lands bytes at a name of its own, so it is refused by the same
|
|
@@ -2193,6 +2483,8 @@ changeGate) {
|
|
|
2193
2483
|
// own containment check: a path with a `..` segment must not reach
|
|
2194
2484
|
// `lstat` outside the workspace, even to be told a name is taken.
|
|
2195
2485
|
assertPlainPath(dest);
|
|
2486
|
+
if (a.dryRun === true)
|
|
2487
|
+
return copyImpact(branch, ctx, src, dest);
|
|
2196
2488
|
// The write verdict comes FIRST, for the reason `move_file` gives at
|
|
2197
2489
|
// length: "already exists" is a fact about the destination folder, and a
|
|
2198
2490
|
// caller who may not write there must not be told it. The lock gate
|
|
@@ -2232,6 +2524,12 @@ changeGate) {
|
|
|
2232
2524
|
await asEntryExists(() => fs.copyFile(src, dest));
|
|
2233
2525
|
}
|
|
2234
2526
|
catch (err) {
|
|
2527
|
+
// The filesystem's own "that is a directory" becomes the sentence the
|
|
2528
|
+
// dry run predicts, instead of escaping as a 500 carrying the
|
|
2529
|
+
// server's absolute path.
|
|
2530
|
+
if (err?.name === 'IsDirectoryError') {
|
|
2531
|
+
throw new ToolError(folderCopyRefusal(src), 400);
|
|
2532
|
+
}
|
|
2235
2533
|
const missing = isAbsence(err) || err.name === 'FileNotFoundError';
|
|
2236
2534
|
if (missing) {
|
|
2237
2535
|
throw (await kindOf(fs, src)) === null
|
|
@@ -2245,8 +2543,8 @@ changeGate) {
|
|
|
2245
2543
|
});
|
|
2246
2544
|
mount({
|
|
2247
2545
|
name: 'unzip',
|
|
2248
|
-
|
|
2249
|
-
|
|
2546
|
+
gated: true,
|
|
2547
|
+
description: 'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.',
|
|
2250
2548
|
inputs: {
|
|
2251
2549
|
type: 'object',
|
|
2252
2550
|
properties: {
|
|
@@ -2278,19 +2576,19 @@ changeGate) {
|
|
|
2278
2576
|
write: true,
|
|
2279
2577
|
handler: async (a, ctx) => {
|
|
2280
2578
|
const zipPath = a.path;
|
|
2281
|
-
//
|
|
2282
|
-
//
|
|
2283
|
-
await
|
|
2579
|
+
// Opening the archive is a read of the archive, so the read hook hears
|
|
2580
|
+
// about it before a single entry is extracted out of it.
|
|
2581
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch, zipPath);
|
|
2284
2582
|
// A .zip that is not there is a missing PATH, not an unreadable archive:
|
|
2285
2583
|
// the service now says so (PathNotFoundError) and the helper turns it
|
|
2286
2584
|
// into the same 404 every other file tool answers. Only that declared
|
|
2287
2585
|
// answer maps — a failure part-way through an extraction is not the
|
|
2288
2586
|
// archive going missing.
|
|
2289
2587
|
return orDeclaredNotFound(() => ctx.workspaceService.unzipFile(workspaceIdForBranch(a.branch), zipPath, typeof a.destination === 'string' ? a.destination : undefined,
|
|
2290
|
-
// Each extracted file is a write:
|
|
2291
|
-
// is skipped (not extracted), so an archive can't
|
|
2292
|
-
// extension policy applies per entry too, so
|
|
2293
|
-
// `.md` into the graph.
|
|
2588
|
+
// Each extracted file is a write of its own: an entry the write
|
|
2589
|
+
// hook refuses is skipped (not extracted), so an archive can't be
|
|
2590
|
+
// a way around it — the extension policy applies per entry too, so
|
|
2591
|
+
// a restricted run can't unzip a `.md` into the graph.
|
|
2294
2592
|
(wsRelPath) => {
|
|
2295
2593
|
// An entry that would land beside the repository is skipped with the
|
|
2296
2594
|
// corrected-path reason, like any other refused entry.
|
|
@@ -2301,15 +2599,309 @@ changeGate) {
|
|
|
2301
2599
|
throw new ToolError('roles.yaml is never extracted from an archive — change it with edit_file or write_file, where the change is checked.', 422);
|
|
2302
2600
|
}
|
|
2303
2601
|
writePolicy.assertPathWritable(ctx.sessionId, wsRelPath);
|
|
2304
|
-
return
|
|
2602
|
+
return assertAgentWriteAllowed(agentAccessGate, ctx, a.branch, wsRelPath);
|
|
2305
2603
|
}), 'Nothing to extract');
|
|
2306
2604
|
},
|
|
2307
2605
|
});
|
|
2606
|
+
// ── uploads (bytes that never pass through the model) ───────────────────
|
|
2607
|
+
//
|
|
2608
|
+
// The pair exists because MCP tool arguments are JSON. Every byte an agent
|
|
2609
|
+
// sends through `write_file` is first typed out by the model, which
|
|
2610
|
+
// truncates long files, mangles backslash and `\u` escapes, and cannot carry
|
|
2611
|
+
// a PNG at all. `request_file_upload` answers an address; the agent POSTs
|
|
2612
|
+
// the file (or one zip holding many) there with any HTTP client;
|
|
2613
|
+
// `apply_file_upload` lands it on a branch in one commit. The bytes go from
|
|
2614
|
+
// the agent's disk to the server's and never enter a prompt.
|
|
2615
|
+
//
|
|
2616
|
+
/**
|
|
2617
|
+
* Why one of an upload's paths may not be landed, judged on the path ALONE —
|
|
2618
|
+
* or undefined when nothing about the name itself refuses it.
|
|
2619
|
+
*
|
|
2620
|
+
* The platform files are the whole of it. `access.md` governs who may read
|
|
2621
|
+
* and write the folder it sits in, `roles.yaml` says which roles exist, and
|
|
2622
|
+
* the agent guide is read as instructions: each is configuration the platform
|
|
2623
|
+
* obeys, and each has a write path that CHECKS the change (the roles gate
|
|
2624
|
+
* refuses an edit that would lock every admin out; a folder's access rules
|
|
2625
|
+
* are judged against who is asking). Bytes arriving by upload meet none of
|
|
2626
|
+
* those gates — they are a buffer the sender chose — so an upload never
|
|
2627
|
+
* lands one, whatever else the caller may write. `unzip` has refused
|
|
2628
|
+
* `roles.yaml` from an archive for the same reason; this is that rule, over
|
|
2629
|
+
* all four names.
|
|
2630
|
+
*/
|
|
2631
|
+
const platformFileReason = (wsPath) => {
|
|
2632
|
+
const rel = toKbRelative(wsPath, kbDirName);
|
|
2633
|
+
return rel !== null && isPlatformFile(rel, kb.layout) ? platformFileUploadRefusal(rel) : undefined;
|
|
2634
|
+
};
|
|
2635
|
+
/**
|
|
2636
|
+
* `apply_file_upload`'s handler: resolve the stored bytes into one path per
|
|
2637
|
+
* file, judge each path the way `write_files` judges its own, and land the
|
|
2638
|
+
* survivors as ONE commit.
|
|
2639
|
+
*
|
|
2640
|
+
* The judging is deliberately the same shape as `write_files`, down to the
|
|
2641
|
+
* second verdict under the lock, because the promise the ticket makes is
|
|
2642
|
+
* that an upload is judged "exactly as `write_file` would judge it". Three
|
|
2643
|
+
* gates run per path and a path that fails one is that path's outcome and no
|
|
2644
|
+
* more: the deployment's write hook, the platform-file rule above, and the
|
|
2645
|
+
* `mode`. What is judged ONCE for the whole call is the destination — a
|
|
2646
|
+
* caller who may not write the folder at all gets one refusal naming the
|
|
2647
|
+
* change-request route, rather than the same refusal repeated per entry.
|
|
2648
|
+
*/
|
|
2649
|
+
const applyFileUpload = async (a, ctx, uploads) => {
|
|
2650
|
+
const branch = a.branch;
|
|
2651
|
+
const token = a.token;
|
|
2652
|
+
if (typeof token !== 'string' || token === '') {
|
|
2653
|
+
throw new ToolError('Name the `token` `request_file_upload` answered with, after POSTing the file to its `uploadUrl`.', 400, { code: 'token-required' });
|
|
2654
|
+
}
|
|
2655
|
+
const mode = modeOf(a);
|
|
2656
|
+
const destination = a.destination.replace(/\/+$/, '');
|
|
2657
|
+
assertInsideRepo(destination, kbDirName);
|
|
2658
|
+
// CLAIMED, not consumed: an apply refused whole (a protected destination,
|
|
2659
|
+
// an archive that will not open) leaves the token alive so the caller can
|
|
2660
|
+
// retry somewhere else rather than send the bytes again. The claim is what
|
|
2661
|
+
// keeps it single-use meanwhile — a second apply finds the token in use.
|
|
2662
|
+
const upload = uploads.claim(token, ctx.user.id);
|
|
2663
|
+
let spent = false;
|
|
2664
|
+
try {
|
|
2665
|
+
const fs = await ctx.getFilesystem(branch);
|
|
2666
|
+
const root = await workspaceRoot(branch, ctx);
|
|
2667
|
+
// The destination, once, for the whole call. On a protected branch a
|
|
2668
|
+
// caller who may not write the folder gets the lock gate's own refusal —
|
|
2669
|
+
// which `rethrowAsWriteDenial` turns into `write-denied` with the
|
|
2670
|
+
// change-request steps — and nothing lands.
|
|
2671
|
+
const blockedDest = await writeBlocked(branch, ctx, [destination]);
|
|
2672
|
+
if (blockedDest.length > 0)
|
|
2673
|
+
throw await writeRefusal(branch, blockedDest[0], 'dir');
|
|
2674
|
+
if ((await kindOf(fs, destination)) === 'file') {
|
|
2675
|
+
throw new ToolError(`"${displayPath(destination)}" is a file, not a folder — \`destination\` names the folder the upload lands in.`, 409, { code: 'not_a_folder' });
|
|
2676
|
+
}
|
|
2677
|
+
const planned = await planUpload(upload, destination, kbDirName);
|
|
2678
|
+
const paths = planned.filter((p) => p.content !== undefined).map((p) => p.path);
|
|
2679
|
+
// One batched access read for every path, like `write_files` — empty on
|
|
2680
|
+
// a draft branch, where changes reach a protected branch only through a
|
|
2681
|
+
// change request.
|
|
2682
|
+
const blocked = new Set(await writeBlocked(branch, ctx, paths));
|
|
2683
|
+
const writes = [];
|
|
2684
|
+
const outcomes = [];
|
|
2685
|
+
/** The `files` entry for `writes[i]`, so the under-lock verdict can revise it. */
|
|
2686
|
+
const entryOf = [];
|
|
2687
|
+
for (const item of planned) {
|
|
2688
|
+
const entry = { path: item.path };
|
|
2689
|
+
outcomes.push(entry);
|
|
2690
|
+
if (item.content === undefined) {
|
|
2691
|
+
entry.outcome = 'refused';
|
|
2692
|
+
entry.error = item.error;
|
|
2693
|
+
entry.message = item.message;
|
|
2694
|
+
continue;
|
|
2695
|
+
}
|
|
2696
|
+
const wsPathOf = item.path;
|
|
2697
|
+
try {
|
|
2698
|
+
if (blocked.has(wsPathOf))
|
|
2699
|
+
throw await writeRefusal(branch, wsPathOf);
|
|
2700
|
+
const platform = platformFileReason(wsPathOf);
|
|
2701
|
+
if (platform !== undefined)
|
|
2702
|
+
throw new ToolError(platform, 422, { code: 'platform_file' });
|
|
2703
|
+
// The git folder is never a workspace path, in any spelling. A ZIP
|
|
2704
|
+
// entry's name has already met this rule in `zipEntryNameRefusal`; a
|
|
2705
|
+
// SINGLE uploaded file's has not — `.git` is a name the upload
|
|
2706
|
+
// route's `validateFilename` accepts — and the preflight that reads
|
|
2707
|
+
// the caller's own arguments never sees it either, because the name
|
|
2708
|
+
// came from the upload, not from the call. Asked here so that path
|
|
2709
|
+
// is REFUSED like any other, with the rest of the upload landing,
|
|
2710
|
+
// rather than failing the whole apply from inside `writeFiles`.
|
|
2711
|
+
assertNoGitInternalsSegment(wsPathOf);
|
|
2712
|
+
assertRepoRootNameFree(wsPathOf, kbDirName);
|
|
2713
|
+
// A link already on disk under the destination must not redirect
|
|
2714
|
+
// these bytes — the rule `unzip` applies per entry, applied here on
|
|
2715
|
+
// the path the write will take.
|
|
2716
|
+
const link = await symlinkOnPath(root, wsPathOf);
|
|
2717
|
+
if (link !== undefined) {
|
|
2718
|
+
throw new ToolError(`"${wsPathOf}" goes through the symbolic link "${link}"; an upload never follows links.`, 400, { code: 'symlink' });
|
|
2719
|
+
}
|
|
2720
|
+
writePolicy.assertPathWritable(ctx.sessionId, wsPathOf);
|
|
2721
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, wsPathOf);
|
|
2722
|
+
// An earlier entry of this same upload counts as existing, as it
|
|
2723
|
+
// does in `write_files`: two `create` entries for one path are a
|
|
2724
|
+
// mistake the commit would otherwise hide.
|
|
2725
|
+
const exists = writes.some((w) => w.path === wsPathOf) || (await kindOf(fs, wsPathOf)) !== null;
|
|
2726
|
+
entry.outcome = decideWrite(mode, wsPathOf, exists);
|
|
2727
|
+
writes.push({ path: wsPathOf, content: item.content });
|
|
2728
|
+
entryOf.push(entry);
|
|
2729
|
+
}
|
|
2730
|
+
catch (err) {
|
|
2731
|
+
refuseEntry(entry, err);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
// The mode gate again, with every path's lock held — the verdict the
|
|
2735
|
+
// answer carries, for the reason `write_file` states at length. A path
|
|
2736
|
+
// whose verdict changed under the lock is dropped from the batch and
|
|
2737
|
+
// reported refused, leaving the rest to land.
|
|
2738
|
+
const recheck = async (pending) => {
|
|
2739
|
+
const kept = [];
|
|
2740
|
+
for (let i = 0; i < pending.length; i++) {
|
|
2741
|
+
const entry = entryOf[i];
|
|
2742
|
+
try {
|
|
2743
|
+
const exists = kept.some((k) => k.path === pending[i].path) || (await kindOf(fs, pending[i].path)) !== null;
|
|
2744
|
+
entry.outcome = decideWrite(mode, pending[i].path, exists);
|
|
2745
|
+
kept.push(pending[i]);
|
|
2746
|
+
}
|
|
2747
|
+
catch (err) {
|
|
2748
|
+
refuseEntry(entry, err);
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
return kept;
|
|
2752
|
+
};
|
|
2753
|
+
if (writes.length > 0) {
|
|
2754
|
+
// `write: true` guarantees a LockingFilesystem here; `writeFiles` lands
|
|
2755
|
+
// the whole set as ONE commit and takes a Buffer as content, so bytes
|
|
2756
|
+
// reach disk exactly as they were sent — no text decode anywhere on
|
|
2757
|
+
// the way, which is what makes a PNG and a backslash-heavy page land
|
|
2758
|
+
// with the checksum they were uploaded with.
|
|
2759
|
+
const batching = fs;
|
|
2760
|
+
// In the DESTINATION folder's TURN, which `delete_folder` takes over the
|
|
2761
|
+
// same subtree (and `keepFolderOf` with it). `writeFiles` creates the
|
|
2762
|
+
// destination, and any folder above a zip entry on the way to it, as
|
|
2763
|
+
// part of landing the batch — and a folder delete running between that
|
|
2764
|
+
// creation and the commit enumerates the folder's files BEFORE these
|
|
2765
|
+
// exist and then removes the folder they are landing in, which is an
|
|
2766
|
+
// answer saying `created` for bytes that are already gone. The turn is
|
|
2767
|
+
// taken OUTSIDE `writeFiles`, so it is held across the under-lock
|
|
2768
|
+
// recheck and the commit both, and in the same order the delete takes
|
|
2769
|
+
// its own (the folder's turn first, then each path's lock), which is
|
|
2770
|
+
// what keeps two callers from waiting on each other's half.
|
|
2771
|
+
await ctx.workspaceService.withFolderTurn(workspaceIdForBranch(branch), destination, async () => {
|
|
2772
|
+
await batching.writeFiles(writes, `Apply upload of ${writes.length} file(s)`, [], recheck);
|
|
2773
|
+
});
|
|
2774
|
+
}
|
|
2775
|
+
// The token is spent once an ANSWER exists, even an answer in which
|
|
2776
|
+
// every path was refused: the apply ran and said what happened at each
|
|
2777
|
+
// path, and re-running it would say the same. Only a refusal that landed
|
|
2778
|
+
// nothing AND answered nothing (thrown above) gives the token back.
|
|
2779
|
+
spent = true;
|
|
2780
|
+
await uploads.consume(token);
|
|
2781
|
+
const listed = a.all === true ? outcomes : outcomes.slice(0, APPLY_ANSWER_CAP);
|
|
2782
|
+
return {
|
|
2783
|
+
destination,
|
|
2784
|
+
count: outcomes.filter((o) => o.outcome !== 'refused').length,
|
|
2785
|
+
total: outcomes.length,
|
|
2786
|
+
files: listed,
|
|
2787
|
+
...(listed.length < outcomes.length ? { truncated: true } : {}),
|
|
2788
|
+
};
|
|
2789
|
+
}
|
|
2790
|
+
finally {
|
|
2791
|
+
if (!spent)
|
|
2792
|
+
uploads.release(token);
|
|
2793
|
+
}
|
|
2794
|
+
};
|
|
2795
|
+
// Mounted only when the composition supplied a store — see the `uploads`
|
|
2796
|
+
// parameter. Core always does.
|
|
2797
|
+
if (uploads) {
|
|
2798
|
+
mount({
|
|
2799
|
+
name: 'request_file_upload',
|
|
2800
|
+
fileTool: false,
|
|
2801
|
+
description:
|
|
2802
|
+
// Within the description cap (`tool-registry/description-length.ts`):
|
|
2803
|
+
// why a file goes this way is one of the shared rules, and the header
|
|
2804
|
+
// spelling of the token is on the `uploadUrl` output, where the
|
|
2805
|
+
// address it changes is.
|
|
2806
|
+
'Ask for a one-time address to send FILE BYTES to, so their content never passes through this conversation. ' +
|
|
2807
|
+
'Use it for anything `write_file` cannot carry faithfully: a large file, a file full of backslashes or `\\u` ' +
|
|
2808
|
+
'escapes, a binary file (a PNG, a PDF, a zip), or many files at once (zip them). ' +
|
|
2809
|
+
'Returns `{ uploadUrl, token, expiresAt, expiresInSeconds, maxBytes }`. THEN: ' +
|
|
2810
|
+
'(1) POST the file as the raw request body to `uploadUrl` with `?filename=<name>` — ' +
|
|
2811
|
+
'`curl -X POST --data-binary @skill.zip "<uploadUrl>?filename=skill.zip"` — which answers what it received; ' +
|
|
2812
|
+
'(2) call `apply_file_upload` with the same `token`, a `branch` and a destination folder. ' +
|
|
2813
|
+
'One token carries one file or one zip, is bound to you and expires at `expiresAt`: an upload nobody applies ' +
|
|
2814
|
+
'by then is deleted, and one over `maxBytes` is refused when you send it, naming the limit.',
|
|
2815
|
+
inputs: { type: 'object', properties: {}, additionalProperties: false },
|
|
2816
|
+
outputs: {
|
|
2817
|
+
type: 'object',
|
|
2818
|
+
properties: {
|
|
2819
|
+
uploadUrl: str('The absolute URL to POST the bytes to. Carries the token; add `?filename=<name>`. To keep the token out ' +
|
|
2820
|
+
'of a URL — when the command line you send from is logged or shared — POST to this address without its ' +
|
|
2821
|
+
'last (token) segment and send the token in an `x-upload-token` header instead.'),
|
|
2822
|
+
token: str('The token itself — what `apply_file_upload` takes, and what an `x-upload-token` header carries when you ' +
|
|
2823
|
+
'would rather it not sit in a URL. Treat it as a credential.'),
|
|
2824
|
+
expiresAt: str('ISO-8601 instant after which the token, and any bytes sent with it, are gone.'),
|
|
2825
|
+
expiresInSeconds: int('Seconds from now until `expiresAt`.'),
|
|
2826
|
+
maxBytes: int('The largest upload this deployment accepts, in bytes.'),
|
|
2827
|
+
},
|
|
2828
|
+
required: ['uploadUrl', 'token', 'expiresAt', 'expiresInSeconds', 'maxBytes'],
|
|
2829
|
+
},
|
|
2830
|
+
// A read-scoped caller has nothing to do with an upload token: the only
|
|
2831
|
+
// thing it unlocks is a write. Refused at the handler factory, by scope,
|
|
2832
|
+
// before the token is minted.
|
|
2833
|
+
write: true,
|
|
2834
|
+
handler: async (_a, ctx) => uploads.issue(ctx.user),
|
|
2835
|
+
});
|
|
2836
|
+
mount({
|
|
2837
|
+
name: 'apply_file_upload',
|
|
2838
|
+
gated: true,
|
|
2839
|
+
description: 'Land a file you have already uploaded (see `request_file_upload`) in a folder on a branch, in ONE commit, as you. ' +
|
|
2840
|
+
'A single file lands under the name it was sent with; a zip lands as its entries, keeping their folder structure. ' +
|
|
2841
|
+
'Returns `{ destination, count, total, files }`: one entry per path, each `{ path, outcome }` — `created` / ' +
|
|
2842
|
+
'`replaced` / `updated`, or `refused` with `error` (the code) and `message` (why). `count` is how many landed and ' +
|
|
2843
|
+
'`total` how many paths there were; `files` is cut to the first 25 unless you pass `all: true`. ' +
|
|
2844
|
+
'Every path is judged one by one — by your write access, the platform-file rules and what is already there — ' +
|
|
2845
|
+
'exactly as `write_file` judges it, and a refused path does not stop the others. ' +
|
|
2846
|
+
'The token is single-use: it is spent by the apply that lands it, and refused if you use it twice, let it ' +
|
|
2847
|
+
'expire, or present one issued to somebody else. `mode` means what it means on `write_file`.',
|
|
2848
|
+
inputs: {
|
|
2849
|
+
type: 'object',
|
|
2850
|
+
properties: {
|
|
2851
|
+
branch: BRANCH_INPUT,
|
|
2852
|
+
token: str('The `token` from `request_file_upload`, after you have POSTed the file to its `uploadUrl`.'),
|
|
2853
|
+
destination: wsPath(kbDirName, 'Folder the upload lands in (created if it is not there yet)'),
|
|
2854
|
+
mode: WRITE_MODE_INPUT,
|
|
2855
|
+
all: {
|
|
2856
|
+
type: 'boolean',
|
|
2857
|
+
description: 'List EVERY path in `files` instead of the first 25. `total` always says how many there were, so ask for ' +
|
|
2858
|
+
'all only when you need to read each outcome.',
|
|
2859
|
+
},
|
|
2860
|
+
sessionId: SESSION_ID_INPUT,
|
|
2861
|
+
},
|
|
2862
|
+
required: ['branch', 'token', 'destination'],
|
|
2863
|
+
additionalProperties: false,
|
|
2864
|
+
},
|
|
2865
|
+
outputs: {
|
|
2866
|
+
type: 'object',
|
|
2867
|
+
properties: {
|
|
2868
|
+
destination: str('The folder the upload was applied to (echoes the input).'),
|
|
2869
|
+
count: int('How many paths landed — the entries in `files` whose `outcome` is not `refused`.'),
|
|
2870
|
+
total: int('How many paths the upload held, whether or not `files` lists them all.'),
|
|
2871
|
+
files: {
|
|
2872
|
+
type: 'array',
|
|
2873
|
+
description: 'One entry per path, in the order the upload held them. Cut to 25 unless `all` was true.',
|
|
2874
|
+
items: {
|
|
2875
|
+
type: 'object',
|
|
2876
|
+
properties: {
|
|
2877
|
+
path: str('The workspace path this entry was judged at.'),
|
|
2878
|
+
outcome: {
|
|
2879
|
+
type: 'string',
|
|
2880
|
+
enum: ['created', 'replaced', 'updated', 'refused'],
|
|
2881
|
+
description: 'What happened at this path. `refused` means nothing was written there.',
|
|
2882
|
+
},
|
|
2883
|
+
error: str('Present when `outcome` is `refused`: the refusal code — e.g. `exists`, `missing`, `invalid_entry`, `platform_file`, `write-denied`.'),
|
|
2884
|
+
message: str('Present when `outcome` is `refused`: the full refusal, the same one `write_file` would have given.'),
|
|
2885
|
+
},
|
|
2886
|
+
required: ['path', 'outcome'],
|
|
2887
|
+
},
|
|
2888
|
+
},
|
|
2889
|
+
truncated: { type: 'boolean', description: 'True when `files` was cut: `total` is larger than what it lists. Pass `all: true` for the rest.' },
|
|
2890
|
+
},
|
|
2891
|
+
required: ['destination', 'count', 'total', 'files'],
|
|
2892
|
+
},
|
|
2893
|
+
write: true,
|
|
2894
|
+
// So a protected-branch refusal arrives as `write-denied`, with the
|
|
2895
|
+
// change-request steps, exactly as it does from write_file.
|
|
2896
|
+
proposable: true,
|
|
2897
|
+
handler: async (a, ctx) => applyFileUpload(a, ctx, uploads),
|
|
2898
|
+
});
|
|
2899
|
+
}
|
|
2308
2900
|
// ── shell (internal-only) ───────────────────────────────────────────────
|
|
2309
2901
|
mount({
|
|
2310
2902
|
name: 'execute_command',
|
|
2311
|
-
|
|
2312
|
-
|
|
2903
|
+
gated: true,
|
|
2904
|
+
description: 'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.',
|
|
2313
2905
|
internalOnly: true,
|
|
2314
2906
|
fileTool: false,
|
|
2315
2907
|
// The one tool the mount's branch check skips: the handler below resolves an
|
|
@@ -2397,11 +2989,12 @@ changeGate) {
|
|
|
2397
2989
|
throw new ToolError(`execute_command got an invalid \`branch\`: ${err.message} — ` +
|
|
2398
2990
|
'pass the exact branch (draft) whose workspace to run the command in.', 400);
|
|
2399
2991
|
}
|
|
2400
|
-
// Shell is a write path with no single target path to check, so
|
|
2401
|
-
//
|
|
2402
|
-
//
|
|
2992
|
+
// Shell is a write path with no single target path to check, so the
|
|
2993
|
+
// write hook is asked once for the call itself, with no path — and the
|
|
2994
|
+
// run must not be restricted to a file type either, since shell could
|
|
2995
|
+
// write anything.
|
|
2403
2996
|
writePolicy.assertUnrestricted(ctx.sessionId);
|
|
2404
|
-
await
|
|
2997
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch);
|
|
2405
2998
|
// Canonical per-branch bootstrap entry point — it owns the workspace-id
|
|
2406
2999
|
// encoding and the single-flight clone, so the shell never derives a
|
|
2407
3000
|
// workspace path by hand.
|