@bevel-software/platform-core-backend 0.23.0 → 0.25.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/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +17 -3
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +25 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +12 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +10 -0
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core-config.d.ts +16 -0
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +17 -0
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +4 -1
- 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-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/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +25 -15
- package/dist/modules/auth/auth.service.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 +296 -96
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +81 -31
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/database/migrate.d.ts +95 -1
- package/dist/modules/database/migrate.d.ts.map +1 -1
- package/dist/modules/database/migrate.js +390 -2
- package/dist/modules/database/migrate.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +29 -0
- package/dist/modules/kb-fs/locking-filesystem.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/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/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +8 -2
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +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/git/git.service.d.ts +25 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +40 -1
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +5 -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.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +4 -1
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- 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/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 +1 -1
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +13 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +61 -33
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +11 -9
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +570 -134
- 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/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/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.map +1 -1
- package/dist/tenancy/static-tenant-source.js +1 -0
- 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 +42 -0
- package/migrations/0016_pii_encryption.sql +20 -0
- package/migrations/meta/0016_snapshot.json +2327 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/core/__tests__/lifecycle.test.ts +71 -4
- package/src/core/create-core-server.ts +21 -3
- package/src/core/create-core-services.ts +27 -1
- package/src/core/lifecycle.ts +19 -0
- package/src/core-config.ts +18 -0
- package/src/index.ts +25 -0
- package/src/modules/access/__tests__/users-db-double.ts +21 -12
- package/src/modules/access/access.routes.ts +4 -1
- package/src/modules/access/directory-sync-bot.ts +7 -3
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +8 -4
- 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 +2 -1
- package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
- package/src/modules/auth/__tests__/account.routes.test.ts +6 -3
- package/src/modules/auth/__tests__/auth.service.test.ts +3 -1
- package/src/modules/auth/account-erasure.service.ts +69 -18
- package/src/modules/auth/auth.service.ts +33 -23
- 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 +780 -0
- package/src/modules/database/connection.ts +117 -0
- package/src/modules/database/core-schema.ts +81 -31
- package/src/modules/database/migrate.ts +540 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
- package/src/modules/kb-fs/locking-filesystem.ts +37 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
- package/src/modules/mcp/__tests__/mcp.service.test.ts +47 -7
- package/src/modules/mcp/mcp.service.ts +46 -7
- package/src/modules/plugins/join-request-records.store.ts +7 -4
- package/src/modules/tool-auth/external-api-key.service.ts +8 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
- 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/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
- package/src/modules/workflow/git/git.service.ts +47 -1
- package/src/modules/workflow/pending-commits.service.ts +5 -1
- 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.service.ts +4 -1
- package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +24 -14
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +77 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +57 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +39 -36
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +196 -46
- package/src/modules/workspace/agent-upload.routes.ts +214 -0
- package/src/modules/workspace/agent-upload.store.ts +668 -0
- 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 +1 -2
- package/src/modules/workspace/workspace.service.ts +63 -37
- package/src/modules/workspace/workspace.tools.ts +647 -148
- package/src/modules/workspace/write-denial.ts +0 -8
- package/src/modules/workspace/zip-entry-rules.ts +173 -0
- package/src/shared/__tests__/column-crypto.test.ts +217 -0
- package/src/shared/column-crypto.ts +218 -0
- package/src/shared/token-crypto.ts +28 -1
- package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -0
- package/src/tenancy/static-tenant-source.ts +1 -0
- package/src/tenancy/tenant-secrets.ts +5 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import nodeFs from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import AdmZip from 'adm-zip';
|
|
4
5
|
import type { Router, RequestHandler } from 'express';
|
|
5
6
|
import type { LocalFilesystem } from '@mastra/core/workspace';
|
|
6
7
|
import type { IToolRegistry, JsonSchema } from '../tool-registry/tool.contract.js';
|
|
@@ -16,11 +17,17 @@ import type { IRoutineWritePolicy } from './routine-write-policy.js';
|
|
|
16
17
|
import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
|
|
17
18
|
import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
|
|
18
19
|
import { workspaceIdForBranch } from '../../shared/workspace-id.js';
|
|
19
|
-
import { assertBranchProvided } from '../../shared/domain-errors.js';
|
|
20
|
+
import { assertBranchProvided, GitInternalsError, WorkflowValidationError } from '../../shared/domain-errors.js';
|
|
20
21
|
// Leaf-level shared primitive (same exception `workspace.service.ts` already
|
|
21
22
|
// relies on) — not a workflow service, so this stays inside the module boundary.
|
|
22
23
|
import { assertValidBranchName } from '../kb-fs/branch-name.js';
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
assertInsideRepo,
|
|
26
|
+
assertRepoRootNameFree,
|
|
27
|
+
assertRepoRootNameFreeArgs,
|
|
28
|
+
isInsideRepo,
|
|
29
|
+
normalizePathArgs,
|
|
30
|
+
} from '../kb-fs/repo-path.js';
|
|
24
31
|
import { GitGuardedFilesystem } from '../kb-fs/git-guarded-filesystem.js';
|
|
25
32
|
import { assertNoGitInternalsSegment, assertNotGitInternals, hasGitInternalsSegment } from '../../shared/git-internals.js';
|
|
26
33
|
import { isRolesYamlPath } from '../access-model/roles-yaml-guard.js';
|
|
@@ -38,28 +45,36 @@ import { createFileReaderRegistry } from './file-readers/file-reader.registry.js
|
|
|
38
45
|
import { DocumentReader } from './file-readers/document-reader.js';
|
|
39
46
|
import { mcpImageResult } from '@bevel-software/platform-mcp-core';
|
|
40
47
|
import {
|
|
41
|
-
LEGACY_AGENTS_FILE,
|
|
42
48
|
folderPlaceholderPath,
|
|
43
49
|
isFolderPlaceholder,
|
|
44
50
|
isPlatformFile,
|
|
45
51
|
isPlatformFolder,
|
|
46
52
|
platformFileCreationRefusal,
|
|
47
|
-
platformFileNames,
|
|
48
53
|
platformFileRefusal,
|
|
54
|
+
platformFileUploadRefusal,
|
|
49
55
|
platformFolderRefusal,
|
|
50
56
|
entryExistsMessage,
|
|
51
57
|
type ExistingEntryKind,
|
|
52
|
-
type KbLayout,
|
|
53
58
|
} from '@bevel-software/platform-shared';
|
|
54
59
|
import type { KbContext } from '../../shared/kb-context.js';
|
|
55
60
|
import { AccessDeniedError } from '../access-model/access-errors.js';
|
|
56
61
|
import { removeEmptyDirs } from './empty-dirs.js';
|
|
57
|
-
import {
|
|
62
|
+
import { rethrowAsWriteDenial } from './write-denial.js';
|
|
63
|
+
import { sharedRulesPointer } from '../agent-instructions/shared-file-rules.js';
|
|
58
64
|
import type { IChangeReadGate } from '../access-model/change-gate.js';
|
|
59
65
|
import { notFound, orDeclaredNotFound, orNotFound } from './not-found.js';
|
|
60
66
|
import { logger } from '../../shared/logging.js';
|
|
61
67
|
import { printable } from '../../shared/printable.js';
|
|
62
68
|
import { DestinationTakenError, inspectDestination } from '../../shared/rename-no-replace.js';
|
|
69
|
+
import { AgentUploadStore, type ClaimedUpload } from './agent-upload.store.js';
|
|
70
|
+
import {
|
|
71
|
+
isSymlinkZipEntry,
|
|
72
|
+
isZipNoiseEntry,
|
|
73
|
+
readZipEntry,
|
|
74
|
+
zipEntryName,
|
|
75
|
+
zipEntryNameRefusal,
|
|
76
|
+
zipEntrySegments,
|
|
77
|
+
} from './zip-entry-rules.js';
|
|
63
78
|
|
|
64
79
|
const log = logger('workspace-tools');
|
|
65
80
|
|
|
@@ -193,60 +208,26 @@ async function keepFolderOf(
|
|
|
193
208
|
}
|
|
194
209
|
}
|
|
195
210
|
|
|
196
|
-
/**
|
|
197
|
-
* Appended (centrally, in `mount`) to EVERY workspace tool description. The
|
|
198
|
-
* platform's managed agent guide sits at the workspace root and documents the
|
|
199
|
-
* conventions of that knowledge base; agents (ours and external) should consult
|
|
200
|
-
* it before touching files. It rides on every entrypoint — reads (grep/
|
|
201
|
-
* list_files/file_stat) included — because any of them can be a session's first
|
|
202
|
-
* touch.
|
|
203
|
-
*
|
|
204
|
-
* `CLAUDE.md` is named as a fallback because knowledge bases seeded before the
|
|
205
|
-
* rename still carry one, and the seeder never deletes a file it did not
|
|
206
|
-
* expect. Naming both means an agent finds the conventions either way, instead
|
|
207
|
-
* of reading none because it looked for the newer name and stopped.
|
|
208
|
-
*
|
|
209
|
-
* WHEN THE GUIDE HAS BEEN RENAMED the sentence names two files, ours first. The
|
|
210
|
-
* second is the organisation's OWN `AGENTS.md`, which on such a deployment is
|
|
211
|
-
* ordinary content the platform never touches — and which no harness reads for a
|
|
212
|
-
* remote agent, because a remote agent has no checkout. Telling it to read both
|
|
213
|
-
* is the only way the conventions the customer actually wrote reach the agent
|
|
214
|
-
* working in their knowledge base. Under the default name the wording collapses
|
|
215
|
-
* to the one file it has always named.
|
|
216
|
-
*
|
|
217
|
-
* A FUNCTION of the layout, called when a description is built: the name is a
|
|
218
|
-
* deployment setting, and a module-scope string would snapshot the default.
|
|
219
|
-
*/
|
|
220
|
-
function kbConventionsNote(layout: KbLayout): string {
|
|
221
|
-
const agentsFile = layout.agentsFile ?? LEGACY_AGENTS_FILE;
|
|
222
|
-
if (agentsFile === LEGACY_AGENTS_FILE) {
|
|
223
|
-
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.';
|
|
224
|
-
}
|
|
225
|
-
return (
|
|
226
|
-
` Before your first read or change in a workspace, read \`${agentsFile}\` at the KB root, then ` +
|
|
227
|
-
'`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.'
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** The platform files as a tool description lists them — the guide under its own name. */
|
|
232
|
-
function platformFileList(layout: KbLayout): string {
|
|
233
|
-
return platformFileNames(layout)
|
|
234
|
-
.map((name) => `\`${name}\``)
|
|
235
|
-
.join(', ');
|
|
236
|
-
}
|
|
237
|
-
|
|
238
211
|
const int = (description: string): JsonSchema => ({ type: 'integer', description });
|
|
239
212
|
|
|
240
213
|
const str = (description: string): JsonSchema => ({ type: 'string', description });
|
|
241
214
|
|
|
242
215
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
216
|
+
* The upload route, named on every tool that takes content as a JSON string.
|
|
217
|
+
*
|
|
218
|
+
* ONE sentence, and the tools' own: it is what stops the three failures the
|
|
219
|
+
* route was built for. An agent landing 27 files read each one and typed it
|
|
220
|
+
* out again as a tool argument: a 37 KB write was truncated mid-answer, a page
|
|
221
|
+
* of regex backslashes failed to parse as a JSON parameter, and a PNG could not
|
|
222
|
+
* be sent at all. None of that is discoverable from a refusal — a truncated
|
|
223
|
+
* write reports success — so the tools that invite it name where the bytes
|
|
224
|
+
* should go instead, in the description itself, for a client that reads
|
|
225
|
+
* nothing else. WHY, and how the route is used, is one of the shared rules
|
|
226
|
+
* (`agent-instructions/shared-file-rules.ts`): said in full on three
|
|
227
|
+
* descriptions it took each of them past the length a client cuts at.
|
|
247
228
|
*/
|
|
248
|
-
const
|
|
249
|
-
'
|
|
229
|
+
const UPLOAD_ROUTE_NOTE =
|
|
230
|
+
' Large, escape-heavy or binary content does not go through here: use `request_file_upload` + `apply_file_upload`.';
|
|
250
231
|
|
|
251
232
|
/**
|
|
252
233
|
* A path input that names the clone folder, and says what happens when it does
|
|
@@ -285,6 +266,9 @@ const RESERVED_ROOT_NAME_TARGETS: Readonly<Record<string, readonly string[]>> =
|
|
|
285
266
|
copy_file: ['dest'],
|
|
286
267
|
move_file: ['dest'],
|
|
287
268
|
unzip: ['destination'],
|
|
269
|
+
// The folder the upload lands in. Each of its own paths is checked again
|
|
270
|
+
// inside the handler — an archive chooses its entry names, not the caller.
|
|
271
|
+
apply_file_upload: ['destination'],
|
|
288
272
|
};
|
|
289
273
|
|
|
290
274
|
function asText(content: string | Buffer): string {
|
|
@@ -313,16 +297,6 @@ interface DocGrepState {
|
|
|
313
297
|
skippedUncached: number;
|
|
314
298
|
}
|
|
315
299
|
|
|
316
|
-
/**
|
|
317
|
-
* THE binary capability contract, stated once and appended (in `mount`) to
|
|
318
|
-
* every file tool's description — which is also what `tools_info` returns.
|
|
319
|
-
* The split it states is enforced by the reader registry: the text tools
|
|
320
|
-
* refuse what their reader marks not `textEditable` (and binary content under
|
|
321
|
-
* any name) with a `binary_not_writable` refusal; the byte tools never look.
|
|
322
|
-
*/
|
|
323
|
-
export const CONTENT_RULE =
|
|
324
|
-
' 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.';
|
|
325
|
-
|
|
326
300
|
/** What a `binary_not_writable` refusal points to, in the order to try them. */
|
|
327
301
|
const BINARY_USE_INSTEAD = ['upload', 'copy_file', 'move_file'] as const;
|
|
328
302
|
|
|
@@ -335,7 +309,7 @@ const BINARY_USE_INSTEAD = ['upload', 'copy_file', 'move_file'] as const;
|
|
|
335
309
|
function binaryNotWritable(fileKind: FileKind, explanation: string): ToolError {
|
|
336
310
|
return new ToolError(
|
|
337
311
|
`${explanation} [binary_not_writable: this file's kind is ${fileKind}; write_file, write_files and edit_file accept text only. ` +
|
|
338
|
-
'Use upload for new bytes (`
|
|
312
|
+
'Use upload for new bytes (`request_file_upload` + `apply_file_upload`, or Upload in the app), ' +
|
|
339
313
|
'or copy_file / move_file to place bytes that are already in the workspace.]',
|
|
340
314
|
415,
|
|
341
315
|
{ kind: 'binary_not_writable', fileKind, useInstead: [...BINARY_USE_INSTEAD] },
|
|
@@ -379,17 +353,17 @@ function assertNotDocumentEdit(readers: FileReaderRegistry, path: string): void
|
|
|
379
353
|
*
|
|
380
354
|
* Costs one read of the existing file, and only for readers that ask the
|
|
381
355
|
* question. A path with nothing at it is a CREATE: there is nothing to destroy.
|
|
382
|
-
*
|
|
383
|
-
* `edit_file`
|
|
384
|
-
*
|
|
356
|
+
* For the tools that REPLACE a file without needing what it held (`write_file`,
|
|
357
|
+
* `write_files`); `edit_file` holds the bytes already and asks
|
|
358
|
+
* `assertBytesTextEditable` of each reading it takes.
|
|
385
359
|
*/
|
|
386
360
|
async function assertNotBinaryOverwrite(
|
|
387
361
|
readers: FileReaderRegistry,
|
|
388
362
|
path: string,
|
|
389
363
|
fs: { readFile(p: string): Promise<string | Buffer> },
|
|
390
|
-
): Promise<
|
|
364
|
+
): Promise<void> {
|
|
391
365
|
const reader = readers.readerFor(path);
|
|
392
|
-
if (reader.editRefusalForExisting === undefined) return
|
|
366
|
+
if (reader.editRefusalForExisting === undefined) return;
|
|
393
367
|
let existing: Buffer;
|
|
394
368
|
try {
|
|
395
369
|
existing = asBytes(await fs.readFile(path));
|
|
@@ -398,12 +372,21 @@ async function assertNotBinaryOverwrite(
|
|
|
398
372
|
// FileNotFoundError carry the disk's absence codes). Any other failure —
|
|
399
373
|
// permissions, I/O — means the existing content could not be inspected:
|
|
400
374
|
// propagate it rather than let the write destroy bytes the gate never saw.
|
|
401
|
-
if (isAbsence(err)) return
|
|
375
|
+
if (isAbsence(err)) return; // nothing there yet
|
|
402
376
|
throw err;
|
|
403
377
|
}
|
|
404
|
-
|
|
378
|
+
assertBytesTextEditable(readers, path, existing);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The same refusal, over bytes the caller already holds. Split out so a tool
|
|
383
|
+
* that reads the file more than once — `edit_file`, before the lock and again
|
|
384
|
+
* under it — judges EVERY reading with the one rule, and the bytes it replaces
|
|
385
|
+
* are always bytes this gate has seen.
|
|
386
|
+
*/
|
|
387
|
+
function assertBytesTextEditable(readers: FileReaderRegistry, path: string, existing: Buffer): void {
|
|
388
|
+
const refusal = readers.readerFor(path).editRefusalForExisting?.(existing, path) ?? null;
|
|
405
389
|
if (refusal !== null) throw binaryNotWritable('binary', refusal);
|
|
406
|
-
return existing;
|
|
407
390
|
}
|
|
408
391
|
|
|
409
392
|
/** What a write is ALLOWED to do at a path. `create` is the default everywhere. */
|
|
@@ -427,34 +410,6 @@ const WRITE_MODE_INPUT: JsonSchema = {
|
|
|
427
410
|
'EXISTING file and refuses (`missing`) a path that holds nothing.',
|
|
428
411
|
};
|
|
429
412
|
|
|
430
|
-
/** The same three modes, said once, for both tool descriptions. */
|
|
431
|
-
const WRITE_MODE_NOTE =
|
|
432
|
-
' `mode` decides what may happen at a path and DEFAULTS TO `create`: `create` writes a new file and refuses a path that ' +
|
|
433
|
-
'already exists (`exists`, with the path — pass `mode: overwrite` to replace it), `overwrite` replaces what is there ' +
|
|
434
|
-
'(creating it if there is nothing), `update` replaces an existing file and refuses a path that does not exist (`missing`). ' +
|
|
435
|
-
'A refused path is left exactly as it was.';
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* What an agent needs to know about escape sequences in the content it sends,
|
|
439
|
-
* on the three tools that take content as a JSON string.
|
|
440
|
-
*
|
|
441
|
-
* The three write routes — the MCP endpoint, the `/api/agent/tools/<name>`
|
|
442
|
-
* route and `call_tool_chain` — were measured end to end against raw requests
|
|
443
|
-
* and a byte-level read of the stored file (see
|
|
444
|
-
* `__tests__/escape-sequences.routes.test.ts`): each stores content exactly as
|
|
445
|
-
* the JSON string value decodes ONCE. So when an escape arrives already
|
|
446
|
-
* decoded, the decoding happened in the client that built the request, and no
|
|
447
|
-
* tool here can tell that content from content that was meant to be decoded.
|
|
448
|
-
* Hence a warning rather than a fix, and the pointer to the one route whose
|
|
449
|
-
* payload is bytes rather than a JSON string.
|
|
450
|
-
*/
|
|
451
|
-
const ESCAPE_SEQUENCE_NOTE =
|
|
452
|
-
' Escape sequences: some clients decode them in arguments before sending, so content meant to CONTAIN an escape rather ' +
|
|
453
|
-
'than what it stands for (the six characters backslash, `u`, `0`, `0`, `4`, `1`, say, rather than the letter `A`) can ' +
|
|
454
|
-
'reach this tool already decoded — what arrives is stored byte for byte, so when that distinction matters, verify what ' +
|
|
455
|
-
'landed (`read_file`, or a hash) and send such content through the upload route (`request_upload_token` + `apply_upload` ' +
|
|
456
|
-
'where offered, otherwise Upload in the app), which lands it unchanged.';
|
|
457
|
-
|
|
458
413
|
/** The refusal `create` gives on a path that already holds something. */
|
|
459
414
|
function pathExists(path: string): ToolError {
|
|
460
415
|
return new ToolError(
|
|
@@ -486,6 +441,169 @@ function decideWrite(mode: WriteMode, path: string, exists: boolean): WriteOutco
|
|
|
486
441
|
return exists ? 'replaced' : 'created';
|
|
487
442
|
}
|
|
488
443
|
|
|
444
|
+
/**
|
|
445
|
+
* How many of an upload's paths the answer NAMES before it stops and says how
|
|
446
|
+
* many there were. A 300-file zip's full outcome list is pages of text an agent
|
|
447
|
+
* pays for on every call; 25 is enough to see the shape of what happened, and
|
|
448
|
+
* `total` plus `truncated` say that there is more. `all: true` asks for the
|
|
449
|
+
* rest, for a caller that really does have to read each one.
|
|
450
|
+
*/
|
|
451
|
+
const APPLY_ANSWER_CAP = 25;
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* How many entries of one uploaded archive are landed, and how many bytes of
|
|
455
|
+
* uncompressed content in total.
|
|
456
|
+
*
|
|
457
|
+
* Tighter than `unzip`'s own caps on purpose. An apply lands its whole set as
|
|
458
|
+
* ONE commit, which means every entry's bytes are held in memory at once —
|
|
459
|
+
* the property that makes the commit atomic is the one that makes a zip bomb
|
|
460
|
+
* expensive. The upload itself is already bounded by the deployment's upload
|
|
461
|
+
* limit; these bound what that upload is allowed to expand into.
|
|
462
|
+
*/
|
|
463
|
+
const APPLY_MAX_ENTRIES = 5_000;
|
|
464
|
+
const APPLY_MAX_TOTAL_BYTES = 128 * 1024 * 1024; // 128 MB uncompressed
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* One path of an upload, as `apply_file_upload` plans it: the bytes to write,
|
|
468
|
+
* or the reason this path is refused before any gate is asked. A refused path
|
|
469
|
+
* carries the name the archive held rather than a workspace path, because for
|
|
470
|
+
* those the whole problem is that no workspace path can be built from it.
|
|
471
|
+
*/
|
|
472
|
+
interface PlannedUploadPath {
|
|
473
|
+
path: string;
|
|
474
|
+
content?: Buffer;
|
|
475
|
+
error?: string;
|
|
476
|
+
message?: string;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Turn a stored upload into one planned path per file.
|
|
481
|
+
*
|
|
482
|
+
* A single file is one path: the destination plus the name it was sent with.
|
|
483
|
+
* A zip is one path per member, with the member's folder structure kept under
|
|
484
|
+
* the destination — judged by the same entry rules `unzip` applies
|
|
485
|
+
* (`zip-entry-rules.ts`), plus one `unzip` does not have: an entry that is a
|
|
486
|
+
* symbolic LINK is refused outright. A zip stores a link as a member whose
|
|
487
|
+
* bytes are its target text, so a reader that ignored the mode bits would
|
|
488
|
+
* write that text out as a file — content nobody sent, under a name that was
|
|
489
|
+
* meant to point elsewhere.
|
|
490
|
+
*/
|
|
491
|
+
/** The refusal an entry gets when the archive would expand past what one commit lands. */
|
|
492
|
+
function tooLargeToApply(): string {
|
|
493
|
+
return `This archive expands past the ${APPLY_MAX_TOTAL_BYTES} byte total the apply lands in one commit; this entry was not applied.`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function planUpload(
|
|
497
|
+
upload: ClaimedUpload,
|
|
498
|
+
destination: string,
|
|
499
|
+
kbDirName: string,
|
|
500
|
+
): Promise<PlannedUploadPath[]> {
|
|
501
|
+
const bytes = await nodeFs.readFile(upload.absolutePath);
|
|
502
|
+
if (upload.kind !== 'zip') {
|
|
503
|
+
return [{ path: `${destination}/${upload.filename}`, content: bytes }];
|
|
504
|
+
}
|
|
505
|
+
let zip: AdmZip;
|
|
506
|
+
try {
|
|
507
|
+
zip = new AdmZip(bytes);
|
|
508
|
+
} catch (err) {
|
|
509
|
+
throw new ToolError(
|
|
510
|
+
`"${upload.filename}" could not be opened as a .zip archive: ${err instanceof Error ? err.message : String(err)}`,
|
|
511
|
+
422,
|
|
512
|
+
{ code: 'unreadable_archive' },
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
const planned: PlannedUploadPath[] = [];
|
|
516
|
+
let seen = 0;
|
|
517
|
+
let totalBytes = 0;
|
|
518
|
+
for (const entry of zip.getEntries()) {
|
|
519
|
+
const rawName = zipEntryName(entry.entryName);
|
|
520
|
+
if (isZipNoiseEntry(rawName)) continue;
|
|
521
|
+
if (seen >= APPLY_MAX_ENTRIES) {
|
|
522
|
+
planned.push({
|
|
523
|
+
path: rawName || '(empty)',
|
|
524
|
+
error: 'too_many_entries',
|
|
525
|
+
message: `This archive holds more than ${APPLY_MAX_ENTRIES} entries; the rest were not applied.`,
|
|
526
|
+
});
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
seen++;
|
|
530
|
+
const nameRefusal = zipEntryNameRefusal(rawName);
|
|
531
|
+
if (nameRefusal !== null) {
|
|
532
|
+
planned.push({ path: rawName || '(empty)', error: 'invalid_entry', message: nameRefusal });
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (isSymlinkZipEntry(entry)) {
|
|
536
|
+
planned.push({
|
|
537
|
+
path: rawName,
|
|
538
|
+
error: 'link',
|
|
539
|
+
message: `"${rawName}" is a symbolic link, not a file; an upload lands files, never links.`,
|
|
540
|
+
});
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
// A folder comes into being with the files under it (the write path mkdirs
|
|
544
|
+
// each parent), so a directory member has nothing of its own to land.
|
|
545
|
+
if (entry.isDirectory) continue;
|
|
546
|
+
const segments = zipEntrySegments(rawName);
|
|
547
|
+
const target = [destination, ...segments].join('/');
|
|
548
|
+
// Belt and braces: `zipEntryNameRefusal` already refuses a `..` segment
|
|
549
|
+
// and a root-anchored name, so nothing should reach here that climbs out.
|
|
550
|
+
// The check stays because the cost of being wrong about that is bytes
|
|
551
|
+
// landing outside the folder the caller named.
|
|
552
|
+
if (!target.startsWith(`${destination}/`) || !isInsideRepo(target, kbDirName)) {
|
|
553
|
+
planned.push({ path: rawName, error: 'invalid_entry', message: 'Path escapes destination' });
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
// Through the one bounded reader `unzip` uses too, capped at what is left
|
|
557
|
+
// of the budget: a deflate stream can expand a thousandfold, and the
|
|
558
|
+
// header's declared size is the archive's claim, not a fact — an entry
|
|
559
|
+
// declaring ZERO would otherwise be inflated with no cap at all (see
|
|
560
|
+
// `readZipEntry`). A read that fails is this entry's outcome and no more.
|
|
561
|
+
const read = readZipEntry(entry, APPLY_MAX_TOTAL_BYTES - totalBytes);
|
|
562
|
+
if (!read.ok) {
|
|
563
|
+
planned.push(
|
|
564
|
+
read.reason === 'too_large'
|
|
565
|
+
? { path: rawName, error: 'too_large', message: tooLargeToApply() }
|
|
566
|
+
: { path: rawName, error: 'unreadable_entry', message: `"${rawName}" could not be read: ${read.detail}.` },
|
|
567
|
+
);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
totalBytes += read.data.byteLength;
|
|
571
|
+
planned.push({ path: target, content: read.data });
|
|
572
|
+
}
|
|
573
|
+
return planned;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Record on `entry` that this path was refused, saying what the gate that
|
|
578
|
+
* refused it said. Four kinds of refusal count as one path's outcome: a
|
|
579
|
+
* typed tool refusal (`exists`, `platform_file`, the mode gate), a permission
|
|
580
|
+
* refusal (the caller may not write this path, where the DESTINATION was
|
|
581
|
+
* writable), the git folder in any spelling, and a path-shape refusal from the
|
|
582
|
+
* repository rules. Anything else
|
|
583
|
+
* is not a verdict about this path — it is a gate failing — so it travels on
|
|
584
|
+
* and the whole apply fails loudly, exactly as it does in `write_files`.
|
|
585
|
+
*/
|
|
586
|
+
function refuseEntry(entry: Record<string, unknown>, err: unknown): void {
|
|
587
|
+
entry.outcome = 'refused';
|
|
588
|
+
if (err instanceof ToolError) {
|
|
589
|
+
const details = (err.details ?? {}) as { code?: string; kind?: string };
|
|
590
|
+
entry.error = details.code ?? details.kind ?? 'refused';
|
|
591
|
+
entry.message = err.message;
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (err instanceof AccessDeniedError) {
|
|
595
|
+
entry.error = 'write-denied';
|
|
596
|
+
entry.message = err.message;
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (err instanceof GitInternalsError || err instanceof WorkflowValidationError) {
|
|
600
|
+
entry.error = (err.payload as { kind?: string } | undefined)?.kind ?? 'refused';
|
|
601
|
+
entry.message = err.message;
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
throw err;
|
|
605
|
+
}
|
|
606
|
+
|
|
489
607
|
/** The three modes, as a set the handler can check a raw argument against. */
|
|
490
608
|
const WRITE_MODES: readonly WriteMode[] = ['create', 'overwrite', 'update'];
|
|
491
609
|
|
|
@@ -730,6 +848,15 @@ export function registerWorkspaceTools(
|
|
|
730
848
|
* verdict alone then decides, which differs only at a root.
|
|
731
849
|
*/
|
|
732
850
|
changeGate?: IChangeReadGate,
|
|
851
|
+
/**
|
|
852
|
+
* The upload-token store behind `request_file_upload` / `apply_file_upload`
|
|
853
|
+
* — the route an agent lands bytes by, without their content passing
|
|
854
|
+
* through the model. Optional for the same reason the two above are: a tool
|
|
855
|
+
* harness that is about the file primitives need not stand one up. Every
|
|
856
|
+
* real composition wires it (`create-core-server.ts`), and without it the
|
|
857
|
+
* two tools are not mounted at all rather than mounted and broken.
|
|
858
|
+
*/
|
|
859
|
+
uploads?: AgentUploadStore,
|
|
733
860
|
): void {
|
|
734
861
|
const { kbDirName } = kb;
|
|
735
862
|
/**
|
|
@@ -1347,10 +1474,14 @@ export function registerWorkspaceTools(
|
|
|
1347
1474
|
handler: ToolHandler;
|
|
1348
1475
|
}): void => {
|
|
1349
1476
|
const path = `/api/agent/tools/${spec.name}`;
|
|
1350
|
-
// Every
|
|
1351
|
-
//
|
|
1352
|
-
// proposal route
|
|
1353
|
-
//
|
|
1477
|
+
// Every description ends with ONE sentence pointing at the rules these
|
|
1478
|
+
// tools share — the content rule, the agent guide, the write modes, the
|
|
1479
|
+
// dry-run protocol, the proposal route. They used to be appended here in
|
|
1480
|
+
// FULL, which made a description several thousand characters of text the
|
|
1481
|
+
// agent had already read on the tool above, and clients cut a long
|
|
1482
|
+
// description from the END, where what is specific to the tool sits. The
|
|
1483
|
+
// rules themselves are in the handshake instructions and in the managed
|
|
1484
|
+
// guide (see `shared-file-rules.ts`), stated once and from one text.
|
|
1354
1485
|
// Whether a call to this tool MUST name a branch, read off the tool's own
|
|
1355
1486
|
// declaration rather than assumed of the family. Every tool mounted here
|
|
1356
1487
|
// requires `branch` today; keying on the schema means a tool that declares
|
|
@@ -1359,10 +1490,8 @@ export function registerWorkspaceTools(
|
|
|
1359
1490
|
const requiresBranch = ((spec.inputs as { required?: string[] }).required ?? []).includes('branch');
|
|
1360
1491
|
const describe = (): string =>
|
|
1361
1492
|
(typeof spec.description === 'function' ? spec.description() : spec.description) +
|
|
1362
|
-
(spec.
|
|
1363
|
-
(
|
|
1364
|
-
kbConventionsNote(kb.layout) +
|
|
1365
|
-
(spec.gated ? agentAccessGate.notes.gatedToolNote() : '');
|
|
1493
|
+
(spec.gated ? agentAccessGate.notes.gatedToolNote() : '') +
|
|
1494
|
+
sharedRulesPointer(kb.layout);
|
|
1366
1495
|
const def = toolDef({
|
|
1367
1496
|
name: spec.name,
|
|
1368
1497
|
description: describe(),
|
|
@@ -1528,7 +1657,13 @@ export function registerWorkspaceTools(
|
|
|
1528
1657
|
name: 'read_file',
|
|
1529
1658
|
gated: true,
|
|
1530
1659
|
description:
|
|
1531
|
-
'Read a workspace file as text. Returns `{ path, content }`.
|
|
1660
|
+
'Read a workspace file as text. Returns `{ path, content }`. What comes back for a document, an email file, an image ' +
|
|
1661
|
+
'or any other binary file is the content rule\'s business (see the shared rules): text files as text, documents and ' +
|
|
1662
|
+
'email files as extracted text, an image as the picture itself, anything else as a one-line description. ' +
|
|
1663
|
+
'Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored ' +
|
|
1664
|
+
'for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. ' +
|
|
1665
|
+
'It also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: such a ref belongs to no ' +
|
|
1666
|
+
'workspace, so `branch` is ignored for it.',
|
|
1532
1667
|
inputs: {
|
|
1533
1668
|
type: 'object',
|
|
1534
1669
|
properties: {
|
|
@@ -1635,13 +1770,18 @@ export function registerWorkspaceTools(
|
|
|
1635
1770
|
mount({
|
|
1636
1771
|
name: 'file_stat',
|
|
1637
1772
|
gated: true,
|
|
1638
|
-
description:
|
|
1639
|
-
'Get a file/directory\'s metadata (name, type, size, …) without returning content
|
|
1640
|
-
'
|
|
1641
|
-
|
|
1642
|
-
'`access: { read, write, download, owner }` is your own verdict under the access rules; pass `explainAccess: true` to
|
|
1643
|
-
'
|
|
1644
|
-
'Call this before a move or delete
|
|
1773
|
+
description:
|
|
1774
|
+
'Get a file/directory\'s metadata (name, type, size, …) without returning content, and what you may DO with it. ' +
|
|
1775
|
+
'A file also reports `contentMode`, `kind`, `mime`, `mimeSource` and `textEditable` — decided by the same readers ' +
|
|
1776
|
+
'read_file, grep and the write tools use, so an extensionless text file is `text/plain`. ' +
|
|
1777
|
+
'`access: { read, write, download, owner }` is your own verdict under the access rules; pass `explainAccess: true` to ' +
|
|
1778
|
+
'learn why, and who else holds each verb. ' +
|
|
1779
|
+
'Call this before a move or delete: `managed`, `movable` and `deletable` answer the shared rules on what these tools ' +
|
|
1780
|
+
'never move or delete, judged like the dry runs (on a draft branch writes are not gated); `movable` judges the SOURCE ' +
|
|
1781
|
+
'side only, so the destination still wants a `move_file` dry run. ' +
|
|
1782
|
+
'For a folder, `descendants` counts the files under it at any depth; counting stops at 10000 and ' +
|
|
1783
|
+
'`descendantsTruncated` says so, past which `movable` and `deletable` are false — a folder that large was not judged ' +
|
|
1784
|
+
'in full, so run the `move_file` or `delete_folder` dry run for the real verdict.',
|
|
1645
1785
|
inputs: {
|
|
1646
1786
|
type: 'object',
|
|
1647
1787
|
properties: {
|
|
@@ -1949,9 +2089,7 @@ export function registerWorkspaceTools(
|
|
|
1949
2089
|
description:
|
|
1950
2090
|
'Write a workspace TEXT file. The change is committed + pushed as you. Returns `{ path, bytes, outcome }`, where `outcome` is ' +
|
|
1951
2091
|
'`created`, `replaced` or `updated`.' +
|
|
1952
|
-
|
|
1953
|
-
IMAGE_CONVENTION_NOTE +
|
|
1954
|
-
ESCAPE_SEQUENCE_NOTE,
|
|
2092
|
+
UPLOAD_ROUTE_NOTE,
|
|
1955
2093
|
inputs: {
|
|
1956
2094
|
type: 'object',
|
|
1957
2095
|
properties: {
|
|
@@ -2039,9 +2177,7 @@ export function registerWorkspaceTools(
|
|
|
2039
2177
|
'`created` / `replaced` / `updated` for a path it wrote, or `refused` with `error` (the code) and `message` (why) for a ' +
|
|
2040
2178
|
'path it could not. `count` is how many were written. A path it refuses — the mode said no, or the file is not text — ' +
|
|
2041
2179
|
'does not stop the others; read `files` to see what landed.' +
|
|
2042
|
-
|
|
2043
|
-
IMAGE_CONVENTION_NOTE +
|
|
2044
|
-
ESCAPE_SEQUENCE_NOTE,
|
|
2180
|
+
UPLOAD_ROUTE_NOTE,
|
|
2045
2181
|
inputs: {
|
|
2046
2182
|
type: 'object',
|
|
2047
2183
|
properties: {
|
|
@@ -2211,7 +2347,7 @@ export function registerWorkspaceTools(
|
|
|
2211
2347
|
gated: true,
|
|
2212
2348
|
description:
|
|
2213
2349
|
'Replace an exact string in a workspace TEXT file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you.' +
|
|
2214
|
-
|
|
2350
|
+
UPLOAD_ROUTE_NOTE,
|
|
2215
2351
|
inputs: {
|
|
2216
2352
|
type: 'object',
|
|
2217
2353
|
properties: {
|
|
@@ -2240,29 +2376,57 @@ export function registerWorkspaceTools(
|
|
|
2240
2376
|
const path = a.path as string;
|
|
2241
2377
|
const oldStr = a.old_string as string;
|
|
2242
2378
|
const newStr = a.new_string as string;
|
|
2243
|
-
//
|
|
2244
|
-
//
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2379
|
+
// Everything the tool decides about ONE reading of the file: may these
|
|
2380
|
+
// bytes be edited as text at all, is `old_string` there, is it unique,
|
|
2381
|
+
// and what the file becomes. One function, because the file is read
|
|
2382
|
+
// twice — before the lock and under it — and a reading that skipped any
|
|
2383
|
+
// of these questions would let bytes land that were never judged.
|
|
2384
|
+
// `split`/`join`, not `String.replace`, which reads `$&`, `$'`, `` $` ``
|
|
2385
|
+
// and `$$` in `new_string` as patterns and writes something the caller
|
|
2386
|
+
// never sent.
|
|
2387
|
+
const edit = (existing: Buffer): { updated: string; replaced: number } => {
|
|
2388
|
+
assertBytesTextEditable(readers, path, existing);
|
|
2389
|
+
const text = asText(existing);
|
|
2390
|
+
const pieces = oldStr ? text.split(oldStr) : [text];
|
|
2391
|
+
const count = pieces.length - 1;
|
|
2392
|
+
if (count === 0) throw new ToolError('old_string not found in the file.', 400);
|
|
2393
|
+
if (count > 1 && a.replace_all !== true) {
|
|
2394
|
+
throw new ToolError(`old_string appears ${count} times — add more context to make it unique, or set replace_all.`, 400);
|
|
2395
|
+
}
|
|
2396
|
+
return { updated: pieces.join(newStr), replaced: count };
|
|
2397
|
+
};
|
|
2398
|
+
// A first verdict before any lock is taken, so an ordinary refusal costs
|
|
2399
|
+
// no lock cycle. It is a verdict about a file anyone may still change.
|
|
2400
|
+
let result = edit(await orNotFound(path, async () => asBytes(await fs.readFile(path))));
|
|
2401
|
+
// The one the answer carries is taken again with the path's lock HELD,
|
|
2402
|
+
// over the bytes read there (`write: true` guarantees the locking
|
|
2403
|
+
// filesystem): read, verdict and write are one step nobody can get
|
|
2404
|
+
// between. Taken before the lock only, two callers replacing the same
|
|
2405
|
+
// text — two runners claiming a work item by filling its empty owner
|
|
2406
|
+
// field — were BOTH told their edit landed, and the second silently
|
|
2407
|
+
// overwrote the first. A filesystem without the method has no lock to
|
|
2408
|
+
// read under, so the first verdict stands.
|
|
2409
|
+
const locking = fs as unknown as {
|
|
2410
|
+
rewriteFile?(path: string, rewrite: (current: Buffer | null) => string): Promise<void>;
|
|
2411
|
+
};
|
|
2412
|
+
if (typeof locking.rewriteFile === 'function') {
|
|
2413
|
+
await locking.rewriteFile(path, (current) => {
|
|
2414
|
+
if (current === null) throw notFound(path);
|
|
2415
|
+
result = edit(current);
|
|
2416
|
+
return result.updated;
|
|
2417
|
+
});
|
|
2418
|
+
} else {
|
|
2419
|
+
await fs.writeFile(path, result.updated);
|
|
2253
2420
|
}
|
|
2254
|
-
|
|
2255
|
-
await fs.writeFile(path, updated);
|
|
2256
|
-
return { path, replaced: a.replace_all === true ? count : 1, ...(await saveWarnings(ctx, path, updated)) };
|
|
2421
|
+
return { path, replaced: result.replaced, ...(await saveWarnings(ctx, path, result.updated)) };
|
|
2257
2422
|
},
|
|
2258
2423
|
});
|
|
2259
2424
|
|
|
2260
2425
|
mount({
|
|
2261
2426
|
name: 'delete_file',
|
|
2262
2427
|
gated: true,
|
|
2263
|
-
description:
|
|
2264
|
-
'Delete ONE workspace file
|
|
2265
|
-
`A platform file (\`access.md\` or \`.bevelignore\` in any folder, \`roles.yaml\` or \`${kb.layout.agentsFile}\` at the repository root) and git metadata are refused.`,
|
|
2428
|
+
description:
|
|
2429
|
+
'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`.',
|
|
2266
2430
|
inputs: {
|
|
2267
2431
|
type: 'object',
|
|
2268
2432
|
properties: {
|
|
@@ -2315,10 +2479,12 @@ export function registerWorkspaceTools(
|
|
|
2315
2479
|
gated: true,
|
|
2316
2480
|
description:
|
|
2317
2481
|
'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. ' +
|
|
2318
|
-
'
|
|
2319
|
-
'
|
|
2320
|
-
'
|
|
2321
|
-
'
|
|
2482
|
+
'The dry run answers `{ path, kind: "folder", descendants, files, filesTruncated, allowed, reason? }` — `descendants` is ' +
|
|
2483
|
+
'the file count, `files` names up to 100 of them — and a non-empty folder wants `confirm: true`. ' +
|
|
2484
|
+
'Beyond what the shared rules refuse, a folder HOLDING a symbolic link, or any file you may not write, is refused ' +
|
|
2485
|
+
'(the link itself is never removed), and a path that is a FILE ' +
|
|
2486
|
+
'is refused with a pointer to `delete_file`. You must be able to write the folder\'s own platform files too: they go ' +
|
|
2487
|
+
'with it in that same one change, so its files are never left ungoverned part-way.',
|
|
2322
2488
|
inputs: {
|
|
2323
2489
|
type: 'object',
|
|
2324
2490
|
properties: {
|
|
@@ -2481,11 +2647,15 @@ export function registerWorkspaceTools(
|
|
|
2481
2647
|
mount({
|
|
2482
2648
|
name: 'move_file',
|
|
2483
2649
|
gated: true,
|
|
2484
|
-
|
|
2650
|
+
// A plain string again: what refuses a move names the guide, and that is in
|
|
2651
|
+
// the shared rules now, which are rebuilt from the layout where they live.
|
|
2652
|
+
description:
|
|
2485
2653
|
'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. ' +
|
|
2486
|
-
|
|
2487
|
-
'
|
|
2488
|
-
'
|
|
2654
|
+
'The destination must not exist — a move never overwrites a file or merges into a folder. Access follows the ' +
|
|
2655
|
+
'DESTINATION folder, so a move can change what you (and others) may do with the file: the dry run answers ' +
|
|
2656
|
+
'`{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }`, where `access` is your ' +
|
|
2657
|
+
'own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the move has landed, ' +
|
|
2658
|
+
'with every `access.md` inside a moved folder counted at its new place, and a move whose `accessChanges` is true wants `confirm: true`.',
|
|
2489
2659
|
inputs: {
|
|
2490
2660
|
type: 'object',
|
|
2491
2661
|
properties: {
|
|
@@ -2830,6 +3000,335 @@ export function registerWorkspaceTools(
|
|
|
2830
3000
|
},
|
|
2831
3001
|
});
|
|
2832
3002
|
|
|
3003
|
+
// ── uploads (bytes that never pass through the model) ───────────────────
|
|
3004
|
+
//
|
|
3005
|
+
// The pair exists because MCP tool arguments are JSON. Every byte an agent
|
|
3006
|
+
// sends through `write_file` is first typed out by the model, which
|
|
3007
|
+
// truncates long files, mangles backslash and `\u` escapes, and cannot carry
|
|
3008
|
+
// a PNG at all. `request_file_upload` answers an address; the agent POSTs
|
|
3009
|
+
// the file (or one zip holding many) there with any HTTP client;
|
|
3010
|
+
// `apply_file_upload` lands it on a branch in one commit. The bytes go from
|
|
3011
|
+
// the agent's disk to the server's and never enter a prompt.
|
|
3012
|
+
//
|
|
3013
|
+
/**
|
|
3014
|
+
* Why one of an upload's paths may not be landed, judged on the path ALONE —
|
|
3015
|
+
* or undefined when nothing about the name itself refuses it.
|
|
3016
|
+
*
|
|
3017
|
+
* The platform files are the whole of it. `access.md` governs who may read
|
|
3018
|
+
* and write the folder it sits in, `roles.yaml` says which roles exist, and
|
|
3019
|
+
* the agent guide is read as instructions: each is configuration the platform
|
|
3020
|
+
* obeys, and each has a write path that CHECKS the change (the roles gate
|
|
3021
|
+
* refuses an edit that would lock every admin out; a folder's access rules
|
|
3022
|
+
* are judged against who is asking). Bytes arriving by upload meet none of
|
|
3023
|
+
* those gates — they are a buffer the sender chose — so an upload never
|
|
3024
|
+
* lands one, whatever else the caller may write. `unzip` has refused
|
|
3025
|
+
* `roles.yaml` from an archive for the same reason; this is that rule, over
|
|
3026
|
+
* all four names.
|
|
3027
|
+
*/
|
|
3028
|
+
const platformFileReason = (wsPath: string): string | undefined => {
|
|
3029
|
+
const rel = toKbRelative(wsPath, kbDirName);
|
|
3030
|
+
return rel !== null && isPlatformFile(rel, kb.layout) ? platformFileUploadRefusal(rel) : undefined;
|
|
3031
|
+
};
|
|
3032
|
+
|
|
3033
|
+
/**
|
|
3034
|
+
* `apply_file_upload`'s handler: resolve the stored bytes into one path per
|
|
3035
|
+
* file, judge each path the way `write_files` judges its own, and land the
|
|
3036
|
+
* survivors as ONE commit.
|
|
3037
|
+
*
|
|
3038
|
+
* The judging is deliberately the same shape as `write_files`, down to the
|
|
3039
|
+
* second verdict under the lock, because the promise the ticket makes is
|
|
3040
|
+
* that an upload is judged "exactly as `write_file` would judge it". Three
|
|
3041
|
+
* gates run per path and a path that fails one is that path's outcome and no
|
|
3042
|
+
* more: the deployment's write hook, the platform-file rule above, and the
|
|
3043
|
+
* `mode`. What is judged ONCE for the whole call is the destination — a
|
|
3044
|
+
* caller who may not write the folder at all gets one refusal naming the
|
|
3045
|
+
* change-request route, rather than the same refusal repeated per entry.
|
|
3046
|
+
*/
|
|
3047
|
+
const applyFileUpload = async (
|
|
3048
|
+
a: Record<string, unknown>,
|
|
3049
|
+
ctx: ToolContext,
|
|
3050
|
+
uploads: AgentUploadStore,
|
|
3051
|
+
): Promise<unknown> => {
|
|
3052
|
+
const branch = a.branch as string;
|
|
3053
|
+
const token = a.token;
|
|
3054
|
+
if (typeof token !== 'string' || token === '') {
|
|
3055
|
+
throw new ToolError(
|
|
3056
|
+
'Name the `token` `request_file_upload` answered with, after POSTing the file to its `uploadUrl`.',
|
|
3057
|
+
400,
|
|
3058
|
+
{ code: 'token-required' },
|
|
3059
|
+
);
|
|
3060
|
+
}
|
|
3061
|
+
const mode = modeOf(a);
|
|
3062
|
+
const destination = (a.destination as string).replace(/\/+$/, '');
|
|
3063
|
+
assertInsideRepo(destination, kbDirName);
|
|
3064
|
+
// CLAIMED, not consumed: an apply refused whole (a protected destination,
|
|
3065
|
+
// an archive that will not open) leaves the token alive so the caller can
|
|
3066
|
+
// retry somewhere else rather than send the bytes again. The claim is what
|
|
3067
|
+
// keeps it single-use meanwhile — a second apply finds the token in use.
|
|
3068
|
+
const upload = uploads.claim(token, ctx.user.id);
|
|
3069
|
+
let spent = false;
|
|
3070
|
+
try {
|
|
3071
|
+
const fs = await ctx.getFilesystem(branch);
|
|
3072
|
+
const root = await workspaceRoot(branch, ctx);
|
|
3073
|
+
// The destination, once, for the whole call. On a protected branch a
|
|
3074
|
+
// caller who may not write the folder gets the lock gate's own refusal —
|
|
3075
|
+
// which `rethrowAsWriteDenial` turns into `write-denied` with the
|
|
3076
|
+
// change-request steps — and nothing lands.
|
|
3077
|
+
const blockedDest = await writeBlocked(branch, ctx, [destination]);
|
|
3078
|
+
if (blockedDest.length > 0) throw await writeRefusal(branch, blockedDest[0], 'dir');
|
|
3079
|
+
if ((await kindOf(fs, destination)) === 'file') {
|
|
3080
|
+
throw new ToolError(
|
|
3081
|
+
`"${displayPath(destination)}" is a file, not a folder — \`destination\` names the folder the upload lands in.`,
|
|
3082
|
+
409,
|
|
3083
|
+
{ code: 'not_a_folder' },
|
|
3084
|
+
);
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
const planned = await planUpload(upload, destination, kbDirName);
|
|
3088
|
+
const paths = planned.filter((p) => p.content !== undefined).map((p) => p.path as string);
|
|
3089
|
+
// One batched access read for every path, like `write_files` — empty on
|
|
3090
|
+
// a draft branch, where changes reach a protected branch only through a
|
|
3091
|
+
// change request.
|
|
3092
|
+
const blocked = new Set(await writeBlocked(branch, ctx, paths));
|
|
3093
|
+
|
|
3094
|
+
const writes: { path: string; content: Buffer }[] = [];
|
|
3095
|
+
const outcomes: Record<string, unknown>[] = [];
|
|
3096
|
+
/** The `files` entry for `writes[i]`, so the under-lock verdict can revise it. */
|
|
3097
|
+
const entryOf: Record<string, unknown>[] = [];
|
|
3098
|
+
for (const item of planned) {
|
|
3099
|
+
const entry: Record<string, unknown> = { path: item.path };
|
|
3100
|
+
outcomes.push(entry);
|
|
3101
|
+
if (item.content === undefined) {
|
|
3102
|
+
entry.outcome = 'refused';
|
|
3103
|
+
entry.error = item.error;
|
|
3104
|
+
entry.message = item.message;
|
|
3105
|
+
continue;
|
|
3106
|
+
}
|
|
3107
|
+
const wsPathOf = item.path;
|
|
3108
|
+
try {
|
|
3109
|
+
if (blocked.has(wsPathOf)) throw await writeRefusal(branch, wsPathOf);
|
|
3110
|
+
const platform = platformFileReason(wsPathOf);
|
|
3111
|
+
if (platform !== undefined) throw new ToolError(platform, 422, { code: 'platform_file' });
|
|
3112
|
+
// The git folder is never a workspace path, in any spelling. A ZIP
|
|
3113
|
+
// entry's name has already met this rule in `zipEntryNameRefusal`; a
|
|
3114
|
+
// SINGLE uploaded file's has not — `.git` is a name the upload
|
|
3115
|
+
// route's `validateFilename` accepts — and the preflight that reads
|
|
3116
|
+
// the caller's own arguments never sees it either, because the name
|
|
3117
|
+
// came from the upload, not from the call. Asked here so that path
|
|
3118
|
+
// is REFUSED like any other, with the rest of the upload landing,
|
|
3119
|
+
// rather than failing the whole apply from inside `writeFiles`.
|
|
3120
|
+
assertNoGitInternalsSegment(wsPathOf);
|
|
3121
|
+
assertRepoRootNameFree(wsPathOf, kbDirName);
|
|
3122
|
+
// A link already on disk under the destination must not redirect
|
|
3123
|
+
// these bytes — the rule `unzip` applies per entry, applied here on
|
|
3124
|
+
// the path the write will take.
|
|
3125
|
+
const link = await symlinkOnPath(root, wsPathOf);
|
|
3126
|
+
if (link !== undefined) {
|
|
3127
|
+
throw new ToolError(
|
|
3128
|
+
`"${wsPathOf}" goes through the symbolic link "${link}"; an upload never follows links.`,
|
|
3129
|
+
400,
|
|
3130
|
+
{ code: 'symlink' },
|
|
3131
|
+
);
|
|
3132
|
+
}
|
|
3133
|
+
writePolicy.assertPathWritable(ctx.sessionId, wsPathOf);
|
|
3134
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, wsPathOf);
|
|
3135
|
+
// An earlier entry of this same upload counts as existing, as it
|
|
3136
|
+
// does in `write_files`: two `create` entries for one path are a
|
|
3137
|
+
// mistake the commit would otherwise hide.
|
|
3138
|
+
const exists =
|
|
3139
|
+
writes.some((w) => w.path === wsPathOf) || (await kindOf(fs, wsPathOf)) !== null;
|
|
3140
|
+
entry.outcome = decideWrite(mode, wsPathOf, exists);
|
|
3141
|
+
writes.push({ path: wsPathOf, content: item.content });
|
|
3142
|
+
entryOf.push(entry);
|
|
3143
|
+
} catch (err) {
|
|
3144
|
+
refuseEntry(entry, err);
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
// The mode gate again, with every path's lock held — the verdict the
|
|
3149
|
+
// answer carries, for the reason `write_file` states at length. A path
|
|
3150
|
+
// whose verdict changed under the lock is dropped from the batch and
|
|
3151
|
+
// reported refused, leaving the rest to land.
|
|
3152
|
+
const recheck = async (
|
|
3153
|
+
pending: readonly { path: string; content: Buffer }[],
|
|
3154
|
+
): Promise<{ path: string; content: Buffer }[]> => {
|
|
3155
|
+
const kept: { path: string; content: Buffer }[] = [];
|
|
3156
|
+
for (let i = 0; i < pending.length; i++) {
|
|
3157
|
+
const entry = entryOf[i];
|
|
3158
|
+
try {
|
|
3159
|
+
const exists =
|
|
3160
|
+
kept.some((k) => k.path === pending[i].path) || (await kindOf(fs, pending[i].path)) !== null;
|
|
3161
|
+
entry.outcome = decideWrite(mode, pending[i].path, exists);
|
|
3162
|
+
kept.push(pending[i]);
|
|
3163
|
+
} catch (err) {
|
|
3164
|
+
refuseEntry(entry, err);
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
return kept;
|
|
3168
|
+
};
|
|
3169
|
+
if (writes.length > 0) {
|
|
3170
|
+
// `write: true` guarantees a LockingFilesystem here; `writeFiles` lands
|
|
3171
|
+
// the whole set as ONE commit and takes a Buffer as content, so bytes
|
|
3172
|
+
// reach disk exactly as they were sent — no text decode anywhere on
|
|
3173
|
+
// the way, which is what makes a PNG and a backslash-heavy page land
|
|
3174
|
+
// with the checksum they were uploaded with.
|
|
3175
|
+
const batching = fs as unknown as {
|
|
3176
|
+
writeFiles(
|
|
3177
|
+
writes: { path: string; content: Buffer }[],
|
|
3178
|
+
summary: string,
|
|
3179
|
+
deletes: string[],
|
|
3180
|
+
check: (
|
|
3181
|
+
pending: readonly { path: string; content: Buffer }[],
|
|
3182
|
+
) => Promise<{ path: string; content: Buffer }[]>,
|
|
3183
|
+
): Promise<void>;
|
|
3184
|
+
};
|
|
3185
|
+
// In the DESTINATION folder's TURN, which `delete_folder` takes over the
|
|
3186
|
+
// same subtree (and `keepFolderOf` with it). `writeFiles` creates the
|
|
3187
|
+
// destination, and any folder above a zip entry on the way to it, as
|
|
3188
|
+
// part of landing the batch — and a folder delete running between that
|
|
3189
|
+
// creation and the commit enumerates the folder's files BEFORE these
|
|
3190
|
+
// exist and then removes the folder they are landing in, which is an
|
|
3191
|
+
// answer saying `created` for bytes that are already gone. The turn is
|
|
3192
|
+
// taken OUTSIDE `writeFiles`, so it is held across the under-lock
|
|
3193
|
+
// recheck and the commit both, and in the same order the delete takes
|
|
3194
|
+
// its own (the folder's turn first, then each path's lock), which is
|
|
3195
|
+
// what keeps two callers from waiting on each other's half.
|
|
3196
|
+
await ctx.workspaceService.withFolderTurn(workspaceIdForBranch(branch), destination, async () => {
|
|
3197
|
+
await batching.writeFiles(writes, `Apply upload of ${writes.length} file(s)`, [], recheck);
|
|
3198
|
+
});
|
|
3199
|
+
}
|
|
3200
|
+
// The token is spent once an ANSWER exists, even an answer in which
|
|
3201
|
+
// every path was refused: the apply ran and said what happened at each
|
|
3202
|
+
// path, and re-running it would say the same. Only a refusal that landed
|
|
3203
|
+
// nothing AND answered nothing (thrown above) gives the token back.
|
|
3204
|
+
spent = true;
|
|
3205
|
+
await uploads.consume(token);
|
|
3206
|
+
const listed = a.all === true ? outcomes : outcomes.slice(0, APPLY_ANSWER_CAP);
|
|
3207
|
+
return {
|
|
3208
|
+
destination,
|
|
3209
|
+
count: outcomes.filter((o) => o.outcome !== 'refused').length,
|
|
3210
|
+
total: outcomes.length,
|
|
3211
|
+
files: listed,
|
|
3212
|
+
...(listed.length < outcomes.length ? { truncated: true } : {}),
|
|
3213
|
+
};
|
|
3214
|
+
} finally {
|
|
3215
|
+
if (!spent) uploads.release(token);
|
|
3216
|
+
}
|
|
3217
|
+
};
|
|
3218
|
+
|
|
3219
|
+
// Mounted only when the composition supplied a store — see the `uploads`
|
|
3220
|
+
// parameter. Core always does.
|
|
3221
|
+
if (uploads) {
|
|
3222
|
+
mount({
|
|
3223
|
+
name: 'request_file_upload',
|
|
3224
|
+
fileTool: false,
|
|
3225
|
+
description:
|
|
3226
|
+
// Within the description cap (`tool-registry/description-length.ts`):
|
|
3227
|
+
// why a file goes this way is one of the shared rules, and the header
|
|
3228
|
+
// spelling of the token is on the `uploadUrl` output, where the
|
|
3229
|
+
// address it changes is.
|
|
3230
|
+
'Ask for a one-time address to send FILE BYTES to, so their content never passes through this conversation. ' +
|
|
3231
|
+
'Use it for anything `write_file` cannot carry faithfully: a large file, a file full of backslashes or `\\u` ' +
|
|
3232
|
+
'escapes, a binary file (a PNG, a PDF, a zip), or many files at once (zip them). ' +
|
|
3233
|
+
'Returns `{ uploadUrl, token, expiresAt, expiresInSeconds, maxBytes }`. THEN: ' +
|
|
3234
|
+
'(1) POST the file as the raw request body to `uploadUrl` with `?filename=<name>` — ' +
|
|
3235
|
+
'`curl -X POST --data-binary @skill.zip "<uploadUrl>?filename=skill.zip"` — which answers what it received; ' +
|
|
3236
|
+
'(2) call `apply_file_upload` with the same `token`, a `branch` and a destination folder. ' +
|
|
3237
|
+
'One token carries one file or one zip, is bound to you and expires at `expiresAt`: an upload nobody applies ' +
|
|
3238
|
+
'by then is deleted, and one over `maxBytes` is refused when you send it, naming the limit.',
|
|
3239
|
+
inputs: { type: 'object', properties: {}, additionalProperties: false },
|
|
3240
|
+
outputs: {
|
|
3241
|
+
type: 'object',
|
|
3242
|
+
properties: {
|
|
3243
|
+
uploadUrl: str(
|
|
3244
|
+
'The absolute URL to POST the bytes to. Carries the token; add `?filename=<name>`. To keep the token out ' +
|
|
3245
|
+
'of a URL — when the command line you send from is logged or shared — POST to this address without its ' +
|
|
3246
|
+
'last (token) segment and send the token in an `x-upload-token` header instead.',
|
|
3247
|
+
),
|
|
3248
|
+
token: str(
|
|
3249
|
+
'The token itself — what `apply_file_upload` takes, and what an `x-upload-token` header carries when you ' +
|
|
3250
|
+
'would rather it not sit in a URL. Treat it as a credential.',
|
|
3251
|
+
),
|
|
3252
|
+
expiresAt: str('ISO-8601 instant after which the token, and any bytes sent with it, are gone.'),
|
|
3253
|
+
expiresInSeconds: int('Seconds from now until `expiresAt`.'),
|
|
3254
|
+
maxBytes: int('The largest upload this deployment accepts, in bytes.'),
|
|
3255
|
+
},
|
|
3256
|
+
required: ['uploadUrl', 'token', 'expiresAt', 'expiresInSeconds', 'maxBytes'],
|
|
3257
|
+
},
|
|
3258
|
+
// A read-scoped caller has nothing to do with an upload token: the only
|
|
3259
|
+
// thing it unlocks is a write. Refused at the handler factory, by scope,
|
|
3260
|
+
// before the token is minted.
|
|
3261
|
+
write: true,
|
|
3262
|
+
handler: async (_a, ctx: ToolContext) => uploads.issue(ctx.user),
|
|
3263
|
+
});
|
|
3264
|
+
|
|
3265
|
+
mount({
|
|
3266
|
+
name: 'apply_file_upload',
|
|
3267
|
+
gated: true,
|
|
3268
|
+
description:
|
|
3269
|
+
'Land a file you have already uploaded (see `request_file_upload`) in a folder on a branch, in ONE commit, as you. ' +
|
|
3270
|
+
'A single file lands under the name it was sent with; a zip lands as its entries, keeping their folder structure. ' +
|
|
3271
|
+
'Returns `{ destination, count, total, files }`: one entry per path, each `{ path, outcome }` — `created` / ' +
|
|
3272
|
+
'`replaced` / `updated`, or `refused` with `error` (the code) and `message` (why). `count` is how many landed and ' +
|
|
3273
|
+
'`total` how many paths there were; `files` is cut to the first 25 unless you pass `all: true`. ' +
|
|
3274
|
+
'Every path is judged one by one — by your write access, the platform-file rules and what is already there — ' +
|
|
3275
|
+
'exactly as `write_file` judges it, and a refused path does not stop the others. ' +
|
|
3276
|
+
'The token is single-use: it is spent by the apply that lands it, and refused if you use it twice, let it ' +
|
|
3277
|
+
'expire, or present one issued to somebody else. `mode` means what it means on `write_file`.',
|
|
3278
|
+
inputs: {
|
|
3279
|
+
type: 'object',
|
|
3280
|
+
properties: {
|
|
3281
|
+
branch: BRANCH_INPUT,
|
|
3282
|
+
token: str('The `token` from `request_file_upload`, after you have POSTed the file to its `uploadUrl`.'),
|
|
3283
|
+
destination: wsPath(kbDirName, 'Folder the upload lands in (created if it is not there yet)'),
|
|
3284
|
+
mode: WRITE_MODE_INPUT,
|
|
3285
|
+
all: {
|
|
3286
|
+
type: 'boolean',
|
|
3287
|
+
description:
|
|
3288
|
+
'List EVERY path in `files` instead of the first 25. `total` always says how many there were, so ask for ' +
|
|
3289
|
+
'all only when you need to read each outcome.',
|
|
3290
|
+
},
|
|
3291
|
+
sessionId: SESSION_ID_INPUT,
|
|
3292
|
+
},
|
|
3293
|
+
required: ['branch', 'token', 'destination'],
|
|
3294
|
+
additionalProperties: false,
|
|
3295
|
+
},
|
|
3296
|
+
outputs: {
|
|
3297
|
+
type: 'object',
|
|
3298
|
+
properties: {
|
|
3299
|
+
destination: str('The folder the upload was applied to (echoes the input).'),
|
|
3300
|
+
count: int('How many paths landed — the entries in `files` whose `outcome` is not `refused`.'),
|
|
3301
|
+
total: int('How many paths the upload held, whether or not `files` lists them all.'),
|
|
3302
|
+
files: {
|
|
3303
|
+
type: 'array',
|
|
3304
|
+
description: 'One entry per path, in the order the upload held them. Cut to 25 unless `all` was true.',
|
|
3305
|
+
items: {
|
|
3306
|
+
type: 'object',
|
|
3307
|
+
properties: {
|
|
3308
|
+
path: str('The workspace path this entry was judged at.'),
|
|
3309
|
+
outcome: {
|
|
3310
|
+
type: 'string',
|
|
3311
|
+
enum: ['created', 'replaced', 'updated', 'refused'],
|
|
3312
|
+
description: 'What happened at this path. `refused` means nothing was written there.',
|
|
3313
|
+
},
|
|
3314
|
+
error: str('Present when `outcome` is `refused`: the refusal code — e.g. `exists`, `missing`, `invalid_entry`, `platform_file`, `write-denied`.'),
|
|
3315
|
+
message: str('Present when `outcome` is `refused`: the full refusal, the same one `write_file` would have given.'),
|
|
3316
|
+
},
|
|
3317
|
+
required: ['path', 'outcome'],
|
|
3318
|
+
},
|
|
3319
|
+
},
|
|
3320
|
+
truncated: { type: 'boolean', description: 'True when `files` was cut: `total` is larger than what it lists. Pass `all: true` for the rest.' },
|
|
3321
|
+
},
|
|
3322
|
+
required: ['destination', 'count', 'total', 'files'],
|
|
3323
|
+
},
|
|
3324
|
+
write: true,
|
|
3325
|
+
// So a protected-branch refusal arrives as `write-denied`, with the
|
|
3326
|
+
// change-request steps, exactly as it does from write_file.
|
|
3327
|
+
proposable: true,
|
|
3328
|
+
handler: async (a, ctx: ToolContext) => applyFileUpload(a, ctx, uploads),
|
|
3329
|
+
});
|
|
3330
|
+
}
|
|
3331
|
+
|
|
2833
3332
|
// ── shell (internal-only) ───────────────────────────────────────────────
|
|
2834
3333
|
mount({
|
|
2835
3334
|
name: 'execute_command',
|